useSeo()

useSeo() 是 framework SEO 体系的入口 composable。每个 page 一次调用,自动输出全套 og:* / twitter:* / canonical / JSON-LD(WebSite + WebPage,可选 Article),并接管 robots meta 与 dev 长度告警。SEO 在 Swift Web 项目中强制启用(不需要 SEO 应该 选 Vue 3 SPA 而非 Nuxt),所有项目自动获得 sitemap.xml + robots.txt + JSON-LD 全套能力。

完整 SEO 规范、最佳实践、常见误区见 /guide/seo;本页只覆盖 useSeo() 这个 composable 本身的 API。

使用方式

// 基础(首页 / 静态页)
useSeo({
  title: '首页',
  description: '一句话站点描述(中文目标 25-80 字 / 英文 50-160 字符)',
})

// 详情页 + 后端页面级 SEO 节点
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,
  },
}))

// 工具型 / 私有页(title/description 天然短,关闭长度告警)
useSeo({
  title: '登录',
  description: '登录账号以使用完整功能',
  robots: 'private', // 等价于 'noindex, nofollow'
  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 正常不能替代生产导航验收。

// nuxt.config.ts
export default defineNuxtConfig({
  schemaOrg: { reactive: true },
})

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

import { ref } from 'vue'

const series = ref({ name: 'Example series', path: '/series/300', image: '/images/series.jpg' })
const episode = ref({ number: 3, name: 'Episode 3', description: 'Episode overview', path: '/series/300/episode/81' })
useSeo(() => ({
  title: `${series.value.name} ${episode.value.name}`,
  description: episode.value.description,
  canonicalPath: episode.value.path,
  media: {
    kind: 'series', name: series.value.name, image: series.value.image,
    url: series.value.path, mainEntity: 'episode',
    episode: { episodeNumber: episode.value.number, name: episode.value.name, url: episode.value.path },
  },
}))

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

SeoInput 字段

名称类型必填默认值说明
titlestring✓—页面标题(建议 10-60 显示宽度,> 70 触发关键词堆砌警告。CJK 字符算 2,ASCII 算 1)
descriptionstring✓—页面描述(建议 50-160 显示宽度,> 200 触发关键词堆砌警告,LLM 摘要关键字段)
imagestring/og-image.pngOG 图。绝对 URL / 站内相对路径;不传走本地 /og-image.png 兜底(不读后端 header.og_image)
type'website' | 'article' | 'profile''website'OG 类型。详情页传 'article' 配合 article 字段输出 Article schema
keywordsstring—关键词,逗号分隔字符串(≤10 个、≤200 字符)
localestring—i18n locale 标识,OG 用下划线格式如 'zh_CN' / 'en_US' / 'zh_TW'
siteUrlstring走 NUXT_PUBLIC_SITE_URL,未配置则 useRequestURL().origin站点根域名(含 protocol)。单页覆盖:传值即跳过 framework 的 CSR 一致性诊断(典型场景:该页需随访问域名变化)
canonicalPathstringuseRequestURL().pathnamecanonical 路径(不含 origin)
schemaOrgbooleantrue是否输出 JSON-LD。罕见场景显式 false 关闭
articleSeoArticleFields—type='article' 时的结构化字段:author / datePublished / dateModified
videoSeoVideoFields—视频页输出 VideoObject,见下「影视结构化数据」
mediaSeoMediaFields—影视详情页输出 Movie / TVSeries(+ TVEpisode)+ WatchAction
itemListSeoItemListEntry[]—频道 / 片单页输出 ItemList
robots'noindex' | 'private' | string—搜索引擎索引策略。'noindex' = 'noindex, follow';'private' = 'noindex, nofollow';也接受任意原始字符串如 'noindex, nofollow, noarchive'
silenceLengthWarningsbooleanfalse静音 dev 长度类告警。工具型 / 私有页用(robots: 'noindex' | 'private' 时长度告警无意义)

影视结构化数据(video / media / itemList)

面向 Google 视频富结果与 AI 搜索(AI Overviews / ChatGPT Search / Perplexity)的影视页 JSON-LD(framework#381)。三个入参各产一段,与 WebSite / WebPage 挂进同一个 graph、@id 互引;WebPage.mainEntity 默认指向作品(无作品则指视频),显式 media.mainEntity: 'episode' 时指向本集。缺必填整节点不输出(残缺节点会被 Google 判错,不如不出)并 dev 告警点名缺的字段。

入参产出必填字段可选字段
video: SeoVideoFieldsVideoObjectname / thumbnailUrl / uploadDate(ISO 8601)description(默认页面 description)/ duration(秒,框架转 PT1H30M)/ embedUrl(播放器本身的地址,不能是视频所在页,不传就不出)/ contentUrl / expires / regionsAllowed / views
media: SeoMediaFieldsMovie 或 TVSeries(kind)+ WatchAction(isAccessibleForFree: true);kind='series' 且传 episode 时另出 TVEpisodekind / name / imageurl / mainEntity: 'work' | 'episode' / description / datePublished / genre[] / countryOfOrigin / inLanguage(默认 i18n 当前 locale)/ actor[] / director[] / numberOfEpisodes(仅 series)/ episode: { episodeNumber, name?, url? }
itemList: SeoItemListEntry[]ItemList(ListItem 位置从 1 起)每条 urlname
// 剧集播放页:作品 + 本集 + 视频三节点互引
useSeo({
  title: `${series.name} 第 ${ep.no} 集`,
  description: series.seo.description,
  image: series.seo.img,
  video: { name: `第 ${ep.no} 集`, thumbnailUrl: series.seo.img, uploadDate: ep.publishedAt, duration: ep.durationSeconds ?? undefined },
  media: { kind: 'series', name: series.name, image: series.seo.img, genre: series.tags, numberOfEpisodes: series.total, episode: { episodeNumber: ep.no, name: ep.title } },
});

// 频道 / 片单页
useSeo({ title: '悬疑剧', description: '…', itemList: list.map((m) => ({ url: `/movie/${m.id}`, name: m.name })) });

三条硬约束(都踩过):

  • thumbnailUrl / image 必须是 Googlebot 能取、能解码的普通图 —— 不能给 .bnc 加密封面,用后端 seo.img 原图(同 og:image 那个坑)。
  • contentUrl 给不了(签名 m3u8 几小时过期、供应商禁对外缓存)就给 embedUrl,但它必须是独立播放器地址(iframe / /embed/:id 一类),不能是视频所在的详情页;两者都没有时 Google 抓不到视频、富结果不会出——这时 VideoObject 只剩 name / thumbnailUrl / uploadDate,价值有限。
  • duration 没有就不传,别编;views 同理。

不做:llms.txt(Google 明确不用、爬虫几乎不取);FAQ 富结果(页面没有真实可见问答就别输出)。GEO 的大头是 SEO 基本功 + 直答式简介 + 语义标签与 schema 一致,见 issue framework#381 §1.4。

origin 决策与 image 兜底链

origin 解析:3 层、配置优先

  1. L1 显式 input.siteUrl —— 单页覆盖(多副域名想把 canonical 强制收敛到主域名时用)。传了此参数即跳过下方 CSR 一致性诊断。
  2. L2 NUXT_PUBLIC_SITE_URL(站点配置) —— 全站主路径,配了就用它。同样跳过 CSR 一致性诊断。
  3. L3 useRequestURL().origin —— 未配置站点地址时的兜底。SSR 期 Node 从 Host / X-Forwarded-Proto 头解析,CSR / hydration 后等价于 window.location.origin。

有 ISR 页或预渲染页的站点必须配 NUXT_PUBLIC_SITE_URL,否则 canonical / og:url 会是 http://localhost/...。

原因是被缓存的页面拿不到请求头:nitro 的 cachedEventHandler 进 handler 前把请求头只保留 cache.varies 白名单,Host 被丢掉 ⇒ useRequestURL() 退化成 http://localhost。

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

systemStore.info.site_url 仍不参与 origin 决策(它是后端「最新地址」跳转用的业务字段,语义不同),保留供业务读取。

CSR 一致性诊断

SSR 阶段 useSeo 把 useRequestURL().origin 写入 <link rel="canonical">,完全依赖 nginx / cloudflare 正确透传 Host / X-Forwarded-Host / X-Forwarded-Proto。透传漂移会让 SSR 渲染出内网地址 / 错误域名 —— framework 在 CSR hydration 阶段做一次比对:

document.querySelector('link[rel="canonical"]').href.origin   ←  SSR 写入
                            vs
window.location.origin                                          ←  浏览器实际访问

不一致即 nginx 透传缺失,立刻在 console 输出诊断(dev console.warn / prod console.error)+ 修复指引 + docs URL。每会话仅触发 1 次(模块级 hasChecked 去重,避免刷屏)。显式传 input.siteUrl 时跳过此诊断。

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

image 兜底(不读后端): 显式 input.image → /og-image.png(项目本地占位,starter 通过 prebuild 脚本强制存在)。后端运营默认图如需使用,由页面从 systemStore 读取后显式传入 input.image。

单一入口原则

useSeo 是 SEO 的唯一出口,不再从后端 systemStore.header 隐式兜底 title / description / keywords / og_image:

  • title / description:每页必须显式传。缺失只 console.warn(不阻断渲染),不再用后端值兜底。
  • keywords:可选,不传则不输出(传了才校验数量 / 长度)。
  • og:image:见上方 image 兜底,仅本地 /og-image.png,不读后端。
  • <title> 站点名后缀:framework 不再注入 titleTemplate,交还 Nuxt 原生 —— applier 在 nuxt.config.ts 的 app.head.titleTemplate 配置(如 `%s | ${swiftConfig.project.title}`)。
  • JSON-LD WebSite.name:取 runtimeConfig.public.siteName(applier 从 swift.config.json 的 project.title 写入,与 titleTemplate 后缀同源),不再读后端 site_title。

为什么:旧版 framework 的 system.client 插件会在 hydration 后用后端 header 覆盖页面 useSeo 的 title/description,导致 <title> 重复成 站点名 | 站点名、description 被后端值盖掉。现已砍掉这条隐式链,让"写进 HTML 的 SEO 字段都能在页面 useSeo 调用处一眼看全"。后端 CMS 数据要用于 SEO,由页面从 store 读取后显式塞进 useSeo(后端 → store → 页面 → useSeo)。详见 framework#89。

displayWidth 长度阈值

dev 环境长度校验用显示宽度(visual width)算字符数,不是 JS .length。CJK 字符算 2、ASCII 算 1,让阈值数字 50-160 对中英繁三语言都精确:

  • 英文 description 50-160 字符(与之前一致)
  • 中文 / 繁体 description 25-80 字(每字算 2,故宽度 50-160)

警告文案直接显示 (显示宽度 N) 提醒开发者单位不是 .length。keywords 检查的是数量与字符串总长,与视觉宽度无关,仍走 .length。

displayWidth 函数从 useSeo.ts 中 export 出来,单测可直接 import 使用。详细推导见 长度建议来自哪里。

实战配方

// 配方 1:详情页 —— 后端 seo 节点优先、业务字段兜底(显式链,不靠隐式兜底)
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: { datePublished: data.value?.created_at }, // US-001:article 必带 datePublished
}))
// 配方 2:列表 / 标签页 —— 没有现成 description 也要拼一个有意义的(US-002)
useSeo({
  title: `${tag} 相关内容`,
  description: `${tag} 主题下 ${count} 部精选,包括 ${top3.join('、')} 等`,
})
// 配方 3:错误页 / 私有页 —— noindex + 静音长度告警
useSeo({ title: '页面未找到', description: '您访问的内容不存在', robots: 'noindex,nofollow', silenceLengthWarnings: true })

注意事项

  • 必填校验:title / description 每页必须显式传,缺失会 console.warn(无后端兜底),但不阻断渲染(避免 useFetch 失败时崩 dev)
  • silenceLengthWarnings 不影响硬告警:缺失告警永远不静音(那是真实风险)
  • OG 图尺寸:默认输出 og:image:width=1200 / og:image:height=630,业务方上传的 OG 图要按 1200×630 PNG/JPG 准备
  • Article schema 仅当 type='article' 且传 article 字段时输出,非详情页不需要传 article