新闻采集管线如何清洗正文并聚类去重
教程围绕新闻转载、URL变体和持续更新导致的重复问题,构建结构化采集管线。覆盖URL规范化、正文提取、日期处理及MinHash近重复聚类,并称代码片段已在样本页面运行。
AI 深度解读
同一条通讯社消息,被几十家媒体转载,再加上移动版、打印版和追踪参数,很容易在数据库里变成几十条「新闻」。如果拿页面数量衡量报道量,采集越勤,结果反而可能偏得越远。
作者给出了一条从网页到新闻数据集的处理链:统一 URL、提取正文、校准时间,再用 MinHash 聚合近似重复稿件。它值得程序员花几分钟读,因为这里真正影响分析结果的,是如何定义一条新闻,以及如何保留它的传播记录。
先弄清楚:你要数页面,还是数新闻
原文把重复来源分成四类:通讯社稿件被多家转载;同一文章存在多个 URL;持续更新的新闻出现修订版;栏目页、专题页和订阅源反复暴露同一链接。
这些情况需要分层处理。URL 统一化能解决地址差异,却无法识别两家媒体发布的同一篇通稿;正文相似度能发现转载,但不能代替链接清洗。
作者提供的 URL 处理函数,优先使用页面声明的 rel="canonical",再清除追踪参数和已知路径变体:
from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode
TRACKING = ("utm_", "fbclid", "gclid", "mc_", "ref", "cmpid")
def canonical_url(url: str, rel_canonical: str | None = None) -> str:
"""Prefer the page's own canonical link; otherwise strip tracking noise."""
url = rel_canonical or url
parts = urlsplit(url)
query = [(k, v) for k, v in parse_qsl(parts.query)
if not k.lower().startswith(TRACKING)]
path = parts.path.rstrip("/") or "/"
for suffix in ("/amp", "/print"):
if path.endswith(suffix):
path = path[: -len(suffix)] or "/"
return urlunsplit((parts.scheme.lower(), parts.netloc.lower().removeprefix("www."),
path, urlencode(sorted(query)), ""))
要看的是 startswith(TRACKING):它按前缀过滤参数,不只是删除完全同名的键。函数还会移除尾部斜杠、/amp、/print 和片段标识,统一协议与域名大小写,移除 www.,并排序剩余参数;业务参数 id=5 会保留。
不过,这些规则包含站点假设:www. 与裸域名、打印页与普通页,未必处处等价。作者也没解释,首次请求之前怎样获得页面内的 canonical 声明。实际落地需要区分请求前可做的地址清洗,以及拿到 HTML 后的 canonical 归并。
正文和时间不干净,后面的去重也会失真
新闻网页里的导航、推荐链接、图片说明,都会污染正文。不同文章共享大量模板文字,可能因此显得很像;同一篇稿件混入不同推荐内容,也可能被误判为不同文章。
作者先读 application/ld+json,寻找 schema.org 的 NewsArticle、Article、ReportageNewsArticle 或 AnalysisNewsArticle,获取 headline、publisher、articleSection 和日期。正文则优先取 <article>,找不到再退到 body,删除 nav、header、footer、aside、form、script、style、figure,最后收集段落。
示例只保留 len(p.split()) >= 8 的段落,用来过滤说明文字和短句。作者在一个包含导航、图片说明、相关推荐和页脚的样本上,提取出了标题、媒体、栏目、语言,以及只含文章段落的 51 词正文。
这属于样本验证。八个空白分隔词的门槛尤其依赖语言:中文段落通常没有英文式空格,直接照搬会误删正文。短段落也可能是有效信息。
时间字段同样需要明确规则。原文建议依次使用 datePublished、article:published_time,最后才解析可见文本;但给出的实现只覆盖前两种来源,没有实现第三种兜底。
所有可信时间转换成 UTC。例如 +01:00 时区的 09:30,应存成 08:30 UTC。to_utc 遇到缺少时区或晚于当前时间的值,会返回 None,留待检查。dateModified 则单独保存,避免下午更新的稿件被当成下午首发。
时间缺失可以标记,错误时间却会悄悄改变传播顺序。
MinHash 先缩小比较范围,再决定是否归为同稿
跨媒体转载通常有小改动:加上 UPDATED:、补一段背景,或者在末尾添加署名。全文哈希无法识别这些近似副本,而文章两两比较的成本会按平方增长。
作者采用 shingling、MinHash 和局部敏感哈希(LSH)。先把正文拆成连续词片段,再生成短签名,最后通过签名分桶,只比较可能相似的文章。
下面这段代码完整展示了候选生成过程:
import hashlib
import re
from collections import defaultdict
NUM_PERM, BANDS = 64, 16 # 16 bands x 4 rows
ROWS = NUM_PERM // BANDS
def shingles(text: str, k: int = 5) -> set[str]:
words = re.findall(r"[a-z0-9]+", text.lower())
return {" ".join(words[i:i + k]) for i in range(max(len(words) - k + 1, 1))}
def signature(text: str) -> list[int]:
"""MinHash signature: for each seeded hash, the minimum over all shingles."""
sh = shingles(text)
return [min(int.from_bytes(hashlib.blake2b(f"{seed}:{s}".encode(), digest_size=8).digest(), "big")
for s in sh)
for seed in range(NUM_PERM)]
def estimated_similarity(a: list[int], b: list[int]) -> float:
return sum(x == y for x, y in zip(a, b)) / len(a)
def candidate_pairs(signatures: dict[str, list[int]]) -> set[tuple[str, str]]:
"""LSH banding: articles sharing any identical band become candidate pairs."""
pairs = set()
for band in range(BANDS):
buckets = defaultdict(list)
for doc_id, sig in signatures.items():
buckets[tuple(sig[band * ROWS:(band + 1) * ROWS])].append(doc_id)
for ids in buckets.values():
pairs.update((a, b) for i, a in enumerate(ids) for b in ids[i + 1:])
return pairs
要注意 candidate_pairs 的返回值:它只产生候选对,没有执行最终合并。正文经过 [a-z0-9]+ 提词后,以 k=5 生成连续五词片段;blake2b 使用 digest_size=8,最终生成 64 个签名值,再分成 16 个 band,每组 4 行。
作者的测试中,添加 UPDATED: 和末尾署名的转载稿,相似度估计为 0.86,成功进入候选;无关文章得到 0.0,没有进入后续比较。
原文说,这组参数下,相似度超过约 0.5 的文章较容易成为候选,之后可再用至少 0.8 的估计相似度确认。这里的 0.5 是概率性的候选召回范围,并非硬性分界;0.8 也是示例门槛,需要结合语料调整。
确认后,应把文章归入新闻簇,通常选择最早发布的版本,或能识别出的原始来源作为代表,同时保留全部转载媒体和 URL。一小时内被 40 家媒体转载,与只被 2 家采用,提供的是不同的传播信号。
我的判断
我认同作者把 URL、正文、时间和近似去重放进同一条处理链。直接拿网页全文做相似度,或者只按标题判断重复,都容易把页面模板、改标题和转载关系混在一起。
但标题中的「规模化」,还缺少性能证据。作者说所有片段都跑过样本,却没有提供数据集规模、吞吐量、内存占用、误合并率或漏检率,也没有与其他去重方法做对照。现有结果能说明示例有效,不能证明这套参数适合一般团队。
中文适配是更直接的限制。[a-z0-9]+ 不提取汉字,纯中文正文可能退化成相同的空片段签名;只替换部署环境,无法解决这个问题。实现也没有覆盖 JSON-LD 的 @graph 等结构,新闻簇如何处理传递关系同样没说清。
因此,小规模数据可以先用更直接的相似度比较,建立人工核验样本,再决定是否引入 LSH。原文的价值在于处理顺序和数据保留策略;具体算法参数仍是待验证的工程选择。
能拿来做什么
- 如果正在做新闻聚合或媒体监测,可以同时保存文章记录和新闻簇记录。文章保留标题、正文、媒体、栏目、语言、canonical URL、发布时间、修改时间和采集时间,新闻簇保留 ID、代表文章及全部转载来源。
- 如果要用 Node 重写处理流程,可以沿用分层顺序,但应按目标站点验证 URL 规则,并为中文重新设计分词和短段落过滤;不要直接照搬两个正则与词数门槛。
- 如果准备调节
64/16/4和0.8,先建立包含转载、更新稿及无关稿件的核验样本,再检查误合并和漏检。候选召回与最终归并需要分别评估。 - 上线后按媒体监控平均正文长度和有效日期比例,并抽样保存原始 HTML,方便模板变化后重跑提取。发现入口优先使用 feed 和 sitemap,同时遵守
robots.txt、站点条款和低请求频率要求,不绕过付费墙,也不重新发布全文。