SEOAstro建站

双语站的 <head> 里到底该放什么——canonical、hreflang 与结构化数据

给一个中英双语 Astro 站补齐 SEO head。重点是 hreflang 组的正确性(自引用 + 互指 + x-default,只指对方语言是错的)、没有译文的文章不该发 hreflang,以及文章页和首页各自该发哪种 JSON-LD。

站的每篇文章都有中英两版:中文在 /blog/<slug>/,英文在 /en/blog/<slug>/,Astro 的 i18n 路由 prefixDefaultLocale: false 让中文落在根、英文落在 /en/。内容早就双语了,但 <head> 一直很单薄:只有 titledescription 和一个指向对方语言的 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-CNen 是这组的两个成员(无论当前在哪一页,都把两个都列全),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——ogTypepubDatetags。文章页把 frontmatter 透传进来,其余页面用默认的 website。再配 og:localezh_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 + PersonPersonsameAs 把 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——只要站是双语的,这些就都得有两份,漏一份就露馅。

留言

  • 加载中…

留言先审后发,通过后公开显示;邮箱只有站主可见。