import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import type { AddressInfo } from "node:net"; import { deflateSync } from "node:zlib"; import type { MockCall, MockFault, MockHttp, MockReply } from "../tmdb/mock-http.ts"; /** * mock camofox-browser(形状按 jo-inc/camofox-browser v1.17.0,2026-09-28 与真实容器对照过请求/响应字段;取值虚构)。 * 路由:GET /health、GET /openapi.json、POST /tabs、GET /tabs、DELETE /tabs/{id}、POST /tabs/{id}/navigate、 * POST /tabs/{id}/{back,forward,refresh}、GET /tabs/{id}/snapshot、GET /tabs/{id}/screenshot(base64 PNG)、 * POST /tabs/{id}/{click,type,select,scroll,press,extract,evaluate}、GET /tabs/{id}/downloads、DELETE /sessions/{userId}。 * 不给 site:快照为固定文本(含密码值/Cookie/passkey 供脱敏测试)。 * 给 site:模拟一个 NexusPHP 风格站点——未登录访问受保护页重定向登录页,登录页可带验证码(需人工), * 点击/输入改变页面,点"下载"把 .torrent 放进该标签页的下载列表;登录状态按 userId(profile)保存。 * 坐标:页面元素按快照行排布,第 i 行(从 0 起)占 y ∈ [MOCK_ROW_Y0 + i·MOCK_ROW_H, +MOCK_ROW_H);与真实上游一样 * click 不接受 {x, y},适配器先 evaluate(elementFromPoint)打 data-umt-pt 标记再按选择器点击。 * accessKey 设置时模拟 CAMOFOX_ACCESS_KEY:除 /health 外要求 `Authorization: Bearer `,否则 401。 * 故障注入(401/超时/断连)用 MockHttp.fault。 */ /** 交互式 mock 站点 */ export interface MockCamofoxSite { /** 例 https://audiences.example */ origin: string; username: string; password: string; loginPath?: string; /** 未登录访问会被重定向到登录页的路径 */ protectedPaths?: RegExp; /** 登录页带验证码:只有填对 captchaAnswer 才能登录(模拟需本人操作) */ captcha?: boolean; captchaAnswer?: string; /** 登录成功后先到二次验证页(短信验证码,需本人操作;自动化不能完成) */ twoFactor?: boolean; releases?: { id: string; title: string }[]; /** 下载内容;缺省返回固定字节 */ torrent?: (id: string) => Uint8Array; passkey?: string; /** * 登录态 Cookie(BR-2):注入的 Cookie 里 name 的值在 valid 中 → 该 profile 视为已登录;值不在其中 → 视为未登录(过期)。 * 表单登录成功后把 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; /** 页面 innerText(缺省按页面行拼);返回 null 时用缺省 */ pageText?: (url: string) => string | null; } /** mock 的 Cookie 接口形态:plugin = umt-cookies 插件(读写);upstream = 只有上游导入端点(要求 CAMOFOX_API_KEY);none = 都没有 */ export type MockCookieApi = "plugin" | "upstream" | "none"; export const MOCK_VIEWPORT = { width: 1280, height: 800 } as const; export const MOCK_ROW_Y0 = 20; export const MOCK_ROW_H = 40; export interface MockCamofoxOptions { accessKey?: string; site?: MockCamofoxSite; /** 覆盖快照文本;{url} 替换为当前地址 */ snapshotText?: string; /** 让 openapi.json 缺少某些端点(模拟旧版本/fork) */ omitPaths?: string[]; version?: string; port?: number; /** 视口(CSS 像素)与设备像素比:截图尺寸 = 视口 × DPR;元素行坐标按 CSS 像素。缺省 MOCK_VIEWPORT、DPR 1 */ viewport?: { width: number; height: number }; devicePixelRatio?: number; /** 缺省 plugin */ cookieApi?: MockCookieApi; /** upstream 模式下上游导入端点认的口令(模拟 CAMOFOX_API_KEY);缺省不设 → 403 */ cookieImportKey?: string; } export interface MockCamofoxCookie { name: string; value: string; domain: string; path: string; expires: number; httpOnly: boolean; secure: boolean; sameSite?: string; } export interface MockCamofoxTab { tabId: string; userId: string; sessionKey: string; url: string; history: string[]; cursor: number; /** 元素引用 → 已输入的值 */ fields: Map; focus: string | null; /** evaluate(elementFromPoint)打的 data-umt-pt 标记 → 元素引用 */ marks: Map; downloads: MockCamofoxDownload[]; error: string | null; /** Desktop visibility fixture, independent of the adapter selected tab. */ visible?: boolean | null; } export interface MockCamofoxDownload { id: string; filename: string; url: string; mimeType: string; bytes: Uint8Array; } /** 交互动作记录(断言凭证确实下发到了浏览器、坐标换算结果等) */ export interface MockCamofoxAction { action: string; tabId: string; body: Record; } export type MockCamofox = MockHttp & { tabs: Map; actions: MockCamofoxAction[]; /** 已登录的 userId(profile) */ loggedIn: Set; /** 各 profile 的 Cookie(注入的 + 表单登录写入的) */ cookieJars: Map; /** 模拟站点登录过期(全部 profile 或指定 profile) */ expireLogin(userId?: string): void; /** 模拟上游回收标签页(空闲超时 / 重启) */ dropTab(tabId: string): void; /** * 模拟 camofox 容器重启(BR-3):全部标签页消失,旧 tabId 之后返回 410 browser_restarted(与 1.17.0 实测一致), * 实例标识(`GET /umt/instance`)换新。loseProfiles = true 时连 profile 的 Cookie / 登录态一起丢(SIGKILL 未落盘)。 */ restart(opts?: { loseProfiles?: boolean }): void; /** 当前实例标识 */ readonly instanceId: string; /** 各 profile 的出口(`PUT /umt/sessions/:userId/proxy`,BR-4):null = 直连 */ egress: Map; /** 已通过 Cloudflare 挑战的 profile(CF-2) */ cfPassed: Set; /** 各 profile 读页面内容(umt-page)的次数 */ contentReads: Map; }; /** mock 的固定浏览器身份(`GET /umt/identity`,BR-4) */ export const MOCK_CAMOFOX_IDENTITY = { userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0", os: "windows", platform: "Win32", firefoxVersion: "152", camoufoxVersion: "152.0.4-mock", fingerprintId: "mockfp000001", revision: 1, schema: 1, createdAt: "2026-09-30T00:00:00.000Z", updatedAt: "2026-09-30T00:00:00.000Z", }; /** 1x1 透明 PNG */ export const PNG_1X1_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; export const CAMOFOX_FIXTURE_PASSWORD = "hunter2-fixture-password"; export const CAMOFOX_FIXTURE_COOKIE = "c0ffee-fixture-cookie"; export const CAMOFOX_FIXTURE_PASSKEY = "0123456789abcdef0123456789abcdef"; export const CAMOFOX_FIXTURE_SNAPSHOT = [ "[heading e1] 观众 · 种子列表", `[link e2] 下载 https://audiences.example/download.php?id=42&passkey=${CAMOFOX_FIXTURE_PASSKEY}`, "[textbox e3] 用户名: fixture-user", `[textbox e4 password] 密码: ${CAMOFOX_FIXTURE_PASSWORD}`, `[text e5] Cookie: c_secure_pass=${CAMOFOX_FIXTURE_COOKIE}; c_secure_uid=1001`, "[button e6] 登录", "[row e7] 奥本海默 Oppenheimer 2023 2160p UHD BluRay 做种 128", ].join("\n"); const OPENAPI_PATHS: Record = { "/tabs/{tabId}/scroll": ["post"], "/tabs/{tabId}/press": ["post"], "/tabs/{tabId}/back": ["post"], "/tabs/{tabId}/forward": ["post"], "/tabs/{tabId}/refresh": ["post"], "/health": ["get"], "/tabs": ["post", "get"], "/tabs/{tabId}": ["delete"], "/tabs/{tabId}/navigate": ["post"], "/tabs/{tabId}/snapshot": ["get"], "/tabs/{tabId}/screenshot": ["get"], "/tabs/{tabId}/click": ["post"], "/tabs/{tabId}/type": ["post"], "/tabs/{tabId}/select": ["post"], "/tabs/{tabId}/evaluate": ["post"], "/tabs/{tabId}/extract": ["post"], "/tabs/{tabId}/downloads": ["get"], "/sessions/{userId}": ["delete"], "/sessions/{userId}/cookies": ["post"], }; type Body = Record; type Route = (m: RegExpMatchArray, q: Record, req: IncomingMessage, body: Body) => MockReply; async function readJson(req: IncomingMessage): Promise { const chunks: Buffer[] = []; for await (const c of req) chunks.push(c as Buffer); if (chunks.length === 0) return {}; try { const v = JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown; return v && typeof v === "object" ? (v as Body) : {}; } catch { return {}; } } const matches = (p: string | RegExp, path: string) => (typeof p === "string" ? path.startsWith(p) : p.test(path)); /** 与 tmdb/mock-http 同形的 MockHttp,但路由能拿到 JSON 请求体(camofox 动作都是 POST JSON) */ async function startJsonMock( routes: [RegExp, Route][], opts: { port?: number | undefined; guard?: (req: IncomingMessage) => MockReply | null }, ): Promise { const calls: MockCall[] = []; const faults: { pattern: string | RegExp; f: MockFault }[] = []; const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { const u = new URL(req.url ?? "/", "http://mock"); const q = Object.fromEntries(u.searchParams); const call: MockCall = { method: req.method ?? "GET", path: u.pathname, query: q, headers: req.headers, status: 0 }; calls.push(call); const body = await readJson(req); const reply = (r: MockReply) => { call.status = r.status ?? 200; if (res.destroyed) return; res.writeHead(call.status, { "content-type": "application/json", ...r.headers }); res.end(r.json === undefined ? "" : JSON.stringify(r.json)); }; const fi = faults.findIndex((x) => matches(x.pattern, u.pathname)); const fault = fi >= 0 ? faults[fi]!.f : undefined; if (fault) { fault.times = (fault.times ?? 1) - 1; if (fault.times <= 0) faults.splice(fi, 1); if (fault.delayMs) await new Promise((ok) => setTimeout(ok, fault.delayMs)); if (fault.drop) { call.status = -1; req.socket.destroy(); return; } if (fault.status) return reply({ status: fault.status, json: fault.body, ...(fault.headers ? { headers: fault.headers } : {}) }); } const denied = opts.guard?.(req); if (denied) return reply(denied); for (const [re, h] of routes) { const m = re.exec(u.pathname); if (m) { try { return reply(h(m, q, req, body)); } catch (e) { return reply({ status: 500, json: { error: String(e) } }); } } } reply({ status: 404, json: { error: "no route" } }); }); await new Promise((ok) => server.listen(opts.port ?? 0, "127.0.0.1", ok)); const { port } = server.address() as AddressInfo; return { url: `http://127.0.0.1:${port}`, calls, close: () => new Promise((ok) => { server.closeAllConnections(); server.close(() => ok()); }), fault: (pattern, f) => faults.push({ pattern, f: { ...f } }), clearFaults: () => { faults.length = 0; }, callsTo: (p) => calls.filter((c) => matches(p, c.path)), }; } // ---------- 截图:按页面种类填色的 PNG(1280x800) ---------- const CRC_TABLE = (() => { const t = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } return t; })(); function crc32(buf: Uint8Array): number { let c = 0xffffffff; for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff]! ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; } function pngChunk(type: string, data: Uint8Array): Buffer { const len = Buffer.alloc(4); len.writeUInt32BE(data.byteLength); const td = Buffer.concat([Buffer.from(type, "ascii"), Buffer.from(data)]); const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td)); return Buffer.concat([len, td, crc]); } const pngCache = new Map(); /** 纯色 PNG(base64),可选若干横条表示元素行 */ export function solidPngBase64( w: number, h: number, rgb: [number, number, number], rows: number[] = [], /** 行坐标按 CSS 像素,乘以 DPR 画到截图上 */ scale = 1, ): string { const key = `${w}x${h}:${rgb.join(",")}:${rows.join(",")}:${scale}`; const hit = pngCache.get(key); if (hit) return hit; const raw = Buffer.alloc((w * 3 + 1) * h); for (let y = 0; y < h; y++) { const off = y * (w * 3 + 1); const cy = y / scale; const inRow = rows.some( (r) => cy >= MOCK_ROW_Y0 + r * MOCK_ROW_H + 8 && cy < MOCK_ROW_Y0 + (r + 1) * MOCK_ROW_H - 8, ); for (let x = 0; x < w; x++) { const bar = inRow && x >= 40 * scale && x < 640 * scale; raw[off + 1 + x * 3] = bar ? 90 : rgb[0]; raw[off + 2 + x * 3] = bar ? 110 : rgb[1]; raw[off + 3 + x * 3] = bar ? 140 : rgb[2]; } } const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2; const png = Buffer.concat([ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), pngChunk("IHDR", ihdr), pngChunk("IDAT", deflateSync(raw)), pngChunk("IEND", new Uint8Array()), ]).toString("base64"); pngCache.set(key, png); return png; } // ---------- 交互式站点 ---------- interface Line { ref: string; role: string; text: string; /** 点击行为 */ onClick?: () => void; } export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise { const tabs = new Map(); const actions: MockCamofoxAction[] = []; const loggedIn = new Set(); const cookieJars = new Map(); const egress = new Map(); const cookieApi = opts.cookieApi ?? "plugin"; let instanceId = `inst-${crypto.randomUUID()}`; /** 重启前存在过的 tabId:之后访问返回 410 */ const restartedTabs = new Set(); const site = opts.site; const cfPassed = new Set(); const contentReads = new Map(); 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), ); jar.push(c); cookieJars.set(userId, jar); const sc = site?.sessionCookie; if (sc && c.name === sc.name && new URL(site.origin).hostname.endsWith(c.domain.replace(/^\./, ""))) { if (sc.valid.includes(c.value)) loggedIn.add(userId); else loggedIn.delete(userId); } }; const importCookies = (userId: string, b: Body): MockReply => { const list = Array.isArray(b.cookies) ? (b.cookies as Record[]) : null; if (!list) return err(400, "cookies must be an array"); for (const c of list) { if (typeof c.name !== "string" || typeof c.value !== "string" || typeof c.domain !== "string") return err(400, "invalid cookie objects"); putCookie(userId, { name: c.name, value: c.value, domain: c.domain, path: typeof c.path === "string" ? c.path : "/", expires: typeof c.expires === "number" ? c.expires : -1, httpOnly: c.httpOnly === true, secure: c.secure === true, ...(typeof c.sameSite === "string" ? { sameSite: c.sameSite } : {}), }); } return { json: { ok: true, userId, count: list.length } }; }; const viewport = opts.viewport ?? MOCK_VIEWPORT; const dpr = opts.devicePixelRatio ?? 1; const loginPath = site?.loginPath ?? "/login.php"; const protectedPaths = site?.protectedPaths ?? /^\/(?:torrents|details|download|index)\.php/; let seq = 0; let dl = 0; const err = (status: number, error: string): MockReply => ({ status, json: { error } }); const tabFor = (id: string, userId: string | undefined) => { const t = tabs.get(decodeURIComponent(id)); return t && t.userId === userId ? t : undefined; }; /** 站点路由:未登录访问受保护页 → 登录页(带 returnto) */ const resolve = (t: MockCamofoxTab, url: string): string => { if (!site) return new URL(url).searchParams.get("redirect") ?? url; const u = new URL(url); if (u.origin !== new URL(site.origin).origin) return url; if (!loggedIn.has(t.userId) && protectedPaths.test(u.pathname)) return `${site.origin}${loginPath}?returnto=${encodeURIComponent(u.pathname + u.search)}`; return url; }; const go = (t: MockCamofoxTab, url: string, push = true) => { const target = resolve(t, url); if (push) { t.history = t.history.slice(0, t.cursor + 1); t.history.push(target); t.cursor = t.history.length - 1; } else t.history[t.cursor] = target; t.url = target; t.fields.clear(); t.focus = null; t.error = null; return target; }; const tryLogin = (t: MockCamofoxTab) => { if (!site) return; const ok = t.fields.get("e2") === site.username && t.fields.get("e3") === site.password && (!site.captcha || t.fields.get("e5") === (site.captchaAnswer ?? "4321")); if (!ok) { t.error = site.captcha && t.fields.get("e5") !== (site.captchaAnswer ?? "4321") ? "验证码错误" : "用户名或密码错误"; return; } const back = new URL(t.url).searchParams.get("returnto") ?? "/torrents.php"; if (site.twoFactor) { go(t, `${site.origin}/2fa.php?returnto=${encodeURIComponent(back)}`); return; } loggedIn.add(t.userId); if (site.sessionCookie) { if (!site.sessionCookie.valid.includes(site.sessionCookie.fresh)) site.sessionCookie.valid.push(site.sessionCookie.fresh); putCookie(t.userId, { name: site.sessionCookie.name, value: site.sessionCookie.fresh, domain: new URL(site.origin).hostname, path: "/", expires: Math.floor(Date.now() / 1000) + 86400, httpOnly: true, secure: site.origin.startsWith("https:"), }); } go(t, `${site.origin}${back}`); }; 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" }]; const field = (ref: string) => t.fields.get(ref) ?? ""; if (u.pathname === loginPath) { const lines: Line[] = [ { ref: "e1", role: "heading", text: "登录" }, { ref: "e2", role: "textbox", text: `用户名: ${field("e2")}` }, { ref: "e3", role: "textbox password", text: `密码: ${field("e3")}` }, ]; if (site.captcha) lines.push( { ref: "e4", role: "img", text: "验证码图片" }, { ref: "e5", role: "textbox", text: `验证码: ${field("e5")}` }, ); lines.push({ ref: "e6", role: "button", text: "登录", onClick: () => tryLogin(t) }); if (t.error) lines.push({ ref: "e7", role: "alert", text: t.error }); return lines; } if (u.pathname === "/2fa.php") { return [ { ref: "e1", role: "heading", text: "二次验证" }, { ref: "e2", role: "text", text: "验证码已发送到尾号 0042 的手机,请输入短信验证码" }, { ref: "e3", role: "textbox", text: `短信验证码: ${field("e3")}` }, { ref: "e4", role: "button", text: "确认" }, ]; } if (u.pathname === "/torrents.php") { const lines: Line[] = [{ ref: "e1", role: "heading", text: "种子列表" }]; releases.forEach((r, i) => { const dlUrl = `${site.origin}/download.php?id=${r.id}&passkey=${passkey}`; lines.push( { ref: `e${10 + i * 2}`, role: "link", text: r.title, onClick: () => void go(t, `${site.origin}/details.php?id=${r.id}`), }, { ref: `e${11 + i * 2}`, role: "link", text: `下载 ${dlUrl}`, onClick: () => { const bytes = site.torrent?.(r.id) ?? new TextEncoder().encode(`fake-torrent-${r.id}`); t.downloads.push({ id: `dl-${++dl}`, filename: `[UHD].${r.title.replace(/\s+/g, ".")}.torrent`, url: dlUrl, mimeType: "application/x-bittorrent", bytes, }); }, }, ); }); return lines; } return [ { ref: "e1", role: "heading", text: "首页" }, { ref: "e2", role: "link", text: `种子 ${site.origin}/torrents.php`, onClick: () => void go(t, `${site.origin}/torrents.php`), }, ]; }; const snapshotOf = (t: MockCamofoxTab) => site ? pageLines(t) .map( (l) => `[${l.role.split(" ")[0]} ${l.ref}${l.role.includes(" ") ? ` ${l.role.split(" ").slice(1).join(" ")}` : ""}] ${l.text}`, ) .join("\n") : (opts.snapshotText ?? CAMOFOX_FIXTURE_SNAPSHOT).replaceAll("{url}", t.url); const findLine = (t: MockCamofoxTab, b: Body): Line | undefined => { const lines = pageLines(t); if (typeof b.ref === "string") return lines.find((l) => l.ref === b.ref); if (typeof b.selector === "string") { const mark = /^\[data-umt-pt="([^"]+)"\]$/.exec(b.selector)?.[1]; if (mark) return lines.find((l) => l.ref === t.marks.get(mark)); return lines.find((l) => l.role.startsWith("button")); } return undefined; }; const action = (name: string, fn: (t: MockCamofoxTab, b: Body) => MockReply | undefined) => (m: RegExpMatchArray, _q: Record, _req: IncomingMessage, b: Body): MockReply => { const t = tabFor(m[1]!, b.userId as string | undefined); if (!t) return err(404, "Tab not found"); actions.push({ action: name, tabId: t.tabId, body: { ...b } }); return fn(t, b) ?? { json: { ok: true, tabId: t.tabId, url: t.url } }; }; const mock = await startJsonMock( [ [ /^\/health$/, () => ({ json: { ok: true, engine: "camoufox", browserConnected: true, browserRunning: true, activeTabs: tabs.size, activeSessions: new Set([...tabs.values()].map((t) => t.userId)).size, consecutiveFailures: 0, }, }), ], [ /^\/openapi\.json$/, () => { const paths: Record> = {}; for (const [p, ms] of Object.entries(OPENAPI_PATHS)) for (const m of ms) if (!opts.omitPaths?.includes(`${m} ${p}`)) { const ops = paths[p] ?? {}; ops[m] = { summary: `${m} ${p}` }; paths[p] = ops; } return { json: { openapi: "3.0.3", info: { title: "camofox-browser", version: opts.version ?? "1.17.0-mock" }, paths, }, }; }, ], [ /^\/tabs$/, (_m, q, req, b) => { if (req.method === "GET") return { json: { running: true, tabs: [...tabs.values()] .filter((t) => !q.userId || t.userId === q.userId) .map((t) => ({ tabId: t.tabId, targetId: t.tabId, url: t.url, title: "", listItemId: t.sessionKey })), }, }; if (typeof b.userId !== "string" || typeof b.sessionKey !== "string") return err(400, "userId and sessionKey required"); const tabId = `tab-${++seq}`; const t: MockCamofoxTab = { tabId, userId: b.userId, sessionKey: b.sessionKey, url: "about:blank", history: [], cursor: -1, fields: new Map(), focus: null, marks: new Map(), downloads: [], error: null, visible: true, }; // A newly opened desktop tab becomes visible; visibility is independent // of the adapter's currentTabId and can be changed explicitly by fixtures. for (const other of tabs.values()) if (other.userId === t.userId) other.visible = false; tabs.set(tabId, t); const url = typeof b.url === "string" ? go(t, b.url) : go(t, "about:blank"); return { json: { tabId, url, httpStatus: b.url ? 200 : null, navigationOk: true } }; }, ], [ /^\/tabs\/([^/]+)$/, (m, q, req, b) => { if (req.method !== "DELETE") return err(405, "method"); const t = tabFor(m[1]!, (q.userId ?? b.userId) as string | undefined); if (!t) return err(404, "Tab not found"); tabs.delete(t.tabId); return { json: { ok: true } }; }, ], [ /^\/tabs\/([^/]+)\/navigate$/, action("navigate", (t, b) => { if (typeof b.url !== "string") return err(400, "url required"); const target = go(t, b.url); const httpStatus = target.includes("/missing") ? 404 : 200; return { json: { ok: true, tabId: t.tabId, url: target, httpStatus, navigationOk: httpStatus < 400 } }; }), ], [ /^\/tabs\/([^/]+)\/(back|forward|refresh)$/, (m, q, req, b) => { const dir = m[2]!; return action(dir, (t) => { if (dir === "back" && t.cursor > 0) t.cursor--; if (dir === "forward" && t.cursor < t.history.length - 1) t.cursor++; const target = t.history[t.cursor] ?? t.url; go(t, target, false); return { json: { ok: true, tabId: t.tabId, url: t.url, httpStatus: 200, navigationOk: true } }; })(m, q, req, b); }, ], [ /^\/tabs\/([^/]+)\/snapshot$/, (m, q) => { const t = tabFor(m[1]!, q.userId); if (!t) return err(404, "Tab not found"); const snapshot = snapshotOf(t); const offset = Number(q.offset ?? 0); return { json: { url: t.url, snapshot: snapshot.slice(offset), structure: site ? null : { forms: [ { action: "takelogin.php", fields: [ { name: "username", type: "text", value: "fixture-user" }, { name: "password", type: "password", value: CAMOFOX_FIXTURE_PASSWORD }, ], }, ], }, refsCount: new Set(snapshot.match(/\[[a-z]+ (e\d+)/g) ?? []).size, truncated: false, totalChars: snapshot.length, hasMore: false, }, }; }, ], [ /^\/tabs\/([^/]+)\/screenshot$/, (m, q) => { const t = tabFor(m[1]!, q.userId); if (!t) return err(404, "Tab not found"); if (!site) return { json: { screenshot: { data: PNG_1X1_BASE64, mimeType: "image/png" } } }; const login = new URL(t.url).pathname === loginPath; const rows = pageLines(t).map((_l, i) => i); const data = solidPngBase64( Math.round(viewport.width * dpr), Math.round(viewport.height * dpr), login ? [236, 232, 224] : [248, 248, 250], rows, dpr, ); return { json: { screenshot: { data, mimeType: "image/png" } } }; }, ], [ /^\/tabs\/([^/]+)\/click$/, action("click", (t, b) => { // 1.17.0 实测:只认 ref / selector,坐标点击由适配器先 evaluate 打标记 if (typeof b.ref !== "string" && typeof b.selector !== "string") return err(400, "ref or selector required"); const line = findLine(t, b); if (!line) return typeof b.ref === "string" ? err(422, "Unknown ref") : undefined; if (line.role.startsWith("textbox")) t.focus = line.ref; line.onClick?.(); return undefined; }), ], [ /^\/tabs\/([^/]+)\/type$/, action("type", (t, b) => { if (typeof b.text !== "string") return err(400, "text required"); if (typeof b.ref !== "string" && typeof b.selector !== "string" && b.mode !== "keyboard") return err(400, "ref or selector required for mode=fill"); const ref = typeof b.ref === "string" ? b.ref : (findLine(t, b)?.ref ?? t.focus); if (!ref) return err(422, "No focused element"); t.fields.set(ref, b.clear === false ? (t.fields.get(ref) ?? "") + b.text : b.text); t.focus = ref; if (b.submit === true) tryLogin(t); return undefined; }), ], [ /^\/tabs\/([^/]+)\/evaluate$/, action("evaluate", (t, b) => { // 只模拟适配器的坐标定位表达式:elementFromPoint(x, y) + data-umt-pt 标记 const expr = typeof b.expression === "string" ? b.expression : ""; // 适配器的页面度量表达式(VIS-1) if (expr.includes("umt-metrics")) return { json: { ok: true, result: { w: viewport.width, h: viewport.height, dpr, pw: pageLines(t).some((l) => l.role.includes("password")), text: pageLines(t) .map((l) => l.text) .join("\n"), }, }, }; if (expr.includes("umt-visible-tab")) return { json: { ok: true, result: { visible: t.visible === null ? null : (t.visible ?? true) } } }; // 页面内容(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 ? `Just a moment...
${ mode === "captcha" ? '
' : "" }` : site?.html ? site.html(t.url) : `${title}${lines.map((l) => `

${l.text}

`).join("")}`; return { json: { ok: true, result: { url: t.url, title, html, text: site?.pageText?.(t.url) ?? 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 } }; const line = pageLines(t)[Math.floor((Number(pt[2]) - MOCK_ROW_Y0) / MOCK_ROW_H)]; if (!line) return { json: { ok: true, result: false } }; t.marks.clear(); t.marks.set(nonce, line.ref); return { json: { ok: true, result: true } }; }), ], [/^\/tabs\/([^/]+)\/select$/, action("select", () => undefined)], [/^\/tabs\/([^/]+)\/scroll$/, action("scroll", () => undefined)], [ /^\/tabs\/([^/]+)\/press$/, action("press", (t, b) => { if (b.key === "Enter" && site && new URL(t.url).pathname === loginPath) tryLogin(t); if (b.key === "Backspace" && t.focus) t.fields.set(t.focus, (t.fields.get(t.focus) ?? "").slice(0, -1)); return undefined; }), ], [ /^\/tabs\/([^/]+)\/extract$/, action("extract", (t, b) => { const props = ((b.schema as Body | undefined)?.properties ?? {}) as Record; const lines = site ? pageLines(t) : []; const text = snapshotOf(t).split("\n"); const data: Record = {}; for (const [k, p] of Object.entries(props)) { const ref = p["x-ref"]; const hit = lines.find((l) => l.ref === ref)?.text ?? text.find((l) => l.includes(` ${ref}]`) || l.includes(` ${ref} `))?.replace(/^\[[^\]]*\]\s*/, ""); data[k] = hit ?? null; } return { json: { ok: true, data } }; }), ], [ /^\/tabs\/([^/]+)\/downloads$/, (m, q) => { const t = tabFor(m[1]!, q.userId); if (!t) return err(404, "Tab not found"); const max = q.maxBytes ? Number(q.maxBytes) : Number.POSITIVE_INFINITY; // 字段形状按 1.17.0 实测(lib/downloads.js getDownloadsList) const list = t.downloads.map((d) => ({ id: d.id, url: d.url, suggestedFilename: d.filename, mimeType: d.mimeType, bytes: d.bytes.byteLength, createdAt: Date.now(), failure: null, ...(q.includeData === "true" ? d.bytes.byteLength <= max ? { dataBase64: Buffer.from(d.bytes).toString("base64") } : { dataSkipped: "max_bytes_exceeded" } : {}), })); if (q.consume === "true") t.downloads = []; 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$/, () => (cookieApi === "plugin" ? { json: { ...MOCK_CAMOFOX_IDENTITY } } : err(404, "no route")), ], [ /^\/umt\/sessions\/([^/]+)\/proxy$/, (m, _q, req, b) => { if (cookieApi !== "plugin" || req.method !== "PUT") return err(404, "no route"); const userId = decodeURIComponent(m[1]!); const p = b.proxy as { server?: string } | null | undefined; if (p !== null && (!p || typeof p.server !== "string")) return err(400, "proxy must be {server} or null"); egress.set(userId, p === null ? null : { ...(p as { server: string }) }); return { json: { ok: true, userId, egress: p ? "proxy" : "direct", mode: "site", restarted: false } }; }, ], [ /^\/umt\/sessions\/([^/]+)\/cookies$/, (m, q, req, b) => { if (cookieApi !== "plugin") return err(404, "no route"); const userId = decodeURIComponent(m[1]!); if (req.method === "POST") return importCookies(userId, b); const hosts = (q.urls ?? "") .split(",") .filter(Boolean) .map((u) => new URL(u).hostname); const cookies = (cookieJars.get(userId) ?? []).filter((c) => { const d = c.domain.replace(/^\./, ""); return hosts.some((h) => h === d || h.endsWith(`.${d}`)); }); return { json: { source: "live", cookies } }; }, ], [ /^\/sessions\/([^/]+)\/cookies$/, (m, _q, req, b) => { if (cookieApi === "none") return err(404, "no route"); if (!opts.cookieImportKey || req.headers.authorization !== `Bearer ${opts.cookieImportKey}`) return err(403, "Forbidden"); return importCookies(decodeURIComponent(m[1]!), b); }, ], [ /^\/sessions\/([^/]+)$/, (m) => { const userId = decodeURIComponent(m[1]!); let closed = 0; for (const [id, t] of tabs) if (t.userId === userId) { tabs.delete(id); closed++; } return { json: { ok: true, closed } }; }, ], ], { port: opts.port, guard: (req) => { const path = new URL(req.url ?? "/", "http://mock").pathname; // 与上游一致:导入端点有自己的口令(CAMOFOX_API_KEY),不走访问凭据 const gone = /^\/tabs\/([^/]+)/.exec(path)?.[1]; const restarted = gone && restartedTabs.has(decodeURIComponent(gone)) ? { status: 410, json: { error: "Tab no longer exists (browser was restarted).", code: "browser_restarted" }, } : null; if (!opts.accessKey || path === "/health" || /^\/sessions\/[^/]+\/cookies$/.test(path)) return restarted; return req.headers.authorization === `Bearer ${opts.accessKey}` ? restarted : err(401, "Unauthorized"); }, }, ); const out = Object.assign(mock, { tabs, actions, loggedIn, cookieJars, egress, cfPassed, contentReads, expireLogin(userId?: string) { if (userId) loggedIn.delete(userId); else loggedIn.clear(); }, dropTab(tabId: string) { tabs.delete(tabId); }, restart(o: { loseProfiles?: boolean } = {}) { for (const id of tabs.keys()) restartedTabs.add(id); tabs.clear(); instanceId = `inst-${crypto.randomUUID()}`; if (o.loseProfiles) { loggedIn.clear(); cookieJars.clear(); cfPassed.clear(); } }, }); // Object.assign 会把 getter 取值成常量:单独定义 Object.defineProperty(out, "instanceId", { get: () => instanceId, enumerable: true }); return out as MockCamofox; }