1. 从零拆解一个图片站抓取需求
1.1 这个需求到底在做什么
拿到“抓取某个图片站图片区第一页所有图片”这个题目,很多人第一反应是打开编辑器就写requests.get,但真正做过一批站点的人会先停下来想三件事:目标页面是静态渲染还是动态加载、图片真实地址藏在哪一层、第一页的边界怎么界定。这三个问题决定了后面代码是二十行还是两百行。
这个需求的核心目标很明确:给定一个图片列表页,把该页展示的所有图片原图下载到本地,并且按一定规则命名归档。它解决的是“手动一张张右键另存”的效率问题,适合刚接触爬虫、想找一个完整小项目练手的人,也适合需要批量收集素材的设计或运营同学。技术栈上,最轻量的组合是requests负责请求、lxml或BeautifulSoup负责解析、os与pathlib负责落盘,全程不需要浏览器内核,资源占用低,跑在小内存机器上也没压力。
需要提前说清楚的是:本文所有示例中的域名、路径、选择器均为演示用的虚构结构,实际抓取时必须替换为你自己有权访问的目标站点,并且严格遵守目标站点的 robots 协议与版权声明。抓取公开可访问的图片用于个人学习和小规模素材整理,和批量搬运他人版权内容,是两件性质完全不同的事,这条线心里要有数。
1.2 为什么选 requests 而不是 Selenium
热词里出现了“python爬虫可视化界面”“分布式爬虫”这些词,说明不少人一上来就想上重武器。我的经验是:能用 HTTP 请求直接拿到 HTML 的站点,就别碰浏览器自动化。原因很实在——Selenium 启动一个 Chromium 实例,内存起步就是几百 MB,速度比纯请求慢一个数量级,而且还要处理驱动版本匹配这种破事。只有当页面内容确实由 JavaScript 异步渲染、HTML 源码里根本找不到图片地址时,才值得上浏览器方案。
判断方法很简单:用curl或者浏览器开发者工具的 Network 面板看一眼,如果 Response 里能直接搜到图片的src,那就是静态的,requests足够。如果搜不到,图片地址是后面 XHR 请求回来再塞进 DOM 的,那要么去分析那个 XHR 接口直接请求数据,要么才考虑浏览器渲染。绝大多数图片列表站的第一页,其实都是服务端直出的,这也是为什么这个项目适合入门。
1.3 整体流程的四个阶段
把整个抓取拆成流水线,会清晰很多:
- 请求阶段:构造带合理请求头的 GET 请求,拿到列表页 HTML。
- 解析阶段:从 HTML 中定位“图片区”容器,提取其中每个图片项的详情页链接或直接的原图链接。
- 二次请求阶段:如果列表页只给了缩略图和详情页链接,就需要再进详情页拿原图地址。
- 下载与归档阶段:对图片二进制流写文件,处理重名、失败重试、格式识别。
这四个阶段里,坑最多的是解析和下载。解析的坑在于选择器写得太脆,站点改个 class 名就全废;下载的坑在于没做超时和重试,遇到一张图卡住整个脚本就挂在那里。后面会逐个展开。
2. 请求头、会话与反爬的基本应对
2.1 请求头不是随便抄一份就行
很多人从网上抄一段 User-Agent 就完事,结果请求回来是 403 或者一个验证页。请求头里真正影响服务端判断的主要是这几项:
| 请求头字段 | 作用 | 常见坑 |
|---|---|---|
| User-Agent | 标识客户端类型 | 用默认的 python-requests 极易被识别 |
| Referer | 标识来源页面 | 图片站常校验,缺失会返回防盗链图 |
| Accept | 声明可接受的内容类型 | 一般保持浏览器默认即可 |
| Accept-Language | 语言偏好 | 部分站点据此返回不同页面 |
| Cookie | 会话状态 | 需要登录或有防刷时必带 |
我的习惯是直接用浏览器开发者工具里“Copy as cURL”,然后把无关字段删掉,保留上面这几项。这样拿到的请求头和真实浏览器几乎一致,成功率最高。注意 User-Agent 要和 Accept-Language 匹配,别搞出一个英文 UA 配中文语言包的组合,反而显得可疑。
2.2 用 Session 保持连接与 Cookie
单次requests.get每次都要重新握手,而且 Cookie 不共享。对于需要先访问列表页、再访问详情页的场景,用requests.Session()是标准做法:
import requests session = requests.Session() session.headers.update({ "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://example-image-site.test/", "Accept-Language": "zh-CN,zh;q=0.9", }) resp = session.get("https://example-image-site.test/pic/list", timeout=10) resp.raise_for_status() html = resp.textSession 的好处是底层复用了 TCP 连接,批量请求时速度提升明显,同时自动携带服务端下发的 Cookie,模拟出一个连续的浏览会话。timeout一定要设,不设的话遇到慢响应会一直挂着,这是新手最常见的“脚本卡死”原因。
2.3 关于“防止爬虫”的那些前端手段
热词里有“怎么在前端进行防止爬虫”“防止查看页面源码”,这里顺带说清楚:前端能做的防护本质上都是提高成本,而不是真正阻止。常见的几种手段和应对思路如下。
- 禁用右键和 F12:纯 JS 层面拦截,直接关掉浏览器 JS 或者用开发者工具的独立窗口就能绕过,防护价值极低。
- 字体混淆 / 图片替换文字:把关键数字或文字用自定义字体映射或雪碧图展示,抓到的文本是乱码。应对方式是下载字体文件做映射还原,成本较高。
- 接口签名与时间戳校验:请求参数带签名,过期失效。需要逆向 JS 找到签名算法,属于进阶内容。
- 频率限制与 IP 封禁:这是最有效的,服务端按 IP 或账号统计请求频率。应对方式只能是降低频率、加随机延时,而不是硬刚。
站在抓取方的角度,正确的态度是:控制请求频率,加time.sleep随机间隔,别把人家服务器打挂。站在站点方的角度,真正有效的防护是服务端限流加行为分析,前端那些花活更多是心理安慰。这个项目里,我们只需要保证自己的请求“看起来正常、频率克制”就够了。
3. 解析图片区:XPath 与 CSS 选择器的取舍
3.1 先定位“图片区”这个容器
一个列表页往往有导航、广告、推荐位、页脚等一堆区域,直接全局搜img标签会把 logo、图标、广告图全抓进来。所以第一步是精确定位到“图片区”这个容器。假设页面结构大致如下(虚构示例):
<div class="main-content"> <div class="sidebar">...</div> <div class="pic-list" id="picArea"> <div class="pic-item"> <a href="/detail/1001.html"> <img>from lxml import etree tree = etree.HTML(html) # 定位图片区容器,再取其中所有图片项的详情页链接 items = tree.xpath('//div[@id="picArea"]//div[@class="pic-item"]/a/@href') print(items)用BeautifulSoup配合 CSS 选择器的示例:
from bs4 import BeautifulSoup soup = BeautifulSoup(html, "lxml") container = soup.select_one("#picArea") links = [a.get("href") for a in container.select("div.pic-item > a")]两种写法结果一致。我个人的偏好是:解析列表页用 CSS 选择器,因为直观;处理详情页里那些“第几个符合条件的元素”时用 XPath,因为[1]、[last()]这类索引和函数太方便了。
3.3 注意>def pick_img_url(img_tag): for attr in ("data-src", "data-original", "data-lazy", "src"): val = img_tag.get(attr) if val and not val.startswith("data:image"): return val return None
data:image开头的是 base64 内联占位图,必须排除,否则你会下载一堆几十字节的灰色小方块。
4. 从列表页到原图:二次请求与地址还原
4.1 列表页给的是缩略图怎么办
很多图片站的列表页只展示缩略图,原图在详情页里。这时候流程就变成“列表页拿详情链接 → 进详情页拿原图地址 → 下载”。多一层请求,也就多一层失败可能,所以每一步都要做异常处理。
def get_detail_img_url(session, detail_url): resp = session.get(detail_url, timeout=10) resp.raise_for_status() tree = etree.HTML(resp.text) # 详情页里通常有一个主图容器,取其中最大的那张 urls = tree.xpath('//div[contains(@class,"detail-pic")]//img/@src') return urls[0] if urls else None这里用contains(@class, ...)而不是精确匹配,是因为详情页的 class 经常带状态后缀,比如detail-pic active,精确匹配会漏掉。
4.2 缩略图地址到原图地址的规律还原
有些站点更省事,缩略图和原图只是路径或文件名不同,比如缩略图是/thumb/1001.jpg,原图是/origin/1001.jpg,或者缩略图带_small后缀。这种情况下不需要进详情页,直接字符串替换就能拿到原图,速度快很多。
def thumb_to_origin(thumb_url): # 演示用的替换规则,实际要按目标站点规律调整 return thumb_url.replace("/thumb/", "/origin/").replace("_small", "")但要注意:这种规律不是所有站点都成立,有的站点缩略图和原图是完全不同的文件名(带哈希),那就只能老老实实进详情页。判断方法是随便挑几张图,手动对比缩略图和原图的 URL,看有没有稳定的映射关系。有就省事,没有就别偷懒。
4.3 相对路径转绝对路径
解析出来的链接经常是/detail/1001.html这种相对路径,直接请求会报错。需要用urljoin拼成绝对地址:
from urllib.parse import urljoin base = "https://example-image-site.test/pic/list" detail_url = urljoin(base, "/detail/1001.html") # 结果:https://example-image-site.test/detail/1001.htmlurljoin会自动处理各种斜杠和层级问题,比手动拼字符串靠谱得多。这一步千万别省,我见过太多人因为相对路径没转,请求全打到本地或者报MissingSchema错误。
5. 下载、命名与归档的实操细节
5.1 流式下载与超时控制
图片是二进制文件,用resp.content一次性读进内存对小图没问题,但遇到几 MB 的大图,批量下载时内存会飙。更稳的做法是流式下载:
def download_image(session, url, save_path): with session.get(url, timeout=15, stream=True) as resp: resp.raise_for_status() with open(save_path, "wb") as f: for chunk in resp.iter_content(chunk_size=8192): if chunk: f.write(chunk)stream=True让响应体不立即加载,iter_content按块写入,内存占用稳定在几 KB 级别。chunk_size用 8192 字节是经验值,太小会增加 IO 次数,太大对内存又没好处。
5.2 文件命名与去重
命名要解决两个问题:可读性和唯一性。直接用原图 URL 的最后一段做文件名最省事,但不同目录下可能有同名文件,会互相覆盖。我的做法是“序号 + 原始文件名”:
import os from pathlib import Path def build_save_path(save_dir, index, url): name = os.path.basename(url.split("?")[0]) or f"img_{index}.jpg" return Path(save_dir) / f"{index:03d}_{name}"url.split("?")[0]是为了去掉查询参数,避免文件名里出现?这种非法字符。序号用三位补零,排序后顺序和页面一致,方便对照。如果担心重复下载,可以在下载前检查文件是否已存在,存在就跳过。
5.3 格式识别与扩展名修正
有时候 URL 里没有扩展名,或者扩展名和实际格式不符(比如.jpg实际是 webp)。稳妥的做法是下载后根据文件头魔数判断真实格式:
def guess_ext(data_head): if data_head.startswith(b"\xff\xd8\xff"): return ".jpg" if data_head.startswith(b"\x89PNG"): return ".png" if data_head.startswith(b"GIF8"): return ".gif" if data_head[:4] == b"RIFF" and data_head[8:12] == b"WEBP": return ".webp" return ".bin"读文件头只需要前 12 个字节,成本极低。这个技巧在处理那些“URL 写 jpg 实际返回 webp”的站点时特别有用,否则你本地会有一堆打不开的假 jpg。
6. 常见问题与排查技巧实录
6.1 请求返回 403 或验证页
这是最高频的问题。排查顺序如下:
- 检查 User-Agent 是否还是默认的
python-requests,换成浏览器 UA。 - 检查 Referer 是否缺失,图片站尤其敏感。
- 检查是否请求频率过高,加
time.sleep(random.uniform(1, 3))。 - 检查是否需要 Cookie,先用 Session 访问一次首页再请求目标页。
如果以上都做了还是 403,可能是站点用了更严格的指纹校验(比如 TLS 指纹),这时候纯 requests 就比较难了,需要考虑其他方案,但这类站点占比不高。
6.2 解析结果为空
选择器写对了但结果为空,常见原因有三个:一是页面结构其实是 JS 渲染的,HTML 源码里根本没有目标元素;二是编码问题,resp.text用了错误的编码导致中文乱码,选择器里的中文匹配不上;三是 XPath 里的 class 匹配用了精确等号,而实际 class 有多个值。
编码问题可以显式指定:resp.encoding = resp.apparent_encoding,让 requests 根据内容自动判断。class 问题就用contains代替等号。
6.3 下载的图片打不开
多半是两种情况:一是下载到了防盗链返回的占位图(通常很小,几百字节),二是把 HTML 错误页当图片存了。排查方法是打印文件大小和文件头,如果大小异常小或者文件头是<!DOCTYPE或<html,那就是没拿到真图。这时候要回头检查 Referer 和 Cookie。
6.4 常见问题速查表
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 403 Forbidden | UA/Referer 缺失或频率过高 | 补请求头,加随机延时 |
| 解析结果为空 | 动态渲染 / 编码错误 / 选择器过严 | 查源码、修编码、用 contains |
| 图片打不开 | 防盗链占位图 / 存了错误页 | 补 Referer,校验文件头 |
| 脚本卡死 | 未设 timeout | 所有请求加 timeout |
| 文件名乱码 | 编码未处理 | 用 URL 原始名或做转义 |
| 重复下载 | 未做去重 | 下载前检查文件存在 |
6.5 几个踩过坑才懂的经验
第一,先手动跑通一张图再写循环。很多人一上来就写完整脚本,结果报错时不知道是请求问题、解析问题还是下载问题。正确姿势是先用交互式环境把“请求→解析→拿到一个图片 URL→下载一张”这条链路走通,确认无误再套循环。
第二,把中间结果落盘。解析出来的 URL 列表先存成 txt,下载阶段再读这个 txt。这样解析逻辑改了不用重新请求,下载失败了也不用重新解析,调试效率翻倍。
第三,日志比 print 好用。用logging模块记录每个 URL 的请求状态和耗时,出问题时一眼能看出是哪一步慢、哪一步失败。print 在批量任务里刷屏太快,根本看不清。
第四,别用多线程硬冲。热词里有“python 线程嵌套线程”“分布式爬虫”,但小规模抓取用多线程很容易把目标站打挂,也容易触发封禁。单线程加 1 到 3 秒随机延时,抓个几十上百张图完全够用,稳定压倒一切。
7. 一个可直接复现的完整骨架
把前面的片段串起来,给一个结构清晰的完整示例。注意域名和选择器都是虚构的,实际使用时替换成你自己的目标。
import os import time import random import logging from pathlib import Path from urllib.parse import urljoin import requests from lxml import etree logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") BASE = "https://example-image-site.test" LIST_URL = urljoin(BASE, "/pic/list") SAVE_DIR = Path("./downloads") SAVE_DIR.mkdir(exist_ok=True) session = requests.Session() session.headers.update({ "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": BASE + "/", "Accept-Language": "zh-CN,zh;q=0.9", }) def fetch_list(): resp = session.get(LIST_URL, timeout=10) resp.raise_for_status() resp.encoding = resp.apparent_encoding tree = etree.HTML(resp.text) hrefs = tree.xpath('//div[@id="picArea"]//div[@class="pic-item"]/a/@href') return [urljoin(BASE, h) for h in hrefs] def fetch_origin(detail_url): resp = session.get(detail_url, timeout=10) resp.raise_for_status() tree = etree.HTML(resp.text) urls = tree.xpath('//div[contains(@class,"detail-pic")]//img/@src') return urljoin(BASE, urls[0]) if urls else None def download(url, index): name = os.path.basename(url.split("?")[0]) or f"img_{index}.jpg" path = SAVE_DIR / f"{index:03d}_{name}" if path.exists(): logging.info("skip existing %s", path.name) return with session.get(url, timeout=15, stream=True) as resp: resp.raise_for_status() with open(path, "wb") as f: for chunk in resp.iter_content(8192): if chunk: f.write(chunk) logging.info("saved %s", path.name) def main(): details = fetch_list() logging.info("found %d items", len(details)) for i, d in enumerate(details, 1): try: origin = fetch_origin(d) if origin: download(origin, i) except Exception as e: logging.warning("item %d failed: %s", i, e) time.sleep(random.uniform(1, 3)) if __name__ == "__main__": main()这个骨架覆盖了请求、解析、二次请求、下载、去重、异常处理和限速,直接改选择器和域名就能用。跑之前建议先把fetch_list的结果打印出来确认数量对不对,再放开下载。
8. 关于合规与长期维护的几句实在话
抓取这件事,技术只是门槛的一半,另一半是边界感。目标站点的 robots 协议、版权声明、服务条款,动手前花两分钟看一眼,能省掉很多麻烦。个人学习、小规模素材整理和批量搬运是两回事,前者控制频率、注明来源,后者涉及版权风险,别混为一谈。
从维护角度看,这类脚本最大的敌人是“站点改版”。今天能跑不代表下个月还能跑,所以选择器尽量选语义稳定的属性,把解析逻辑和下载逻辑解耦,中间结果落盘,这样改版时只需要修解析那一小段。我自己维护的几个小工具都是这个结构,改起来十分钟搞定,不用从头重写。
最后分享一个我常用的调试习惯:把每次抓取的 URL 列表、成功数、失败数写进一个日志文件,跑完看一眼统计。如果失败率突然升高,说明站点可能加了新限制,这时候再去针对性排查,比盲目重跑高效得多。抓取是个细活,稳扎稳打比追求速度重要得多。