feat(browser): complete camofox BrowserProvider with tabs, interaction, download capture and control lease

Contracts for browser API and browser.* agent capabilities; adapter implements
close/list/restore sessions, tabs + reconcile, history, click/type/select/scroll/press,
extract, waitForDownload (staged via DownloadSink, sha256), stale-ref guard and
automation/human actor check. testkit mock camofox gains an interactive site
(login redirect, captcha, click-driven page changes, downloads, PNG screenshots).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 20:17:46 +08:00
1 parent 62290d4477
commit c17d4b90bd
12 files changed
+1832 -101

No files matched your search

+410 -24
View File
@@ -1,17 +1,45 @@
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 openapi.json 1.17.0,取值虚构,未对真实实例核验)。
* 路由:GET /health、GET /openapi.json、POST /tabs、GET /tabs、POST /tabs/{id}/navigate、
* GET /tabs/{id}/snapshot(固定可访问性快照 + 表单 structure,含密码值/Cookie/passkey 供脱敏测试)、
* GET /tabs/{id}/screenshot(1x1 PNG,base64)、DELETE /sessions/{userId}。
* 路由: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}、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)。
* accessKey 设置时模拟 CAMOFOX_ACCESS_KEY:除 /health 外要求 `Authorization: Bearer <key>`,否则 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;
releases?: { id: string; title: string }[];
/** 下载内容;缺省返回固定字节 */
torrent?: (id: string) => Uint8Array;
passkey?: string;
}
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) */
@@ -25,9 +53,40 @@ export interface MockCamofoxTab {
userId: string;
sessionKey: string;
url: string;
history: string[];
cursor: number;
/** 元素引用 → 已输入的值 */
fields: Map<string, string>;
focus: string | null;
downloads: MockCamofoxDownload[];
error: string | null;
}
export type MockCamofox = MockHttp & { tabs: Map<string, MockCamofoxTab> };
export interface MockCamofoxDownload {
id: string;
filename: string;
url: string;
mimeType: string;
bytes: Uint8Array;
}
/** 交互动作记录(断言凭证确实下发到了浏览器、坐标换算结果等) */
export interface MockCamofoxAction {
action: string;
tabId: string;
body: Record<string, unknown>;
}
export type MockCamofox = MockHttp & {
tabs: Map<string, MockCamofoxTab>;
actions: MockCamofoxAction[];
/** 已登录的 userId(profile) */
loggedIn: Set<string>;
/** 模拟站点登录过期(全部 profile 或指定 profile) */
expireLogin(userId?: string): void;
/** 模拟上游回收标签页(空闲超时 / 重启) */
dropTab(tabId: string): void;
};
/** 1x1 透明 PNG */
export const PNG_1X1_BASE64 =
@@ -48,6 +107,11 @@ export const CAMOFOX_FIXTURE_SNAPSHOT = [
].join("\n");
const OPENAPI_PATHS: Record<string, string[]> = {
"/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"],
@@ -145,15 +209,214 @@ async function startJsonMock(
};
}
// ---------- 截图:按页面种类填色的 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<string, string>();
/** 纯色 PNG(base64),可选若干横条表示元素行 */
export function solidPngBase64(w: number, h: number, rgb: [number, number, number], rows: number[] = []): string {
const key = `${w}x${h}:${rgb.join(",")}:${rows.join(",")}`;
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 inRow = rows.some((r) => y >= MOCK_ROW_Y0 + r * MOCK_ROW_H + 8 && y < MOCK_ROW_Y0 + (r + 1) * MOCK_ROW_H - 8);
for (let x = 0; x < w; x++) {
const bar = inRow && x >= 40 && x < 640;
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<MockCamofox> {
const tabs = new Map<string, MockCamofoxTab>();
const actions: MockCamofoxAction[] = [];
const loggedIn = new Set<string>();
const site = opts.site;
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;
}
loggedIn.add(t.userId);
const back = new URL(t.url).searchParams.get("returnto") ?? "/torrents.php";
go(t, `${site.origin}${back}`);
};
const pageLines = (t: MockCamofoxTab): Line[] => {
if (!site) return [];
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 === "/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.y === "number") return lines[Math.floor((b.y - MOCK_ROW_Y0) / MOCK_ROW_H)];
if (typeof b.selector === "string") 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<string, string>, _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(
[
[
@@ -201,22 +464,53 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
if (typeof b.userId !== "string" || typeof b.sessionKey !== "string")
return err(400, "userId and sessionKey required");
const tabId = `tab-${++seq}`;
const url = typeof b.url === "string" ? b.url : "about:blank";
tabs.set(tabId, { tabId, userId: b.userId, sessionKey: b.sessionKey, url });
const t: MockCamofoxTab = {
tabId,
userId: b.userId,
sessionKey: b.sessionKey,
url: "about:blank",
history: [],
cursor: -1,
fields: new Map(),
focus: null,
downloads: [],
error: null,
};
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\/([^/]+)\/navigate$/,
(m, _q, _req, b) => {
const t = tabFor(m[1]!, b.userId as string | undefined);
/^\/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");
// 模拟站点重定向:?redirect=<url> 落到另一地址
const target = new URL(b.url).searchParams.get("redirect") ?? b.url;
t.url = target;
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);
},
],
[
@@ -224,23 +518,25 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
(m, q) => {
const t = tabFor(m[1]!, q.userId);
if (!t) return err(404, "Tab not found");
const snapshot = (opts.snapshotText ?? CAMOFOX_FIXTURE_SNAPSHOT).replaceAll("{url}", t.url);
const snapshot = snapshotOf(t);
const offset = Number(q.offset ?? 0);
return {
json: {
url: t.url,
snapshot: snapshot.slice(offset),
structure: {
forms: [
{
action: "takelogin.php",
fields: [
{ name: "username", type: "text", value: "fixture-user" },
{ name: "password", type: "password", value: CAMOFOX_FIXTURE_PASSWORD },
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,
@@ -254,7 +550,86 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
(m, q) => {
const t = tabFor(m[1]!, q.userId);
if (!t) return err(404, "Tab not found");
return { json: { screenshot: { data: PNG_1X1_BASE64, mimeType: "image/png" } } };
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(
MOCK_VIEWPORT.width,
MOCK_VIEWPORT.height,
login ? [236, 232, 224] : [248, 248, 250],
rows,
);
return { json: { screenshot: { data, mimeType: "image/png" } } };
},
],
[
/^\/tabs\/([^/]+)\/click$/,
action("click", (t, b) => {
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");
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\/([^/]+)\/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<string, Body>;
const lines = site ? pageLines(t) : [];
const text = snapshotOf(t).split("\n");
const data: Record<string, unknown> = {};
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;
const list = t.downloads.map((d) => ({
id: d.id,
filename: d.filename,
url: d.url,
mimeType: d.mimeType,
size: d.bytes.byteLength,
status: "completed",
...(q.includeData === "true" && d.bytes.byteLength <= max
? { data: Buffer.from(d.bytes).toString("base64") }
: { truncated: d.bytes.byteLength > max }),
}));
if (q.consume === "true") t.downloads = [];
return { json: { downloads: list } };
},
],
[
@@ -280,5 +655,16 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
},
},
);
return Object.assign(mock, { tabs });
return Object.assign(mock, {
tabs,
actions,
loggedIn,
expireLogin(userId?: string) {
if (userId) loggedIn.delete(userId);
else loggedIn.clear();
},
dropTab(tabId: string) {
tabs.delete(tabId);
},
});
}