内容站的”重复”很少是两篇一模一样的文章。几乎都是两篇文章在打同一个意图——标题不同、措辞不同、但底层 query 一样。Google 挑一个,把另一个降权。快速扩内容时,没有脚本化流程几乎肯定会出这个问题。
问题背景
重复分三种:(1) 完全相同(同一篇发了两次,少见),(2) 近重复(同一篇被改写过——AI 辅助下常见),(3) 重复意图(不同文章打同一个 query——最坏的一种,因为人眼看不出)。三种问题各有各的修法,过了 200 篇之后没法手工做。
判断标准
- 两篇文章在 Search Console 上对同一 query 反复抢位置。
- Search Console 报告的 indexed 数远低于 submitted 数——差距超过 5% 就要注意。
- Pages 报告里出现 “Duplicate without user-selected canonical” 或 “Duplicate, Google chose different canonical”。
- 两篇文章的 H1 去掉修饰语后讲的是同一件事。
- sitemap 里的条数超过你独立主关键词的数量。
开始前准备
- 这是一次 content-ops 清理,不是上线。预留 1-2 小时专注时间。
- 内容库要有备份——跑合并脚本之前
git status必须干净。 - 确认托管层支持 301——Astro +
_redirects、Firebaseredirects、Vercelvercel.json都行。
实操步骤
- 给每篇 frontmatter 加
primaryKeyword字段。 一个字符串告诉你这篇文章服务什么。已有形态参考:
---
title: "How to Submit a Sitemap to Search Console"
urlSlug: "submit-sitemap-search-console"
primaryKeyword: "submit sitemap search console"
category: "indie-dev"
---
- 跑一份”重复关键词报告”。 30 行的 Node 脚本扫一遍内容库,打出任何被两篇文章共享的 keyword:
// scripts/find-duplicate-keywords.mjs
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import matter from 'gray-matter';
const ROOT = 'src/content/articles/zh';
const byKw = new Map();
for (const cat of readdirSync(ROOT)) {
for (const file of readdirSync(join(ROOT, cat))) {
if (!file.endsWith('.mdx')) continue;
const { data } = matter(readFileSync(join(ROOT, cat, file), 'utf8'));
const kw = (data.primaryKeyword || '').toLowerCase().trim();
if (!kw) continue;
if (!byKw.has(kw)) byKw.set(kw, []);
byKw.get(kw).push(`${cat}/${file}`);
}
}
for (const [kw, files] of byKw) {
if (files.length > 1) console.log(`DUP "${kw}":\n ${files.join('\n ')}`);
}
挂到 npm run audit:content 里,重复直接让 prebuild 失败。
- 近重复用 301 合并。 选权重高的那篇(Search Console 看 impressions),把另一篇的独特段落搬过去,然后加 redirect。Firebase 写法:
{
"hosting": {
"redirects": [
{ "source": "/articles/scale-ai-content-safely",
"destination": "/articles/scale-content-with-ai-safely",
"type": 301 }
]
}
}
Astro 静态 + Netlify 风格 _redirects:
/articles/scale-ai-content-safely /articles/scale-content-with-ai-safely 301
- 重复意图:缩范围或 noindex。 要么拆角度(一篇新手、一篇进阶),改 H1 和
primaryKeyword;要么把弱的那篇标draft: true,meta 加 noindex。文章布局里写:
{frontmatter.noindex && <meta name="robots" content="noindex,follow" />}
- 全站默认 self-canonical,只在确认目标更强时跨页指向。Astro 布局里:
<link rel="canonical" href={`${Astro.site}${Astro.url.pathname}`} />
- AI 批量出稿前过一遍相似度检查。 OpenAI embedding 跑 title + 首段 cosine 就能抓出大部分:
// scripts/similarity-check.mjs(节选)
import OpenAI from 'openai';
const client = new OpenAI();
async function embed(text) {
const r = await client.embeddings.create({
model: 'text-embedding-3-small',
input: text,
});
return r.data[0].embedding;
}
function cosine(a, b) {
let dot = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
return dot / (Math.sqrt(na) * Math.sqrt(nb));
}
// cosine > 0.85 的对人工复核。
- 清理上线后,让 Google 重新抓。 用 URL Inspection 检查被合并的那个 URL,再对存活的那个 “Request indexing”。批量重定向后重新提交 sitemap。
执行检查清单
- 每篇文章都有
primaryKeyword;audit 脚本在 prebuild 标记重复。 - 301 写在
firebase.json/vercel.json/_redirects,不是只在文章正文里说。 - 默认全站 self-canonical;跨页 canonical 是 per-article 显式开启。
- 相似度检查接到 AI 内容流水线里,不是手动跑。
上线后验证
- 用 Search Console URL Inspection 重抓被合并的 URL,确认返回 301 + 目标是主 URL。
- 1-2 周后再看 Pages 报告:“Duplicate” 类原因数应下降。
- 确认 sitemap 里不再列被合并的 URL(
grepbuild 输出)。
容易踩的坑
- 相信”标题不同 = 文章不同”。意图比措辞重要得多——表现是两篇在 Search Console 里互抢曝光。
- 用 canonical “掩盖”重复但不解决问题。Google 不认同你的 canonical 时会忽略——Pages 报告会显示 “Google chose different canonical”。
- 生成”10 best X for [职业]“这种 90% 相似的页面。这种模式会触发 helpful content 处理,整个 cluster 一起掉。
- 把去重当一次性清理。它是持续的,每月都会新长出来——把检查接进 prebuild。
- 301 到一个本身也 301 的 URL。链式 redirect 会损耗信号;永远只跳一次。
- 合并后忘了从 sitemap 移除老 URL——Google 会一直抓一直再次标记。
FAQ
- 同文的中英文版本算重复吗?: 不算,前提是 hreflang 配对。EN 和 ZH 是两个 URL 服务两类受众。两边都要
<link rel="alternate" hreflang="...">互指。 - Google 会因重复扣分吗?: 大多数情况下不会有 manual penalty,但弱的那篇会被压制,比例一高,站级质量信号也会受影响。
- 直接 noindex 不行吗?: 可以,但浪费了写作精力。301 合并保留链接权重,noindex 适合没东西可合时。
- 怎么大规模检测近重复?: embedding + title + 首段 cosine 效果不错,超过 0.85 的应该人工复核。
- 相似度脚本阈值设多少?: 复核线 0.85、CI 阻断线 0.92。如果总在近邻题上误报,再往下调。