网站可能发生了变化,而访客却什么也察觉不到。评论数的更新或某个跟踪属性的改动都会改变 HTML 哈希,而一张新的首屏主图却可能几乎不触动标记。这两种情况都无法告诉监控系统,落地页上的重要元素是否真的变了。
为了衡量访客实际看到的内容,我们用 Python 构建了一个监控程序:它通过 Crawlbase 捕获渲染后的页面,用结构相似性指数(SSIM)比较截图,并且只在基于图像的阈值被突破时,才请视觉模型描述差异。它会保留同一次抓取得到的 HTML 用于佐证,写入本地告警,并为每个 URL 和设备维护一份基线。
代码位于 ScraperHub/visual-change-detection-with-screenshots-and-vision-models,每个阶段的检查点放在 steps/ 下,完整的监控程序放在 final/下,另附测试、配置和调度示例。下文的数字来自针对 Hacker News 和 Wikipedia 的一次小规模实时运行,而不是合成基准测试。
- 用 Crawling API 捕获整页 JPEG 并立即下载,同时保存同一响应中的 HTML;HTML 不会随截图一起存储。
- 将两张截图统一到相同宽度,再用 SSIM 和变化面积比例打分。任一分数超过阈值,闸门就会打开,触发一次视觉模型调用。
- 把模型给出的段落视为候选解释。截图、热力图和保存下来的 HTML 才是证据。
为什么 HTML 哈希无法告诉你视觉上变了什么
在两次 Hacker News 桌面端抓取中,HTML 大小 从 34,626 字节变为 34,508 字节 而且 SHA-256 前缀也不同:45b9d53f52b3 与 efc0c606f53b。Wikipedia 的 Python 条目在标记层面同样有变化,大小 从 1,309,531 字节变为 1,310,770 字节,哈希值也不同。然而提取出的 Wikipedia h1 始终是 Python (programming language).
哈希只能回答两段字节序列是否一致。它分不清轮换的 nonce 和改动过的优惠,既不衡量布局,也不衡量像素。反过来,图像可以变化,而 HTML 却没有明显的结构变化。对落地页监控来说,有用的问题不是 文档变了吗? 而是 渲染后的页面变了什么,值不值得深入调查?
测试目标是桌面端和移动端的 Hacker News 首页,以及桌面端的 Wikipedia Python 条目。每个 URL 与设备的组合都有各自的截图基线。
用 Crawlbase 捕获 JavaScript 渲染后的截图
监控程序使用 Crawling API 和 JavaScript token。每次捕获都会请求 screenshot=true, store=true 和 format=json;滚动和页面等待让懒加载内容在截图前出现。捕获步骤以字符串形式构造参数,因此 URL 编码发送的是 true 而不是 Python 的 True:
# steps/02-capture/capture.py def build_options( device: str, page_wait: int, scroll_interval: int, css_click_selector: str = "", ) -> dict[str, str]: """Query params for one rendered JPEG. Strings, so urlencode sends true rather than True.""" options = { "screenshot": "true", "store": "true", "format": "json", "device": device, "scroll": "true", "scroll_interval": str(scroll_interval), "page_wait": str(page_wait), } selector = with_body_fallback(css_click_selector) if selector: options["css_click_selector"] = selector return options
本次运行使用 page_wait=2000 在页面加载后额外等待两秒。scroll_interval=10 是 API 接受的最小值,但它并不是时间上限:API 会把它转换为最多三次滚动到底部的过程,每次最多等待 40 秒让页面继续增长。即使是从不增长的页面,比如 Hacker News 首页,也要在滚动步骤上花大约 43 秒。可选的 country 参数没有传,因此路由是自动选择的。
css_click_selector 可以在捕获前关闭横幅。Wikipedia 目标的 .mw-cookiebanner-dismiss 会以 .mw-cookiebanner-dismiss,body的形式发送,因为不滚动时,未匹配的选择器会让请求失败并返回 cb_status 595,而 ,body 这个兜底总能匹配。若使用 scroll=true(本监控程序总是发送该参数),API 会改为在滚动循环内点击,并直接跳过缺失的元素。这条路径只接受一个选择器列表,所以滚动时请只用单个选择器,而不是用 | 串联多次点击。
Crawling API 的 screenshot=true 默认返回 整页 JPEG 截图。设置 mode=viewport, width 和 height 会将整页截图缩放并裁剪到恰好这个尺寸(默认从右上角开始);如果没有 mode=viewport 则二者都会被忽略。独立的 Screenshots API 已停止接受新用户注册,因此本实现仍使用 Crawling API。Crawlbase Web MCP Server 也提供 crawl_screenshot 工具用于交互式截图,但定时运行的监控程序需要自己的一套可重复的捕获循环。
Hacker News 的第一次尝试撞上了 120 秒的客户端超时,另一次额外的滚动尝试则返回了 HTTP 500。成功的运行使用的是 240 秒,这是一个留有余量的客户端设置,并不是要求每个页面都渲染四分钟。滚动也会改变费用:带滚动的请求前 8 秒按一次 JavaScript 请求计费,此后每多 5 秒再计一次,详见 渲染参数 说明。滚动约 43 秒时,对一个从不增长的页面做一次捕获,大约按八次 JavaScript 请求计费,所以只有当你要比较的内容在首屏以下加载时才开启滚动。
六次成功的捕获都返回了 cb_status 200,但尺寸差异很大:
| 捕获 | 截图尺寸 | JPEG 字节数 |
|---|---|---|
| Hacker News 桌面端,基线 | 3840×2144 | 215,392 |
| Hacker News 桌面端,对比 | 1939×1249 | 199,382 |
| Hacker News 移动端,基线 | 375×1777 | 175,304 |
| Hacker News 移动端,对比 | 1080×2324 | 159,983 |
| Wikipedia 桌面端,基线 | 1680×1050 | 198,864 |
| Wikipedia 桌面端,对比 | 2560×1080 | 215,711 |
渲染请求并不使用固定的浏览器窗口,因此即使设备相同,整页截图的宽度也可能每次抓取都不一样。在下文解读图像差异时,这种波动会成为主要的局限。
在 URL 过期前下载 JPEG
使用 format=json时,响应会在 JSON 正文中携带 screenshot_url 字段(若响应格式为 HTML,则改为以响应头的形式返回);响应文档 对两种情况都有说明。Crawlbase 将该链接标注为临时链接,而且其中包含你账户的令牌,所以请立即下载 JPEG,切勿记录或分享这个 URL 本身。
监控程序会立即把 JPEG 下载到 shots/{url_hash}/{device}/{timestamp}.jpg,检查 JPEG 文件签名 FF D8,并先写入一个临时的 .part 文件,再替换目标文件。它还会把同一响应中的 HTML 作为 .html 文件保存在图片旁边,让证据留存在磁盘上。在这些响应中,请求 ID 可以从截图 URL 的 rid 查询参数中取得:
# steps/02-capture/capture.py def rid_from_screenshot_url(url: str) -> str: if not url: return "" values = parse_qs(urlparse(url).query).get("rid") or [] return values[0] if values else ""
第一张成功的桌面端 Hacker News 截图带有 rid 907337a6a19e932cabde4165,其 /storage URL 返回了一张 JPEG。这是设计使然:在截图请求中 Cloud Storage 会以请求的 rid 保存图片,而不会存储 HTML,无论是否设置了 store=true。设置 store=true 会把 JPEG 作为已存储页面保留,并产生存储费用;监控程序并不依赖它,因为它读取的是 本地保存的 HTML.
先统一截图尺寸,再计算 SSIM
SSIM 比较的是图像中相互对应的区域,因此两张输入图必须尺寸相同。归一化步骤使用 Lanczos 重采样,把每张截图缩放到 1280 像素宽 之后从顶部开始,把两张图都裁剪到二者中较短的那个高度:
# steps/03-normalize/normalize.py shared = min(fitted_before.height, fitted_after.height) if shared < 1: raise ValueError("normalized height is empty") box = (0, 0, width, shared)
这样数组就可以相互比较了;但这 并不 能让布局等价。例如,移动端 Hacker News 截图会分别缩放到约 1280×6065 和 1280×2754。SSIM 只衡量共有的顶部区域,而 3,311 像素的高度差 会单独记录。如果两次捕获之间触发了不同的响应式断点,分数就会把布局变化和内容变化混在一起。
比较步骤先把两张图转为灰度,然后计算 structural_similarity 时使用 data_range=255 和七像素窗口。OpenCV 计算灰度差的绝对值,差值超过 24 个灰度级 的像素计入变化面积比例。轮廓会转换为边界框(取最大的十二个,忽略小于 64 平方像素的框),热力图把变化的像素染成红色并勾勒出边界框。对比运行的测量结果如下:
| 目标 | SSIM | 变化面积 | 归一化高度差 |
|---|---|---|---|
| Hacker News 桌面端 | 0.7934 | 8.27% | 110 px |
| Hacker News 移动端 | 0.5468 | 24.50% | 3,311 px |
| Wikipedia 桌面端 | 0.5694 | 30.99% | 260 px |
一个完全相同的 80×80 测试样例 SSIM 至少为 0.99,变化面积为零。三组实时截图的差距都大得多,但仅凭数字无法区分是首屏主图变了,还是价格区块变了。这些截图的取景并不一致,这使得 捕获波动成为一个混杂因素.
用低成本的图像分数为视觉模型设闸
视觉模型不需要查看每一次捕获。监控程序先判断 SSIM 差异或变化面积比例是否大到值得调查,而这道闸门刻意做得很小:
# steps/05-gate/gate.py def crosses(ssim: float, changed_area: float, ssim_delta: float, area_delta: float) -> bool: return (1.0 - ssim) >= ssim_delta or changed_area >= area_delta
示例配置 final/targets.example.toml中设置了 ssim_delta=0.04 和 area_delta=0.03. 任一条件 满足即可打开闸门:结构差异至少为 0.04,或变化像素至少覆盖比较区域的 3%。这些是来自实现及其测试的初始阈值,而不是经过大量平稳生产运行验证的阈值。
每组实时截图都同时超过了两个阈值,但这并不意味着每个页面都发生了有意义的变化。轮播图、轮换广告、时钟或捕获宽度的跳变同样会打开闸门。在生产环境中,应基于稳定条件下反复捕获的截图来校准阈值,并尽可能遮蔽已知的动态区域。
在真实浏览器中渲染页面,返回整页截图以及同一次抓取的 HTML,可直接用于差异比对。失败的请求不计费。最多可免费使用 5,000 次请求,无需绑卡。
让视觉模型说明变化,并核实它的回答
闸门打开后,解释步骤会把前后两张截图发送给 gpt-4o。每张图的最长边会被缩小到 1280 像素以内,重新编码为质量 70 的 JPEG,并以 detail="low"提交。提示词要求模型只针对可见差异写一段客观描述,并给出一个标签:pricing, hero, banner 或 other.
验证运行使用了 --max-vision-calls 1,因此只有桌面端 Hacker News 的对比得到了文字描述;移动端和 Wikipedia 两组仍然生成了分数和告警。模型返回了 other,但它的段落提到了两张截图中都看不到的帖子标题和 YC 批次年份。这是一个值得尽早发现的失败模式:看似合理的解释,并不一定忠实描述了像素.
解析器还必须处理 LABEL: other 被附加在句末、而不是单独成行的情况。解析后,告警会原样保留模型的段落,只去掉标签。SSIM、热力图和原始截图仍是主要证据;这段文字是有待审核的解读,而不是经过验证的变更日志。
对于标记为 pricing, hero 或 banner的变化,监控程序可以从同一次捕获时保存的 HTML 中提取佐证文本:
# steps/08-monitor/monitor.py if vision and needs_html(label): extracted = storage_fn( token, shot.rid, target.extract_selector, label, timeout=settings.timeout, html_path=shot.html_path, )
虽然叫 storage_fn 这个名字,但默认的 _extract_saved 实现会在本地读取 html_path 指向的文件。配置的选择器(标签、#id 或 .class)可以提取某个元素,例如 Wikipedia 中未变化的 h1;如果没有配置,价格提取会先查找货币金额,找不到再回退到标题。这能补充上下文,但简单的提取并不能证明模型正确识别了变化。本次运行中唯一的标签是 other,因此 HTML 分支没有运行。
存储告警、轮换基线并调度监控程序
每次超过阈值都会生成一条 JSON 告警,包含 URL、设备、SSIM、变化面积比例、rid、热力图路径、标签以及生成的解释(如有)。PNG 热力图保留在磁盘上。若 SLACK_WEBHOOK_URL 已设置,通知器会发送一段文本摘要,但不会上传 PNG。
测试生成了 三条 JSON 告警,每组对比一条,且未设置 Slack Webhook。基线运行耗时 173 秒 而对比运行耗时 391 秒。SQLite 记录了六次运行:三次初始基线和三次对比。只有桌面端对比有模型生成的标签和段落。
SQLite 基线管理器 final/baselines.py为每个 URL 和设备在 baselines 表中保存一份基线,并用 runs 和 alerts 表跟踪历史。设置 rotate_after_n=5时,最新截图会在五次对比后成为基线,平稳的运行也计算在内。使用 --ack <id> 确认一条告警,会立即把基线轮换为该告警对应的截图,因此经过审核的变化会成为新的参照。
要试用它,先克隆仓库,安装 Python 依赖,把 final/.env.example 复制为 final/.env 并设置 CRAWLBASE_JS_TOKEN。设置 OPENAI_API_KEY 以启用模型描述;若只需要本地告警,则将 SLACK_WEBHOOK_URL 留空。在仓库根目录下运行:
python -m pip install -r requirements.txt python -m pytest final python final/monitor.py --once
用 cron 调度它,以 --once 模式运行监控程序,而不是让进程常驻。targets.example.toml 中的路径都相对于该文件解析。
实时运行带来的启示
这条流水线捕获了六张截图,生成了三组对比和热力图,放行了一次视觉请求,并写入了三条本地告警。它也暴露了两个问题,在把同样的工作流用于竞品监控或视觉回归检查之前,需要先解决。
第一,先固定捕获矩形,再调整阈值。仅靠设备标签并不能得到稳定的截图宽度,因为渲染窗口会变化。mode=viewport 配合固定的 width 和 height 能得到固定的输出尺寸,但页面仍然按渲染时的窗口宽度排版,所以要在多次重复捕获中检查布局;对于整页监控,还要同时比较宽度、缩放比例和内容高度。保持等待时间、语言区域和横幅处理方式一致;当整个页面带有无关的动态内容时,可以考虑只比较选定区域。
第二,把 AI 生成的描述视为候选解释。唯一一次视觉调用描述的细节无法在截图中得到确认。当某条告警很重要时,请检查前后两张截图和热力图,再对照保存的 HTML 核实文案或价格的变化。不要把生成的段落直接写入权威的审计记录。
有了这些控制措施,这套工作流可以支持竞品落地页监控、部署后的视觉回归检查,以及借助 Crawlbase 可选的 country 路由,比较同一落地页在不同国家的呈现。每种用途都应有各自稳定的基线,以及针对该页面校准的阈值。
结论
一个实用的视觉监控程序会将三个环节分开:捕获, 测量 和 解读。Crawlbase 无需本地浏览器集群即可提供渲染后的截图;SSIM 和变化面积分数决定哪些捕获值得关注;视觉模型补充可读的解释;而保存的 HTML、热力图和请求 ID 让每条告警都可供检查。
这次实验证明这条流水线可行,但并不证明它最初的阈值已经可以投入生产。一致的捕获几何参数和有证据支撑的解释,才能把截图差异变成可付诸行动的变更检测。完整的 Python 实现 可以直接改造使用:创建一个 Crawlbase 账户 后,即可在你自己的公开页面上运行截图阶段;另请查看 Crawlbase 定价 了解捕获与渲染费用。
常见问题(FAQ)
视觉变更检测与比较 HTML 哈希有何不同?
HTML 哈希会标记任何字节级的标记差异,包括不可见的差异。视觉检测比较的是渲染后的截图,因此能发现布局、图像和呈现方式上的变化。两者结合,就能把可见的差异与其背后的内容联系起来。
Crawlbase 能捕获大量依赖 JavaScript 的落地页吗?
可以。当你使用 JavaScript token(或 Normal token 并设置 javascript=true)时,Crawling API 会在浏览器中渲染页面,而它的 page_wait、滚动和点击控制会在 screenshot=true 捕获页面之前准备好动态内容。
SSIM 分数越低,就一定说明页面发生了重要变化吗?
不一定。SSIM 衡量的是已对齐图像数组之间的结构相似性。不同的视口宽度、响应式布局、轮换内容以及其他捕获波动,都可能在优惠或信息并未改变的情况下拉低分数。
如何减少截图监控中的误报?
保持设备和捕获尺寸稳定,统一渲染等待时间,每次都用相同方式关闭临时浮层,并在可行时排除动态区域。根据每个目标的多次平稳捕获来设定阈值,而不是依赖一个通用分数。
为什么要在视觉模型之前设置 SSIM 阈值?
与模型调用相比,图像比较成本低且结果确定。闸门会跳过未超过阈值的常规捕获,减少模型用量,同时仍让模型去解释可能重要的变化。超过阈值并不保证变化有意义。
如何自动化告警并更新基线?
从 cron 或其他调度器运行 python final/monitor.py --once 即可。监控程序会存储本地 JSON 告警,可选地向 Slack 发送摘要,并将基线保存在 SQLite 中。基线会在达到配置的对比次数后轮换,或在告警被确认时立即轮换。
大规模爬取任何站点,无需与基础设施对抗。
Crawlbase 负责处理代理、指纹和验证码,让您的团队专注于构建数据管道,而不是维护爬取基础设施。免费提供最多 5,000 次请求,无需绑定信用卡。
