☰
SiteMonitor资讯监控实战:轮询、正文提取与变化检测
2026/10/13 16:13:32 网站建设 项目流程

简介:SiteMonitor是一款面向网站运营者、市场调研人员及个人用户的网站资讯监控工具,主要解决多站点内容更新与关键词变化难以及时掌握的问题。资源包内含3个文件,共10.51MB,其中包括Windows安装程序(exe)、安装升级必读说明(html)以及配置建议文档(txt),可帮助用户完成软件部署、版本更新及监控参数定制。工具支持同时监控多个网站,提供更新监控与关键字监控两种模式,并可通过微信接收提醒,适合用于竞品动态追踪、品牌舆情关注或兴趣内容订阅。目前该资源已有1178人学习下载,文档中对安装升级流程和配置要点均有说明,便于新手快速上手,也能满足有一定经验的用户调整监控频率与提醒策略的需求。通过合理设置,可显著提升网络信息获取效率。

1. SiteMonitor 不是探测存活的,是探测变化的

我最早把 SiteMonitor 当成一台「网站存活探测仪」来用,真正落地才发现,判活容易判变难:一个资讯页活着不代表它更新了,而资讯监控的核心恰恰是内容变化的及时性和准确性。很多人用爬虫定时抓全文、存本地、再人工翻一遍,这本质上只是把「打开网页」从手动变成自动,并没有解决「什么时候值得看」的问题。SiteMonitor 的做法是自动轮询目标页面、提取正文特征、比较前后状态,只在真正出现资讯更新时把结果推给你。适合三类人:需要盯竞品公告和行业政策的运营,负责内部资讯聚合页面的研发,以及所有被「每天手工刷十几个页面」折磨的从业者。先说一个反直觉结论:让 SiteMonitor 少误报,比让它多发现更难。

2. 资讯监控的前置选型:四类页面与两种信息获取方式

2.1 先分清四类监控对象:静态 HTML、RSS、JS 渲染页、JSON API

我在给目标站分类时习惯先问一个问题:这个页面到底是怎么把正文渲染出来的?页面类型决定了后面 80% 的坑。最常见的四类对象,各有各的判定方式和抓取成本。

页面类型典型特征变化判定方式抓取成本
静态 HTML服务端渲染,HTML 里包含完整正文提取正文后做哈希或 Diff低,requests 直接拿
RSS / Atom有 XML 条目,每条带标题、时间、链接比对最新条目的标题和链接最低,但很多站点不提供
JS 渲染页首屏 HTML 是空壳,正文由 XHR 动态注入需要先执行 JS,或直接找数据接口高,涉及无头浏览器或接口逆向
JSON API返回结构化数据,字段清晰对业务字段做哈希或版本号比较低,但接口一变就抓空

静态 HTML 是最省心的对象,requests 拿回来就能用,成本和可靠性都容易控制。RSS 是白送的信息源,解析 XML 比解析 HTML 简单得多,更新时间还写在条目里,监控逻辑可以做成「发现新条目才通知」。可惜现实是很多政策页、招投标站和资讯栏目根本没有 RSS,或者 RSS 延迟明显,只能退回页面抓取。

JS 渲染页是新手第一个翻车点:requests 拿回来的 HTML 只有几行 script 标签,页面正文全在异步请求里。这时候硬做全文哈希,结果永远是「内容没变」,因为变化发生在浏览器内存里,不在响应文本里。常见做法有两种,先抓包看数据接口,把接口 URL 当作监控对象,这是成本最低的方案;接口结构太乱或者有签名校验时,才考虑无头浏览器渲染后抓取。

JSON API 是个特殊形态,很多资讯站的移动端接口比 PC 端还干净,返回的 JSON 里直接有标题、发布时间和正文。这类对象对 SiteMonitor 来说反而是最理想的:字段结构化,不需要做正文提取,直接对业务字段哈希。缺点是接口属于文档外的隐性资产,一旦对方改版,你监控的 URL 可能直接 404,所以要在监控配置里加上接口健康检查。

2.2 为什么默认走轮询:没有 webhook 时的可靠替代

资讯监控的信息获取方式只有两条路:要么等对方通知你,要么你定期去问。第一条路叫 webhook 或推模式,前提是目标站愿意把自己的更新事件暴露给你,现实里几乎没有站点会为一个普通监控者提供这种接口。第二条路就是轮询,这也是 SiteMonitor 这类工具默认采用的模型:按固定周期请求目标页面,比较状态,发现变化就通知。

