Skip to content

Commit 6ce8104

Browse files
fix(leaderboard): UA 改回 InvolutionHell-SSR + fallback 保留旧 JSON (re-PR #325) (#326)
* fix(leaderboard): 脚本 UA 换 Chrome 伪装规避 CF Bot Fight 故障复盘 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 恢复正常。 * fix(leaderboard): fallback 优先保留旧 JSON,避免一次拉失败抹掉好数据 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 抖动整块消失。 * fix(leaderboard): UA 改回 InvolutionHell-SSR 让 CF Custom Rule 真正匹配 之前误判 昨天看到 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 恢复显示。 --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 12e00dd commit 6ce8104

1 file changed

Lines changed: 54 additions & 5 deletions

File tree

scripts/generate-leaderboard.mjs

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,15 @@ async function fetchAggregatedFromBackend() {
103103
const res = await fetch(LEADERBOARD_API_URL, {
104104
headers: {
105105
accept: "application/json",
106-
"user-agent": "InvolutionHell-build/1.0 (generate-leaderboard.mjs)",
106+
// UA 必须包含 "InvolutionHell-SSR" —— 这是 CF Custom Rule 的匹配触发词:
107+
// (http.host eq "api.involutionhell.com" and http.user_agent contains "InvolutionHell-SSR")
108+
// → Skip Bot Fight / Browser Integrity / Managed Rules
109+
// 之前用 Chrome 伪装时 UA 不含这个 token,CF 规则不匹配,Vercel build runner
110+
// 仍然被 IP 信誉评分判定为 bot 回 403("Just a moment..." 挑战页)。
111+
// 改回带 token 的 UA 让 CF 规则真正放行。
112+
"user-agent":
113+
"InvolutionHell-SSR/1.0 (build; generate-leaderboard.mjs; " +
114+
"+https://involutionhell.com)",
107115
},
108116
signal: controller.signal,
109117
});
@@ -145,12 +153,53 @@ async function main() {
145153
const aggregated = await fetchAggregatedFromBackend();
146154

147155
if (aggregated === null) {
156+
// 拉不到后端时优先保留 generated/site-leaderboard.json 旧版本:
157+
// 一次 fetch 失败(CF 临时挑战 / Vercel runner IP 信誉低 / 后端短暂抖动)
158+
// 不应该把 commit 进 git 的好数据冲成空数组上线。
159+
//
160+
// 三种情况:
161+
// 1. 文件已存在 + 内容是非空数组 → 保留旧数据 exit 0
162+
// 2. 文件已存在但是空数组 / 不是数组 → 维持原状 exit 0(不主动覆盖)
163+
// 3. 文件不存在(首次 build / 干净 cache)→ 写空数组兜底 exit 0
164+
let preservedExisting = false;
165+
try {
166+
const existing = await fs.readFile(outputAbs, "utf-8");
167+
try {
168+
const parsed = JSON.parse(existing);
169+
if (Array.isArray(parsed) && parsed.length > 0) {
170+
console.warn(
171+
`[generate-leaderboard] 后端不可用,但保留 ${OUTPUT} 已有 ${parsed.length} 条数据,不覆盖。 | Backend unreachable; keeping existing leaderboard with ${parsed.length} entries.`,
172+
);
173+
} else {
174+
console.warn(
175+
`[generate-leaderboard] 后端不可用,且 ${OUTPUT} 已有内容非有效非空数组,维持原状。`,
176+
);
177+
}
178+
preservedExisting = true;
179+
} catch {
180+
// 文件存在但 JSON 损坏:当作没有,走下面写空兜底
181+
console.warn(
182+
`[generate-leaderboard] ${OUTPUT} 已存在但 JSON 解析失败,按"首次 build"兜底覆盖空数组。`,
183+
);
184+
}
185+
} catch (readErr) {
186+
// ENOENT 等:文件不存在,走兜底
187+
if (readErr && readErr.code !== "ENOENT") {
188+
console.warn(
189+
"[generate-leaderboard] 读取既有 leaderboard 失败:",
190+
readErr instanceof Error ? readErr.message : readErr,
191+
);
192+
}
193+
}
194+
195+
if (preservedExisting) {
196+
process.exit(0);
197+
}
198+
199+
// 文件不存在 / 损坏:写空兜底,避免后续 Next import 抛 ENOENT
148200
console.error(
149-
"[generate-leaderboard] 后端不可用,写入空榜单以放行构建。 | Backend unreachable, writing empty leaderboard to unblock build.",
201+
"[generate-leaderboard] 后端不可用且本地无可保留数据,写入空榜单以放行构建。 | Backend unreachable and no existing data, writing empty leaderboard to unblock build.",
150202
);
151-
// mkdir + writeFile 必须放同一个 try:任一步失败都意味着 generated/site-leaderboard.json
152-
// 不存在,后续 Next 端 import 会抛更难定位的 ENOENT。这种情况 build 必须 fail-fast,
153-
// 不能 exit 0 让"看起来一切正常"的 deploy 把站点搞挂。
154203
try {
155204
await ensureParentDir(outputAbs);
156205
await fs.writeFile(outputAbs, "[]", "utf-8");

0 commit comments

Comments
 (0)