From 81c8c3b23bddf0e4f402e92f08e55dc6e3c1dde3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:51:15 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix(leaderboard):=20=E8=84=9A=E6=9C=AC=20UA?= =?UTF-8?q?=20=E6=8D=A2=20Chrome=20=E4=BC=AA=E8=A3=85=E8=A7=84=E9=81=BF=20?= =?UTF-8?q?CF=20Bot=20Fight?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 故障复盘 PR #322 合并后 prod build 跑 generate-leaderboard.mjs 拿到 Cloudflare 的 403 + "Just a moment..." 挑战页,脚本走 fallback 写空数组上线, 首页 Top Rank / /rank contributors 全空。 根因 api.involutionhell.com 走 Cloudflare,默认 Bot Fight Mode 对短时间内多次 请求或低信誉 IP 段(Vercel build runner)会临时 challenge。当时虽然 UA 含 "build" 关键词加重了被拦概率,但实测换任意 UA 当下都能 200,所以本质是 CF 信誉评分 + 时间窗叠加。 本 PR 脚本 UA 从 "InvolutionHell-build/1.0 (generate-leaderboard.mjs)" 改为标准 Chrome UA,避免任何 "build/script/bot" 关键词触发 CF UA 启发式判定。 跟 backend OgFetchService 的 UA 伪装策略对齐。 长期建议(不在本 PR 范围) 在 Cloudflare 给 api.involutionhell.com/api/public/* 加规则: Action: Skip → "Browser Integrity Check" + "Bot Fight Mode" 让公开 API 永远绕过挑战。需要在 CF dashboard 操作。 修复路径 合并 → CI 触发 prod redeploy → generate-leaderboard 拉到真实 21 条 → 首页 Top Rank / /rank contributors 恢复正常。 --- scripts/generate-leaderboard.mjs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/generate-leaderboard.mjs b/scripts/generate-leaderboard.mjs index 350b990..0c6a84c 100644 --- a/scripts/generate-leaderboard.mjs +++ b/scripts/generate-leaderboard.mjs @@ -103,7 +103,15 @@ async function fetchAggregatedFromBackend() { const res = await fetch(LEADERBOARD_API_URL, { headers: { accept: "application/json", - "user-agent": "InvolutionHell-build/1.0 (generate-leaderboard.mjs)", + // UA 用 Chrome 伪装:API 站点走 Cloudflare,CF 默认 Bot Fight Mode 会 + // 把任何含 "bot" / "build" / "script" 关键词的 UA 当机器人拦下,回 + // 403 + "Just a moment..." 挑战页(之前的 UA 就被这么拦了,导致 prod + // build 拿到 403 走 fallback 写空 leaderboard,feed 卡片全空)。 + // 长期方案应是在 CF 给 /api/public/* 加 "Skip Bot Fight" 规则白名单, + // 这里的 UA 伪装只是兜底。 + "user-agent": + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", }, signal: controller.signal, }); From 1e8084b8781e2457178a757eaf88fd1b0c625579 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:43:36 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix(leaderboard):=20fallback=20=E4=BC=98?= =?UTF-8?q?=E5=85=88=E4=BF=9D=E7=95=99=E6=97=A7=20JSON=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E4=B8=80=E6=AC=A1=E6=8B=89=E5=A4=B1=E8=B4=A5=E6=8A=B9?= =?UTF-8?q?=E6=8E=89=E5=A5=BD=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #325 自身的 preview build 仍被 CF 403 拦下(log 显示 "Just a moment..."), 说明 UA 伪装救不了——CF 是基于 Vercel runner 的 IP 段信誉评分,跟 UA 无关。 真正的根治是去 CF 给 /api/public/* 加 "Skip Bot Fight" 规则(用户操作)。 本次至少把"一次失败抹好数据"这个二次伤害堵住: - 拉到数据 → 正常生成 - 拉不到 + 旧 JSON 有非空数组 → 保留旧版,warn 日志,exit 0 - 拉不到 + 旧 JSON 空/损坏 → 写空数组兜底(首次 build 不挂) - 拉不到 + 旧 JSON 不存在 → 写空数组兜底 效果: 即便 CF 后续仍偶发拦截,prod 上线的 leaderboard 也只会"维持上一版" 而不是"突然空了"。Top Rank 不会因为一次 build 抖动整块消失。 --- scripts/generate-leaderboard.mjs | 49 +++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/scripts/generate-leaderboard.mjs b/scripts/generate-leaderboard.mjs index 0c6a84c..33c0c9d 100644 --- a/scripts/generate-leaderboard.mjs +++ b/scripts/generate-leaderboard.mjs @@ -153,12 +153,53 @@ async function main() { const aggregated = await fetchAggregatedFromBackend(); if (aggregated === null) { + // 拉不到后端时优先保留 generated/site-leaderboard.json 旧版本: + // 一次 fetch 失败(CF 临时挑战 / Vercel runner IP 信誉低 / 后端短暂抖动) + // 不应该把 commit 进 git 的好数据冲成空数组上线。 + // + // 三种情况: + // 1. 文件已存在 + 内容是非空数组 → 保留旧数据 exit 0 + // 2. 文件已存在但是空数组 / 不是数组 → 维持原状 exit 0(不主动覆盖) + // 3. 文件不存在(首次 build / 干净 cache)→ 写空数组兜底 exit 0 + let preservedExisting = false; + try { + const existing = await fs.readFile(outputAbs, "utf-8"); + try { + const parsed = JSON.parse(existing); + if (Array.isArray(parsed) && parsed.length > 0) { + console.warn( + `[generate-leaderboard] 后端不可用,但保留 ${OUTPUT} 已有 ${parsed.length} 条数据,不覆盖。 | Backend unreachable; keeping existing leaderboard with ${parsed.length} entries.`, + ); + } else { + console.warn( + `[generate-leaderboard] 后端不可用,且 ${OUTPUT} 已有内容非有效非空数组,维持原状。`, + ); + } + preservedExisting = true; + } catch { + // 文件存在但 JSON 损坏:当作没有,走下面写空兜底 + console.warn( + `[generate-leaderboard] ${OUTPUT} 已存在但 JSON 解析失败,按"首次 build"兜底覆盖空数组。`, + ); + } + } catch (readErr) { + // ENOENT 等:文件不存在,走兜底 + if (readErr && readErr.code !== "ENOENT") { + console.warn( + "[generate-leaderboard] 读取既有 leaderboard 失败:", + readErr instanceof Error ? readErr.message : readErr, + ); + } + } + + if (preservedExisting) { + process.exit(0); + } + + // 文件不存在 / 损坏:写空兜底,避免后续 Next import 抛 ENOENT console.error( - "[generate-leaderboard] 后端不可用,写入空榜单以放行构建。 | Backend unreachable, writing empty leaderboard to unblock build.", + "[generate-leaderboard] 后端不可用且本地无可保留数据,写入空榜单以放行构建。 | Backend unreachable and no existing data, writing empty leaderboard to unblock build.", ); - // mkdir + writeFile 必须放同一个 try:任一步失败都意味着 generated/site-leaderboard.json - // 不存在,后续 Next 端 import 会抛更难定位的 ENOENT。这种情况 build 必须 fail-fast, - // 不能 exit 0 让"看起来一切正常"的 deploy 把站点搞挂。 try { await ensureParentDir(outputAbs); await fs.writeFile(outputAbs, "[]", "utf-8"); From 168fc7537c10c6e2fbca22f3969ea6ada277f2d8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:50:55 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(leaderboard):=20UA=20=E6=94=B9=E5=9B=9E?= =?UTF-8?q?=20InvolutionHell-SSR=20=E8=AE=A9=20CF=20Custom=20Rule=20?= =?UTF-8?q?=E7=9C=9F=E6=AD=A3=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前误判 昨天看到 build 拿 403 + "Just a moment..." 时,第一反应是"UA 含 build 关键词 触发 CF UA 启发式",于是把 UA 改成 Chrome 伪装。错了。 实际 CF 配置 api.involutionhell.com 上有一条 Custom Rule: (http.host eq "api.involutionhell.com" and http.user_agent contains "InvolutionHell-SSR") → Skip: Bot Fight Mode / Browser Integrity Check / Managed Rules 也就是说 CF **明确依赖 UA token "InvolutionHell-SSR"** 来识别"自己人"放行。 Chrome 伪装恰恰把这个 token 拿掉,规则不匹配,Vercel runner 仍然按 IP 信誉被 Bot Fight 拦下回 403。 本 PR 脚本 UA 改成 "InvolutionHell-SSR/1.0 (build; generate-leaderboard.mjs; +https://involutionhell.com)" 带上 CF 规则要求的 token。本机实测 200 + 21 条数据正常返回。 效果 合并后 prod build → CF 规则匹配 Skip → 拉到真实数据 → site-leaderboard.json 回到 21 条 → 首页 Top Rank / /rank contributors 恢复显示。 --- scripts/generate-leaderboard.mjs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/scripts/generate-leaderboard.mjs b/scripts/generate-leaderboard.mjs index 33c0c9d..9b493d1 100644 --- a/scripts/generate-leaderboard.mjs +++ b/scripts/generate-leaderboard.mjs @@ -103,15 +103,15 @@ async function fetchAggregatedFromBackend() { const res = await fetch(LEADERBOARD_API_URL, { headers: { accept: "application/json", - // UA 用 Chrome 伪装:API 站点走 Cloudflare,CF 默认 Bot Fight Mode 会 - // 把任何含 "bot" / "build" / "script" 关键词的 UA 当机器人拦下,回 - // 403 + "Just a moment..." 挑战页(之前的 UA 就被这么拦了,导致 prod - // build 拿到 403 走 fallback 写空 leaderboard,feed 卡片全空)。 - // 长期方案应是在 CF 给 /api/public/* 加 "Skip Bot Fight" 规则白名单, - // 这里的 UA 伪装只是兜底。 + // UA 必须包含 "InvolutionHell-SSR" —— 这是 CF Custom Rule 的匹配触发词: + // (http.host eq "api.involutionhell.com" and http.user_agent contains "InvolutionHell-SSR") + // → Skip Bot Fight / Browser Integrity / Managed Rules + // 之前用 Chrome 伪装时 UA 不含这个 token,CF 规则不匹配,Vercel build runner + // 仍然被 IP 信誉评分判定为 bot 回 403("Just a moment..." 挑战页)。 + // 改回带 token 的 UA 让 CF 规则真正放行。 "user-agent": - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + - "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "InvolutionHell-SSR/1.0 (build; generate-leaderboard.mjs; " + + "+https://involutionhell.com)", }, signal: controller.signal, });