轮询的好处是通用,只要页面能访问就能监控,不需要对方做任何配合。坏处是时效性受间隔限制,间隔设得越小,请求成本越高,也越容易触发目标站的反爬风控。我一般会把轮询周期设在 5 到 30 分钟之间,按目标站的实际更新频率调,而不是无脑设成 1 分钟。一个每天更新三条的公告栏目,5 分钟轮询和 30 分钟轮询在结果上差别不大,但请求次数相差六倍。

轮询方案里,变化判定的核心不是「重新抓全文」,而是「比较特征值」。特征值选错,后面所有逻辑都会跟着错。常用的特征候选有四个:正文哈希、正文长度、标题、HTTP 响应头里的 Last-Modified 和 ETag。Last-Modified 最容易拿,但很多站根本不返回这个头,或者返回的是 CDN 缓存时间而不是内容更新时间,拿来当判定依据会漏报。ETag 同理,它是资源版本的标识,不一定是内容变化的标识。正文哈希最稳,但前提是你先把正文提取干净,否则导航栏里一个随机的验证码图片就会让哈希天天变。

实际项目里,我习惯把特征值做成可配置项。默认用「正文哈希」,每个目标站可以单独指定是否启用标题优先模式。比如某些站正文里嵌了实时股票代码,正文哈希必然波动,这时候只看标题反而更可靠。选型阶段多花十分钟确认对象类型,后面少加三天的班调试误报。

3. 把 SiteMonitor 拆成三个模块:抓取、正文提取与哈希判定

3.1 抓取模块:带超时与指数退避的 Requests 骨架

抓取是整个监控链路的地基,地基不稳后面全是玄学。这里给一个可以直接抄走的 Python 抓取函数,核心就三件事:设置 User-Agent、控制超时、失败后指数退避重试。这三个参数看着不起眼,却是新手最容易踩的地方:不设 UA 会被部分站直接拒绝,不设超时会让工单阻塞一整轮轮询,不重试则一次网络抖动就能造成漏报。

import time import requests def fetch_url(url, timeout=10, retries=3, user_agent=None): headers = { "User-Agent": user_agent or ( "SiteMonitor/1.0 (兼容常见资讯站点)" ), "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", } for attempt in range(retries): try: resp = requests.get(url, headers=headers, timeout=timeout, allow_redirects=True) resp.raise_for_status() # 部分站不返回 charset,requests 会按 latin-1 解码导致中文乱码 if resp.encoding is None or resp.encoding.lower() in ("iso-8859-1", "ascii"): resp.encoding = resp.apparent_encoding return resp.text, resp.status_code except requests.RequestException as e: if attempt == retries - 1: # 最后一次失败不抛出,由上层记录日志并跳过本轮 return "", 0 time.sleep(2 ** attempt) # 退避序列:1s、2s、4s return "", 0

逻辑说明:函数把网络异常和 HTTP 错误码统一收敛成空字符串返回,调用方不需要处理请求层的异常分支,只需要判断返回值是否为空。指数退避用2 ** attempt生成 1 秒、2 秒、4 秒的等待序列,既给对端留出恢复时间,也不会因为连续快速重试把临时故障放大成反爬封禁。

参数层面,timeout 建议 10 到 15 秒,资讯站响应普遍不快,设太短会误伤慢站,设太长会拖住整个轮询队列。retries 建议 2 到 3 次,重试次数再多意义不大,反而让单轮耗时不可控。note 一点,如果同时监控的站点超过 20 个,不要用线程池无脑并发,而是限制并发数在 3 到 5,否则很容易被目标站的安全策略盯上。

3.2 用内容哈希代替全文 Diff:规范化与判定逻辑

拿到 HTML 之后,下一个问题是「怎么判断变了」。很多人第一反应是用 Diff 算法比较两版文本,这在资讯监控里其实是过度设计。Diff 能告诉你哪里变了,但 SiteMonitor 只需要知道「变没变」,这个需求用哈希就能满足,速度还快两个数量级。

import re import hashlib def normalize_html(html: str) -> str: # 剥掉动态脚本和样式,避免轮询时因随机 token 误报 html = re.sub(r"<script[\s\S]*?</script>", "", html, flags=re.IGNORECASE) html = re.sub(r"<style[\s\S]*?</style>", "", html, flags=re.IGNORECASE) # 去掉 HTML 注释 html = re.sub(r"<!--[\s\S]*?-->", "", html) # 常见的静态资源版本参数,例如 style.css?v=20240101 html = re.sub(r"(\?|&)v=[0-9a-zA-Z_-]+", "", html) return html.strip() def content_hash(html: str) -> str: normalized = normalize_html(html) return hashlib.sha256(normalized.encode("utf-8", errors="ignore")).hexdigest()

