我维护着一个每天自动跑十几轮的采集任务数据源是一个十几年前的老站。它有个很要命的特点页面只保留最近三个批次过时就没了——漏一轮那批数据就永久缺失没有补档的地方。跑了几个月真正咬人的不是怎么写解析而是三个沉默的错编码猜错 → 抓回来一堆乱码但程序不报错页面改版 → 解析出 0 条程序开心地写下今天没有数据被限流 → 当成这个页面不存在然后那一批就永远缺了。这三个错的共同点它们都不抛异常。下面是我最后怎么把它们一个个变成会响的警报。一、GBK先搞清楚乱码是从哪来的老站大概率是 GBK/GB18030。乱码通常有三种来源处理方式完全不同。第一种信了响应头。很多 HTTP 客户端比如 requests在resp.text时会按响应头的Content-Type: ...; charset来解码老站经常不写 charset或者写了但和实际字节不一致。这时候resp.text是错的而resp.content永远是原始字节——别用.text除非你已经确认过编码。第二种页面上写了 meta但字节不是那个编码。这种最坑因为它看起来有声明。第三种只有一部分是坏的。比如正文正常、某个字段里混进了另一个编码的内容。整页解码会把\ufffd替换字符混进来你不检查就写进库了。我的做法很朴素defdecode(raw:bytes,hint:str|NoneNone)-str:按 声明 → 探测 → 兜底 的顺序解码解不干净就报警绝不静默写库。forencinfilter(None,(hint,gb18030,utf-8)):try:textraw.decode(enc)exceptUnicodeDecodeError:continueif\ufffdnotintext:# 没有替换字符 这次解码是可信的returntextraiseValueError(解码失败候选编码全部出现替换字符别猜先看原始字节)关键在最后那句解不干净就抛异常。我宁可这一轮的任务红着脸失败也不要往库里写一行看起来正常的问号。顺带一个经验gb18030是gbk的超集直接上gb18030基本能覆盖老站不用纠结到底是 GBK 还是 GB2312。二、结构漂移最危险的不是改版是改版后你还在写老站的 class 名会变今天叫list-item下周可能叫new-list-item但结构往往不动一条记录仍然是一行、仍然由同样几个格子组成。所以锚点我尽量选结构不选样式# 差依赖样式类名改版必挂forliinsoup.select(div.list-item):...# 好靠结构定位行 格子改版时更可能还活着ROW_REre.compile(rtr[^]*data-id([^])[^]*(.*?)/tr,re.S)但真正救命的是断言。解析器必须能区分这个页面今天确实没有记录和我的解析器已经不认识了rowsparse(html)ifnotrows:raiseRuntimeError(解析到 0 条 —— 先确认是页面真空了还是改版了别写空数据)forrinrows:iflen(r)5:raiseRuntimeError(字段数不对期望 5 个格子实际 %d —— 结构变了%len(r))这一段看起来像废话但它救过我一次某天页面换了版式解析器返回 0 条如果当时没断言程序会安安静静地把今天没有比赛写进记录然后那一整批就这么丢了而日志里连个警告都没有。三、限流与退避429、418、521 是三件事采集里最常见的谎话是重试就行。其实要先分类状态含义该怎么办429标准限流退避后重试指数 随机抖动418/403常见于站点前面的 WAF“请求疑似攻击行为”重试基本没用换节奏/换出口并且要报警521/ 502源站抖了退避重试通常几次就过域名解析失败本机网络问题不算作该批不存在下次跑要自动补重试要带抖动否则多个任务会在同一秒一起回来把限流撞得更狠defretry(fn,tries3,base0.5):lastNoneforiinrange(tries):try:returnfn()exceptRateLimitedasexc:lastexcifitries-1:time.sleep(base*(2**i)random.uniform(0,base))# 指数 抖动raiselast最要紧的一条失败必须落进待补绝不能和空混在一起。我的状态文件分两块{done:{p1:3},pending:{p3:FileNotFoundError: 页面不存在p3}}done只记已经拿到的pending记这轮没拿到的。下一轮开始先看pending能补就补。这样断网和这批真的没有就能分开了——采集任务最容易犯的错就是把失败静默成空。四、让任务自己说话每轮一行结论最后一件小事但收益最大每轮结束只打印一行结论。结论: 页面 3 个成功 2 / 失败 1/ 读到 5 行 / 新增 0 条 / 补空 1 个 / 跳过 4 条 → records.csv 累计 5 条待补 1 项下次自动补以前我也写详细日志结果是出问题时没人愿意翻几千行。现在判断任务状态只需要看两类东西结论行本轮干了什么、有没有失败、待补几项启动/结束两个标记是否配对只有启动没有结束就是被中途杀了而不是跑完了没事。配套还有两条纪律幂等只补空、不覆盖已有值。重跑、补跑永远安全所以把它再跑一遍是一个廉价动作而不是一次冒险。状态原子落盘先写.tmp再os.replace。进程被强杀也不会留下半个 JSON。五、小结老站采集的难点不在解析在**“错了但不说”**。就三条判据幂等重跑不会写坏数据失败可区分限流 / 改版 / 断网各走各的路失败落待补不静默成空可判读每轮一行结论人看结论不看日志。对着这三条把一个采集任务放三个月不管它基本还是稳的。