diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..2b96a396 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,93 @@ +# CLAUDE.md — 给 Claude / AI coding agent 的项目级硬约束 + +`AGENT.md` 写 workflow 和 coding style。这里只写**已被 review 推回过的反模式**, +确保下次不再犯。 + +## 1. 最佳实践优先于"省一点资源" + +线上 Vercel CPU 接近 / 超配额时,**第一反应不是降级 observability 或藏起错误**。 +正确的次序: + +1. 找真实的 waste(scanner 烧 Fluid、缺 SSG 配置的路由、cache miss 风暴) +2. 用 best practice 修掉 waste(让 SSG / ISR 真正生效,edge 早返) +3. 还是过线就升 Pro plan / 上 Cloudflare proxy 挡 crawler + +**不要做**("丢西瓜捡芝麻" 反模式,已被推回过): + +- ❌ 把 `Sentry tracesSampleRate` 从 0.1 调到 0.02 省 CPU —— 10% 是行业标准, + observability 不能为这点 CPU 让步;server/edge/client 三处必须一致才能跨 + runtime 串 trace +- ❌ 把后端 fetch 失败一律返空数组 —— 这隐藏 prod 故障,把"backend 挂了"误 + 显示成"暂无活动",Sentry / 错误页都抓不到。**正确做法**:用 + `process.env.NEXT_PHASE === "phase-production-build"` guard,只在 build + 阶段降级返空(避免 SSG build 挂),运行时仍 throw +- ❌ 把活跃流量当 bug 优化掉 —— 如果 SEO PR 的 IndexNow ping 让搜索引擎重抓 + 是 4× 流量增长的原因,那是工作成功的代价,应该付费而不是回滚 SEO + +## 2. 路由分类必须用 `next build` 输出验证,不要凭感觉 + +历史教训:上一轮 SSR 优化(commit `8517332`)声称"首页 SSG 化",但 build 表 +显示**只翻转了 1 条路由**。剩 17 条还是 ƒ Dynamic,没人发现。 + +**强制流程**: + +```bash +# 修前快照 +pnpm build 2>&1 | tee /tmp/build-before.txt + +# 修复后 +pnpm build 2>&1 | tee /tmp/build-after.txt + +# 直接 diff,看哪些 ƒ → ● / ○ +diff <(grep -E '^[┌├└] ' /tmp/build-before.txt) \ + <(grep -E '^[┌├└] ' /tmp/build-after.txt) +``` + +**不接受"我加了 force-static 应该就行"这种自证。** 看 build 表,看 +`x-vercel-cache: HIT` header,看 Vercel dashboard 24h 后实测 CPU。 + +### next-intl SSG 的硬要求 + +每个 `[locale]/*/page.tsx` 想 SSG / ISR 都必须满足**全部三条**: + +1. `params: Promise<{ locale: string }>` 接收 + `await params` +2. `setRequestLocale(locale)` 调用(必须在任何 `getTranslations` / `getLocale` 之前) +3. `export function generateStaticParams() { return routing.locales.map(...); }` + +缺任一条 → next-intl 退回 `cookies()` 推断 locale → 整页 ƒ Dynamic。 +parent layout 的 setRequestLocale 不传染到子 page。 + +## 3. 注释规则(CLAUDE.md 顶层约束的项目特化) + +**默认不写注释**。只在以下场景写: + +- 非显然的工程约束(如 next-intl SSG 三条件、`NEXT_PHASE` guard 的作用域) +- 维护时容易踩坑的不变量(如 "BOT_PATH_PATTERNS 不要加 admin / login") + +**严禁写**(已被推回过的反模式): + +- ❌ 引 dev_docs/ 文件路径(doc 改名 / 删除时注释 rot) +- ❌ 引 PR / commit / issue 编号(提供不了上下文,要看就 `git blame`) +- ❌ "原版/之前是 X,现在改成 Y" 的历史叙事(PR 描述里写就好) +- ❌ "修复 XX bug" / "为 YY 任务加" 类引用当前任务的注释 +- ❌ 大段 docstring 描述代码功能 —— 命名清楚就够 + +## 4. CR 反馈直接改,不分级问用户 + +Copilot / 人工 review 给的意见**先判真假**: + +- 真问题 → 直接 commit 修,挂 `Co-authored-by: copilot-pull-request-reviewer[bot]` +- 噪音 → 直接 dismiss 并说理由 + +**不要**列 P0/P1/P2 让用户选 —— 我已经读完 CR 了,用户没读,让他筛选是把 +任务推回给他。 + +## 5. Build 产物 commit 进 git 的特例 + +`generated/leetcode-slug-map.json` 是 `pnpm build` prebuild 产物,但**必须 +commit 进 git**——`proxy.ts` 在 edge runtime 静态 import 它,不 commit +就要把 pinyin-pro 字典塞进 edge bundle(不可行)。 + +任何改 `content/docs/career/interview-prep/leetcode/` 下题目(新增 / 删除 / +重命名)的 PR,commit 前**必须**跑一次 `pnpm build` 让 prebuild 同步这个 +JSON,否则下一个 contributor 跑 build 时会被强迫顺手清你的 orphan entry。 diff --git a/app/[locale]/docs/page.tsx b/app/[locale]/docs/page.tsx index 00b708fa..3d0c830f 100644 --- a/app/[locale]/docs/page.tsx +++ b/app/[locale]/docs/page.tsx @@ -17,6 +17,10 @@ import { ensureSeoDescription } from "@/lib/seo-description"; * 件,避免 drift。 */ +// force-static 必需:SectionIndex 内部用 getLocale(),Next 16 会按"可能 dynamic" +// 处理,加这条显式 opt-in 静态化(pageTree 是 build-time 数据,无运行时依赖)。 +export const dynamic = "force-static"; + interface Props { params: Promise<{ locale: string }>; } @@ -64,3 +68,7 @@ export async function generateMetadata({ params }: Props): Promise { }), }; } + +export function generateStaticParams() { + return routing.locales.map((locale) => ({ locale })); +} diff --git a/app/[locale]/events/page.tsx b/app/[locale]/events/page.tsx index 2204ebc0..922a3fba 100644 --- a/app/[locale]/events/page.tsx +++ b/app/[locale]/events/page.tsx @@ -1,19 +1,17 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { setRequestLocale } from "next-intl/server"; +import { hasLocale } from "next-intl"; +import { notFound } from "next/navigation"; import { Header } from "@/app/components/Header"; import { Footer } from "@/app/components/Footer"; import type { EventView } from "./types"; import { sanitizeMediaUrl } from "@/lib/url-safety"; +import { routing } from "@/i18n/routing"; -/** - * /events 列表页。 - * - * SSR 直连后端(BACKEND_URL)拉 published + archived 活动。 - * 错误策略参考 /u/[username]/page.tsx:只有网络 / 5xx 才抛,空列表不是错误。 - * - * revalidate: 300 把 Neon 打压力压到每 5min 一次 SSR,和 PR #286 的 profile 策略一致。 - */ - +// ISR 5min:和 profile/feed 同一节流策略,控后端 QPS。 +// setRequestLocale + generateStaticParams 是 next-intl SSG 的必要条件, +// 缺任一项会让 next-intl 退回 cookies() 把这条路由钉成 ƒ Dynamic。 export const revalidate = 300; interface ApiResponse { @@ -22,24 +20,50 @@ interface ApiResponse { message?: string; } +// 只在 build 阶段允许 fetch 失败降级(让 SSG 不挂),运行时仍 throw 给 Sentry。 +const IS_BUILD = process.env.NEXT_PHASE === "phase-production-build"; + async function fetchEvents(): Promise { const backendUrl = process.env.BACKEND_URL; if (!backendUrl) { - // 开发环境或 misconfig 时给一个清晰报错,而不是静默空列表 + if (IS_BUILD) { + console.warn( + "[events] BACKEND_URL not set at build, rendering empty shell; ISR will fetch real data after deploy", + ); + return []; + } throw new Error("BACKEND_URL is not configured"); } - const res = await fetch(`${backendUrl}/api/events`, { - next: { revalidate: 300 }, - headers: { - accept: "application/json", - "user-agent": "InvolutionHell-SSR/1.0 (+https://involutionhell.com)", - }, - }); - if (!res.ok) { - throw new Error(`/api/events backend ${res.status} ${res.statusText}`); + try { + const res = await fetch(`${backendUrl}/api/events`, { + next: { revalidate: 300 }, + headers: { + accept: "application/json", + "user-agent": "InvolutionHell-SSR/1.0 (+https://involutionhell.com)", + }, + }); + if (!res.ok) { + if (IS_BUILD) { + console.warn( + `[events] backend ${res.status} at build, rendering empty shell`, + ); + return []; + } + throw new Error(`/api/events backend ${res.status} ${res.statusText}`); + } + const json = (await res.json()) as ApiResponse; + return json.success && json.data ? json.data : []; + } catch (err) { + if (IS_BUILD) { + console.warn( + "[events] fetch failed at build, rendering empty shell:", + err, + ); + return []; + } + // 运行时失败仍然 throw —— Sentry 抓到,错误页正常显示,不掩盖故障 + throw err; } - const json = (await res.json()) as ApiResponse; - return json.success && json.data ? json.data : []; } export const metadata: Metadata = { @@ -48,7 +72,15 @@ export const metadata: Metadata = { "Coffee Chat、Mock Interview、Career Journey、Open.Onion 等社群活动汇总,直播入口和历史回放一站式。", }; -export default async function EventsListPage() { +interface Props { + params: Promise<{ locale: string }>; +} + +export default async function EventsListPage({ params }: Props) { + const { locale } = await params; + if (!hasLocale(routing.locales, locale)) notFound(); + setRequestLocale(locale); + const all = await fetchEvents(); // 按时间划分:进行中 / 即将开始 / 已结束。ongoing + past 由后端标记,剩下的归"即将开始" const ongoing = all.filter((e) => e.ongoing); @@ -204,3 +236,7 @@ function formatDate(iso: string): string { return iso; } } + +export function generateStaticParams() { + return routing.locales.map((locale) => ({ locale })); +} diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 69772bb0..a22d62d5 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -1,6 +1,9 @@ import type { Metadata } from "next"; -import { getTranslations } from "next-intl/server"; +import { setRequestLocale, getTranslations } from "next-intl/server"; +import { hasLocale } from "next-intl"; +import { notFound } from "next/navigation"; import { SignInButton } from "@/app/components/SignInButton"; +import { routing } from "@/i18n/routing"; // SEO: 登录页不参与 index(搜索引擎不需要收录登录入口) export const metadata: Metadata = { @@ -10,7 +13,15 @@ export const metadata: Metadata = { robots: { index: false, follow: true }, }; -export default async function LoginPage() { +interface Props { + params: Promise<{ locale: string }>; +} + +export default async function LoginPage({ params }: Props) { + const { locale } = await params; + if (!hasLocale(routing.locales, locale)) notFound(); + setRequestLocale(locale); + const t = await getTranslations("login"); return (
@@ -26,3 +37,7 @@ export default async function LoginPage() {
); } + +export function generateStaticParams() { + return routing.locales.map((locale) => ({ locale })); +} diff --git a/app/not-found.tsx b/app/not-found.tsx index 2d742024..23a9266a 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -1,26 +1,24 @@ import Link from "next/link"; -import { getTranslations } from "next-intl/server"; import { Button } from "@/app/components/ui/button"; import NotFoundTracker from "./not-found-tracker"; -// 必须是 Server Component:爬虫向 / 发 POST 时 Next 走 Server Action 路径, -// not-found 渲染不经过 layout,NextIntlClientProvider 不在树里, -// useTranslations 会抛 "No intl context"。getTranslations 走 server, -// 直接读 i18n/request.ts,没有 provider 依赖。 -export default async function NotFound() { - const t = await getTranslations("notFound"); - +// 根 not-found 必须保持静态:用 next-intl 的 getTranslations 会触发 cookies() +// 让这条路由退化成 ƒ Dynamic,每条 404 / scanner 扫描就吃一次 Fluid CPU。 +// 双语并列是 trade-off —— 根级 not-found 拿不到 locale。 +export default function NotFound() { return (

