动态内容不等于搜索引擎看不到内容。只要 FastAPI 在每次请求中返回完整、可读且与用户一致的 HTML,双语文章就不需要预生成页面。真正影响抓取与语言匹配的是稳定的独立 URL、self-canonical、互相返回的 hreflang、只包含已发布内容的 Sitemap,以及与页面可见信息一致的 JSON-LD。

动态 SSR、预渲染和 Dynamic Rendering 有什么区别?

这三个术语经常被混在一起,但它们解决的问题不同。

方式 HTML 何时生成 爬虫与用户是否收到相同内容 适合场景
动态 SSR 每次请求时由服务端生成,可配缓存 数据库驱动的文章、商品、文档
预渲染/静态生成 构建或发布阶段生成文件 内容变化少、构建规模可控的网站
Dynamic Rendering 按 User-Agent 给爬虫预渲染版,给用户客户端版 可能不同 JavaScript 抓取困难时的临时兼容方案

本文讨论的是第一种:MongoDB 保存 Markdown 与发布快照,FastAPI 读取已发布数据,Jinja2 生成完整 HTML。爬虫和普通浏览器访问同一个 URL、得到同一正文,不做爬虫专用内容,也不根据 User-Agent 改变页面含义。

Google 把 Dynamic Rendering 定义为 workaround,而不是推荐的长期方案。它与普通服务端渲染不是一回事。一个请求时 SSR 的页面,也不是“没有静态 .html 文件就只能依赖 JavaScript”。

因此,是否预生成页面应由部署、缓存、内容规模和故障恢复需求决定,而不是由“SEO 必须有物理 HTML 文件”这一误解决定。

请求到达 FastAPI 后,完整页面如何生成?

一个可维护的动态文章链路可以保持得很短:

GET /articles/{slug}
        │
        ├─ Redis 命中 → 返回已缓存的完整 HTML
        │
        └─ Redis 未命中
             ├─ MongoDB 读取 published 白名单字段
             ├─ 选择 zh 或 en 发布快照
             ├─ Jinja2 渲染页面外壳
             ├─ 注入 canonical / hreflang / JSON-LD
             └─ 缓存并返回 HTML

FastAPI 官方提供 Jinja2TemplatesTemplateResponse。模板负责页面外壳,正文则在发布阶段由 Markdown 转换、消毒后写入不可变快照。公网读取不重复执行 Markdown 渲染,也不会接触草稿。

这个边界有两项好处:

  • 首字节返回的 HTML 已包含标题、摘要、正文、目录和来源,无需等待客户端 JavaScript。
  • 发布时统一处理不可信 Markdown;公开请求只读取已消毒 HTML,减少每次请求的成本和攻击面。

JavaScript 可以继续增强阅读进度、目录高亮和代码复制,但把脚本关闭后,正文、导航、语言切换与来源仍然可用。这是渐进增强,不是“为爬虫做一套页面”。

怎样让所有 SEO 信号只维护一套 URL 映射?

双语站最常见的隐性故障,不是某个标签完全缺失,而是模板、Sitemap、JSON-LD 和语言切换器各自拼接 URL。一次路由改名后,canonical 指向新地址,Sitemap 仍提交旧地址,英文按钮又跳到第三个地址;每个组件单看都“有 SEO”,合在一起却互相矛盾。

更稳妥的做法是把 locale 和 slug 交给同一组纯函数:

def article_paths(slug: str) -> dict[str, str]:
    return {
        "zh": f"/articles/{slug}",
        "en": f"/en/articles/{slug}",
    }

def article_links(slug: str, locale: str) -> dict[str, str]:
    paths = article_paths(slug)
    current = paths[locale]
    return {
        "canonical": absolute_url(current),
        "zh": absolute_url(paths["zh"]),
        "en": absolute_url(paths["en"]),
        "x_default": absolute_url(paths["en"]),
        "counterpart": paths["en" if locale == "zh" else "zh"],
    }

模板使用这份结果生成 canonical、alternate 和语言切换;JSON-LD 使用同一个 canonical;Sitemap 则遍历同一对路径。专题页、列表页和分页也应复用相同规则,避免 ?page=1、尾斜杠或域名变体成为重复 URL。

