ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

平台雷达PLFM_RADAR:从零构建页面变更监控与告警收敛实践

平台雷达PLFM_RADAR:从零构建页面变更监控与告警收敛实践

花名叫 PLFM_RADAR 的东西,一开始其实只是我在周五深夜写的两百多行 Python 脚本。当时业务团队在做一个平台侧的大改版,发布后第二天就有用户反馈说“活动入口找不到了”,而我们是过了一整天才从工单里发现的。那种滞后感很折磨人:不是没有反馈渠道,而是反馈太多、太碎,没人能第一时间看出“平台到底发生了什么变化”。PLFM 就是 Platform 的缩写,RADAR 是雷达,合起来就是我们内部对它的定位——一个不停扫描平台公开动态、帮我们发现异常与机会的小系统。

这套东西能做什么,简单说就三件事:定期采集目标页面的可见内容,识别内容是否发生实质变化,再把变化按重要程度推给该看的人。它不替代监控告警平台,也不做大而全的数据分析,它只解决一个很具体的问题:在平台页面、公告、榜单、配置类文本悄悄变化的时候,第一时间有个人能收到一条说得清原因的消息。最适合它的场景是自己维护的官方网站、活动页面、公开配置页、招聘页,以及那些你不完全信任对方更新及时性的第三方合作页面。

  1. 为什么需要一个“平台雷达”:先讲一段救火经历

1.1 一场让我们集体改版的线上事故

那次事故说起来并不复杂:运营同事在后台修改了一个 Banner 跳转地址,保存时误选成了旧版本配置,导致活动落地页整体降级。更麻烦的是,这个动作没有触发任何内部告警,因为监控体系盯的是接口错误率和服务器性能,页面长什么样、配置改没改对,根本不在覆盖范围内。

事后我们复盘时列了一串问题:改版后新的页面文案什么时候上线?首页热区的内容是不是符合预期?合作方页面是否还在包含我们的产品入口?同行业竞品的公开活动页是不是又变了?每个问题都有人能回答,但每个人都是事后翻工单、翻聊天记录才拼出真相。于是我想得很清楚:必须有一个东西,把“平台侧发生了什么变化”这件事从人肉巡检变成自动感知,PLFM_RADAR 就是冲着这个缺口去的。

1.2 雷达要监测的四类信号

第一类是自有页面的完整性,包括官网首页、落地页、公告页的标题和关键区块,防止配置化上线后页面出现大面积降级。第二类是内容型页面的时效性,比如活动规则、版本更新说明、价格表这类静态公开页面,标题不变但正文悄悄改动,往往意味着大事。第三类是合作方或第三方公开页面的入口存在性,防止别人的页面上悄悄撤了我们的入口。第四类是关键词层面的热度与风向,从公开搜索框的联想词、热门列表的变化中感知用户关注点的转移。

这四类信号有一个共同特点:它们都藏在公开可见的文本里,不需要任何特权接口就能拿到。所以我在设计之初就定了一个原则:只采集公开页面,不做模拟登录,不破解加密参数,请求频率克制到不会给对方造成任何压力。合规和稳定永远是第一位,雷达不是爬虫,也不是攻击工具,它只是一个有耐心的观察者。

1.3 为什么选了“雷达”这个比喻

监控平台有很多种叫法,有人叫巡检测试,有人叫变更感知,我最后坚持叫 RADAR,是因为雷达的工作方式和这个系统最像:它不停朝着一个方向发射探测信号,然后对比反射回来的信号与上一帧的差异,差异超过阈值就锁定目标。PLFM_RADAR 的探测信号就是 HTTP 请求,返回值就是页面文本,而我们把“上一帧”存进了数据库,每次拿到新快照都做一次比对,发现偏差就触发告警流程。

这个比喻不只是名字好听,它直接影响了我对系统架构的取舍。雷达不会在同一片海域来回用最大功率扫一百遍,它会把扫描周期和精度做成可配置的;雷达屏幕上的信号需要滤掉杂波,否则会一直误报;雷达发现目标后会有跟踪状态,不会同一目标重复报警。对应到代码里,就是轮询频率、页面指纹去噪、告警静默和三态跟踪,后面每一节都会讲到具体实现。

  1. 模块设计与技术选型:采集、判重、存储、触达

2.1 采集层:轮询、协程与克制请求频率

