数据抓取

大规模变化检测:在不被噪声淹没的情况下对比网页差异

网页每次加载都会变化:广告、时间戳、令牌。如何通过归一化、字段级差异对比和 simhash 从噪声中辨别真实变化,附经过测试的代码。

Chris Collins

Chris Collins

2026年9月27日 · 2 分钟阅读

每一个监控任务最终都会遇到同一个问题:这个页面变了吗?听起来像是一次哈希比较,但其实不是。现代页面在每次加载时都会发生变化,轮播广告、时间戳、推荐模块、会话令牌和实时计数器随处可见,因此简单的比较每次都会报告变化,警报渠道被噪音淹没,直到所有人都将其静音。反过来的失败更安静,也更糟糕:一个为了忽略噪音而调得过于精细的流程,反而漏掉了它本应捕捉到的降价或被移除的证书。

本指南将介绍如何大规模检测真实的变化:比较字段而非整页,规范化那些无法避免比较的内容,衡量页面变化的程度而非仅仅判断是否变化,并将每一类变化路由到合适的地方。

关键要点

  • 对大多数网站而言,原始字节比较毫无用处。我们两次抓取一个新闻主页,间隔45秒:字节不同,甚至清理后的文本也不同。
  • 只要有可能,就比较字段而非整页。价格、标题或库存状态要么变了,要么没变。
  • 在必须比较整页的场景中,先规范化,再衡量相似度。Simhash 是 Google 为在80亿个页面中检测近似重复而提出的指纹技术,它将”变了没有?”转变为”变了多少?”
  • 对每一次变化进行分类:字段变化、内容变化或噪音。每一类都值得不同的应对方式。
  • 按站点和页面类型分别调整阈值。适用于商品页面的阈值,放在新闻主页上就是错的。

为什么简单的差异比较会失败

为了具体说明这个问题,我们两次抓取了一家大型新闻网站的国际版首页,间隔45秒。原始HTML如预期般不同。更有意思的是:在去除脚本、样式和标记,并移除时间戳和令牌之后,可见文本仍然不同。实时页面在持续更新。任何差异都触发警报的监控器,几乎每次运行都会对这个页面发出警报。

噪音的来源是可以预见的:

噪音示例常见修复方式
内嵌脚本和数据分析数据、JSON状态、CSRF令牌比较前剥离脚本和样式块
基于时间的文本”3分钟前更新”、时钟时间、日期用模式移除,或比较排除这些内容的字段
轮播模块广告、“热门推荐”、推荐内容只比较你关心的区域或字段
个性化与实验A/B测试变体、特定位置的模块从固定的观测点采集,使用一致的设置
排序每次加载都会打乱的列表比较前先排序

有些噪音其实源于”谁在提问”的差异。从德国加载的页面,和从美国加载的同一页面可能不同,这与页面变化毫无关系。为每个被监控的页面固定观测点,使得两次运行之间唯一变化的因素是时间。使用住宅网关时,这意味着为该任务的每次运行都固定国家,例如 customer-USERNAME-country-de。

原则一:比较字段,而非整页

最可靠的变化检测器根本不比较整页,而是提取重要的字段,例如价格、库存状态、标题、证书列表或地址,然后比较这些字段。字段要么变了,要么没变,页面上其他地方的噪音无关紧要。

结构化数据让这件事比听起来容易。许多页面以JSON-LD形式发布其关键字段,这比周围的布局稳定得多;提取方法参见停止解析HTML。当字段来自选择器时,比较提取出的值,而不是它们周围的HTML。

字段比较也让警报变得有用。“价格从89.00变为79.00”是可以采取行动的。“页面变了”则不是。

原则二:规范化那些必须比较的内容

有些监控确实是针对整页的:服务条款、供应商的”关于我们”页面、可持续发展声明、政策页面。对于这些情况,在比较之前先剥离那些永远不重要的内容:

  • 移除非内容区块: 脚本、样式、模板、内联SVG。
  • 只保留可见文本,合并空白字符并统一大小写。
  • 移除易变的片段: 时钟时间、ISO日期、相对时间、令牌和长十六进制标识符。
  • 限定到某个区域,当页面存在稳定的主要内容区,例如文章正文或主元素时。

规范化能消除大部分噪音,但无法消除全部,这就是为什么下一步很重要。

原则三:衡量变化程度,而非是否变化

与其判断规范化后的文本是否完全相同,不如判断它们有多相似。Simhash 是完成这项工作的好工具。它将一份文档压缩成一个64位的指纹,并具有一个有用的特性:相似的文档得到的指纹只在少数几个比特位上不同。Google 在《Detecting Near-Duplicates for Web Crawling》(WWW 2007)一文中描述了这一用法,作者验证了”对于一个包含80亿网页的存储库,64位simhash指纹和k = 3是合理的”,意味着指纹最多相差三位的页面可以被视为近似重复。

这使得变化检测成为一个衡量距离的问题。在我们的测试中,间隔45秒的两次新闻主页抓取相差2位:低于阈值,因此判定为噪音。同一个主页与一篇无关的文章相比,相差34位:显然是不同的内容。

import hashlib
import re

