Merge branch 'cf2'
Co-Authored-By: Claude Fable 5.1 <[email protected]> # Conflicts: # docs/plan/00-first-version-plan.md
This commit is contained in:
commit
1ce937345e
36 files changed
+2609
-64
No files matched your search
@@ -27,6 +27,8 @@ function realUpstream(handler: (c: Call) => Reply | undefined) {
|
||||
calls.push(call);
|
||||
const r =
|
||||
handler(call) ??
|
||||
// 真实上游(不带 umt 插件)没有 /umt/* 路由
|
||||
(call.path.startsWith("/umt/") ? { status: 404, json: { error: "Not found" } } : undefined) ??
|
||||
(call.path === "/tabs" && call.method === "POST"
|
||||
? { json: { tabId: "t-1", url: `${ORIGIN}/login.php`, httpStatus: 200, navigationOk: true } }
|
||||
: { json: { ok: true } });
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
type NavigationResult,
|
||||
type OpenSessionRequest,
|
||||
type OpenSessionResult,
|
||||
type PageContent,
|
||||
type PageMetrics,
|
||||
profileKeyFor,
|
||||
type ReopenSessionRequest,
|
||||
@@ -143,6 +144,14 @@ const PAGE_METRICS_EXPR = `/* umt-metrics */ (() => ({ w: innerWidth, h: innerHe
|
||||
pw: !!document.querySelector("input[type=password]"),
|
||||
text: String((document.body && document.body.innerText) || "").slice(0, 4000) }))()`;
|
||||
|
||||
/** 页面内容表达式(mock 按 umt-page 标记识别) */
|
||||
function pageContentExpr(maxChars: number): string {
|
||||
return `/* umt-page */ (() => ({ url: location.href, title: document.title,
|
||||
html: document.documentElement ? document.documentElement.outerHTML.slice(0, ${maxChars}) : "",
|
||||
text: String((document.body && document.body.innerText) || "").slice(0, 20000),
|
||||
ua: navigator.userAgent }))()`;
|
||||
}
|
||||
|
||||
const LOGIN_PATH = /(?:^|\/)(?:login|takelogin|signin|sign_in|auth)(?:\.php)?(?:\/|$|\?)/i;
|
||||
|
||||
export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
@@ -159,6 +168,8 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
private cookieApi: "plugin" | "upstream" | "none" | null = null;
|
||||
/** 实例标识接口:plugin = umt 插件 GET /umt/instance;health = 只能从 /health 里找;null = 未探测 */
|
||||
private instanceApi: "plugin" | "health" | null = null;
|
||||
/** 真实鼠标坐标点击(CF-2):plugin = umt 插件 `POST /umt/tabs/{id}/mouse-click`;none = 插件没有,退回 elementFromPoint;null = 未探测 */
|
||||
private mouseApi: "plugin" | "none" | null = null;
|
||||
|
||||
constructor(private readonly cfg: CamofoxConfig) {
|
||||
this.base = cfg.baseUrl.replace(/\/+$/, "");
|
||||
@@ -419,7 +430,10 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
* 该 profile 的出口(BR-4 出口 IP 按站点统一):umt 插件 `PUT /umt/sessions/{userId}/proxy`。
|
||||
* proxy = null 直连;活动上下文出口不同时插件会先关掉它(落盘登录状态)。插件不在报 not_implemented。
|
||||
*/
|
||||
async setEgress(profileKey: string, proxy: BrowserEgressProxy | null): Promise<{ restarted: boolean }> {
|
||||
async setEgress(
|
||||
profileKey: string,
|
||||
proxy: BrowserEgressProxy | null,
|
||||
): Promise<{ restarted: boolean; mode?: string }> {
|
||||
try {
|
||||
const body = await this.json(
|
||||
await this.request("PUT", `/umt/sessions/${encodeURIComponent(profileKey)}/proxy`, {
|
||||
@@ -428,7 +442,8 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
}),
|
||||
"/umt/sessions/{userId}/proxy",
|
||||
);
|
||||
return { restarted: body.restarted === true };
|
||||
const mode = str(body.mode);
|
||||
return { restarted: body.restarted === true, ...(mode ? { mode } : {}) };
|
||||
} catch (e) {
|
||||
if (e instanceof BrowserError && e.httpStatus === 404)
|
||||
throw new BrowserError("not_implemented", "浏览器服务不支持按站点设出口(镜像未带新版 umt-cookies 插件)");
|
||||
@@ -1047,6 +1062,22 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
await this.withProfile(s, async () => {
|
||||
this.assertActor(s, ref.as);
|
||||
const tab = this.tabOf(s, ref);
|
||||
// 坐标点击先走 umt 插件的真实鼠标事件(page.mouse.click):跨域 iframe 里的控件(Cloudflare Turnstile)与画布也点得到(CF-2)
|
||||
if (
|
||||
action === "click" &&
|
||||
typeof payload.x === "number" &&
|
||||
typeof payload.y === "number" &&
|
||||
this.mouseApi !== "none"
|
||||
) {
|
||||
const done = await this.mouseClick(s, tab, payload.x, payload.y);
|
||||
if (done) {
|
||||
this.touch(s, done.url ?? s.rawUrl);
|
||||
if (done.url) tab.url = s.session.currentUrl;
|
||||
s.currentTabId = tab.upstreamTabId;
|
||||
after?.(s);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 上游 click/type 只认 ref/selector(1.17.0 实测不接受 {x, y}):坐标先经 evaluate 标记该点元素,再按选择器操作,
|
||||
// 这样仍走上游完整的鼠标事件序列与下载守卫,而不是页面内合成 click
|
||||
if (typeof payload.x === "number" && typeof payload.y === "number") {
|
||||
@@ -1068,6 +1099,73 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件的真实鼠标点击:`POST /umt/tabs/{id}/mouse-click {userId, x, y}`(视口 CSS 像素)。插件没有这个端点(404)时记下,
|
||||
* 返回 null 让调用方退回 elementFromPoint。
|
||||
*/
|
||||
private async mouseClick(
|
||||
s: SessionState,
|
||||
tab: BrowserTab,
|
||||
x: number,
|
||||
y: number,
|
||||
): Promise<{ url: string | undefined } | null> {
|
||||
const path = `/umt/tabs/${encodeURIComponent(tab.upstreamTabId)}/mouse-click`;
|
||||
try {
|
||||
const res = await this.request("POST", path, {
|
||||
body: { userId: s.session.profileKey, x, y },
|
||||
timeoutMs: s.budget.timeoutMs,
|
||||
});
|
||||
const out = (await res.json().catch(() => ({}))) as Json;
|
||||
this.mouseApi = "plugin";
|
||||
return { url: str(out.url) };
|
||||
} catch (e) {
|
||||
// 404 = 插件没有这个端点(旧镜像);插件对"标签页不在"回 422,按标签页已不存在处理(BR-3 自愈)
|
||||
if (e instanceof BrowserError && e.httpStatus === 404) {
|
||||
this.mouseApi = "none";
|
||||
return null;
|
||||
}
|
||||
if (e instanceof BrowserError && e.httpStatus === 422)
|
||||
throw new BrowserError("not_found", "标签页不存在或已关闭", { httpStatus: 404 });
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前页面内容(CF-2):地址(已脱敏)、标题、outerHTML、正文样本、navigator.userAgent。只读,不要求自动化控制权;
|
||||
* 上游不支持 evaluate 时返回 null。HTML 只给服务端解析器用,不进模型与日志。
|
||||
*/
|
||||
async pageContent(ref: TabRef, opts: { maxHtmlBytes?: number } = {}): Promise<PageContent | null> {
|
||||
const s = this.state(ref.sessionId);
|
||||
const max = Math.max(1024, Math.min(opts.maxHtmlBytes ?? 8 * 1024 * 1024, 16 * 1024 * 1024));
|
||||
return this.withProfile(s, async () => {
|
||||
const tab = this.tabOf(s, ref);
|
||||
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/evaluate`;
|
||||
let out: Json;
|
||||
try {
|
||||
const res = await this.request("POST", path, {
|
||||
body: { userId: s.session.profileKey, expression: pageContentExpr(max) },
|
||||
timeoutMs: s.budget.timeoutMs,
|
||||
});
|
||||
out = await this.json(res, path);
|
||||
} catch (e) {
|
||||
if (e instanceof BrowserError && e.kind === "not_found") throw e;
|
||||
return null;
|
||||
}
|
||||
const r = (out.result && typeof out.result === "object" ? out.result : null) as Json | null;
|
||||
if (!r || typeof r.html !== "string") return null;
|
||||
const rawUrl = str(r.url) ?? s.rawUrl;
|
||||
this.touch(s, rawUrl ?? s.rawUrl);
|
||||
tab.url = s.session.currentUrl;
|
||||
return {
|
||||
url: rawUrl ? redactBrowserUrl(rawUrl) : null,
|
||||
title: str(r.title) ?? "",
|
||||
html: r.html,
|
||||
text: str(r.text) ?? "",
|
||||
userAgent: str(r.ua) ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 视口坐标 → 唯一属性选择器(POST /tabs/{id}/evaluate + elementFromPoint);坐标处无元素报 bad_request */
|
||||
private async pointSelector(s: SessionState, tab: BrowserTab, x: number, y: number): Promise<string> {
|
||||
const nonce = this.newId()
|
||||
|
||||
@@ -68,6 +68,11 @@ export interface BrowserProvider {
|
||||
* 只读,不要求自动化控制权;上游不支持 evaluate 时返回 null。
|
||||
*/
|
||||
pageMetrics(ref: TabRef): Promise<PageMetrics | null>;
|
||||
/**
|
||||
* 当前页面内容(CF-2 挑战识别与浏览器代抓):地址(已脱敏)、标题、outerHTML、正文样本、navigator.userAgent。
|
||||
* 只读,不要求自动化控制权;上游不支持时返回 null。HTML 只给服务端解析器,不进模型与日志。
|
||||
*/
|
||||
pageContent?(ref: TabRef, opts?: { maxHtmlBytes?: number }): Promise<PageContent | null>;
|
||||
|
||||
// ── BR-2 凭据统一 ──
|
||||
/**
|
||||
@@ -108,7 +113,7 @@ export interface BrowserProvider {
|
||||
* 该 profile 的出口:proxy = 站点开代理时与 HTTP 同一代理,null = 直连。上游没有接口时报 BrowserError("not_implemented")。
|
||||
* restarted = 活动上下文出口不同、已被关闭(下次操作按新出口重建)。
|
||||
*/
|
||||
setEgress?(profileKey: string, proxy: BrowserEgressProxy | null): Promise<{ restarted: boolean }>;
|
||||
setEgress?(profileKey: string, proxy: BrowserEgressProxy | null): Promise<{ restarted: boolean; mode?: string }>;
|
||||
}
|
||||
|
||||
export interface ReopenSessionRequest {
|
||||
@@ -124,6 +129,15 @@ export interface ReopenSessionResult {
|
||||
cookies?: { status: "injected" | "unsupported" | "failed"; count: number };
|
||||
}
|
||||
|
||||
export interface PageContent {
|
||||
url: string | null;
|
||||
title: string;
|
||||
html: string;
|
||||
/** document.body.innerText 前 2 万字 */
|
||||
text: string;
|
||||
userAgent: string | null;
|
||||
}
|
||||
|
||||
export interface PageMetrics {
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
|
||||
@@ -163,12 +163,67 @@ describe("camofox 适配器:身份与出口", () => {
|
||||
mock = await createMockCamofox({ accessKey: "k" });
|
||||
const p = provider(mock);
|
||||
expect(await p.identity!()).toMatchObject({ userAgent: MOCK_CAMOFOX_IDENTITY.userAgent, os: "windows" });
|
||||
expect(await p.setEgress!("site-a", { server: "http://127.0.0.1:7891" })).toEqual({ restarted: false });
|
||||
expect(await p.setEgress!("site-b", null)).toEqual({ restarted: false });
|
||||
expect(await p.setEgress!("site-a", { server: "http://127.0.0.1:7891" })).toEqual({
|
||||
restarted: false,
|
||||
mode: "site",
|
||||
});
|
||||
expect(await p.setEgress!("site-b", null)).toEqual({ restarted: false, mode: "site" });
|
||||
expect(mock.egress.get("site-a")).toEqual({ server: "http://127.0.0.1:7891" });
|
||||
expect(mock.egress.get("site-b")).toBeNull();
|
||||
});
|
||||
|
||||
const budget = { domainAllowlist: ["audiences.example"], timeoutMs: 5_000, maxRetries: 0, maxBytes: 1_000_000 };
|
||||
const openCf = async (m: MockCamofox) => {
|
||||
const p = provider(m);
|
||||
const { session } = await p.openSession({
|
||||
providerId: "11111111-1111-4111-8111-111111111111",
|
||||
siteId: "s",
|
||||
accountId: "a",
|
||||
profileKey: "umt-site-s-acct-a",
|
||||
initialUrl: "https://audiences.example/torrents.php",
|
||||
budget,
|
||||
});
|
||||
return { p, ref: { sessionId: session.id } };
|
||||
};
|
||||
const cfSite = (mode: "auto" | "interactive" | "captcha") => ({
|
||||
origin: "https://audiences.example",
|
||||
username: "u",
|
||||
password: "p",
|
||||
protectedPaths: /^$/,
|
||||
cloudflare: { mode, autoPolls: 2, clearance: "clr-1" },
|
||||
});
|
||||
|
||||
it("CF-2 pageContent():地址 / 标题 / HTML / UA;挑战页分类由调用方做", async () => {
|
||||
mock = await createMockCamofox({ accessKey: "k", site: cfSite("auto") });
|
||||
const { p, ref } = await openCf(mock);
|
||||
const c1 = await p.pageContent!(ref);
|
||||
expect(c1).toMatchObject({ title: "Just a moment...", userAgent: MOCK_CAMOFOX_IDENTITY.userAgent });
|
||||
expect(c1?.html).toContain("_cf_chl_opt");
|
||||
// 第 2 次读页面时 mock 自动放行,并写 cf_clearance
|
||||
const c2 = await p.pageContent!(ref);
|
||||
expect(c2?.title).not.toBe("Just a moment...");
|
||||
const cookies = await p.getCookies!("umt-site-s-acct-a", ["https://audiences.example/"]);
|
||||
expect(cookies.find((c) => c.name === "cf_clearance")?.value).toBe("clr-1");
|
||||
});
|
||||
|
||||
it("CF-2 坐标点击走插件真实鼠标事件(跨域 iframe 的 Turnstile 也点得到),不再 elementFromPoint", async () => {
|
||||
mock = await createMockCamofox({ accessKey: "k", site: cfSite("interactive") });
|
||||
const { p, ref } = await openCf(mock);
|
||||
// 第 2 行(y = 20 + 40 + 10)是复选框
|
||||
await p.click(ref, { x: 100, y: 70 });
|
||||
expect(mock.actions.some((a) => a.action === "mouse-click" && a.body.x === 100 && a.body.y === 70)).toBe(true);
|
||||
expect(mock.actions.some((a) => a.action === "evaluate")).toBe(false);
|
||||
expect(mock.cfPassed.has("umt-site-s-acct-a")).toBe(true);
|
||||
});
|
||||
|
||||
it("CF-2 插件没有 mouse-click(旧镜像)→ 退回 elementFromPoint + 选择器点击", async () => {
|
||||
mock = await createMockCamofox({ accessKey: "k", cookieApi: "none", site: cfSite("interactive") });
|
||||
const { p, ref } = await openCf(mock);
|
||||
await p.click(ref, { x: 100, y: 70 });
|
||||
expect(mock.actions.some((a) => a.action === "evaluate")).toBe(true);
|
||||
expect(mock.actions.some((a) => a.action === "click")).toBe(true);
|
||||
});
|
||||
|
||||
it("旧插件(没有接口):identity() 为 null,setEgress() 报 not_implemented", async () => {
|
||||
mock = await createMockCamofox({ accessKey: "k", cookieApi: "none" });
|
||||
const p = provider(mock);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { challengeRuleOfHtml, classifyChallengePage, detectChallenge } from "./challenge.ts";
|
||||
import { siteFetch } from "./http.ts";
|
||||
import { createSiteRateLimiter } from "./rate-limit.ts";
|
||||
import { SiteError } from "./types.ts";
|
||||
|
||||
// CF-2:Cloudflare 挑战识别。HTTP 端挑战不是凭据错误(403 不再算 auth_invalid、503 不再算维护);
|
||||
// 浏览器端按页面分:自动 / 可点(Turnstile)/ 真验证码。
|
||||
|
||||
const CF_PAGE = `<!DOCTYPE html><html><head><title>Just a moment...</title></head><body>
|
||||
<div id="challenge-spinner"></div><script>window._cf_chl_opt={cType: 'managed'}</script></body></html>`;
|
||||
const CF_JS_PAGE = `<html><head><title>Just a moment...</title></head><body>
|
||||
<script>window._cf_chl_opt={cType: 'non-interactive'}</script></body></html>`;
|
||||
const NORMAL = `<html><head><title>种子 - 观众</title><script src="/cdn-cgi/challenge-platform/scripts/jsd/main.js"></script></head><body>ok</body></html>`;
|
||||
|
||||
function ctx(fetchImpl: typeof fetch) {
|
||||
return {
|
||||
siteId: "s1",
|
||||
fetch: fetchImpl,
|
||||
limiter: createSiteRateLimiter({ minIntervalMs: 0, burst: 10 }),
|
||||
timeoutMs: 5_000,
|
||||
now: () => new Date(),
|
||||
};
|
||||
}
|
||||
const reply = (status: number, body: string, headers: Record<string, string> = {}) =>
|
||||
(async () => new Response(body, { status, headers: { "content-type": "text/html", ...headers } })) as typeof fetch;
|
||||
|
||||
describe("challengeRuleOfHtml", () => {
|
||||
it("挑战页标题 / 脚本特征命中;正常页面里注入的 challenge-platform 脚本不算", () => {
|
||||
expect(challengeRuleOfHtml(CF_PAGE)).toBe("title=Just a moment...");
|
||||
expect(challengeRuleOfHtml('<html><body><form id="challenge-form"></form></body></html>')).toContain(
|
||||
"challenge-form",
|
||||
);
|
||||
expect(challengeRuleOfHtml(NORMAL)).toBeNull();
|
||||
expect(challengeRuleOfHtml("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectChallenge", () => {
|
||||
it("cf-mitigated: challenge 不读正文;403 / 503 读正文判定并重新包装响应", async () => {
|
||||
const a = await detectChallenge(new Response("x", { status: 200, headers: { "cf-mitigated": "challenge" } }));
|
||||
expect(a.rule).toBe("header cf-mitigated=challenge");
|
||||
const b = await detectChallenge(new Response(CF_PAGE, { status: 403 }));
|
||||
expect(b.rule).toContain("Just a moment");
|
||||
expect(await b.res.text()).toBe(CF_PAGE);
|
||||
const c = await detectChallenge(new Response("Forbidden", { status: 403 }));
|
||||
expect(c.rule).toBeNull();
|
||||
expect(await c.res.text()).toBe("Forbidden");
|
||||
const d = await detectChallenge(new Response(NORMAL, { status: 200 }));
|
||||
expect(d.rule).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("siteFetch 挑战分类", () => {
|
||||
const url = "https://audiences.example/torrents.php";
|
||||
const kind = async (f: typeof fetch) => {
|
||||
try {
|
||||
await siteFetch(ctx(f), url);
|
||||
return "ok";
|
||||
} catch (e) {
|
||||
return e instanceof SiteError ? e.kind : "other";
|
||||
}
|
||||
};
|
||||
|
||||
it("403 + cf-mitigated → challenge(不是 auth_invalid),不算凭据错误", async () => {
|
||||
let err: unknown;
|
||||
try {
|
||||
await siteFetch(ctx(reply(403, CF_PAGE, { "cf-mitigated": "challenge" })), url);
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
expect(err).toBeInstanceOf(SiteError);
|
||||
const se = err as SiteError;
|
||||
expect(se.kind).toBe("challenge");
|
||||
expect(se.isAuthError).toBe(false);
|
||||
expect(se.detail).toMatchObject({ reason: "challenge", rule: "header cf-mitigated=challenge", httpStatus: 403 });
|
||||
});
|
||||
|
||||
it("503 挑战页 → challenge(不是维护);403 挑战页(没有响应头)也认", async () => {
|
||||
expect(await kind(reply(503, CF_PAGE))).toBe("challenge");
|
||||
expect(await kind(reply(403, CF_PAGE))).toBe("challenge");
|
||||
expect(await kind(reply(429, CF_JS_PAGE))).toBe("challenge");
|
||||
});
|
||||
|
||||
it("普通 503 仍是维护,普通 403 交给适配器(响应体可读)", async () => {
|
||||
expect(await kind(reply(503, "maintenance"))).toBe("site_maintenance");
|
||||
const res = await siteFetch(ctx(reply(403, "Forbidden")), url);
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.text()).toBe("Forbidden");
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyChallengePage", () => {
|
||||
it("不是挑战页 → none", () => {
|
||||
expect(classifyChallengePage({ title: "种子", html: NORMAL, text: "种子列表" }).kind).toBe("none");
|
||||
});
|
||||
it("JS 检查 → waiting;托管 / Turnstile → interactive", () => {
|
||||
expect(classifyChallengePage({ html: CF_JS_PAGE, text: "Checking your browser" }).kind).toBe("waiting");
|
||||
expect(classifyChallengePage({ html: CF_PAGE, text: "" }).kind).toBe("interactive");
|
||||
expect(
|
||||
classifyChallengePage({
|
||||
title: "请稍候…",
|
||||
html: "<html><body></body></html>",
|
||||
text: "请完成以下操作,验证您是真人。",
|
||||
}).kind,
|
||||
).toBe("interactive");
|
||||
});
|
||||
it("选图 / 输码 → captcha(不自动处理)", () => {
|
||||
expect(classifyChallengePage({ html: `${CF_PAGE}<div class="h-captcha"></div>`, text: "Just a moment" }).kind).toBe(
|
||||
"captcha",
|
||||
);
|
||||
expect(classifyChallengePage({ title: "Just a moment...", html: "", text: "请点击图中所有的红绿灯" }).kind).toBe(
|
||||
"captcha",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
// 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 };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { redactText } from "../rss/redact.ts";
|
||||
import { detectChallenge } from "./challenge.ts";
|
||||
import type { SiteRateLimiter } from "./rate-limit.ts";
|
||||
import { SiteError } from "./types.ts";
|
||||
|
||||
@@ -20,7 +21,8 @@ function retryAfterMs(res: Response, now: Date): number | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点请求:先取站点令牌,再发请求;429/503 统一映射并顺延限流器。
|
||||
* 站点请求:先取站点令牌,再发请求;Cloudflare 挑战(`cf-mitigated: challenge`,或 403 / 503 / 429 的挑战页)→ `challenge`
|
||||
* (不是凭据错误,CF-2);其余 429/503 统一映射并顺延限流器。
|
||||
* 返回的 Response 由调用方消费。错误消息只含脱敏后的 URL。
|
||||
*/
|
||||
export async function siteFetch(
|
||||
@@ -42,6 +44,15 @@ export async function siteFetch(
|
||||
const reason = timeout.aborted ? "超时" : (e as Error).message;
|
||||
throw new SiteError(ctx.siteId, "network", redactText(`请求 ${safeUrl} 失败:${reason}`));
|
||||
}
|
||||
const ch = await detectChallenge(res);
|
||||
res = ch.res;
|
||||
if (ch.rule) {
|
||||
await res.body?.cancel().catch(() => {});
|
||||
throw new SiteError(ctx.siteId, "challenge", `请求 ${safeUrl}:站点要求人机验证(Cloudflare)`, {
|
||||
httpStatus: res.status,
|
||||
detail: { reason: "challenge", rule: ch.rule, httpStatus: res.status },
|
||||
});
|
||||
}
|
||||
if (res.status === 429 || res.status === 503) {
|
||||
const ra = retryAfterMs(res, ctx.now());
|
||||
await res.body?.cancel().catch(() => {});
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
export {
|
||||
CHALLENGE_STATUSES,
|
||||
type ChallengePageKind,
|
||||
challengeRuleOfHeaders,
|
||||
challengeRuleOfHtml,
|
||||
classifyChallengePage,
|
||||
detectChallenge,
|
||||
} from "./challenge.ts";
|
||||
export {
|
||||
audiencesDefinition,
|
||||
BUILTIN_SITE_DEFINITIONS,
|
||||
|
||||
@@ -124,6 +124,41 @@ export const SiteLastHealth = z.object({
|
||||
});
|
||||
export type SiteLastHealth = z.infer<typeof SiteLastHealth>;
|
||||
|
||||
/**
|
||||
* 站点的 Cloudflare 处理状态(CF-2,存 site.config.cloudflare;docs/runbook/sites.md「Cloudflare」):
|
||||
* - browserUntil:在此之前该站能走浏览器的页面请求直接由浏览器代抓(HTTP 仍被挑战时置 6 小时,到期再试 HTTP);
|
||||
* - cfReuse:浏览器拿到的 cf_clearance 给 HTTP 用的结果(ok 有效 / fails 无效,之后不再复用);
|
||||
* - lastSolvedBy:最近一次怎么过的挑战(auto 等它自己过 / vision 视觉子流程点了一下 / human 人在原站视图里过);
|
||||
* - pending = human:遇到选图 / 输码类验证码,等人在原站视图里过一次;
|
||||
* - lastAttemptAt:最近一次尝试过挑战(同站 30 分钟最多一次)。
|
||||
*/
|
||||
export const SiteCloudflareState = z.object({
|
||||
browserUntil: Timestamp.nullable().default(null),
|
||||
cfReuse: z.enum(["ok", "fails"]).nullable().default(null),
|
||||
lastChallengeAt: Timestamp.nullable().default(null),
|
||||
lastSolvedBy: z.enum(["auto", "vision", "human"]).nullable().default(null),
|
||||
lastSolvedAt: Timestamp.nullable().default(null),
|
||||
lastAttemptAt: Timestamp.nullable().default(null),
|
||||
pending: z.enum(["human"]).nullable().default(null),
|
||||
/** 等人处理的浏览器会话(原站视图接管入口) */
|
||||
sessionId: Id.nullable().default(null),
|
||||
/** 最近一次处理结果(技术信息):passed / reuse_ok / reuse_fails / browser / needs_human / failed / throttled / skipped */
|
||||
lastResult: z.string().nullable().default(null),
|
||||
/** 命中的挑战规则(技术信息) */
|
||||
lastRule: z.string().nullable().default(null),
|
||||
/** 最近一次失败原因(技术信息) */
|
||||
lastError: z.string().nullable().default(null),
|
||||
});
|
||||
export type SiteCloudflareState = z.infer<typeof SiteCloudflareState>;
|
||||
|
||||
/** 从 site.config 读 Cloudflare 状态;没有或格式不对返回 null */
|
||||
export function siteCloudflareState(config: unknown): SiteCloudflareState | null {
|
||||
const raw = (config as { cloudflare?: unknown } | null | undefined)?.cloudflare;
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const r = SiteCloudflareState.safeParse(raw);
|
||||
return r.success ? r.data : null;
|
||||
}
|
||||
|
||||
export const Site = EntityBase.extend({
|
||||
key: z.string().regex(/^[a-z][a-z0-9_-]*$/),
|
||||
name: z.string().min(1),
|
||||
|
||||
@@ -39,6 +39,15 @@ export interface MockCamofoxSite {
|
||||
* 表单登录成功后把 name=fresh 写进该 profile 的 Cookie(读回时可见)。
|
||||
*/
|
||||
sessionCookie?: { name: string; valid: string[]; fresh: string };
|
||||
/**
|
||||
* Cloudflare 挑战(CF-2):没通过的 profile 打开站点任何页面都是挑战页(标题 Just a moment...)。
|
||||
* auto = 读页面内容(umt-page)autoPolls 次后自己通过;interactive = 第 1 行是「确认您是真人」复选框,
|
||||
* 用插件真实鼠标点击(`POST /umt/tabs/{id}/mouse-click`)点中才通过;captcha = 选图验证码,自动化过不去。
|
||||
* 通过后该 profile 得到 cf_clearance(值 clearance)。
|
||||
*/
|
||||
cloudflare?: { mode: "auto" | "interactive" | "captcha"; autoPolls?: number; clearance?: string };
|
||||
/** 页面 HTML(umt-page 读页面内容时返回);缺省按快照行拼一段简单 HTML */
|
||||
html?: (url: string) => string;
|
||||
}
|
||||
|
||||
/** mock 的 Cookie 接口形态:plugin = umt-cookies 插件(读写);upstream = 只有上游导入端点(要求 CAMOFOX_API_KEY);none = 都没有 */
|
||||
@@ -128,6 +137,10 @@ export type MockCamofox = MockHttp & {
|
||||
readonly instanceId: string;
|
||||
/** 各 profile 的出口(`PUT /umt/sessions/:userId/proxy`,BR-4):null = 直连 */
|
||||
egress: Map<string, { server: string; username?: string; password?: string } | null>;
|
||||
/** 已通过 Cloudflare 挑战的 profile(CF-2) */
|
||||
cfPassed: Set<string>;
|
||||
/** 各 profile 读页面内容(umt-page)的次数 */
|
||||
contentReads: Map<string, number>;
|
||||
};
|
||||
|
||||
/** mock 的固定浏览器身份(`GET /umt/identity`,BR-4) */
|
||||
@@ -353,6 +366,23 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
||||
/** 重启前存在过的 tabId:之后访问返回 410 */
|
||||
const restartedTabs = new Set<string>();
|
||||
const site = opts.site;
|
||||
const cfPassed = new Set<string>();
|
||||
const contentReads = new Map<string, number>();
|
||||
const passCf = (userId: string) => {
|
||||
if (!site?.cloudflare || cfPassed.has(userId)) return;
|
||||
cfPassed.add(userId);
|
||||
putCookie(userId, {
|
||||
name: "cf_clearance",
|
||||
value: site.cloudflare.clearance ?? "cf-clearance-mock",
|
||||
domain: `.${new URL(site.origin).hostname}`,
|
||||
path: "/",
|
||||
expires: Math.floor(Date.now() / 1000) + 365 * 86400,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
});
|
||||
};
|
||||
const underCf = (t: MockCamofoxTab) =>
|
||||
!!site?.cloudflare && !cfPassed.has(t.userId) && t.url.startsWith(new URL(site.origin).origin);
|
||||
const putCookie = (userId: string, c: MockCamofoxCookie) => {
|
||||
const jar = (cookieJars.get(userId) ?? []).filter(
|
||||
(x) => !(x.name === c.name && x.domain === c.domain && x.path === c.path),
|
||||
@@ -454,6 +484,14 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
||||
|
||||
const pageLines = (t: MockCamofoxTab): Line[] => {
|
||||
if (!site) return [];
|
||||
if (underCf(t)) {
|
||||
const mode = site.cloudflare!.mode;
|
||||
const lines: Line[] = [{ ref: "e1", role: "heading", text: "Just a moment..." }];
|
||||
if (mode === "interactive")
|
||||
lines.push({ ref: "e2", role: "checkbox", text: "确认您是真人", onClick: () => undefined });
|
||||
if (mode === "captcha") lines.push({ ref: "e2", role: "img", text: "请点击图中所有的红绿灯" });
|
||||
return lines;
|
||||
}
|
||||
const u = new URL(t.url);
|
||||
const passkey = site.passkey ?? CAMOFOX_FIXTURE_PASSKEY;
|
||||
const releases = site.releases ?? [{ id: "42", title: "Oppenheimer 2023 2160p UHD BluRay" }];
|
||||
@@ -749,6 +787,36 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
||||
},
|
||||
},
|
||||
};
|
||||
// 页面内容(CF-2):地址 / 标题 / HTML / 正文 / UA
|
||||
if (expr.includes("umt-page")) {
|
||||
const n = (contentReads.get(t.userId) ?? 0) + 1;
|
||||
contentReads.set(t.userId, n);
|
||||
if (underCf(t) && site?.cloudflare?.mode === "auto" && n >= (site.cloudflare.autoPolls ?? 2))
|
||||
passCf(t.userId);
|
||||
const lines = pageLines(t);
|
||||
const cf = underCf(t);
|
||||
const mode = site?.cloudflare?.mode;
|
||||
const title = cf ? "Just a moment..." : (lines[0]?.text ?? "");
|
||||
const html = cf
|
||||
? `<html><head><title>Just a moment...</title></head><body><div id="challenge-spinner"></div><script>window._cf_chl_opt={cType: '${mode === "auto" ? "non-interactive" : "managed"}'}</script>${
|
||||
mode === "captcha" ? '<div class="h-captcha"></div>' : ""
|
||||
}</body></html>`
|
||||
: site?.html
|
||||
? site.html(t.url)
|
||||
: `<html><head><title>${title}</title></head><body>${lines.map((l) => `<p>${l.text}</p>`).join("")}</body></html>`;
|
||||
return {
|
||||
json: {
|
||||
ok: true,
|
||||
result: {
|
||||
url: t.url,
|
||||
title,
|
||||
html,
|
||||
text: lines.map((l) => l.text).join("\n"),
|
||||
ua: MOCK_CAMOFOX_IDENTITY.userAgent,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
const pt = /elementFromPoint\((\d+), (\d+)\)/.exec(expr);
|
||||
const nonce = /setAttribute\("data-umt-pt", "([^"]+)"\)/.exec(expr)?.[1];
|
||||
if (!pt || !nonce) return { json: { ok: true, result: null } };
|
||||
@@ -811,6 +879,27 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
||||
return { json: { downloads: list } };
|
||||
},
|
||||
],
|
||||
[
|
||||
// 插件的真实鼠标点击(CF-2):坐标按行找元素并点击;挑战页的复选框点中即通过
|
||||
/^\/umt\/tabs\/([^/]+)\/mouse-click$/,
|
||||
(m, q, req, b) => {
|
||||
if (cookieApi !== "plugin" || req.method !== "POST") return err(404, "no route");
|
||||
const t = tabFor(m[1]!, b.userId as string | undefined);
|
||||
if (!t) return err(422, "tab not found");
|
||||
actions.push({ action: "mouse-click", tabId: t.tabId, body: { ...b } });
|
||||
void q;
|
||||
const y = Number(b.y);
|
||||
const line = pageLines(t)[Math.floor((y - MOCK_ROW_Y0) / MOCK_ROW_H)];
|
||||
if (line) {
|
||||
if (underCf(t) && site?.cloudflare?.mode === "interactive" && line.role === "checkbox") passCf(t.userId);
|
||||
else {
|
||||
if (line.role.startsWith("textbox")) t.focus = line.ref;
|
||||
line.onClick?.();
|
||||
}
|
||||
}
|
||||
return { json: { ok: true, tabId: t.tabId, url: t.url } };
|
||||
},
|
||||
],
|
||||
[/^\/umt\/instance$/, () => (cookieApi === "plugin" ? { json: { instanceId } } : err(404, "no route"))],
|
||||
[
|
||||
/^\/umt\/identity$/,
|
||||
@@ -891,6 +980,8 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
||||
loggedIn,
|
||||
cookieJars,
|
||||
egress,
|
||||
cfPassed,
|
||||
contentReads,
|
||||
expireLogin(userId?: string) {
|
||||
if (userId) loggedIn.delete(userId);
|
||||
else loggedIn.clear();
|
||||
@@ -905,6 +996,7 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
||||
if (o.loseProfiles) {
|
||||
loggedIn.clear();
|
||||
cookieJars.clear();
|
||||
cfPassed.clear();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user