很多页面值得持续关注其变化:竞争对手的定价页面、产品的库存状态、政策或条款文档、招聘板块、版本说明页面。这些信息是公开的,但变化本身才是你真正关注的信号,而手动刷新标签页在超过一两个页面后就完全无法规模化。你需要的是一个脚本,让它替你检查,只在内容发生变化时通知你。

本指南展示如何以可靠的方式使用 Python 构建网站变更追踪器。你将构建一个可运行的小工具,通过 Crawling API 获取页面,提取有意义的文本,计算 SHA-256 指纹,将每个快照存储到磁盘,将新指纹与上次的进行比较以检测变化,并按计划运行整个检查流程。这种方法是通用的:它适用于你指向的任何公开页面,而非某个特定网站。

你将构建的内容

一个 Python 脚本,接受一个或多个公开 URL,通过 Crawling API 检索每个页面,将其化简为可比较的文本,对该文本进行指纹处理,并报告自上次运行以来页面是否发生了变化。每个组件都是一个小函数,便于阅读和复用。各部分如下:

  • Fetcher(获取器)通过 Crawling API 检索页面 HTML,让封锁和 JavaScript 渲染由其处理。
  • Extractor(提取器)剥离脚本、样式、导航和页脚,留下可阅读的正文文本。
  • Fingerprint(指纹)对清洗后文本的 SHA-256 哈希,使得任何单个词的变化都会产生完全不同的值。
  • Store(存储)一个 JSON 文件,将每个 URL 映射到其最后的指纹,加上用于差异比较的最后一次文本。
  • Comparator(比较器)加载上一个指纹,进行比较,并报告是否有变化。
  • Scheduler(调度器)一个带睡眠间隔的循环,或一个 cron 条目,使检查自动运行。

为什么普通请求不够用

你可以完全不使用 API,用一个裸 HTTP 客户端来写这个工具,在简单的静态页面上甚至能正常工作。麻烦从真实目标开始。许多网站会直接限制或封锁自动化请求:一个来自数据中心 IP 的脚本按固定间隔访问同一 URL,是很容易被标记的模式,而监控器从定义上就是自动化流量。其他页面在浏览器中用 JavaScript 构建内容,因此普通请求返回的原始 HTML 几乎是一个空壳,你的指纹最终追踪的是外壳而非你本想监控的内容。

因此,一个可靠的追踪器从每次获取中需要两样东西:网站认为像真实访客的 IP,以及在 HTML 返回之前运行脚本的浏览器(当页面是客户端渲染时)。你可以自己组装无头浏览器和轮换住宅代理,但保持这套设施正常运转就占了大部分工作量。Crawling API 将两者合并为一次调用:发送 URL,可选地提供 JavaScript token,它会返回完整的 HTML 供你进行指纹处理。

还有第二个原因,即比较本身必须谨慎,这与获取方式无关。原始 HTML 会以你不关心的方式不断变化:内联脚本、广告位、嵌入的时间戳、CSRF token、动态组件。如果你对原始响应进行哈希,几乎每次运行都会得到误报。先将页面化简为可阅读的文本,才能使指纹成为真实信号而非噪音。

先决条件

首先需要确认几件事。没有一件需要花很长时间。

Python 基础。你应该能够编写和运行脚本,并使用 pip 安装包。如果 BeautifulSoup 对你来说是新东西,我们的在 Python 中使用 BeautifulSoup 的指南涵盖了本教程所假设的解析知识。

Python 3.10 或更高版本。python --version 确认你的版本。代码使用了 str | None 类型提示语法,需要 3.10 版本。如果你没有安装,请从 python.org 安装。

Crawlbase 账户和 token。注册后,打开你的仪表板,从账户文档页面复制你的 token。免费套餐包含 1,000 次请求,足够测试一个追踪器。将 token 视为密码,不要放入版本控制:以下代码从 CRAWLBASE_TOKEN 环境变量读取它。

搭建项目

创建一个虚拟环境以隔离依赖,然后安装两个第三方库。哈希、存储、调度和差异比较都来自 Python 标准库(hashlibjsontimedifflib),因此这些不需要额外安装。

bash
python --version

python -m venv tracker_env
source tracker_env/bin/activate

pip install requests beautifulsoup4

在 Windows 上,用 tracker_env\Scripts\activate 替代 source 那行来激活环境。两个依赖项承担具体工作:requests 向 Crawling API 发送 HTTP 调用,beautifulsoup4 解析返回的 HTML,让你能提取可阅读的文本。

第一步:通过 Crawling API 获取页面

首先确认你能够获取到页面。下面的函数读取你的 token,构建包含目标页面 URL 编码的 Crawling API 请求 URL,发送请求,并返回 HTML。检查响应状态可以让失败变得显而易见而非悄无声息。

