知识

去重抓取数据:产品、公司和列表的实体解析

同一产品、公司或列表在不同来源和不同抓取批次中多次出现。如何规范化标识符,大规模安全匹配,并保留一条干净的记录。

Matt Brown

Matt Brown

2026年9月28日 · 3 分钟阅读

从多个地方采集数据,或者从同一个地方多次采集,重复项就随之而来。同一款产品会以三个略有不同的名称出现在三个市场平台上。同一家公司在一个注册库中是”Acme Widgets Ltd”,在另一个注册库中是”ACME WIDGETS LIMITED”。同一条列表出现两次,可能是因为你在爬取时分页发生了偏移,也可能是因为一个链接带有跟踪参数而另一个没有。

如果放任不管,重复项会虚增计数、把历史记录拆分到不同条目中,并在悄无声息中破坏建立在其之上的每一个指标。本指南将介绍如何解决这些问题:规范化标识符、优先按强键匹配、在大规模场景下安全地进行模糊匹配、聚类,以及选择保留哪个版本的记录。

关键要点

  • 大多数重复项在任何模糊匹配之前,通过规范化标识符就能被发现:规范化的URL、经过校验的产品代码,以及清理过的公司名称。
  • 优先按强标识符匹配。一个有效的条形码或注册号胜过任何程度的名称相似性。
  • 永远不要把每条记录都与其他所有记录逐一比较。一百万条记录大约会产生5000亿对组合;分块(blocking)可以把这个数字降到可处理的规模。
  • 判断哪种错误代价更大。对某些任务来说,错误合并比漏掉重复项更糟;对另一些任务则相反。
  • 保留数据来源信息。合并后的记录应仍然能追溯到它来自的每一个来源。

三种重复项

类型示例如何发现
重复采集同一个URL被抓取两次,或结果分页出现重叠规范化URL或来源标识符
同一实体,不同来源同一款产品在三个市场平台上被分别列出共享标识符,再辅以模糊匹配
近似重复内容同一篇文章经过小幅编辑后被转载内容相似度,详见大规模变更检测

本指南聚焦于前两种情况,目标是让每一个真实存在的产品、公司或列表都只对应一条记录。

第一步:规范化标识符

规范化的成本很低,却能捕获的重复项比任何精巧的匹配方法都多。

URL。 同一个页面会以多种URL形式出现。将主机名转为小写、去掉www.、移除诸如utm_*、gclid和fbclid之类的跟踪参数、对剩余的查询参数排序,并去掉结尾的斜杠。http://www.Shop.com/p/123/?utm_source=x&b=2&a=1和https://shop.com/p/123?a=1&b=2会变成同一个键。

产品代码。 全球贸易项目代码(UPC和EAN条形码背后的数字)带有一个校验位,这让你能在信任这些代码之前拒绝掉输入错误或抓取有误的代码。GS1的方法是从校验位旁边的那一位数字开始,依次以3、1、3、1……的权重加权求和,然后取凑够下一个十的倍数所需的数值。在GS1自己给出的示例中,11位数主体61414121022的校验位是0。将有效代码补齐到14位,使同一代码的13位版本和14位版本能够比较相等。

公司名称。 在比较之前先统一大小写和重音符号、去掉标点符号,并移除诸如Ltd、Limited、Inc、GmbH和SA之类的法律后缀。“Acme Widgets Ltd.”和”ACME WIDGETS LIMITED”都会变成acme widgets。如果公司有注册号或法人识别码(Legal Entity Identifier),应优先使用这些标识符而非名称;关于将公司解析为标识符的内容,详见监控你的供应商的公开信息。

第二步:优先按强键匹配

一旦标识符经过规范化,基于它们的精确匹配既快速又可靠。两条具有相同有效条形码的记录就是同一款产品。两条具有相同规范化URL的记录就是同一个页面。两家具有相同注册号的公司就是同一家公司,不管它们的名称叫什么。

结构化数据在这里也很有帮助。许多产品页面会在JSON-LD中发布条形码和SKU,这比从可见页面中抓取要可靠得多;参见停止解析HTML。

第三步:模糊匹配,但仅限于分块内部

没有共享标识符的记录需要在名称、地址或描述上进行模糊匹配。这里的陷阱在于规模。将每条记录与其他所有记录比较,其增长速度是数据集规模的平方:一百万条记录会产生大约5000亿对组合。

分块(blocking)能解决这个问题。按照一个成本低廉、真正匹配的记录几乎总会共享的键对记录分组,比如国家加上规范化名称的第一个单词,或者品牌加产品类别,然后只在每个分组内部进行比较。一个好的分块键能把比较次数降低几个数量级,同时只损失很少的真实匹配。可以时不时地跨分块抽样比较一些记录对,来检查它究竟漏掉了什么。

在一个分块内部,由字符串相似度得分和一个阈值来决定是否匹配。一开始设得严格一些,大约0.9,只有在审查过更宽松的设置会合并哪些内容之后,才放宽阈值。

第四步:谨慎地聚类

匹配是成对的;实体是群组。并查集(union-find)结构能高效地把配对转化为聚类。但它也带来一个风险:传递性。如果A与B匹配,B与C匹配,那么即使A和C毫无共同之处,它们最终也会被归到一起。一长串弱匹配正是两家不同公司被合并的原因。留意异常庞大的聚类,并在接受之前先审查它们。

整个流程可以放进一个小模块里:

import re
import unicodedata
from collections import defaultdict
from difflib import SequenceMatcher
from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode

