Skip to content

Commit 0fbd243

Browse files
fix(seo): 回应 CR 删 no-locale 兜底 301 + 补 dotted 中文 leetcode 旧 URL 301
CR(Copilot):next.config 的 `/docs/:path*` → `/zh/docs/:path*` 兜底是 301 (永久),抢在 next-intl middleware 之前把所有无前缀 /docs 链接(Hero/Footer 站内链接 + 外链)强制钉到 zh,英文用户再也协商不到 /en 且被浏览器/Google 缓存。删掉,无前缀路径交回 next-intl 按 Accept-Language/cookie 协商(307 单跳)。 dotted 中文 leetcode 旧 URL(46.全排列 / 1234. 替换… 这类带点的)此前被 proxy.ts matcher 的 .*\..* 排除、进不了 slug-map 301,只能硬 404。三处改动: - matcher 给 leetcode 开口:(?!.*[Ll]eetcode).*\..*,带点的 leetcode 路径放进中间件 - slug-map value 改成完整 canonical URL:按题号优先指向英文页 /en(.en.md 一定 200),无英文兄弟才退回 zh 拼音;并加 byNumber 兜住「中文文件已改名成英文、 旧中文 URL 仍在 GSC」的题(46.全排列→46-permutations) - 题号兜底仅对含中文的 slug 生效,避免英文 canonical slug 自我重定向成环 / 把合法 /zh 英文命名页打到 /en leetcode canonical 逻辑抽到 lib/leetcode-slug.ts(buildLeetcodeAsciiSlugByNumber / leetcodeCanonicalUrl),proxy.ts、slug-map 脚本、排行榜生成共用同一真源。 dev 实测:46.全排列 / [213]打家劫舍 / 93复原Ip地址 / 1234.替换 / 2894.分类 全部 301→200;/en/46-permutations、/zh/46-permutations 等 canonical 页不被劫持无环; 排行榜仍 153/153;sitemap/robots/普通路由无回归。 Co-authored-by: copilot-pull-request-reviewer[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 916a6e8 commit 0fbd243

6 files changed

Lines changed: 210 additions & 110 deletions

File tree

generated/leetcode-slug-map.json

Lines changed: 72 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,74 @@
11
{
2-
"1234. 替换子串得到平衡字符串_translated": "1234-ti-huan-zi-chuan-de-dao-ping-heng-zi-fu-chuan-translated",
3-
"142.环形链表II_translated": "142-huan-xing-lian-biao-iitranslated",
4-
"1653. 使字符串平衡的最少删除次数_translated": "1653-shi-zi-fu-chuan-ping-heng-de-zui-shao-shan-chu-ci-shu-translated",
5-
"1664生成平衡数组的方案数_translated": "1664-sheng-cheng-ping-heng-shu-zu-de-fang-an-shu-translated",
6-
"1825求出 MK 平均值_translated": "1825-qiu-chu-mk-ping-jun-zhi-translated",
7-
"1828统计一个圆中点的数目_translated": "1828-tong-ji-yi-ge-yuan-zhong-dian-de-shu-mu-translated",
8-
"2299强密码检验器II_translated": "2299-qiang-mi-ma-jian-yan-qi-iitranslated",
9-
"2309兼具大小写的最好英文字母_translated": "2309-jian-ju-da-xiao-xie-de-zui-hao-ying-wen-zi-mu-translated",
10-
"2335. 装满杯子需要的最短总时长_translated": "2335-zhuang-man-bei-zi-xu-yao-de-zui-duan-zong-shi-chang-translated",
11-
"2341. 数组能形成多少数对_translated": "2341-shu-zu-neng-xing-cheng-duo-shao-shu-dui-translated",
12-
"2639. 查询网格图中每一列的宽度_translated": "2639-cha-xun-wang-ge-tu-zhong-mei-yi-lie-de-kuan-du-translated",
13-
"2679.矩阵中的和_translated": "2679-ju-zhen-zhong-de-he-translated",
14-
"2894. 分类求和并作差": "2894-fen-lei-qiu-he-bing-zuo-cha",
15-
"3072. 将元素分配到两个数组中 II_translated": "3072-jiang-yuan-su-fen-pei-dao-liang-ge-shu-zu-zhong-iitranslated",
16-
"345. 反转字符串中的元音字母_translated": "345-fan-zhuan-zi-fu-chuan-zhong-de-yuan-yin-zi-mu-translated",
17-
"538.把二叉搜索树转换为累加树_translated": "538-ba-er-cha-sou-suo-shu-zhuan-huan-wei-lei-jia-shu-translated",
18-
"6323. 将钱分给最多的儿童_translated": "6323-jiang-qian-fen-gei-zui-duo-de-er-tong-translated",
19-
"76最小覆盖子串_translated": "76-zui-xiao-fu-gai-zi-chuan-translated",
20-
"994.腐烂的橘子_translated": "994-fu-lan-de-ju-zi-translated",
21-
"[121]买卖股票的最佳时期_translated": "121-mai-mai-gu-piao-de-zui-jia-shi-qi-translated",
22-
"[1333]餐厅过滤器_translated": "1333-can-ting-guo-l-qi-translated",
23-
"[146]LRU 缓存_translated": "146lru-huan-cun-translated",
24-
"[213]打家劫舍 II_translated": "213-da-jia-jie-she-iitranslated",
25-
"[2490]回环句_translated": "2490-hui-huan-ju-translated",
26-
"[2562]找出数组的串联值_translated": "2562-zhao-chu-shu-zu-de-chuan-lian-zhi-translated",
27-
"[2582]递枕头_translated": "2582-di-zhen-tou-translated",
28-
"brief_alternate 作业帮忙_translated": "briefalternate-zuo-ye-bang-mang-translated",
29-
"剑指 Offer II 021. 删除链表的倒数第 n 个结点_translated": "jian-zhi-offerii021-shan-chu-lian-biao-de-dao-shu-di-n-ge-jie-dian-translated"
2+
"byName": {
3+
"1234. 替换子串得到平衡字符串_translated": "/en/docs/career/interview-prep/leetcode/1234-replace-substring-for-balanced-string",
4+
"142.环形链表II_translated": "/en/docs/career/interview-prep/leetcode/142-linked-list-cycle-ii",
5+
"1653. 使字符串平衡的最少删除次数_translated": "/en/docs/career/interview-prep/leetcode/1653-minimum-deletions-to-make-string-balanced",
6+
"1664生成平衡数组的方案数_translated": "/en/docs/career/interview-prep/leetcode/1664-ways-to-make-a-fair-array",
7+
"1825求出 MK 平均值_translated": "/en/docs/career/interview-prep/leetcode/1825-mk-average",
8+
"1828统计一个圆中点的数目_translated": "/en/docs/career/interview-prep/leetcode/1828-queries-on-number-of-points-inside-a-circle",
9+
"2299强密码检验器II_translated": "/en/docs/career/interview-prep/leetcode/2299-strong-password-checker-ii",
10+
"2309兼具大小写的最好英文字母_translated": "/en/docs/career/interview-prep/leetcode/2309-greatest-english-letter-in-upper-and-lower-case",
11+
"2335. 装满杯子需要的最短总时长_translated": "/en/docs/career/interview-prep/leetcode/2335-minimum-amount-of-time-to-fill-cups",
12+
"2341. 数组能形成多少数对_translated": "/en/docs/career/interview-prep/leetcode/2341-maximum-number-of-pairs-in-array",
13+
"2639. 查询网格图中每一列的宽度_translated": "/en/docs/career/interview-prep/leetcode/2639-find-column-width-of-grid",
14+
"2679.矩阵中的和_translated": "/en/docs/career/interview-prep/leetcode/2679-sum-in-a-matrix",
15+
"2894. 分类求和并作差": "/en/docs/career/interview-prep/leetcode/2894-divisible-and-non-divisible-sums-difference",
16+
"3072. 将元素分配到两个数组中 II_translated": "/en/docs/career/interview-prep/leetcode/3072-distribute-elements-into-two-arrays-ii",
17+
"345. 反转字符串中的元音字母_translated": "/en/docs/career/interview-prep/leetcode/345-reverse-vowels-of-a-string",
18+
"538.把二叉搜索树转换为累加树_translated": "/en/docs/career/interview-prep/leetcode/538-convert-bst-to-greater-sum-tree",
19+
"6323. 将钱分给最多的儿童_translated": "/en/docs/career/interview-prep/leetcode/6323-distribute-money-to-maximum-children",
20+
"76最小覆盖子串_translated": "/en/docs/career/interview-prep/leetcode/76-minimum-window-substring",
21+
"994.腐烂的橘子_translated": "/en/docs/career/interview-prep/leetcode/994-rotting-oranges",
22+
"[121]买卖股票的最佳时期_translated": "/en/docs/career/interview-prep/leetcode/121-best-time-to-buy-and-sell-stock",
23+
"[1333]餐厅过滤器_translated": "/en/docs/career/interview-prep/leetcode/1333-filter-restaurants-by-vegan-friendly-price-and-distance",
24+
"[146]LRU 缓存_translated": "/en/docs/career/interview-prep/leetcode/146-lru-cache",
25+
"[213]打家劫舍 II_translated": "/en/docs/career/interview-prep/leetcode/213-house-robber-ii",
26+
"[2490]回环句_translated": "/en/docs/career/interview-prep/leetcode/2490-circular-sentence",
27+
"[2562]找出数组的串联值_translated": "/en/docs/career/interview-prep/leetcode/2562-find-the-array-concatenation-value",
28+
"[2582]递枕头_translated": "/en/docs/career/interview-prep/leetcode/2582-pass-the-pillow",
29+
"brief_alternate 作业帮忙_translated": "/zh/docs/career/interview-prep/leetcode/briefalternate-zuo-ye-bang-mang-translated",
30+
"剑指 Offer II 021. 删除链表的倒数第 n 个结点_translated": "/en/docs/career/interview-prep/leetcode/sword-offer-ii-021-remove-nth-node-from-end-of-list"
31+
},
32+
"byNumber": {
33+
"42": "/en/docs/career/interview-prep/leetcode/42-trapping-rain-water",
34+
"46": "/en/docs/career/interview-prep/leetcode/46-permutations",
35+
"76": "/en/docs/career/interview-prep/leetcode/76-minimum-window-substring",
36+
"80": "/en/docs/career/interview-prep/leetcode/80-remove-duplicates-from-sorted-array-ii",
37+
"93": "/en/docs/career/interview-prep/leetcode/93-restore-ip-addresses",
38+
"121": "/en/docs/career/interview-prep/leetcode/121-best-time-to-buy-and-sell-stock",
39+
"142": "/en/docs/career/interview-prep/leetcode/142-linked-list-cycle-ii",
40+
"146": "/en/docs/career/interview-prep/leetcode/146-lru-cache",
41+
"213": "/en/docs/career/interview-prep/leetcode/213-house-robber-ii",
42+
"219": "/en/docs/career/interview-prep/leetcode/219-contains-duplicate-ii",
43+
"345": "/en/docs/career/interview-prep/leetcode/345-reverse-vowels-of-a-string",
44+
"538": "/en/docs/career/interview-prep/leetcode/538-convert-bst-to-greater-sum-tree",
45+
"994": "/en/docs/career/interview-prep/leetcode/994-rotting-oranges",
46+
"1004": "/en/docs/career/interview-prep/leetcode/1004-max-consecutive-ones-iii",
47+
"1234": "/en/docs/career/interview-prep/leetcode/1234-replace-substring-for-balanced-string",
48+
"1333": "/en/docs/career/interview-prep/leetcode/1333-filter-restaurants-by-vegan-friendly-price-and-distance",
49+
"1545": "/en/docs/career/interview-prep/leetcode/1545-find-kth-bit-in-nth-binary-string",
50+
"1653": "/en/docs/career/interview-prep/leetcode/1653-minimum-deletions-to-make-string-balanced",
51+
"1664": "/en/docs/career/interview-prep/leetcode/1664-ways-to-make-a-fair-array",
52+
"1825": "/en/docs/career/interview-prep/leetcode/1825-mk-average",
53+
"1828": "/en/docs/career/interview-prep/leetcode/1828-queries-on-number-of-points-inside-a-circle",
54+
"2131": "/en/docs/career/interview-prep/leetcode/2131-longest-palindrome-by-concatenating-two-letter-words",
55+
"2241": "/en/docs/career/interview-prep/leetcode/2241-design-an-atm-machine",
56+
"2270": "/en/docs/career/interview-prep/leetcode/2270-number-of-ways-to-split-array",
57+
"2293": "/en/docs/career/interview-prep/leetcode/2293-min-max-game",
58+
"2299": "/en/docs/career/interview-prep/leetcode/2299-strong-password-checker-ii",
59+
"2309": "/en/docs/career/interview-prep/leetcode/2309-greatest-english-letter-in-upper-and-lower-case",
60+
"2335": "/en/docs/career/interview-prep/leetcode/2335-minimum-amount-of-time-to-fill-cups",
61+
"2341": "/en/docs/career/interview-prep/leetcode/2341-maximum-number-of-pairs-in-array",
62+
"2490": "/en/docs/career/interview-prep/leetcode/2490-circular-sentence",
63+
"2562": "/en/docs/career/interview-prep/leetcode/2562-find-the-array-concatenation-value",
64+
"2582": "/en/docs/career/interview-prep/leetcode/2582-pass-the-pillow",
65+
"2639": "/en/docs/career/interview-prep/leetcode/2639-find-column-width-of-grid",
66+
"2679": "/en/docs/career/interview-prep/leetcode/2679-sum-in-a-matrix",
67+
"2894": "/en/docs/career/interview-prep/leetcode/2894-divisible-and-non-divisible-sums-difference",
68+
"3072": "/en/docs/career/interview-prep/leetcode/3072-distribute-elements-into-two-arrays-ii",
69+
"3138": "/en/docs/career/interview-prep/leetcode/3138-minimum-length-of-anagram-concatenation",
70+
"6323": "/en/docs/career/interview-prep/leetcode/6323-distribute-money-to-maximum-children",
71+
"9021": "/en/docs/career/interview-prep/leetcode/9021-tut-3-25t1",
72+
"021": "/en/docs/career/interview-prep/leetcode/sword-offer-ii-021-remove-nth-node-from-end-of-list"
73+
}
3074
}

lib/leetcode-slug.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,46 @@ export function convertSlugToPinyin(text: string): string {
2828
.filter(Boolean)
2929
.join("-");
3030
}
31+
32+
/** leetcode 目录相对 content/docs 的路径(= 对外 URL 里 /docs 之后的固定段)。*/
33+
export const LEETCODE_DIR_SLUG = "career/interview-prep/leetcode";
34+
35+
/**
36+
* 从 leetcode 目录的文件名列表构建「题号 → 英文 ASCII slug」映射。
37+
*
38+
* 英文命名文件(`1234-replace-....en.md`)的 ASCII slug 一定能被 fumadocs 解析、
39+
* 一定 200;中文翻译文件名经 i18n 解析后的真实 slug 不可预测(带 `. ` 的会塌缩、
40+
* 带方括号的能独立成拼音页)。所以同一题优先用英文 slug 当 canonical。
41+
*/
42+
export function buildLeetcodeAsciiSlugByNumber(
43+
filenames: string[],
44+
): Map<string, string> {
45+
const map = new Map<string, string>();
46+
for (const f of filenames) {
47+
if (!/\.(md|mdx)$/i.test(f)) continue;
48+
const stem = f.replace(/\.(md|mdx)$/i, "").replace(/\.(en|zh)$/i, "");
49+
if (/[^\x00-\x7f]/.test(stem)) continue; // 含非 ASCII = 中文命名,不是 canonical 来源
50+
if (/_translated$/i.test(stem)) continue; // 英文名也带 _translated 的极少数
51+
const num = stem.match(/(\d+)/); // 题号(取第一段数字,兼容 1234- / sword-offer-ii-021)
52+
if (!num) continue;
53+
if (!map.has(num[1])) map.set(num[1], stem);
54+
}
55+
return map;
56+
}
57+
58+
/**
59+
* 给一个 leetcode 文件的 stem(已去 locale / 扩展名后缀),算它的真实 canonical URL。
60+
*
61+
* 英文页只在 `/en` 渲染(`.en.md`,zh 不回退 en),所以按题号命中英文 slug 时一律
62+
* 指向 `/en`;命不中(无英文兄弟)才退回 `/zh` 拼音页。`index` 对应目录根。
63+
*/
64+
export function leetcodeCanonicalUrl(
65+
stem: string,
66+
asciiByNumber: Map<string, string>,
67+
): string {
68+
if (stem === "index") return `/zh/docs/${LEETCODE_DIR_SLUG}`;
69+
const num = stem.match(/(\d+)/);
70+
const ascii = num ? asciiByNumber.get(num[1]) : undefined;
71+
if (ascii) return `/en/docs/${LEETCODE_DIR_SLUG}/${ascii}`;
72+
return `/zh/docs/${LEETCODE_DIR_SLUG}/${convertSlugToPinyin(stem)}`;
73+
}

next.config.mjs

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -280,15 +280,11 @@ const config = {
280280
statusCode: 301,
281281
},
282282

283-
// ============= 兜底:no-locale /docs 一律送默认 locale =============
284-
// 段化后 canonical 必带 locale 前缀,任何裸 /docs/... 都是旧链接(外链 /
285-
// GSC / 热榜 DB 旧路径)。必须排在所有具体规则之后,让特化规则先命中。
286-
// 目的地带 /zh 前缀,不会再被本规则匹配,无重定向环。
287-
{
288-
source: "/docs/:path*",
289-
destination: "/zh/docs/:path*",
290-
statusCode: 301,
291-
},
283+
// 注意:不要在这里加 `/docs/:path*` → `/zh/docs/:path*` 的 no-locale 兜底。
284+
// 那是个 301(永久),会抢在 next-intl middleware 之前把所有无前缀 /docs
285+
// 链接(Hero / Footer 站内链接 + 外链)强制钉到 zh,英文用户再也协商不到
286+
// /en(且被浏览器/Google 缓存)。无前缀路径交给 next-intl 按 Accept-Language
287+
// / NEXT_LOCALE 协商(307,单跳到 /zh 或 /en)才是对的。
292288
];
293289
},
294290
async rewrites() {

proxy.ts

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,16 @@ import { routing } from "@/i18n/routing";
1818
* - URL 段化后,匹配规则 matcher 也得放宽到全站(不限于 /docs/:path*)
1919
*/
2020

21-
const SLUG_MAP = new Map<string, string>(
22-
Object.entries(leetcodeSlugMap as Record<string, string>),
21+
// generated/leetcode-slug-map.json:
22+
// byName 中文文件名(decode 后)→ 完整 canonical 路径(含无英文兄弟时的 zh 拼音页)
23+
// byNumber 题号 → 英文页,兜住「中文文件已改名成英文、旧中文 URL 在 GSC 还活着」
24+
const slugMap = leetcodeSlugMap as {
25+
byName: Record<string, string>;
26+
byNumber: Record<string, string>;
27+
};
28+
const SLUG_BY_NAME = new Map<string, string>(Object.entries(slugMap.byName));
29+
const SLUG_BY_NUMBER = new Map<string, string>(
30+
Object.entries(slugMap.byNumber),
2331
);
2432

2533
// 既要兼容老的不带 locale 前缀的 URL(/docs/...),也要兼容已经带 locale 的
@@ -85,15 +93,29 @@ function redirectLeetcodeIfNeeded(req: NextRequest): NextResponse | null {
8593
rawSlug = rest;
8694
}
8795

88-
const mapped = SLUG_MAP.get(rawSlug);
89-
const targetSlug = mapped ?? rawSlug;
90-
91-
// 新路径 + ASCII slug 命中原样:放行,不绕圈
92-
if (baseMatched === "new" && !mapped) return null;
96+
// value 是完整 canonical 路径(含 locale 前缀)。先按文件名精确查(覆盖只有 zh
97+
// 拼音页的情况),命不中再按题号查(兜住已改名成英文、旧中文 URL 仍存活的题)。
98+
//
99+
// 题号兜底只对“含中文的 slug”生效:canonical 英文 slug(46-permutations)和
100+
// 合法的拼音页都是纯 ASCII,若也按题号重定向会把英文页指向自己造成 301 死循环、
101+
// 或把合法 /zh 英文命名页强行打到 /en。中文旧 URL 才需要这层兜底。
102+
const numMatch = rawSlug.match(/(\d+)/);
103+
const hasNonAscii = /[^\x00-\x7f]/.test(rawSlug);
104+
const mapped =
105+
SLUG_BY_NAME.get(rawSlug) ??
106+
(hasNonAscii && numMatch ? SLUG_BY_NUMBER.get(numMatch[1]) : undefined);
107+
if (mapped) {
108+
const url = req.nextUrl.clone();
109+
url.pathname = mapped;
110+
url.search = "";
111+
return NextResponse.redirect(url, 301);
112+
}
93113

94-
// 否则 301 到(带 locale 前缀的)拼音 URL
114+
// 未映射 = 英文 / ASCII slug,真实 slug == rawSlug:
115+
// 新前缀已经正确,放行不绕圈;老前缀只换前缀(默认 zh,_translated 等都是 zh 文件)。
116+
if (baseMatched === "new") return null;
95117
const url = req.nextUrl.clone();
96-
url.pathname = `${localePrefix}${LEETCODE_PATH_TAIL}/${targetSlug}`;
118+
url.pathname = `${localePrefix || `/${routing.defaultLocale}`}${LEETCODE_PATH_TAIL}/${rawSlug}`;
97119
return NextResponse.redirect(url, 301);
98120
}
99121

@@ -122,6 +144,10 @@ export const config = {
122144
// rewrite source(/oauth/:path*)不匹配带 locale 的版本(/en/oauth/...),
123145
// 落到 [locale]/oauth/... 404。所以必须排除掉,让请求直接走 rewrite。
124146
// - _next / _vercel:Next.js 内部
125-
// - .*\..*:任何带 . 的路径(静态资源 / sitemap.xml / robots.txt 等)
126-
matcher: "/((?!api|trpc|auth|oauth|analytics|_next|_vercel|.*\\..*).*)",
147+
// - (?!.*[Ll]eetcode).*\..*:带 . 的路径(静态资源 / sitemap.xml 等)一律排除,
148+
// 但 leetcode 例外——GSC 旧 URL 里有大量带点的中文题名("46.全排列"、
149+
// "1234. 替换…"),不放进来就触不到上面的 slug-map 301,只能硬 404。
150+
// leetcode 目录下不存在带点的静态资源,开这个口子安全。
151+
matcher:
152+
"/((?!api|trpc|auth|oauth|analytics|_next|_vercel|(?!.*[Ll]eetcode).*\\..*).*)",
127153
};

0 commit comments

Comments
 (0)