解析一个老网站:GBK 编码、页面结构漂移与限流退避

我维护着一个每天自动跑十几轮的采集任务,数据源是一个十几年前的老站。它有个很要命的特点:页面只保留最近三个批次,过时就没了------漏一轮,那批数据就永久缺失,没有补档的地方。

跑了几个月,真正咬人的不是"怎么写解析",而是三个沉默的错:

  1. 编码猜错 → 抓回来一堆乱码,但程序不报错;
  2. 页面改版 → 解析出 0 条,程序开心地写下"今天没有数据";
  3. 被限流 → 当成"这个页面不存在",然后那一批就永远缺了。

这三个错的共同点:它们都不抛异常。下面是我最后怎么把它们一个个变成会响的警报。

一、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。

五、小结

老站采集的难点不在解析,在**"错了但不说"**。就三条判据:

  1. 幂等:重跑不会写坏数据;
  2. 失败可区分:限流 / 改版 / 断网各走各的路,失败落"待补",不静默成空;
  3. 可判读:每轮一行结论,人看结论不看日志。

对着这三条,把一个采集任务放三个月不管,它基本还是稳的。

相关推荐
只睡四小时1 小时前
AI 生成 PPTX:11 页课件编译出 429 个形状
javascript·python·pptx·ai生成ppt·ooxml
leisoo80971 小时前
股票筹码分布怎么用获利比例成本区间与集中度实战 IG50免费开源股票数据API接口
开发语言·jvm·数据库·python·开源
ControlM1 小时前
从官方 CDN 里扒出 TRAE (TraeCode) 历史版本安装包
python·逆向·trae
tianyuanwo1 小时前
Python 属性查找陷阱:从 `AttributeError: ‘X‘ object has no attribute ‘_children‘` 说起
python
程序猿阿森1 小时前
混合精度详解:FP16 vs BF16,Loss Scaling 与 PyTorch 实战
人工智能·pytorch·python
benchmark_cc1 小时前
A股、港股、美股日K如何拼接?处理不同交易日造成的时间轴错位
大数据·python·pandas·量化交易·股票数据·quantdash
龙腾-虎跃1 小时前
AI-Vue3-python-flask-Blog 全栈博客项目深度解析:从零搭建你的 AI 博客
人工智能·python·flask
余槐i1 小时前
无慢查询 RT 却飙升:cProfile 定位 FastAPI 事件循环阻塞
后端·python·性能优化·fastapi·asyncio
Xiu Yan2 小时前
Python 数据分析:数据分析步骤
开发语言·python·数据分析