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走主路徑 useRequestURL().origin站點根網域(含 protocol)。顯式逃生口:傳值即跳過 framework 的 CSR 一致性診斷(典型場景:多副網域想強制 canonical 收斂到主網域)
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 使用。詳細推導見 /guide/seo。

實戰配方

// 配方 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