内容抽取任务最容易出的事故不是报错,而是静默少做。
页面改版,标签层级变了,抽取脚本按旧路径找不到节点,返回空值。任务照常跑完,日志零错误,入库表里那一批字段变成空。三天后有人发现某个参数在系统里查不到,回查才发现从改版那天起就没抽到过。
防范这类问题的思路是:让抽取器在取到值的同时,把"从哪儿取到的"也记下来。改版之后重跑,比较记录与实际,路径不匹配就是改版信号,取不到值就是丢失信号。
下面这套做法只用标准库html.parser,适合在没有浏览器环境的抓取管道里运行。
一、记录两个特征
对每个被抽取的字段,同时记录两样东西:
一个是结构位置。形如div.spec-table > table > tr[2] > td[1],表示它出现在文档树的哪条链上。结构位置对布局改动敏感,适合发现"表格挪了位置"。
一个是锚文本特征。取该节点前面最近的一段有文字节点,做一次归一化后计算短哈希。锚文本对措辞敏感、对布局不敏感,适合发现"这段说明被删了"。
两者组合的判据比单独任何一个稳。只信结构位置,会把"位置没变但内容整块换了"漏掉;只信锚文本,改个标点就误报。
import hashlib import re def normalize_text(s): s = re.sub(r"\s+", "", s or "") return s[:24] def anchor_hash(text): t = normalize_text(text) if not t: return "" return hashlib.sha1(t.encode("utf-8")).hexdigest()[:8]二、一个可运行的遍历器
继承HTMLParser,在遍历过程中维护一条父节点栈,遇到目标标签时输出结构路径与最近的前置文字。这里以最常用的两种字段容器为例:定义列表dl/dt/dd与表格table/tr/td。
from html.parser import HTMLParser class SpecParser(HTMLParser): def __init__(self, wanted): super().__init__() self.wanted = set(wanted) # 要抓的标签名 self.stack = [] self.last_text = "" self.rows = [] def handle_starttag(self, tag, attrs): self.stack.append(tag) def handle_endtag(self, tag): if tag in self.stack: self.stack.remove(tag) def handle_data(self, data): t = data.strip() if t: self.last_text = t上面是骨架:入栈出栈维持路径。真正把字段落下来的是下面这段,在取到值时把三项一起写出去。
def capture(self, name, value): if name in self.wanted: self.rows.append({ "field": name, "value": value, "path": ">".join(self.stack), "anchor": anchor_hash(self.last_text), })三、比对与判定
基线是改版前一次成功抽取的结果,字段为(field, path, anchor)。重跑后逐字段比对,四种情况对应四种处置。
| 情况 | path | anchor | 判定 | 处置 |
|---|---|---|---|---|
| 一致 | 同 | 同 | 正常 | 无需动作 |
| 结构移动 | 不同 | 同 | 布局改版 | 更新映射,抽取继续 |
| 文案改写 | 同 | 不同 | 措辞变化 | 人工看一眼,多为正常 |
| 双双不匹配 | 不同 | 不同 | 内容整块换了 | 阻断入库,报待确认 |
def diff(baseline, current): cur = {r["field"]: r for r in current} alerts = [] for b in baseline: c = cur.get(b["field"]) if not c or not c.get("value"): alerts.append((b["field"], "missing")) continue path_ok = c["path"] == b["path"] anchor_ok = c["anchor"] == b["anchor"] if not path_ok and not anchor_ok: alerts.append((b["field"], "review")) elif not path_ok: alerts.append((b["field"], "moved")) elif not anchor_ok: alerts.append((b["field"], "reworded")) return alerts四、把判定接进任务管道
三类判定建议走不同的严格度。
missing直接阻断该页面的写入。字段取不到而不写,比写一个空值安全得多——空值会覆盖掉上一版正确数据。
moved只发通知。布局改版是常态,阻断会让运维人员习惯性忽略所有告警。
review建议积累到一定比例再触发人工。经验值是同一页面连续两次review,或者全站单日review数超过页面总数的 5%,说明发生了大范围改版,此时应当暂停覆盖写入。
还有一点容易漏:基线本身要随确认过的改版更新,否则每次改版之后,所有页面都会长期报moved,噪声会淹没真实故障。
五、小结
这套校验没有语义理解,只做一件事:把"从哪儿抽到的"变成一份可比对的记录。它的收益集中在改版密集的阶段——布局换了能发现,内容整块没了能拦住。至于抽取本身准不准,那仍然要靠字段级的规则与抽检,两者不互相替代。
(本文由一支长期做抓取管道与数据质量的技术团队整理,欢迎同行交流指正。)