SiteMonitor实战:用Python实现网站资讯监控与变更检测

发布时间:2026/10/11 16:39:40
SiteMonitor实战:用Python实现网站资讯监控与变更检测
简介SiteMonitor是一款面向企业市场研究、品牌监控及个人兴趣追踪的网站资讯监控工具支持同时监控多个网站能够实时检测页面结构、文本、图片等内容的更新并按需开启关键字监控触发提醒。这份RAR压缩包共包含3个文件其中exe文件为软件安装程序html文件提供详细的安装升级必读指南txt文件则补充了监控频率、提醒方式等配置建议压缩包整体约10.51MB轻量易部署。完成安装后可将竞品站点、行业新闻源和个人关注页面统一纳入监控设置关键词后一旦网站出现更新或命中词即可通过微信接收实时推送快速掌握外部信息变化。配套文档能帮助用户顺利完成部署、升级与参数调优减少试错成本适合需要及时跟踪网站内容的运维人员、运营编辑和独立站管理者。目前已有1178人学习下载对于希望低成本构建网站内容监控方案的读者而言是一份实用性较强的工具包。1. 网站资讯监控不是爬虫是“盯梢”SiteMonitor 要解决什么很多人第一次听到“网站资讯监控工具SiteMonitor”会下意识觉得这就是个爬虫。实际上完全不是一回事。爬虫的目标是“尽量全地抓下来”监控的目标是“只抓变化的那一小块”。你盯着一个行业资讯页、一个公告栏、一个竞品新闻列表真正关心的是“有没有更新”而不是把整个网站镜像下来。SiteMonitor 的定位就是这样一个轻量级盯梢工具周期性地去取页面跟上一轮快照做对比发现值得注意的差异就发通知。它适合运维盯文档更新、运营盯竞品动态、开发盯接口公告这类场景不需要动用重型采集框架也不需要一台常驻服务器最小形态一条 crontab 就能活。这篇文章会从选型、最小实现、持久化到排坑把整条路走一遍。2. 先定方案再写代码SiteMonitor 的核心模块与监控粒度选择2.1 轮询还是推送抓取频率与目标站点特征怎么匹配网站资讯监控最常见的驱动方式是轮询也就是自己定闹钟去访问。另一种是站点主动推送比如 RSS、Webhook但现实中大量资讯页既不提供 RSS也没有 webhook 接口所以轮询仍然是通用方案。SiteMonitor 的第一步就是决定“多久看一次”。这个周期和目标站点的更新规律强相关。更新频繁的科技媒体可以每 5 分钟查一次只看法律条文修订的页面一天一次都嫌多。我一般会先手工观察目标站点几天确认更新峰值时段再定初始频率。频率设定有个容易被忽略的原则宁可漏一次不可每轮都打太重。站点往往有缓存或反爬策略频繁请求会带来封禁风险。常见做法是把初始轮询间隔设为目标站点平均更新间隔的一半比如它平均每小时更新一次那就 30 分钟查一次。再配合条件请求头If-Modified-Since或ETag让服务器在没变化时直接返回 304这样既不浪费带宽也能减少 IP 特征。SiteMonitor 里可以配置request_headers来携带这些字段后续在抓取层会讲到。2.2 变更检测的三种常见做法全文比对、哈希指纹、DOM 结构 diff这是 SiteMonitor 的核心分水岭。你要监控的页面有好几种变化形态正文文字变了、某区块新增一条链接、整个页面重新排版但内容没动、广告区域滚动变化。不同的检测方式对应不同的问题。第一种是全文比对。把两个时间点的 HTML 源码做文本 diff差异大就告警。这个方案最直观但噪声极大。页头的时间戳、访问计数、动态广告位都会导致“假更新”。第二种是哈希指纹。对整个页面或某个 CSS 选择器圈出来的区块计算 Hash比如 SHA-256只要内容一变指纹就变。这种方法无法告诉你“哪里变了”只能告诉你“变了没有”但对大多数“有没有新公告”的场景已经足够。第三种是 DOM 结构 diff。把 HTML 解析成树比较节点增删和属性变化能精确定位到是哪条资讯新增代价是实现复杂容易在页面结构微调时翻车。我的建议是先做选区哈希。也就是用 XPath 或 CSS 选择器把资讯列表区圈出来只对这个区域的内容算指纹。这能把整页的动态噪音隔离在外是灵敏度和误报率平衡最好的方案。后期如果需求明确可以对选区内的每个链接项目单独算指纹这样就能知道“哪条新闻是新的”。SiteMonitor 的配置里会有一个selector字段专门干这个。2.3 存储与通知选型SQLite 起步还是上 Redis 队列监控工具的存储需求其实很小每个被监控页面保存最近几次快照的哈希和抓取时间再加上一条变更记录表。用 SQLite 单文件就完全够用不需要单独起数据库服务。只有你要把 SiteMonitor 做成多实例采集、有大量并发任务时才需要换 Redis 队列把抓取任务和生产消费解耦。对绝大多数个人或小团队场景SQLite 更合适备份简单、迁移方便、没有网络故障。通知渠道常见的就三种邮件、即时通讯机器人、本地日志。邮件适合正式的通知留痕但配置 SMTP 有成本IM 机器人例如某企业通信工具的群机器人提供 Webhook 接口直接发 HTTP POST 即可实时性最好本地日志适合初期验证链路或者和现有日志采集平台对接。SiteMonitor 可以做成插件式通知一个notifier对象统一接口是send(title, content)后面想加渠道就实现一个新的子类。存储选型上如果你预期单表数据超过几十万条或者要跑复杂的统计查询再考虑迁移 PostgreSQL。否则 SQLite 那个几百 KB 的文件能撑住一年以上的监控记录。3. 用 Python 把一个最小 SiteMonitor 跑起来抓取、指纹、告警一条龙3.1 抓取层requests 解析器处理编码与动态渲染先约定一个最小可用的技术栈Python 3.9requests负责 HTTP 抓取lxml做 HTML 解析hashlib计算指纹标准库sqlite3做持久化crontab做调度。这个组合没有任何重型框架部署到一台最小的云主机上也能跑得动。抓取层最容易出问题的不是拿不到内容而是拿到的内容不能用。第一是编码很多老网站是 GBK/GB2312requests的response.text会按 HTTP 头里的 charset 去解码如果头信息缺失或写错就会乱码。第二是压缩某些站点强制返回 gzip不设置Accept-Encoding会拿到乱码二进制。第三是动态渲染目标页面如果用 JavaScript 异步加载资讯列表requests直接拿到的是空壳 HTML。处理办法是在抓取函数里先检查response.history有没有跳转再看response.encoding最后用lxml解析前对文本做一次编码修正。import requests from lxml import etree def fetch_page(url, timeout15, headersNone): 抓取页面返回可解析的HTML文本 # 常见做法伪装成普通浏览器但不伪装过度 default_headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64), Accept-Encoding: gzip, deflate, # 带上条件请求头服务器没变化时返回304可以省流量 Cache-Control: max-age0, } if headers: default_headers.update(headers) try: resp requests.get(url, headersdefault_headers, timeouttimeout) resp.raise_for_status() except requests.exceptions.Timeout: # 超时不能算更新返回None让上层跳过这一轮 return None except requests.exceptions.RequestException as e: # 网络层面的错误也返回None避免误报 return None # 修正编码如果服务器声明了编码就用否则用apparent_encoding推测 if not resp.encoding or resp.encoding.lower() iso-8859-1: resp.encoding resp.apparent_encoding or utf-8 # 处理好gzip/brotlirequests默认会自动解压 return resp.text # 参数说明 # timeout 建议不小于 10有些老站响应很慢太小会误判站点挂了。 # headers 里不建议加太多特征字段某些反爬系统就是靠“过于完整”的UA来识别爬虫。逻辑上fetch_page返回的是解码后的 HTML 字符串上层再交给解析器。这里故意没处理 JavaScript 渲染因为最小版本只面向服务端渲染的资讯页。如果后续遇到动态页面在第 5 章的避坑里再扩展。3.2 指纹层计算内容哈希与提取“资讯条目”的元数据拿到 HTML 后下一步就是圈定监控区域。SiteMonitor 的核心价值在于“选区”而不是整页哈希。假设我们要监控一个公告列表页面公告列表通常包在一个ul或div里每项是带链接的标题。我们可以用 XPath 定位这个列表的容器然后提取容器内部的text_content()或直接序列化成字符串再算 SHA-256。这里有一个取舍只提取链接文本和 href能有效忽略按钮、背景图、内联样式的干扰序列化整段 HTML 则能捕捉到样式类名的变化但误报率会高。我通常只需要知道“列表有没有新增条目”所以提取所有a标签的(href, text)对构造一个列表对列表做序列化后哈希。这样就算页面内嵌了动态时间戳只要链接没变指纹也不会变。import hashlib from lxml import etree def extract_links(html, xpath): 从HTML中提取XPath指向的链接列表返回排序后的稳定字符串 parser etree.HTMLParser() tree etree.fromstring(html, parserparser) links tree.xpath(xpath) items [] for a in links: href a.get(href, ) text .join(a.itertext()).strip() # 相对链接转绝对链接避免被站点改路径误判 if href and href.startswith(/): href https://example.com href # 实际应从配置中取base_url items.append(f{href}|{text}) # 排序很重要列表顺序偶尔变化不算内容更新 items.sort() return \n.join(items) def make_fingerprint(content): 对内容字符串计算SHA-256返回十六进制摘要 return hashlib.sha256(content.encode(utf-8)).hexdigest()参数说明xpath是监控的精髓比如//ul[classnews-list]//a。写 XPath 前先用浏览器开发者工具确认列表节点的稳定 class 或 id。items.sort()处理了列表被倒序排列的情况——有些站会把最新条目插到列表中间而不是顶部排序后只要集合没变就不报警。当然如果业务需要“顺序变了也通知”可以去掉排序看具体诉求。3.3 通知层邮件 / 企业微信机器人 / 本地日志三选一怎么接通知是可插拔的。最小版本先实现一个控制台打印和文件追加确认检测逻辑没问题后再接真实渠道。控制台通知的代码很简单指纹变化时打印页码变化前的标题列表差异。为了后面扩展我习惯把通知抽象成接口。import smtplib from email.message import EmailMessage class Notifier: 通知基类子类实现send方法 def send(self, title, content): raise NotImplementedError class LogNotifier(Notifier): 本地日志通知适合调试和初期验证 def __init__(self, log_path): self.log_path log_path def send(self, title, content): with open(self.log_path, a, encodingutf-8) as f: f.write(f[{time.strftime(%Y-%m-%d %H:%M:%S)}] {title}\\n{content}\\n) class MailNotifier(Notifier): 邮件通知需要配置SMTP账户信息 def __init__(self, smtp_host, smtp_port, username, password, to_addr): self.smtp_host smtp_host self.smtp_port smtp_port self.username username self.password password self.to_addr to_addr def send(self, title, content): msg EmailMessage() msg[Subject] title msg[From] self.username msg[To] self.to_addr msg.set_content(content) with smtplib.SMTP(self.smtp_host, self.smtp_port) as server: server.starttls() server.login(self.username, self.password) server.send_message(msg)调用端不需要感知具体是哪个 Notifier统一notifier.send(资讯更新, 检测到3条新链接)。这样以后接 IM 机器人只改一行配置。注意邮件通知需要保证 SMTP 密码不硬编码在代码里——从环境变量读取避免泄露。3.4 最小可用 crontab 调度每 10 分钟巡检一次的完整脚本把上面几块串起来就是一个完整的单轮巡检函数。每次巡检执行抓 URL → 提取链接 → 计算指纹 → 数据库查旧指纹 → 不同则插入新记录并通知。数据库表结构放在下一章这里先用内存字典模拟状态验证逻辑链路。import json, time # 用一个JSON文件暂存上次指纹真实场景换成SQLite STATE_FILE /tmp/sitemonitor_state.json URL https://example.com/news XPATH //ul[classnews-list]//a def load_state(): try: with open(STATE_FILE, r, encodingutf-8) as f: return json.load(f) except FileNotFoundError: return {} def save_state(state): with open(STATE_FILE, w, encodingutf-8) as f: json.dump(state, f, ensure_asciiFalse, indent2) def run_once(): state load_state() old_fp state.get(fingerprint) html fetch_page(URL) if html is None: print(抓取失败本轮跳过) return raw_content extract_links(html, XPATH) fp make_fingerprint(raw_content) if old_fp is None: # 第一次运行没有基准只保存不通知 print(首次初始化记录指纹) elif fp ! old_fp: # 指纹变了发通知并更新状态 print(f检测到页面变更旧指纹: {old_fp[:16]} 新指纹: {fp[:16]}) # notifier.send(资讯更新, 链接列表发生变化) else: print(无变化) state[fingerprint] fp state[last_check] time.time() save_state(state) if __name__ __main__: run_once()这个脚本本身不包含调度而是作为单次任务交给 crontab。调度配置写在系统 crontab*/10 * * * * cd /data/sitemonitor /usr/bin/python3 monitor.py /var/log/sitemonitor.log 21。每 10 分钟执行一次。第一次跑会全量初始化当天后续每轮只算一次哈希和一次数据库查询负载很低。参数说明XPath 变了或 URL 变了不需要改代码只要改脚本顶部的配置变量。4. 把监控从玩具变成工具持久化、断点续抓与前端看板4.1 用 SQLite 记录每次快照做到可回溯内存字典适合验证但一旦程序重启就丢失历史。SQLite 最大的好处是单文件、零配置、事务可靠。SiteMonitor 需要两张表snapshots记录每次抓取的指纹和概要changes记录两次快照之间发生的差异。这样你不仅能收到通知还能回去查“昨天下午那次更新具体多了哪些链接”。CREATE TABLE IF NOT EXISTS snapshots ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT NOT NULL, fingerprint TEXT NOT NULL, content_preview TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS changes ( id INTEGER PRIMARY KEY AUTOINCREMENT, snapshot_id INTEGER, url TEXT NOT NULL, diff_summary TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (snapshot_id) REFERENCES snapshots(id) );content_preview存的是提取出来的链接列表的截断文本用来快速回复“到底哪几条是新加的”。diff_summary可以存新增链接的标题集合。频繁写库不用担心性能SQLite 单进程写事务比很多人想象中快每秒几千次也不成问题。要注意的是给url created_at建索引否则几个月后查询历史会很慢。4.2 增量抓取与断点续抓只在站点变化时才重新解析有些站点资讯列表很长每次全量解析几百个链接没问题但如果你监控的是分页列表或者资讯正文页每次都全量抓取很浪费。SiteMonitor 常见的优化是分两个阶段先抓列表页算出指纹只有指纹变化了才重新解析并提取新增链接如果没变化直接跳到下一个目标。断点续抓要处理的是抓取中断问题。比如网络超时只抓到半个页面或者解析抛异常。我习惯的做法是在每个步骤前写检查点抓取前记录statuspending抓完更新statusdone。下次运行时发现pending状态就重新抓取而不是沿用旧数据。SQLite 表里可以加一个status字段。这是为了避免“指纹暂时为空被误当成页面清空”的低级错误。另一个细节是重试机制。对于偶发超时常见的做法是连续重试 3 次每次间隔指数退避1 秒、4 秒、9 秒。只有 3 次都失败才放弃本轮并使用上一次成功的快照作为基准继续。这样既不会因为一次抖动就错失监控也不会在网络故障时产生错误通知。4.3 一个 100 行 Flask 看板查看变更历史与当前状态有了 SQLite就可以做一个极简看板用 Flask 提供两个页面当前监控列表和历史变更记录。这个看板不追求花哨只解决“通知来了但我不记得上次改了什么”的问题。用flask读取snapshots和changes表在模板里按时间倒序展示。from flask import Flask, render_template, request import sqlite3 app Flask(__name__) DB_PATH /data/sitemonitor/sitemonitor.db def query_db(q, args()): conn sqlite3.connect(DB_PATH) conn.row_factory sqlite3.Row cur conn.execute(q, args) rows cur.fetchall() conn.close() return rows app.route(/) def index(): # 展示所有监控URL当前状态 rows query_db( SELECT url, fingerprint, MAX(created_at) AS last_check FROM snapshots GROUP BY url ) return render_template(index.html, rowsrows) app.route(/history) def history(): page request.args.get(page, 1, typeint) per_page 20 rows query_db( SELECT * FROM changes ORDER BY created_at DESC LIMIT ? OFFSET ? , (per_page, (page-1)*per_page)) return render_template(history.html, rowsrows)这里需要注意GROUP BY url与MAX(created_at)的配合标准 SQL 里fingerprint不保证取到最大时间对应的那一行SQLite 实际会返回最先遇到的非聚合字段。更稳妥的写法是用子查询或者直接按id DESC取每组最新一条。看板本身不承担监控逻辑只读库所以即使看板挂了也不影响采集进程。5. SiteMonitor 落地避坑5 个真实翻车场景与排查思路5.1 现象明明每 10 分钟只访问一次目标站却封了 IP有人配置了 SiteMonitor 监控某个行业资讯站频率已经很保守但运行两天后发现 IP 被封锁所有请求都返回 403。原因不是请求频率而是请求特征太明显。requests默认的User-Agent是python-requests/x.x.x目标站的防护系统看到这个 UA 直接判定为爬虫。另一个隐藏原因是日志里每次请求都会带Accept-Encoding: gzip但缺少Accept-Language和其他浏览器头行为指纹不完整。解决方式是在fetch_page里设置一组完整的请求头最好从浏览器复制真实请求头的User-Agent、Accept、Accept-Language、Referer。注意不要过度伪装同一个 UA 每次都不变反而可疑可以准备两三个 UA 轮换。5.2 现象抓下来的页面中文全是乱码指纹也因此每天疯狂变化某监控目标是一个老牌政策公告站页面是 GBK 编码但 HTTP 响应头里没有显式charsetrequests默认按ISO-8859-1解码导致response.text里全是乱码。乱码文本的哈希值每次可能不同因为解码结果受字节流微小差异影响于是每天收到几十条假更新通知。排查时先打印resp.encoding和resp.apparent_encoding如果apparent_encoding能正确识别为GBK就修正编码。我的习惯是在解析前强制检查如果页面标题里的中文字符出现替换字符就用resp.content.decode(gbk, errorsignore)重新解码。永远不要相信 HTTP 头里的 charset以实际内容为准。5.3 现象requests 拿到的 HTML 没有资讯列表但是浏览器里明明能看到这是动态渲染的典型特征。目标页面用 Vue 或 React 在客户端加载数据初始 HTML 里只有一个div idapp真正的资讯列表来自后续的 Ajax 请求。遇到这种情况要先在浏览器开发者工具里的 Network 面板找 XHR 请求看看资讯数据是不是从某个 JSON 接口返回的。如果有SiteMonitor 可以直接监控那个 JSON 接口抓取和解析都更简单。如果没有独立接口只能引入无头浏览器。常见做法是用playwright调起 Chromium 渲染完成后取 HTML但代价是内存占用高每分钟巡检一次可能让机器吃紧。我一般会优先找接口实在找不到再上无头浏览器同时把巡检间隔拉长到 15 分钟以上。5.4 现象一条更新通知被发送了三遍记录里又没有任何重复通知重复的根源不在通知层而在状态保存的时机。我最初写的脚本是“先发通知再存指纹”结果进程在发完通知后、执行save_state前崩溃了下一轮巡检发现指纹还是旧的又触发了一次通知。解决方法是把状态更新放到通知之前的同一个事务里先保存新指纹再发通知。更稳妥的是给通知本身加一个去重表记录上一次通知的指纹和通知时间同一个指纹在 5 分钟内不重复发送。另外如果任务并发执行比如 crontab 和手动脚本同时跑了一轮也会存在竞争条件。可以用一个文件锁或数据库锁保证同时只有一个巡检进程在运行。5.5 现象crontab 定时任务每天“晚了一小时”才执行SiteMonitor 部署在一台 UTC 时区的服务器上crontab 里写的是30 * * * *本来应该每小时的 30 分执行实际却在系统本地时区的 30 分执行。如果没有注意TZAsia/Shanghai和 crontab 的环境变量就会出现“差 8 小时”的错觉。排查方法是先运行date看当前时区再看/etc/crontab和用户 crontab 的时区定义。解决方式是不要在 crontab 里依赖时区统一在 Python 脚本内部用datetime.now(timezone.utc)获取绝对时间并让告警消息里的时间戳带上时区信息。另一个常见坑是 crontab 里的PATH不包含 Python 命令路径导致脚本报python3: command not found。在 crontab 顶部显式设置PATH/usr/bin:/bin:/usr/local/bin或者在脚本首行写绝对路径的 shebang。6. 进阶用法把 SiteMonitor 的误报率降下来并验证监控本身6.1 用相似度阈值过滤“假更新”编辑距离加权方案哈希指纹对“改了一个字”和“重排整个列表”一视同仁都会触发更新。但实际业务里列表里偶尔插入一条无关紧要的推荐位链接或者把某条标题从“【重要】xxx”改成“xxx”并不值得发通知。常见做法是把哈希从“完全相等”升华为“相似度”。我试过用difflib.SequenceMatcher计算新旧链接列表的相似度低于一定阈值才通知。比如相似度 0.9 以上说明只是微调忽略0.5 以下说明大改动立即告警。对链接文本做归一化处理也很关键去掉首尾空格和不可见字符统一小写能消掉一半假更新。6.2 自监控与验证用测试页确认告警链路没哑火监控工具最怕的不是误报而是漏报之后你还不知道。SiteMonitor 可以给自己配一个“心跳监控”每轮巡检结束把可观测指标推到本地状态文件再单独有一个脚本检查这个文件是否更新。如果超过两倍的巡检周期没有更新说明主监控卡死或进程退出需要发一条“SiteMonitor 自身异常”的告警。我在实际部署中还会准备一个测试 URL里面放一个随机数变动点每轮手动改一次确保通知链路是通的。这个测试 URL 不参与真实监控纯粹是用来验证整个流水线的“体检信号”。养成这些习惯后SiteMonitor 才算真正从“能跑的脚本”变成“敢托付的监控工具”。希望这篇笔记里的方案和坑位能帮你在同样的路线上少走几圈。本文还有配套的精品资源点击获取