fix(cloudflare): 页面请求返回 200 的挑战 / 人机验证门页也进入 A→B 级联

- handle 对页面请求(GET + Accept text/html)的 2xx 响应也做挑战判定(短页、无站点内容才看正文),
  过挑战 → cf_clearance 回带重试 → 浏览器代抓的级联照常触发
- 没处理掉时返回的挑战响应带 x-umt-challenge(needs_human / failed / throttled / no_browser / no_account),
  适配器据此区分「需要你在原站视图里过一次验证」与「已自动尝试,没有成功」等文案
- 测试:200 门页过挑战后重试成功、200 挑战页走浏览器代抓、转人工时搜索报 challenge 并留脱敏样本

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 15:41:00 +08:00
1 parent 0088c1c9e7
commit bd475a4ce1
2 files changed
+89 -9

No files matched your search

@@ -1,4 +1,4 @@
import { detectChallenge, mergeCookieHeaders, parseCookieHeader } from "@umt/adapters";
import { CHALLENGE_OUTCOME_HEADER, detectChallenge, mergeCookieHeaders, parseCookieHeader } from "@umt/adapters";
import { type SiteCloudflareState, siteCloudflareState } from "@umt/contracts";
import { type SiteAccountRow, type SiteRow, sql } from "@umt/db";
import { egressKey, siteEgress } from "../../../egress.ts";
@@ -11,12 +11,14 @@ import { resolveUserAgent } from "./user-agent.ts";
// 站点 HTTP 请求(SourcesRuntime.siteFetch)经这里:
// 0. 站点在"浏览器代理期"(config.cloudflare.browserUntil 未到)且请求是页面(GET + Accept text/html)→ 直接由浏览器代抓;
// 1. 发 HTTP;账号 UA 跟随浏览器、出口与浏览器一致、复用没失败过时带上浏览器拿到的 cf_clearance(__cf_bm 不带);
// 2. 响应是挑战(cf-mitigated: challenge,或 403 / 503 / 429 的挑战页)→ 不算凭据错误,记 lastChallengeAt;
// 2. 响应是挑战(cf-mitigated: challenge,或 403 / 503 / 429 的挑战页,或页面请求返回 200 的挑战页 / 人机验证门页)
// → 不算凭据错误,记 lastChallengeAt;
// 3. 过挑战(同站 30 分钟最多一次、同一时刻只跑一次;只处理有登录态账号的站点):浏览器用该账号 profile 打开同一地址 →
// ① 等它自己过(≤15 秒)→ ② 复选框 / Turnstile 派视觉子流程点一下(最多 2 次)→ ③ 选图 / 输码类转人工(待处理事项);
// 4. 过了:读回 cf_clearance + navigator.userAgent;满足复用条件就存下来,HTTP 重试 1 次,结果记 cfReuse = ok / fails;
// 5. 仍被挑战:页面请求改由浏览器导航取 HTML 交给原解析器,站点记 6 小时浏览器代理期;其它请求(下载、RSS、POST)照常报挑战。
// 不接任何打码服务。
// 没处理掉时返回的挑战响应带 `x-umt-challenge: <结果>`(needs_human / failed / throttled / no_browser / no_account),
// 适配器据此写错误文案(自动处理到哪一步、要不要人)。不接任何打码服务。
/** 同站两次过挑战的最短间隔 */
export const CF_ATTEMPT_INTERVAL_MS = 30 * 60_000;
@@ -132,6 +134,13 @@ export function clearanceUsable(input: {
);
}
/** 挑战响应上标出级联结果(响应头不可改,重新包一层) */
function tagOutcome(res: Response, outcome: string): Response {
const headers = new Headers(res.headers);
headers.set(CHALLENGE_OUTCOME_HEADER, outcome);
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
}
export class CloudflareGate {
private readonly cache = new Map<string, { at: number; state: SiteCloudflareState }>();
/** 进程内同站同一时刻只过一次挑战:后到的请求等同一个结果 */
@@ -323,19 +332,19 @@ export class CloudflareGate {
// 1. HTTP(满足条件时带 cf_clearance)
// 从没遇到过挑战的站点不查账号(每条请求少两次查询)
const extra = st.lastChallengeAt ? await this.clearanceCookie(siteId, req).catch(() => null) : null;
const first = await detectChallenge(await send(extra));
const first = await detectChallenge(await send(extra), { page: pagey });
if (!first.rule) return first.res;
// 2. 挑战:记下来;没有浏览器 / 没有登录态账号 → 原样返回(适配器报 challenge,不算凭据错误)
await this.patch(siteId, { lastChallengeAt: now.toISOString(), lastRule: first.rule });
const port = challengeBrowserFor(this.ctx);
if (!port) return first.res;
if (!port) return tagOutcome(first.res, "no_browser");
const acc = await this.loggedInAccount(siteId);
if (!acc) {
await this.patch(siteId, { lastResult: "skipped", lastError: "站点没有已登录的账号,不处理挑战" });
return first.res;
return tagOutcome(first.res, "no_account");
}
const site = await this.ctx.db.selectFrom("site").selectAll().where("id", "=", siteId).executeTakeFirst();
if (!site?.enabled || site.deleted_at) return first.res;
if (!site?.enabled || site.deleted_at) return tagOutcome(first.res, "failed");
// 3. 过挑战
const out = await this.solveOnce(site, acc, req.url);
st = await this.state(siteId, true);
@@ -357,7 +366,7 @@ export class CloudflareGate {
const retryExtra = await this.clearanceCookie(siteId, req).catch(() => null);
if (retryExtra) {
await this.rt.limiterOf(siteId)?.acquire();
const second = await detectChallenge(await send(retryExtra));
const second = await detectChallenge(await send(retryExtra), { page: pagey });
if (!second.rule) {
await this.patch(siteId, { cfReuse: "ok", lastResult: "reuse_ok", browserUntil: null });
return second.res;
@@ -387,7 +396,7 @@ export class CloudflareGate {
return r;
}
}
return first.res;
return tagOutcome(first.res, !out ? "throttled" : out.status === "needs_human" ? "needs_human" : "failed");
}
/**