一个内容管线同时抓取 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 就无法识别近似重复。
工程上可以同时保存:
contentHash:完整清洗正文的精确指纹;paragraphHashes:去重后主要段落的指纹集合;contentLength:辅助发现抓取缺失或模板污染;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 应放在确定性规则之后
大模型可以辅助判断标题改写、摘要是否描述同一事件,但不适合成为第一层过滤器。更合理的顺序是:
- URL、唯一约束与精确哈希处理确定性重复;
- 标题和段落相似度形成疑似重复候选;
- 必要时让 AI 输出结构化判断和证据;
- 高风险或低置信结果进入人工复核;
- 人工决定回写为规则评估样本。
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;异常短文不能进入自动重复判定。
十、推荐的落地顺序
- 保存原始 URL、规范 URL、抓取时间和来源;
- 为明确跟踪参数建立最小规范化规则;
- 保存正文清洗结果、精确指纹和清洗器版本;
- 数据库增加幂等键与唯一约束;
- 标题相似度只用于召回候选;
- 后台增加可撤销的人工复核队列;
- 用人工决定构建标注集,再校准阈值;
- 监控新增、更新、重复和复核数量的异常变化。
规则发布前,先对历史数据离线运行,只生成报告,不直接修改生产记录。确认误判范围后再启用写入,并准备按规则版本回滚。
上线检查清单
- canonical URL 与内部数据唯一键职责分离;
- 原始 URL 不因规范化而丢失;
- 查询参数删除规则经过站点级确认;
- 精确正文哈希记录清洗器版本;
- 标题相似不会直接触发物理删除;
- 重试和并发写入由数据库唯一约束兜底;
- URL 相同、正文变化会保存版本;
- 人工复核有证据、操作者、时间和撤销能力;
- AI 判断记录模型与提示词版本;
- 未经真实标注评估,不宣传去重准确率。
FAQ
有 canonical 后还需要后台去重吗?
需要。canonical 面向搜索引擎,是规范页面信号;后台去重解决重复抓取、重复总结、重复入库和列表展示问题,两者不能互相替代。
能不能只用标题相似度?
不建议。相同标题可能对应不同日期或版本,高相似标题也可能讨论不同结论。标题相似度适合召回候选,最终判断应结合 URL、正文、时间和人工证据。
SHA-256 能发现改写后的转载吗?
不能。它适合识别规范化后完全一致的文本。轻微改写会产生不同哈希,需要段落集合、近似指纹或语义信号辅助,并通过人工标注验证。
重复文章应该直接删除吗?
通常不应在自动流程中物理删除。先关联到主记录并保留来源、判定理由和审计记录;只有在备份与撤销机制明确后,才考虑后续清理。
总结
可持续的内容去重不是寻找一个万能相似度,而是建立证据逐层增强的决策管线。规范 URL 和精确指纹处理确定性问题,标题与段落相似度缩小候选范围,数据库唯一约束保证重试幂等,人工复核则守住误判边界。
同时把 canonical 留在 SEO 层:它负责表达规范页面,不能代替数据治理。只有当每次合并都能解释、能撤销、能追踪规则版本,AI 内容管线才不会为了消除重复而误删真正有价值的原创文章。