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