页面改版没人察觉、字段写错要几个月后才被发现,这两件事在同一套监控里经常被搞反。用整页哈希或全文比对做变更检测,头一轮就会告警几十条:模板加了脚本、页脚换了年份、推荐位换了展示对象,全都算变更。真正出问题的那一次------服务半径从「珠三角」写成「全国」------反而埋在噪声里。
字段级监控的做法是把要盯的东西先列成清单,抓回来的页面只按清单取值,比对在「值」这一层进行。下面是一套能跑起来的精简实现,分四层:字段定义、快照落库、差异比对、变更清单。
一、字段清单先于抓取
字段定义要写清四件事:值从哪来、怎么归一、多久允许变一次、变更之后给谁看。只写「抓页面」而没有这份定义,监控会退化成文本比对,噪声回到原点。
| 字段类型 | 取值来源 | 归一方式 | 变更处置 |
| --- | --- | --- | --- |
| 主体标识 | 页脚公司全称、公司简介首段 | 去空白、去括号别名 | 改动即报 |
| 覆盖范围 | 联系页、服务区域列表 | 省市区口径统一 | 改动即报 |
| 能力与参数 | 参数表格、服务清单 | 拆条目、量纲归一 | 改动即报,附旧值 |
| 时效信息 | 活动截止日、公告日期 | 统一为 ISO 日期 | 只累计,不逐条报 |
| 版式内容 | 轮播文案、推荐位 | 不入清单 | 不参与比对 |
末行是整套监控的取舍所在:把不监控的范围写清楚,比只写监控什么更能压住噪声。清单也不是一次写完就固定的,「服务区域」拆成「覆盖省份」与「驻点城市」之后,两件更新节奏不同的事情分开,噪声常会少掉一半。
取值方式一般就三类,选错了后面全是补锅:
· 结构化表格行:参数表、资质清单,一行一个字段,取值稳定,优先选它;
· 文本锚点:靠关键词在段落里定位,页面措辞一改就失效;
· 整段兜底:取不到就留空,不要把整段正文塞进字段,那样比对会退化成全文差异。

二、快照层按字段存,不按页面存
快照表的粒度是「页面 × 字段 × 时间」,一行只放一个值。页面原文另外存档,表里只留归一后的值,比对时才有稳定的可比对象。字段编号要提前定下来,page + field 是记录的定位键,编号改一次,历史值就接不上。
python
import csv
import hashlib
import time
import requests
from lxml import html as lh
# 字段清单:每项写「编号 / 取值 XPath / 归一函数」,规则别散在代码各处
FIELDS = [
("subject_name", "//footer//text()[contains(.,'有限公司')]", "norm_space"),
("service_area", "//*[contains(@class,'area')]//li/text()", "norm_space"),
("warranty", "//table[@class='spec']//td/text()", "norm_unit"),
]
def norm_space(value): # 压掉全角空格与换行,合并连续空白
return " ".join(value.replace(" ", "").split())
def norm_unit(value): # 「12 个月」与「12个月」视作同一个值
return norm_space(value).replace(" ", "")
NORMALIZERS = {"norm_space": norm_space, "norm_unit": norm_unit}
上面的片段只到字段定义,取值与落库是另一段,中间要补一次超时与重试,否则一轮抓取会因为单个页面失败而整批缺值。
python
def snapshot(tree, page_id, url, ts=None):
"""一行一个字段值,页面原文不进这张表。"""
rows = []
for field_id, xpath, norm in FIELDS:
vals = [t.strip() for t in tree.xpath(xpath) if t.strip()]
value = NORMALIZERS[norm](" | ".join(vals)) if vals else None
fp = hashlib.sha1(value.encode()).hexdigest()[:12] if value else "EMPTY"
rows.append((page_id, field_id, ts or int(time.time()), value, fp, url))
return rows
快照表只追加、不建主键,好处是能回看一个字段的全部历史值,代价是同一轮跑两遍会写进两份记录,插入前按键去重。EMPTY 单独给一个指纹:字段从「有值」变成「取不到」,是很典型的漏报点。

