hreflang 说是英文,canonical 说是中文。Google 信了 canonical。
我是 Cody Wang。我做 Prismic、SvelteKit 和 Next.js 站的开发和优化,客户主要在新西兰,也接出海的中国品牌。
上周我上线了一个小型的 SEO 检测工具。一周里大半时间花在了上线里最不体面的那一半:确认搜索引擎真的能读到我做出来的东西。我发现了一个 bug,它悄悄把半个站点从索引里抹掉了,而它花了三天才被我看见,因为每一个页面单独打开都很正常。
#这个站是什么
它是一个探测器。你贴一个网址进去,它告诉你这个站是用什么搭的,并列出可以修的问题。
两种语言,同一个 URL,语言放在查询参数里:
/tools/cloudflare 中文(默认)
/tools/cloudflare?lang=en 英文
66 个 URL,全部服务端渲染。内容就在 HTML 里,不需要 JavaScript。
#我在对 Google 说什么
这是 Cloudflare 探测器页英文版的 head:
<html lang="en">
<title>Cloudflare Detector — check if a website uses Cloudflare</title>
<link rel="canonical" href="https://prismicaudit.com/tools/cloudflare">
<link rel="alternate" hreflang="zh-Hans" href="https://prismicaudit.com/tools/cloudflare?lang=zh">
<link rel="alternate" hreflang="en" href="https://prismicaudit.com/tools/cloudflare?lang=en">
<link rel="alternate" hreflang="x-default" href="https://prismicaudit.com/tools/cloudflare">
看那个 canonical。英文页在声明:它的真实地址是中文那一个。
也就是说,我在同一个页面上说了两句互相矛盾的话。hreflang 说英文版在 ?lang=en,canonical 说正式版本在去掉参数的地址上,而那个地址服务的是中文。两者矛盾时,Google 听 canonical。于是英文页变成了中文页的重复页,我写给英文搜索者的 1600 个英文单词,被折叠进一个他们读不懂语言的页面里。
原因很无趣。我的 head 构造函数把 lang 当作第一个参数,用它生成了 <html> 属性、标题、描述、结构化数据、字体栈。然后 canonical 那一行只用路径拼出来,因为那一行写在语言切换功能之前,后来语言切换来了,没有人回去更新它。一个被传进来、却从没用上的参数,站上每一个页面都是这样。
#我是怎么注意到的
不是靠什么监控报警。这种事根本没有报警。
Search Console 显示四周里 199 次展示、8 次点击。拿到点击的五个页面里,四个是 ?lang=en 的地址。而那些作为 canonical 目标的中文页,只能捡点零头。
这就是线索。几乎所有拿到展示的页面,都是我告诉 Google 去忽略的那些。
在你自己站上检查这件事,用 curl 比用任何工具都快:
curl -s "https://prismicaudit.com/tools/cloudflare?lang=en" \
| grep -o '<link rel="canonical"[^>]*>'
如果返回的 canonical 里没有 lang,而页面本身是英文,那你就是同一个毛病。
#修法
每个语言版本各自指定自己:
<link rel="canonical" href="https://prismicaudit.com/tools/cloudflare"> <!-- zh -->
<link rel="canonical" href="https://prismicaudit.com/tools/cloudflare?lang=en"> <!-- en -->
hreflang 那段保留,只是 x-default 现在指向英文版。这一条是判断,不是规则。x-default 是"没有语言匹配时给谁看",所以它应该指向你的用户真正在搜的那个版本。我的用户搜英文,它就指英文。面向中国市场的站,我会反过来指。
sitemap 也需要同样的处理。它原来每个路径只出一条 <loc> 加三条 alternate,结果英文地址从来没有进过 sitemap。现在每个语言各出一条,66 个 URL 变成了 132 个。
#怎么验证
我不信"看起来对了"。我写了个循环,把 sitemap 里每一条 URL 都走一遍,检查英文版的 canonical:
U="https://prismicaudit.com"
curl -s "$U/sitemap.xml" | grep -o '<loc>[^<]*</loc>' \
| sed -e 's/<loc>//' -e 's/<\/loc>//' | grep -v 'lang=en' | sed "s#^$U##" > /tmp/paths.txt
while read -r p; do
got=$(curl -s "$U$p?lang=en" | grep -o 'rel="canonical" href="[^"]*"' | head -1)
[ "$got" = "rel=\"canonical\" href=\"$U$p?lang=en\"" ] || echo "FAIL $p -> $got"
done < /tmp/paths.txt
66 个路径,66 个通过,没有输出。移动端 Lighthouse 在一个探测器页上仍然是性能、可访问性、最佳实践、SEO 四项 100。这次修复没有付出任何可测量的代价。
#修的过程中我做错的地方
首页有它自己的一套 head 构造函数,跟页面模板是分开的,所以同一个 bug 要修两次。而修首页的时候,我是在已经渲染好的 HTML 上跑正则替换,没有去改生成它的那个模板。
我手上所有的检查它都过了。但它脆在一个我以后要还的地方:那三个 link 标签的顺序一旦变了,正则就什么也匹配不到,而且是静默地匹配不到,这个 bug 会在任何地方都不报错的情况下回来。我在文件里留了备注。下次碰那个模板,就把它挪进模板。
两套 head 构造函数和一个正则。真正的教训其实不是关于 hreflang 的。
#我到现在也还不知道的
它到底有没有效。
canonical 和 hreflang 都是给爬虫看的提示。我改了提示。现在得等 Google 回来,重新抓这 132 个 URL,重新判断哪一页才是真的。这要几天到几周,而在那之前,两个版本都处在一种奇怪的状态里:现在的信号和 Google 上次看到的不一样。
所以我就在等。没有东西要设计,没有东西要上线,只是隔几天看一眼 Search Console,看英文地址有没有开始拿到它们本来就该拿到的展示。
如果你在跑多语言站,那个 curl 循环花你十分钟。Lighthouse 报告永远不会告诉你这件事。