// CF-2 Cloudflare 挑战识别(docs/design/cloudflare-handling.md)。
// - HTTP 端:响应头 `cf-mitigated: challenge`(任何状态码),或 403 / 503 / 429 且正文是挑战页 → 挑战,不是凭据错误;
// 页面类请求(GET + Accept text/html)返回 200 也可能是挑战页 / 人机验证门页(Turnstile、hCaptcha、reCAPTCHA),短页且没有站点正常内容才算;
// - 浏览器端:挑战页分三类——自动(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>/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 级联后仍是挑战时,级联在返回的响应上加这个头说明处理结果
* (needs_human / failed / throttled / no_browser / no_account),适配器据此写错误文案;
* unhandled = 网络层没认出、适配器解析时才认出的验证页(级联没跑)。
*/
export const CHALLENGE_OUTCOME_HEADER = "x-umt-challenge";
/** 人机验证错误文案的后半句:自动处理到哪一步、要不要人 */
export function challengeOutcomeText(outcome: string | null | undefined): string {
switch (outcome) {
case "needs_human":
return "自动处理没能通过,需要你在原站视图里过一次验证";
case "failed":
return "已自动尝试用浏览器通过,没有成功";
case "throttled":
return "30 分钟内已自动尝试过,之后会再试";
case "no_browser":
return "没有可用的浏览器,无法自动通过";
case "no_account":
return "站点没有已登录的账号,不自动处理";
case "unhandled":
return "这类页面没有自动处理,可以在原站视图里过一次验证";
default:
return "会自动尝试用浏览器通过";
}
}
/** 这些状态码的响应才去看正文(Cloudflare 挑战页是 403 / 503,少数 429) */
export const CHALLENGE_STATUSES: readonly number[] = [403, 429, 503];
/** 判定时最多先读这么多正文;超出部分不缓冲,照常流给调用方。挑战 / 人机验证门页都远小于这个 */
export const CHALLENGE_PEEK_BYTES = 256 * 1024;
/** 页面类请求(GET + Accept text/html):只有这类请求的 2xx 响应才检查正文 */
export function isPageRequest(method: string | null | undefined, accept: string | null | undefined): boolean {
return (method ?? "GET").toUpperCase() === "GET" && /text\/html|application\/xhtml/i.test(accept ?? "");
}
/** 人机验证组件:Turnstile / hCaptcha / reCAPTCHA(只认脚本地址与组件 class,不认文案) */
const GATE_WIDGET =
/challenges\.cloudflare\.com\/turnstile|class="[^"]*\bcf-turnstile\b|hcaptcha\.com\/1\/api|class="[^"]*\bh-captcha\b|google\.com\/recaptcha|recaptcha\/api\.js|class="[^"]*\bg-recaptcha\b/i;
/** 站点正常内容的痕迹:种子表、已登录的用户信息块(NexusPHP 等)。出现任何一个就不是门页 */
const SITE_CONTENT =
/id="torrenttable"|]*class="[^"]*\btorrents\b|logout\.php|userdetails\.php\?id=|usercp\.php|id="info_block"/i;
/** 登录表单(登录页本身也可能带 Turnstile,交给登录 / 失效判定,不算门页) */
const LOGIN_FORM = /type=["']?password|takelogin\.php/i;
/** 门页都很短;超过这个长度的页面不按门页判 */
const GATE_MAX_CHARS = 100_000;
/** 页面文字里的人机验证提示(只在页面已经认不出来时用) */
const HUMAN_TEXT = /人机验证|verify you are human|确认您是真人|验证您是真人|checking your browser/i;
/**
* 人机验证门页:页面上有 Turnstile / hCaptcha / reCAPTCHA,且没有登录表单、没有站点正常内容、页面很短。
* 命中返回规则说明,否则 null。
*/
export function challengeGateRule(html: string | null | undefined): string | null {
if (!html || html.length > GATE_MAX_CHARS) return null;
const w = GATE_WIDGET.exec(html);
if (!w || LOGIN_FORM.test(html) || SITE_CONTENT.test(html)) return null;
return `gate*=${w[0].slice(0, 40)}`;
}
/**
* 2xx 页面是不是挑战 / 人机验证页。比非 2xx 严:正文必须完整读到(短页)、没有站点正常内容,
* 再看挑战标题 / 脚本特征或人机验证门页。complete = 正文是否已全部读到。
*/
export function challengeRuleOfOkPage(html: string, complete = true): string | null {
if (!complete || !html || html.length > GATE_MAX_CHARS || SITE_CONTENT.test(html)) return null;
return challengeRuleOfHtml(html) ?? challengeGateRule(html);
}
/**
* 页面已经解析不出来(列表 / 详情认不出结构)时的兜底:像挑战 / 人机验证就返回规则说明。
* 比 challengeRuleOfOkPage 宽:不要求短页、也认页面文字里的「人机验证」提示,但带登录表单的页面不算。
*/
export function challengeRuleOfUnrecognizedPage(html: string | null | undefined): string | null {
if (!html) return null;
const head = html.slice(0, 300_000);
const cf = challengeRuleOfHtml(head);
if (cf) return cf;
if (LOGIN_FORM.test(head)) return null;
const w = GATE_WIDGET.exec(head);
if (w) return `gate*=${w[0].slice(0, 40)}`;
const t = HUMAN_TEXT.exec(head.replace(/