随后把不变量写进测试,而不是依赖人工逐页查看:

  • 中文和英文 canonical 都必须 self-reference;
  • 两页的 zh-Hansenx-default 集合完全相同;
  • counterpart 必须回到同一个 slug;
  • Open Graph URL 与 canonical 相同;
  • Sitemap 中每个 <loc> 都能在对应页面的 alternate 集合里找到;
  • 下线一个发布单元后,两种语言、专题聚合和 Sitemap 同时消失。

这种“单一 URL 真相源”不会直接提高排名,却能消除搜索系统最难解释的自相矛盾。它也让未来增加第三种语言时,改动集中在 locale 配置与映射函数,而不是搜索整个模板目录逐个复制标签。

部署环境也必须进入同一边界。URL 构造器应读取经过审核的站点 Origin,并信任受控反向代理传入的协议,而不是直接采用任意 Host 请求头;否则攻击请求可能把错误域名写进 canonical 或 Sitemap。生产域名、HTTPS、尾斜杠策略和语言前缀一旦确定,就应在应用层与 Nginx 层共同测试。预览环境则应输出 noindex 或使用访问控制,避免它与正式站形成两套可索引 canonical。

分页同样需要明确:第一页通常使用无查询参数的 canonical,第二页以后保留真实页码,并输出匹配该页内容的语言替代链接。不要把所有分页 canonical 到第一页,也不要让中文第二页的 alternate 跳到英文第一页。专题 slug 与名称应有权威记录,防止不同文章给同一专题生成互相矛盾的路径和面包屑。

中英文为什么需要两个稳定 URL?

不要根据 Accept-Language 在同一个 URL 上偷偷替换正文,也不要访问中文 URL 后强制跳去系统猜测的语言。更清晰的结构是:

/articles/{slug}       → 简体中文
/en/articles/{slug}    → English

Google 建议对不同语言使用可明确发现的版本,并指出系统主要依据页面可见内容判断语言,而不是只看 URL 或 lang 属性。独立 URL 还带来几个工程优势:

  • 每种语言有独立 canonical、标题、摘要和缓存键;
  • 用户可以复制并稳定分享当前语言;
  • Sitemap 可以明确列出对应关系;
  • 搜索分析可以按 URL 区分语言表现;
  • 英文可以按英文搜索意图重写,而不是机械翻译中文。

同一 slug 表示同一选题,不代表两个正文必须逐句相同。真正需要同步的是发布边界和对应关系。实现这层一致性的方式,可继续阅读FastAPI、MongoDB 与 Redis 的原子双语发布

hreflang 应该怎样与 canonical 配合?

中文详情页应该 self-canonical 到中文 URL,英文详情页 self-canonical 到英文 URL。不要把英文页 canonical 到中文页;当主内容已经翻译时,它们不是应该合并掉的重复页面。

中文页面 <head> 的核心标记可以是:

<link rel="canonical"
      href="https://www.example.com/articles/atomic-publishing">
<link rel="alternate" hreflang="zh-Hans"
      href="https://www.example.com/articles/atomic-publishing">
<link rel="alternate" hreflang="en"
      href="https://www.example.com/en/articles/atomic-publishing">
<link rel="alternate" hreflang="x-default"
      href="https://www.example.com/en/articles/atomic-publishing">

英文页面输出同一组 alternate URL,只把 canonical 改成英文自身。这里有五个容易出错的细节:

  1. 每个语言版本必须列出自己和所有对应版本。
  2. 对应页面必须互相返回,缺少 return link 时标记可能被忽略。
  3. href 使用完整绝对 URL,不使用相对路径。
  4. 简体中文可以用 zh-Hans;英文面向全球读者时使用通用 en
  5. x-default 是未匹配语言的回退,不是“主要语言权重”标记。

canonical 回答“当前页面希望作为哪个 URL 被索引”,hreflang 回答“同一内容有哪些语言或区域版本”。二者目的不同,但映射必须一致。

Google 提供 HTML、HTTP Header 和 Sitemap 三种等价的 hreflang 表达方式,并明确表示同时使用三种不会带来额外搜索收益。实际项目可以为不同消费者同时输出 HTML 与 Sitemap,但应把它们由同一个 URL 生成函数产生,并用测试保证一致,而不是把重复配置当作排名加成。

