
# 让谷歌忽略你所有译文的 canonical 错误

agentready.md 用路径前缀发布十种语言。每个页面都有 `/about`、`/es/about`、`/ja/about` 等十个地址。有好几个月，九个译文版本的 `<head>` 里都是同一行：

```html
<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 输出的结果是对的，因为英文本来就没有前缀。你用母语开发时看到的每个页面都正常，出问题的只有你不读的那九种语言。

修复只有一次调用，在输出网址之前把前缀补回去：

```ejs
<%# 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` 链接，自引用也在其中。缺少自引用是一组本来正确的标记里最常见的毛病。

**这些链接必须互相指认。** 如果英文页面指了西班牙语页面，而西班牙语页面没有指回来，搜索引擎会丢弃这条关系，往往连整组一起丢弃，因为无法核实。任何人都能自称是别人的译文，只有对方页面能确认这件事。

用一份列表生成，而不是手工维护十处：

```ejs
<% ['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`，就等于把所有没有对应译文的读者都送去看西班牙语，比默认情况更糟。

## 一行命令查出来

整个问题在终端里看得一清二楚。遍历你的语言，打印每个页面的声明：

```bash
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、插件和反向代理都爱加这个头。两者不一致时结果是未定义的，通常也不是你想要的那个。所以响应头也要看：

```bash
curl -sI https://example.com/es/about | grep -i "^link:"
```

什么都没有才是好答案，除非那是你自己特意加的。

[分析你的网站](/zh) · [Meta 标签生成器](/zh/tools/meta-tags-generator) · [AI 就绪清单](/zh/tools/ai-readiness-checklist)
