SEO

Swift Web 框架将 SEO 作为强制启用、不可关闭的核心能力,整体由 @nuxtjs/sitemap + nuxt-schema-org + @nuxtjs/robots + 框架自带 useSeo() composable 协作完成。

设计哲学:SEO 是 Nuxt 项目存在的核心理由(不需要 SEO 用 Vue3 SPA 即可), 框架本不该给"关闭 SEO"的开关。所有 swift-web 项目自动获得 sitemap.xml + robots.txt + JSON-LD 全套能力。

useSeo():一站式 SEO 元信息输出

每个 app/pages/**/*.vue 必须在 <script setup> 顶层调用 useSeo()(ESLint swift-web/require-useSeo-in-pages 规则强制,severity warn;CI 可 --max-warnings=0 升级硬阻断;配套 swift-web/seo-tdk-length 对字面量 title/description 做长度静态预检——description 显示宽度 <50 / >200、title >70 当场 warn),框架自动输出全套 SEO 元标签:

标签来源
<title> <meta name="description"> <meta name="keywords">useSeo input
<meta property="og:title"> og:description og:image og:url og:type og:locale og:site_nameuseSeo input + 自动兜底
<meta property="og:image:width"> og:image:height1200 / 630(OG 标准)
<meta name="twitter:card"> twitter:title twitter:description twitter:imagesummary_large_image + 与 og 同步
<link rel="canonical">input.canonicalPath ?? requestURL.pathname
<script type="application/ld+json">WebSite + WebPage(schemaOrg !== false 时)
<meta name="robots">input.robots(可选)

基础用法

<script setup lang="ts">
useSeo({
  title: '页面标题',
  description: '页面描述(建议 50-160 字符)',
})
</script>

详情页对接后端 SEO 节点

后端约定详情类 API 返回 seo 节点({ title, description, image }),业务侧用 ?? data 兜底链:

<script setup lang="ts">
const { data } = await useFetch(`/movie/detail/${id}`)

useSeo(() => ({
  title: data.value?.seo?.title ?? data.value?.name,
  description: data.value?.seo?.description ?? data.value?.summary,
  image: data.value?.seo?.image ?? data.value?.cover,
  type: 'article',
  article: {
    author: data.value?.author,
    datePublished: data.value?.created_at,
  },
}))
</script>

<title> 模板(titleTemplate)

交还 Nuxt 原生,framework 不注入