动态 Sitemap 怎样只公开真正上线的页面?

Sitemap 不应该扫描草稿,也不应该根据文件系统猜页面。它应查询:

recordType = article
state = published
published != null

然后为列表、专题和每篇文章生成语言对。双语 Sitemap 片段如下:

<url>
  <loc>https://www.example.com/articles/atomic-publishing</loc>
  <xhtml:link rel="alternate" hreflang="zh-Hans"
    href="https://www.example.com/articles/atomic-publishing"/>
  <xhtml:link rel="alternate" hreflang="en"
    href="https://www.example.com/en/articles/atomic-publishing"/>
  <xhtml:link rel="alternate" hreflang="x-default"
    href="https://www.example.com/en/articles/atomic-publishing"/>
</url>

英文 URL 还需要自己的 <url> 节点,并重复同一组 alternate 映射。Google 的多语言 Sitemap 指南要求每个版本都有独立 <url>,且每个节点列出包括自身在内的所有版本。

<lastmod> 只有在能够提供准确、对抓取有意义的最后修改时间时才应输出。内部数据库时间不等于必须公开的内容事实;如果产品决定不展示真实发布日期和修改时间,省略 <lastmod> 比生成一个看似新鲜但不准确的值更可靠。

文章发布或下线后,Sitemap 与页面缓存应一起失效。否则详情页已经上线,Sitemap 仍缺失;或者文章已经下线,旧 Sitemap 仍继续提交它。

JSON-LD 应该包含什么,又不应该编造什么?

结构化数据不是正文的替代品。它应该描述用户在页面上能够确认的信息。一个精简的双语文章 JSON-LD 可以包含:

{
  "@context": "https://schema.org",
  "@type": "Article",
  "@id": "https://www.example.com/en/articles/atomic-publishing#article",
  "url": "https://www.example.com/en/articles/atomic-publishing",
  "headline": "Atomic Bilingual Publishing",
  "description": "A practical architecture for bilingual publishing.",
  "inLanguage": "en",
  "mainEntityOfPage": {
    "@type": "WebPage",
    "@id": "https://www.example.com/en/articles/atomic-publishing"
  },
  "publisher": {
    "@type": "Organization",
    "name": "Example"
  },
  "citation": [
    "https://www.mongodb.com/docs/manual/core/write-operations-atomicity/"
  ]
}

同一页面还可以输出 BreadcrumbList,但面包屑名称与链接必须在可见界面中成立。

Google 的 Article 文档列出许多推荐属性。推荐不等于可以虚构:没有公开作者就不要创建一个人名,没有打算公开真实日期就不要生成假的 datePublished 或每天变化的 dateModified。信息缺失可能减少某些富媒体展示机会,但伪造信息会破坏信任,也让机器获得比用户更多的“事实”。

来源也应出现在正文末尾的可见列表中,再同步进入 citation。这里的 citation 是 schema.org 与本项目使用的语义补充;Google 的 Article Rich Result 文档没有把它列为富媒体展示信号,不能据此承诺额外展示。JSON-LD 不是藏关键词、隐藏证据或给 AI 单独喂内容的地方。

SSR 页面怎样同时服务 SEO、AEO 和 GEO?

动态 SSR 的价值不是某个标签能“触发 AI 引用”,而是把内容变成稳定、可抓取、可定位的表示:

  • 页面开头直接回答核心问题;
  • H2/H3 组织自然问题和完整答案;
  • 表格用于表达比较与故障矩阵;
  • 示例代码周围解释适用边界;
  • 结论与事实旁提供可见的一手来源;
  • 中文和英文分别自然写作;
  • 页面在无 JavaScript、键盘和小屏设备上仍可使用。

Google 的生成式搜索指南仍把可抓取、独特且面向人的内容视为基础。对公开网页搜索和引用而言,系统必须能通过其支持的爬虫、索引、数据提供方或检索路径访问页面。因此,技术 SEO 是可发现性的入口,原创信息和清晰证据才决定页面有没有被引用的价值。更完整的官方边界可见2026 年 GEO/AEO 技术指南

