Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions dev_docs/leetcode_slug_redirect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Leetcode 中文 slug 301 重定向

## 为什么需要这东西

`lib/source.ts` 里的 transformer 会把 `app/docs/career/interview-prep/leetcode/` 下含中文的
文件名转成拼音 slug(例如 `2309兼具大小写的最好英文字母_translated.md` 对外变成
`/docs/career/interview-prep/leetcode/2309-jian-ju-...-translated`)。

但历史上 Google Search Console 已经索引了**两批**旧 URL:

1. `/docs/CommunityShare/Leetcode/<中文原文件名>` —— Option C IA 大重组前的旧路径
2. `/docs/CommunityShare/Leetcode/<拼音 slug>` —— 拼音化上线后被 Google 发现但还没编入

`next.config.mjs` 里只写了一条 wildcard `/docs/CommunityShare/Leetcode/:path*` →
`/docs/career/interview-prep/leetcode/:path*`,**只做前缀替换不做 slug 拼音化**。
所以第 1 批 URL 跳到新路径之后 slug 还是中文,目标页依然 404。

GSC 实测 41 条 404 全都是这个问题。

## 现在的方案

**构建时生成字面映射表 + middleware O(1) 查表 301**。选型考虑:

- ❌ 直接在 `next.config.mjs` 里列出来 41 条:手写脆,文件增删没人同步
- ❌ path-to-regexp wildcard 传参:空格 / `[]` / 中文在 Next.js 路由匹配里不稳
- ❌ 在 middleware 里动态跑 `pinyin-pro`:整本字典(~1MB+)塞进 Edge bundle 太大
- ✅ **构建时扫目录 + 输出 JSON,middleware 导入 JSON 查表**:Edge bundle 只多几 KB

## 组件

| 文件 | 作用 |
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `scripts/generate-leetcode-slug-map.mjs` | 扫 leetcode 目录,对含中文的文件名跑和 `lib/source.ts` 一致的拼音化算法,输出 JSON |
| `generated/leetcode-slug-map.json` | 构建产物,`中文 stem → 拼音 stem` 的字面映射(当前 32 条) |
| `proxy.ts` (`redirectLeetcodeIfNeeded`) | middleware 在 `/docs/CommunityShare/Leetcode/*` 和 `/docs/career/interview-prep/leetcode/*` 上查表并 301 |
| `package.json` `prebuild` | 构建前自动跑生成脚本,保证 JSON 永远最新 |

## 覆盖的请求形态

| 输入 pathname | 查表结果 | 最终 301 → |
| --------------------------------------------------- | -------- | ---------------------------------------------------------------- |
| `/docs/CommunityShare/Leetcode/<中文 slug>` | 命中 | `/docs/career/interview-prep/leetcode/<拼音 slug>` |
| `/docs/CommunityShare/Leetcode/<ASCII slug>` | 未命中 | `/docs/career/interview-prep/leetcode/<ASCII slug>`(slug 原样) |
| `/docs/career/interview-prep/leetcode/<中文 slug>` | 命中 | 同目录拼音 slug |
| `/docs/career/interview-prep/leetcode/<ASCII slug>` | 未命中 | 放行(不动) |

## 新增 / 重命名 leetcode 文件时

不需要做任何事。`pnpm build` 的 prebuild 会重跑脚本,JSON 自动同步。

但如果 **pinyin 规则本身要改**(例如 tone、分隔符),必须**同时**改两处:

1. `lib/source.ts` 里的 `convertSlugToPinyin`(运行时给页面生成 slug)
2. `scripts/generate-leetcode-slug-map.mjs` 里的 `convertSlugToPinyin`(构建时给 redirect 生成 slug)

两者算法不同步 = 301 跳过去还是 404。

## 本地验证

```bash
pnpm build
pnpm start # 默认 3000 端口
# 带中文的旧 URL
curl -I 'http://localhost:3000/docs/CommunityShare/Leetcode/2309%E5%85%BC%E5%85%B7%E5%A4%A7%E5%B0%8F%E5%86%99%E7%9A%84%E6%9C%80%E5%A5%BD%E8%8B%B1%E6%96%87%E5%AD%97%E6%AF%8D_translated'
# 期望:301,Location 指向拼音 slug
```

