原创

AI 内容去重实战:URL 规范化、标题相似度与内容指纹

面向 Node.js 抓取与内容管线,组合 URL 规范化、标题相似度、正文指纹、幂等写入和人工复核,建立可追溯的分层去重流程,并说明 canonical 不能替代数据去重与唯一约束。

AI 内容工程 AI 内容去重 Node.js URL 规范化 内容指纹 幂等
AI 内容工程专题 · 第 7/11 篇查看专题目录 →

一个内容管线同时抓取 RSS、站点列表页和公众号精选时,同一篇文章很容易以不同形式进入系统:URL 带着不同跟踪参数、标题增加栏目后缀、正文被转载或更新,定时任务重跑也可能再次写入。若直接把每条抓取结果交给模型总结,不仅浪费调用资源,还会让文章列表、专题页和站点地图出现重复内容。

可靠的去重不能只靠一个规则。更适合工程落地的方案是分层判断:先用规范 URL 拦截确定重复,再用标题相似度筛选候选,最后用正文指纹提供内容证据;无法确定的条目进入人工复核,而不是静默删除。

本文聚焦 AI 内容去重 Node.js 管线。任务队列与抓取解耦可参考AI 内容流水线:同步脚本还是任务队列,数据持久化升级可继续阅读Node.js 内容管线从 JSON 迁移到 SQLite。

一、先区分数据去重和搜索引擎 canonical

rel="canonical" 用来向搜索引擎表达一组相似页面中偏好的规范网址。它是索引信号,不会阻止你的抓取程序保存两条记录,也不会替数据库执行唯一约束。

两者职责应明确分开:

  • 管线去重:决定一条抓取结果是新增、更新、疑似重复还是跳过;
  • 数据库幂等:确保相同任务重试不会重复插入;
  • 站内 canonical:文章详情页输出唯一、可访问的首选 URL;
  • 重定向:旧 slug 或别名确认迁移后,用 301 指向规范页面;
  • 站点地图与内链:只使用规范 URL,避免持续向搜索引擎发送冲突信号。

因此,页面已经有 canonical,并不代表后台可以不做去重。相反,如果后台误合并两篇不同文章,再正确的 canonical 也无法找回被覆盖的数据。

二、建立稳定的规范 URL

URL 第一层适合处理确定性重复,例如 utm_source 不同、主机名大小写差异、默认端口或 fragment 不同。Node.js 的 URL 类可以承担解析工作:

const TRACKING_PARAMS = new Set([
  'utm_source', 'utm_medium', 'utm_campaign',
  'utm_term', 'utm_content', 'spm', 'from'
]);

export function normalizeUrl(input) {
  const url = new URL(input);
  url.hash = '';
  url.hostname = url.hostname.toLowerCase();

  for (const key of [...url.searchParams.keys()]) {
    if (TRACKING_PARAMS.has(key.toLowerCase())) {
      url.searchParams.delete(key);
    }
  }

  url.searchParams.sort();
  if (url.pathname !== '/') {
    url.pathname = url.pathname.replace(/\/+$/, '');
  }
  return url.toString();
}

不要全局删除所有查询参数。?page=2、?lang=en、文章 ID 或版本参数可能对应不同内容。更稳妥的做法是维护通用跟踪参数白名单,再为已确认的来源配置站点级规则。

还要单独保存 sourceUrl 和 normalizedUrl。前者保留证据,后者用于查重;不要为了规范化而丢失原始地址。

三、用标题相似度寻找候选,而不是直接删除

完全相同的标题可以快速匹配,但转载标题常会增加前后缀,例如“深度解读”“今日精选”或媒体名称。可以先做标题清洗,再使用 token 集合的 Jaccard 相似度筛选候选:

function normalizeTitle(value) {
  return String(value)
    .normalize('NFKC')
    .toLowerCase()
    .replace(/[\s\p{P}\p{S}]+/gu, ' ')
    .trim();
}

function bigrams(value) {
  const text = normalizeTitle(value).replace(/\s+/g, '');
  const result = new Set();
  for (let i = 0; i < text.length - 1; i += 1) {
    result.add(text.slice(i, i + 2));
  }
  return result;
}

function jaccard(left, right) {
  const a = bigrams(left);
  const b = bigrams(right);
  if (!a.size && !b.size) return 1;
  const intersection = [...a].filter(token => b.has(token)).length;
  return intersection / new Set([...a, ...b]).size;
}

这个分数只能表达字符片段的重合程度,不能证明语义相同。“模型 A 对比模型 B”和“模型 B 对比模型 A”可能高度相似,但文章观点、时间或测试版本不同。标题分数适合缩小正文比对范围,不适合单独触发永久删除。

