做图片归档这件事,我从最早拿一个for循环加requests.get硬怼,到后来被登录态、证书报错、频率限制轮番教育,前后重构了四五个版本,才算把一套能长期挂着跑的插画社区爬虫磨出来。核心诉求其实特别朴素:我关注的那些画师,作品散落在时间线上,平台不提供整包导出,手动一张张存既不现实也容易漏;所以我需要一个能读取关注列表、按画师维度把所有插画和元信息落到本地、并且能持续增量更新的工具。这类需求落到工程上,绕不开三件事——模拟登录拿会话、处理各种 SSL 错误、以及在被站点风控盯上之前把访问节奏控制住,也就是常说的反反爬。这篇文章不讲空话,我会把自己这套工具的结构、参数选择、报错对照表和踩过的坑完整摊开,适合已经会写基础请求、想把脚本升级成“能长期运行的小系统”的人看;完全零基础也能照着思路走,因为每个决定我都会说清楚为什么。
1. 先把需求拆干净:归档工具到底在解决什么问题
很多人写这类工具,第一版都是“给我一个画师 ID,我把他的图下下来”。跑通之后才发现真正的工作量根本不在下载,而在“怎么知道还有哪些没下”“怎么保证明天再跑不会重下一遍”“怎么在下到第 3000 张的时候不崩”。所以我现在的习惯是,动手写代码之前先用一张纸把数据链路画清楚,把每个环节可能失败的点标出来。
1.1 从“关注列表”到“本地图库”的数据链路
完整链路我拆成五段:登录并持有会话 → 拉取关注画师列表(分页)→ 对每个画师拉取作品列表(分页)→ 对每个作品解析出原图直链和多图页 → 下载并写入本地库。这五段里,第一段是会话问题,第二三段是频率与分页完整性问题,第四段是解析与结构变化问题,第五段是 IO 与断点问题。
为什么值得单独拆出来?因为它们的失败模式完全不同。会话失效通常表现为整段请求返回登录页 HTML,是“全局性”故障;分页漏页表现为某画师作品数比网页上显示的少几张,是“静默”故障,最恶心;下载失败则是单点故障,重试即可。如果不拆开,日志里全是一片 200,你却不知道数据已经缺了一块。我现在的做法是给每一段都定义明确的成功信号:关注列表拉完必须校验总数,作品列表拉完必须校验每个画师的作品计数,下载完成必须校验文件大小和图片头字节。
还有个容易忽略的点是作品与文件不是一对一。一个作品可能包含多张图,也可能包含动图或短视频,甚至有的作品只有文字没有图。所以我在数据模型里分了三层:画师(artist)→ 作品(work)→ 资源(asset)。下载和去重的粒度放在 asset 层,统计和展示放在 work 层。这个分层在后面对接断点续传时非常关键,因为断点应该按 asset 记录,而不是按作品记录,否则一个多图作品下到第三张断了,你整个作品都要重来。
1.2 三个真正的难点:会话、传输、频率
如果只允许我用一句话概括这类工具的难点,我会说:它不是一个抓取问题,是一个长时间运行的可靠性问题。真正让你半夜爬起来看日志的,无非三类。
第一类是会话。站点用 Cookie + 服务端 Session 维持登录态,Cookie 有有效期,Session 可能因为异地、设备变化、长时间不活跃被服务端主动失效。你的程序如果只在启动时登录一次,跑到第三天就会开始返回登录墙。第二类是传输层,也就是各种SSLError、ConnectionResetError、ReadTimeout。这类问题往往不是你的代码错,而是环境(证书库、时钟、网络中间层设备、容器镜像)和服务端配置的组合结果,但处理不当就会把偶发故障放大成批量失败。第三类是频率,站点会统计单位时间内的请求密度和请求特征的异常程度,密度过高会返回限流页或弹出人机验证,特征过于“像脚本”则可能直接拒绝。
这三类问题的处理方式有个共同点:都不能靠“加个 try 就完事”,必须做成有状态的机制。会话需要持久化和失效检测,传输需要分层重试和退避,频率需要全局令牌桶而不是在每个函数里sleep。
1.3 为什么我把浏览器自动化降级为备用方案
一开始我用过浏览器自动化方案,理由很直接:能拿到完整的渲染后页面,登录也好办。但跑了两周我就放弃了,原因有三个。资源占用是第一个,一个常驻浏览器实例加上页面加载,内存轻松吃掉几百兆,跑十几个小时还可能因为渲染进程堆积卡死。第二是速度不可控,同样一张列表页,纯 HTTP 请求 200 毫秒能拿到,走浏览器要一秒多,等你把几千个画师过一遍,时间差是数量级的。第三是故障不可归因,浏览器方案出问题时你很难判断是网络、是脚本选择器、还是页面自己卡住了,日志里全是一团。
我现在的架构是:HTTP 客户端做主力,浏览器自动化只留一个极小的备用入口,用于两个场景——一是在某些必须人工确认的环节由我本人操作一次,拿到长期会话;二是页面结构大改、解析规则失效时,用它抓一份渲染后的 DOM 下来做对照排查。这个定位很重要,把它当主力你会被资源问题拖死,把它当调试工具它很好用。
2. 模拟登录:把登录态当成一个需要运维的对象
“模拟登录”这四个字容易让人以为是一次性的动作,实际上它应该被设计成一个有生命周期、可持久化、能自我检测的模块。我在这个模块上花的返工时间,比解析代码多得多。
2.1 登录态的本质与生命周期
绝大多数站点维持登录的方式是 Cookie 里的会话标识,服务端对应一份 Session 记录。你要做的不是“每次都重新登录”,而是“登录一次,尽可能久地复用,并且能在失效时自动恢复”。这两种思路的差别在请求量上是几十倍:每次任务前登录一次,等于每天多出若干次登录请求,反而更容易触发风控。
我在设计上把会话分成三层状态:有效(请求返回正常页面)、疑似失效(返回登录墙或重定向到登录页)、明确失效(接口返回未授权状态码)。只有落到第三层才触发重新登录,第二层先做一次轻量探测确认,避免因为偶发的页面异常误判。探测接口我一般选一个轻量的、需要登录才能访问的自身信息接口,返回体小、判断逻辑简单。
持久化方面,我用requests.Session或者httpx.Client持有连接池和 Cookie,同时把 Cookie 序列化到本地文件,格式用 JSON,字段保留 domain、path、expires。这样进程重启后可以免登录恢复。这里有个细节值得说:不要只存 Cookie 的值,把过期时间一起存,判断有效性时先看本地过期时间,能省掉一次网络请求。
2.2 凭据存放:从硬编码到外部配置
我见过太多脚本把帐号密码写在源码里,甚至提交到了公开仓库。这件事的风险不需要我多讲,我只说工程上的替代方案:用一个独立的配置文件,权限设成 600,字段只留帐号标识和凭据,程序启动时读取。更好的做法是用系统密钥环,Python 里可以用keyring,把密码交给操作系统的凭据管理服务,配置文件里只放一个引用名。
如果你需要保存的是 Cookie 而不是密码,安全性要求更高一些,因为 Cookie 等同于登录凭据。我的做法是本地文件加密(用cryptography库的 Fernet,密钥从环境变量或密钥环读取),并且在程序启动时校验文件权限,如果发现是 644 就直接拒绝启动并提示。
注意:无论怎么存,都不要把凭据文件放在会被同步盘自动同步的目录里,也不要放进项目目录里一起打包。我吃过一次亏,把打包好的工具发给朋友,里面带着自己的会话文件,只能当场去改密码。
另外提一句,登录动作本身尽量低频、人工参与。我现在的做法是第一次运行时由我本人在浏览器里正常登录一次,导出 Cookie 给程序用,之后靠自动续期和失效探测维持。这样程序里根本不需要处理复杂的登录流程和可能出现的验证环节,代码量少了一大半,风险也低。这不是偷懒,是把不可控的环节从自动化流程里剥离出去。
2.3 登录失效的判定与恢复流程
失效检测要放在统一的请求封装里做,而不是散落在各处。我的封装长这样:所有请求走一个fetch()方法,内部做三件事——发送、判断响应是否像登录墙、必要时抛出特定异常交给上层调度器。
import time import httpx class SessionExpired(Exception): pass class Client: def __init__(self, session_file, headers): self.client = httpx.Client( headers=headers, timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=5.0), limits=httpx.Limits(max_connections=8, max_keepalive_connections=4), follow_redirects=True, ) self.session_file = session_file def _looks_like_login_wall(self, resp) -> bool: if resp.status_code in (401, 403): return True # 登录墙通常是短页面 + 表单特征,用长度和关键节点双重判断 text = resp.text if len(text) < 4000 and ('login' in resp.url.path) and ('<form' in text): return True return False def fetch(self, url, **kwargs): for attempt in range(3): resp = self.client.get(url, **kwargs) if self._looks_like_login_wall(resp): if attempt == 2: raise SessionExpired(url) time.sleep(2 + attempt * 3) continue return resp raise RuntimeError(f"unreachable: {url}")判断登录墙这件事,光看状态码是不够的。很多站点的登录墙返回的是 200,页面里是一个登录表单。所以我用了两个特征叠加:路径里包含登录相关关键字,加上页面里有form标签,再加长度偏短。这三个条件同时满足时误判率很低。反过来说,如果你只判断“页面里有没有 login 这个词”,很容易在正常页面的导航栏里误判,那就会陷入无意义的重新登录循环。
恢复流程我设计成:捕获SessionExpired→ 暂停当前队列 → 尝试用持久化的 Cookie 恢复 → 失败则挂起并输出明确提示,等待人工介入。关键是要能“挂起”而不是“退出”,因为队列可能有几千个待办,退出意味着一切重来。我的实现是把队列状态完整落盘,进程收到信号后保存断点再退出,人工处理完重新启动即可续跑。
3. SSL 与连接层:报错不可怕,乱关校验才可怕
requests.exceptions.SSLError是这类工具里最常见、也最容易被错误地“解决”掉的报错。我看到过太多人在 Stack Overflow 上抄一行verify=False,然后带着一堆InsecureRequestWarning继续跑。这确实是“解决了”,但解决的是报错,不是问题。
3.1 常见 SSL 报错逐条对照
我把这些年遇到的 SSL 相关报错整理成一张表,按“根因 → 正确做法”排列,比盲目搜索有用得多。
| 报错关键字 | 典型根因 | 正确处理方向 |
|---|---|---|
certificate verify failed: unable to get local issuer certificate | 本地根证书库缺失或不完整,常见于精简容器镜像 | 安装系统 CA 包,或显式指定certifi的证书路径 |
certificate has expired | 本地系统时钟偏差,或服务端证书确实过期 | 先校准系统时间(容器里尤其常见),再排查服务端 |
hostname mismatch/CertificateError | 请求的域名与证书 SAN 不匹配 | 检查是否请求了错误的子域,或 CDN 回源配置问题 |
SSLEOFError/UNEXPECTED_EOF_WHILE_READING | 连接被中途切断,常见于服务端主动断连或网络中间层设备 | 降低并发、加大超时、加重试,不要关校验 |
certificate verify failed: self signed certificate in certificate chain | 网络路径上存在注入自签证书的设备,或调试工具在抓包 | 定位并移除注入点;确属内网环境才考虑指定自定义 CA |
这张表我建议直接存进项目的 README,比事后翻日志猜要快得多。特别注意第一行和第四行的区别:前者是“证书链不完整,本地无法验证”,后者是“连接被切断”,两者处理方式完全相反。很多人看到SSL就去关校验,对SSLEOFError根本无效。
3.2 正确的证书处理姿势
我的原则是:全局校验永远开启,个别环境做定点修复。具体做法分三层。
第一层,保证运行环境有完整的 CA。在容器里跑的时候,Dockerfile 里加一行装ca-certificates,Python 环境里显式依赖certifi,并在客户端初始化时把证书路径指过去。这一层能解决八成报错。
import certifi import httpx client = httpx.Client(verify=certifi.where(), timeout=30.0)第二层,如果是内网或者特定环境确实需要自定义 CA,那就把 CA 文件放进项目,用verify="/path/to/ca.pem"精确指定。这样校验依然是开启的,只是信任列表里多了一个你明确知道来源的条目。这跟verify=False有本质区别,前者是“我信任这个特定的签发者”,后者是“我谁都不验”。
第三层,只有在纯调试、且明确知道自己在做什么的情况下,才临时关闭校验,并且要在代码里留一条明显的注释和日志告警。我自己的项目里这条路径是用环境变量控制的,默认关闭,需要手动开启,跑完立刻关掉。
注意:关闭校验不只是“不安全”这么抽象。在你抓取的过程中,如果网络路径上存在任何中间设备,关闭校验意味着你无法分辨返回的内容是否被篡改。对于只下载公开图片的场景,风险相对可控;但一旦涉及登录态,Cookie 在网络上是明文传输的,这才是真正的隐患。
还有个实际的小技巧:SSL 握手失败有时候是服务端对某些 TLS 版本或密码套件的兼容问题。遇到莫名其妙的握手错误,可以尝试明确指定 TLS 版本,或者换一个 HTTP 客户端(requests底层是 urllib3,httpx用 h11,两者对某些边界情况的处理不同)。我遇到过一类情况,同一个 URL,requests报SSLEOFError,httpx能正常拿到,换过去就好了,这类问题不值得深挖协议栈,绕过是更务实的选择。
3.3 连接池、超时与重试的三角关系
这三个参数必须一起调,单独调任何一个都会出问题。我的经验值是这样的:连接池大小控制在 4 到 8 之间,不要一上来就开 32;超时拆成 connect / read 两段,connect 给 10 秒,read 给 30 秒;重试次数 3 次,退避采用指数加随机抖动。
为什么要限连接池?因为你控制的是“我这边的并发”,但服务端看到的是“这个 IP 在短时间内发起了多少个连接”。连接池开很大,等于主动把自己的请求密度顶到风控线以上。8 个连接对于图片下载已经够用了,瓶颈通常在服务端响应而不是本地并发。
超时为什么要拆开?connect超时通常意味着网络不通或者服务端拒绝,重试意义不大,快速失败更好;read超时意味着服务端已经接受但响应慢,这时候重试是有意义的。把两者混成一个 30 秒,会导致网络不通时每次都要等满 30 秒,几千个任务下来时间全耗在等待上。
import random import time def with_retry(fn, max_attempts=3, base=1.0, cap=30.0): for i in range(max_attempts): try: return fn() except (httpx.ReadTimeout, httpx.ConnectError, httpx.RemoteProtocolError) as e: if i == max_attempts - 1: raise delay = min(cap, base * (2 ** i)) * (0.5 + random.random()) time.sleep(delay)这段代码里(0.5 + random.random())这个抖动因子很关键。没有抖动的话,一批失败的任务会在同一时刻集体重试,形成一次新的请求尖峰,把刚刚恢复的服务再打一遍。加了这个因子,重试时间被摊平在 0.5 到 1.5 倍之间,尖峰就被抹平了。这个技巧在分布式系统里叫“去同步化”,用在单机爬虫上同样有效。
4. 访问节奏控制:让程序看起来“不像机器”
这一节是整套工具里最需要耐心的地方。我把“反反爬”这个词打了个引号,因为在我看来,这个事情的本质不是“对抗”,而是把自己的访问行为约束在一个对服务端友好的范围内。你越是想方设法伪装,越容易在特征上露出破绽;反过来,把频率降下来、把行为做得有规律、把无效请求减到最少,反而是最稳的路子。
4.1 请求头该伪装到什么程度
请求头这件事上,我踩过的坑可以总结成一句话:不要自创一套header,直接用真实浏览器的完整header。很多人喜欢自己拼一个User-Agent,只写 UA 不写其他字段,结果请求头列表极其稀疏,跟正常浏览器差得远。
我的做法是打开浏览器开发者工具,抓一个真实的、正常浏览页面的请求,把它的请求头完整复制下来,做成一份配置。这里面有几个字段值得单独说:Accept要包含image/avif,image/webp这类现代浏览器会带的类型;Accept-Language要和你的帐号所在地区匹配;Referer要符合导航逻辑,比如从作品列表点进详情页,Referer 就应该是列表页的地址,而不是站点首页。
还有一个容易忽略的:连接复用比 header 更能体现行为一致性。真实浏览器访问一个站点,会复用同一个 TCP 连接发多个请求,TLS 会话也会复用。如果你的程序每次都新建连接,在服务端看来就是“每次都是陌生人”。这也是为什么我一直坚持用带连接池的客户端,而不是每次requests.get()裸调。
至于 TLS 指纹这类更深的层面,我的态度是明确的:不去做。这类方案维护成本极高(指纹库需要持续更新),而且它属于主动对抗风控而不是约束自身行为,不符合我前面说的原则。真到了需要这一步的境界,说明请求量已经超出合理范围了,正确的解法是把量降下来,而不是把伪装做深。
4.2 令牌桶 + 随机抖动:我用的限速实现
限速这件事,我最开始的做法是在每个循环里time.sleep(1),效果很差。因为下载图片的耗时是不一样的,有的 200 毫秒,有的一秒多,sleep(1)加在一起,实际频率忽高忽低,平均下来还偏快。后来换成令牌桶,控制了全局速率,行为就规律多了。
import threading import time class TokenBucket: def __init__(self, rate: float, capacity: int): self.rate = rate # 每秒补充的令牌数 self.capacity = capacity # 桶容量,决定允许的突发量 self.tokens = float(capacity) self.last = time.monotonic() self._lock = threading.Lock() def acquire(self, n: int = 1, timeout: float = 30.0): deadline = time.monotonic() + timeout while True: with self._lock: now = time.monotonic() self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.rate) self.last = now if self.tokens >= n: self.tokens -= n return need = (n - self.tokens) / self.rate if time.monotonic() + need > deadline: raise TimeoutError("rate limit budget exhausted") time.sleep(need * (0.8 + 0.4 * __import__("random").random()))参数选择上,我按场景分了三档,这个分档是实跑调出来的:列表页请求(返回 JSON 或 HTML)给 1 到 1.5 请求每秒,桶容量 3,允许小突发;图片下载给 2 到 3 每秒,桶容量 5;登录和需要鉴权的敏感接口给 0.2 每秒,桶容量 1,也就是 5 秒一次。
为什么桶容量不能设大?容量代表突发能力。容量是 10,意味着程序可以在一瞬间发 10 个请求,虽然平均速率没变,但瞬时并发足够触发风控。我把容量刻意压到接近 1,图的就是“平滑”,宁可慢一点也不要尖峰。
另外那个0.8 + 0.4 * random()的抖动也是必需的。如果所有请求都精确地按照固定间隔发出,请求间隔的方差接近零,这个特征在服务端日志里极其显眼——人类不可能做到毫秒级的均匀间隔。加 20% 的抖动之后,间隔分布就自然多了。
4.3 碰到人机验证时的正确处理
我要把这件事说清楚:人机验证是站点风控体系的正常组成部分,它出现说明你的访问密度或者行为特征已经进入了可疑区间。这时候正确的动作是把速度降下来,或者停下来人工完成一次,而不是想办法自动化通过它。
我的处理逻辑是这样的:程序检测到响应里出现验证挑战的特征(页面结构变化、特定的脚本加载、状态码变化),立刻做三件事——把当前任务标记为“待人工”、把全局速率临时降到原来的一半、发一条通知到我的手机上。然后进程挂起等待,我在手机或电脑上手动完成一次验证,会话恢复后程序继续,同时速率保持降低状态跑一段时间再慢慢恢复到原值。
这个机制的好处是:它把“异常”变成了一个可控的、有明确处理路径的状态,而不是让程序在那里死循环重试——死循环重试恰恰是最容易让封禁升级的行为。我见过有人写了个 while 循环疯狂重试,结果从“偶尔弹验证”升级成“IP 段被拒绝”,得不偿失。
还有一个更根本的减少验证出现概率的办法:把请求打散到更长的时间窗口里。比如我有 800 个关注画师,如果一天内跑完,平均每小时 33 个,每个画师几十个作品,总量不小。但归档这个需求根本没有时效性——今天下完和这周下完,对结果毫无影响。所以我把任务队列设计成可以按优先级排期,新关注的画师优先,历史画师按时间轮转,每天只跑一个配额。这样日均请求量降了一个数量级,验证几乎不再出现。这是我在这件事上最大的一条经验:能用时间换安全的地方,就别用技巧换。
4.4 缓存与增量抓取,减少无效请求
减少请求总量是比限速更彻底的方案。我的做法是两级缓存。
第一级是 HTTP 缓存,尊重服务端返回的ETag和Last-Modified。对于列表页和详情页这类可能变化的内容,请求时带上If-None-Match,返回 304 就直接用本地缓存,一个字节都不多传。这一级能省掉大量重复的列表请求。
第二级是业务缓存,也就是本地数据库里记录的“这个作品我已经下过了”。每次拉到作品列表,先跟数据库里的记录做差集,只处理新增的。这里的关键是用稳定不变的标识做键,而不是用标题或者文件名,因为标题可能被作者修改。我用作品 ID 做唯一键,图片则用作品 ID 加序号。
两级缓存叠加之后,日常增量运行(比如每天一次)的请求量往往只有全量的百分之几。这带来的不只是速度提升,更重要的是把风控暴露面压缩到了极小——你每天只发几十个请求,跟一个正常用户在刷页面的量级差不多。
5. 数据落地:目录、元数据与断点续传
下载部分的代码通常是最先写完的,但也是最容易在长期运行中出问题的。我见过太多脚本把文件都堆在一个目录里,几千张图混在一起,文件名还带一堆非法字符,最后自己都找不到想要的那张。
5.1 目录结构与命名规范
我的目录结构是按画师分层的,每一位画师一个目录,目录名用「画师ID_昵称」的格式。为什么带上 ID?因为昵称随时会改,改了之后目录就对不上数据库里的记录了,用 ID 作为前缀能保证唯一性,昵称只是给人看的。
作品层面,我按「作品ID」建子目录,里面放这个作品的所有资源文件。用子目录而不是把图片平铺,是因为多图作品的顺序需要保留,而且详情页有时会附带动图或说明文本,放在一起便于查看。
文件名我用「序号_资源ID.扩展名」,比如01_12345678.jpg。这里有两个细节:序号补零到两位,保证文件管理器里按名称排序时顺序正确;扩展名从响应的Content-Type推导,而不是从 URL 猜,因为很多站点的图片直链是不带扩展名的。至于文件名里的非法字符问题,如果用了昵称就一定要做清洗,把/ \ : * ? " < > |和控制字符全部替换掉。
5.2 SQLite 状态表设计
状态管理我用 SQLite,理由是它不需要额外服务、单文件、支持事务和并发读,对这种单机工具来说是最优解。表结构我设了三张主表和一张队列表。
CREATE TABLE artist ( artist_id TEXT PRIMARY KEY, name TEXT NOT NULL, followed_at TEXT, last_sync TEXT, sync_cursor TEXT -- 分页游标,用于增量拉取 ); CREATE TABLE work ( work_id TEXT PRIMARY KEY, artist_id TEXT NOT NULL REFERENCES artist(artist_id), title TEXT, created_at TEXT, asset_count INTEGER DEFAULT 0, fetched_at TEXT ); CREATE TABLE asset ( asset_id TEXT PRIMARY KEY, -- work_id + '_' + 序号 work_id TEXT NOT NULL REFERENCES work(work_id), url TEXT, file_path TEXT, bytes INTEGER, status TEXT DEFAULT 'pending', -- pending/done/failed/skip attempts INTEGER DEFAULT 0, updated_at TEXT );三个设计点值得展开。第一,asset表里status和attempts分开记,因为“失败”和“尝试次数”是两个维度,重试策略要根据尝试次数决定退避时长,而不是只看状态。第二,sync_cursor字段存的是分页游标,这样增量同步时可以从上次的位置继续,不用每次从头翻页——对于作品很多的画师,这能省下大量请求。第三,asset_id用work_id加序号的组合,保证幂等,重复插入时用INSERT OR IGNORE就能安全跳过。
写入时我统一走 upsert,避免“先查再插”的竞态:
def mark_done(conn, asset_id, path, size): conn.execute( """INSERT INTO asset (asset_id, file_path, bytes, status, updated_at) VALUES (?, ?, ?, 'done', datetime('now')) ON CONFLICT(asset_id) DO UPDATE SET file_path = excluded.file_path, bytes = excluded.bytes, status = 'done', updated_at = excluded.updated_at""", (asset_id, path, size), )5.3 并发模型与磁盘 IO 的平衡
并发这件事我用的是“生产者 + 有限消费者”的模型:主线程负责遍历作品列表、生成下载任务压入队列;若干个工作线程从队列取任务、限速、下载、写库。线程数我固定在 4 到 6,不跟着 CPU 核心数走,因为瓶颈在外部请求和磁盘,不在计算。
为什么不用异步?其实aiohttp方案我也写过,性能确实更好,但对这个场景是过度设计。下载的瓶颈在令牌桶限速,异步能带来的并发优势被限速吃掉了,反而增加了代码复杂度——所有阻塞操作(SQLite 写入、文件写盘)都要小心处理,稍不注意就阻塞事件循环。线程模型下这些顾虑少得多,出问题也更好定位。
磁盘 IO 上有个容易忽略的点:文件写盘要用临时文件加原子重命名。直接往目标路径写,如果中途进程被杀或者断电,会留下一个半截文件,而数据库里可能已经标记为 done 了。正确做法是先写xxx.part,写完flush加os.fsync,然后os.replace原子重命名,最后才更新数据库。这三步的顺序不能反,否则会出现数据不一致。
def atomic_write(path, content_iter): tmp = path + ".part" with open(tmp, "wb") as f: for chunk in content_iter: f.write(chunk) f.flush() os.fsync(f.fileno()) os.replace(tmp, path)另外建议定期做一次校验任务,把数据库里status='done'的记录跟磁盘上的文件对一遍,检查存在性和大小。这一步能抓出不少意外情况,比如被清理工具误删、被同步盘改坏、或者写入时磁盘满导致的零字节文件。
6. 问题速查与踩坑实录
这一节是我这些年攒下来的问题清单,按“现象 → 排查方向 → 处理方式”整理,遇到问题先查表,比重新看代码快得多。
6.1 常见问题速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 前几百个请求正常,之后全部返回登录页 | 会话被服务端判失效,或触发了风控 | 暂停并检查 Cookie 有效期;把速率降低后重新登录 |
部分请求报SSLEOFError,重试偶发成功 | 服务端主动断连或网络路径中间设备 | 加大重试次数与退避;降低并发;换 HTTP 客户端库试 |
| 下载的图片全部是几 KB 的小文件 | 拿到的是占位图或防盗链响应 | 检查Referer是否正确;检查是否需要携带特定 Cookie |
| 某些画师作品数比页面显示的少 | 分页逻辑漏页,或接口有隐藏分页 | 用浏览器对照实际请求;确认是否需要游标而不是页码 |
| 进程跑几小时后内存持续上涨 | 响应内容未释放,或队列无限堆积 | 流式下载而非全量读入;队列设最大值并做背压 |
| 所有请求都 200,但数据大量缺失 | 静默失败,解析规则失效 | 增加“数量对账”校验,拉完必比总数 |
| 中文文件名变成乱码 | 文件系统编码或响应头解析问题 | 统一用 UTF-8 处理,文件名做 NFC 归一化 |
这张表里我最想强调最后一行和倒数第二行。静默失败是这类工具最大的敌人,因为它不会报错,你只会在一周后发现收藏少了一半。所以我现在所有解析之后都强制做一次数量校验:画师的作品总数、作品的资源数,只要跟预期对不上就打印警告并把该条目降级为“待复核”,绝不静默通过。
6.2 几个反直觉的坑
第一个坑:重试不是越多越好。我最开始把重试设成 10 次,觉得稳。实际结果是,当服务端开始限流时,重试让请求量翻了好几倍,直接把自己送进了更严格的黑名单。现在我的策略是:连续失败超过阈值就停下整个队列,冷却一段时间再说。这个“熔断”逻辑比无限重试有用得多。
第二个坑:分页的最后几页容易漏。很多站点的分页在总数变化时会有偏移,你今天翻到第 5 页,明天新增了作品,第 5 页的内容就整体后移了,导致重复和遗漏。解法是用游标分页而不是页码分页,如果站点只提供页码,那就按时间倒序、记录已处理的最小作品 ID,靠 ID 做去重和续拉。这个思路上的转变,让我后来几乎再没遇到过漏数据。
第三个坑:别把日志写成异步的。为了性能,我一度把所有日志都丢进队列异步写,结果进程崩溃时最后几十条日志全丢了,而这几百条恰恰是崩溃前的现场。现在的做法是:普通调试日志异步写,但错误和状态变更类日志同步落盘,宁可损失一点性能,也要保证出事时能看到现场。
第四个坑,也是最反直觉的一个:慢一点真的更快。我把全量速率从 3 请求每秒降到 1.2 之后,整体完成时间只多了不到一倍,但进程中位失败率从百分之几降到了千分位以下,重试和人工介入的成本反而大幅下降。这个结论我一开始不信,跑了几轮数据对比才接受。很多人卡在“怎么绕过限制”上,其实换个角度想“怎么让限制不触发”,路会宽很多。
最后分享一个我一直在用的做法:给程序留一个“手动模式”。任何自动流程都会遇到边界情况,与其把代码写得越来越复杂去覆盖所有分支,不如留一个入口,让程序在遇到无法处理的情况时,把需要的信息打印出来(URL、所需参数、当前 Cookie 状态),由人复制到浏览器里手动完成,然后把结果填回队列。这套机制在两年里救了我无数次,也是这套工具能一直维护下去的关键——它不是追求 100% 自动化,而是追求“总能把事情做完”。