采集层是整个 PLFM_RADAR 的引擎,它决定了你能不能在正确的时间发现变化。我第一版用的是最朴素的串行 for 循环,每个目标页一个 requests.get,拿到结果存库,循环结束就睡觉。这种写法在目标少于 20 个的时候完全没问题,但目标页一旦涉及多个域名、不同响应时间,串行带来的问题就很明显:一个慢页面会把整轮拉长,导致后面的页面采集间隔被拉偏。

后来我改成了 asyncio 加 aiohttp 的并发采集,信号量控制在 5 以内。这样做的原因很实在:并发太低,效率没有质变;并发太高,容易被对方服务器限流,也可能把自己的内网出口打满。信号量 5 对绝大多数公开页面来说是一个既不显得激进、又能明显缩短一轮总耗时的值。我还把超时时间统一设成 8 秒,超过就记一条失败,而不是无限等待。

轮询频率这件事,说实话没有银弹,全看业务敏感度。我对不同页面分了等级:核心落地页 5 分钟一次,公告页 15 分钟一次,合作方页面 30 分钟一次。频率写死是高维护度的来源,所以我把它放在了配置表里,每次调整目标时可以直接改 interval 字段,不用动代码。

2.2 变更识别层:先做减法再做对比

页面原始 HTML 是不适合直接对比的。且不说导航栏、版权信息这些常年不变的区域会稀释真正的变化,光是时间戳、随机参数、统计脚本这类噪音就足够让简单字符串比较变成“每次都在变”。所以我在变更识别上分了两步:先做减法,再做对比。

减法包括两步:用 CSS 选择器把页面里真正关心的区块提取出来,再把区块内的 HTML 标签去掉,只保留文本。比如一个活动页,我提取的是 main 区域里的文字,而不是整份 HTML。这样处理之后,页面指纹就变成了一串干净的文本,再交给相似度算法计算新旧两个版本的差异。选形更清晰,误报率也降了一大截。

对比这一步,我用的不是复杂的机器学习模型,而是最实用的序列比对和相似度计算。常规文本可以用标准库里的 difflib.SequenceMatcher 算 ratio,也可以直接算编辑距离。对于较长文本,我建议先转成词集合,用杰卡德相似系数粗筛一遍,只有粗筛低于阈值时才进精细比对。这个“先粗筛再精算”的设计大大减少了无谓的计算量,实测在几百个页面的规模下,CPU 占用可以忽略不计。

2.3 存储与状态管理:一张快照表搞定

PLFM_RADAR 的状态管理没有引入重型数据库,SQLite 就够了。核心只有一张快照表,字段包括目标 ID、页面指纹、哈希值、内容摘要、抓取时间、状态码。每次采集都往里插新记录,比对时取同一目标最新一条作为参照。

这里有个小设计值得分享:我用的是哈希值做快速判断,把处理好的正文文本扔进 SHA-256,得到 64 位十六进制字符串。只要页面正文没变化,哈希值一定相同;正文有改动,哈希值几乎必然不同。基于哈希判断可以避免每次都跑一次相似度算法,只有哈希不同才需要算“变化程度”。这样一轮下来,99% 的页面都在毫秒级完成比较,真正进入深度比对的是极少数。

状态管理上我引入了三态:NORMAL、CHANGED、ALERT。页面首次采集进入 NORMAL;哈希变化时进入 CHANGED,同时计算变化比例;变化比例超过阈值就升级为 ALERT,触发通知。下一次采集如果内容又回到旧版本,状态回退到 NORMAL,并记录一次回退事件。这套状态机的实现也就一百来行,但有了它,告警才能做到“不是每次变化都叫唤”。

2.4 预警触达层:报警收敛比报警本身更重要

这节要说的经验,是我做监控类项目时被现实教育出来的:报警发散是比不报警更可怕的灾难。PLFM_RADAR 刚跑通时,连续三天每天都往群里丢几十条通知,结果群很快就被全员设置了免打扰,真正的要紧告警反而没人看。

收敛手段我做了三层。第一层叫静默时间,同一个目标触发告警后,进入默认 60 分钟的静默期,期间相同目标的重复变化不再通知,只更新状态。第二层叫相似度门槛,变化比例低于 5% 的页面差异只记录日志,不触发通知,因为这个尺度通常只是排版微调或运营改了两个字。第三层叫合并摘要,把同一轮采集里多个目标的变化合并成一条消息,标题列出“今天有哪些页面变了”,正文再逐一展开。