python
import os
from urllib.parse import quote
import requests

CRAWLBASE_API_URL = "https://api.crawlbase.com"

def fetch_page(url: str, token: str | None = None) -> str:
    api_token = token or os.environ.get("CRAWLBASE_TOKEN", "")
    if not api_token:
        raise ValueError("Set CRAWLBASE_TOKEN or pass token=")
    api_url = f"{CRAWLBASE_API_URL}/?token={api_token}&url={quote(url)}"
    response = requests.get(api_url, timeout=30)
    response.raise_for_status()
    return response.text

if __name__ == "__main__":
    html = fetch_page("https://example.com")
    print(html[:300])

设置好 token 后运行(export CRAWLBASE_TOKEN="your_token"),你应该能看到真实页面 HTML 的前几百个字符。这个简单的确认很重要:它证明请求已经到达页面并成功返回内容,然后你才在此基础上构建其他功能。timeout=30raise_for_status() 调用是有意为之的,后面的错误处理部分会在此基础上进一步构建。如果目标页面用 JavaScript 渲染其内容,请使用 JavaScript token 而非标准 token,以便在 HTML 返回之前完成页面渲染。

Crawlbase Crawling API

第一次 fetch_page 调用返回了真实的 HTML,而你无需管理任何一个代理。Crawling API 处理了会让简单请求循环失败的封锁、限速和 CAPTCHA 挑战,并在服务器端通过受信任的 IP 进行轮换,因此长期运行的监控器可以持续获得干净的 HTML 进行指纹处理,而不会被标记。添加 JavaScript token,它会在返回页面之前渲染客户端页面。先在免费套餐上指向一个公开页面试试。

第二步:提取并对内容进行指纹处理

比较原始 HTML 是不可靠的,因此下一步是将页面化简为可阅读的文本,然后对其进行哈希处理。提取器将 HTML 加载到 BeautifulSoup 中,删除那些在没有实质意义的情况下发生变化的元素(scriptstylenavfooter),提取可见文本,并折叠空白字符,这样美观上的重排就不会被记录为变化。

python
import hashlib
from bs4 import BeautifulSoup

def extract_monitorable_text(html: str) -> str:
    soup = BeautifulSoup(html, "html.parser")
    for tag in soup(["script", "style", "nav", "footer"]):
        tag.decompose()
    text = soup.get_text(separator=" ", strip=True)
    return " ".join(text.split())

def content_fingerprint(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).hexdigest()

指纹是对清洗后文本的 SHA-256 哈希。哈希是从输入推导出的固定长度字符串,输入的任何变化(哪怕只是一个字符)都会产生完全不同的输出。这个特性正是追踪器所需要的:不是存储整个页面并进行字节比较,你只需每个 URL 存储一个 64 字符的字符串并进行比较。比较速度快,存储占用少,即使是微小的编辑也能被捕获。将此与我们的通用 Python 抓取指南结合,如果你想扩展提取器以针对页面的特定区域而非整个正文。

第三步:存储快照并进行比较

为了检测变化,工具必须记住上一次运行的状态。两个小型 JSON 文件保存状态:snapshots.json 将每个 URL 映射到其最后的指纹,snapshots_text.json 保留最后提取的文本,以便在内容移动时显示人类可读的差异。加载函数在首次运行时返回空字典,而不会失败。

python
import json
from pathlib import Path

def load_json(path: str | Path) -> dict[str, str]:
    p = Path(path)
    if not p.exists():
        return {}
    with open(p, encoding="utf-8") as f:
        return json.load(f)

def save_json(data: dict[str, str], path: str | Path) -> None:
    with open(path, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2)

def check_for_change(url: str, current_hash: str, snapshots: dict[str, str]) -> bool:
    previous = snapshots.get(url)
    if previous is None:
        return True
    return previous != current_hash

比较逻辑是追踪器的核心,且刻意保持简单。check_for_change 查找 URL 存储的指纹。如果没有,说明这是你第一次看到这个页面,因此报告有变化,新的指纹会被保存。如果有,则返回两者是否不同。对任何 URL 的第一次运行总是报告已变化,原因正在于此,这是预期行为,而非缺陷。

现在将各部分串联成一次完整运行。下面的函数循环处理 URL,获取并对每个 URL 进行指纹处理,判断是否有变化,在有变化时对存储的文本输出统一差异,并在最后保存更新的状态,以便下次运行时有内容可以比较。差异比较使用标准库的 difflib 模块,无需额外依赖。

python
import difflib

