网页变更监控这个东西,demo 阶段和生产阶段的体验差距大到离谱。
demo 阶段:抓两次,比一下 hash,不一样就告警。十分钟写完,跑起来还挺爽。
生产阶段:上线第二天早上,你的手机里躺着 340 条告警,其中 338 条是"页面右下角的在线人数从 1,203 变成了 1,207"。
这篇写的是从第二种状态爬回第一种的过程中,我具体撞过的墙。顺序基本就是踩坑的时间顺序。
坑 1:以为"去掉 script 标签"就够了
最早的降噪逻辑长这样:
python
soup = BeautifulSoup(html, "lxml")
for tag in soup(["script", "style"]):
tag.decompose()
text = soup.get_text()
确实干掉了一大批噪音------内联的 CSRF token、埋点参数、资源指纹全没了。误报率从"每次都报"降到"大概三次报一次"。
然后卡住了。剩下的噪音全在可见文本里:
- "最后更新:3 分钟前"
- "当前 1,203 人在看"
- "剩余 04:31:12"
- "为你推荐" 下面那一排每次都不一样的商品
这些东西 get_text() 一个都去不掉,因为它们本来就是给人看的。
教训:结构降噪和内容降噪是两件事,前者能解决 70%,剩下 30% 得靠后者。
坑 2:正则黑名单写成了全局的
第二版加了正则替换,把时间戳、相对时间、大数字统统替换成占位符:
python
NOISE = [
(re.compile(r"\d+\s*(分钟|小时|天)前"), "<AGO>"),
(re.compile(r"\b\d{1,3}(,\d{3})+\b"), "<NUM>"),
]
误报率确实下来了。然后收到用户反馈:"我监控的众筹项目金额从 1,203,400 涨到 2,800,000,你们没告诉我。"
因为 \d{1,3}(,\d{3})+ 把它替换成 <NUM> 了。这条规则本来是用来杀"在线人数"的,结果把人家的核心指标一起杀了。
教训 :降噪规则必须是每个监控项独立配置的,不能是全局的。同一个正则在 A 站点是降噪,在 B 站点就是数据丢失。
改完之后的数据结构大概是:
python
@dataclass
class Monitor:
url: str
selector: str | None = None
ignore_patterns: list[str] = field(default_factory=list) # 每项独立
threshold: float = 0.0
全局只保留一条绝对安全的规则(ISO 时间戳),其余全部下放。
坑 3:选择器匹配不到时返回了空字符串
这个坑代价最大。
python
def extract(html, selector):
nodes = BeautifulSoup(html, "lxml").select(selector)
return "\n".join(n.get_text() for n in nodes) # 匹配不到 → ""
某天目标站点改版,.price-now 变成了 .price__now。我的选择器匹配到 0 个节点,返回空串。上游 diff 一比:上次 800 字,这次 0 字 → 内容全部删除 → 高优先级告警。
用户收到的通知是"你监控的页面内容被清空了"。用户跑去看,页面好好的。
更糟的是这个状态会持续:第二次检查,空串 vs 空串,没变化,系统安静了。从此这个监控项永久静默------它在"正常运行",但实际上什么都没在监控。
教训 :空结果 和 内容为空 必须是两种状态,走两条不同的路径。
python
class SelectorMiss(Exception):
pass
def extract(html: str, selector: str) -> str:
nodes = BeautifulSoup(html, "lxml").select(selector)
if not nodes:
raise SelectorMiss(selector)
return "\n".join(n.get_text(separator="\n", strip=True) for n in nodes)
然后 SelectorMiss 走"配置失效"告警,和内容变更告警完全分开。这类告警的文案也不一样------"你的选择器可能失效了,请检查",而不是"页面变了"。
坑 4:没意识到"抓取端不稳定"会伪装成"页面变更"
有一批监控项误报率异常高,正则怎么调都压不下去。查了两天才定位到:这些监控项走的是代理池,每次检查随机分配一个出口 IP。
而目标站点:
- 按 IP 归属地展示不同币种的价格
- 对新 IP 展示 cookie 同意横幅,对老 IP 不展示
- 有个 A/B 实验按 IP 哈希分桶
于是"页面变更"里混进了大量"我换了个身份去看同一个页面"。
教训 :出口的稳定性是降噪的一部分。同一个监控项要长期绑定同一个出口。
我后来在抓取结果里加了这几个字段:
python
@dataclass
class Snapshot:
content: str
fetched_at: datetime
exit_region: str # 出口地域
proxy_id: str | None # 具体节点
status: int
render_mode: str # "static" | "browser"
对比两个快照之前先看这几个字段是否一致,不一致就在 diff 里标注"抓取条件已变化"。这一条上线之后,一批查不出原因的历史误报直接闭环了。
坑 5:把"检测"和"通知"写在一起
最初的代码是这样的:
python
if new_hash != old_hash:
send_email(user, f"{monitor.name} 变了")
save_snapshot(new_content)
看起来没毛病。问题在于所有告警策略的调整都要动这段核心逻辑:
- 想加"每天最多告警 3 次" → 改这里
- 想加"变更小于 2% 不通知" → 改这里
- 想加"半夜不推送,攒到早上" → 改这里
- 想加"webhook 失败要重试" → 改这里
改到第四次的时候这个函数已经没法看了,而且每次改都有丢数据的风险------因为检测和通知耦合在一起,通知逻辑里抛异常会导致快照没存下来。
教训:检测层只负责如实记录,通知层负责决定推不推。
python
# 检测层:只管记录,永远不判断"要不要告警"
def check(monitor: Monitor) -> ChangeEvent | None:
snapshot = fetch(monitor)
prev = store.latest(monitor.id)
store.save(snapshot) # 无论如何先落盘
if prev is None or snapshot.digest == prev.digest:
return None
return ChangeEvent(
monitor_id=monitor.id,
prev=prev, curr=snapshot,
ratio=change_ratio(prev.content, snapshot.content),
)
# 通知层:独立消费事件,所有策略都在这里
def dispatch(event: ChangeEvent, policy: AlertPolicy) -> None:
if event.ratio < policy.min_ratio:
return
if policy.in_quiet_hours(now()):
queue.defer(event, until=policy.next_active_time())
return
if rate_limiter.exceeded(event.monitor_id):
return
notifier.send(event)
拆开之后还有个意外收益:历史数据是完整的。用户后来问"能不能把阈值调低重新看看过去一个月漏了什么"------因为检测层什么都记了,这个需求直接查库就行,不用重抓。
坑 6:相似度阈值用错了地方
加了阈值之后,我一开始是这么算的:
python
ratio = 1 - SequenceMatcher(None, old, new).ratio()
if ratio < 0.02:
return # 变化小于 2%,忽略
对长页面这条规则很好用。对短内容它是灾难。
监控一个价格,选择器裁剪之后内容就是 ¥299。改成 ¥259 ------ 4 个字符里变了 1 个,比例 25%,能过。改成 ¥289------只变了 1 个字符里的 1 位,SequenceMatcher 算出来 ratio 是 0.875,变更比例 12.5%,也能过。
看起来没问题?那这个呢:监控一段 3000 字的服务条款,对方偷偷把"不承担责任"改成"承担有限责任"。变更比例 0.2%,被阈值吃掉了。
教训 :阈值是长度相关的,不能一个数字走天下。而且对某些内容,"变了多少"根本不是正确的问题------正确的问题是"变的是哪里"。
后来的做法:
python
def should_notify(event: ChangeEvent, policy: AlertPolicy) -> bool:
# 1. 关键词命中,无条件通知
if policy.keywords and any(k in event.curr.content for k in policy.keywords):
return True
# 2. 短内容(比如价格、库存),任何变化都通知
if len(event.curr.content) < 200:
return True
# 3. 长内容才用比例阈值
return event.ratio >= policy.min_ratio
关键词那条是用户最喜欢的功能,没有之一。"只在出现'售罄'/'恢复供应'/'截止日期'时叫我",比任何自动降噪都精准,因为它直接编码了业务意图。
坑 7:diff 给的是"哪些行变了",用户想知道的是"变成什么了"
技术上我很满意的一版 unified diff:
diff
@@ -14,7 +14,7 @@
<div class="price-wrap">
- ¥299.00
+ ¥259.00
</div>
用户的反馈是:"能不能直接告诉我降价了多少?"
对啊。用户不关心第 14 行,用户关心的是这个变化对他意味着什么。
于是在 diff 之上加了一层"变更摘要",规则很土但极其有效:
python
PRICE_RE = re.compile(r"[¥$€¥]\s*([\d,]+(?:\.\d{1,2})?)")
def summarize(event: ChangeEvent) -> str:
old_p = PRICE_RE.search(event.prev.content)
new_p = PRICE_RE.search(event.curr.content)
if old_p and new_p:
o = Decimal(old_p.group(1).replace(",", ""))
n = Decimal(new_p.group(1).replace(",", ""))
if o != n:
pct = (n - o) / o * 100
arrow = "↓" if n < o else "↑"
return f"价格 {arrow} {o} → {n}({pct:+.1f}%)"
added = sum(1 for l in event.diff_lines if l.startswith("+"))
removed = sum(1 for l in event.diff_lines if l.startswith("-"))
return f"新增 {added} 行,删除 {removed} 行"
通知的标题用摘要,正文才放 diff。打开率肉眼可见地上去了。
教训:diff 是给工程师看的中间产物,不是给用户的最终产品。中间隔着一层"这意味着什么"。
回头看,顺序应该是这样的
如果重来一次,我会按这个顺序做:
- 选择器裁剪------投入产出比最高,一条 CSS 选择器顶十条正则。
- 抓取条件固定------同一监控项绑定同一出口,先把变量减少。
- 检测/通知分层------趁代码还小的时候拆,晚了就拆不动了。
- 每项独立的忽略规则------全局规则只保留绝对安全的那几条。
- 关键词优先于阈值------业务意图 > 统计特征。
- 变更摘要------最后一公里,但对体验影响最大。
结构化 diff、树编辑距离这些我一直没上。倒不是不好,是前面六条做完之后,剩下的误报已经少到不值得为它加一个 O(n²) 的算法了。典型的过早优化陷阱,我差点掉进去。
如果你也在做类似的东西,欢迎在评论区聊聊你踩的坑------我最想知道的是有没有人真的把结构化 diff 跑到生产环境里并且觉得值。