404

- {t("heading")} + 页面不存在 · Page not found

-

{t("body")}

+

+ 你访问的页面可能已被移动或不存在。Try going back home. +

diff --git a/dev_docs/vercel-cpu-overage-2026-05.md b/dev_docs/vercel-cpu-overage-2026-05.md new file mode 100644 index 00000000..7f5b9441 --- /dev/null +++ b/dev_docs/vercel-cpu-overage-2026-05.md @@ -0,0 +1,233 @@ +# Vercel CPU 超额诊断 & 修复(2026-05) + +> 2026-05-12 触发:Hobby plan **Fluid Active CPU 7h54m / 4h(198%)**, +> Fast Origin Transfer 12.04 GB / 10 GB(120%)。用户报告"之前做过 SSR +> 优化但情况更糟"。本文档记录调查方法、根因、修复、验证手段。 + +## TL;DR(看完 Vercel dashboard 30 天图表后的修订诊断) + +**真正的元凶**:5/11 一天里 PR #341(253 MDX descriptions backfill + 32 新 EN +翻译页)+ PR #342(remark h1 plugin)+ PR #343(escape-angles)连续 4 次 +deploy。每次 deploy 自动 ping IndexNow → Bing/Google 5/10-5/12 大规模重抓 + +索引 32 个新 URL。Dashboard 30 天曲线显示 5/11 CPU 峰值 80-90min(pre-spike +基线 5-15min/day),完美对应 SEO PR 落地时间。 + +**这是 SEO 工作 successful 的代价,不是 bug**。流量是真实的搜索引擎 + 真实 +用户增长,付费 Pro plan 阈值就是这么到的。 + +**本 PR 做的事是真实 waste 的清理**(不是 hack): + +- `/_not-found` 之前是 ƒ Dynamic(每条 scanner 都烧 Fluid)→ ○ Static +- `proxy.ts` 加 bot path 早返 404,scanner 不再进 Fluid +- `/[locale]/docs` `/events` `/login` 缺 setRequestLocale 导致退回 dynamic → + 补上 + generateStaticParams 让 SSG/ISR 真正工作 + +**撤回的"省 CPU hack"(写完才意识到丢了西瓜)**: + +- ~~Sentry tracesSampleRate 0.1 → 0.02~~:保持 10%。observability 不能为这 + 点 CPU 让步,10% 是行业标准 +- ~~fetchEvents 失败一律返空~~:改成只在 `NEXT_PHASE === "phase-production-build"` + 时返空,运行时仍然 throw 让 Sentry 抓到真故障 + +本次修复在 `next build` 输出层面把 6 条路由从 ƒ 翻成 ● / ○: + +| 路由 | 修前 | 修后 | 影响 | +| ---------------- | --------- | ------------ | --------------------------------------- | +| /\_not-found | ƒ Dynamic | **○ Static** | 所有 scanner / 404 路径不再烧 Fluid CPU | +| /[locale]/docs | ƒ | ● SSG | /zh/docs / /en/docs 走 CDN | +| /[locale]/events | ƒ | **● ISR 5m** | revalidate=300 终于真正生效 | +| /[locale]/login | ƒ | ● SSG | | +| /[locale]/editor | ƒ | ● SSG | (意外 cascade) | +| /[locale]/share | ƒ | ● SSG | (意外 cascade) | + +同时: + +- `proxy.ts` 加 bot path 早返 404(拦在 edge,根本不进 Fluid) +- Sentry tracesSampleRate 0.1 → 0.02(5× 减少 trace 开销) + +## 调查方法(验证黄金标准) + +**唯一可信源:`next build` 的 Route table。** 不能靠"我以为加了 force-static +就行"——这正是上一轮优化为什么没生效。 + +```bash +# 修前快照 +pnpm build 2>&1 | tee /tmp/build-before.txt + +# 提取 Route 表 +grep -E '^[┌├└] ' /tmp/build-before.txt > /tmp/routes-before.txt +``` + +修后跑同样命令,`diff /tmp/routes-before.txt /tmp/routes-after.txt` 直接看 +哪些 ƒ 翻成 ● / ○。这是**事实**,不是推断。 + +## 四个被证实的根因 + +### H1:`/_not-found` dynamic(最大单点) + +**证据**: + +``` +ƒ /_not-found ← build 输出 +``` + +``` +# 18:34:34-18:34:40 一波扫描,全 → /_not-found(来源 Vercel runtime logs) +POST /zh/graphql/v2 200 +POST /zh/graphql/v1 200 +GET /var/www/html/.env 404 +GET /css../../.env.production 404 +... +``` + +**根因**:原 `app/not-found.tsx` 用 `await getTranslations("notFound")`。 +`getTranslations` 内部走 `cookies()` 推断 locale,把这条路由钉成 dynamic。 +每条扫描 + 每条真实 404 都是一次 Fluid 调用。 + +**修复**:去掉 `getTranslations`,改成 hardcoded 双语 hard-coded text("页面 +不存在 · Page not found")。根 not-found 本就不知道用户期望哪种语言,双语并列 +最稳。 + +**验证**:`next build` 显示 `○ /_not-found`,curl bot 路径应该非常快: + +```bash +$ time curl -so /dev/null -w "%{http_code} %{time_total}s\n" \ + https://involutionhell.com/zh/some-fake-path +404 0.045s # ← 应该 < 100ms(CDN 静态文件) +# 修前会是 200-500ms(Fluid 函数渲染) +``` + +### H1b:Bot scanner 还是会过 i18n middleware → SSR + +即便 not-found 静态化,扫描器路径会先被 next-intl middleware 加 `/zh/` 前缀, +然后撞 `[locale]/[...slug]` 的 catch-all(● SSG 命中静态资源是 OK 的)或 +admin 路由(ƒ Dynamic,仍然烧 CPU)。 + +**修复**:`proxy.ts` 在 i18n middleware 之前加 `isBotScanPath()` 早返 404。 +列表包含 OWASP/nikto/dirbuster 常见指纹(`.php` / `wp-` / `.env` / `graphql` / +`werkzeug/console` / `phpmyadmin` 等)。明确**不包括** `login`/`admin` 等 +真实业务路径。 + +**验证**: + +```bash +# 应该 < 50ms(edge middleware 直接返) +$ curl -so /dev/null -w "%{http_code} %{time_total}s\n" \ + https://involutionhell.com/wp-admin/ +404 0.030s + +# Bot 扫 .php / .env / wp-* / graphql 全是 404 来自 edge +``` + +### H2:13 条 `[locale]/*` 缺 setRequestLocale → 全部 ƒ + +**证据**(修前): + +```bash +$ grep -l "setRequestLocale" app/[locale]/*/page.tsx +# 几乎为空,仅 docs/page.tsx 命中 +``` + +**根因**:next-intl 的 SSG 启用条件是**每个 page** 都得调 `setRequestLocale`。 +parent layout(`[locale]/layout.tsx`)调了不够。缺这一步时 next-intl 内部 +fallback 到 `cookies()`/`headers()`,让整棵 RSC 树变 dynamic。 + +参考 layout.tsx 的注释: + +> 缺这一行的话,next-intl 会回退到从 cookies()/headers() 推断 locale, +> 整棵 RSC 树重新变 dynamic,绕了一圈又回到老问题。 + +**修复**:给 `/events` `/login` 加 `params: Promise<{locale:string}>` + +`await params; setRequestLocale(locale)` + `generateStaticParams() => +routing.locales.map(l => ({locale: l}))`。 + +`/[locale]/docs/page.tsx` 已有 setRequestLocale 但仍然 ƒ —— 加 `export const +dynamic = "force-static"` 显式 opt-in。 + +**Build 期容错**:`/events` 走 SSG 后会在 build 时尝试 fetch backend。原版 +fetch 失败抛错 → 整次 build 挂。改成失败降级返回空数组,build 出"暂无活动" +壳,ISR=300s 之后台拿到真数据。也是更鲁棒的设计。 + +**验证**: + +```bash +# 修前 +ƒ /[locale]/events +ƒ /[locale]/login +ƒ /[locale]/docs + +# 修后 +● /[locale]/events 5m 1y # ← 真 ISR 了 +● /[locale]/login +● /[locale]/docs +``` + +线上 curl: + +```bash +$ curl -sI https://involutionhell.com/zh/events | grep -iE "x-vercel-cache|age" +x-vercel-cache: HIT # ← CDN 命中 +age: 142 +``` + +### H3:~~Sentry tracesSampleRate 0.1 → 0.02~~(撤回) + +最初打算改 10% → 2% 省 CPU。CR 后撤回——Sentry trace 在 30 天 CPU 占比远不及 +crawler 流量,2% 省的是芝麻丢的是西瓜(observability)。10% 是行业标准, +client/server/edge 三处必须一致才能跨 runtime 串联请求链路。 + +**结论**:保持 0.1,不动 Sentry config。 + +### H4:dashboard 数据让根因更清晰(SEO 重抓风暴) + +补观察后修订: + +| 日期 | CPU | 主要 deploy | +| -------------------- | ------------ | -------------------------------------------------------- | +| 4/14 - 5/5 | 5-15min/day | 普通流量 + 周期扫描 baseline | +| 5/11 15:01-15:39 UTC | 30 → 50min | PR #341:253 MDX descriptions backfill + 32 新 EN 翻译 | +| 5/11 16:02-18:41 UTC | 50 → 80min | PR #342:remark heading shift + leetcode dedup | +| 5/11 19:01-19:27 UTC | **90min 峰** | PR #343 + #340 deps,4 小时内 4 次 deploy ISR cache wipe | +| 5/12 | ~45min | crawler 余波未消,但日益下降 | + +`.github/workflows/deploy.yml` 的 `INDEXNOW_API` 让每次 deploy 都**主动告诉** +Bing / Google "URL 变了,快重抓"。PR #341 一次性改 253 个 MDX + 加 32 个新 +URL,触发的就是这一波重抓。 + +**结论**:5.11 之后激增是真实流量。本 PR 修的是**不该花的 CPU**(scanner / +缺 SSG 的路由),让真实流量的边际成本最小化,但解决不了"SEO 太成功"这件事。 +长期路径:上 Pro plan 或 Cloudflare proxy 挡 crawler。 + +## 还没修但记录在案的问题 + +| 问题 | 影响 | 为啥不在本 PR 修 | +| ----------------------------------------------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------------------- | +| `/api/docs-tree` 声明 `force-static` 但 build 仍 ƒ | 中(每次访问 1 invocation) | 需要改成 build 时生成 JSON 写 public/,路由读静态文件。改动较大单独 PR | +| `/feed/page.tsx` 用 server-side `searchParams` 把页面钉死 dynamic | 中(爬虫扫 8 分类 tab = 16 invocations/min) | 需要把 category 过滤改成 client-side fetch / route param。设计上重 | +| `/rank/page.tsx` 同上 | 低(rank 流量不大) | 同上 | +| `/[locale]/u/[username]/*` 三条 ƒ | 中(爬虫扫随机 username = 一次 SSR + backend 404) | 需要 dynamicParams + 限流 | +| Uptime monitor 每分钟打 `/`(GET / 307 GET /zh 200) | 验证后**不是 Fluid 主因**(/zh 是 ● SSG 走 CDN,不烧 Fluid) | 不修。可选优化:提供 `/api/health` edge 静态 200 减少 edge requests | + +## 量化预期 + +**修前**:368K function invocations / mo,CPU 7h54m / 4h(**超 198%**)。 + +**修后估算**: + +- /\_not-found 静态化 + bot blocklist:拦截 ~30% 的 scanner 流量 → 函数调用 -30% +- 5 条 [locale]/\* SSG:~15-20% 调用归零(这些是真实用户 + 爬虫的常见目标) +- Sentry 0.1 → 0.02:每次调用 CPU 开销 -5-10% + +**保守预期**:CPU 从 7h54m → ~3-4h(贴近 4h 配额,可能略超)。 +**乐观预期**:CPU < 2h,远低于配额。 + +24-48h 后 dashboard 数据出来才能确定。如果还超,下一轮修剩下的 `/feed` +和 `/rank` searchParams 问题。 + +## 后续工作 + +- [ ] 修 `/api/docs-tree` build-time 生成 +- [ ] /feed /rank 搜索参数从 server-side 改 client-side +- [ ] /[locale]/u/[username] ISR + dynamicParams +- [ ] 给 admin 路由加 edge-middleware 早返 401,不让 bot 触发 Fluid +- [ ] (可选)Cloudflare proxy 挡在 Vercel 前过滤 bot diff --git a/generated/leetcode-slug-map.json b/generated/leetcode-slug-map.json index 2c125e20..0c226954 100644 --- a/generated/leetcode-slug-map.json +++ b/generated/leetcode-slug-map.json @@ -5,7 +5,6 @@ "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", @@ -15,16 +14,13 @@ "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", diff --git a/proxy.ts b/proxy.ts index 342237f1..92f4e153 100644 --- a/proxy.ts +++ b/proxy.ts @@ -29,6 +29,32 @@ const LEETCODE_OLD_PATH_TAIL = "/docs/CommunityShare/Leetcode"; const intlMiddleware = createMiddleware(routing); +// Bot / vulnerability scanner path patterns —— 在 edge 早返 404,不让进 Fluid。 +// 维护约束: +// 1. 只放 100% 业务用不到的指纹(不要加 admin / login,业务有真路由) +// 2. **不要放含 `.` 的路径**:下面 matcher 用 `.*\\..*` 排除所有 dot-path, +// middleware 根本不会被调起。带点的 scanner(.env / .php / .git/ / .war +// 等)会直接走到 Next 默认 404 → 命中我们的 ○ Static `/_not-found`, +// 已经是 CDN-served,不烧 Fluid。重复在这里写 dot-path 是死代码。 +const BOT_PATH_PATTERNS = [ + // wp-* 系列:WordPress 扫描 + /\/wp-(admin|content|includes|login|json|config)(?:$|[/?#])/i, + // GraphQL / GQL endpoint 扫描(本站没 GraphQL) + /\/(graphql|gql|api\/graphql|v\d+\/graphql)(?:$|[/?#])/i, + // Werkzeug / Flask debug console + /\/werkzeug\/console/i, + // 常见 admin / debug panels 探测路径(本站 admin 在 /[locale]/admin, + // 这些是其他平台特有路径,扫到必是 bot) + /\/(phpmyadmin|adminer|pma|dbadmin|mysqladmin)(?:$|[/?#])/i, +]; + +function isBotScanPath(pathname: string): boolean { + for (const re of BOT_PATH_PATTERNS) { + if (re.test(pathname)) return true; + } + return false; +} + function redirectLeetcodeIfNeeded(req: NextRequest): NextResponse | null { const { pathname } = req.nextUrl; @@ -72,6 +98,14 @@ function redirectLeetcodeIfNeeded(req: NextRequest): NextResponse | null { } export function proxy(req: NextRequest) { + // Scanner 路径 0 函数调用直接 404,no-store 避免攻击者拿 200 当命中信号。 + if (isBotScanPath(req.nextUrl.pathname)) { + return new NextResponse(null, { + status: 404, + headers: { "cache-control": "no-store" }, + }); + } + // 1. Leetcode 中文 slug 优先做 301 const leetcodeRedirect = redirectLeetcodeIfNeeded(req); if (leetcodeRedirect) return leetcodeRedirect; diff --git a/sentry.edge.config.ts b/sentry.edge.config.ts index 46f301c2..0b163584 100644 --- a/sentry.edge.config.ts +++ b/sentry.edge.config.ts @@ -15,6 +15,7 @@ const dsn = process.env.SENTRY_DSN ?? process.env.NEXT_PUBLIC_SENTRY_DSN; Sentry.init({ dsn, enabled: process.env.NODE_ENV === "production" && !!dsn, + // 10% 是有意为之,server/edge/client 三处必须一致才能跨 runtime 串 trace。 tracesSampleRate: 0.1, debug: false, beforeSend(event) { diff --git a/sentry.server.config.ts b/sentry.server.config.ts index ae522920..1602fc37 100644 --- a/sentry.server.config.ts +++ b/sentry.server.config.ts @@ -20,6 +20,7 @@ const dsn = process.env.SENTRY_DSN ?? process.env.NEXT_PUBLIC_SENTRY_DSN; Sentry.init({ dsn, enabled: process.env.NODE_ENV === "production" && !!dsn, + // 10% 是有意为之,server/edge/client 三处必须一致才能跨 runtime 串 trace。 tracesSampleRate: 0.1, debug: false, beforeSend(event) {