VOLATILE = [
    r"\b\d{1,2}:\d{2}(?::\d{2})?\s?(?:am|pm|AM|PM)?\b",          # clock times
    r"\b\d{4}-\d{2}-\d{2}(?:T[\d:.]+Z?)?\b",                      # ISO dates
    r"\b\d+\s+(?:second|minute|hour|day)s?\s+ago\b",              # relative times
    r"\b(?:csrf|nonce|token|session|sid)[\w-]*[=:]\s*[\w-]+",      # tokens
    r"\b[0-9a-f]{24,}\b",                                         # long hex ids
]


def normalise(html):
    """Visible text only, with volatile fragments removed."""
    html = re.sub(r"(?is)<(script|style|noscript|svg|template)\b.*?</\1>", " ", html)
    text = re.sub(r"(?s)<[^>]+>", " ", html)
    for pattern in VOLATILE:
        text = re.sub(pattern, " ", text, flags=re.I)
    return re.sub(r"\s+", " ", text).strip().lower()


def simhash(text, bits=64):
    """Charikar-style simhash over word 3-grams."""
    words = text.split()
    grams = [" ".join(words[i:i + 3]) for i in range(max(1, len(words) - 2))]
    weights = [0] * bits
    for gram in grams:
        h = int.from_bytes(hashlib.blake2b(gram.encode(), digest_size=8).digest(), "big")
        for i in range(bits):
            weights[i] += 1 if h >> i & 1 else -1
    return sum(1 << i for i in range(bits) if weights[i] > 0)


def distance(a, b):
    return bin(a ^ b).count("1")


def compare(old_html, new_html, old_fields=None, new_fields=None, threshold=3):
    """Classify a change as none, noise, content or field-level."""
    old_fields, new_fields = old_fields or {}, new_fields or {}
    field_changes = {k: (old_fields.get(k), new_fields.get(k))
                     for k in set(old_fields) | set(new_fields)
                     if old_fields.get(k) != new_fields.get(k)}
    if field_changes:
        return {"kind": "field", "changes": field_changes}
    if old_html == new_html:
        return {"kind": "none"}
    d = distance(simhash(normalise(old_html)), simhash(normalise(new_html)))
    return {"kind": "content" if d > threshold else "noise", "distance": d}

将阈值视为一个起点,而非一个常量。WWW 2007论文中的数值是为了在数十亿页面中寻找重复内容而选定的,并非为了长期监控单个页面。请按页面类型进行校准:在短时间内多次抓取每个被监控的页面,此时不应发生有意义的变化,然后将阈值设定为略高于你观察到的距离值。短页面需要更多留意,因为几个字词的变化,在小型文档中造成的指纹偏移,比在长文档中更大。

原则四:先分类,再路由

一个只返回”变了”的检测器,把真正的工作都推给了阅读警报的人。应当返回一种类别,并据此路由:

类别含义去向
field你追踪的某个值变了直接进入数据集,如果重要则发出警报
content页面实质内容发生了超出噪音范围的变化进入人工审核队列,并附上文本差异
noise页面有变动,但属于正常波动范围内记录用于校准,不发出警报
none字节完全相同无操作

有两处改进值得投入。为每一次content变化保留之前的快照和一份可读的文本差异,以便审核人员在几秒钟内做出判断。同时记录每个站点各类别的发生率:一个噪音距离在数周内逐渐上升的站点,说明它正在改变其模板;而一个突然完全不产生任何变化的站点,可能正在向你提供陈旧或被拦截的页面,这种失败模式在隐性失败率一文中有详述。

规模化扩展

大规模的变化检测本质上是一个存储和调度的问题。

  • 对于大多数运行,只存储指纹和字段,而非完整页面。 一个64位指纹和少数几个字段体积极小。仅在检测到变化时,或按更慢的周期进行审计时,才保留完整快照。
  • 按预期变化频率重新访问。 很少发生变化的页面不需要每小时检查一次。成本感知的抓取调度阐述了如何将抓取预算花在变化可能发生的地方,而每一个none或noise结果都是这个模型的证据。
  • 优先使用低成本信号。 如果某站点发送可靠的ETag或Last-Modified头,一次条件请求返回304 Not Modified,几乎不消耗带宽就能回答这个问题。
  • 监视监视器本身。 具有已知变化模式的哨兵页面能告诉你检测器本身是否仍在正常工作。

应用场景

同一套机制支撑着截然不同的任务:价格和库存监控,其中字段变化正是全部意义所在,如构建实时竞品价格数据流所述;供应商和合规性监控,其中整页内容变化才是关键,如监控供应商的公开信息足迹和核实ESG声明所述;以及在变化具有法律意义时保存证据。

结论

“这个页面变了吗?”对于现代网络来说是个错误的问题,因为答案几乎总是”是”。真正有用的问题是”我关心的字段变了吗?“,以及在必须比较整页时,“实质内容是否发生了超出该页面正常波动范围的变化?”

只要有可能就比较字段。规范化那些无法避免比较的内容,衡量相似度而非等同性,按页面类型校准阈值,并将每一类变化路由到能够采取行动的地方。最终得到的,是一个人们信任的变化信息流,而这才是唯一会被真正阅读的那种。

来源与参考资料

准备好开始了吗?

试用 Shifter 住宅代理,205M+ 个 IP,195+ 个国家,低至 $0.10/GB。

立即开始