做网页变更监控,我在“降噪”上踩过的 7 个坑

网页变更监控这个东西,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 是给工程师看的中间产物,不是给用户的最终产品。中间隔着一层"这意味着什么"。


回头看,顺序应该是这样的

如果重来一次,我会按这个顺序做:

  1. 选择器裁剪------投入产出比最高,一条 CSS 选择器顶十条正则。
  2. 抓取条件固定------同一监控项绑定同一出口,先把变量减少。
  3. 检测/通知分层------趁代码还小的时候拆,晚了就拆不动了。
  4. 每项独立的忽略规则------全局规则只保留绝对安全的那几条。
  5. 关键词优先于阈值------业务意图 > 统计特征。
  6. 变更摘要------最后一公里,但对体验影响最大。

结构化 diff、树编辑距离这些我一直没上。倒不是不好,是前面六条做完之后,剩下的误报已经少到不值得为它加一个 O(n²) 的算法了。典型的过早优化陷阱,我差点掉进去。

如果你也在做类似的东西,欢迎在评论区聊聊你踩的坑------我最想知道的是有没有人真的把结构化 diff 跑到生产环境里并且觉得值。

相关推荐
果汁华1 小时前
CLI 命令行与 Python 框架实战
git·python·github
Maiko Star2 小时前
FastAPI 进阶三部曲:中间件、依赖注入与 ORM 实战
python·中间件·fastapi
MrDJun2 小时前
长期稳定跑网页监控:TLS 指纹、代理选路与请求节流的工程实践
运维·爬虫·python·网络协议·网站监控
m沐沐2 小时前
【计算机视觉】OpenCV 物体跟踪——原理、算法与CSRT跟踪器实战
人工智能·python·深度学习·opencv·算法·计算机视觉·人脸识别
大数据魔法师2 小时前
AI Agent - OpenAI从零开始完整学习教程(零基础入门+实战落地)
python
卷无止境2 小时前
Python的魔术方法:那些藏在双下划线背后的魔法
后端·python
米码收割机2 小时前
【Python】Django恒达科技门户网站(源码+文档)【独一无二】
数据库·python·科技
卷无止境3 小时前
别再被"乱码"吓到了:Python文件操作的门道
后端·python
STLearner3 小时前
ICML 2026 | LLM×Graph论文总结[2]【Graph4LLM,Graph4Agent,智能体记忆(Memory)
大数据·人工智能·python·深度学习·学习·机器学习·数据挖掘