LEGAL_SUFFIXES = {"ltd", "limited", "inc", "incorporated", "llc", "gmbh", "ag", "sa", "sas",
                  "srl", "bv", "nv", "plc", "co", "corp", "corporation", "company", "oy", "ab"}
TRACKING = re.compile(r"^(utm_|gclid$|fbclid$|mc_|ref$|ref_)")


def gtin_valid(code):
    """GS1 check digit: weights 3,1,3,... from the digit next to the check digit."""
    digits = re.sub(r"\D", "", str(code or ""))
    if len(digits) not in (8, 12, 13, 14):
        return False
    body, check = digits[:-1], int(digits[-1])
    total = sum(int(d) * (3 if i % 2 == 0 else 1) for i, d in enumerate(reversed(body)))
    return (10 - total % 10) % 10 == check


def norm_name(name):
    text = unicodedata.normalize("NFKD", name or "").encode("ascii", "ignore").decode().lower()
    tokens = [t for t in re.findall(r"[a-z0-9]+", text) if t not in LEGAL_SUFFIXES]
    return " ".join(tokens)


def norm_url(url):
    parts = urlsplit((url or "").strip())
    query = urlencode(sorted((k, v) for k, v in parse_qsl(parts.query) if not TRACKING.match(k.lower())))
    host = parts.netloc.lower().removeprefix("www.")
    return urlunsplit(("https", host, parts.path.rstrip("/") or "/", query, ""))


def similar(a, b):
    return SequenceMatcher(None, a, b).ratio()


def cluster(records, threshold=0.9):
    """Group records that refer to the same entity. Returns lists of record indexes."""
    parent = list(range(len(records)))

    def find(i):
        while parent[i] != i:
            parent[i] = parent[parent[i]]
            i = parent[i]
        return i

    def union(i, j):
        parent[find(i)] = find(j)

    # 1. Exact matches on strong identifiers.
    by_key = defaultdict(list)
    for i, r in enumerate(records):
        if gtin_valid(r.get("gtin")):
            by_key["gtin:" + re.sub(r"\D", "", r["gtin"]).zfill(14)].append(i)
        if r.get("url"):
            by_key["url:" + norm_url(r["url"])].append(i)
    for ids in by_key.values():
        for j in ids[1:]:
            union(ids[0], j)

    # 2. Fuzzy name match, only within a cheap blocking key.
    blocks = defaultdict(list)
    for i, r in enumerate(records):
        name = norm_name(r.get("name"))
        if name:
            blocks[(r.get("country") or "", name.split()[0])].append((i, name))
    for members in blocks.values():
        for a in range(len(members)):
            for b in range(a + 1, len(members)):
                if similar(members[a][1], members[b][1]) >= threshold:
                    union(members[a][0], members[b][0])

    groups = defaultdict(list)
    for i in range(len(records)):
        groups[find(i)].append(i)
    return list(groups.values())

在一个小型测试集上,它会合并”Acme Widgets Ltd”、“ACME WIDGETS LIMITED”以及第三条共享该公司规范化URL的记录,合并两条条形码仅相差一个前导零的鞋类列表,并刻意把美国的”Acme Widget Co”保留为独立实体,因为分块键中包含了国家信息。最后这个决定是否正确,取决于你的数据,而这正是下一步要解决的问题。

第五步:决定保留哪条记录

一个聚类是同一实体的多个版本,而你需要的是一个。常见的存留规则(survivorship rules)有:

  • 最完整的胜出,逐字段判断:对每个字段,选取来源最可靠的非空值,而不是整条记录中的某一个。
  • 最新的胜出,适用于会变化的值,比如价格和库存情况。
  • 最可信来源的胜出,适用于官方名称和地址这类值,比如注册库的数据优先于目录网站的数据。

无论你选择哪种规则,都要在合并后的记录上保留每一个来源标识符和URL,以及每次观测的时间。当某次合并被证明是错误的时候,来源信息正是让你能够重新拆分它的依据。

衡量准确率和召回率

实体解析有两类错误,而哪一类更重要取决于具体任务。

错误发生的情况对哪类任务最不利
错误合并两个真实实体被合并为一个公司和涉及个人的数据、合规事务,以及任何法律相关事项
漏掉重复项一个实体仍以多条记录的形式存在计数、市场规模估算、价格比较

手动标注几百对候选记录,同时衡量这两种错误率,并针对你最关心的错误来调整阈值和分块键。像构建B2B潜在客户数据库中的B2B潜在客户数据库,通常对漏掉重复项的容忍度要远高于错误地把两家公司合并。而像实时竞品价格信息流这样的价格比较信息流则恰恰相反:漏掉一个重复项意味着一款产品会以两个价格出现两次。

尽早在源头去重

重复项在损害准确性之前,先损害的是成本。对同一个规范化URL的每一次重复抓取,都是白白花费的带宽或额度,这就是为什么URL规范化应该在爬虫的待抓取队列(frontier)中完成,而不仅仅是在数据仓库中完成。这对单位成本的影响,详见每条干净记录的成本。

结论

去重工作大部分是规范化。规范化的URL、经过校验的产品代码和清理过的公司名称,能在任何模糊匹配运行之前捕获大部分重复项。在此之后,优先按强键匹配,只在分块内部进行模糊匹配,聚类时留意长链条,在每条合并记录上保留来源信息,并衡量对你的应用场景真正重要的那类错误。

做得好,一个真实存在的事物就会变成一条记录,并附带完整的历史信息。做得不好,数据集看起来更大,但同时也更不可信。

来源与参考资料

准备好开始了吗?

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

立即开始