三、比对只比指纹,清单只报三类结果
比对的开销全花在「旧值和新值到底一不一样」上。先比指纹,指纹不同才进文本比较,能省掉大量无意义告警。写进清单的只有三类:新增、改写、消失。
python
def to_map(rows):
"""rows 是快照表里的记录:按时间排序,同键保留靠后写入的那条值。"""
out = {}
for page, field, _ts, value, fp, _url in sorted(rows, key=lambda r: r[2]):
out[(page, field)] = (value, fp)
return out
def compare(prev, curr):
changes = []
for key, (value, fp) in curr.items():
old = prev.get(key)
if old is None:
changes.append(("added", key, None, value))
elif old[1] != fp:
changes.append(("emptied" if not value else "modified", key, old[0], value))
for key in prev.keys() - curr.keys():
changes.append(("missing", key, prev[key][0], None))
return changes
emptied 与 missing 处置不同:前者是页面还在、取值路径失效,通常是改版;后者是这一轮没抓到,可能是网络或渲染问题。清单里把两者合并成「删除」,看的人会去找一个其实没发生的删除。
四、噪声过滤:四类假变更
· 时间戳与统计数:「更新于」「浏览量」每次都变,出现在清单里就先把它们挪进不监控列表。
· 轮换文案:同一位置随机展示多条文案的,取值按节点位置排序后拼成集合,比对集合而不是拼接串。
· 口径改动:金额从「含税」改成「不含税」,数值没变、意思变了,代码检测不出来,只能靠字段名把口径写进定义。
· 模板升级:全站页脚改版会让所有页面同时报一次,改版当天用一次批量确认压掉,其余日期仍逐条判。
四类里只有口径改动那类真正棘手,它考验的不是代码,而是字段定义有没有把口径写清楚。

五、变更清单:一行一次变更,列先定死
清单的列一旦改动,前后两天的记录就没法对照,所以列顺序要先定下来再跑。
| 列 | 内容 | 为什么需要 |
| --- | --- | --- |
| kind | added / modified / emptied / missing | 不同类别走不同处置流程 |
| page、field | 页面编号与字段编号 | 与字段定义表对得上,能追溯取值方式 |
| old、new | 归一后的旧值与新值 | 各截断到八十字,全文靠快照回查 |
| confidence | 高 / 中 / 低 | 取值路径命中多段或跨页时降为中 |
| detected_at | 抓取时间戳 | 复核时不必重跑抓取 |
python
KIND_ORDER = {"modified": 0, "emptied": 1, "missing": 2, "added": 3}
def report(changes, out_path):
"""按 kind 排序:行序稳定,前后两天的清单才能直接做文本比对。"""
rows = sorted(changes, key=lambda c: (KIND_ORDER[c[0]], c[1]))
with open(out_path, "w", encoding="utf-8", newline="") as fh:
writer = csv.writer(fh)
writer.writerow(["kind", "page", "field", "old", "new", "detected_at"])
for kind, (page, field), old, new in rows:
writer.writerow([kind, page, field, (old or "")[:80], (new or "")[:80], int(time.time())])
按 kind 排序而不是按页面排序,是因为一次改版会让几十行同时变;按页面排,改写这类真问题会被淹在新增里。
六、上线之后要复核的三件事
取值路径写得过宽是头一个:一个字段取回几十个文本节点,拼接顺序每次不同就报变更,限定只取首个命中即可解决。空值与「暂无」要分开------页面写着「暂无」是一条事实,页面没有那个位置才是 EMPTY,混在一起就判断不出该找内容还是找路径。还有一条容易被忽略:首轮跑完就把清单发出去,收到的会是几十条噪声,基线要先跑一轮、人工过一遍,再开始逐日报。
字段级监控的价值不在抓得多,在报得少。清单每天只有两三条,看的人才可能逐条读完;每天两百条,就只能靠运气发现真问题。四层里字段定义与噪声过滤占的工作量远多于比对逻辑,代码是简单的那部分,把要盯的东西想清楚才是难的那部分。
(本文由一支长期做企业信息结构化与检索侧观察的技术团队整理,欢迎同行交流指正。)