- http.ts:cf-mitigated: challenge 或 403/503/429 挑战页 → SiteError(challenge),503 不再算维护、403 不再落到 auth_invalid - 挑战页分类 classifyChallengePage:自动 / 可点(Turnstile)/ 真验证码 - 刷新登录 / 用户数据遇 Cloudflare 挑战只记 last_error,账号状态不变、不开登录失效事项 Co-Authored-By: Claude Opus 5.5 <[email protected]>
84 lines
4.5 KiB
TypeScript
84 lines
4.5 KiB
TypeScript
// CF-2 Cloudflare 挑战识别(docs/design/cloudflare-handling.md)。
|
|
// - HTTP 端:响应头 `cf-mitigated: challenge`(任何状态码),或 403 / 503 / 429 且正文是挑战页 → 挑战,不是凭据错误;
|
|
// - 浏览器端:挑战页分三类——自动(JS 检查,等几秒自己过)、可点(「我是人类」复选框 / Turnstile)、真验证码(选图 / 输码)。
|
|
// 只用正则,不解析 DOM:HTTP 端要在限流器之后快速判断,浏览器端拿到的是 outerHTML + innerText 样本。
|
|
|
|
/** Cloudflare / DDoS-Guard 挑战页的标题 */
|
|
const CHALLENGE_TITLE = /^\s*(?:just a moment|attention required! \| cloudflare|请稍候|ddos-guard|one moment, please)/i;
|
|
/** 挑战页的脚本 / 元素特征。不认 `/cdn-cgi/challenge-platform/`:Cloudflare 会往正常页面也注入这段脚本 */
|
|
const CHALLENGE_HTML =
|
|
/cf-browser-verification|cf_chl_opt|cf-challenge-running|id="challenge-form"|id="challenge-spinner"|id="cf-please-wait"|id="trk_jschal_js"/i;
|
|
|
|
function titleOf(html: string): string {
|
|
return /<title[^>]*>([^<]*)<\/title>/i.exec(html.slice(0, 50_000))?.[1]?.trim() ?? "";
|
|
}
|
|
|
|
/** 页面是不是挑战页;命中返回规则说明(进技术信息),否则 null */
|
|
export function challengeRuleOfHtml(html: string | null | undefined): string | null {
|
|
if (!html) return null;
|
|
const title = titleOf(html);
|
|
if (CHALLENGE_TITLE.test(title)) return `title=${title.slice(0, 40)}`;
|
|
const m = CHALLENGE_HTML.exec(html.slice(0, 200_000));
|
|
return m ? `html*=${m[0]}` : null;
|
|
}
|
|
|
|
/** 响应头判定(不读正文):`cf-mitigated: challenge` */
|
|
export function challengeRuleOfHeaders(headers: Headers): string | null {
|
|
return headers.get("cf-mitigated")?.trim().toLowerCase() === "challenge" ? "header cf-mitigated=challenge" : null;
|
|
}
|
|
|
|
/** 这些状态码的响应才去看正文(Cloudflare 挑战页是 403 / 503,少数 429) */
|
|
export const CHALLENGE_STATUSES: readonly number[] = [403, 429, 503];
|
|
|
|
/**
|
|
* 判定一个站点响应是不是挑战。正文只在状态码可能是挑战时读(读过的正文重新包成 Response 返回,调用方照常消费)。
|
|
* 返回 { res, rule }:rule 为 null 表示不是挑战。
|
|
*/
|
|
export async function detectChallenge(res: Response): Promise<{ res: Response; rule: string | null }> {
|
|
const byHeader = challengeRuleOfHeaders(res.headers);
|
|
if (byHeader) return { res, rule: byHeader };
|
|
if (!CHALLENGE_STATUSES.includes(res.status) || !res.body) return { res, rule: null };
|
|
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
const html = new TextDecoder("utf-8").decode(bytes.subarray(0, 300_000));
|
|
const copy = new Response(bytes, { status: res.status, statusText: res.statusText, headers: res.headers });
|
|
return { res: copy, rule: challengeRuleOfHtml(html) };
|
|
}
|
|
|
|
/**
|
|
* 浏览器里打开的页面属于哪种挑战:
|
|
* - none:不是挑战页(已通过);
|
|
* - waiting:自动检查(JS / 非交互),等几秒会自己过;
|
|
* - interactive:「我是人类」复选框 / Turnstile,可以点一下;
|
|
* - captcha:选图、输验证码这类真验证码,不自动处理。
|
|
*/
|
|
export type ChallengePageKind = "none" | "waiting" | "interactive" | "captcha";
|
|
|
|
const CAPTCHA_MARKERS =
|
|
/hcaptcha\.com|class="h-captcha"|g-recaptcha|recaptcha\/api|imagecode\.php|select all (?:images|squares)|请(?:依次)?点击|选择所有|拖动滑块|输入验证码|enter the (?:characters|code)/i;
|
|
const INTERACTIVE_MARKERS =
|
|
/challenges\.cloudflare\.com\/(?:turnstile|cdn-cgi)|cf-turnstile|cType\s*:\s*['"](?:managed|interactive)['"]|verify you are human|确认您是真人|验证您是真人|我是人类/i;
|
|
|
|
export function classifyChallengePage(page: { title?: string | null; html?: string | null; text?: string | null }): {
|
|
kind: ChallengePageKind;
|
|
rule: string | null;
|
|
} {
|
|
const html = page.html ?? "";
|
|
const text = page.text ?? "";
|
|
const title = page.title ?? titleOf(html);
|
|
const rule =
|
|
(CHALLENGE_TITLE.test(title) ? `title=${title.slice(0, 40)}` : null) ??
|
|
challengeRuleOfHtml(html) ??
|
|
(/verify you are human|确认您是真人|验证您是真人|checking (?:if the site connection is secure|your browser)/i.test(
|
|
text,
|
|
)
|
|
? "text"
|
|
: null);
|
|
if (!rule) return { kind: "none", rule: null };
|
|
const all = `${html.slice(0, 300_000)}\n${text.slice(0, 20_000)}`;
|
|
const cap = CAPTCHA_MARKERS.exec(all);
|
|
if (cap) return { kind: "captcha", rule: `${rule}; captcha*=${cap[0]}` };
|
|
const inter = INTERACTIVE_MARKERS.exec(all);
|
|
if (inter) return { kind: "interactive", rule: `${rule}; interactive*=${inter[0]}` };
|
|
return { kind: "waiting", rule };
|
|
}
|