|
| 1 | +# SEO Meta Description 三层方案 |
| 2 | + |
| 3 | +## 为什么需要这东西 |
| 4 | + |
| 5 | +2026-05 Bing Webmaster Tools 报告:involutionhell.com 有 **118 个页面 meta description 太短** |
| 6 | +(< 150 字符),评为 Moderate SEO 问题。 |
| 7 | + |
| 8 | +根因不是代码 bug,是**内容缺失**: |
| 9 | + |
| 10 | +- docs 页面(`app/[locale]/docs/[...slug]/page.tsx:165`)的 `generateMetadata` 直接读 MDX |
| 11 | + frontmatter 的 `description` 字段,没有 fallback; |
| 12 | +- `content/docs/` 下 292 个 MDX 中: |
| 13 | + - **96 个** 完全没有 `description` 字段(绝大多数是程序化导入的 leetcode 题解) |
| 14 | + - **67 个** `description: ""` 空字符串 |
| 15 | + - **35 个** description < 20 字符("First page" 这种) |
| 16 | + - **56 个** description 20-60 字符(合格但偏短) |
| 17 | + - 仅 **38 个** ≥ 60 字符 |
| 18 | + |
| 19 | +这条 Bing 告警不是 hard error,是 ranking signal —— description 缺失时搜索引擎会从正文 |
| 20 | +随便抓一段做摘要,质量不可控,间接拉低 CTR 和排名。 |
| 21 | + |
| 22 | +## 方案:三层叠加 |
| 23 | + |
| 24 | +### Layer 1 — 代码层兜底(`lib/seo-description.ts`) |
| 25 | + |
| 26 | +所有 docs 页的 `generateMetadata` 都过 `ensureSeoDescription()`,把短/空/缺失的 |
| 27 | +description 自动拼接到 ≥ 80 字符: |
| 28 | + |
| 29 | +```ts |
| 30 | +description: ensureSeoDescription({ |
| 31 | + description: page.data.description, |
| 32 | + title: page.data.title, |
| 33 | + sectionPath: slug.slice(0, -1), // 当前页的分区面包屑 |
| 34 | + locale, |
| 35 | +}); |
| 36 | +``` |
| 37 | + |
| 38 | +兜底文本结构: |
| 39 | + |
| 40 | +``` |
| 41 | +[作者原 description 如有] 主题:{title}。 所属分区:{breadcrumb}。 站点 tagline。 |
| 42 | +``` |
| 43 | + |
| 44 | +中英文 tagline 各一份,按 `locale` 选。 |
| 45 | + |
| 46 | +**影响范围**:以下 4 处页面 metadata 都已接入: |
| 47 | + |
| 48 | +| 文件 | 用途 | |
| 49 | +| ------------------------------------------ | ------------------------------------------------------- | |
| 50 | +| `app/[locale]/docs/[...slug]/page.tsx:165` | docs 动态路由 generateMetadata + TechArticle JSON-LD | |
| 51 | +| `app/[locale]/docs/page.tsx:47` | docs 根落地页 | |
| 52 | +| `app/[locale]/events/[id]/page.tsx:57` | 活动详情页(兜底用户/管理员录入的 `event.description`) | |
| 53 | +| `app/[locale]/feed/page.tsx:24` | 社区分享墙 | |
| 54 | + |
| 55 | +**作用**:立即消除 Bing 告警,不动 content/。但兜底文本是模板化拼接, |
| 56 | +搜索摘要质量稀薄 —— 这是 Layer 1 的局限。 |
| 57 | + |
| 58 | +### Layer 2 — CI lint 阻止再积累低质量 description |
| 59 | + |
| 60 | +`scripts/check-frontmatter-description.mjs` 在 pre-commit + GitHub Actions PR |
| 61 | +检查新增/修改的 MDX,强制 `description` 字段 ≥ 60 字符。 |
| 62 | + |
| 63 | +豁免规则: |
| 64 | + |
| 65 | +- `content/docs/career/interview-prep/leetcode/` 全部豁免 —— 程序化导入太多, |
| 66 | + Layer 1 兜底已能用 |
| 67 | +- `*_translated.md` / `*_translated.mdx` —— 机翻产物,等人工 review 时再补 |
| 68 | + |
| 69 | +**接入位置**: |
| 70 | + |
| 71 | +- `.husky/pre-commit`:`pnpm check:frontmatter`(默认 `--changed` 模式) |
| 72 | +- `.github/workflows/content-check.yml`:同上,PR 上下文自动从 `GITHUB_BASE_REF` |
| 73 | + diff 找改动文件 |
| 74 | + |
| 75 | +**只看不阻塞模式**:`pnpm check:frontmatter:all` 扫所有文件输出报表,但不退出 |
| 76 | +非 0;用于本地一次性看现状。 |
| 77 | + |
| 78 | +### Layer 3 — 离线脚本回填 description 写入 frontmatter(`scripts/generate-descriptions.mjs`) |
| 79 | + |
| 80 | +把存量 253 个 description 缺失/空/极短的 MDX 回填精准描述。 |
| 81 | + |
| 82 | +两种生成策略: |
| 83 | + |
| 84 | +1. **默认(推荐)**:所有文件走 DeepSeek API |
| 85 | + - 输入:title + filename + 正文前 800 字符(清洗 import / 代码块 / MDX 组件) |
| 86 | + - 输出:单行 description,中文文档 80-100 字、英文文档 120-160 字符 |
| 87 | + - leetcode 题解会被额外提示"以 LeetCode {题号}. {题名} 题解 — 开头" |
| 88 | + - 成本:~$0.05 总 token 成本(DeepSeek 2026-05 价目) |
| 89 | + |
| 90 | +2. **`--leetcode-only`(离线模式)**:仅 leetcode 走文件名+正文模板拼接,不调 LLM |
| 91 | + - 用于无 `DEEPSEEK_API_KEY` 时的 fallback |
| 92 | + - 模板覆盖:题号 + 题名 + 正文首句 + 通用 tail |
| 93 | + |
| 94 | +**用法**: |
| 95 | + |
| 96 | +```bash |
| 97 | +# 1. dry-run(默认),生成 scripts/.descriptions-report.json 供 review |
| 98 | +DEEPSEEK_API_KEY=sk-xxx node scripts/generate-descriptions.mjs |
| 99 | + |
| 100 | +# 2. 看 dry-run 结果后真写回 frontmatter |
| 101 | +DEEPSEEK_API_KEY=sk-xxx node scripts/generate-descriptions.mjs --apply |
| 102 | + |
| 103 | +# 3. 试运行只跑前 N 个 |
| 104 | +DEEPSEEK_API_KEY=sk-xxx node scripts/generate-descriptions.mjs --limit=5 |
| 105 | + |
| 106 | +# 4. 离线模式(leetcode 模板) |
| 107 | +node scripts/generate-descriptions.mjs --leetcode-only --apply |
| 108 | +``` |
| 109 | + |
| 110 | +**安全设计**: |
| 111 | + |
| 112 | +- 默认 dry-run,绝不动 content;`--apply` 才真写 |
| 113 | +- `gray-matter` 保留其他 frontmatter 字段(title / date / docId / lang 等) |
| 114 | +- 已合格(≥ 60 字符)的 description 跳过 —— 重跑幂等 |
| 115 | +- DeepSeek 调用失败的不写,留待重跑 |
| 116 | +- 输出 < 50 字符的不写,标记在 report 的 `skippedTooShort` 里 |
| 117 | +- API key 只从 env 读,不进任何 commit |
| 118 | + |
| 119 | +## 验证 |
| 120 | + |
| 121 | +```bash |
| 122 | +# 看 Layer 1 兜底是否在跑:访问随便一个无 description 的 leetcode 页面 |
| 123 | +curl -s https://involutionhell.com/zh/docs/career/interview-prep/leetcode/1004... | grep '<meta name="description"' |
| 124 | +# 期望:description 至少包含 "主题:" 或 "Topic:" + 分区面包屑 + 站点 tagline |
| 125 | + |
| 126 | +# 看 Layer 2 lint 是否触发:本地建一个无 description 的 mdx |
| 127 | +echo "---\ntitle: x\n---\n内容" > content/docs/__test.mdx |
| 128 | +git add content/docs/__test.mdx |
| 129 | +pnpm check:frontmatter # 预期退出码 1 |
| 130 | + |
| 131 | +# Layer 3 dry-run,看 report |
| 132 | +DEEPSEEK_API_KEY=sk-xxx node scripts/generate-descriptions.mjs --limit=5 |
| 133 | +cat scripts/.descriptions-report.json | jq '.results[] | {file, length, after}' |
| 134 | +``` |
| 135 | + |
| 136 | +## 决策记录 |
| 137 | + |
| 138 | +- **为什么 lint 阈值是 60 字符而非 Bing 推荐的 150**:60 是保守值,照顾老贡献者 |
| 139 | + 适应;Layer 1 兜底会把渲染时实际长度补到 ≥ 80。两道防线足够。 |
| 140 | +- **为什么 leetcode 豁免 lint**:96 个题解是程序化导入的,没人会手写 |
| 141 | + description;强制写人为操作太重,靠 Layer 1 兜底 + Layer 3 模板/LLM 一次性补齐 |
| 142 | + 即可。 |
| 143 | +- **为什么用 DeepSeek 而非更贵的模型**:本任务对模型推理要求不高(看 800 字 |
| 144 | + 正文摘要成一句话),DeepSeek-chat 足够;成本 $0.05 vs GPT-4 的 $5+,差 100 倍。 |
| 145 | +- **为什么不直接在 source.config.ts 装 remark 插件自动生成 description**: |
| 146 | + fumadocs 已经把 frontmatter 转为 `page.data`,在 metadata 层加 fallback 比改 |
| 147 | + frontmatter pipeline 侵入性小、回退安全。 |
| 148 | + |
| 149 | +## 相关文件 |
| 150 | + |
| 151 | +- `lib/seo-description.ts` — Layer 1 兜底函数 |
| 152 | +- `tests/seo-description.test.ts` — 兜底函数单元测试 |
| 153 | +- `scripts/check-frontmatter-description.mjs` — Layer 2 lint |
| 154 | +- `scripts/generate-descriptions.mjs` — Layer 3 离线生成 |
| 155 | +- `app/[locale]/docs/[...slug]/page.tsx` — 主要消费方 |
| 156 | +- `.husky/pre-commit` — 接入点 |
| 157 | +- `.github/workflows/content-check.yml` — 接入点 |
0 commit comments