framework 不接管 titleTemplate(framework#89)。<title> 的站点名后缀由 applier 在 nuxt.config.ts 的 app.head.titleTemplate 用 Nuxt 原生机制配置,站点名来自 swift.config.json 的 project.title(单一事实源):

// nuxt.config.ts
import swiftConfig from './swift.config.json'

export default defineNuxtConfig({
  app: {
    head: {
      titleTemplate: `%s | ${swiftConfig.project.title}`,
    },
  },
})
// 页面
useSeo({ title: '影城会员管理', description: '...' })
// → <title>影城会员管理 | 影城管理系统</title>

自定义分隔符 / 品牌前缀 —— 直接调整 titleTemplate 字符串顺序:

titleTemplate: `${swiftConfig.project.title} - %s`   // 品牌前缀
titleTemplate: '%s · 影城管理系统'                    // 写死品牌
titleTemplate: '%s'                                  // 无品牌后缀

多语言站点名 —— Nuxt 原生 titleTemplate 是构建期静态字符串,不支持 t()。需要按语种翻译站点名时把 titleTemplate 设为 '%s',在页面用 useSeo({ title: \${t('页面标题')} | ${t('站点名')}` })` 传拼好的完整 title。

兜底行为

  • 页面没传 title → console.warn(<title> 仅剩 titleTemplate 的站点名后缀)。所有页面都应显式传 title。
  • useSeo 不再从后端 site_title 兜底 title,也不再注入 titleTemplate / 替换 %site_title。
  • JSON-LD WebSite.name 取 runtimeConfig.public.siteName(applier 从 swift.config.json 的 project.title 写入),不再读后端 site_title。

私有页 noindex(robots prop)

登录页、个人中心、后台等不应被搜索引擎索引的页面,加 robots prop:

<script setup lang="ts">
// 完全私有页(不索引、不追链接)
useSeo({
  title: '登录',
  description: '登录账号以使用完整功能',
  robots: 'private', // → <meta name="robots" content="noindex, nofollow">
})

// 中转 / 搜索结果页(不索引但允许追链接)
useSeo({
  title: '页面即将跳转',
  description: '安全外链跳转中转页',
  robots: 'noindex', // → <meta name="robots" content="noindex, follow">
})
</script>

⚠️ 不要同时在 _robots.txt 加 Disallow: /login/ —— 那会让爬虫访问不到页面,反而看不到 noindex meta,可能"无 snippet 索引"反向损害 SEO。延伸阅读 SEO 最佳实践 · sitemap.xml / robots.txt。

origin / canonical 单一事实源 + CSR 诊断

useSeo 的 origin 解析是 3 层、配置优先:

L1  显式 input.siteUrl              ← 单页覆盖(跳过 CSR 诊断)
L2  NUXT_PUBLIC_SITE_URL            配了它即**收敛**到该域名(opt-in,跳过 CSR 诊断)
L3  useRequestURL().origin          **默认**:跟随用户实际访问的域名

有 ISR 页或预渲染页的站点,都必须配 NUXT_PUBLIC_SITE_URL。

原因是被缓存的页面拿不到请求头:nitro 的 cachedEventHandler 进 handler 前会把请求头只保留 cache.varies 白名单,Host 被丢掉 ⇒ useRequestURL() 退化成 http://localhost,canonical / og:url 全错。预渲染期同理(那时根本没有真实请求)。

实测对照(同一进程、同样请求头,差异只由「是否被缓存」决定):

未被缓存的页面 → canonical = https://真域名/...   ✅
被缓存的页面   → canonical = http://localhost/... ❌

自查:curl -sI https://<域名>/<某 ISR 页> 看 canonical,不得出现 localhost。

🔴 L2 是 opt-in 的收敛开关,不是默认行为。 默认走 L3 跟随访问域名 —— 这是刻意的默认值,理由来自真实约束:①开发常不知道正式生产域名 ②生产域名会换 ③一个产品往往有多个域名。

配了 NUXT_PUBLIC_SITE_URL 才收敛 —— 那是运营决策。⚠️ 收敛对多域名产品有害:其余域名的页面全部 canonical 到这一个,等于自己声明「我是副本」、可能被搜索引擎剔除。这类产品应留空该变量,或用 L1 逐页覆盖。

⚠️ 有 prerender: true 页的项目必须配该变量(构建期没有真实请求)⇒ 无法靠留空退出收敛,多域名场景只能用 L1 逐页覆盖。

⚠️ L3 能正常工作,靠的是 framework 在 isr → cache 翻译时固定注入 varies: ['host','x-forwarded-proto'] —— 不带它,被缓存的页面拿不到 Host。

原先的一层理由(保留供参考):被缓存的 HTML 是全体访客共用的,把「本次请求的 Host」烤进去在原理上就不对;canonical 的语义本就是「首选域名」,取站点配置反而更准。代价:从副域名访问也输出主域名 canonical(这正是 canonical 该做的),og:url 同样跟随 —— 确需随访问域名变化的页面用 L1 覆盖。未配置该变量的项目行为不变(走 L3)。

预渲染(prerender / SSG)origin(framework#117):prerender 期 useRequestURL().origin 是构建机的 http://localhost,会把 og:image / canonical 烤成 localhost、分享时社交爬虫抓不到。故 useSeo 在预渲染期改取构建期环境变量 NUXT_PUBLIC_SITE_URL,缺该变量直接构建报错(暴露误配而非静默劣化)。有预渲染页的项目,构建环境必须设 NUXT_PUBLIC_SITE_URL=https://你的域名;ISR / SSR / dev 不受影响。

useRequestURL().origin 完全依赖 nginx / cloudflare 透传 Host / X-Forwarded-Host / X-Forwarded-Proto。透传漂移会让 SSR 拿到内网地址污染 SEO —— framework 在 CSR hydration 阶段比对 <link rel="canonical"> 与 window.location.origin,不一致即 dev console.warn / prod console.error 一次性诊断 + 修复指引,运维必须修。

完整诊断指引:nginx origin 透传配置。

为什么不拿 systemStore.info.site_url 兜底:它的语义跟「用户实际访问域名」脱节(framework#87),origin 链里没有它这一层——SystemInfo.site_url 字段仍保留供业务读取(如「最新地址」跳转),与 SEO 解耦。

⚠️ NUXT_PUBLIC_SITE_URL(即 runtimeConfig.public.siteUrl)不在此列:它自 framework#278 起就是上面优先级链的 L2,配了即收敛、优先于请求头,不是「仅 build-time 用」。

image 兜底链:显式 input.image → 本地 /og-image.png(#89 起不再读后端 header.og_image / header.img)。

og-image 占位图(1200×630 PNG,prebuild 强制)

每个项目 public/og-image.png 必须存在(1200×630 PNG,≤8MB),由 scripts/check-og-image.ts 在 prebuild 钩子检查,缺失则 build 失败。

业务详情页推荐用真实封面图覆盖,而非通用占位:

<SwImage
  :src="data.cover"
  alt="..."
  ratio="16/9"
  loading="eager"
  fetchpriority="high"
  preload                     ← LCP 优化:自动注入 <link rel="preload">
/>

dev 环境关键词堆砌警告

业界 2026 SEO 标准下,搜索引擎对过长的 title / description 会反向降权(判定为 keyword stuffing)。useSeo 在 dev 环境 console.warn 提示:

字段建议触发警告
title10–60 字符> 70
description50–160 字符> 200
keywords≤10 个、≤200 字符数量 > 10 或字符 > 200

生产构建 dead code elimination 后零开销,仅 dev 环境提示运营 / 开发人员。

警告输出带上页面 URL 方便直接跳转排查,同时附一行链接到 SEO 知识库 wiki。

静音单页(私有 / 工具型)

私有页(如 /admin、/internal-tools)的 title / description 通常更短,频繁告警噪音大。 传 silenceLengthWarnings: true 关闭长度告警:

useSeo({
  title: '内部工具',
  description: '仅内部使用',
  robots: 'private',
  silenceLengthWarnings: true,
})

响应式输入与单集主实体

useSeo(source: MaybeRefOrGetter<SeoInput>): void 接受同步 getter、ref 或 computed,并随数据更新 title、description、canonical、OG 与 schema。普通 SeoInput 对象仍是调用时的快照;不要把 data.value 复制进普通对象后期待切集更新,也不要传异步 getter。

JSON-LD 默认在 SSR 输出;dev 允许客户端更新,生产客户端默认不更新。需要在同一页面实例内切集同步 JSON-LD 时,在 nuxt.config.ts 显式设置 schemaOrg: { reactive: true }。head 响应式输入与生产 schema 更新是两个开关;dev 正常不能替代生产导航验收。

media.url?: string 是稳定的作品地址,默认当前 canonical;单集页必须显式传整剧地址。media.mainEntity?: 'work' | 'episode' 默认作品;仅 kind: 'series' 且有 episode 时可选单集,否则回退作品。episode.url 默认当前 canonical;单集 @id 使用该集 URL + #episode,partOfSeries 指回稳定作品 @id。 完整字段和示例见 useSeo API。

本节 API 为本地新增,待 framework#430 发布。消费方须按实际安装版本进行完整 XML 失败 / 恢复验收;本地通过不等于生产部署或收录。

JSON-LD 结构化数据

useSeo 默认输出 WebSite + WebPage schema(schemaOrg !== false 时):

{
  "@context": "https://schema.org",
  "@graph": [
    { "@type": "WebSite", "name": "...", "url": "...", "description": "..." },
    { "@type": "WebPage", "name": "...", "description": "...", "url": "..." }
  ]
}

详情页传 type: 'article' + article 字段时额外输出 Article schema。

业务可在 page 内额外调用 useSchemaOrg() 输出自定义 schema(如 Movie / Recipe / Product)。

导入路径:必须从 @unhead/schema-org/vue 导入(不是 nuxt-schema-org/schema,两者签名不同):

import { useSchemaOrg, defineMovie } from '@unhead/schema-org/vue'
useSchemaOrg([defineMovie({ name, datePublished, /* ... */ })])

sitemap.xml 与 robots.txt(动态生成)

  • /sitemap.xml 由 @nuxtjs/sitemap 模块自动生成,含静态路由 + 显式注册的动态路由
    • 静态路由自动收录零配置;动态路由([id] 等)走 sitemap.sources 端点接入(defineSitemapEventHandler 拉后端 ID,见下方 FAQ)
    • sitemap 默认不带 XSL 展示页(xsl: false,framework#445):Chrome 正在移除 XSLT,带 xml-stylesheet 的 sitemap 在浏览器里会弹弃用提示;爬虫读原始 XML,不受影响。需要时可在顶级 sitemap.xsl 重新开启,此时展示页站点名默认取 app.head.title 回填 site.name(#237),显式配 site.name 可覆盖
  • /robots.txt 由 @nuxtjs/robots 模块自动生成,框架默认配置:
User-agent: *
Allow: /
Disallow: /api/

Sitemap: <siteUrl>/sitemap.xml

必需源配置、错误状态和验收见 Sitemap 指南。

siteUrl 由 NUXT_PUBLIC_SITE_URL 锁定(framework#85)

sitemap.xml 必须输出稳定的主域名给搜索引擎,由 process.env.NUXT_PUBLIC_SITE_URL 锁定,跟 useSeo runtime origin(useRequestURL().origin)解耦 —— 跟用户访问域名变化无关。

framework 在 module setup 阶段对此变量做 host 格式校验(不是合法 URL / 协议非 http(s) / 带 path 或 query 都会 warn),见 nginx origin 透传配置 → NUXT_PUBLIC_SITE_URL 配置。

默认 exclude 私有路径(framework#85)

framework 默认 sitemap exclude 通用私有路径,避免 sitemap.xml 跟 useSeo({ robots: 'private' | 'noindex' }) 自相矛盾:

/login, /register, /forgot,
/mine/**, /profile/**, /account/**,
/admin/**, /dashboard/**,
/search, /oauth/**

业务自定义私有路径(如中转页 /link)通过 swift.config.json 追加:

{
  "framework": {
    "seo": {
      "sitemap": {
        "exclude": ["/link", "/promo-draft"]
      }
    }
  }
}

详见 ModuleOptions → seo.sitemap。

i18n strategy 与 hreflang(framework#85 / #88)

启用 @nuxtjs/i18n 时建议使用 strategy: 'prefix' 或 'prefix_except_default',让三语 URL 有独立路径(如 /, /zh-TW/, /en/),sitemap.xml 才能输出 <xhtml:link rel="alternate" hreflang="..."> 互链让搜索引擎正确识别多语言版本。

多语言站的 hreflang 互链已内置、零配置:i18n 启用且 locales ≥ 2 且 strategy 非 no_prefix 时,sitemap 自动按 locale 拆分(/sitemap.xml → sitemapindex + 每语言一份),每条 <url> 输出全语言 <xhtml:link rel="alternate" hreflang> 互链(含 x-default)。单语言站保持单份 sitemap 不变;确需覆盖走 nuxt.config.ts 顶级 sitemap.autoI18n。hreflang 是绝对 URL,多语言站需配置 NUXT_PUBLIC_SITE_URL。

strategy: 'no_prefix'(三语共用同一 URL)会被 framework dev console.warn 提示,且 sitemap 的 hreflang 集成自动跳过 —— 无独立 URL 就没有可互链的对象。

AI bot 抓取策略

swift.config.json 唯一暴露的 SEO 选项:

{
  "framework": {
    "seo": {
      "aiBotPolicy": "allow"
    }
  }
}
  • 'allow'(默认):robots.txt 不对任何 AI bot 单独设限,最大化 AI Overviews / 答案引用曝光
  • 'disallow':robots.txt 对下列 UA 整站 Disallow: /(适合订阅 / 付费内容站): GPTBot / CCBot / ClaudeBot / anthropic-ai / PerplexityBot / Bytespider / Google-Extended

disallow 拦的不只是训练爬虫,也不是全部 AI 爬虫(fw#381):清单里 PerplexityBot 是 Perplexity 的搜索检索爬虫、Google-Extended 管 Gemini 的 grounding——开 disallow 会把这两条 AI 搜索流量一起挡掉;而 ChatGPT 搜索用的 OAI-SearchBot / ChatGPT-User 与 Claude-SearchBot 不在清单里,仍按 * 放行。所以它的实际语义是「拦训练 + 拦 Perplexity / Gemini 检索,放过 ChatGPT / Claude 检索」,选它之前要接受这一点。「只拦训练、放过所有检索」的 search-only 档只预留了名字,未实现——真有站需要时再按当时各家 UA 清单落地,别照现在的清单猜。

Google 官方口径:AI Overviews / AI Mode 由 Googlebot 决定,不受 Google-Extended 影响,也不需要任何额外文件或 schema;本开关不会影响 Google 自家的 AI 搜索曝光。

SystemHeader 字段约定

后端 /system/info 返回值的 header 字段:

字段用途
site_title站点标题。不参与 SEO(不再注入 titleTemplate、不再当 title 兜底)。站点名改由 applier 在 nuxt.config.ts 的 app.head.titleTemplate + runtimeConfig.public.siteName 提供,源自 swift.config.json 的 project.title。此字段仍供业务读取
description站点描述(useSeo 描述兜底)
keywords站点关键词(useSeo keywords 兜底)
og_imageOG 默认分享图 URL(运营 CMS 上传 1200×630 PNG/JPG)
img旧后端兼容字段(新代码一律用 og_image);与 og_image 长期共存兼容,待后端全升级后再清除,不预设具体版本
site_url站点根域名(含 protocol,多副域名场景的 canonical 主源)

常见问题

为啥不引入 nuxt-og-image?

满文字 satori 动态生图运行时压力大、出图不美观。框架推荐运营在 CMS 上传真实封面图, 业务详情页用 data.cover 等真实封面,远比 satori 文字图好看。

动态路由怎么进 sitemap?

静态路由自动收录;动态详情页(/movie/[id] 等)framework 自动发现不了,走标准 sitemap.sources 端点(defineSitemapEventHandler + 后端 ID 数据源),见 Sitemap 站点地图。

私有页加了 robots: 'private',sitemap 还会包含吗?

不会。framework 默认已 exclude 私有路径(framework#85,见上文「默认 exclude 私有路径」); 业务自定义私有路径走 swift.config.json 的 framework.seo.sitemap.exclude 追加。

相关链接