前两天有个做内训材料整理的朋友来问我:他在一个文档分享站上看到自己早年上传的一份讲义,想把文字导出来重新排版,结果按下全选复制,粘进记事本是一堆乱码加空行;按 F12 打开元素面板一看,正文区域干干净净,一个字都没有。他一度以为是浏览器出问题了,换了三台电脑都一样。这种情况在文档分享类站点里其实是常态——正文根本不在 HTML 里,它可能被拆成几十张小图片、可能藏在某个异步接口返回的 JSON 里、也可能用画布整个重绘了一遍。想用 Python爬虫 把这类文档还原成可编辑的文本,靠的不是什么一键神器,而是老老实实做三件事:搞清楚内容以什么形态存在、找到承载它的那个请求、再把拿回来的数据还原成文字和版面。这篇就按这三步走一遍,顺带把我在这类文档解析任务里踩过的坑、调过的参数、写过的拼接脚本都摊开讲清楚。适合已经会写基础 requests 请求、但一碰到"页面看得见、代码抓不到"就卡住的同学;也适合单纯想搞懂浏览器渲染与网络请求对应关系的朋友,哪怕你最后不做爬虫,这套勘察思路对排查任何网页问题都有用。
1. 先别急着写代码:文档内容在页面里的四种存在形态
很多人一上来就requests.get(url),然后print(res.text),发现只有一坨框架代码,就断言"这站反爬"。其实九成情况下不是反爬,是你找错了地方。文档类站点的正文渲染方式就那么几种,先判断清楚属于哪一类,后面所有工作才有方向。
1.1 形态一:真·静态渲染,DOM 里躺着完整的字
这是最省事的一种。页面源码里能直接搜到正文的开头几个字,比如你在文档第一段里看到"本手册适用于……",就去Ctrl+U出来的源码里搜"本手册",能搜到,说明文字就在 HTML 里。
判断方法很直接:关掉浏览器的 JavaScript(或者用curl直接拿一次源码),如果正文还在,就是静态渲染;如果变成"加载中"三个字,那就往下看第二种。对静态渲染的页面,BeautifulSoup 或者 lxml 加 XPath 就够了,提取时注意别按 class 名硬编码,很多站点的 class 是构建时随机生成的(类似class="a8f3d2"),这种选择器下周就会失效。我更倾向于用结构定位——找正文所在的容器,按"第几个子节点""包含最多文字块的节点"这类特征来选。
1.2 形态二:接口异步加载,数据在 JSON 里
这是目前最主流的做法。页面首次加载只给你一个骨架,真正的正文是弹出一个 XHR 请求拿回来的。这时候你在源码里搜不到字,但在 Network 面板里能看见一个返回 JSON 的请求,展开preview就能看到整段文本。
这类接口的价值在于:它返回的往往不是渲染后的 HTML,而是结构化的段落数组,每条带pageIndex、text、style之类的字段。拿到这种数据,你连解析 HTML 的力气都省了,直接遍历数组拼字符串就行。代价是你得把请求参数搞对,后面第 2 节会专门讲参数解剖。
1.3 形态三:图片切块,一页被切成十几张小图
这是最"烦人"但技术上最有意思的一种。站点把每一页渲染成位图,为了防盗和加载速度,再把它横向或纵向切成若干个小块,每块一个 URL。你在页面上看到的是完整一页,是因为前端用 CSS 把这些小图按坐标拼回去了。
怎么快速认出来?两个特征:一是把页面放大到 300%,文字边缘依然是糊的(因为它是位图不是字体);二是在 Network 面板按Img过滤,请求数量会远大于页数,而且 URL 里常带x=0、y=1024、page=3这类参数。碰到这种,你的工作重心就从"解析 HTML"变成了"下载图片 + 按坐标拼接 + OCR 识别",工作量直接翻倍,但也不是不能做。
1.4 形态四:画布重绘与字体映射混淆
少部分站点走得更极端:用 canvas 逐字绘制,或者把正文用一套自定义字体编码后输出,页面上看着正常,复制出来是乱序字母。这种的技术门槛最高,一般需要做字体文件比对(把自定义字体和标准字体做字形轮廓匹配,反推映射表),属于另一篇文章的体量。
我的建议是:遇到第四种,先评估投入产出比。如果只是为了几份文档,人工截图再 OCR 更快;如果是批量任务,才值得啃字体映射。认清形态这一步花的十分钟,能省掉后面十个小时的瞎试。
2. 抓包定位数据源:把"看得见"翻译成"抓得到"
判断完形态,接下来是最关键的一步——找到那个真正承载正文的请求,并且能用代码复现它。这一步做扎实了,后面的代码基本是水到渠成;这一步糊弄过去,你会在"为什么我代码里返回 403、浏览器里却正常"这个问题上耗掉一整晚。
2.1 Network 面板的正确用法:先过滤,再清空,再操作
我的固定动作顺序是这样的,别看简单,每一步都有原因:
- 打开开发者工具,切到 Network,勾选
Preserve log(保留日志)。原因是很多站点在文档加载过程中会跳转或重载,不勾选的话请求记录会被清空,你刚看到的那个接口就没了。 - 按
Fetch/XHR过滤。这一下能把图片、样式、字体这些噪音全部挡掉,剩下的基本都是数据接口。 - 点一下面板左上角的清空按钮,把历史请求清干净。这一步是为了建立因果——清空之后你做的每一个操作,产生的请求都对应这个操作。
- 现在再去操作页面:滚动一屏、点"下一页"、点"展开全文"。观察新冒出来的请求。
- 逐个点开候选请求,看
Response里有没有正文。有,就是它;没有,看Preview里的字段名,有时候正文是加密的字符串,那就得再往前找解密逻辑。
这里有个我踩过的坑:有些站点的正文接口在页面刚加载时就发完了,你清空日志的时候它早跑完了,于是你滚半天也等不到新请求。这种情况改用"先清空,再刷新页面"的顺序,就能把首屏的接口一并抓下来。
2.2 参数解剖:分清哪些是业务参数,哪些是防伪参数
拿到目标请求后,右键Copy as cURL,粘到一个文本文件里,然后逐项拆参数。参数一般分三类:
| 参数类型 | 典型样子 | 处理思路 |
|---|---|---|
| 业务参数 | docId=xxxx、page=3、size=20 | 必须准确,通常能从页面 URL 或接口返回里找到 |
| 会话参数 | token、sessionId、Cookie里的某项 | 由登录态或首屏接口下发,需要先请求一次拿到 |
| 防伪参数 | sign、_t、nonce、timestamp | 由前端 JS 计算,需要看混淆代码或直接调用 |
实践中的判断技巧:把某个参数删掉重发,如果接口报"参数缺失"或者返回空数据,说明它必需;如果删了照样返回正常数据,说明它可能是防伪校验里被服务端忽略的(这种情况比想象中多)。再试着一项项改成明显的错值,看服务端在哪个参数上拦你——拦得最严的那个,往往就是签名类参数。
关于防伪参数,我得说一句实在话:分析签名逻辑属于逆向范畴,工作量大、维护成本高,而且站点一改就全部作废。如果你的需求是偶发性的、量很小的,用浏览器自动化工具(Playwright 一类)在真实页面上下文里执行,让浏览器自己去算签名,反而是更省心的方案——代价是慢,且占用资源多。这个取舍没有标准答案,看你更怕"写不出来"还是更怕"跑得慢"。
2.3 分页逻辑:滚动加载和翻页接口完全是两码事
文档类页面常见的分页有三种,处理方式完全不同:
- 真分页:地址栏或接口里明确有
page=1,2,3,这种最友好,循环递增就行,注意先探明总页数字段在哪个 key 里。 - 滚动加载:页面滚动到底部才触发下一页请求,接口里通常是
offset或cursor参数。这种要做的是先请求第一页,从返回里取出下一页的游标,再带着游标请求,直到返回空数组为止。千万别用固定步长硬猜,游标可能是加密字符串。 - 预加载:进页面时一次性把全文接口都发完,但只在滚动到可视区域时才渲染。这种最好办,你把所有请求的响应都存下来就行,甚至不需要滚动。
判断方法:滚动一屏,看接口里的参数是page递增、offset递增,还是一个看不懂的长字符串在变。第三种是预加载的特征——请求数量在首屏就固定了。
2.4 先用最小请求跑通,再谈封装
我强烈建议在写任何类、任何函数之前,先在 Python 交互环境里用最原始的方式跑通一次单页请求:
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/122.0 Safari/537.36", "Referer": "https://doc.example.com/view/abc123", "Accept": "application/json, text/plain, */*", }) resp = session.get( "https://doc.example.com/api/doc/page", params={"docId": "abc123", "page": 1, "size": 1}, timeout=10, ) print(resp.status_code) print(resp.text[:500])跑通的标准是:状态码 200,返回体里有你要的正文。跑不通的话,按这个顺序排查——状态码 403 优先怀疑 Cookie/Referer/UA;返回 HTML 而不是 JSON 优先怀疑跳到了验证页;返回空数组优先怀疑参数值不对。这个"最小闭环"跑通了,后面加循环、加存储都是机械劳动;跑不通就着急封装,只会把问题藏进层层调用里,越查越乱。
3. 图片型文档的还原:拼图、增强、OCR 三段式
如果第 1 节判断下来是图片切块型,那核心工作就变成了图像处理。这条路我走过几次,工序比想象中多,但每一步都有明确的验收标准,不容易迷路。整体是三步:把碎图拼回整页、把整页处理成 OCR 友好的形态、把识别结果还原成合理排版。
3.1 切片规律:URL 参数里藏的坐标系
切片图的 URL 通常带坐标信息,形如.../slice?page=2&x=0&y=0&w=1024&h=1024。执着的做法是先把一页的所有切片 URL 收集齐(从接口返回里找,或者从 DOM 的 img 标签里提取),然后解析出x、y两个值。
拼接的画布尺寸怎么定?两种算法:一是取所有切片里x + w的最大值作为画布宽度,y + h的最大值作为高度;二是从接口返回的元数据里直接读pageWidth、pageHeight(更可靠,优先用这个)。切片之间往往有 1 到 2 像素的重叠,这是为了防止缩放时出现缝隙,拼接时按x、y直接贴上去即可,重叠部分自然覆盖,不需要特殊处理。
也有站点不用坐标参数,而是把整页图切成固定顺序的若干块(第 1 块、第 2 块……),这时候你只需要按序号排好,做规则的九宫格拼接。判断方法:URL 里是连续的part=1,2,3而不是二维坐标,就看切片的宽高比,如果所有切片等宽等高,基本就是规则网格。
3.2 一版能用的拼接实现
from io import BytesIO from PIL import Image import requests, re def fetch(url, session): r = session.get(url, timeout=15) r.raise_for_status() return Image.open(BytesIO(r.content)).convert("RGB") def stitch(slices, canvas_size): """slices: [(x, y, url), ...]; canvas_size: (width, height)""" canvas = Image.new("RGB", canvas_size, "white") for x, y, url in slices: tile = fetch(url, session) canvas.paste(tile, (x, y)) return canvas # 从 DOM 或接口里拿到的切片信息 slices = [ (0, 0, "https://img.example.com/s?p=1&x=0&y=0"), (1024, 0, "https://img.example.com/s?p=1&x=1024&y=0"), (0, 1024, "https://img.example.com/s?p=1&x=0&y=1024"), (1024, 1024, "https://img.example.com/s?p=1&x=1024&y=1024"), ] page = stitch(slices, (2048, 2048)) page.save("page_01.png", dpi=(300, 300))这段代码里有三个细节值得说。第一,convert("RGB")是必须的,切片有时是带透明通道的 PNG,直接贴到 RGB 画布上会出现黑底。第二,画布底色设成白色而不是默认黑色,因为文档页面本来就是白底,贴不满的地方留白比留黑更接近原样。第三,保存时的dpi参数虽然不影响像素,但会写进 PNG 元数据,后面某些 OCR 工具会读这个值来推断字号,写上没坏处。
3.3 分辨率与二值化:OCR 精度的两个开关
同一个 OCR 引擎,输入质量不同,识别率能差出 20 个百分点。我总结下来影响最大的两个因素是分辨率和对比度。
分辨率方面,经验值是正文小字的字高不低于 20 像素。如果拼接出来的整页图里,一行字的高度只有 10 个像素,那基本没救,得想办法拿到更高清的切片(有些站点支持scale=2之类的参数,值得试一下)。反过来,如果原图分辨率很高但字很小(比如扫描件整页 4000 像素宽),先做一次等比缩放反而更好——OCR 引擎对过大的图会做内部下采样,你自己缩放到合适尺寸能控制插值质量。
对比度方面,很多文档是灰底浅字或者带水印背景,直接丢给 OCR 会大量漏识别。预处理两步走:先转灰度,再做自适应二值化。整页统一阈值(比如 128)对付均匀照明的扫描件够用,但对付有渐变阴影的手机翻拍图就不行了,得用局部阈值。
from PIL import Image, ImageOps, ImageFilter img = Image.open("page_01.png").convert("L") img = ImageOps.autocontrast(img, cutoff=1) # 拉一下对比度 img = img.filter(ImageFilter.SHARPEN) # 轻微锐化,强化笔画 img = img.point(lambda p: 0 if p < 160 else 255) # 统一阈值二值化 # 局部阈值更适合光照不均的情况,用 opencv 的 adaptiveThreshold # import cv2, numpy as np # arr = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2GRAY) # arr = cv2.adaptiveThreshold(arr, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, # cv2.THRESH_BINARY, 31, 10)阈值 160 这个数字不是随便写的。我实测下来,多数文档站导出的图片背景接近纯白(240 以上),文字笔画在 60 以下,中间地带主要是抗锯齿边缘和水印。阈值设在 150 到 180 之间能保住笔画又不引入水印;设到 200 以上,浅色水印会变成实心块,OCR 会把水印当成文字识别进去,反而更糟。
注意:二值化之后一定要人眼看一眼。我吃过一次亏——某批文档的字是深灰色但不是纯黑,阈值 160 一切,笔画被削掉一半,OCR 出来一整页都是"口口口"。后来改成 190 才正常。这类参数没有通用最优值,必须拿样本调。
3.4 OCR 结果的版面还原:比识别本身更费神
开源 OCR 引擎(Tesseract 一类)直接输出的是一行行文本,换行位置由引擎自己判断,结果经常是一句话被拆成三行,或者两段被粘成一段。要让结果可用,得做后处理:
第一,按行框坐标重排。OCR 一般能给出每个文本块的边界框,你可以按y坐标排序得到行的顺序,再按行内x排序得到字的顺序。这一步比直接读纯文本可靠得多。
第二,处理断行。判断逻辑可以简单点:如果上一行末尾没有标点、且下一行开头不是缩进也不是编号,就把两行合并。中文还有个额外信号——行尾如果是逗号、顿号,肯定没说完,必须合并。
第三,标题识别。字号明显大于正文(比如行高是正文的 1.5 倍以上)、且独占一行的,判为标题,前后加空行。这个启发式规则简单但有效,我用了很久没换过。
第四,常见错字映射表。OCR 对中文的典型错误高度集中:一和—、0和O、l和1、巳和己和已。维护一张小映射表做定点替换,能省掉大量人工校对时间。但要注意别过度替换——"第一"里的"一"不能改成破折号。我的做法是只在特定上下文里替换,比如数字后面紧跟的O才改成0。
整套流程跑下来,一份 20 页的文档大概需要 3 到 5 分钟机器时间,加上人工校对 10 分钟。比起逐字重打,还是划算的。但如果只有一两页,我的建议是别写代码了,直接截图丢进现成的识别工具更快——工具是拿来省时间的,不是拿来练习的。
4. 稳定性工程:限速、会话、重试、断点续传
单页跑通只是开始。真正让人抓狂的是批量任务跑到第三百页突然全部 403,或者跑到一半网络断了要重头再来。这一节讲的全是"看起来不酷但少一样就出事"的东西。
4.1 限速不是道德问题,是稳定性问题
我见过太多教程里的示例代码是一个裸的for循环,中间一点间隔都没有。这种写法在大批量任务上必然出事,但不是因为"被封"这么简单——短时间内几十个并发连接打过去,服务端的连接池被打满,你的请求会开始超时、开始返回 502,然后你的重试逻辑又把失败请求重新打一遍,形成雪崩,最后整批任务全部失败。
合理的节奏控制是这样的:单线程串行,每个请求之间sleep一个随机区间,比如 1.5 到 3.5 秒。用随机而不是固定值,是因为固定间隔的请求在服务端看来是极其规律的机器特征,随机间隔至少在流量形态上更接近人工操作。
import random, time def polite_sleep(base=1.5, jitter=2.0): time.sleep(base + random.random() * jitter)如果任务量确实大(比如上千页),正确做法不是调高并发,而是拉长时间窗口——分批次跑,每跑 50 页停几分钟。这个方案听起来笨,但它是唯一稳定的方案。我试过用 8 个线程跑同样的任务,前期速度是串行的 5 倍,跑到 40% 的时候集体卡死,重试也没用,最后老老实实回到串行,总耗时反而更短。
4.2 会话一致性:为什么要用 Session 而不是裸 get
requests.Session()相比直接requests.get(),核心价值是自动维持 Cookie 和连接复用。很多站点的首屏会下发一个短期的会话标识,后续所有接口请求都要带上它,裸 get 每次都是新连接、新 Cookie 罐子,自然对不上。
除了 Session,还有几个头值得固定配置:
| 请求头 | 为什么需要 | 常见错误 |
|---|---|---|
User-Agent | 服务端会按 UA 分流返回不同内容 | 用默认的python-requests/2.x,直接被识别 |
Referer | 部分接口校验来源页,防止外链直取 | 漏配,返回 403 或空数据 |
Accept | 声明期望的数据类型 | 期望 JSON 却发text/html,服务端返回错误页 |
Accept-Language | 影响返回内容的语言版本 | 一般不用管,多语言站点才需要 |
一个实操细节:Referer的值要和接口所属的域名、路径层级匹配。有些站点校验的是"Referer 的域名是否为本站",有些校验得更细,要求 Referer 里包含具体的文档 ID。后者在批量任务里很容易翻车——你把 Referer 写死成第一份文档的地址,抓第二份的时候就会被拦。正确做法是每份文档单独设置对应的 Referer。
4.3 重试策略:区分"能救的错"和"救不了的错"
不加区分地重试所有失败请求,是最常见的错误。有的错重试一百次也没用,还白白拉长任务时间;有的错重试一次就好。
我的分类是这样的:
- 值得重试:连接超时、读取超时、502 / 503 / 504、连接被重置。这类多半是网络抖动或服务端临时压力,退避后重试有效。
- 不要重试:400(参数错)、401 / 403(权限或校验失败)、404(资源不存在)。这些重试只会重复消耗配额,还可能让服务端把你标记成异常流量。
- 要特殊处理:429(请求过多)。这个必须重试,但间隔要显著拉长,最好按返回头里的
Retry-After来等。
退避策略用指数退避加随机抖动,第 n 次重试等2^n * base + random秒,最多重试 3 次。这个公式的道理是:第一次失败可能是偶发,立刻重试大概率成功;连续失败说明对面有持续压力,这时候快速重试是添乱,必须让路。
import time, random, requests def get_with_retry(session, url, params=None, max_retry=3): retryable = {429, 500, 502, 503, 504} for attempt in range(max_retry + 1): try: r = session.get(url, params=params, timeout=(5, 20)) if r.status_code == 200: return r if r.status_code not in retryable: raise RuntimeError(f"不可重试状态码: {r.status_code}") wait = (2 ** attempt) + random.random() time.sleep(wait) except (requests.Timeout, requests.ConnectionError): if attempt == max_retry: raise time.sleep((2 ** attempt) + random.random()) raise RuntimeError(f"重试耗尽: {url}")timeout=(5, 20)这个元组是连接超时和读取超时。分开设的理由:连接超时通常应该短(5 秒连不上,多半是网络不通,继续等没意义),读取超时可以长一些(大文档的接口响应慢是正常的)。全部设成同一个值,要么连接卡太久,要么读取被误杀。
4.4 断点续传:把任务状态落盘
批量任务的必备设计。核心思路很简单:每完成一页,就往一个状态文件里追加一条记录(页码 + 状态 + 时间戳),启动时先读这个文件,跳过已完成的页。
状态文件我倾向用 JSONL(每行一个 JSON 对象),而不是一个大 JSON。原因是追加写方便、中途崩溃不会破坏已有内容、出问题时可以直接tail看最后几行定位。
import json, os def load_done(path="progress.jsonl"): done = set() if os.path.exists(path): with open(path, encoding="utf-8") as f: for line in f: try: rec = json.loads(line) if rec.get("status") == "ok": done.add(rec["page"]) except json.JSONDecodeError: continue # 最后一行可能是写一半崩掉的,跳过 return done def mark_done(page, path="progress.jsonl"): with open(path, "a", encoding="utf-8") as f: f.write(json.dumps({"page": page, "status": "ok", "ts": time.time()}, ensure_ascii=False) + "\n")那个except json.JSONDecodeError: continue是血泪教训。进程被强杀的时候,最后一行经常是写了一半的残缺 JSON,如果不捕获,下次启动整个进度文件都读不出来,前功尽弃。
5. 抓取边界:哪些事能做,哪些事碰了就是自找麻烦
技术手段是中性的,但用在什么地方决定了它是不是合适。这一节不是套话,是我必须说清楚的部分,因为很多人做这类任务翻车不是因为代码写错,而是因为压根没想过边界在哪。
5.1 只处理你有权处理的内容
最安全也最没有争议的场景,是处理你自己创作、自己上传、或者明确获得授权的文档。比如开头那位朋友,他要导出的就是自己早年上传的讲义——这种情况下,技术工作只是在做格式转换,不涉及任何权利问题。
反过来,把别人付费或受限的内容批量抓下来再分发,那就是另一回事了,而且这类行为通常明确违反平台的用户协议,风险不在技术层面。我写这类文章时会有意把重点放在"如何解析页面结构""如何做图片拼接与文本还原"这些通用技能上,因为这套技能在正规场景里有大量用途——企业内部的文档归档系统、把纸质档案数字化、把历史资料转成可检索文本,全都是同一个技术栈。
5.2 频率控制的另一面:别给对方添麻烦
前面讲限速是从"自己任务稳不稳"的角度讲的,其实还有个更基本的角度:你的每一个请求,都要占用对方服务器的一点资源。一次几百个并发请求打过去,小站点可能直接就挂了。这不是危言耸听,是真实发生过的事。
所以我的下限原则是:能少请求就少请求(比如接口支持size=50就别用size=10)、能缓存就缓存(同一份文档别抓两遍)、能白天不跑就晚上跑(避开高峰)。这几条不写在任何规范里,但只要是在别人的服务上取数据,就该这么做。
5.3 数据落地后也别忘了收尾
任务跑完,一堆中间文件(切片图、二值化图、状态文件)留在硬盘上,几百兆很常见。我的习惯是任务结束后清理中间产物,只保留最终文本和必要的日志。一是省空间,二是这些东西里可能包含不该长期留着的内容。养成习惯比事后补救省心。
6. 一份能直接照着查的排错清单
下面这张表是我在多次类似任务里攒下来的,遇到问题先在这张表里对一遍,能省掉大量瞎猜的时间。
| 现象 | 最可能的原因 | 处理办法 |
|---|---|---|
| 源码里搜不到正文 | 异步接口加载或图片渲染 | 转 Network 面板过滤 XHR,或按 Img 过滤 |
| 接口返回 403 | 缺 Cookie / Referer / UA 不匹配 | 用 Session 先访问首屏拿 Cookie,补齐请求头 |
| 接口返回 HTML 而不是 JSON | 被重定向到验证页或错误页 | 检查响应 URL 是否变化,检查参数是否被服务端拒绝 |
| 返回空数组 | 参数值不对(ID 错、页码从 0 开始) | 逐个参数对照浏览器请求,注意页码起点 |
| 前 100 页正常,之后全失败 | 触发频率限制 | 拉长间隔、分批跑、检查是否有 429 返回 |
| 拼接后图像错位 | 坐标解析错或缩放系数没考虑 | 核对x/y/w/h与画布尺寸,注意是否有 scale 参数 |
| 拼接后有黑边 | 切片带透明通道直接贴 RGB 画布 | 先convert("RGB"),画布底色设白 |
| OCR 大量漏字 | 分辨率过低或二值化阈值过高 | 提高源图分辨率,阈值往低调,人眼看二值化结果 |
| OCR 混入水印文字 | 阈值过低导致浅色水印变实心 | 调高阈值,或先做中值滤波去背景 |
| 任务中断后要重头跑 | 没做状态落盘 | 加 JSONL 进度文件,启动时读已完成列表 |
| 重试反而让情况更糟 | 对不可重试错误也做了重试 | 按状态码分类,4xx 类直接失败不重试 |
最后一类坑我觉得值得单独提一句:别在调试阶段开并发。我见过不少人一上来就上线程池,结果日志交错、报错混在一起,根本分不清是哪个请求出的问题。串行跑通全部流程、日志清晰可读之后,再考虑要不要并发——而多数时候你会发现,串行加上合理的限速,速度已经够用了,并发带来的那点提速完全抵不上排查成本。
我在实际操作中的体会是,这类任务的难点从来不在代码本身,代码量其实很小,几百行就能覆盖全流程。真正消耗时间的是勘察阶段——搞清楚数据在哪、参数怎么算、图片怎么拼。这个阶段的投入没法跳过,也没有捷径,但一旦摸清楚,后面就是重复劳动。所以每次接到新任务,我都会先花半小时纯手工把一页从头到尾走通一遍,不写一行代码,全部用浏览器和命令行完成。这半小时走通了,写代码就是二十分钟的事;走不通就急着写,往往要在代码里绕一整天。