def run_once(urls: list[str], hash_path="snapshots.json",
             text_path="snapshots_text.json") -> None:
    snapshots = load_json(hash_path)
    snapshot_texts = load_json(text_path)

    for url in urls:
        html = fetch_page(url)
        text = extract_monitorable_text(html)
        if not text:
            print(f"[warn] empty text, skipping {url}")
            continue
        fingerprint = content_fingerprint(text)

        if check_for_change(url, fingerprint, snapshots):
            print(f"[changed] {url}")
            old = snapshot_texts.get(url, "")
            diff = difflib.unified_diff(
                old.split(), text.split(),
                lineterm="", n=0)
            print(" ".join(diff)[:500])
        else:
            print(f"[no change] {url}")

        snapshots[url] = fingerprint
        snapshot_texts[url] = text

    save_json(snapshots, hash_path)
    save_json(snapshot_texts, text_path)

同时保存指纹和文本,才能让未来的运行既能检测变化,又能解释变化。指纹回答"是否有任何内容发生了变化",而存储的文本让 difflib 回答"什么发生了变化"。如果你只需要是/否信号,可以去掉文本文件,只保留指纹映射。

JSON 之外的存储方案

JSON 文件非常适合少量 URL,且便于手动检查。一旦你追踪数百个页面,就将加载和保存函数换成标准库 sqlite3 模块的 SQLite:它处理并发读取,扩展到大量 URL 列表,并将所有状态保存在一个可移植的文件中。脚本的其余部分不需要更改。

第四步:按计划运行追踪器

变更追踪器只有在自动运行时才有用。有两种简洁的方式可以实现。第一种内置于脚本中:一个可选的间隔循环,每隔 N 秒重新检查每个 URL,直到你停止它。第二种是让操作系统通过 cron 定时运行单次检查。下面是带有单次和间隔两种模式的 CLI 入口点。

python
import argparse
import time

def main() -> None:
    parser = argparse.ArgumentParser(description="Website change tracker")
    parser.add_argument("urls", nargs="+", help="public URLs to monitor")
    parser.add_argument("--interval", type=float, metavar="SECONDS",
        help="re-check every SECONDS (e.g. 3600 for hourly); Ctrl+C to stop")
    args = parser.parse_args()

    while True:
        run_once(args.urls)
        if args.interval is None:
            break
        time.sleep(args.interval)

if __name__ == "__main__":
    main()

运行单次检查,或保持每小时循环:

bash
export CRAWLBASE_TOKEN="your_token"

# one pass, then exit
python tracker.py https://example.com

# check every hour until you stop it
python tracker.py https://example.com --interval 3600

间隔循环是最简单的选项,将进程保持在一处,在测试阶段非常方便。对于无人值守的生产环境,cron 通常更合适:它在重启后仍能存活,不需要占用终端。一个每小时运行一次并将输出追加到日志的 crontab 条目如下所示:

bash
# run at the top of every hour
0 * * * * cd /path/to/project && \
  CRAWLBASE_TOKEN=your_token \
  ./tracker_env/bin/python tracker.py https://example.com >> tracker.log 2>&1

在 Windows 上,等效方案是任务计划程序(Task Scheduler)按触发器运行同一个单次命令。无论哪种方式,当 cron 或任务计划程序负责计时时,去掉 --interval 标志,因为调度器已经处理了重复执行。

输出的样子

脚本每次运行为每个 URL 打印一行,当页面发生变化时还会打印一段截断的差异。第一次检查某个 URL 时,它总是报告有变化,因为还没有存储的指纹,快照会被写入供下次使用:

bash
# first run: no snapshot exists yet
[changed] https://example.com

# later run, content edited
[changed] https://example.com
--- +++ @@ -Old pricing copy +New pricing copy

# later run, nothing moved
[no change] https://example.com

磁盘上的状态同样清晰可读。snapshots.json 是 URL 到指纹的扁平映射,这是比较所需的全部内容:

json
{
  "https://example.com": "3e1f9c...a7d2",
  "https://example.com/pricing": "b04c88...11ef"
}

处理失败与规模化

长期运行的监控器会遇到失败,而它如何处理失败决定了它是否能持续运行。三种情况经常出现。超时:requests.get(timeout=30) 调用如果 API 没有及时响应会引发异常,因此请封装获取过程并使用指数退避重试,而不是让一个慢响应终止整个运行。HTTP 错误:raise_for_status() 将 4xx 和 5xx 响应转化为异常;记录状态码和 URL,然后跳过该 URL,继续处理其余的。空提取:如果 extract_monitorable_text 返回空字符串,跳过比较并记录警告,而不是记录虚假的变化,这是 run_onceif not text 守卫已经处理的情况。

python
def fetch_with_retry(url: str, retries: int = 3,
                     backoff: float = 2.0) -> str:
    for attempt in range(retries):
        try:
            return fetch_page(url)
        except requests.exceptions.RequestException:
            if attempt < retries - 1:
                time.sleep(backoff ** attempt)
            else:
                raise

