批量下载网页资源,Python 从入门到“不封号”
- 2026-11-05 21:37:08
你有没有遇到过这种场景:看到一个素材站,几十上百张图片要一张张右键保存;或者一个文档归档页,几十个 PDF 需要挨个点开下载。手动操作不仅慢,还特别容易漏。
这篇文章从零开始,用 Python 实现一个可复用的批量下载脚本。先跑通最简版本,再逐步加上线程池、进度条、断点续传和合规检查。所有代码都可以直接复制使用,你只需要替换目标网址。
先明确一件事:你能下载什么,不能下载什么
在写任何代码之前,先把边界说清楚。
批量下载网页资源本身是中性技术,但落到具体网站,必须看两样东西:robots.txt 和服务条款。robots.txt 里的 Disallow 规则如果明确禁止抓取某个路径,就不应该越过它;服务条款如果写明禁止“automated scraping”或“bulk download”,批量下载就可能构成违约。此外,尊重网站的反爬机制,不绕过登录、验证码或付费墙,是底线,不是“技巧”。
这篇代码会加入基础的合规检查,但你仍然需要对自己要下载的目标网站做一次人工判断。下面的代码是工具,判断在你自己。
第一步:把资源地址提取出来
批量下载的第一步不是“下载”,而是“找到所有要下载的东西”。
网页上的图片和文件地址,远不止 <img src="..."> 这么简单。相对路径需要补全域名,懒加载的真实地址可能藏在 data-src 或 data-original 里,响应式图片用 srcset 提供多个尺寸,PDF 链接可能在 <a href="xxx.pdf"> 中。只读 src,大概率会漏掉一大半。
下面这段代码用 requests + BeautifulSoup 做一件事:从一个页面中尽可能完整地提取图片和文件链接,并统一成可访问的绝对地址。
import osimport refrom urllib.parse import urljoin, urlparseimport requestsfrom bs4 import BeautifulSoupHEADERS = {"User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) ""AppleWebKit/537.36 (KHTML, like Gecko) ""Chrome/120.0 Safari/537.36")}def get_best_srcset_url(srcset: str) -> str | None:"""从 srcset 中选出分辨率/宽度最大的候选地址。"""candidates = []for item in srcset.split(","):parts = item.strip().split()if not parts:continueimage_url = parts[0]score = 0if len(parts) > 1:descriptor = parts[1].lower()try:if descriptor.endswith("w"):score = float(descriptor[:-1])elif descriptor.endswith("x"):score = float(descriptor[:-1]) * 10000except ValueError:passcandidates.append((score, image_url))if not candidates:return Nonereturn max(candidates, key=lambda x: x[0])[1]def extract_media_urls(page_url: str) -> dict:"""提取页面中的图片和文件链接,返回 {'images': [...], 'files': [...]}。"""resp = requests.get(page_url, headers=HEADERS, timeout=15)resp.raise_for_status()if not resp.encoding:resp.encoding = resp.apparent_encodingsoup = BeautifulSoup(resp.text, "html.parser")images = []seen = set()# --- 图片:处理 src / 懒加载 / srcset ---lazy_attrs = ["data-original", "data-src", "data-lazy-src", "data-url", "src"]for img in soup.find_all("img"):raw = Noneif img.get("srcset"):raw = get_best_srcset_url(img["srcset"])if not raw:for attr in lazy_attrs:val = (img.get(attr) or "").strip()if val and not val.startswith("data:image"):raw = valbreakif not raw:continueabsolute = urljoin(page_url, raw).split("#", 1)[0]if urlparse(absolute).scheme not in {"http", "https"}:continueif absolute not in seen:seen.add(absolute)images.append(absolute)# --- 文件:从 <a href> 中筛选常见资源后缀 ---file_exts = (".pdf", ".zip", ".rar", ".7z", ".doc", ".docx",".xls", ".xlsx", ".ppt", ".pptx", ".csv", ".txt")files = []seen_files = set()for a in soup.find_all("a", href=True):href = a["href"].strip()path = urlparse(urljoin(page_url, href)).path.lower()if path.endswith(file_exts):absolute = urljoin(page_url, href).split("#", 1)[0]if absolute not in seen_files:seen_files.add(absolute)files.append(absolute)return {"images": images, "files": files}
这个函数返回两个列表:images 和 files。提取逻辑本身不“高级”,但它覆盖了真实网页中最常见的几种“地址藏法”。如果你的目标页面图片是由 JavaScript 动态渲染的,requests 拿到的 HTML 里可能根本没有目标元素,这时候需要改用 Playwright 等浏览器自动化方案,等页面渲染完成后再提取 DOM。本文先聚焦在“HTML 中已有地址”的场景。
第二步:单文件下载,先把基础打牢
有了地址列表,下一个问题是:怎么把单个文件可靠地下载到本地。
最简写法是 requests.get(url).content 直接写入文件,但它有两个隐患:大文件会把整个内容一次性读进内存;网络中断时下载直接失败,没有续传能力。更稳妥的做法是流式写入 + 断点续传。
def download_one(url: str, save_path: str, chunk_size: int = 8192,max_retries: int = 3) -> bool:"""流式下载单个文件,支持断点续传和简单重试。"""os.makedirs(os.path.dirname(save_path) or ".", exist_ok=True)existing_size = os.path.getsize(save_path) if os.path.exists(save_path) else 0headers = dict(HEADERS)if existing_size:headers["Range"] = f"bytes={existing_size}-"for attempt in range(max_retries):try:with requests.get(url, headers=headers, stream=True, timeout=30) as r:if r.status_code == 416: # Range 越界,通常意味着已完整下载return Truer.raise_for_status()mode = "ab" if existing_size and r.status_code == 206 else "wb"with open(save_path, mode) as f:for chunk in r.iter_content(chunk_size=chunk_size):if chunk:f.write(chunk)return Trueexcept Exception as e:print(f" 重试 {attempt + 1}/{max_retries}: {url[:60]}... ({e})")return False
这段代码做了三件关键的事:
第一,如果本地文件已经存在,发送 Range 头从断点处继续,而不是从头重来。服务器返回 206 Partial Content 时以追加模式写入,否则回退到覆盖写入。第二,stream=True 配合 iter_content 分块写入,内存占用不随文件大小增长。第三,简单的重试循环覆盖临时网络抖动。
第三步:串行 → 线程池,速度上一个台阶
如果只有十几张图片,逐个下载完全够用。但资源数量到几十上百,同步阻塞的 requests 就会把大量时间花在“等网络”上。一个 100 张图片的页面,每张耗时 0.5 秒,串行至少 50 秒,而实际大部分时间都在等待 I/O。
concurrent.futures.ThreadPoolExecutor 是最小改动的提速方案:不需要把代码重写成异步风格,把下载任务提交到线程池即可。
from concurrent.futures import ThreadPoolExecutor, as_completedfrom tqdm import tqdmdef batch_download(url_list, output_dir, workers=5, desc="下载"):os.makedirs(output_dir, exist_ok=True)results = {"success": 0, "fail": 0, "failed_urls": []}def _task(item):idx, url = itemfilename = os.path.basename(urlparse(url).path) or f"file_{idx}"save_path = os.path.join(output_dir, filename)ok = download_one(url, save_path)return url, okwith ThreadPoolExecutor(max_workers=workers) as executor:futures = {executor.submit(_task, (i, u)): ufor i, u in enumerate(url_list)}with tqdm(total=len(futures), desc=desc, unit="个") as pbar:for fut in as_completed(futures):url, ok = fut.result()if ok:results["success"] += 1else:results["fail"] += 1results["failed_urls"].append(url)pbar.update(1)return results
workers 控制并发数。不要一上来就把 workers 拉到几十。对大多数中小型站点,5 到 8 个并发已经能显著提速,同时对服务器保持相对友好的压力。并发过高不仅容易触发限流或封 IP,还可能导致连接池耗尽,反而降低成功率。
如果你追求更极致的吞吐量,异步方案 aiohttp + asyncio 在纯 I/O 密集场景下通常比线程池更快,百张图片从同步的数十秒可以压缩到几秒量级。但异步代码的调试成本和错误处理复杂度也更高。一个务实的策略是:先用线程池跑通,遇到明确的速度瓶颈再考虑异步迁移。
第四步:让脚本“能跑完、不白跑”
批量下载最让人烦躁的不是慢,是跑到 90% 的时候中断,重来一遍全部从头开始。上面 download_one 里的 Range 逻辑已经解决了单文件的续传问题,但还需要注意几个收尾细节。
文件头校验:不是所有返回 200 的响应都是你要的文件。有些站点会对未授权的请求返回一个 HTML 登录页或错误页,状态码却是 200。下载完成后快速检查文件魔数,可以拦截一部分明显不对的文件。PDF 以 %PDF 开头,PNG 以 \x89PNG 开头,JPEG 以 \xff\xd8\xff 开头。
def quick_header_check(filepath, expected_ext):magic_map = {".pdf": [b"%PDF"],".png": [b"\x89PNG"],".jpg": [b"\xff\xd8\xff"],".jpeg": [b"\xff\xd8\xff"],".zip": [b"PK\x03\x04"],}magics = magic_map.get(expected_ext.lower())if not magics:return Truewith open(filepath, "rb") as f:head = f.read(4)return any(head.startswith(m) for m in magics)
文件名安全:URL 路径里可能包含中文、空格或特殊字符,直接当文件名在部分系统上会出问题。os.path.basename 之后做一次清理,或者用 quote 规范化路径,能避免不少“下载成功但文件找不到”的诡异情况。
完整组装:一个可以跑的脚本
把上面的部分串起来,就是一个可用的入口。以下载一个页面的所有图片为例:
if __name__ == "__main__":target = "https://example.com/gallery" # 替换为你的目标页面output = "downloads"media = extract_media_urls(target)all_urls = media["images"] + media["files"]if not all_urls:print("没有在页面 HTML 中找到可下载资源。")print("如果页面由 JavaScript 渲染,请改用 Playwright 方案。")else:print(f"提取到 {len(media['images'])} 张图片,{len(media['files'])} 个文件")result = batch_download(all_urls, output, workers=5)print(f"\n完成:成功 {result['success']},失败 {result['fail']}")if result["failed_urls"]:print("失败列表已保存到 failed_urls.txt")with open("failed_urls.txt", "w") as f:f.write("\n".join(result["failed_urls"]))
跑完之后,downloads/ 目录里是下载好的文件,failed_urls.txt 里是这次没成功的地址。下次运行时,已完成的文件会因为断点续传逻辑被跳过或续传,你只需要关注失败列表即可。
容易被忽略的三件事
第一,robots.txt 不是摆设。 在下载之前花几行代码检查一下目标路径是否被禁止,是对网站的基本尊重。urllib.robotparser 可以直接解析 robots.txt,can_fetch 返回布尔值。
from urllib.robotparser import RobotFileParserfrom urllib.parse import urlparsedef is_allowed(url, user_agent="*"):parsed = urlparse(url)robots_url = f"{parsed.scheme}://{parsed.netloc}/robots.txt"rp = RobotFileParser()try:rp.set_url(robots_url)rp.read()return rp.can_fetch(user_agent, url)except Exception:return True # robots.txt 不可达时不做硬性拦截,但应记录
第二,限速比“高并发”更重要。 很多网站的反爬不是因为你用了 requests,而是因为你在短时间内发了太多请求。即使线程池 workers=5,如果每个任务几乎不等待地连续打,也可能触发频率限制。在 download_one 里加一个随机的小延迟,或者把 workers 降到 3 到 5,稳定性通常比“冲高并发”更好。
第三,不要用这套代码去碰登录态、验证码或付费内容。 绕过这些机制在部分司法管辖区可能涉及“非法侵入计算机信息系统”或违约,这不是技术问题,是法律问题。本文的代码和场景默认限定在公开可访问、且网站允许自动化获取的资源上。
批量下载的代码量不大,真正决定成败的是两件事:地址提取的完整度,和对目标网站的克制程度。前者影响“能不能下全”,后者影响“能不能一直下”。
把上面的 extract_media_urls 和 batch_download 保存成两个文件,换掉 target,你就可以开始处理自己手头那个“右键到手酸”的页面了。