逻辑说明:normalize_html 做的事叫「内容规范化」,目的是把不影响资讯内容的页面噪声去掉。script 和 style 标签里经常带时间戳、随机 ID、统计代码,如果不剥掉,两次抓取内容完全相同但哈希不同,就会产生一次假的「变化」。用 SHA-256 做哈希,碰撞概率在实际工程中可以忽略,而且不管页面多长,算完都是一个固定长度的十六进制串,存数据库、做比较都方便。

参数说明:这里的替换规则是通用版本,实际部署时建议按目标站单独加规则。比如有的站在正文里嵌了「当前在线人数」,那就得把对应的标签或文本模式也加进正则。有一个原则:规范化规则宁可多,不可少,因为少一条规则就多一类误报来源。另外注意errors="ignore"的作用,是防止个别站的编码声明与实际内容不符时,编码异常打断整个哈希计算。

3.3 正文提取:文本密度法剥掉导航、广告与空白

哈希判定虽然快,但哈希的对象如果包含导航栏、页脚、广告位,这些位置的微小变动就会造成误报。所以规范化之后,最好再往前一步:把正文区域单独切出来。这里提供一个教科书级的轻量做法,叫文本密度法,核心逻辑很简单:正文区域的文本密度比导航和广告高出很多。

import re def extract_main_text(html: str, min_line_len=40): # 先整块删除对正文无贡献的标签 for tag in ("script", "style", "nav", "footer", "header", "aside", "noscript"): html = re.sub(rf"<{tag}[\s\S]*?</{tag}>", "", html, flags=re.IGNORECASE) html = re.sub(r"<!--[\s\S]*?-->", "", html) # 块级标签转换为换行,把正文拆成多个文本块 html = re.sub(r"<(p|br|div|li|h[1-6]|tr)[^>]*>", "\n", html, flags=re.IGNORECASE) text = re.sub(r"<[^>]+>", "", html) lines = [ln.strip() for ln in text.split("\n") if ln.strip()] # 贪心合并连续的长行,取最长的那一段作为正文 best, current = [], [] for ln in lines: if len(ln) >= min_line_len: current.append(ln) else: if len(current) > len(best): best = current current = [] if len(current) > len(best): best = current return "\n".join(best)

逻辑说明:算法先把明确不可能是正文的标签整块删掉,然后在块级标签处插入换行,把 HTML 变成纯文本行。接着按行长度做贪心合并:连续的长行大概率是正文段落,短行会被跳过。最终返回最长连续行块,那就是正文区域。这套逻辑对大多数资讯站是够用的,不需要引入重量级解析库,几百行就能跑通。

参数说明:min_line_len 是最小行长度,默认 40 个字符,移动版页面或短新闻页可以调到 20。注意文本密度法的盲区:表格型页面、列表型导航、图文混排的页面里,正文行可能被图片标签打断,导致「最长连续块」切到错误位置。我一般对这个函数做两处补强:参数里加一个min_block_len,低于这个长度的候选块直接弃用;另外对提取结果为空的情况单独处理,见第 5 章避坑清单。

4. 通知链路与轮询节奏:告警不发疯的两道保险

4.1 多渠道通知:机器人 Webhook 是性价比最高的接入方式

正文变化被识别出来后,下一步是把结果推给人。邮件是最传统的渠道,配置简单,但经常被企业邮箱的反垃圾策略吞掉,或者因为手机没开邮件推送而延迟几小时。我现在的默认选择是群机器人 Webhook,钉钉、企业微信、飞书都提供类似接口,结构上大同小异,一个通用函数就能覆盖主流场景。

import requests def push_webhook(webhook_url: str, title: str, text: str, msgtype: str = "markdown") -> bool: # 钉钉 / 企业微信 / 飞书均兼容此结构,差异在加签字段 payload = { "msgtype": msgtype, "markdown": { "title": title[:50], "text": text[:2000], }, } try: resp = requests.post(webhook_url, json=payload, timeout=5) data = resp.json() # 三方机器人的成功返回码都是 0 return resp.status_code == 200 and data.get("errcode") == 0 except requests.RequestException: return False

逻辑说明:函数把消息体构造成 markdown 格式,title 和 text 都做了长度截断,防止正文过长把机器人接口打爆。返回值的判断同时检查 HTTP 状态码和业务返回码,避免出现「HTTP 200 但实际发送失败」的假成功。

参数说明:msgtype 支持 markdown 和 text 两种,资讯通知建议 markdown,因为可以把更新来源、时间、链接都排版在一张卡片里。部分机器人在群配置里开了「关键词校验」,要求消息正文必须包含指定关键词,碰到这种情况就把关键词拼在 text 开头。还有一点经验:通知模块也要做重试,但重试次数控制在 1 到 2 次,因为通知失败最多是漏报一条,不值得像抓取那样重试 3 次。