从一个页面扩展到多个页面是自然而然的。追踪更多 URL 只需将更长的列表传递给 run_once。为加速处理大型列表,使用 concurrent.futures.ThreadPoolExecutor 并行获取,因为工作是 I/O 密集型的。追踪数百个页面时,如上所述将状态从 JSON 迁移到 SQLite。如果你的任何目标在客户端渲染内容,将获取方式切换到 JavaScript token,以便在你提取之前页面已被渲染:我们关于使用 Python 抓取 JavaScript 页面的说明涵盖了何时需要这样做。

负责任地追踪变化

变更追踪器是自动化流量,因此要像希望别人对你自己的网站那样运行它。按照页面实际变化的频率来设置检查频率:快速变化的新闻页面或仪表板可以每 15 到 60 分钟检查一次,定价和列表页面通常几小时一次就够了,而政策和文档页面每天或每周检查一次即可。以远超页面变化频率的频率检查静态页面只会增加请求成本和来源负载,而不会提升检测效果,因此选择仍能捕获你所需内容的最慢间隔。

同时也要遵守来源的规则。只追踪公开页面(任何人无需账户即可加载的页面),在将定期任务指向某个网站之前阅读其服务条款和 robots.txt;将两者视为你收集内容的边界。将每个目标的请求量保持在不会给服务器造成压力的水平,将检查分散在各目标上而非集中打击一个 URL,遇到错误或挑战时退后而非更频繁地重试。如果网站提供官方 API 或变更推送,优先使用:这是网站为此预期的路径,通常也比解析 HTML 更稳定。

回顾

核心要点

  • 指纹优于原始比较。使用 SHA-256 对清洗后的文本进行哈希,将"这个页面是否发生了变化"变成比较两个 64 字符字符串的快速操作,而非整个页面的比较。
  • 先提取,再哈希。剥离脚本、样式、导航和页脚,并折叠空白字符,才能防止时间戳和广告位触发误报。
  • 存储文本,而非仅存储哈希。将最后提取的文本与指纹一起保存,让 difflib 能够显示具体发生了什么变化,而不仅仅是告诉你有东西变了。
  • 封锁发生在获取层。Crawling API 处理渲染和受信任 IP 轮换,因此长期运行的监控器能持续获得干净的 HTML,而不会被标记。
  • 设置调度并保持礼貌。睡眠循环或 cron 条目让检查自动运行;将间隔与页面实际变化的频率匹配,并遵守来源的条款和 robots.txt。

常见问题

为什么对文本进行指纹处理而不是比较原始 HTML?

原始 HTML 在几乎每次请求中都会以你不关心的方式发生变化:内联脚本、广告位、嵌入的时间戳和 CSRF token 都在移动,而实际内容保持不变。比较原始 HTML 几乎每次运行都会给你误报。先将页面化简为可阅读的文本,然后对其进行哈希,才能使指纹追踪你本想监控的内容,而非其周围的噪音。

这对 JavaScript 密集型网站有效吗?

有效,只需一个改变。使用 JavaScript token 而非标准 token 与 Crawling API 配合使用。这会在真实浏览器中完整渲染页面后再返回 HTML,因此 BeautifulSoup 提取文本时客户端内容已经存在。没有它,客户端渲染的页面返回的几乎是一个空框架,你的指纹最终追踪的是框架而非内容。

它可以同时监控多个页面吗?

可以。在命令行传入多个 URL,脚本会依次处理它们,在快照文件中为每个 URL 保存一个指纹。对于大型列表,使用 concurrent.futures.ThreadPoolExecutor 并行获取,因为工作是 I/O 密集型的,并考虑将存储迁移到 SQLite,以便状态能干净地扩展。

发生变化时如何收到提醒?

核心脚本将变化报告到标准输出,如果 cron 将其输出发送给你,这就足够了。要推送真正的提醒,在 check_for_change 返回 True 的地方添加调用:向 Slack 或 Discord webhook 发送消息,通过事务性 API 发送电子邮件,或请求任何 HTTP 端点。这只需在 run_once 的变化分支中添加几行代码。

追踪数百个 URL 的最佳存储方案是什么?

通过标准库的 sqlite3 模块将 JSON 文件替换为 SQLite。它处理并发读取,扩展到大量 URL 列表,并将所有状态保存在一个可移植的文件中。加载和保存函数是唯一需要更改的代码;获取、提取、指纹处理和比较逻辑保持完全不变。

追踪器应该多久运行一次?

将间隔与页面实际变化的频率匹配。快速变化的新闻页面和仪表板每 15 到 60 分钟一次是合理的;定价和产品列表通常几小时一次就够了;政策和文档页面可以每天或每周检查一次。运行频率远超页面变化频率只会增加请求成本和来源负载,而不会捕获任何额外的变化。

开始构建

大规模爬取任何站点,无需与基础设施对抗。

Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。

自助开通 · 无需销售通话 · 提供企业级爬取量