触达渠道上,我最初接的是邮件,效果很一般,因为大家根本没有看邮件的习惯。后来换成了飞书和钉钉的 webhook 机器人,通过一个极其简单的封装调用:构建 JSON 消息体,POST 到 webhook 地址。这个切换让告警的响应速度提升非常明显,说明工具选型这件事,先进与否不重要,契合团队习惯才重要。

  1. 实操过程:把一个最小可用版本跑起来

3.1 项目目录与配置示例

PLFM_RADAR 的初版代码量不大,但目录结构我一开始就分得很清楚,因为这类小系统最容易死在“所有东西都堆在一个文件里”。我的建议是至少拆成四个模块:采集器负责请求,解析器负责提取正文,比对器负责差异计算,通知器负责发送消息;配置文件独立成 YAML,避免改参数就要改代码。

项目目录大概是这样的:

plfm_radar/ ├── config.yaml ├── main.py ├── collector.py ├── parser.py ├── diff_engine.py ├── notifier.py └── alert.db

main.py 是调度入口,我用的调度库是 schedule,原因是它足够简单,适合这种轻量级轮询任务。一行 schedule.every(interval).minutes.do(job) 就能跑起来,不需要上 Celery,也不需要 Redis 里的任务队列。项目规模再大一倍之前,老老实实用轻量方案就够了,过早分布式是另一种浪费。

3.2 目标配置:一个真实业务场景的写法

config.yaml 是整个系统的地图,里面定义了每个被观察目标的采集规则和阈值。下面是我在真实场景里用过一个配置片段,你可以直接拿去做模板:

targets: - name: "官网首页-主视觉活动区" url: "https://example.com/" type: "html" selector: "main .activity-area" interval_minutes: 5 similarity_threshold: 0.95 silence_minutes: 60 notify: - "feishu_group_hook" - name: "版本更新公告" url: "https://example.com/release-notes" type: "html" selector: ".release-content" interval_minutes: 15 similarity_threshold: 0.92 silence_minutes: 180 notify: - "feishu_group_hook"

selector 是 CSS 选择器,只提取关心的区域。similarity_threshold 是相似度阈值,0.95 代表新旧文本相似度低于 95% 才认定发生了实质变化。这两个参数是误报率的两个核心旋钮,建议每个新目标上线后先观察两天,再根据历史误报情况调整。

3.3 页面快照与相似度判断的实现

采集和比对的核心逻辑,我用一个可以直接运行的最小版本来展示。第一步是拿到页面并提取正文,这里需要注意的是 requests 库默认的 encoding 推断经常出错,在 requests.get 之后最好用 resp.encoding = resp.apparent_encoding 修正一下,否则中文页面容易出现乱码。

import requests from bs4 import BeautifulSoup def fetch_text(url, selector): resp = requests.get(url, timeout=8) resp.encoding = resp.apparent_encoding soup = BeautifulSoup(resp.text, "lxml") node = soup.select_one(selector) if node is None: return "" text = node.get_text(" ", strip=True) return text

第二步是计算哈希并做相似度判断。我用了标准库的 hashlib 负责快速判断,只有当哈希不一致时才计算相似度:

import hashlib import difflib def text_hash(text): return hashlib.sha256(text.encode("utf-8")).hexdigest() def similarity(a, b): return difflib.SequenceMatcher(None, a, b).ratio()

拿到新文本后,先查库里该目标最新的哈希。哈希一致就什么都不做;哈希不一致再算相似度,和配置里的阈值对比,决定是否进入告警流程。这套逻辑只要 30 行左右就可以完整跑通,但它已经是 PLFM_RADAR 的核心发动机了。

3.4 消息通知与告警静默的实现

通知模块我封装了一个简单的 send_message 函数,body 参数凑成 JSON,POST 到 webhook 地址。为了让消息更好读,我固定了一套模板:标题栏写“页面变更提醒”,正文按“目标名、变更前摘要、变更后摘要、差异片段、变更时间”的顺序排列。

静默期的实现并不复杂:快照表里记录每个目标上次告警的时间戳,每次准备发通知前先检查当前时间减去上次告警时间是否超过配置的 silence_minutes,没超过就直接 return。这里有一个容易忽略的细节:静默期过了之后,如果页面从“变化状态 A”变成“变化状态 B”,应该触发一次新告警,而不是继续静默,否则你的告警系统会漏掉连续变化。

def should_notify(target, last_alert_at): if last_alert_at is None: return True elapsed = (time.time() - last_alert_at) / 60 return elapsed >= target["silence_minutes"]
  1. 实战中踩过的坑与排查方案