缓存与故障响应会影响抓取吗?

会,但核心是保证状态诚实。

  • 正常 HTML 可以使用 Cache-Control: public, no-cache,允许缓存保存,但复用前必须重验证。
  • 对完整 HTML 计算 ETag;If-None-Match 命中时返回 304
  • Redis 失败时回源 MongoDB,而不是把所有页面直接判为不存在。
  • MongoDB 也不可用且没有缓存时,返回同语言 HTML 503Retry-Afternoindex
  • 真正不存在或未发布的 slug 返回 HTML 404noindex
  • GET 与 HEAD 应共享状态码和主要响应头;HEAD 不传正文。

不要在数据库短暂故障时返回 200 空页面,也不要把所有异常改写成永久 404。搜索引擎会根据状态码理解“临时不可用”和“不存在”,页面壳的文案不能替代正确的 HTTP 语义。

上线前如何检查一组双语页面?

页面内容

  • curl 获取的原始 HTML 已包含完整正文,不依赖脚本执行。
  • 中文 URL 的正文主要是中文,英文 URL 的正文主要是英文。
  • 两个页面标题、摘要、可见来源和内部链接自然匹配各自语言。
  • 页面不显示未经产品确认的作者或日期。

URL 与标记

  • 两个页面都是 200,且不会按浏览器语言自动重定向。
  • 每页 canonical 指向自己。
  • 两页输出完全一致的 zh-Hansenx-default 集合。
  • HTML langContent-Language 和可见正文一致。
  • Open Graph URL 与 canonical 一致。

Sitemap 与结构化数据

  • 只有已发布快照进入 Sitemap。
  • 中文和英文各有一个 <url> 节点,alternate 映射互相完整。
  • JSON-LD 的 URL、语言、标题和描述对应当前页面。
  • JSON-LD 不包含页面无法证实的作者、日期或图片。
  • 引用来源既在 HTML 可见,也能在结构化数据中找到。

故障与缓存

  • ETag 重验证能返回 304
  • HEAD 与 GET 状态一致。
  • Redis 故障能回源数据库。
  • 数据库故障返回临时 503,而不是空白 200 或永久 404
  • 发布和下线会同时更新两种语言、列表、专题与 Sitemap 的缓存版本。

这里检查的是搜索系统能观察到的结果。关于 MongoDB 已提交而 Redis 失效失败时如何安全重试,请看原子双语发布中的状态修订设计,不要在本层重复实现另一套发布状态机。

常见问题

动态 SSR 页面必须预生成 HTML 才能被收录吗?

不必。只要请求时返回的初始响应包含完整可抓取 HTML,并且状态码、链接和 robots 允许抓取,就不存在“必须落地成物理文件”的要求。预生成是部署策略,不是 SEO 身份。

hreflang 能代替翻译质量吗?

不能。Google 主要依据可见内容判断页面语言。只有导航被翻译、正文仍相同的页面,不能靠 hreflang 变成高质量本地化版本。

中英文页面应该互相 canonical 吗?

不应该。完整翻译后的页面分别 self-canonical,再用 reciprocal hreflang 建立语言对应。把英文 canonical 到中文可能让英文 URL 被合并掉。

是否必须在 HTML 和 Sitemap 中都写 hreflang?

不必须。Google 认为 HTML、HTTP Header 和 Sitemap 方法等价。若同时输出 HTML 与 Sitemap,应出于系统一致性或其他消费者需求,并保证它们来自同一映射。

Article JSON-LD 没有作者和日期会报错吗?

不会因此变成无效 JSON-LD。Google 当前 Article 文档没有必需属性;作者和日期是在适用时建议提供的属性。可以省略,但不要为了填字段而虚构。缺少推荐属性可能限制某些展示机会,这比发布不真实信息更可控。

动态 SSR 能保证收录或 AI 引用吗?

不能。完整 HTML、正确状态码和一致搜索标记只是可发现与可理解的基础,任何平台都不保证抓取、收录、排名或引用。页面还必须提供与查询相关、具有信息增益且可核验的内容,并用平台报告和访问数据观察真实结果。