1. 项目缘起与整体设计思路
做数据采集这行十来年,图片批量下载一直是个高频刚需。不管是给算法团队准备训练素材,还是给设计部门收集参考图,又或者自己想做某个主题的图库归档,绕不开的第一步就是:怎么把网页上的图片高效、稳定地扒下来。很多人第一反应是找现成的工具,但现成工具要么限制多,要么不够灵活,遇到稍微特殊一点的页面结构就歇菜。所以自己写一个基于requests加BeautifulSoup的图片爬取脚本,反而是最省心、最可控的路子。
这个组合为什么经典?requests负责网络请求,把网页源码拿回来;BeautifulSoup负责解析 HTML,把图片链接从一堆标签里揪出来。两者分工明确,学习曲线平缓,调试起来也直观。相比 Scrapy 那种重型框架,这套方案更适合中小规模的采集任务,几十行代码就能跑通一个完整的流程。而且它足够透明,每一步你都知道发生了什么,出了问题也好定位。
这篇文章面向的读者,是已经会一点 Python 基础、想动手做一个完整图片采集项目的人。我会从环境准备讲起,把请求头构造、HTML 解析、图片链接提取、去重、下载、异常处理这些环节全部拆开揉碎,配上可直接复制的代码和参数说明。中间还会穿插我这些年踩过的坑,比如反爬怎么绕、动态加载怎么判断、文件名冲突怎么解决。看完你就能自己搭一个能用的图片采集器,并且知道怎么根据不同的网站结构去调整它。
整体设计上,我遵循一个原则:先跑通最小闭环,再逐步加固。最小闭环就是“请求一个页面 → 解析出图片链接 → 下载一张图”。这个闭环通了,剩下的就是在这个骨架上加功能:批量翻页、并发下载、断点续传、日志记录。很多人一上来就想写个全能爬虫,结果卡在某个细节上就放弃了。我的建议是先把最简单的跑起来,看到图片真的落到本地文件夹里,那种正反馈会让你有动力继续优化。
2. 环境准备与核心工具选型
2.1 Python 环境与依赖安装
Python 版本我建议用 3.8 以上,太老的版本有些库的兼容性会出问题。虚拟环境一定要建,这不是矫情,是血泪教训。我见过太多人因为全局环境里库版本冲突,导致 requests 和 BeautifulSoup 打架,排查半天才发现是环境问题。用 venv 或者 conda 都行,命令很简单:
python -m venv img_spider_env source img_spider_env/bin/activate # Windows 用 img_spider_env\Scripts\activate激活之后装两个核心库:
pip install requests beautifulsoup4就这两个,别的一概不需要。有人会问要不要装 lxml 作为解析器,我的建议是装,因为 BeautifulSoup 默认用的 html.parser 虽然能用,但解析速度慢,遇到不规范 HTML 容错性也一般。lxml 快且稳,装一下不亏:
pip install lxml装完之后可以顺手验证一下:
import requests from bs4 import BeautifulSoup print(requests.__version__) print(BeautifulSoup("<a>test</a>", "lxml").a.text)能打印出版本号和 test 就说明环境没问题。
2.2 为什么选 requests 而不是 urllib
Python 标准库自带 urllib,理论上不装第三方库也能发请求。但我强烈建议用 requests,原因有三。第一,requests 的 API 设计极其人性化,一个requests.get(url)就搞定,urllib 要折腾 Request 对象、urlopen、decode 一堆东西。第二,requests 对 session、cookie、代理、超时、重试的支持都是开箱即用,urllib 要实现这些得写不少额外代码。第三,也是最关键的,requests 处理编码问题比 urllib 省心太多,response.text会自动根据响应头推断编码,urllib 经常返回乱码还得手动猜。
2.3 BeautifulSoup 解析器的选择对比
BeautifulSoup 支持多种解析器,不同解析器在速度和容错性上差异明显。我整理了一个对比表,方便你根据场景选:
| 解析器 | 速度 | 容错性 | 依赖 | 适用场景 |
|---|---|---|---|---|
| html.parser | 中等 | 一般 | 无(内置) | 快速测试、简单页面 |
| lxml | 快 | 强 | 需安装 lxml | 生产环境、复杂页面 |
| html5lib | 慢 | 最强 | 需安装 html5lib | 极度不规范的 HTML |
我的常规选择是 lxml,它在速度和容错之间取得了最好的平衡。只有遇到那种标签闭合乱七八糟、lxml 都解析不了的页面,才会退回到 html5lib。html.parser 基本只在临时验证时用一下。
注意:解析器不同,生成的 DOM 树结构可能有细微差异。如果你发现某个选择器在 html.parser 下能选中,换到 lxml 却选不中,先检查是不是解析器导致的标签嵌套差异。
3. 核心细节解析与实操要点
3.1 请求头构造:让服务器把你当正常人
裸奔的 requests 请求,User-Agent 默认是python-requests/x.x.x,这等于举着牌子告诉服务器“我是爬虫”。很多网站第一层反爬就是检查 UA,一看是 python-requests 直接返回 403。所以构造一个像浏览器的请求头是基本操作:
headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Referer": "https://www.example.com/", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", }这里有几个细节值得说。Referer字段很多网站会校验,尤其是图片资源,如果 Referer 不对或者缺失,图片请求会被拒绝。我一般把 Referer 设成目标网站的首页地址。Accept和Accept-Language不是必须,但加上更真实,成本也低。UA 不要用太老的版本,Chrome 内核版本号保持在近一两年内的比较稳妥。
实操心得:如果你要采集的网站对请求头校验很严,可以把浏览器里 F12 抓到的完整请求头复制过来,逐字段比对。我遇到过只校验 UA 的,也遇到过连 Accept-Encoding 都要对的,具体情况具体分析。
3.2 超时与重试:别让脚本卡死
网络请求最怕的就是卡住不动。requests 默认没有超时,意味着如果服务器不响应,你的脚本会一直等下去。必须显式设置 timeout:
response = requests.get(url, headers=headers, timeout=(5, 15))timeout 传元组时,第一个值是连接超时,第二个值是读取超时。连接超时设短一点(5秒),因为连不上就是连不上,等再久也没用;读取超时设长一点(15秒),给服务器一点处理时间。如果只传一个数字,则同时作为连接和读取超时。
光有超时还不够,网络抖动、服务器临时抽风都会导致单次请求失败。加一个重试机制能大幅提升稳定性。requests 配合 urllib3 的 Retry 可以很优雅地实现:
from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session = requests.Session() retry = Retry(total=3, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504]) adapter = HTTPAdapter(max_retries=retry) session.mount("http://", adapter) session.mount("https://", adapter)这段代码的意思是:最多重试 3 次,每次重试间隔按 0.5 秒的指数退避递增(0.5s、1s、2s),遇到 500/502/503/504 这些服务器错误状态码时触发重试。用 session 而不是直接 requests.get,还能复用 TCP 连接,批量下载时速度提升明显。
3.3 图片链接提取:从 img 标签到真实地址
BeautifulSoup 提取图片链接,最直接的方式是找所有 img 标签,取 src 属性:
soup = BeautifulSoup(response.text, "lxml") img_tags = soup.find_all("img") for img in img_tags: src = img.get("src")但现实往往没这么简单。第一个坑:很多网站的图片用了懒加载,img 标签的 src 是个占位图,真实地址藏在>def get_real_src(img): for attr in ["data-src", "data-original", "data-lazy-src", "src"]: val = img.get(attr) if val and not val.startswith("data:image"): return val return None
第二个坑:src 可能是相对路径,比如/images/photo.jpg或者../pic/a.png。下载时需要拼成完整 URL。用urllib.parse.urljoin可以正确处理各种相对路径:
from urllib.parse import urljoin full_url = urljoin(page_url, src)urljoin 会自动处理//、/、../这些情况,比手动拼接字符串靠谱得多。
第三个坑:有些图片地址写在srcset属性里,格式是url1 1x, url2 2x,需要解析出第一个 URL。还有的写在 CSS 的 background-image 里,那就得用正则从 style 属性里抠。这些属于进阶情况,遇到再处理。
3.4 图片格式与扩展名判断
拿到图片 URL 后,不能想当然地认为.jpg结尾就是 JPEG。很多图片 URL 是动态生成的,比如/img?id=12345,根本没有扩展名。这时候有两个办法确定格式。一是看响应头的 Content-Type,image/jpeg、image/png、image/webp等;二是看文件内容的魔数(magic number),前几个字节就能判断格式。我一般用 Content-Type 为主,魔数为辅:
content_type = response.headers.get("Content-Type", "") ext_map = {"image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp"} ext = ext_map.get(content_type.split(";")[0].strip(), ".jpg")如果 Content-Type 缺失或者不标准,再读前几个字节判断。JPEG 开头是FF D8,PNG 是89 50 4E 47,GIF 是47 49 46。这个判断逻辑写成一个函数,下载时调用即可。
4. 完整实操流程与核心环节实现
4.1 最小闭环:从请求到落盘
先把最核心的流程跑通。下面这段代码是一个能用的最小版本:
import os import requests from bs4 import BeautifulSoup from urllib.parse import urljoin def download_images(page_url, save_dir="images"): os.makedirs(save_dir, exist_ok=True) headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" } resp = requests.get(page_url, headers=headers, timeout=(5, 15)) resp.encoding = resp.apparent_encoding soup = BeautifulSoup(resp.text, "lxml") img_tags = soup.find_all("img") count = 0 for img in img_tags: src = img.get("src") or img.get("data-src") if not src: continue full_url = urljoin(page_url, src) try: img_resp = requests.get(full_url, headers=headers, timeout=(5, 15)) if img_resp.status_code == 200: filename = os.path.join(save_dir, f"img_{count:04d}.jpg") with open(filename, "wb") as f: f.write(img_resp.content) count += 1 print(f"已下载: {full_url}") except Exception as e: print(f"下载失败 {full_url}: {e}") print(f"共下载 {count} 张图片") if __name__ == "__main__": download_images("https://www.example.com/gallery")这段代码虽然简单,但包含了完整流程:请求页面、解析 HTML、提取链接、拼接 URL、下载图片、保存文件。你可以直接改 page_url 跑起来。但注意,它有几个明显问题:没有去重、没有并发、文件名用序号不直观、没有处理异常重试。下面逐个优化。
4.2 链接去重与文件名生成
同一个图片可能在页面里出现多次,或者不同页面的图片有重复。去重是必须的。我用一个 set 记录已经下载过的 URL:
seen_urls = set() if full_url in seen_urls: continue seen_urls.add(full_url)文件名方面,用序号虽然简单,但下载多了根本不知道哪张是哪张。更好的做法是从 URL 里提取原始文件名,如果提取不到再用哈希值。用os.path.basename配合urlparse可以拿到路径最后一段:
from urllib.parse import urlparse import hashlib def make_filename(url, index): path = urlparse(url).path name = os.path.basename(path) if not name or "." not in name: name = hashlib.md5(url.encode()).hexdigest()[:12] + ".jpg" # 防止文件名过长或含非法字符 name = "".join(c for c in name if c.isalnum() or c in "._-") return f"{index:04d}_{name}"这样文件名既有顺序又有原始信息,方便后续查找。哈希值用 MD5 前 12 位,碰撞概率极低,足够用。
4.3 并发下载:从串行到线程池
串行下载 100 张图可能要几分钟,用线程池可以快 5 到 10 倍。Python 的concurrent.futures.ThreadPoolExecutor是最简单的并发方案:
from concurrent.futures import ThreadPoolExecutor, as_completed def download_one(url, save_path, headers): try: resp = requests.get(url, headers=headers, timeout=(5, 15)) if resp.status_code == 200: with open(save_path, "wb") as f: f.write(resp.content) return True, url except Exception as e: return False, f"{url} -> {e}" return False, url with ThreadPoolExecutor(max_workers=8) as executor: futures = {executor.submit(download_one, u, p, headers): u for u, p in tasks} for future in as_completed(futures): ok, info = future.result() if ok: print(f"成功: {info}") else: print(f"失败: {info}")max_workers 设多少合适?我的经验是 5 到 10 之间。设太小提升不明显,设太大容易触发服务器限流甚至封 IP。8 是个比较稳妥的默认值。如果你发现下载速度上不去,可以适当加到 12 或 16,但要注意观察是否出现大量失败。
注意:并发下载时,文件写入操作是独立的,不会冲突。但如果多个线程同时往同一个目录写文件,操作系统层面是安全的,不需要额外加锁。真正需要加锁的是共享的计数器或列表,用
threading.Lock保护即可。
4.4 翻页采集:处理多页图库
很多图库是分页的,比如?page=1、?page=2。要采集全部图片,就得循环翻页。基本思路是构造每一页的 URL,请求后解析,直到没有新图片或者达到设定的页数上限:
def crawl_pages(base_url, start=1, end=10): all_tasks = [] for page in range(start, end + 1): page_url = f"{base_url}?page={page}" resp = requests.get(page_url, headers=headers, timeout=(5, 15)) if resp.status_code != 200: break soup = BeautifulSoup(resp.text, "lxml") imgs = soup.find_all("img") if not imgs: break for img in imgs: src = img.get("src") or img.get("data-src") if src: all_tasks.append(urljoin(page_url, src)) return all_tasks这里有个判断:如果某一页解析出来没有 img 标签,说明可能已经到最后一页了,直接 break。但有些网站即使没有图片也会返回 200 和空页面,所以这个判断要结合实际情况调整。更稳妥的方式是检查页面里有没有“下一页”按钮,或者比较当前页和上一页的图片 URL 集合是否完全重复。
4.5 断点续传:避免重复下载
采集到一半脚本崩了,重新跑一遍又要从头下载,浪费时间也浪费服务器资源。加一个简单的断点续传机制:下载前检查文件是否已存在,存在就跳过。
if os.path.exists(save_path) and os.path.getsize(save_path) > 0: print(f"已存在,跳过: {save_path}") return True, url这个判断放在 download_one 函数开头即可。更严谨的做法是记录已下载 URL 到一个文本文件,启动时读取,但文件存在性检查已经能覆盖 90% 的场景。注意要检查文件大小大于 0,因为有时候下载中断会留下一个空文件。
5. 常见问题与排查技巧实录
5.1 请求被拒绝的几种典型情况
403 Forbidden是最常见的。原因通常是 UA 不对、Referer 缺失、或者 IP 被限。排查顺序:先换 UA,再加 Referer,还不行就考虑请求频率太高,加个time.sleep(1)降速。如果都不行,可能是 IP 被临时封了,等一段时间再试。
429 Too Many Requests是明确的限流信号。这时候必须降速,把并发数调低,或者在每次请求之间加随机延迟:
import time, random time.sleep(random.uniform(0.5, 1.5))随机延迟比固定延迟更不容易被识别为机器行为。
返回内容为空或乱码通常是编码问题。requests 的resp.text依赖响应头里的 charset,如果服务器没返回或者返回错了,就会乱码。解决办法是手动指定编码:
resp.encoding = resp.apparent_encodingapparent_encoding会用 chardet 库猜测编码,准确率很高。或者直接resp.content.decode("utf-8", errors="ignore")强制用 UTF-8 解码并忽略错误。
5.2 图片下载下来打不开
这种情况我遇到过好几次,原因主要有三个。第一,下载到的其实是 HTML 页面而不是图片,比如服务器返回了一个登录页或者错误页。判断方法是检查 Content-Type,如果不是image/*就丢弃。第二,图片是分块传输的,但脚本只读了第一块。requests 的resp.content会自动读取全部内容,一般不会出现这个问题,除非你用了stream=True但没读完。第三,文件写入时用了文本模式而不是二进制模式。必须用"wb",用"w"会把二进制数据搞坏。
排查技巧:下载一张有问题的图,用file命令(Linux/Mac)或者查看文件头几个字节,看看实际格式是什么。如果开头是<!DOCTYPE html>或者<html>,那就是下载到了网页。
5.3 动态加载图片的识别与应对
有些网站用 JavaScript 动态加载图片,requests 拿到的 HTML 里根本没有 img 标签,或者只有占位符。判断方法很简单:用浏览器打开页面,右键查看网页源代码(不是 F12 的 Elements 面板),搜索图片 URL。如果源代码里搜不到,但 Elements 面板里能看到,那就是动态加载的。
应对方案有两个。一是找 XHR 请求,在 F12 的 Network 面板里筛选 XHR,看图片数据是从哪个接口返回的,直接请求那个接口。二是如果接口有加密参数搞不定,就得用浏览器自动化工具,但那超出了本文范围。我的建议是优先找接口,大部分图库的图片列表接口都是明文 JSON,很好处理。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 403 Forbidden | UA/Referer 不对 | 对比浏览器请求头 | 补全请求头字段 |
| 429 Too Many Requests | 请求频率过高 | 查看响应头 Retry-After | 降并发、加随机延迟 |
| 返回乱码 | 编码判断错误 | 检查响应头 charset | 用 apparent_encoding |
| 图片打不开 | 下载到 HTML | 检查 Content-Type | 过滤非图片响应 |
| 文件为空 | 下载中断 | 检查文件大小 | 加断点续传判断 |
| 链接提取不到 | 懒加载/动态渲染 | 查看网页源代码 | 找>import argparse parser = argparse.ArgumentParser() parser.add_argument("--url", required=True) parser.add_argument("--dir", default="images") parser.add_argument("--workers", type=int, default=8) parser.add_argument("--max-pages", type=int, default=10) args = parser.parse_args() 这样同一个脚本可以处理不同的目标网站,不用改代码。 6.2 日志记录代替 printprint 调试可以,但正式跑的时候建议用 logging 模块。日志可以同时输出到控制台和文件,方便事后排查。配置很简单: 然后把 print 换成 6.3 采集频率与合规提醒最后说一个容易被忽视但很重要的事:采集频率。不管目标网站有没有反爬,都应该自觉控制请求速度。我的默认配置是每次请求间隔 0.5 到 1 秒,并发不超过 10。这样对服务器的压力很小,也不容易触发风控。如果网站有明确的 API 或者批量下载功能,优先用官方渠道,别硬爬。 另外,采集到的图片版权归原作者所有,自己学习研究没问题,但不要用于商业用途或者公开传播。这一点心里要有数。 整套流程跑下来,从环境准备到并发下载再到问题排查,覆盖了一个图片采集项目从零到可用的全部环节。代码你可以直接复制去改,参数根据目标网站调整。真正动手跑一遍,遇到问题再回来看对应的章节,比光读不练强十倍。 |