4.1 页面结构一动就报错,原因让大跌眼镜

我在对接一个活动页面时,采集程序连续报错,Fetch 的时候发现页面状态码是 200,HTML 也能拿到,但 CSS 选择器什么都选不中。查了半天,才发现页面被前端框架改成了 Shadow DOM,内容节点藏在自定义元素内部,BeautifulSoup 默认解析器根本看不到。

解决方式不是让 BeautifulSoup 去穿透 Shadow DOM,而是调整抓取策略:优先用能看到完整 DOM 的 Playwright 无头浏览器。代价是多消耗不少内存和 CPU,所以这个方案只对少数核心页面启用,普通页面仍然走轻量的 requests。这个经历告诉我,解析失败不一定要马上上升到“换框架”,先确认页面 DOM 结构的深度,再决定要不要启用重型工具。

4.2 同一内容反复被判定为新变化

最典型的场景是这样:页面上有一个按时间排序的新闻列表,服务端每次请求都会生成新的时间戳,提取出来的正文虽然文字一样,但中间夹着“刚刚”“3 分钟前”这类动态文本,导致哈希每次都变。

针对这个坑,我的解法是引入一层“停用词过滤”:在提取正文之后,用正则把时间、数字、括号注释等常见噪音替换成占位符,再做哈希。比如 r"\d{1,2}:\d{2}" 这种模式可以覆盖大部分时间戳。不要小看这层过滤,它消灭了大约七成的“无效变更”,是 PLFM_RADAR 能安静运行的重要基础。

4.3 网络抖动制造“幽灵变更”

有一段时间 PLFM_RADAR 老是深夜报警,点开链接一看页面完全正常。查日志发现,采集时目标服务器返回的是一个网关错误页,错误页里包含的文本和正常页面差异巨大,于是系统判定为“页面变更”,傻乎乎地发了告警。

排查后我在采集逻辑里加了一道防线:HTTP 状态码不是 200 的记录,直接标记为 FETCH_ERROR,不进入内容比对。同时,连续三次采集失败的目标会自动进入“暂挂状态”,停止发送错误告警,只保留一条摘要日志。网关错误的页面往往是 502、503,加这个判断成本很低,却能把深夜着凉级别的误报基本清干净。

4.4 数据重复采集导致告警刷屏

另一个让我记忆深刻的 bug 出在调度逻辑本身。当时我以为页面级配置里的 interval_minutes 写的是分钟,但有一个目标写成了 0,schedule 库接到 0 之后就疯狂跑,一小时内积累了上千条快照记录,告警也同步刷屏。

这个问题暴露的是配置校验缺失。后来我在 main.py 启动时加了一个配置检查函数,interval_minutes 必须大于等于 1,selector 不能为空,url 必须是 http 或 https 开头。这几行校验拯救了后续所有粗心大意的配置改动。做监控类系统,我一直信奉一条规则:能拦在数据写入之前的脏数据,绝不等到算了半天之后再处理。

4.5 关键词热度计算的一个经典偏差

PLFM_RADAR 后期增加了关键词热度分析,最初的定义是“某个关键词在目标页面出现的次数”。跑了一段时间后发现,一条出现一百次“免费”的营销页,把另一个出现二十次“免费”的新品公告页彻底压过了,这显然不合理,因为公告页虽然次数少,但每一次出现都是核心语义。

我把统计方式改成了加权热度:位置权重加标题和首段出现的词,重复次数做归一化,不再直接累加。新公式上线后,明显更贴近业务判断。这算是一个小教训:简单计数最容易做,但只有结合实际语义的统计才真正有用。监控工具最终是要辅助人做判断的,如果数字不能和人脑直觉对齐,那这个数字就会慢慢失去信任。

最后说点实在的。PLFM_RADAR 从一开始的临时脚本,到后来稳定跑了半年,最大的收获不是代码量,而是让我把“平台发生变化”这件事的感知周期从天级缩短到了分钟级。做这类小工具,我的建议是不要一开始就想做成大平台,先用最简单的快照加哈希方案跑起来,让业务尝到甜头,再一点点加解析器、加收敛算法、加监控指标。它最核心的价值不是技术上的复杂度,而是让原本靠人肉盯屏的重复劳动,变成了自动、可追溯、知道该打扰谁的系统行为。如果你手上也有类似的页面巡检需求,完全可以照着前面这套设计自己搭一个,成本很低,但用起来会非常踏实。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进