你的页面在自己打自己:2026 核心更新后,用脚本批量揪出关键词蚕食
2026 工具化 SEO 实战 · 第 16 篇
配套脚本:
cannibal_detector.py(零依赖,读 GSC 导出 CSV,Python 标准库即可跑)
目录
- 00|一个反直觉的现象:内容越多,排名越差
- [01|2026 为什么蚕食更致命了](#01|2026 为什么蚕食更致命了)
- 02|最危险的是"隐形蚕食"
- [03|怎么检测:GSC 是唯一免费的权威数据源](#03|怎么检测:GSC 是唯一免费的权威数据源)
- 04|工具:关键词蚕食批量检测脚本
- 05|四个修复动作,按场景选
- 06|预防:写内容前先回答三个问题
- [07|发布前 checklist](#07|发布前 checklist)
00|一个反直觉的现象:内容越多,排名越差
很多外贸站运营会遇到这种怪事:
老板:"我们这个产品词,以前一个页面排第 8,后来我又写了一篇更详细的,怎么两篇都掉出前 20 了?"
这不是运气差,这是关键词蚕食(Keyword Cannibalization)。
简单说:你站内有两个及以上页面在抢同一个词、同一个搜索意图。 Google 不知道该推哪个,于是------
- 要么在几个页面之间来回切(今天 A 排,明天 B 排),
- 要么干脆两个都不给好位置,
- 要么选中了"不该排的那个"(比如你希望产品页排,Google 却推了博客)。
信号被拆散了。你原来的外链、内链、点击数据,全部分给了两三个"半成品",而不是集中在一个强页面上。
2026 年,这个问题被显著放大了。
01|2026 为什么蚕食更致命了
两个变化叠加:
第一,2026-03 核心更新加重了对"站内自我竞争"的惩罚。 行业复盘(Search Engine Journal 2026-03 等)指出,存在内部排名冲突的站点,可见度跌幅可达平均水平的约 3 倍。核心逻辑:核心更新强化了主题权威(topical authority)------一个站的内容结构应当协调、不重叠。多个页面抢同一个词,等于给 Google 发出一个混乱的主题信号。
第二,AI 内容工具让"批量生成"变得太容易了。 一周一篇 AI 文章发一年,几十个话题会自然地被"语义相近的长尾变体"覆盖,蚕食呈指数级累积。
行业研究数据(Studio 36 Digital 2026 等):蚕食影响 35%~50% 的大型电商站 ;有站被审计出 40+ 对互相打架的页面,自己完全没察觉。
02|最危险的是"隐形蚕食"
很多人以为蚕食 = 两个页面用了完全相同的词。这只是最简单的版本。
更致命的是"隐形蚕食"(intent-based cannibalization):两个页面用不同的词,但满足同一个搜索意图。
举例(外贸站高频):
how to choose a gate valve和gate valve buying guide------不同词,同意图。gate valve supplier和gate valve manufacturer------你以为是两个词,Google 当成一件事。- 分类页
/valves/和产品页/gate-valve/------都在抢industrial valve这种词。
隐形蚕食的工具通常不会直接标红 ,因为字面不同。它最典型的症状是:排名无故停滞/波动,但你怎么查内容质量都觉得"没问题"。
还一种很常见:旧版和新版打架 。你 2024 年发过一篇 best SEO tools,2026 年又发了一篇 top SEO platforms,两篇都在、都被索引、都在抢,谁都不强。
03|怎么检测:GSC 是唯一免费的权威数据源
其它工具(Semrush/Ahrefs 的蚕食报告)要花钱,而且它们本质也是在跑同一套逻辑。Google Search Console 的 query × page 数据是离真相最近的。
核心方法只有一句话:
导出 GSC 的 query + page 数据,看哪些 query 底下出现了 2 个及以上的你自己的 URL。
GSC 界面里可以手动点:Performance → 点某个 query → 切到 "Pages" 标签,如果出现两个以上 URL,就是蚕食候选。但页面上限 1000 行、单击查询太慢,站点一大就没法搞。
所以:导出,用脚本批量跑。
导出方式两种:
- GSC 界面:Performance → 导出(但界面导出的默认只有 query 层或 page 层;要 query+page 两层,建议用 API)。
- GSC API :
dimensions=['query','page'],一次最多 25000 行,可拉 16 个月历史。用官方脚本导出成 CSV。
04|工具:关键词蚕食批量检测脚本
CSV 就绪后(列含 query + page,有 clicks/impressions/position 更好),跑这个脚本:
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""cannibal_detector.py --- 关键词蚕食批量检测(零依赖)"""
import argparse, csv, os, sys
from collections import defaultdict
COLMAP = {
"query": ["query","查询","top queries","热门查询","search query"],
"page": ["page","网页","top pages","热门网页","landing page","着陆页","url"],
"clicks": ["clicks","点击次数","点击"],
"impressions": ["impressions","展示次数","展示","曝光"],
"ctr": ["ctr","点击率"],
"position": ["position","平均排名","排名"],
}
def norm_cols(fieldnames):
m = {}
for f in fieldnames:
key = (f or "").strip().lower()
for canon, aliases in COLMAP.items():
if key in aliases: m[canon] = f
return m
def to_num(v):
if v is None: return 0.0
v = str(v).strip().replace("%","").replace(",","")
try: return float(v)
except ValueError: return 0.0
def strip_params(url):
return url.split("?")[0].split("#")[0]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("-i", "--input", required=True)
ap.add_argument("-o", "--output", default="cannibal_report.csv")
ap.add_argument("--min-impressions", type=int, default=50)
ap.add_argument("--ignore-params", action="store_true")
args = ap.parse_args()
if not os.path.exists(args.input):
print("找不到输入文件: %s" % args.input); sys.exit(1)
with open(args.input, encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f)
cols = norm_cols(reader.fieldnames or [])
if not {"query","page"}.issubset(cols):
print("CSV 缺少 query/page 列。检测到: %s" % reader.fieldnames); sys.exit(1)
groups = defaultdict(list)
for row in reader:
q = (row.get(cols["query"]) or "").strip()
p = (row.get(cols["page"]) or "").strip()
if not q or not p: continue
if args.ignore_params: p = strip_params(p)
g = groups[q]
for item in g:
if item["page"] == p:
item["clicks"] += to_num(row.get(cols.get("clicks","")))
item["impressions"] += to_num(row.get(cols.get("impressions","")))
break
else:
g.append({"page": p, "clicks": to_num(row.get(cols.get("clicks",""))),
"impressions": to_num(row.get(cols.get("impressions",""))),
"position": to_num(row.get(cols.get("position","")))})
rows = []
for q, pages in groups.items():
if len(pages) < 2: continue
total_imp = sum(p["impressions"] for p in pages)
total_clk = sum(p["clicks"] for p in pages)
if total_imp < args.min_impressions: continue
ps = sorted(pages, key=lambda x: -x["clicks"])
winner, second = ps[0], ps[1]
positions = [p["position"] for p in pages if p["position"] > 0]
spread = (max(positions)-min(positions)) if positions else 0
no_clear = (second["clicks"] >= winner["clicks"]*0.6) if winner["clicks"] > 0 else True
top10 = sum(1 for p in pages if 0 < p["position"] <= 10)
score = 0
if total_imp >= 1000: score += 3
elif total_imp >= 300: score += 2
elif total_imp >= 100: score += 1
if len(pages) >= 3: score += 2
if no_clear: score += 2
if spread >= 10: score += 1
if top10 >= 2: score += 1
sev = "HIGH" if score >= 6 else ("MEDIUM" if score >= 3 else "LOW")
if len(pages) >= 2 and no_clear:
action = "合并最强页 + 301 重定向旧页(不明确赢家,信号被拆分)"
elif len(pages) >= 2 and not no_clear:
action = "保留赢家,弱页改为差异化内容或加 canonical 指向赢家"
else:
action = "持续观察"
rows.append({"query": q, "num_pages": len(pages), "total_clicks": int(total_clk),
"total_impressions": int(total_imp), "position_spread": round(spread,1),
"no_clear_winner": "是" if no_clear else "否",
"pages": " ;; ".join("%s(点%s/展%s/位%s)" % (p["page"], int(p["clicks"]),
int(p["impressions"]), round(p["position"],1)) for p in ps),
"severity": sev, "action": action})
order = {"HIGH":0,"MEDIUM":1,"LOW":2}
rows.sort(key=lambda r: (order.get(r["severity"],9), -r["total_impressions"]))
with open(args.output, "w", newline="", encoding="utf-8-sig") as f:
w = csv.DictWriter(f, fieldnames=["query","num_pages","total_clicks",
"total_impressions","position_spread","no_clear_winner","pages","severity","action"])
w.writeheader()
for r in rows: w.writerow(r)
sev = {"HIGH":0,"MEDIUM":0,"LOW":0}
for r in rows: sev[r["severity"]] += 1
print("疑似蚕食 query: %d | HIGH %d | MEDIUM %d | LOW %d" % (
len(rows), sev["HIGH"], sev["MEDIUM"], sev["LOW"]))
for r in rows[:12]:
print(" [%s] %s | %d 页 | 展示 %d | 建议: %s" % (
r["severity"], r["query"], r["num_pages"], r["total_impressions"], r["action"]))
print("报告已导出: %s" % args.output)
if __name__ == "__main__":
main()
用法:
bash
python cannibal_detector.py -i gsc_export.csv -o cannibal_report.csv
# 只看展示量高的问题:
python cannibal_detector.py -i gsc.csv --min-impressions 200
# 筛选页 URL 参数很多时,忽略参数避免误判:
python cannibal_detector.py -i gsc.csv --ignore-params
判断逻辑(重要):
no_clear_winner=是:前两页点击数接近(弱页 ≥ 强页 60%)------信号被明显拆分,优先处理。no_clear_winner=否:已有一个明确赢家------处理成本低,把弱页差异化或 canonical 即可。position_spread大:两个页面排名差距大,说明 Google 在"试"。- 综合展示量 + 页数 + 是否无赢家 + 位置跨度,给出 HIGH/MEDIUM/LOW。
05|四个修复动作,按场景选
检测出来之后,不是所有蚕食都要"合并"。按情况走:
| 场景 | 推荐动作 | 说明 |
|---|---|---|
| 两页内容高度重合、抢同一个词 | 合并 + 301 | 把弱页内容并进强页,弱页 301 到强页。约 90-99% 链接权重会传导过去 |
| 两页意图不同(信息 vs 交易) | 差异化 | 重写标题/正文,让各自锁定不同关键词簇,互不重叠 |
| 必须都存在的近似重复页(如产品变体) | canonical | 用 canonical 指定权威版本,消除排名分裂 |
| 薄页/标签页/筛选页抢主词 | noindex | 让它们退出索引,不再蚕食 |
合并是最高频、最有效的动作。 行业实践里,约 70% 的情况用"合并 + 301"效果最好,通常能在 1~3 个月内看到排名提升(恢复信号需要 6~12 周稳定)。
06|预防:写内容前先回答三个问题
修复是补救,止血靠流程。每写一个新内容 brief 之前,先确认三件事:
- 现有页面里,有没有已经在打这个意图的?(有就去优化它,别新开一篇)
- 我要打的词,和现有页面的目标词,是不是语义相近的"同一件事"?
- 这篇新内容,和现有内容有没有"信息增量"?(没有就别写)
再配套两个机制:
- 维护一份 keyword map:每个 URL ↔ 一个主关键词,重了立刻发现。
- 每季度跑一次本脚本:蚕食是动态的,新页面会不断制造新冲突。
07|发布前 checklist
- 从 GSC 导出 query + page 维度数据(用 API 最佳,可拉 16 个月)
- 跑
cannibal_detector.py,拿到cannibal_report.csv - 优先处理 HIGH 且
no_clear_winner=是的条目(信号拆分最严重) - 按上表 4 个场景选择修复动作(合并/差异化/canonical/noindex)
- 隐形蚕食(不同词同意图)另行人工判断------脚本只能抓同词
- 合并/重定向后 4~12 周内跟踪 GSC 排名与点击恢复
- 建立 keyword map + 季度跑脚本的预防机制
免责声明:文中 2026 数据(2026-03 核心更新对自我竞争惩罚、可见度跌幅约 3 倍、35%-50% 电商站存在蚕食、约 70% 案例合并最有效、恢复需 6-12 周等)为行业研究/多源披露,非 Google 官方声明,各家统计口径有出入。建议以 Google Search Central 文档与官方核心更新说明为准。本脚本只做 query 级粗筛,无法识别"不同词同意图"的隐形蚕食,也需结合筛选页参数与实际业务意图人工复核后再动手。