4.2 轮询间隔与请求成本:按目标更新曲线设定周期

轮询间隔是最影响长期运行体验的参数,设短了打扰自己,设长了漏消息。我给目标站设间隔之前,会先花两三天观察它的更新规律,再套下面这张经验表。

目标类型建议轮询间隔说明
行业政策、招投标公告5-15 分钟更新时间跳跃,漏看的代价高
自媒体、资讯聚合站15-30 分钟实时性要求一般,请求频率要克制
内部系统状态页1-5 分钟需要最快感知故障和恢复
冷门公告、通知栏30-60 分钟别给对方服务器添负担

这个表不是金科玉律,真正的调参依据是「目标站多久更新一次」。一个每天 18 点固定更新的栏目,你在白天 5 分钟一次疯狂轮询是纯浪费;一个不定时发布重要公告的站点,间隔拉长到 1 小时确实可能漏掉关键信息。另外,间隔下限不要低于 30 秒,高频访问对多数内容站都不友好,一旦触发风控,轻则验证码,重则封 IP,监控直接瘫痪。

执行层面有个细节:每一次轮询的开始时间应该从「上一轮结束」开始计算,而不是从零点对齐。用sleep(interval - elapsed)的方式做单循环,比 cron 定时更稳。原因很简单,cron 是固定时刻触发,如果抓取本身耗时 90 秒,cron 周期又是 60 秒,任务就会无限堆积,实际监控延迟越拖越大。

4.3 确认轮次与持久化状态机:让一条资讯只告警一次

通知风暴是 SiteMonitor 上线后最常见的投诉:一条更新触发七八条告警,因为页面每次轮询都返回同样内容,而监控逻辑只比较「变没变」,不比较「这条变化是不是已经通知过了」。解法是引入一个有状态的状态机,并把它持久化到磁盘。

import json import os class ChangeState: def __init__(self, path="site_state.json"): self.path = path self.records = self._load() def _load(self): if os.path.exists(self.path): with open(self.path, "r", encoding="utf-8") as f: return json.load(f) return {} def _save(self): tmp = self.path + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(self.records, f, ensure_ascii=False, indent=2) os.replace(tmp, self.path) def check_change(self, url, current_hash, confirm_rounds=2): rec = self.records.setdefault( url, {"last_hash": "", "round": 0, "notified_hash": ""} ) # 哈希没变,清零累计轮次 if rec["last_hash"] == current_hash: rec["round"] = 0 self._save() return None rec["last_hash"] = current_hash rec["round"] += 1 # 连续多轮观察到同一个新哈希,才认为是可信变化 if rec["round"] < confirm_rounds: self._save() return None # 已通知过的 hash 不重复通知 if rec["notified_hash"] == current_hash: self._save() return None rec["notified_hash"] = current_hash rec["round"] = 0 self._save() return current_hash

逻辑说明:状态机维护三个字段,last_hash 标识最近一次观察到的内容,round 记录同一哈希连续出现的次数,notified_hash 记录最后一条已通知的哈希。check_change 的返回值是 None 表示不通知,返回哈希表示新变化。confirm_rounds 实现了去抖,第一次发现变化时不立即通知,等连续两轮都确认是同一个新哈希才放行,这一招能滤掉广告位和动态时间戳造成的瞬时抖动。

参数说明:confirm_rounds 建议 2 到 3,数值越大误报越少,但通知延迟也会增加一轮间隔时间。例如轮询间隔 10 分钟、confirm_rounds 为 2,实际通知延迟是 10 到 20 分钟,这个要在配置时讲清楚。文件持久化的意义在于,进程重启后状态不丢,否则每次重启都会把重启前的所有页面再告警一遍。

5. SiteMonitor 的避坑清单:从误报到漏报的五个高频事故

5.1 页面没变却报变了:动态时间戳与随机广告位在捣乱

现象:同一个页面两轮轮询,内容完全没有更新,但 SiteMonitor 每次都触发告警,点开通知发现页面正文一个字没变。 原因:页面里存在动态内容,最常见的是首页时间戳、随机推荐位、访问计数器、版本号参数。这些内容每次刷新都不同,直接做全文哈希必然每次都变。 解决:先看「差异块」到底在哪,用 Diff 工具比对两版 HTML,把动态区块的标签或文本模式加入 normalize_html 的剥除规则。同时把 confirm_rounds 设为 2,动态内容可能两到三轮都不同,但真正稳定存在的哈希会被连续确认,误报率能降一个量级。