阈值也不应凭感觉写死。应从本站历史文章中抽取一批候选,由编辑标注“重复、相关但不同、完全不同”,再观察不同阈值下的误判和漏判。没有完成标注前,应将中间区域全部送入复核。

四、正文指纹提供更强的内容证据

对正文去除 HTML、导航、空白差异和常见模板后,可以计算 SHA-256。完全一致的清洗正文会得到相同指纹:

import { createHash } from 'node:crypto';

export function contentFingerprint(text) {
  const normalized = String(text)
    .normalize('NFKC')
    .replace(/<[^>]+>/g, ' ')
    .replace(/\s+/g, ' ')
    .trim();

  return createHash('sha256')
    .update(normalized, 'utf8')
    .digest('hex');
}

密码学哈希适合判断规范化后完全一致的正文,但只改一个字,哈希也会完全不同。转载时增加编辑导语、免责声明或相关推荐,单一 SHA-256 就无法识别近似重复。

工程上可以同时保存:

  1. contentHash:完整清洗正文的精确指纹;
  2. paragraphHashes:去重后主要段落的指纹集合;
  3. contentLength:辅助发现抓取缺失或模板污染;
  4. extractorVersion:记录正文清洗规则版本。

比较段落集合可以识别“正文大部分相同但前后增加模板”的情况。若要引入 SimHash、MinHash 或向量相似度,也应把它们当作候选信号,并通过标注语料验证,不能把相似度直接包装成事实。

五、组合证据形成可解释决策

去重服务不应只返回 true 或 false,而应输出决策、理由和证据:

function decideDuplicate(candidate, existing) {
  if (candidate.normalizedUrl === existing.normalizedUrl) {
    return { decision: 'duplicate', reason: 'same_normalized_url' };
  }
  if (candidate.contentHash === existing.contentHash) {
    return { decision: 'duplicate', reason: 'same_content_hash' };
  }

  const titleScore = jaccard(candidate.title, existing.title);
  if (titleScore >= candidate.reviewThreshold) {
    return {
      decision: 'review',
      reason: 'similar_title',
      titleScore
    };
  }
  return { decision: 'new', reason: 'no_strong_match' };
}

实际系统还应限制候选范围,例如只比较相近发布日期、相同语言或相同主题中的记录,避免全表两两计算。所有阈值和规则版本都应写入判定记录,方便日后解释为什么某篇文章没有发布。

建议使用四种状态:

  • new:没有强匹配,可以进入后续处理;
  • update:规范 URL 相同,但正文指纹变化,需要保留版本;
  • duplicate:证据明确,关联到主记录;
  • review:信号冲突或处于灰区,由人工决定。

“URL 相同、正文变化”尤其不能简单跳过。它可能是原文修正、抓取失败后恢复,或动态页面内容漂移,应保存旧指纹和抓取时间后再更新。

六、让抓取和入库具备幂等性

定时任务、网络超时和 worker 重启都会导致同一任务重试。幂等键应来自稳定业务事实,而不是每次随机生成的任务 ID。可以组合来源、规范 URL 和内容指纹:

function buildIdempotencyKey(item) {
  return createHash('sha256')
    .update([item.source, item.normalizedUrl, item.contentHash].join('\n'))
    .digest('hex');
}

数据库层仍要设置唯一约束,并用事务或原子 upsert 兜底。应用层“先查询、再插入”存在并发窗口:两个 worker 可能同时查不到,然后各写入一次。SQLite 可通过唯一索引与冲突处理表达这一约束;迁移方案见Node.js 内容管线从 JSON 迁移到 SQLite。

CREATE UNIQUE INDEX IF NOT EXISTS idx_ingest_idempotency
ON ingest_items(idempotency_key);

INSERT INTO ingest_items (idempotency_key, normalized_url, content_hash)
VALUES (?, ?, ?)
ON CONFLICT(idempotency_key) DO NOTHING;

需要注意:内容更新后指纹变化,幂等键也会变化。因此还应以 source + normalized_url 定位逻辑主记录,以内容指纹建立版本记录。这样既不会重复新增,又能保存来源内容发生过变化的事实。

七、AI 应放在确定性规则之后

大模型可以辅助判断标题改写、摘要是否描述同一事件,但不适合成为第一层过滤器。更合理的顺序是:

  1. URL、唯一约束与精确哈希处理确定性重复;
  2. 标题和段落相似度形成疑似重复候选;
  3. 必要时让 AI 输出结构化判断和证据;
  4. 高风险或低置信结果进入人工复核;
  5. 人工决定回写为规则评估样本。

AI 返回的“置信度 0.92”不是经过校准的概率。后台应展示两篇标题、规范 URL、正文差异、来源时间和模型理由,而不是只显示一个分数。模型配置、提示词版本和响应也要留档,可参考AI 总结质量评估与回归测试建立固定测试集。

八、人工复核要能撤销和追踪