## GSC 善后

这些 301 上线后,GSC 需要时间重抓:

- 41 条「未找到 (404)」:点「验证修复」→ GSC 会重新爬取,看到 301 就清出 404 列表
- 36 条「已发现 - 尚未编入索引」:这批本来就是**新**拼音 URL,路径没错,只是 Google 还没排到抓取 —— 可以手动「请求编入索引」加速,但没有代码层面要改的
34 changes: 34 additions & 0 deletions generated/leetcode-slug-map.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"1234. 替换子串得到平衡字符串_translated": "1234-ti-huan-zi-chuan-de-dao-ping-heng-zi-fu-chuan-translated",
"142.环形链表II_translated": "142-huan-xing-lian-biao-iitranslated",
"1653. 使字符串平衡的最少删除次数_translated": "1653-shi-zi-fu-chuan-ping-heng-de-zui-shao-shan-chu-ci-shu-translated",
"1664生成平衡数组的方案数_translated": "1664-sheng-cheng-ping-heng-shu-zu-de-fang-an-shu-translated",
"1825求出 MK 平均值_translated": "1825-qiu-chu-mk-ping-jun-zhi-translated",
"1828统计一个圆中点的数目_translated": "1828-tong-ji-yi-ge-yuan-zhong-dian-de-shu-mu-translated",
"2131. 连接两字母单词得到的最长回文串": "2131-lian-jie-liang-zi-mu-dan-ci-de-dao-de-zui-chang-hui-wen-chuan",
"2299强密码检验器II_translated": "2299-qiang-mi-ma-jian-yan-qi-iitranslated",
"2309兼具大小写的最好英文字母_translated": "2309-jian-ju-da-xiao-xie-de-zui-hao-ying-wen-zi-mu-translated",
"2335. 装满杯子需要的最短总时长_translated": "2335-zhuang-man-bei-zi-xu-yao-de-zui-duan-zong-shi-chang-translated",
"2341. 数组能形成多少数对_translated": "2341-shu-zu-neng-xing-cheng-duo-shao-shu-dui-translated",
"2639. 查询网格图中每一列的宽度_translated": "2639-cha-xun-wang-ge-tu-zhong-mei-yi-lie-de-kuan-du-translated",
"2679.矩阵中的和_translated": "2679-ju-zhen-zhong-de-he-translated",
"2894. 分类求和并作差": "2894-fen-lei-qiu-he-bing-zuo-cha",
"3072. 将元素分配到两个数组中 II_translated": "3072-jiang-yuan-su-fen-pei-dao-liang-ge-shu-zu-zhong-iitranslated",
"345. 反转字符串中的元音字母_translated": "345-fan-zhuan-zi-fu-chuan-zhong-de-yuan-yin-zi-mu-translated",
"46.全排列": "46-quan-pai-lie",
"538.把二叉搜索树转换为累加树_translated": "538-ba-er-cha-sou-suo-shu-zhuan-huan-wei-lei-jia-shu-translated",
"6323. 将钱分给最多的儿童_translated": "6323-jiang-qian-fen-gei-zui-duo-de-er-tong-translated",
"76最小覆盖子串_translated": "76-zui-xiao-fu-gai-zi-chuan-translated",
"93复原Ip地址": "93-fu-yuan-ip-di-zhi",
"994.腐烂的橘子_translated": "994-fu-lan-de-ju-zi-translated",
"[121]买卖股票的最佳时期_translated": "121-mai-mai-gu-piao-de-zui-jia-shi-qi-translated",
"[1333]餐厅过滤器_translated": "1333-can-ting-guo-l-qi-translated",
"[146]LRU 缓存_translated": "146lru-huan-cun-translated",
"[1545]找出第 N 个二进制字符串中的第 K 位": "1545-zhao-chu-di-n-ge-er-jin-zhi-zi-fu-chuan-zhong-de-di-k-wei",
"[213]打家劫舍 II_translated": "213-da-jia-jie-she-iitranslated",
"[2490]回环句_translated": "2490-hui-huan-ju-translated",
"[2562]找出数组的串联值_translated": "2562-zhao-chu-shu-zu-de-chuan-lian-zhi-translated",
"[2582]递枕头_translated": "2582-di-zhen-tou-translated",
"brief_alternate 作业帮忙_translated": "briefalternate-zuo-ye-bang-mang-translated",
"剑指 Offer II 021. 删除链表的倒数第 n 个结点_translated": "jian-zhi-offerii021-shan-chu-lian-biao-de-dao-shu-di-n-ge-jie-dian-translated"
}
30 changes: 30 additions & 0 deletions lib/leetcode-slug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { pinyin } from "pinyin-pro";

