双语站的 <head> 里到底该放什么——canonical、hreflang 与结构化数据
给一个中英双语 Astro 站补齐 SEO head。重点是 hreflang 组的正确性(自引用 + 互指 + x-default,只指对方语言是错的)、没有译文的文章不该发 hreflang,以及文章页和首页各自该发哪种 JSON-LD。
站的每篇文章都有中英两版:中文在 /blog/<slug>/,英文在 /en/blog/<slug>/,Astro 的 i18n 路由 prefixDefaultLocale: false 让中文落在根、英文落在 /en/。内容早就双语了,但 <head> 一直很单薄:只有 title、description 和一个指向对方语言的 hreflang。今天把它补成一套完整的 SEO head,过程里最值得写的是几个「看着对、其实错」的细节。
canonical:每页都要,不只是首页
先补最基础的规范链接。每个页面声明自己的 canonical URL,告诉搜索引擎「不管你从哪个带参数的地址进来,这才是我的正身」:
const canonical = new URL(Astro.url.pathname, Astro.site);
<link rel="canonical" href={canonical} />
对静态博客,canonical 看着有点多余——URL 本来就干净。但它挡的是你控制不了的入口:社交平台加的 ?utm_*、别人分享时手滑带的尾斜杠差异、Pages 的重定向变体。一行成本,堵住一整类重复内容。
hreflang:只指对方语言是错的
原来的代码是这样发 hreflang 的:
{altHref && (
<link rel="alternate" hreflang={lang === 'zh' ? 'en' : 'zh-CN'} href={altHref} />
)}
中文页发一个指向英文的 alternate,英文页发一个指向中文的。看着对称、合理——但它是错的。
Google 对 hreflang 的要求是:一个语言组里的每个页面,都必须列出组内的全部版本,包括它自己。也就是说中文页不能只说「我的英文版在那儿」,它得同时说「我的中文版是我自己」和「我的英文版在那儿」。缺了自引用,Google 会认为这组 hreflang 声明不完整,整组降权甚至忽略。
改成完整的组:
const zhURL = lang === 'zh' ? canonical : altURL;
const enURL = lang === 'en' ? canonical : altURL;
{zhURL && enURL && (
<>
<link rel="alternate" hreflang="zh-CN" href={zhURL} />
<link rel="alternate" hreflang="en" href={enURL} />
<link rel="alternate" hreflang="x-default" href={zhURL} />
</>
)}
三条缺一不可:zh-CN 和 en 是这组的两个成员(无论当前在哪一页,都把两个都列全),x-default 指定「语言不匹配时的兜底版本」——我指向中文站,因为它是默认 locale。现在中文页和英文页发出的是同一组声明,互相印证,Google 才认。
没有译文的文章,不该发 hreflang
上面的 zhURL && enURL && 守卫不是随手加的。站里绝大多数文章是双语的,但结构上允许只有中文版的文章存在(altHref 为空)。这时候:
- 有 canonical——它永远指向自己,任何页面都该有;
- 没有 hreflang——一个只有中文版的页面,发一组声称「英文版在某地」的 hreflang,指向一个 404,是比不发更糟的信号。
所以逻辑是:canonical 无条件发,hreflang 组只在「确实存在对应译文」时才发。宁可不声明,也不发一组自相矛盾的声明。这类边界——「功能存在但这条数据缺失」——是 SEO meta 最容易生成脏数据的地方,值得专门守一道。
Open Graph:文章和首页是两种东西
分享到社交平台的卡片走 Open Graph。这里的关键区分是 og:type:首页是 website,文章页是 article,而 article 类型能多带一批语义:
<meta property="og:type" content={ogType} />
{ogType === 'article' && pubDate && (
<meta property="article:published_time" content={pubDate.toISOString()} />
)}
{ogType === 'article' && tags.map((tag) => <meta property="article:tag" content={tag} />)}
实现上,Base.astro 加了三个可选 prop——ogType、pubDate、tags。文章页把 frontmatter 透传进来,其余页面用默认的 website。再配 og:locale(zh_CN / en_US)加 og:locale:alternate 声明另一语言版本存在,以及 Twitter 的 summary_large_image 大图卡。一次布局改动,全站每个页面自动带上正确的社交元数据。
结构化数据:给机器读的第二份内容
<head> 里最「重」的一块是 JSON-LD——一段给搜索引擎和 AI 读的结构化描述,和给人看的 HTML 正文平行。同样按页面类型分叉:
文章页发 BlogPosting:
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "...",
"datePublished": "2026-07-09T...",
"inLanguage": "zh-CN",
"mainEntityOfPage": "https://mahui.me/blog/...",
"keywords": "SEO, Astro, 建站",
"author": { "@type": "Person", "name": "Ma Hui", "url": "...", "sameAs": [...] }
}
首页发 WebSite + Person,Person 的 sameAs 把 GitHub、X、知乎串起来——这是告诉 Google「网上这几个账号是同一个人」的标准做法,长期有助于建立实体识别。
一个务实的校验:构建后我把 dist 里所有 <script type="application/ld+json"> 抽出来逐个 JSON.parse,80 处全部合法。结构化数据是纯文本拼接,最容易的翻车方式就是某个字段里有个引号没转义、整段 JSON 废掉——构建时跑一遍解析,比上线后在 Search Console 里看到报错强。
顺手补上的:分语言 RSS
改 head 时发现一个洞:站里只有一个 /rss.xml,喂的是中文集合。英文读者没有对应的 feed,英文页 <head> 里的 RSS 链接也指向中文源。补一个 /en/rss.xml:
// src/pages/en/rss.xml.js
const posts = await getCollection('blogEn', ({ data }) => !data.draft);
再让每个页面的 RSS <link> 按语言指向对应 feed(rssHref = lang === 'zh' ? '/rss.xml' : '/en/rss.xml')。双语站的每一个「面向外部的入口」——sitemap、hreflang、RSS——都得成对出现,漏一个就是半条腿的国际化。
复盘
- hreflang 是「组」语义,不是「指向对方」的链接。每个页面都得列全组内所有成员(含自己)加 x-default,只发一条指向另一语言的 alternate 是最常见的错法;
- meta 的边界情况比正常情况值钱。「有译文」时发 hreflang 很简单,真正决定数据质量的是「没译文」时记得别发——SEO 脏数据几乎都出在这种缺失分支上;
- 按页面类型分叉 og:type 和 JSON-LD。文章是
article/BlogPosting,首页是website/WebSite,混用会让抓取工具拿到语义错位的元数据; - 结构化数据要在构建时验证。它是字符串拼出来的 JSON,一个未转义的引号就能废掉整段;把「解析全部 JSON-LD」加进构建检查,成本一行,收益是不在线上翻车;
- 国际化的入口要成对。sitemap、hreflang、RSS、OG locale——只要站是双语的,这些就都得有两份,漏一份就露馅。
留言