让谷歌忽略你所有译文的 canonical 错误
agentready.md 用路径前缀发布十种语言。每个页面都有 /about、/es/about、/ja/about 等十个地址。有好几个月,九个译文版本的 <head> 里都是同一行:
<link rel="canonical" href="https://agentready.md/about">
/es/about 等于在对搜索引擎说:我是英文页面的副本,请去收录那一个。/ja/about 也这么说,另外七个同样如此。十种语言,只有一种可被收录。
canonical 到底声明了什么
rel="canonical" 不是在提示哪个网址更整洁,而是一个断言:这个页面和我指向的页面是同一个,值得进索引的是被指向的那个。链接、排名信号乃至索引条目本身,全部记到目标身上,这个页面自己退出竞争。
/product?color=blue 指向 /product 时,这正是你想要的行为;用在译文上就完全相反。西班牙语页面不是英文页面的副本,它是给另一批读者看的另一个页面,应该凭自己在西班牙语搜索里获得排名。
它为什么活了这么久
我们的路由器在分发之前先剥掉语言前缀,所以对 /es/about 的请求到达处理函数时变成了 /about,语言信息另走一路。这个设计本身是合理的,路由只声明一次就能在所有语言下工作,但 request.url 里已经没有前缀了,而 <head> 模板正是拿这条光秃秃的路径去拼 canonical。
在英文下这个 bug 输出的结果是对的,因为英文本来就没有前缀。你用母语开发时看到的每个页面都正常,出问题的只有你不读的那九种语言。
修复只有一次调用,在输出网址之前把前缀补回去:
<%# request.url 已经丢掉了 /es/ 前缀 —— localizedUrl 把它补回来。 %>
<% const canonicalHref = localizedUrl(canonicalPath); %>
<link rel="canonical" href="<%= baseUrl %><%= canonicalHref %>">
规则:每个语言版本的 canonical 都指向自己
永远使用自引用的 canonical。/es/about 声明 /es/about,/ja/about 声明 /ja/about,译文没有例外。即使两个语言版本几乎一模一样,比如同一个产品页只差三个词,也不改变结论:语言不同不算重复内容。
hreflang:每个版本都要列出全部版本
canonical 决定收录哪个页面,hreflang 决定把已收录的哪个页面展示给哪位读者。两者都需要,而 hreflang 有两条规则最常被做错。
每个版本都列出全部版本,包括它自己。 十种语言意味着十个页面上各有十个 alternate 链接,自引用也在其中。缺少自引用是一组本来正确的标记里最常见的毛病。
这些链接必须互相指认。 如果英文页面指了西班牙语页面,而西班牙语页面没有指回来,搜索引擎会丢弃这条关系,往往连整组一起丢弃,因为无法核实。任何人都能自称是别人的译文,只有对方页面能确认这件事。
用一份列表生成,而不是手工维护十处:
<% ['en','es','fr','de','pt','it','ja','zh','ko','ru'].forEach(l => { %>
<link rel="alternate" hreflang="<%= l %>" href="<%= baseUrl %><%= localizedUrl(canonicalPath, l) %>">
<% }) %>
<link rel="alternate" hreflang="x-default" href="<%= baseUrl %><%= canonicalPath %>">
如果一个版本就能服务该语言的所有读者,用纯语言代码(es)即可。只有当你确实按市场发布不同版本时才加地区(es-ES、pt-BR);凭空造出并不存在的地区,只会把这组标记白白拆散。
x-default 不是又一个译文
x-default 指定的是:当读者的语言你根本没有发布时,该给他看哪一个版本。有人用荷兰语搜索而你没有荷兰语,他拿到的就是 x-default 指向的页面。
它不是「最重要的语言」,也不是轮换里多出来的一项。我们的 x-default 留在没有前缀的英文网址上,因为整个站点本来就以它为兜底。把它指向某个带语言的页面,比如 /es/about,就等于把所有没有对应译文的读者都送去看西班牙语,比默认情况更糟。
一行命令查出来
整个问题在终端里看得一清二楚。遍历你的语言,打印每个页面的声明:
for p in "" es/ fr/ de/ pt/ it/ ja/ zh/ ko/ ru/; do
printf '%-4s ' "${p:-en}"
curl -s "https://example.com/${p}about" | grep -o '<link rel="canonical"[^>]*>'
done
正确的输出里,每一行都指向自己的路径:
en <link rel="canonical" href="https://example.com/about">
es <link rel="canonical" href="https://example.com/es/about">
fr <link rel="canonical" href="https://example.com/fr/about">
十行全是同一个网址,就是本文说的这个 bug。请对你改过的每个模板都跑一遍,而不只是首页:canonical 通常是按布局拼出来的,一个有三套布局的站点,可能只有两套是对的。
其他几种出错方式
canonical 里带上了跟踪参数。 如果你直接拿原始请求网址去拼 canonical,那么从 ?utm_source=newsletter 进来的访客得到的 canonical 就指向 ?utm_source=newsletter。每一次分享都会造出一个自称 canonical 的新网址,于是一个页面对应无穷多个网址。改成从路径部分构建,只把真正代表另一个页面的参数补回去,比如筛选后的列表或分页,并固定顺序,这样同一个页面就无法写出两种自己的名字。
canonical 指向一个跳转。 如果 /es/about 声明的是 /es/about/,而后者又 301 回 /es/about,你就给爬虫留了一个要解开的圈,也给了它怀疑这个信号的理由。指向返回 200 的那个网址。
两个 canonical 互相矛盾。 HTML 里的 <link> 和 HTTP 响应头 Link: <...>; rel="canonical" 同样有效,而 CDN、插件和反向代理都爱加这个头。两者不一致时结果是未定义的,通常也不是你想要的那个。所以响应头也要看:
curl -sI https://example.com/es/about | grep -i "^link:"
什么都没有才是好答案,除非那是你自己特意加的。
分析你的网站 · Meta 标签生成器 · AI 就绪清单