/**
* 把 leetcode 目录下含中文的文件名 / slug 片段转成纯 ASCII 拼音 slug。
*
* 为什么抽出来独立成文件:
* 1. 运行时 (`lib/source.ts` 里的 transformer) 要用它把 Fumadocs 预生成的 slugs 拼音化
* 2. 构建时 (`scripts/generate-leetcode-slug-map.mts`) 要用它生成「中文 → 拼音」字面映射
* 给 proxy.ts 做 301 查表
* 两处算法必须完全一致,否则 301 跳过去找不到页面。复制粘贴迟早忘记同步,
* 因此抽成唯一真源。
*
* 规则:
* - 无中文 → 原样返回(保留纯英文 slug 的连字符、数字等)
* - 有中文 → 拼音化 + 去掉所有非 `[a-z0-9]` 字符,再用 `-` 连接
*/
export function convertSlugToPinyin(text: string): string {
// Fumadocs 内部的 slugs 可能被 encode 过(%E6%BC...),先 decode 再判断汉字
const decodedText = decodeURIComponent(text);
if (!/[\u4e00-\u9fa5]/.test(decodedText)) return text;

return pinyin(decodedText, {
toneType: "none",
type: "array",
nonZh: "consecutive",
})
.map((t) => t.toLowerCase().replace(/[^a-z0-9]/g, ""))
.filter(Boolean)
.join("-");
}
19 changes: 1 addition & 18 deletions lib/source.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,6 @@
import { docs } from "@/.source";
import { loader, getSlugs } from "fumadocs-core/source";
import { pinyin } from "pinyin-pro";

// 拼音转换工具,仅针对中文部分转换,保留原本的标点和数字
function convertSlugToPinyin(text: string) {
// 核心修复点:Fumadocs 内部生成的 slugs 可能是被 encode 处理过的(%E6%BC...),需要先解码再判断汉字
const decodedText = decodeURIComponent(text);

if (!/[\u4e00-\u9fa5]/.test(decodedText)) return text;

return pinyin(decodedText, {
toneType: "none",
type: "array",
nonZh: "consecutive",
})
.map((t) => t.toLowerCase().replace(/[^a-z0-9]/g, "")) // 进一步清理非字母数字字符
.filter(Boolean)
.join("-");
}
import { convertSlugToPinyin } from "./leetcode-slug";