去重误判的代价可能是原创内容消失,因此复核操作不应物理删除候选。建议保存如下审计记录:

{
  "candidateId": "item-new",
  "matchedId": "item-existing",
  "decision": "not_duplicate",
  "reason": "同一产品的不同版本更新",
  "reviewedBy": "editor-id",
  "reviewedAt": "2026-08-13T12:00:00.000Z",
  "ruleVersion": "dedup-v1"
}

后台至少应支持:并排查看、标记重复、确认非重复、选择主记录、撤销决定和查看历史。人工确认“非重复”的配对应加入例外或训练评估集,避免每次重跑都重复报警。

九、常见误判与防护

标题相同但正文不同

日报、版本发布和系列教程经常重复使用标题模板。加入发布日期、来源栏目和正文证据,禁止仅凭标题自动合并。

URL 不同但内容相同

转载、AMP、打印页和移动端页面可能属于同一内容。保留来源关系,选择主记录展示,但不要伪造转载来源的 canonical 控制权。

正文相似但属于更新版本

产品发布说明常在旧文基础上更新。若日期、版本号或关键段落变化,应作为版本或独立文章复核,而不是按相似度覆盖。

清洗器把不同文章变成相同文本

正文提取失败时,两个页面都可能只剩“请开启 JavaScript”。设置最小正文质量规则,并记录 extractorVersion;异常短文不能进入自动重复判定。

十、推荐的落地顺序

  1. 保存原始 URL、规范 URL、抓取时间和来源;
  2. 为明确跟踪参数建立最小规范化规则;
  3. 保存正文清洗结果、精确指纹和清洗器版本;
  4. 数据库增加幂等键与唯一约束;
  5. 标题相似度只用于召回候选;
  6. 后台增加可撤销的人工复核队列;
  7. 用人工决定构建标注集,再校准阈值;
  8. 监控新增、更新、重复和复核数量的异常变化。

规则发布前,先对历史数据离线运行,只生成报告,不直接修改生产记录。确认误判范围后再启用写入,并准备按规则版本回滚。

上线检查清单

  • canonical URL 与内部数据唯一键职责分离;
  • 原始 URL 不因规范化而丢失;
  • 查询参数删除规则经过站点级确认;
  • 精确正文哈希记录清洗器版本;
  • 标题相似不会直接触发物理删除;
  • 重试和并发写入由数据库唯一约束兜底;
  • URL 相同、正文变化会保存版本;
  • 人工复核有证据、操作者、时间和撤销能力;
  • AI 判断记录模型与提示词版本;
  • 未经真实标注评估,不宣传去重准确率。

FAQ

有 canonical 后还需要后台去重吗?

需要。canonical 面向搜索引擎,是规范页面信号;后台去重解决重复抓取、重复总结、重复入库和列表展示问题,两者不能互相替代。

能不能只用标题相似度?

不建议。相同标题可能对应不同日期或版本,高相似标题也可能讨论不同结论。标题相似度适合召回候选,最终判断应结合 URL、正文、时间和人工证据。

SHA-256 能发现改写后的转载吗?

不能。它适合识别规范化后完全一致的文本。轻微改写会产生不同哈希,需要段落集合、近似指纹或语义信号辅助,并通过人工标注验证。

重复文章应该直接删除吗?

通常不应在自动流程中物理删除。先关联到主记录并保留来源、判定理由和审计记录;只有在备份与撤销机制明确后,才考虑后续清理。

总结

可持续的内容去重不是寻找一个万能相似度,而是建立证据逐层增强的决策管线。规范 URL 和精确指纹处理确定性问题,标题与段落相似度缩小候选范围,数据库唯一约束保证重试幂等,人工复核则守住误判边界。

同时把 canonical 留在 SEO 层:它负责表达规范页面,不能代替数据治理。只有当每次合并都能解释、能撤销、能追踪规则版本,AI 内容管线才不会为了消除重复而误删真正有价值的原创文章。

实测与内容说明

实测记录

  • 本文提供分层去重设计与 Node.js 示例,代码已按 ESM 语法静态审阅,但未在本站生产语料上测得准确率、误判率、吞吐量或性能提升,不宣称任何未经验证的实测数字。
  • 标题相似度阈值、正文截断长度与跟踪参数列表均为演示配置;正式使用前必须基于本站语料标注重复与非重复样本,评估误判并保留人工复核。
  • URL 规范化规则具有站点差异,删除查询参数、合并路径或忽略 fragment 前,应确认不会把分页、语言版本、筛选结果或不同正文错误合并。
  • canonical 是给搜索引擎的规范网址信号,不代替采集、存储和发布管线中的数据去重与唯一约束。

参考资料

内容版本 1.0 · 审核:推荐智能手记 · 计划复审:2026-11-13