// 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(/|/gi, " ")); return t ? `text*=${t[0]}` : null; } function concatBytes(chunks: Uint8Array[], total: number): Uint8Array { const out = new Uint8Array(total); let off = 0; for (const c of chunks) { out.set(c, off); off += c.byteLength; } return out; } /** 先读最多 max 字节;返回读到的部分、是否读完,以及一个内容不变的新响应(没读完的部分接着原流) */ export async function peekBody( res: Response, max: number, ): Promise<{ head: Uint8Array; complete: boolean; res: Response }> { const reader = (res.body as unknown as ReadableStream).getReader(); const chunks: Uint8Array[] = []; let total = 0; let complete = false; let remainder: Uint8Array | undefined; while (total < max) { const r = await reader.read(); if (r.done) { complete = true; break; } const length = Math.min(r.value.byteLength, max - total); chunks.push(r.value.subarray(0, length)); total += length; if (length < r.value.byteLength) remainder = r.value.subarray(length); } const head = concatBytes(chunks, total); const init = { status: res.status, statusText: res.statusText, headers: res.headers }; if (complete) return { head, complete, res: new Response(head, init) }; const stream = new ReadableStream({ start(c) { c.enqueue(head); if (remainder) c.enqueue(remainder); }, async pull(c) { const r = await reader.read(); if (r.done) c.close(); else c.enqueue(r.value); }, cancel(reason) { return reader.cancel(reason); }, }); return { head, complete, res: new Response(stream, init) }; } /** * 判定一个站点响应是不是挑战。 * - 响应头 `cf-mitigated: challenge` → 挑战(不读正文); * - 403 / 429 / 503 → 读正文看挑战页特征; * - opts.page(页面类请求)且 2xx 的 HTML → 读正文看挑战页 / 人机验证门页(challengeRuleOfOkPage); * - 其它情况不读正文,零开销。 * 正文最多先读 CHALLENGE_PEEK_BYTES,读过的部分重新包进返回的响应,调用方照常消费。 * 返回 { res, rule, html }:rule 为 null 表示不是挑战;html 是读到的正文开头(没读为 null,留样用)。 */ export async function detectChallenge( res: Response, opts: { page?: boolean } = {}, ): Promise<{ res: Response; rule: string | null; html: string | null }> { const byHeader = challengeRuleOfHeaders(res.headers); if (byHeader) return { res, rule: byHeader, html: null }; const suspect = CHALLENGE_STATUSES.includes(res.status); const okPage = !!opts.page && res.status >= 200 && res.status < 300 && res.status !== 204 && res.status !== 205 && /html|^$/i.test(res.headers.get("content-type")?.trim() ?? ""); if ((!suspect && !okPage) || !res.body) return { res, rule: null, html: null }; const peek = await peekBody(res, CHALLENGE_PEEK_BYTES); const html = new TextDecoder("utf-8").decode(peek.head); const rule = suspect ? challengeRuleOfHtml(html) : challengeRuleOfOkPage(html, peek.complete); return { res: peek.res, rule, 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) ?? challengeGateRule(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 }; }