From 45a5326d6def9efcb91b51315ed8b7e8071ba865 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:42:59 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(seo):=20leetcode=20=E4=B8=AD=E6=96=87?= =?UTF-8?q?=20slug=20=E6=97=A7=20URL=20301=20=E5=88=B0=E6=8B=BC=E9=9F=B3?= =?UTF-8?q?=E6=96=B0=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GSC 报 41 条 /docs/CommunityShare/Leetcode/<中文文件名> 的 404。根因: lib/source.ts 里的 transformer 会把 leetcode 目录下含中文的文件名转成拼音 slug,但 next.config.mjs 的 wildcard 只做前缀替换没做 slug 拼音化,跳过去 slug 还是中文导致 404。 改动: - scripts/generate-leetcode-slug-map.mjs 构建时扫目录,输出 generated/leetcode-slug-map.json「中文 stem → 拼音 stem」字面映射 - proxy.ts 新增 redirectLeetcodeIfNeeded:Edge 端 O(1) 查表单跳 301 - next.config.mjs 删 leetcode wildcard,改由 middleware 处理避免二跳 - prebuild 挂钩生成脚本,保证 JSON 永远同步最新文件列表 - 补 dev_docs/leetcode_slug_redirect.md 讲清方案 --- dev_docs/leetcode_slug_redirect.md | 73 ++++++++++++++++++++ generated/leetcode-slug-map.json | 34 +++++++++ next.config.mjs | 10 ++- package.json | 2 +- proxy.ts | 63 +++++++++++++++++ scripts/generate-leetcode-slug-map.mjs | 96 ++++++++++++++++++++++++++ 6 files changed, 271 insertions(+), 7 deletions(-) create mode 100644 dev_docs/leetcode_slug_redirect.md create mode 100644 generated/leetcode-slug-map.json create mode 100644 scripts/generate-leetcode-slug-map.mjs diff --git a/dev_docs/leetcode_slug_redirect.md b/dev_docs/leetcode_slug_redirect.md new file mode 100644 index 0000000..89a1e7f --- /dev/null +++ b/dev_docs/leetcode_slug_redirect.md @@ -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/` | 未命中 | `/docs/career/interview-prep/leetcode/`(slug 原样) | +| `/docs/career/interview-prep/leetcode/<中文 slug>` | 命中 | 同目录拼音 slug | +| `/docs/career/interview-prep/leetcode/` | 未命中 | 放行(不动) | + +## 新增 / 重命名 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 还没排到抓取 —— 可以手动「请求编入索引」加速,但没有代码层面要改的 diff --git a/generated/leetcode-slug-map.json b/generated/leetcode-slug-map.json new file mode 100644 index 0000000..2c125e2 --- /dev/null +++ b/generated/leetcode-slug-map.json @@ -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" +} diff --git a/next.config.mjs b/next.config.mjs index d44673b..79863fe 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -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*", diff --git a/package.json b/package.json index a33981c..d24a7f6 100644 --- a/package.json +++ b/package.json @@ -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.mjs", "build": "next build", "start": "next start -p 3000", "test": "vitest run", diff --git a/proxy.ts b/proxy.ts index f0dbcab..90d97aa 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,4 +1,63 @@ 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 字面匹配可靠。 + */ +const SLUG_MAP = leetcodeSlugMap as Record; +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[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 读取。 @@ -12,6 +71,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; + // 用户已选过语言,尊重选择不覆盖 if (req.cookies.get("locale")) { return NextResponse.next(); diff --git a/scripts/generate-leetcode-slug-map.mjs b/scripts/generate-leetcode-slug-map.mjs new file mode 100644 index 0000000..1d385d5 --- /dev/null +++ b/scripts/generate-leetcode-slug-map.mjs @@ -0,0 +1,96 @@ +/** + * 构建时扫描 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/source.ts 的 convertSlugToPinyin 完全一致,否则链接对不上。 + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { pinyin } from "pinyin-pro"; + +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"); + +/** + * 与 lib/source.ts 中 convertSlugToPinyin 保持同步。 + * 入参:单个 slug 片段(一般是文件名 stem)。 + * 无中文直接原样返回;有中文则按拼音 + 非字母数字清洗 + 连字符拼接。 + */ +function convertSlugToPinyin(text) { + 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("-"); +} + +/** + * 从文件名去掉 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) { + 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 = {}; + const collisions = []; + + 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(); From 25a076e550fcfeb3a1d31c0f00b41bb0b2397a04 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:14:52 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(proxy):=20Copilot=20CR=20=E2=80=94=20?= =?UTF-8?q?=E6=8A=BD=E5=85=B1=E4=BA=AB=E6=A8=A1=E5=9D=97=20+=20Map=20?= =?UTF-8?q?=E9=98=B2=E5=8E=9F=E5=9E=8B=E9=93=BE=E6=B1=A1=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 抽 convertSlugToPinyin 到 lib/leetcode-slug.ts,lib/source.ts 和生成脚本 共用同一实现,消除双点维护(Copilot CR comment #1) 2. proxy.ts 里把 SLUG_MAP 从 plain object 换成 Map,实测 __proto__/constructor 等原型链 key 会被错误命中(拿到 [object Object] / Object 构造函数), Map 天然隔离原型链(Copilot CR comment #2) 3. 脚本从 .mjs 改为 .mts 以便 import TypeScript 共享模块,用 Node 24 的 原生 TS 支持执行,prebuild 走 node 不走 tsx(tsx v4.21 在 Node 24 下 会把 TS 转成 CJS 导致命名 ESM 导出丢失) --- lib/leetcode-slug.ts | 30 +++++++++++++++++++ lib/source.ts | 19 +----------- package.json | 2 +- proxy.ts | 8 +++-- ...map.mjs => generate-leetcode-slug-map.mts} | 29 ++++-------------- 5 files changed, 44 insertions(+), 44 deletions(-) create mode 100644 lib/leetcode-slug.ts rename scripts/{generate-leetcode-slug-map.mjs => generate-leetcode-slug-map.mts} (77%) diff --git a/lib/leetcode-slug.ts b/lib/leetcode-slug.ts new file mode 100644 index 0000000..9e22465 --- /dev/null +++ b/lib/leetcode-slug.ts @@ -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("-"); +} diff --git a/lib/source.ts b/lib/source.ts index c6c99f8..03ce9d0 100644 --- a/lib/source.ts +++ b/lib/source.ts @@ -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", diff --git a/package.json b/package.json index d24a7f6..bf04778 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "dev": "next dev", - "prebuild": "node scripts/escape-angles.mjs && tsx scripts/generate-leaderboard.mjs && node scripts/generate-leetcode-slug-map.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", diff --git a/proxy.ts b/proxy.ts index 90d97aa..fbec3d0 100644 --- a/proxy.ts +++ b/proxy.ts @@ -18,7 +18,11 @@ import leetcodeSlugMap from "@/generated/leetcode-slug-map.json"; * 为什么不走 next.config 的 redirects: * path-to-regexp 对方括号 / 空格 / 中文的处理不稳,不如 middleware 字面匹配可靠。 */ -const SLUG_MAP = leetcodeSlugMap as Record; +// 用 Map 而不是 plain object,杜绝 __proto__ / constructor 这类原型链 key 被当成命中 +// 导致 redirect 目标异常(例如 mapped 返回 Object 构造函数)。 +const SLUG_MAP = new Map( + Object.entries(leetcodeSlugMap as Record), +); const LEETCODE_NEW_BASE = "/docs/career/interview-prep/leetcode"; const LEETCODE_OLD_BASE = "/docs/CommunityShare/Leetcode"; @@ -47,7 +51,7 @@ function redirectLeetcodeIfNeeded(req: NextRequest): NextResponse | null { rawSlug = rest; } - const mapped = SLUG_MAP[rawSlug]; + const mapped = SLUG_MAP.get(rawSlug); const targetSlug = mapped ?? rawSlug; // 新路径 + ASCII slug 命中原样:放行,不绕圈 diff --git a/scripts/generate-leetcode-slug-map.mjs b/scripts/generate-leetcode-slug-map.mts similarity index 77% rename from scripts/generate-leetcode-slug-map.mjs rename to scripts/generate-leetcode-slug-map.mts index 1d385d5..cc89de4 100644 --- a/scripts/generate-leetcode-slug-map.mjs +++ b/scripts/generate-leetcode-slug-map.mts @@ -9,12 +9,13 @@ * proxy.ts (Next 16 middleware) 要在 edge 端 O(1) 查表把旧 URL 301 到正确拼音路径, * 又不能把 pinyin-pro 的整本字典塞进 edge bundle,所以构建时先把映射固化成 JSON。 * - * 生成规则必须和 lib/source.ts 的 convertSlugToPinyin 完全一致,否则链接对不上。 + * 算法从 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 { pinyin } from "pinyin-pro"; +import { convertSlugToPinyin } from "../lib/leetcode-slug.ts"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -25,31 +26,13 @@ const LEETCODE_DIR = path.join( ); const OUTPUT_FILE = path.join(PROJECT_ROOT, "generated/leetcode-slug-map.json"); -/** - * 与 lib/source.ts 中 convertSlugToPinyin 保持同步。 - * 入参:单个 slug 片段(一般是文件名 stem)。 - * 无中文直接原样返回;有中文则按拼音 + 非字母数字清洗 + 连字符拼接。 - */ -function convertSlugToPinyin(text) { - 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("-"); -} - /** * 从文件名去掉 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) { +function stripSuffix(filename: string): string { let stem = filename.replace(/\.(md|mdx)$/i, ""); stem = stem.replace(/\.(en|zh)$/i, ""); return stem; @@ -65,8 +48,8 @@ function main() { .readdirSync(LEETCODE_DIR) .filter((f) => /\.(md|mdx)$/i.test(f)); - const map = {}; - const collisions = []; + const map: Record = {}; + const collisions: { stem: string; existing: string; incoming: string }[] = []; for (const file of files) { const stem = stripSuffix(file);