5.2 页面变了却没报:JS 渲染页只拿到了空壳 HTML

现象:首页标题肉眼可见地换掉了,SiteMonitor 却一声不吭,状态记录里哈希纹丝不动。 原因:requests 拿到的响应是空壳 HTML,正文由页面里的脚本异步请求数据接口后渲染,监控抓到的这部分内容天然不含正文,自然感知不到变化。 解决:打开浏览器开发者工具,切到 Network 面板过滤 XHR 请求,找到真正返回标题和正文的那个接口,把监控对象从 HTML 换成接口 URL。接口返回的是 JSON,直接对里面的标题字段或正文哈希做判定。如果接口签名复杂、没法直接抓,再考虑用无头浏览器渲染,但要注意无头浏览器并发数别超过 2,内存开销很重。

5.3 一次更新刷几十条告警:状态没有持久化

现象:某条资讯上线后,机器人在一个小时内连发十几条相同内容,不是重要的集群故障,只是页面更新了一次。 原因:进程崩溃重启、或状态字典只存在内存里,导致 notified_hash 丢失,SiteMonitor 把同一条变化当成新变化反复通知。 解决:把状态文件落地到磁盘,每次判定后立即写临时文件再原子替换,避免直接覆盖导致文件损坏。另一个办法是在通知里加时间去重,一分钟内同一 URL 的相同哈希不重复推送,两者配合能有效压住通知风暴。

5.4 慢抓取拖垮轮询周期:定时任务与执行时间互相叠加

现象:日志里每轮开始时间越来越晚,最早是 10:00 开始,跑了几天变成 10:20 才开始,监控延迟已经远超设定间隔。 原因:单轮抓取耗时比轮询间隔还长,如果用 cron 按固定周期调度,上一轮还没跑完,下一轮又启动了,任务队列无限堆积。 解决:改用单进程循环,本轮结束之后计算sleep(max(interval - elapsed, 0)),保证两轮之间的间隔是「结束到开始」的间隔。同时给每次抓取加最长执行时间预算,单个 URL 超过 30 秒就标记失败并继续下一项,不让一个慢站拖死整条流水线。

5.5 正文提取失败被误判为删除:空内容不参与比较

现象:目标站在凌晨做了一次改版,第二天所有页面全部触发告警,通知里显示「正文已删除」。 原因:改版后 HTML 结构调整,文本密度法没提取到正文,返回空字符串。空字符串的哈希和之前正常正文的哈希不同,被判定成一次「删除式更新」。 解决:在 extract_main_text 返回结果后加一道防线,结果为空或去除空白后长度小于 20 个字符时,跳过本轮比较并记录日志,同时把「提取失败」和「正文变空」区分开。日志里如果看到连续多轮提取失败,优先检查是不是页面结构变了,而不是怀疑内容被删除。

6. 给 SiteMonitor 加回归样本:把误报扼杀在改代码之前

SiteMonitor 这类工具最容易栽的跟头不是写不出轮询和通知,而是改一个参数之后,把原来好好的监控改坏了。这个坑我踩过不止一次,后来养成的习惯是给每个监控目标建一个样本目录,专门存放标记过的历史页面,用来验证每一次算法或阈值调整。

样本目录的结构可以按站点和时间组织。每个目录里放三份文件:变化前的页面、变化后的页面、以及变化后的第二份抓取。监控逻辑修改后,拿第一对文件跑一轮,应该返回「有变化」;拿第三份文件自比,应该返回「无变化」。这一步就是回归测试,跑通了才允许更新线上代码。做法其实很朴素,一个断言脚本就能兜住。

samples/ site_a/ 2025-02-01_before.html 2025-02-01_after.html 2025-02-01_after_fetch2.html site_b/ before.json after.json after_fetch2.json
def test_site_change_detection(): from site_monitor import detect_change before = open("samples/site_a/2025-02-01_before.html", encoding="utf-8").read() after = open("samples/site_a/2025-02-01_after.html", encoding="utf-8").read() after2 = open("samples/site_a/2025-02-01_after_fetch2.html", encoding="utf-8").read() assert detect_change(before, after) is True assert detect_change(after2, after2) is False

回放这个动作本身不复杂,但价值很大。我经历过一次典型的翻车:上线前改了正文提取的最小行长度参数,没有做样本回放,结果当晚所有带二维码新闻配图的页面全部报变更,因为图片标签打断了长文本块。后来把「任何阈值或算法改动必须回放至少三组样本」写进了自己的开发习惯里,这个坑就再也没出现过。SiteMonitor 的价值建立在它长期安静且准确之上,而这份安静是需要靠回归样本守住的。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询