批量归档自己关注的画师作品,这件事听起来简单,真正动手就会发现问题一堆:登录窗口的人机校验卡在第一步、跑着跑着连接层抛 SSL 异常、请求发得稍密一点列表接口就开始返回空数据、下载到一半断了又得从头再来。这篇讲的就是我给自己搭的这套插画归档工具,从某日系插画社区(下文统一叫“图站”)的关注列表出发,把关注画师的全部公开插画批量拉到本地按目录整理好。整套东西不复杂,但踩过的坑足够写一篇:登录态怎么持久化、人机校验环节怎么处理才不越线、SSL 报错到底该看哪一层、限速和重试怎么设计才不至于把自己搞得寸步难行。适合已经会写点 Python、想拿一个真实项目练手爬虫工程化的朋友,也适合只是想把自己收藏夹备份下来、不打算深究原理的使用者——后半部分的实操步骤可以直接照抄。
1. 这套工具到底要解决什么问题
1.1 从“手动另存为”到关注列表全量归档
我最初的动机非常朴素:关注列表里有几百个画师,每个画师多则上千张作品,少则几十张。想按画师分目录存档,手动点是不现实的,一张一张右键另存为,光是改名就能把人逼疯。更麻烦的是有些作品是多页的,一张作品下面挂着十几张图,手动存的时候极容易漏页,事后翻回去核对又是一场灾难。所以目标很明确:输入一个账号的关注列表,输出一个按画师组织的本地目录树,每个画师目录下是该画师的全部公开作品,多页作品按页序排列,文件名携带作品 ID 方便日后去重和溯源。
这件事的价值不止于“省事”。当你把几百个画师的作品结构化地落到本地之后,可以做很多二次的事情:按标签统计自己偏好的题材分布、按时间线看某个画师风格演变、给自己做一个离线检索的小索引。我在做完第一版之后,又顺手加了个按作品标签生成索引的功能,找参考图的速度提升非常明显。所以这套东西的定位不是“爬虫练习”,而是一个长期可维护的个人数字资产归档系统,这个定位决定了后面所有的设计取舍——我要的不是跑一次就完事,而是三个月后重新跑一次还能增量更新、不重复下载、不把已有的目录搞乱。
1.2 三个硬骨头:登录态、人机校验、传输层报错
真正写起来才会发现,难点根本不在“发请求解析 HTML”。这个站点的公开作品信息大部分不需要登录就能看,但关注列表属于账号私有数据,必须登录后才能拿到。于是第一道坎就是登录态:你不能每次跑都弹一次登录窗口手动点,那就失去了自动化的意义。第二道坎是人机校验,登录环节偶尔会触发,这是个必须正面处理但绝不能越界的问题。第三道坎是传输层的报错,SSL 握手失败、连接被重置、读取超时,这些错误在批量请求场景下出现频率远高于单次请求,如果不做统一的错误处理和重试策略,整个任务跑到一半卡死是常态。
这三个问题之外,还有一层隐性的坎:请求节奏。站点对高频请求是有防护的,短时间内密集请求同一个接口,轻则返回空结果,重则让你的会话直接失效,前面的登录态白做。所以整套工具的核心其实不是“怎么抓”,而是“怎么稳定地、长期地、不惹麻烦地抓”。我把这三块拆成独立模块分别处理,互不干扰:会话模块管 Cookie 和请求头,传输模块管重试、超时和错误分类,调度模块管限速和任务队列。这样任何一块出问题,定位范围都很小。
1.3 先把边界划清楚:只碰自己有权限看的东西
这一点我必须放在前面讲,因为它决定了后面很多技术选择的方向。这套工具的全部职责是:把我自己账号能看到的内容,复制一份到我自己的硬盘上。它不碰付费内容、不碰需要额外权限才能访问的内容、不绕过任何访问控制、不对外分发任何抓下来的东西。基于这个前提,我在设计上做了几条硬性约束:不做自动化的登录流程,登录这一步必须由真人完成一次;不做验证码的自动识别或破解,遇到校验就停下来交给人;不做任何并发压榨,请求间隔按秒级算,宁可跑一晚上也不要在十分钟内把对方服务器打出一堆 4xx。
这几条约束听起来像是“自缚手脚”,实际上反而让整个工程更省心。因为一旦你放弃“全自动无人值守”这个不切实际的目标,登录态的维护就变成了一个简单的持久化问题,人机校验就变成了一个人工介入的检查点,你省下的是大量反检测特征的调试时间。我见过太多人在这上面死磕,最后写出来的代码脆弱得像纸糊,对方一个小改动就全盘失效。承认“有些环节必须人工”,工程复杂度会断崖式下降。
2. 整体架构与方案选型
2.1 分层设计:会话层、调度层、解析层、落盘层
整套工具我拆成四层,每层职责单一,方便单独调试。会话层负责创建和复用 HTTP 会话对象、加载 Cookie、维护请求头;调度层负责控制请求节奏、管理重试、维护一个待处理队列;解析层负责从 JSON 或 HTML 里提取画师 ID、作品 ID、页数和图片直链;落盘层负责目录创建、文件命名、断点续传和去重。四层之间通过简单的数据对象传递,不用框架,纯 Python 脚本加几个类就够。
为什么不用现成的爬虫框架?我试过一个主流框架,配置起来反而更绕,而且它的调度模型面向大规模分布式场景,我这种单机、低并发、需要频繁人工介入的任务用不上。自建四层的好处是每层都能单独跑:我会写一些小脚本专门测解析层,拿一段保存下来的响应离线跑,不用每次都真发请求;调度层的限速逻辑也可以用一个假的请求函数测。这种可测性在后期维护时价值极大,站点结构一变,我只需要改解析层,其余三层完全不动。
2.2 为什么不做全自动登录
标题里“模拟登录”这个词容易引起误解,我说的“模拟登录”不是指程序自动填账号密码、自动过校验,而是指把浏览器里已经完成的登录结果搬到程序里复用。具体做法是:用带界面的浏览器自动化工具打开登录页,由你本人手动完成登录(包括可能出现的人机校验),登录成功后把浏览器的 Cookie 全部导出来,存成本地文件。程序每次启动时加载这份 Cookie,直接带着它发请求,相当于“借用”你已经登录的会话。
这个方案的优点是显而易见的:登录环节的所有复杂度(包括那些人眼一看就过、程序一看就懵的校验环节)都留在了浏览器里,程序只处理纯 HTTP 请求。缺点也明确:Cookie 会过期,过期了就得重新来一次。所以我在工具里加了一个“健康检查”步骤——启动时先用会话请求一个需要登录才能访问的轻量接口,如果返回的是登录页或者 401,就提示“登录态失效,请重新导出 Cookie”,而不是让程序带着废 Cookie 跑一晚上然后发现自己下了一堆登录页的 HTML。
2.3 存储结构与命名规则的设计
目录结构我改过三版,最后定下来是这样:
archive/ artists.json # 画师元信息索引 artworks.db # 已下载作品记录(SQLite) 1001_某某画师/ avatar.jpg 120000001_p1.jpg 120000001_p2.jpg 120000002_p1.jpg画师目录名用“画师 ID + 下划线 + 画师名”的组合。为什么加 ID 前缀?因为画师改昵称是常事,如果只用名字做目录,改一次名就会多出一个新目录,历史文件全散架。用 ID 做前缀,名字只是给人看的,程序认 ID,改名时只需要把目录重命名一次,或者干脆新建目录后把文件搬过去,去重逻辑不受影响。
文件名用“作品 ID + 页码 + 扩展名”。作品 ID 是全局唯一的,这就保证了去重的可靠性——判断一个文件是否已下载,不需要比对文件内容或大小,直接查数据库里有没有这个作品 ID 加页码的记录就行。页码补零到两位,保证按文件名排序时页序正确(p10排在p2后面的问题,补零后p10就是p10,p2是p02,字典序正确)。扩展名保留原图格式,不做统一转换,因为我需要的是原图备份,不是二次压缩。
3. 登录态与人机校验环节的处理
3.1 手动登录加 Cookie 持久化:一次操作,长期复用
具体流程分三步。第一步,用一个带可视化界面的浏览器自动化工具开一个持久化的用户数据目录,这个目录的作用是保存浏览器的完整状态(Cookie、LocalStorage、缓存),下次打开同一个目录,登录状态还在。第二步,脚本打开登录页后就暂停,等你在窗口里完成登录。我用的判断方式是轮询检查当前页面 URL 或者某个“已登录才会出现的元素”,一旦出现就认为登录完成,脚本继续往下走。第三步,登录完成后,从浏览器上下文里把所有 Cookie 导出成 JSON,按域名分组存好。
这里有个细节值得说:不要把 Cookie 只存成一条Cookie头字符串。理由是有些站点会同时使用 HttpOnly 的会话 Cookie 和一些普通 Cookie,还可能区分不同的子域。存成结构化数据(name、value、domain、path、expires)之后,加载时可以精确还原,也能判断哪些快过期了。我用的格式大概是这样:
{ "domain_a": [ {"name": "session_id", "value": "xxxx", "domain": ".example.com", "path": "/", "expires": 1735689600, "httpOnly": true} ] }加载到请求库的会话对象里时,逐条塞进 cookie jar,注意domain字段要正确,否则请求根本不会带上这条 Cookie。我第一版就栽在这——导出时把 domain 前面的点丢了,结果请求一直返回未登录,排查了两个小时才发现是 Cookie 域匹配的问题。
3.2 登录态失效的判断与刷新节奏
Cookie 的过期时间不一定准,服务端可能提前让会话失效(比如检测到异地、检测到请求特征异常)。所以不能只依赖expires字段做判断,必须做实际的健康检查。我的做法是每次任务启动时,先用会话请求一个需要登录的轻量接口,比如账号自己的基本信息接口,检查返回内容里有没有用户 ID 字段。有,说明登录态有效;没有或者返回了登录页特征,说明失效。
健康检查还有一个额外好处:它顺便验证了传输层是否正常。如果这次请求直接抛 SSL 异常或超时,那说明网络环境有问题,跟登录态无关,错误分类就清晰了。我把这个检查放在任务开始的第一秒,成本极低,收益极高——省掉了无数次“跑了两小时发现全是废数据”的崩溃。
至于刷新节奏,实测下来 Cookie 在持续活跃使用的情况下能撑比较久,但如果中途停用几天再跑,大概率要重新登录。所以我在工具里加了个时间戳记录,超过一定天数没跑过,就直接提示重新导出,不做无谓的尝试。
3.3 会话对象复用的两个坑
第一个坑是会话复用导致的连接池耗尽。请求库的会话对象默认会维持一个连接池,如果你用多线程共享同一个会话对象,在高并发下可能出现连接池排队甚至报错。我的应对很简单:并发控制在很小的数值(后面会讲),并且每个线程持有一个独立的会话对象,只共享 Cookie 数据,不共享连接池。
第二个坑是请求头里的某些字段在会话复用时容易被忽略。比如Referer,图片直链在有些情况下会校验来源页,缺了 Referer 直接返回 403。我在下载图片时固定带上作品详情页的 URL 作为 Referer,这个问题就解决了。还有Accept-Language和User-Agent,这两个字段在会话初始化时统一设置一次,避免每个请求单独传参时漏掉。这些细节单看都很小,但组合起来就是“跑得好好的突然就开始 403”的元凶。
注意:Cookie 文件里包含的是你的账号凭证,等同于密码。落地时放在项目目录之外,或者加进
.gitignore,不要随代码一起提交。
4. SSL 与传输层报错的排查路径
4.1 先把报错分类,不要一上来就改代码
批量请求跑起来之后,控制台里经常是一堆花花绿绿的异常。我的经验是先把它们分成三类,分类处理比逐个看堆栈高效得多。
第一类是证书验证失败,典型报错是SSLCertVerificationError或者CERTIFICATE_VERIFY_FAILED,提示unable to get local issuer certificate。这类问题的根源通常在本地的证书链不完整,或者 Python 依赖的证书包版本太旧,跟目标站点本身没什么关系。第二类是握手阶段的连接中断,表现为SSLError伴随EOF occurred in violation of protocol,这种常见于连接被中间设备重置,或者是服务端主动断掉了长连接。第三类是超时和读取失败,ReadTimeout、ConnectionResetError,属于纯粹的链路抖动,重试就能解决。
我把三类的处理策略分开写在了传输模块里:第一类先修复环境,不做无脑重试(因为重试一百次结果一样);第二类退避重试,同时降低并发;第三类直接指数退避重试。这个分类让我的日志清爽了很多,出问题时一眼就能看出是环境问题还是网络抖动。
4.2 证书问题的定位手法
遇到证书验证失败,我按这个顺序排查。先确认是不是全局性的——用一个最简单的请求访问任意一个 HTTPS 站点,如果也报同样的错,那问题百分百在本地环境,跟目标站点无关。第二步检查证书包是否最新,很多环境里装的是很久以前打包的证书,根证书过期了还不自知,更新一下就能解决。第三步检查系统的 CA 存储是否被正确读取,某些环境下需要显式指定证书文件路径。
这里必须强调一句:不要用关闭证书校验的方式来“解决”问题。我知道很多教程里一句verify=False就带过了,但那等于把你的流量暴露在任何中间人面前,对于一个要携带登录 Cookie 的工具来说,这是极其糟糕的做法。我承认在定位阶段临时关掉校验、看看是不是证书问题,是个有用的诊断手段,但绝不能留在生产代码里。诊断完立刻关掉,并且在代码里留个注释提醒自己。如果确实遇到公司内网自签证书之类的场景,正确做法是把那张证书显式加入信任列表,而不是一刀切关闭校验。
4.3 重试逻辑的写法与参数选择
重试不是简单包个for循环,参数选不对反而会加剧问题。我用的是带抖动的指数退避:基准等待 2 秒,每次失败翻倍,上限 60 秒,最后加一个 0 到 1 秒之间的随机抖动。公式大概是wait = min(60, 2 * 2**(n-1)) + random.uniform(0, 1)。为什么加抖动?因为如果多个线程同时失败,没有抖动的话它们会在同一时刻一起重试,形成新的尖峰,反而更容易被防护机制盯上。抖动把这些重试打散,效果立竿见影。
重试次数的上限我设成 5 次。超过 5 次还失败,说明不是临时抖动,可能是会话失效、被封、或者页面结构变了,继续重试只是在浪费时间和制造噪音。这时候应该把任务挂起、记录到失败队列、继续处理下一个目标,等整轮跑完再统一处理失败队列。这个“失败队列”的设计救了我很多次,因为往往跑完一轮之后,前面的限速阈值也摸清楚了,回头补跑的成功率高得多。
| 错误类型 | 典型报错 | 处理策略 | 重试次数上限 |
|---|---|---|---|
| 证书验证失败 | SSLCertVerificationError | 修复本地证书环境,不重试 | 0 |
| 握手中断 | EOF occurred in violation of protocol | 退避重试,同时降并发 | 3 |
| 读取超时 | ReadTimeout | 指数退避重试 | 5 |
| 连接重置 | ConnectionResetError | 指数退避重试 | 5 |
| 状态码 429 | 限流响应 | 长退避,降速到一半 | 3 |
| 状态码 403 | 拒绝访问 | 检查 Referer 与 Cookie,不盲目重试 | 1 |
5. 反反爬:用“像人”的方式换取长期可用
5.1 请求特征的一致性比花哨的技巧更重要
这个话题网上的讨论容易走偏,很多人一上来就想着伪造各种指纹、随机化各种头。我的经验恰恰相反:请求特征的一致性远比随机化重要。什么叫一致性?就是你的 User-Agent、Accept、Accept-Language、Accept-Encoding 这几个字段,在整轮任务里保持固定,并且跟真实的浏览器一致。如果你每个请求随机换一个 UA,一会儿是某版本浏览器、一会儿是另一个平台,从服务端的视角看这就是明显的异常——真实用户不会在一秒内换三次浏览器。
所以我的做法是:选定一组真实的、当前主流浏览器的请求头,写死在配置里,整轮任务不变。如果任务跨天跑,可以换一组,但一天之内不换。Cookie、Referer、请求头的组合逻辑也要连贯:访问详情页时 Referer 是列表页,下载图片时 Referer 是详情页,这个链条符合真实浏览行为。这些细节加起来,比任何“高科技”手段都管用。
5.2 限速参数的推算是怎么来的
限速不是拍脑袋定的。我的推算逻辑是这样:先搞清楚任务总量,比如 500 个画师、平均每人 300 个作品、平均每个作品 1.5 页图。那么作品列表请求大约 500 次(每人分页可能多次,算 1000 次),图片请求约 225000 次。如果目标是 8 小时内跑完,平均每秒要发 7.8 个请求。这个速率对图片 CDN 来说可能勉强能接受,但对 API 接口来说绝对太快。
所以我把两类请求分开限速:API 接口(关注列表、作品列表)控制在每分钟 20 次以内,也就是间隔 3 秒左右;图片 CDN 放开一些,间隔 0.3 到 0.5 秒,并发 2 到 3 个。这样算下来,主要瓶颈在 API 那一侧,1000 次请求需要 50 分钟左右,图片下载可以并行铺开。整个任务跑一晚上的时间完全够用,而且峰值压力很小。宁可慢,也不要快——快一小时对你没有任何意义,被封一天才是真的损失。
并发数我压得很低,最多 3 个线程处理图片下载。原因是并发一高,退避和限速的逻辑就会互相干扰,出问题时也难定位。3 个线程对图片下载来说已经足够了,因为瓶颈在网络往返而不是 CPU。
5.3 状态码要当语义读,不要当错误读
这是我认为最有价值的一条经验。很多人写爬虫,看到非 200 就一律当错误重试。实际上状态码是在告诉你信息,你得读它。返回 429 是在明确告诉你“太快了”,这时候正确的反应是大幅降速并延长退避,而不是重试;返回 403 往往意味着你的请求特征不对(缺 Referer、Cookie 失效、UA 被识别),重试一百次也没用;返回 404 可能是作品已被删除,应该直接标记跳过,而不是反复请求;返回 200 但内容是空的列表,这在图站很常见,往往意味着分页到底了或者触发了某种软限制,需要区分对待。
我专门写了一个状态码处理函数,把每个码映射到具体动作,而不是统一的raise_for_status。这个函数大概是这样:
def handle_response(resp, task): code = resp.status_code if code == 200: return "ok" if code == 404: return "gone" # 作品已删除,标记跳过 if code == 403: return "forbidden" # 特征问题,挂起待人工检查 if code == 429: return "slow_down" # 限流,全局降速 if 500 <= code < 600: return "retry" # 服务端问题,退避重试 return "unknown"有了这个映射,调度层就能根据返回值做全局决策,比如收到一次slow_down就把全局间隔乘以 1.5,收到forbidden就暂停整个任务并打印诊断信息。这种“读状态码做决策”的模式,让工具在遇到软限制时能自己收敛,而不是一路撞墙。
6. 完整实操流程与核心环节
6.1 环境准备与依赖清单
环境用 Python 3.10 以上,依赖不多:requests做 HTTP 会话,urllib3处理重试,playwright负责那个手动登录窗口和 Cookie 导出,tqdm给个进度条(纯属心理安慰,长任务没进度条真的难受)。数据库用标准库自带的sqlite3,不需要额外装。
pip install requests urllib3 playwright tqdm playwright install chromium装完之后先跑一个连通性测试,确认能正常访问目标站点的公开页面。这一步很重要,如果这一步就报 SSL 错误,说明是环境问题,先把环境修好再往下走,别在业务代码里找原因。
6.2 关注列表的抓取与分页处理
关注列表接口通常是分页的,需要先拿到总数或者总页数,然后逐页请求。这里的注意点是分页参数的类型——有些站点用offset加limit,有些用page加per_page,还有些用游标(返回结果里带一个next_cursor)。看图站的实际响应来判断,别凭经验硬猜。
抓取逻辑我写成这样:先请求第一页,从响应里解析出总页数,然后循环请求后续页,每页之间固定睡 2 到 3 秒。如果某一页返回了空数组但总页数还没到,就认为触发了软限制,主动延长等待到 10 秒再重试一次,还是空就跳出并记录。这个“空页判定”非常关键,因为返回 200 加空数组是最容易被忽略的软限制信号,如果不处理,你会静悄悄地丢掉大量数据,最后发现关注列表只抓到了一半。
def fetch_following(session, user_id): page = 1 result = [] while True: resp = session.get(API_FOLLOWING, params={"user_id": user_id, "page": page}, timeout=20) action = handle_response(resp, None) if action != "ok": break items = resp.json().get("items", []) if not items: time.sleep(10) resp = session.get(API_FOLLOWING, params={"user_id": user_id, "page": page}, timeout=20) items = resp.json().get("items", []) if not items: break result.extend(items) page += 1 time.sleep(random.uniform(2.0, 3.0)) return result6.3 作品列表与多页作品的解析
拿到画师 ID 之后,逐个拉作品列表。这里有个容易踩的坑:作品列表里返回的图片信息,往往不包含全部页,有些站点只给第一页的缩略图,完整页列表需要再请求一次作品详情。我一开始就是只用了列表里的数据,结果下载下来全是只有第一页的残图,还以为是站点限制了,绕了一大圈才发现要二次请求详情。
详情请求要不要单独限速?要。我把它和列表请求放在同一个限速桶里,共享那个每分钟 20 次的上限。因为详情请求的数量跟作品数同级,如果放开,请求量会翻倍。
解析出来的数据结构我统一成一个字典,包含作品 ID、页数、每页的图片直链、标签、发布时间。图片直链里往往带一个尺寸参数,比如_master1200或者_p0这种后缀,要保留原样,不要自己拼接——自己拼很容易拼错路径规则,直接用它给的链接最稳。
6.4 下载与断点续传的正确姿势
下载环节我踩过最惨的坑是“文件写了一部分然后程序崩溃”,留下一个半截的图片文件,下次跑的时候因为文件名已存在就跳过了,于是本地永久存了一张坏图。解决办法是标准的临时文件加原子重命名:先写到xxx.jpg.part,写完之后校验一下接收到的字节数是否等于响应头里的Content-Length,一致才os.replace成正式文件名。这样任何中途崩溃都不会污染正式文件,下次跑还会重新下。
def download(session, url, dest, referer): part = dest.with_suffix(dest.suffix + ".part") dest.parent.mkdir(parents=True, exist_ok=True) with session.get(url, stream=True, timeout=60, headers={"Referer": referer}) as r: r.raise_for_status() expected = int(r.headers.get("Content-Length", 0)) written = 0 with open(part, "wb") as f: for chunk in r.iter_content(64 * 1024): if chunk: f.write(chunk) written += len(chunk) if expected and written != expected: part.unlink(missing_ok=True) raise IOError(f"字节数不符 {written}/{expected}") os.replace(part, dest)区块大小选 64KB 是个折中:太小会导致写盘次数过多,太大在慢链路上不好控制进度。校验Content-Length这一步不能省,尤其是图片这种二进制文件,截断了肉眼看不出问题,打开才发现下半截是灰的。
6.5 增量更新与去重
去重我用 SQLite 存一张表,主键是(artwork_id, page)。每次下载前查一下是否存在,存在就跳过。这样第二次运行的时候,只处理新增的作品,几百个画师的列表请求照发(因为要知道有没有新作品),但图片请求几乎为零,几分钟就跑完。
表结构很简单:
CREATE TABLE IF NOT EXISTS downloaded ( artwork_id TEXT NOT NULL, page INTEGER NOT NULL, artist_id TEXT NOT NULL, path TEXT NOT NULL, size INTEGER, ts INTEGER, PRIMARY KEY (artwork_id, page) );用数据库而不是“检查文件是否存在”的好处是:文件可能被你手动删掉、移动、改名,但数据库里有记录,你能知道曾经下过什么、路径原本是什么。我通常会定期做一个一致性检查,用数据库里的记录去核对磁盘上的文件,发现缺失的就标记成待重下。这个检查脚本只有二十来行,但非常实用。
7. 常见问题速查与踩坑实录
7.1 症状与处理对照表
| 症状 | 大概率原因 | 处理办法 |
|---|---|---|
| 启动即报证书错误 | 本地证书包过旧或不完整 | 更新证书包,检查系统 CA 配置 |
| 所有请求都返回登录页 | Cookie 过期或域名匹配错误 | 重新导出 Cookie,核对 domain 字段 |
| 图片全变 403 | 缺 Referer 或 UA 被识别 | 补上详情页 Referer,固定请求头 |
| 列表抓一半变空 | 触发软限制 | 延长等待,降低全局速率 |
| 图片是灰色的半截 | 下载中断未校验 | 加 Content-Length 校验与原子重命名 |
| 目录里出现重复画师 | 用了昵称做目录名 | 改用 ID 做前缀 |
| 程序卡死不退出 | 超时未设置 | 所有请求强制 timeout |
| 进度条卡住不动 | 单线程阻塞在某个下载 | 加单任务超时,超时转入失败队列 |
7.2 几个没写在任何文档里的细节
第一个,目录里的文件数量太多会导致某些操作变慢。几百个画师如果都平铺在一个目录下还好,但单个画师上千个文件时,文件管理器打开会明显卡顿。我的处理是把单个画师的文件量做软上限,超过之后按年份分子目录。这个改动不大,但日常使用体验好很多。
第二个,不要相信任何一次跑完的结果。长任务我习惯在结束之后做一次完整性抽查,随机抽十个画师,核对数据库记录数跟目录文件数是否一致。有偏差就说明中途出了静默错误,这种错误不会报异常,只会悄悄少文件,不抽查根本发现不了。
第三个,日志要记请求 URL,但不要记完整 Cookie。排查问题时最需要知道的是“哪个 URL 在什么时候返回了什么码”,而 Cookie 属于敏感信息,日志里写出去就有泄漏风险。我的日志格式是时间 | 状态码 | 耗时 | URL,足够了。
第四个,多页作品下载时,页与页之间加一个小延迟(0.2 秒)。因为多页作品的图片往往在同一个 CDN 上连续请求,不加延迟时瞬时并发看起来很不自然。加了之后 403 的比例明显下降。
7.3 关于“别把工具用成负担”的一点体会
我一开始特别执着于“全自动、无人值守、跑完自动通知”。为此折腾了很久的自动登录、自动重试、自动恢复。后来发现,这套东西的维护成本远高于它节省的时间——每次站点改点什么,我就得花一晚上修,修完还提心吊胆地怕哪里埋了雷。
后来我把它改成“半自动、有人看着、出错就停”的模式,反而用得最舒服。每周找一次空闲时间,手动登录一次,导一次 Cookie,启动任务,跑的过程中偶尔瞄一眼日志。真出问题了,停下来看看,改一改再跑。这种节奏下,工具回归到它应有的位置——一个帮我省力的东西,而不是一个需要我伺候的东西。如果你也打算搭类似的系统,我建议一开始就接受“半自动”这个设定,把精力花在错误分类、断点续传、增量更新这些真正提升可靠性的地方,而不是花在跟登录环节死磕上。前者让你的工具越用越稳,后者让你的工具越改越脆。