export const source = loader({
baseUrl: "/docs",
Expand Down
10 changes: 4 additions & 6 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -187,12 +187,10 @@ const config = {
destination: "/docs/projects/:path*",
statusCode: 301,
},
// CommunityShare 分家:Leetcode 归求职刷题,其他按主题归 community/*
{
source: "/docs/CommunityShare/Leetcode/:path*",
destination: "/docs/career/interview-prep/leetcode/:path*",
statusCode: 301,
},
// CommunityShare 分家:其他按主题归 community/*
// ↑ Leetcode 的 301 从这里挪到 proxy.ts,因为 lib/source.ts 会把中文文件名拼音化,
// 前缀替换 wildcard 跳过去的 URL slug 还是中文,目标页仍 404。proxy.ts 改用构建时
// 生成的 slug 映射做字面匹配,单跳 301 到正确拼音 URL。
{
source: "/docs/CommunityShare/Language/:path*",
destination: "/docs/community/language/:path*",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"private": true,
"scripts": {
"dev": "next dev",
"prebuild": "node scripts/escape-angles.mjs && tsx scripts/generate-leaderboard.mjs",
"prebuild": "node scripts/escape-angles.mjs && tsx scripts/generate-leaderboard.mjs && node scripts/generate-leetcode-slug-map.mts",
"build": "next build",
"start": "next start -p 3000",
"test": "vitest run",
Expand Down
67 changes: 67 additions & 0 deletions proxy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,67 @@
import { NextResponse, type NextRequest } from "next/server";
import leetcodeSlugMap from "@/generated/leetcode-slug-map.json";

/**
* Leetcode 旧 URL / 中文 slug 301 到拼音 slug 的新路径。
*
* 背景:
* lib/source.ts 的 transformer 把 career/interview-prep/leetcode/ 下含中文的文件名转成拼音 slug。
* 但 GSC 旧索引里存着 /docs/CommunityShare/Leetcode/<中文原文件名> 的 URL,
* next.config.mjs 的 wildcard 只做前缀替换,没做 slug 拼音化,跳过去依然 404。
* 在这里用构建时生成的 slug map 做 O(1) 查表,单跳 301 到正确拼音 URL。
*
* 覆盖的请求形态:
* 1. /docs/CommunityShare/Leetcode/<中文 slug> → 拼音新路径
* 2. /docs/CommunityShare/Leetcode/<拼音或纯 ASCII slug> → 新路径同 slug(兼容老收藏)
* 3. /docs/career/interview-prep/leetcode/<中文 slug> → 同目录拼音 slug(防止用户手抖)
*
* 为什么不走 next.config 的 redirects:
* path-to-regexp 对方括号 / 空格 / 中文的处理不稳,不如 middleware 字面匹配可靠。
*/
// 用 Map 而不是 plain object,杜绝 __proto__ / constructor 这类原型链 key 被当成命中
// 导致 redirect 目标异常(例如 mapped 返回 Object 构造函数)。
const SLUG_MAP = new Map<string, string>(
Object.entries(leetcodeSlugMap as Record<string, string>),
);
const LEETCODE_NEW_BASE = "/docs/career/interview-prep/leetcode";
const LEETCODE_OLD_BASE = "/docs/CommunityShare/Leetcode";

function redirectLeetcodeIfNeeded(req: NextRequest): NextResponse | null {
const { pathname } = req.nextUrl;

let baseMatched: "old" | "new" | null = null;
let rest = "";
if (pathname.startsWith(LEETCODE_OLD_BASE + "/")) {
baseMatched = "old";
rest = pathname.slice(LEETCODE_OLD_BASE.length + 1);
} else if (pathname.startsWith(LEETCODE_NEW_BASE + "/")) {
baseMatched = "new";
rest = pathname.slice(LEETCODE_NEW_BASE.length + 1);
} else {
return null;
}

if (!rest) return null;

// Next.js pathname 已经 decode,但保险起见再 decode 一次,兼容爬虫可能发来的二次编码
let rawSlug: string;
try {
rawSlug = decodeURIComponent(rest);
} catch {
rawSlug = rest;
}

const mapped = SLUG_MAP.get(rawSlug);
const targetSlug = mapped ?? rawSlug;

// 新路径 + ASCII slug 命中原样:放行,不绕圈
if (baseMatched === "new" && !mapped) return null;

// 新路径 + 中文 slug / 旧路径任意 slug:301 到新路径 + 拼音(或原 ASCII)slug
const url = req.nextUrl.clone();
url.pathname = `${LEETCODE_NEW_BASE}/${targetSlug}`;
return NextResponse.redirect(url, 301);
}

/**
* IP geo 判断默认 locale,并写入 cookie 供 Server Component 读取。
Expand All @@ -12,6 +75,10 @@ import { NextResponse, type NextRequest } from "next/server";
* cookie 有效期 1 年,用户在 /settings 页切换语言时会覆盖此 cookie。
*/
export function proxy(req: NextRequest) {
// Leetcode 老 URL / 中文 slug 优先做 301,避免后续 locale 逻辑给 404 页种 cookie
const leetcodeRedirect = redirectLeetcodeIfNeeded(req);
if (leetcodeRedirect) return leetcodeRedirect;
Comment on lines 77 to +80
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个是 Copilot 过时了——Next 16 把 middleware.ts 官方改名为 proxy.ts,导出符号也从 middleware 改成 proxy。仓库里 commit 7e079c3 "fix: 修复警告, 现在已经用Proxy代替Middleware了" 就是做这次迁移。

实测验证(production build + next start):

curl -I "http://localhost:3999/docs/CommunityShare/Leetcode/%5B146%5DLRU%20%E7%BC%93%E5%AD%98_translated"
# → 301 Location: /docs/career/interview-prep/leetcode/146lru-huan-cun-translated

Build 输出里也有 ƒ Proxy (Middleware) 那行,说明它被识别为 middleware。


// 用户已选过语言,尊重选择不覆盖
if (req.cookies.get("locale")) {
return NextResponse.next();
Expand Down
79 changes: 79 additions & 0 deletions scripts/generate-leetcode-slug-map.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* 构建时扫描 app/docs/career/interview-prep/leetcode/*.md(x),
* 把「中文/含特殊字符的文件名」→「拼音 slug」的映射写进 generated/leetcode-slug-map.json。
*
* 为什么要这个 map:
* lib/source.ts 里的 transformer 会把 leetcode 目录下含中文的文件名转成拼音 slug(对外 URL)。
* GSC 旧索引里还存着 /docs/CommunityShare/Leetcode/<中文原文件名> 这类 URL,
* next.config.mjs 只做了前缀替换 wildcard,slug 没拼音化,跳过去还是 404。
* proxy.ts (Next 16 middleware) 要在 edge 端 O(1) 查表把旧 URL 301 到正确拼音路径,
* 又不能把 pinyin-pro 的整本字典塞进 edge bundle,所以构建时先把映射固化成 JSON。
*
* 算法从 lib/leetcode-slug.ts 里 import,运行时和构建时共用同一实现,
* 消除双点维护。脚本必须用 tsx 执行(见 package.json prebuild)。
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { convertSlugToPinyin } from "../lib/leetcode-slug.ts";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PROJECT_ROOT = path.resolve(__dirname, "..");
const LEETCODE_DIR = path.join(
PROJECT_ROOT,
"app/docs/career/interview-prep/leetcode",
);
const OUTPUT_FILE = path.join(PROJECT_ROOT, "generated/leetcode-slug-map.json");

/**
* 从文件名去掉 locale / 扩展名后缀,还原 Fumadocs 会当 slug 的 stem。
* 2309兼具大小写的最好英文字母_translated.md → 2309兼具大小写的最好英文字母_translated
* 2241-design-an-atm-machine.zh.md → 2241-design-an-atm-machine
* [146]LRU 缓存_translated.md → [146]LRU 缓存_translated
*/
function stripSuffix(filename: string): string {
let stem = filename.replace(/\.(md|mdx)$/i, "");
stem = stem.replace(/\.(en|zh)$/i, "");
return stem;
}

function main() {
if (!fs.existsSync(LEETCODE_DIR)) {
console.error(`[leetcode-slug-map] 目录不存在: ${LEETCODE_DIR}`);
process.exit(1);
}

const files = fs
.readdirSync(LEETCODE_DIR)
.filter((f) => /\.(md|mdx)$/i.test(f));

const map: Record<string, string> = {};
const collisions: { stem: string; existing: string; incoming: string }[] = [];

for (const file of files) {
const stem = stripSuffix(file);
const pinyinSlug = convertSlugToPinyin(stem);
if (pinyinSlug === stem) continue; // 无中文,不需要映射
if (map[stem] && map[stem] !== pinyinSlug) {
collisions.push({ stem, existing: map[stem], incoming: pinyinSlug });
}
map[stem] = pinyinSlug;
}

if (collisions.length) {
console.warn(
`[leetcode-slug-map] 检测到 slug 冲突 ${collisions.length} 条:`,
collisions,
);
}

fs.mkdirSync(path.dirname(OUTPUT_FILE), { recursive: true });
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(map, null, 2) + "\n", "utf8");

console.log(
`[leetcode-slug-map] 生成 ${Object.keys(map).length} 条映射 → ${path.relative(PROJECT_ROOT, OUTPUT_FILE)}`,
);
}

main();
Loading