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:
1 parent
62290d4477
commit
c17d4b90bd
12 files changed
+1832
-101
No files matched your search
@@ -8,19 +8,14 @@
|
||||
- `profile-lock.ts`:同一 profile 串行锁(进程内内存实现;多进程部署换持久化实现,接口不变)。
|
||||
- `budget.ts`:导航域名白名单(含子域),非 http(s) 拒绝。
|
||||
|
||||
## v0.1 已实现
|
||||
## 已实现(C1-E,仅对 mock 与上游文档验证)
|
||||
|
||||
`health`(`/health`,免认证,503 → degraded)、`capabilities`(静态声明;`probe: true` 读上游 `/openapi.json` 推导,缺端点即降级)、`openSession`(`POST /tabs`)、`navigate`(`POST /tabs/{id}/navigate`,预算内重试 5xx/超时/网络错误)、`snapshot`(`GET /tabs/{id}/snapshot?format=text`,字节预算截断 + 脱敏)、`screenshot`(`GET /tabs/{id}/screenshot`,JSON base64 或二进制图片;登录页/含凭据地址标 `modelSafe: false`)。
|
||||
- 会话:`openSession`(`POST /tabs`)、`closeSession`(`DELETE /sessions/{userId}`)、`listSessions`(内存镜像;服务层以数据库为准)、`restoreSession`(重启后按 DB 恢复,旧引用失效)。
|
||||
- 标签页:`openTab`、`closeTab`(`DELETE /tabs/{id}`)、`reconcileTabs`(`GET /tabs?userId`,上游已回收的标 `lost`)。
|
||||
- 导航:`navigate`(白名单 / 超时 / 预算内重试 5xx·超时·网络)、`history`(`/back` `/forward` `/refresh`)。
|
||||
- 读取:`snapshot`(字节预算截断 + 脱敏)、`screenshot`(JSON base64 或 image/*;登录页 / 含凭据地址 `modelSafe: false`)、`extract`(`POST /extract`,需先快照)。
|
||||
- 交互:`click`(ref / selector / 视口坐标)、`type`(明文或凭证引用,凭证经 `BrowserSecretResolver` 解出后直接下发)、`select`、`scroll`、`press`。
|
||||
- 下载:`waitForDownload` 轮询 `GET /tabs/{id}/downloads?includeData&consume&maxBytes`,字节交 `DownloadSink` 暂存、算 sha256,只返回引用。
|
||||
- 控制权:`TabRef.as` 标明发起方(automation / human),与会话 controller 不符报 `control_conflict`;`setController` 任何变更都让旧 `eN` 引用失效(`stale_ref`);`acquireControl` / `releaseControl` 为内存版,服务层用 `browser_control_lease` 持久化。
|
||||
|
||||
## M2(当前抛 `BrowserError('not_implemented')`)
|
||||
|
||||
| 方法 | 上游候选端点 | 待办 |
|
||||
| --- | --- | --- |
|
||||
| `closeSession` | `DELETE /sessions/{userId}` | 与 browser_session 持久化、租约释放一起落地 |
|
||||
| `listSessions` | `GET /tabs?userId=` | 上游只列标签页,会话清单以 DB 为准,需对账 |
|
||||
| `click` / `type` | `POST /tabs/{id}/click` / `type` / `select` | 接凭证代理(`TypeInput.kind = 'secret'`);README 示例 `pressEnter` 与 OpenAPI `submit` 字段名不一致,需核验 |
|
||||
| `extract` | `POST /tabs/{id}/extract`(JSON Schema + `x-ref`) | 需先 snapshot 建 ref 表 |
|
||||
| `waitForDownload` | `GET /tabs/{id}/downloads?includeData&consume&maxBytes` | 产物落 blob 暂存 + sha256,交后端核验 |
|
||||
| `acquireControl` / `releaseControl` | 无(上游无接管 API) | 服务层以 `browser_control_lease` 持久化;画面/输入通道待 VNC 原型 |
|
||||
|
||||
仅对 mock 与上游文档验证,未连接真实实例。
|
||||
未核验的上游行为(坐标点击、无目标输入、scroll / press / back 等端点、downloads 字段形状)见 `docs/runbook/browser.md` 第 6 节。
|
||||
@@ -84,14 +84,13 @@ async function rejectKind(p: Promise<unknown>): Promise<string> {
|
||||
}
|
||||
|
||||
describe("能力声明", () => {
|
||||
it("静态声明:已实现能力 supported、M2 能力 false 并注明,默认全部未验证", () => {
|
||||
it("静态声明:全部能力 supported,verified 只来自配置;待核验项有注明", () => {
|
||||
const c = camofoxStaticCapabilities({ snapshot: true, interact: true });
|
||||
expect(c.navigate).toMatchObject({ supported: true, verified: false, source: "static" });
|
||||
expect(c.snapshot).toMatchObject({ supported: true, verified: true });
|
||||
// 未实现的能力即使配置了 verified 也不会被标为已验证
|
||||
expect(c.interact).toMatchObject({ supported: false, verified: false });
|
||||
expect(c.interact.note).toContain("M2");
|
||||
expect(c.takeover.supported).toBe(false);
|
||||
expect(c.interact).toMatchObject({ supported: true, verified: true });
|
||||
expect(c.interact.note).toContain("待核验");
|
||||
expect(c.takeover).toMatchObject({ supported: true, verified: false });
|
||||
expect(c.upstreamVersion).toBeNull();
|
||||
});
|
||||
|
||||
@@ -279,8 +278,8 @@ describe("串行锁", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("M2 未实现分支", () => {
|
||||
it("click/type/extract/waitForDownload/接管/关闭会话均抛 not_implemented", async () => {
|
||||
describe("未知会话", () => {
|
||||
it("交互/提取/下载/关闭对不存在的会话报 not_found;listSessions 只列内存镜像", async () => {
|
||||
const { provider } = await setup();
|
||||
const ref = { sessionId: "x" };
|
||||
const calls: Promise<unknown>[] = [
|
||||
@@ -288,18 +287,11 @@ describe("M2 未实现分支", () => {
|
||||
provider.type(ref, { ref: "e1" }, { kind: "text", text: "x" }),
|
||||
provider.extract(ref, {}),
|
||||
provider.waitForDownload(ref, { timeoutMs: 10, maxBytes: 10 }),
|
||||
provider.acquireControl({
|
||||
sessionId: "x",
|
||||
holderKind: "human",
|
||||
holder: "admin",
|
||||
reason: "登录验证",
|
||||
ttlMs: 1000,
|
||||
}),
|
||||
provider.releaseControl("lease"),
|
||||
provider.closeSession("x"),
|
||||
provider.listSessions(),
|
||||
];
|
||||
for (const c of calls) expect(await rejectKind(c)).toBe("not_implemented");
|
||||
for (const c of calls) expect(await rejectKind(c)).toBe("not_found");
|
||||
expect(await rejectKind(provider.releaseControl("lease"))).toBe("not_found");
|
||||
expect(await provider.listSessions()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -18,30 +18,34 @@ export const CAMOFOX_ENDPOINTS: Record<BrowserCapabilityKey, readonly string[]>
|
||||
interact: ["post /tabs/{tabId}/click", "post /tabs/{tabId}/type", "post /tabs/{tabId}/select"],
|
||||
extract: ["post /tabs/{tabId}/extract"],
|
||||
downloadCapture: ["get /tabs/{tabId}/downloads"],
|
||||
// 上游 OpenAPI 无接管端点;VNC 插件(ENABLE_VNC,noVNC 独立端口)是整块虚拟显示,需原型验证
|
||||
takeover: [],
|
||||
// 原站视图接管 = 同一会话的定时截图 + 点击/输入映射(不另起浏览器);VNC 插件未接入
|
||||
takeover: ["get /tabs/{tabId}/screenshot", "post /tabs/{tabId}/click", "post /tabs/{tabId}/type"],
|
||||
// 持久化插件默认开启,无需端点;storage_state 端点属插件,不在 OpenAPI 中
|
||||
persistentProfile: [],
|
||||
};
|
||||
|
||||
/** 适配器已实现的能力;其余上游虽有端点,适配器在 M2 实现 */
|
||||
/** 适配器已实现的能力(C1-E 起全部实现;是否可用仍取决于 verified) */
|
||||
const ADAPTER_IMPLEMENTED: ReadonlySet<BrowserCapabilityKey> = new Set([
|
||||
"sessions",
|
||||
"tabs",
|
||||
"navigate",
|
||||
"snapshot",
|
||||
"screenshot",
|
||||
"interact",
|
||||
"extract",
|
||||
"downloadCapture",
|
||||
"takeover",
|
||||
"persistentProfile",
|
||||
]);
|
||||
|
||||
const STATIC_NOTES: Partial<Record<BrowserCapabilityKey, string>> = {
|
||||
sessions: "上游无显式建会话端点:userId 即会话(BrowserContext),首个 POST /tabs 隐式创建;关闭会话适配器 M2",
|
||||
tabs: "仅建首个标签页;列举/关闭适配器 M2",
|
||||
sessions: "上游无显式建会话端点:userId 即会话(BrowserContext),首个 POST /tabs 隐式创建;会话以数据库为准",
|
||||
tabs: "新建/关闭/对账(GET /tabs?userId,上游回收的标 lost)",
|
||||
snapshot: "可访问性树文本 + eN 元素引用,支持 offset 分页",
|
||||
interact: "上游有 click/type/select;适配器 M2",
|
||||
extract: "上游 POST /extract 需先 snapshot 建 ref 表(x-ref);适配器 M2",
|
||||
downloadCapture: "上游 GET /downloads 支持 includeData/consume/maxBytes;适配器 M2",
|
||||
takeover: "上游无接管 API;VNC 插件画面/输入与同会话连续操作待原型验证",
|
||||
interact: "click/type/select + scroll/press(后两者不在 1.17.0 OpenAPI,待核验);坐标点击待核验",
|
||||
extract: "POST /extract 需先 snapshot 建 ref 表(x-ref)",
|
||||
downloadCapture: "GET /downloads?includeData&consume&maxBytes(README;字段形状待核验)",
|
||||
takeover: "原站视图:同一会话定时截图 + 输入映射;坐标点击与无目标输入待核验,VNC 未接入",
|
||||
persistentProfile: "上游持久化插件默认保存 cookies+localStorage(~/.camofox/profiles),重启恢复待验证",
|
||||
};
|
||||
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
* 认证:README/OpenAPI 为 `Authorization: Bearer <CAMOFOX_ACCESS_KEY>`(设置后除 /health 外全部路由要求);
|
||||
* 凭据每次请求经 BrowserSecretResolver 解出,不缓存、不入日志、不进错误信息。
|
||||
*
|
||||
* v0.1 实现:health、capabilities(静态 + /openapi.json 探测)、openSession、navigate、snapshot、screenshot;
|
||||
* 其余方法抛 BrowserError('not_implemented'),见 README 的 M2 清单。
|
||||
* 已实现(仅对 mock 与上游文档验证):health、capabilities、会话开/关/列举、标签页增删与对账、navigate(白名单/超时/重试)、
|
||||
* 后退/前进/刷新、snapshot(脱敏)、screenshot、click/type/select/scroll/press、extract、waitForDownload、
|
||||
* 内存版控制权租约与串行锁。会话/标签/租约的持久化与对账由服务层(apps/server modules/browser)以数据库为准。
|
||||
* 未核验的上游行为(坐标点击、无目标输入、downloads 字段形状)见 docs/runbook/browser.md。
|
||||
*/
|
||||
import type {
|
||||
BrowserCapabilities,
|
||||
@@ -23,19 +25,22 @@ import type {
|
||||
NavigationBudget,
|
||||
} from "@umt/contracts";
|
||||
import { assertNavigable, hostAllowed } from "../budget.ts";
|
||||
import { BrowserError, classifyHttpStatus, notImplemented } from "../errors.ts";
|
||||
import { BrowserError, classifyHttpStatus } from "../errors.ts";
|
||||
import { InMemoryProfileLock, type ProfileLock } from "../profile-lock.ts";
|
||||
import {
|
||||
type AcquireControlRequest,
|
||||
type ActorKind,
|
||||
type BrowserLogger,
|
||||
type BrowserProvider,
|
||||
type BrowserScreenshot,
|
||||
type BrowserSecretResolver,
|
||||
type BrowserSessionInfo,
|
||||
type ElementTarget,
|
||||
type NavigationResult,
|
||||
type OpenSessionRequest,
|
||||
type OpenSessionResult,
|
||||
profileKeyFor,
|
||||
type RestoreSessionState,
|
||||
type SnapshotOptions,
|
||||
type TabRef,
|
||||
type TypeInput,
|
||||
@@ -58,6 +63,10 @@ export interface CamofoxConfig {
|
||||
verified?: VerifiedMap;
|
||||
/** 截图/快照默认字节上限(未传 budget 时) */
|
||||
defaultMaxBytes?: number;
|
||||
/** 下载产物落点(服务层写 DATA_DIR/tmp);缺省进程内内存(memoryDownloadSink) */
|
||||
downloadSink?: DownloadSink;
|
||||
/** waitForDownload 轮询间隔 */
|
||||
downloadPollMs?: number;
|
||||
fetch?: typeof fetch;
|
||||
logger?: BrowserLogger;
|
||||
now?: () => Date;
|
||||
@@ -71,6 +80,46 @@ interface SessionState {
|
||||
/** 未脱敏地址只留在内存,用于判断截图能否进入模型;不对外返回 */
|
||||
rawUrl: string | null;
|
||||
budget: NavigationBudget;
|
||||
/** 最近一次快照之后没有导航/点击/交接:eN 引用可用 */
|
||||
refsValid: boolean;
|
||||
/** 已见过的下载(consume 失败时防重复) */
|
||||
seenDownloads: Set<string>;
|
||||
}
|
||||
|
||||
/** 下载产物的受控暂存:返回引用,字节不进合同/日志 */
|
||||
export type DownloadSink = (
|
||||
bytes: Uint8Array,
|
||||
meta: { id: string; sessionId: string; filename: string; mimeType: string | null },
|
||||
) => Promise<{ kind: "blob" | "secret"; ref: string }>;
|
||||
|
||||
/** 进程内内存暂存(测试/脚本);服务层换成 DATA_DIR/tmp 下的文件 */
|
||||
export function memoryDownloadSink(): DownloadSink & { blobs: Map<string, Uint8Array> } {
|
||||
const blobs = new Map<string, Uint8Array>();
|
||||
const sink = (async (bytes, meta) => {
|
||||
const ref = `mem:${meta.id}`;
|
||||
blobs.set(ref, bytes);
|
||||
return { kind: "blob" as const, ref };
|
||||
}) as DownloadSink & { blobs: Map<string, Uint8Array> };
|
||||
sink.blobs = blobs;
|
||||
return sink;
|
||||
}
|
||||
|
||||
export function isLoginUrl(raw: string | null): boolean {
|
||||
if (!raw) return false;
|
||||
try {
|
||||
return LOGIN_PATH.test(new URL(raw).pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function b64decode(b64: string): Uint8Array {
|
||||
return Uint8Array.from(atob(b64.replace(/^data:[^,]*,/, "").replace(/\s+/g, "")), (c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
||||
const d = await crypto.subtle.digest("SHA-256", bytes as Uint8Array<ArrayBuffer>);
|
||||
return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
@@ -85,6 +134,8 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly lock: ProfileLock;
|
||||
private readonly sessions = new Map<string, SessionState>();
|
||||
private readonly leases = new Map<string, BrowserControlLease>();
|
||||
private readonly sink: DownloadSink;
|
||||
private readonly now: () => Date;
|
||||
private readonly newId: () => string;
|
||||
|
||||
@@ -94,6 +145,7 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
this.lock = cfg.lock ?? new InMemoryProfileLock(cfg.lockWaitMs);
|
||||
this.now = cfg.now ?? (() => new Date());
|
||||
this.newId = cfg.newId ?? (() => crypto.randomUUID());
|
||||
this.sink = cfg.downloadSink ?? memoryDownloadSink();
|
||||
}
|
||||
|
||||
// ---------- HTTP ----------
|
||||
@@ -168,10 +220,29 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
return t;
|
||||
}
|
||||
|
||||
/** 接管/等待/关闭中的会话拒绝自动化动作(REQ §4.7.3) */
|
||||
private assertAutomation(s: SessionState): void {
|
||||
if (s.session.controller !== "automation")
|
||||
throw new BrowserError("control_conflict", `会话当前由 ${s.session.controller} 控制,自动化动作已停止`);
|
||||
/** 动作发起方必须与会话控制者一致:接管/等待/关闭中的会话拒绝自动化动作(REQ §4.7.3) */
|
||||
private assertActor(s: SessionState, as: ActorKind = "automation"): void {
|
||||
const c = s.session.controller;
|
||||
if (c === as) return;
|
||||
if (c === "closed") throw new BrowserError("control_conflict", "会话已关闭");
|
||||
if (as === "automation")
|
||||
throw new BrowserError(
|
||||
"control_conflict",
|
||||
c === "human" ? "会话正由人工接管,自动化动作已停止" : "会话在等待人工处理,自动化动作已停止",
|
||||
);
|
||||
throw new BrowserError("control_conflict", "先接管会话再操作");
|
||||
}
|
||||
|
||||
/** 用 eN 引用前确认快照仍有效:导航、点击、交接之后旧引用一律作废,不重放 */
|
||||
private assertTarget(s: SessionState, target: ElementTarget): Json {
|
||||
if ("ref" in target) {
|
||||
if (!s.refsValid) throw new BrowserError("stale_ref", "元素引用已失效(页面已变化或控制权已交接),先重新读取页面");
|
||||
return { ref: target.ref };
|
||||
}
|
||||
if ("selector" in target) return { selector: target.selector };
|
||||
if (!Number.isFinite(target.x) || !Number.isFinite(target.y) || target.x < 0 || target.y < 0)
|
||||
throw new BrowserError("bad_request", "坐标无效");
|
||||
return { x: Math.round(target.x), y: Math.round(target.y) };
|
||||
}
|
||||
|
||||
private touch(s: SessionState, rawUrl?: string | null): void {
|
||||
@@ -189,13 +260,46 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
return this.lock.run(key, fn, this.cfg.lockWaitMs === undefined ? {} : { waitMs: this.cfg.lockWaitMs });
|
||||
}
|
||||
|
||||
/** 服务层把会话控制权变更(接管/交还/等待)同步给适配器;租约本身由服务层持久化 */
|
||||
/** 服务层把会话控制权变更(接管/交还/等待)同步给适配器;租约本身由服务层持久化。任何变更都让旧引用失效 */
|
||||
setController(sessionId: string, controller: BrowserSession["controller"]): void {
|
||||
const s = this.state(sessionId);
|
||||
if (s.session.controller !== controller) s.refsValid = false;
|
||||
s.session.controller = controller;
|
||||
if (controller === "closed" && !s.session.closedAt) s.session.closedAt = this.now().toISOString();
|
||||
this.touch(s);
|
||||
}
|
||||
|
||||
restoreSession(st: RestoreSessionState): void {
|
||||
const tabs = new Map(st.tabs.map((t) => [t.upstreamTabId, { ...t }]));
|
||||
const open = st.tabs.find((t) => t.status === "open");
|
||||
this.sessions.set(st.session.id, {
|
||||
session: { ...st.session },
|
||||
tabs,
|
||||
currentTabId: st.currentTabId && tabs.has(st.currentTabId) ? st.currentTabId : (open?.upstreamTabId ?? ""),
|
||||
rawUrl: st.session.currentUrl,
|
||||
budget: st.budget,
|
||||
refsValid: false,
|
||||
seenDownloads: new Set(),
|
||||
});
|
||||
}
|
||||
|
||||
sessionInfo(sessionId: string): BrowserSessionInfo | null {
|
||||
const s = this.sessions.get(sessionId);
|
||||
if (!s) return null;
|
||||
return {
|
||||
session: { ...s.session },
|
||||
tabs: [...s.tabs.values()].map((t) => ({ ...t })),
|
||||
currentTabId: s.currentTabId || null,
|
||||
refsValid: s.refsValid,
|
||||
loginPage: isLoginUrl(s.rawUrl),
|
||||
};
|
||||
}
|
||||
|
||||
async drain(sessionId: string): Promise<void> {
|
||||
const s = this.state(sessionId);
|
||||
await this.withProfile(s, async () => {});
|
||||
}
|
||||
|
||||
// ---------- 健康与能力 ----------
|
||||
|
||||
async health(): Promise<BrowserHealth> {
|
||||
@@ -295,6 +399,8 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
currentTabId: tabId,
|
||||
rawUrl,
|
||||
budget: req.budget,
|
||||
refsValid: false,
|
||||
seenDownloads: new Set(),
|
||||
};
|
||||
this.sessions.set(sessionId, st);
|
||||
let navigation: NavigationResult | null = null;
|
||||
@@ -324,22 +430,143 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
throw new BrowserError("domain_blocked", `导航被重定向到白名单外:${redactBrowserUrl(new URL(rawUrl).origin)}`);
|
||||
}
|
||||
|
||||
async closeSession(_sessionId: string, _reason?: string): Promise<void> {
|
||||
throw notImplemented("closeSession");
|
||||
/** DELETE /sessions/{userId}:关闭该 profile 的全部上游标签页;上游 404(已被回收)视为已关闭 */
|
||||
async closeSession(sessionId: string, reason = "closed"): Promise<void> {
|
||||
const s = this.state(sessionId);
|
||||
await this.withProfile(s, async () => {
|
||||
try {
|
||||
await this.request("DELETE", `/sessions/${encodeURIComponent(s.session.profileKey)}`);
|
||||
} catch (e) {
|
||||
if (!(e instanceof BrowserError && e.kind === "not_found")) throw e;
|
||||
}
|
||||
const at = this.now().toISOString();
|
||||
s.session.controller = "closed";
|
||||
s.session.closedAt = at;
|
||||
s.session.closeReason = reason;
|
||||
s.refsValid = false;
|
||||
for (const t of s.tabs.values())
|
||||
if (t.status === "open") {
|
||||
t.status = "closed";
|
||||
t.closedAt = at;
|
||||
t.updatedAt = at;
|
||||
}
|
||||
for (const l of this.leases.values())
|
||||
if (l.sessionId === sessionId && l.status === "active") {
|
||||
l.status = "revoked";
|
||||
l.releasedAt = at;
|
||||
l.releaseReason = "session_closed";
|
||||
}
|
||||
this.sessions.delete(sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
/** 本进程登记的未关闭会话;服务层以数据库为准,这里只是内存镜像 */
|
||||
async listSessions(): Promise<BrowserSession[]> {
|
||||
throw notImplemented("listSessions");
|
||||
return [...this.sessions.values()].map((s) => ({ ...s.session }));
|
||||
}
|
||||
|
||||
// ---------- 标签页 ----------
|
||||
|
||||
async openTab(
|
||||
sessionId: string,
|
||||
opts: { url?: string; budget: NavigationBudget; as?: ActorKind },
|
||||
): Promise<BrowserTab> {
|
||||
const s = this.state(sessionId);
|
||||
this.assertActor(s, opts.as);
|
||||
if (opts.url) assertNavigable(opts.url, opts.budget);
|
||||
return this.withProfile(s, async () => {
|
||||
this.assertActor(s, opts.as);
|
||||
const res = await this.request("POST", "/tabs", {
|
||||
body: { userId: s.session.profileKey, sessionKey: s.session.id, ...(opts.url ? { url: opts.url } : {}) },
|
||||
timeoutMs: opts.budget.timeoutMs,
|
||||
});
|
||||
const body = await this.json(res, "/tabs");
|
||||
const tabId = str(body.tabId);
|
||||
if (!tabId) throw new BrowserError("protocol", "camofox POST /tabs 未返回 tabId");
|
||||
const rawUrl = str(body.url) ?? opts.url ?? null;
|
||||
this.touch(s, rawUrl);
|
||||
const at = s.session.lastActivityAt;
|
||||
const tab: BrowserTab = {
|
||||
id: this.newId(),
|
||||
sessionId,
|
||||
upstreamTabId: tabId,
|
||||
url: s.session.currentUrl,
|
||||
title: null,
|
||||
status: "open",
|
||||
lastSnapshotAt: null,
|
||||
closedAt: null,
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
};
|
||||
s.tabs.set(tabId, tab);
|
||||
s.currentTabId = tabId;
|
||||
s.refsValid = false;
|
||||
s.budget = opts.budget;
|
||||
if (opts.url) this.assertLanded(rawUrl, opts.budget);
|
||||
return { ...tab };
|
||||
});
|
||||
}
|
||||
|
||||
async closeTab(ref: TabRef): Promise<void> {
|
||||
const s = this.state(ref.sessionId);
|
||||
this.assertActor(s, ref.as);
|
||||
await this.withProfile(s, async () => {
|
||||
const tab = this.tabOf(s, ref);
|
||||
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}`;
|
||||
try {
|
||||
await this.request("DELETE", path, { query: { userId: s.session.profileKey } });
|
||||
} catch (e) {
|
||||
if (!(e instanceof BrowserError && e.kind === "not_found")) throw e;
|
||||
}
|
||||
const at = this.now().toISOString();
|
||||
tab.status = "closed";
|
||||
tab.closedAt = at;
|
||||
tab.updatedAt = at;
|
||||
if (s.currentTabId === tab.upstreamTabId) {
|
||||
const next = [...s.tabs.values()].reverse().find((t) => t.status === "open");
|
||||
s.currentTabId = next?.upstreamTabId ?? "";
|
||||
s.rawUrl = next?.url ?? null;
|
||||
s.session.currentUrl = next?.url ?? null;
|
||||
}
|
||||
s.refsValid = false;
|
||||
this.touch(s);
|
||||
});
|
||||
}
|
||||
|
||||
/** GET /tabs?userId:上游已回收(空闲超时/上限淘汰/重启)的标签页标 lost,不再对它发动作 */
|
||||
async reconcileTabs(sessionId: string): Promise<{ alive: string[]; lost: string[] }> {
|
||||
const s = this.state(sessionId);
|
||||
return this.withProfile(s, async () => {
|
||||
const res = await this.request("GET", "/tabs", { query: { userId: s.session.profileKey } });
|
||||
const body = await this.json(res, "/tabs");
|
||||
const list = Array.isArray(body.tabs) ? (body.tabs as Json[]) : [];
|
||||
const upstream = new Set(list.map((t) => str(t.tabId) ?? str(t.targetId)).filter((x): x is string => !!x));
|
||||
const alive: string[] = [];
|
||||
const lost: string[] = [];
|
||||
const at = this.now().toISOString();
|
||||
for (const t of s.tabs.values()) {
|
||||
if (t.status !== "open") continue;
|
||||
if (upstream.has(t.upstreamTabId)) alive.push(t.upstreamTabId);
|
||||
else {
|
||||
t.status = "lost";
|
||||
t.updatedAt = at;
|
||||
lost.push(t.upstreamTabId);
|
||||
}
|
||||
}
|
||||
if (lost.length) s.refsValid = false;
|
||||
if (s.tabs.get(s.currentTabId)?.status !== "open") s.currentTabId = alive[0] ?? "";
|
||||
return { alive, lost };
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 页面 ----------
|
||||
|
||||
async navigate(ref: TabRef, url: string, budget: NavigationBudget): Promise<NavigationResult> {
|
||||
const s = this.state(ref.sessionId);
|
||||
this.assertAutomation(s);
|
||||
this.assertActor(s, ref.as);
|
||||
assertNavigable(url, budget);
|
||||
return this.withProfile(s, async () => {
|
||||
this.assertAutomation(s);
|
||||
this.assertActor(s, ref.as);
|
||||
const tab = this.tabOf(s, ref);
|
||||
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/navigate`;
|
||||
let attempts = 0;
|
||||
@@ -356,6 +583,8 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
tab.url = s.session.currentUrl;
|
||||
tab.updatedAt = s.session.updatedAt;
|
||||
s.budget = budget;
|
||||
s.currentTabId = tab.upstreamTabId;
|
||||
s.refsValid = false;
|
||||
this.assertLanded(rawUrl, budget);
|
||||
return {
|
||||
url: redactBrowserUrl(rawUrl),
|
||||
@@ -411,6 +640,8 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
const at = s.session.lastActivityAt;
|
||||
tab.lastSnapshotAt = at;
|
||||
tab.url = s.session.currentUrl;
|
||||
// 只有当前标签页的最新快照让引用生效
|
||||
if (tab.upstreamTabId === s.currentTabId && s.session.controller !== "closed") s.refsValid = true;
|
||||
const refsCount = int(body.refsCount) ?? new Set(text.match(/\be\d+\b/g) ?? []).size;
|
||||
return {
|
||||
sessionId: s.session.id,
|
||||
@@ -457,7 +688,7 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
const b64 = str(shot.data);
|
||||
if (!b64) throw new BrowserError("protocol", "camofox 截图响应缺少 data");
|
||||
try {
|
||||
data = Uint8Array.from(atob(b64.replace(/^data:[^,]*,/, "")), (c) => c.charCodeAt(0));
|
||||
data = b64decode(b64);
|
||||
} catch {
|
||||
throw new BrowserError("protocol", "camofox 截图不是合法 base64");
|
||||
}
|
||||
@@ -466,12 +697,7 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
if (data.byteLength > maxBytes) throw new BrowserError("budget_exceeded", `截图超出字节预算(${maxBytes})`);
|
||||
this.touch(s);
|
||||
const raw = s.rawUrl;
|
||||
let loginPage = false;
|
||||
try {
|
||||
loginPage = raw !== null && LOGIN_PATH.test(new URL(raw).pathname);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const loginPage = isLoginUrl(raw);
|
||||
return {
|
||||
mimeType,
|
||||
data,
|
||||
@@ -482,25 +708,265 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- M2 ----------
|
||||
// ---------- 交互 ----------
|
||||
|
||||
async click(_ref: TabRef, _target: ElementTarget): Promise<void> {
|
||||
throw notImplemented("click");
|
||||
/** 交互动作的公共路径:控制者校验 → 串行锁 → POST /tabs/{id}/<action> */
|
||||
private async act(ref: TabRef, action: string, body: (s: SessionState) => Json, after?: (s: SessionState) => void) {
|
||||
const s = this.state(ref.sessionId);
|
||||
this.assertActor(s, ref.as);
|
||||
const payload = body(s);
|
||||
await this.withProfile(s, async () => {
|
||||
this.assertActor(s, ref.as);
|
||||
const tab = this.tabOf(s, ref);
|
||||
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/${action}`;
|
||||
const res = await this.request("POST", path, {
|
||||
body: { userId: s.session.profileKey, ...payload },
|
||||
timeoutMs: s.budget.timeoutMs,
|
||||
});
|
||||
const out = await res.json().catch(() => ({}) as Json);
|
||||
const rawUrl = out && typeof out === "object" ? str((out as Json).url) : undefined;
|
||||
this.touch(s, rawUrl ?? s.rawUrl);
|
||||
if (rawUrl) tab.url = s.session.currentUrl;
|
||||
s.currentTabId = tab.upstreamTabId;
|
||||
after?.(s);
|
||||
});
|
||||
}
|
||||
async type(_ref: TabRef, _target: ElementTarget, _text: TypeInput): Promise<void> {
|
||||
throw notImplemented("type");
|
||||
|
||||
/** 点击可能跳转或改动页面:之后旧引用作废 */
|
||||
async click(ref: TabRef, target: ElementTarget): Promise<void> {
|
||||
await this.act(
|
||||
ref,
|
||||
"click",
|
||||
(s) => this.assertTarget(s, target),
|
||||
(s) => {
|
||||
s.refsValid = false;
|
||||
},
|
||||
);
|
||||
}
|
||||
async extract(_ref: TabRef, _schema: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
throw notImplemented("extract");
|
||||
|
||||
/** 凭证型输入在服务端解析后直接下发;明文不进日志、错误信息与返回值 */
|
||||
async type(ref: TabRef, target: ElementTarget | null, input: TypeInput): Promise<void> {
|
||||
let text: string;
|
||||
if (input.kind === "secret") {
|
||||
const v = await this.cfg.secrets.resolve({
|
||||
kind: "site_login",
|
||||
siteId: input.siteId,
|
||||
accountId: input.accountId,
|
||||
field: input.field,
|
||||
});
|
||||
if (!v)
|
||||
throw new BrowserError(
|
||||
"auth",
|
||||
`站点账号没有可用的${input.field === "username" ? "用户名" : input.field === "password" ? "密码" : "TOTP"}凭证`,
|
||||
);
|
||||
text = v;
|
||||
} else text = input.text;
|
||||
await this.act(
|
||||
ref,
|
||||
"type",
|
||||
(s) => ({
|
||||
...(target ? this.assertTarget(s, target) : {}),
|
||||
text,
|
||||
...(input.clear !== undefined ? { clear: input.clear } : {}),
|
||||
...(input.submit !== undefined ? { submit: input.submit } : {}),
|
||||
}),
|
||||
(s) => {
|
||||
if (input.submit) s.refsValid = false;
|
||||
},
|
||||
);
|
||||
}
|
||||
async waitForDownload(_ref: TabRef, _opts: { timeoutMs: number; maxBytes: number }): Promise<DownloadArtifact> {
|
||||
throw notImplemented("waitForDownload");
|
||||
|
||||
async select(ref: TabRef, target: ElementTarget, value: string): Promise<void> {
|
||||
await this.act(ref, "select", (s) => ({ ...this.assertTarget(s, target), value }));
|
||||
}
|
||||
async acquireControl(_req: AcquireControlRequest): Promise<BrowserControlLease> {
|
||||
throw notImplemented("acquireControl");
|
||||
|
||||
async scroll(ref: TabRef, opts: { direction: "up" | "down" | "left" | "right"; amount: number }): Promise<void> {
|
||||
await this.act(ref, "scroll", () => ({ direction: opts.direction, amount: opts.amount }));
|
||||
}
|
||||
async releaseControl(_leaseId: string, _reason?: string): Promise<void> {
|
||||
throw notImplemented("releaseControl");
|
||||
|
||||
/** 单个按键(Enter / Tab / Backspace …);Enter 可能提交表单,之后旧引用作废 */
|
||||
async press(ref: TabRef, key: string): Promise<void> {
|
||||
await this.act(
|
||||
ref,
|
||||
"press",
|
||||
() => ({ key }),
|
||||
(s) => {
|
||||
s.refsValid = false;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async history(
|
||||
ref: TabRef,
|
||||
action: "back" | "forward" | "reload",
|
||||
budget: NavigationBudget,
|
||||
): Promise<NavigationResult> {
|
||||
const s = this.state(ref.sessionId);
|
||||
this.assertActor(s, ref.as);
|
||||
return this.withProfile(s, async () => {
|
||||
this.assertActor(s, ref.as);
|
||||
const tab = this.tabOf(s, ref);
|
||||
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/${action === "reload" ? "refresh" : action}`;
|
||||
const res = await this.request("POST", path, {
|
||||
body: { userId: s.session.profileKey },
|
||||
timeoutMs: budget.timeoutMs,
|
||||
});
|
||||
const body = await this.json(res, path);
|
||||
const rawUrl = str(body.url) ?? s.rawUrl;
|
||||
this.touch(s, rawUrl);
|
||||
tab.url = s.session.currentUrl;
|
||||
s.refsValid = false;
|
||||
this.assertLanded(rawUrl, budget);
|
||||
return {
|
||||
url: rawUrl ? redactBrowserUrl(rawUrl) : "",
|
||||
httpStatus: int(body.httpStatus) ?? null,
|
||||
ok: body.ok !== false && body.navigationOk !== false,
|
||||
attempts: 1,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** POST /tabs/{id}/extract {schema}:属性 x-ref 指向快照元素,需先 snapshot;结果按快照同样规则脱敏 */
|
||||
async extract(ref: TabRef, schema: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
const s = this.state(ref.sessionId);
|
||||
return this.withProfile(s, async () => {
|
||||
if (!s.refsValid) throw new BrowserError("stale_ref", "提取依赖最新快照,先重新读取页面");
|
||||
const tab = this.tabOf(s, ref);
|
||||
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/extract`;
|
||||
const res = await this.request("POST", path, {
|
||||
body: { userId: s.session.profileKey, schema },
|
||||
timeoutMs: s.budget.timeoutMs,
|
||||
});
|
||||
const body = await this.json(res, path);
|
||||
const data = (body.data && typeof body.data === "object" && !Array.isArray(body.data) ? body.data : body) as Json;
|
||||
const red = redactSnapshot({ text: "", url: "", structure: data });
|
||||
this.touch(s);
|
||||
return (red.structure ?? {}) as Record<string, unknown>;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询 GET /tabs/{id}/downloads?includeData&consume&maxBytes,拿到第一个未见过的完成下载:
|
||||
* 字节交 downloadSink 暂存、算 sha256,只返回引用。超时报 timeout,超限报 budget_exceeded。
|
||||
*/
|
||||
async waitForDownload(ref: TabRef, opts: { timeoutMs: number; maxBytes: number }): Promise<DownloadArtifact> {
|
||||
const s = this.state(ref.sessionId);
|
||||
const deadline = Date.now() + opts.timeoutMs;
|
||||
const poll = this.cfg.downloadPollMs ?? 500;
|
||||
for (;;) {
|
||||
const found = await this.withProfile(s, async () => {
|
||||
const tab = this.tabOf(s, ref);
|
||||
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/downloads`;
|
||||
const res = await this.request("GET", path, {
|
||||
query: {
|
||||
userId: s.session.profileKey,
|
||||
includeData: "true",
|
||||
consume: "true",
|
||||
maxBytes: String(opts.maxBytes),
|
||||
},
|
||||
timeoutMs: Math.max(1_000, Math.min(this.cfg.timeoutMs ?? 30_000, deadline - Date.now() + 1_000)),
|
||||
});
|
||||
const body = (await res.json().catch(() => null)) as unknown;
|
||||
const list = (
|
||||
Array.isArray(body) ? body : Array.isArray((body as Json | null)?.downloads) ? (body as Json).downloads : []
|
||||
) as Json[];
|
||||
for (const d of list) {
|
||||
const key = str(d.id) ?? `${str(d.filename) ?? str(d.suggestedFilename)}|${str(d.url)}|${d.size}`;
|
||||
if (s.seenDownloads.has(key)) continue;
|
||||
const status = str(d.status) ?? str(d.state);
|
||||
if (status && !["completed", "complete", "done", "finished"].includes(status)) continue;
|
||||
const b64 = str(d.data) ?? str(d.base64) ?? str(d.content);
|
||||
const size = int(d.size) ?? int(d.bytes) ?? null;
|
||||
if (size !== null && size > opts.maxBytes)
|
||||
throw new BrowserError("budget_exceeded", `下载产物超出字节预算(${opts.maxBytes})`);
|
||||
if (!b64) {
|
||||
if (d.truncated === true || d.tooLarge === true)
|
||||
throw new BrowserError("budget_exceeded", `下载产物超出字节预算(${opts.maxBytes})`);
|
||||
continue;
|
||||
}
|
||||
let bytes: Uint8Array;
|
||||
try {
|
||||
bytes = b64decode(b64);
|
||||
} catch {
|
||||
throw new BrowserError("protocol", "camofox 下载内容不是合法 base64");
|
||||
}
|
||||
if (bytes.byteLength > opts.maxBytes)
|
||||
throw new BrowserError("budget_exceeded", `下载产物超出字节预算(${opts.maxBytes})`);
|
||||
s.seenDownloads.add(key);
|
||||
const id = this.newId();
|
||||
const rawName = (str(d.filename) ?? str(d.suggestedFilename) ?? str(d.name) ?? "download.bin").slice(0, 255);
|
||||
// 去掉路径分隔符与控制字符
|
||||
const filename = [...rawName].map((ch) => (ch < " " || ch === "/" || ch === "\\" ? "_" : ch)).join("");
|
||||
const mimeType = str(d.mimeType) ?? str(d.contentType) ?? null;
|
||||
const blob = await this.sink(bytes, { id, sessionId: s.session.id, filename, mimeType });
|
||||
const srcUrl = str(d.url);
|
||||
this.touch(s);
|
||||
return {
|
||||
id,
|
||||
sessionId: s.session.id,
|
||||
filename,
|
||||
mimeType,
|
||||
sizeBytes: bytes.byteLength,
|
||||
bytes: blob,
|
||||
sha256: await sha256Hex(bytes),
|
||||
sourceUrl: srcUrl ? redactBrowserUrl(srcUrl) : null,
|
||||
capturedAt: this.now().toISOString(),
|
||||
} satisfies DownloadArtifact;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (found) return found;
|
||||
if (Date.now() + poll > deadline) throw new BrowserError("timeout", `等待下载超时(${opts.timeoutMs}ms)`);
|
||||
await new Promise((ok) => setTimeout(ok, poll));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 控制权(内存版;服务层以 browser_control_lease 持久化) ----------
|
||||
|
||||
/** 接管:先把控制者切走(新的自动化输入立刻被拒),再等进行中的操作收尾,然后发放租约 */
|
||||
async acquireControl(req: AcquireControlRequest): Promise<BrowserControlLease> {
|
||||
const s = this.state(req.sessionId);
|
||||
if (s.session.controller === "closed") throw new BrowserError("control_conflict", "会话已关闭");
|
||||
for (const l of this.leases.values())
|
||||
if (l.sessionId === req.sessionId && l.status === "active") {
|
||||
if (l.holderKind === req.holderKind && l.holder === req.holder) return { ...l };
|
||||
if (l.holderKind === "human" && req.holderKind === "human")
|
||||
throw new BrowserError("control_conflict", "会话已被他人接管");
|
||||
l.status = "revoked";
|
||||
l.releasedAt = this.now().toISOString();
|
||||
l.releaseReason = `${req.holderKind}_takeover`;
|
||||
}
|
||||
this.setController(req.sessionId, req.holderKind);
|
||||
await this.drain(req.sessionId);
|
||||
const at = this.now();
|
||||
const lease: BrowserControlLease = {
|
||||
id: this.newId(),
|
||||
sessionId: req.sessionId,
|
||||
holderKind: req.holderKind,
|
||||
holder: req.holder,
|
||||
reason: req.reason,
|
||||
status: "active",
|
||||
acquiredAt: at.toISOString(),
|
||||
expiresAt: new Date(at.getTime() + req.ttlMs).toISOString(),
|
||||
releasedAt: null,
|
||||
releaseReason: null,
|
||||
createdAt: at.toISOString(),
|
||||
updatedAt: at.toISOString(),
|
||||
};
|
||||
this.leases.set(lease.id, lease);
|
||||
return { ...lease };
|
||||
}
|
||||
|
||||
/** 交还:人工租约释放后回到自动化,旧引用失效(Agent 必须重新读取页面) */
|
||||
async releaseControl(leaseId: string, reason = "released"): Promise<void> {
|
||||
const l = this.leases.get(leaseId);
|
||||
if (l?.status !== "active") throw new BrowserError("not_found", "租约不存在或已结束");
|
||||
const at = this.now().toISOString();
|
||||
l.status = "released";
|
||||
l.releasedAt = at;
|
||||
l.releaseReason = reason;
|
||||
l.updatedAt = at;
|
||||
if (this.sessions.has(l.sessionId) && l.holderKind === "human") this.setController(l.sessionId, "automation");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { NavigationBudget } from "@umt/contracts";
|
||||
import { buildFakeTorrent, createMockCamofox, MOCK_ROW_H, MOCK_ROW_Y0, type MockCamofox } from "@umt/testkit";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { BrowserError, createCamofoxBrowserProvider, memoryDownloadSink, staticBrowserSecrets } from "./index.ts";
|
||||
|
||||
// C1-E:camofox 适配器的交互、标签页、下载捕获与控制权(对 testkit 交互式 mock 站点验证,未连真实实例)。
|
||||
|
||||
const PROVIDER = "11111111-1111-4111-8111-111111111111";
|
||||
const SITE = "22222222-2222-4222-8222-222222222222";
|
||||
const ACCOUNT = "33333333-3333-4333-8333-333333333333";
|
||||
const ORIGIN = "https://audiences.example";
|
||||
const PASSWORD = "s3cret-login-pw";
|
||||
const PASSKEY = "fedcba9876543210fedcba9876543210";
|
||||
const TORRENT = buildFakeTorrent({ name: "Oppenheimer.2023.2160p.UHD.BluRay" });
|
||||
|
||||
const budget = (over: Partial<NavigationBudget> = {}): NavigationBudget => ({
|
||||
domainAllowlist: ["audiences.example"],
|
||||
timeoutMs: 500,
|
||||
maxRetries: 1,
|
||||
maxBytes: 1_000_000,
|
||||
...over,
|
||||
});
|
||||
|
||||
let mocks: MockCamofox[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(mocks.map((m) => m.close()));
|
||||
mocks = [];
|
||||
});
|
||||
|
||||
async function setup(opts: { captcha?: boolean; accessKey?: string } = {}) {
|
||||
const mock = await createMockCamofox({
|
||||
...(opts.accessKey ? { accessKey: opts.accessKey } : {}),
|
||||
site: {
|
||||
origin: ORIGIN,
|
||||
username: "fixture-user",
|
||||
password: PASSWORD,
|
||||
passkey: PASSKEY,
|
||||
...(opts.captcha ? { captcha: true, captchaAnswer: "4321" } : {}),
|
||||
releases: [{ id: "42", title: "Oppenheimer 2023 2160p UHD BluRay" }],
|
||||
torrent: () => TORRENT.bytes,
|
||||
},
|
||||
});
|
||||
mocks.push(mock);
|
||||
const sink = memoryDownloadSink();
|
||||
const provider = createCamofoxBrowserProvider({
|
||||
providerId: PROVIDER,
|
||||
baseUrl: mock.url,
|
||||
secrets: staticBrowserSecrets({
|
||||
...(opts.accessKey ? { [`provider:${PROVIDER}`]: opts.accessKey } : {}),
|
||||
[`site:${SITE}:${ACCOUNT}:username`]: "fixture-user",
|
||||
[`site:${SITE}:${ACCOUNT}:password`]: PASSWORD,
|
||||
}),
|
||||
timeoutMs: 500,
|
||||
downloadSink: sink,
|
||||
downloadPollMs: 20,
|
||||
});
|
||||
const { session, tab } = await provider.openSession({
|
||||
providerId: PROVIDER,
|
||||
siteId: SITE,
|
||||
accountId: ACCOUNT,
|
||||
budget: budget(),
|
||||
initialUrl: `${ORIGIN}/torrents.php`,
|
||||
});
|
||||
return { mock, provider, sink, session, tab, ref: { sessionId: session.id } };
|
||||
}
|
||||
|
||||
async function kind(p: Promise<unknown>): Promise<string> {
|
||||
try {
|
||||
await p;
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(BrowserError);
|
||||
return (e as BrowserError).kind;
|
||||
}
|
||||
throw new Error("预期抛错");
|
||||
}
|
||||
|
||||
type Setup = Awaited<ReturnType<typeof setup>>;
|
||||
async function login(x: Setup) {
|
||||
await x.provider.snapshot(x.ref);
|
||||
await x.provider.type(x.ref, { ref: "e2" }, { kind: "secret", siteId: SITE, accountId: ACCOUNT, field: "username" });
|
||||
await x.provider.type(x.ref, { ref: "e3" }, { kind: "secret", siteId: SITE, accountId: ACCOUNT, field: "password" });
|
||||
await x.provider.click(x.ref, { ref: "e6" });
|
||||
}
|
||||
|
||||
describe("登录与凭证注入", () => {
|
||||
it("登录失效落到登录页;凭证由服务端解析后下发,快照里的密码值被遮蔽", async () => {
|
||||
const x = await setup();
|
||||
const info = x.provider.sessionInfo(x.session.id)!;
|
||||
expect(info.loginPage).toBe(true);
|
||||
expect(info.session.currentUrl).toContain("/login.php");
|
||||
await x.provider.snapshot(x.ref);
|
||||
await x.provider.type(
|
||||
x.ref,
|
||||
{ ref: "e2" },
|
||||
{ kind: "secret", siteId: SITE, accountId: ACCOUNT, field: "username" },
|
||||
);
|
||||
await x.provider.type(
|
||||
x.ref,
|
||||
{ ref: "e3" },
|
||||
{ kind: "secret", siteId: SITE, accountId: ACCOUNT, field: "password" },
|
||||
);
|
||||
// 输入后页面上的密码框有值:快照进入模型前必须遮蔽
|
||||
const snap = await x.provider.snapshot(x.ref);
|
||||
expect(snap.text).not.toContain(PASSWORD);
|
||||
expect(snap.redactions.some((r) => r.kind === "password_field")).toBe(true);
|
||||
// 凭证确实到了浏览器
|
||||
expect(x.mock.actions.find((a) => a.action === "type" && a.body.ref === "e3")?.body.text).toBe(PASSWORD);
|
||||
await x.provider.click(x.ref, { ref: "e6" });
|
||||
expect(x.provider.sessionInfo(x.session.id)!.loginPage).toBe(false);
|
||||
expect(x.mock.loggedIn.has(x.session.profileKey)).toBe(true);
|
||||
});
|
||||
|
||||
it("账号没有该凭证 → auth,错误信息不含任何凭证", async () => {
|
||||
const x = await setup();
|
||||
await x.provider.snapshot(x.ref);
|
||||
const e = await x.provider
|
||||
.type(x.ref, { ref: "e3" }, { kind: "secret", siteId: SITE, accountId: null, field: "password" })
|
||||
.catch((err) => err as BrowserError);
|
||||
expect(e).toMatchObject({ kind: "auth" });
|
||||
expect(String((e as Error).message)).not.toContain(PASSWORD);
|
||||
});
|
||||
});
|
||||
|
||||
describe("元素引用与控制权", () => {
|
||||
it("导航/点击后旧引用失效(stale_ref),重新快照后可用;选择器与坐标不受限", async () => {
|
||||
const x = await setup();
|
||||
expect(await kind(x.provider.click(x.ref, { ref: "e6" }))).toBe("stale_ref");
|
||||
await x.provider.snapshot(x.ref);
|
||||
expect(x.provider.sessionInfo(x.session.id)!.refsValid).toBe(true);
|
||||
await x.provider.navigate(x.ref, `${ORIGIN}/`, budget());
|
||||
expect(await kind(x.provider.click(x.ref, { ref: "e2" }))).toBe("stale_ref");
|
||||
await x.provider.click(x.ref, { selector: "button[type=submit]" });
|
||||
expect(x.mock.actions.at(-1)).toMatchObject({ action: "click", body: { selector: "button[type=submit]" } });
|
||||
});
|
||||
|
||||
it("接管:人工期间自动化被拒、人工输入可用;交还后旧引用作废,需重新读取", async () => {
|
||||
const x = await setup();
|
||||
await x.provider.snapshot(x.ref);
|
||||
const lease = await x.provider.acquireControl({
|
||||
sessionId: x.session.id,
|
||||
holderKind: "human",
|
||||
holder: "admin",
|
||||
reason: "验证码",
|
||||
ttlMs: 60_000,
|
||||
});
|
||||
expect(lease.status).toBe("active");
|
||||
expect(await kind(x.provider.click(x.ref, { ref: "e6" }))).toBe("control_conflict");
|
||||
expect(
|
||||
await kind(
|
||||
x.provider.acquireControl({
|
||||
sessionId: x.session.id,
|
||||
holderKind: "human",
|
||||
holder: "other",
|
||||
reason: "x",
|
||||
ttlMs: 60_000,
|
||||
}),
|
||||
),
|
||||
).toBe("control_conflict");
|
||||
// 人工按坐标点用户名框(第 2 行),输入落到焦点元素
|
||||
const human = { ...x.ref, as: "human" as const };
|
||||
await x.provider.click(human, { x: 100, y: MOCK_ROW_Y0 + MOCK_ROW_H * 1 + 5 });
|
||||
await x.provider.type(human, null, { kind: "text", text: "fixture-user" });
|
||||
await x.provider.click(human, { x: 100, y: MOCK_ROW_Y0 + MOCK_ROW_H * 2 + 5 });
|
||||
await x.provider.type(human, null, { kind: "text", text: PASSWORD });
|
||||
await x.provider.press(human, "Enter");
|
||||
expect(x.mock.loggedIn.has(x.session.profileKey)).toBe(true);
|
||||
await x.provider.releaseControl(lease.id, "done");
|
||||
const info = x.provider.sessionInfo(x.session.id)!;
|
||||
expect(info.session.controller).toBe("automation");
|
||||
expect(info.refsValid).toBe(false);
|
||||
expect(await kind(x.provider.click(x.ref, { ref: "e11" }))).toBe("stale_ref");
|
||||
const snap = await x.provider.snapshot(x.ref);
|
||||
expect(snap.text).toContain("种子列表");
|
||||
expect(snap.text).not.toContain(PASSKEY);
|
||||
});
|
||||
|
||||
it("人工身份在自动化控制期间操作 → control_conflict(先接管)", async () => {
|
||||
const x = await setup();
|
||||
expect(await kind(x.provider.scroll({ ...x.ref, as: "human" }, { direction: "down", amount: 300 }))).toBe(
|
||||
"control_conflict",
|
||||
);
|
||||
await x.provider.scroll(x.ref, { direction: "down", amount: 300 });
|
||||
await x.provider.snapshot(x.ref);
|
||||
await x.provider.select(x.ref, { ref: "e2" }, "401");
|
||||
expect(x.mock.actions.map((a) => a.action)).toEqual(expect.arrayContaining(["scroll", "select"]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("标签页与历史", () => {
|
||||
it("新建/关闭标签页;上游回收的标签页对账标 lost", async () => {
|
||||
const x = await setup();
|
||||
const t2 = await x.provider.openTab(x.session.id, { url: `${ORIGIN}/`, budget: budget() });
|
||||
expect(x.provider.sessionInfo(x.session.id)!.currentTabId).toBe(t2.upstreamTabId);
|
||||
expect(await kind(x.provider.openTab(x.session.id, { url: "https://evil.example/", budget: budget() }))).toBe(
|
||||
"domain_blocked",
|
||||
);
|
||||
await x.provider.closeTab({ ...x.ref, tabId: t2.upstreamTabId });
|
||||
expect(x.mock.tabs.has(t2.upstreamTabId)).toBe(false);
|
||||
expect(x.provider.sessionInfo(x.session.id)!.currentTabId).toBe(x.tab.upstreamTabId);
|
||||
x.mock.dropTab(x.tab.upstreamTabId);
|
||||
const r = await x.provider.reconcileTabs(x.session.id);
|
||||
expect(r.lost).toEqual([x.tab.upstreamTabId]);
|
||||
expect(
|
||||
x.provider.sessionInfo(x.session.id)!.tabs.find((t) => t.upstreamTabId === x.tab.upstreamTabId)?.status,
|
||||
).toBe("lost");
|
||||
expect(await kind(x.provider.snapshot({ ...x.ref, tabId: x.tab.upstreamTabId }))).toBe("not_found");
|
||||
});
|
||||
|
||||
it("后退/前进/刷新", async () => {
|
||||
const x = await setup();
|
||||
await login(x);
|
||||
await x.provider.navigate(x.ref, `${ORIGIN}/details.php?id=42`, budget());
|
||||
const back = await x.provider.history(x.ref, "back", budget());
|
||||
expect(back.url).toContain("/torrents.php");
|
||||
const fwd = await x.provider.history(x.ref, "forward", budget());
|
||||
expect(fwd.url).toContain("/details.php");
|
||||
await x.provider.history(x.ref, "reload", budget());
|
||||
expect(x.mock.callsTo(/\/refresh$/)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("提取与下载捕获", () => {
|
||||
it("extract 按 x-ref 取字段,需先快照", async () => {
|
||||
const x = await setup();
|
||||
await login(x);
|
||||
const schema = { type: "object", properties: { title: { type: "string", "x-ref": "e10" } } };
|
||||
expect(await kind(x.provider.extract(x.ref, schema))).toBe("stale_ref");
|
||||
await x.provider.snapshot(x.ref);
|
||||
expect(await x.provider.extract(x.ref, schema)).toEqual({ title: "Oppenheimer 2023 2160p UHD BluRay" });
|
||||
});
|
||||
|
||||
it("点击下载 → 捕获 .torrent:字节进暂存、sha256 一致、来源地址去掉 passkey;只取一次", async () => {
|
||||
const x = await setup();
|
||||
await login(x);
|
||||
await x.provider.snapshot(x.ref);
|
||||
await x.provider.click(x.ref, { ref: "e11" });
|
||||
const a = await x.provider.waitForDownload(x.ref, { timeoutMs: 1_000, maxBytes: 5_000_000 });
|
||||
expect(a.filename).toMatch(/\.torrent$/);
|
||||
expect(a.sizeBytes).toBe(TORRENT.bytes.byteLength);
|
||||
expect(a.sha256).toBe(createHash("sha256").update(TORRENT.bytes).digest("hex"));
|
||||
expect(x.sink.blobs.get(a.bytes.ref)).toEqual(TORRENT.bytes);
|
||||
expect(a.sourceUrl).toContain("id=42");
|
||||
expect(a.sourceUrl).not.toContain(PASSKEY);
|
||||
expect(JSON.stringify(a)).not.toContain(PASSKEY);
|
||||
// 已消费:再等只会超时
|
||||
expect(await kind(x.provider.waitForDownload(x.ref, { timeoutMs: 100, maxBytes: 5_000_000 }))).toBe("timeout");
|
||||
});
|
||||
|
||||
it("下载超字节预算 → budget_exceeded", async () => {
|
||||
const x = await setup();
|
||||
await login(x);
|
||||
await x.provider.snapshot(x.ref);
|
||||
await x.provider.click(x.ref, { ref: "e11" });
|
||||
expect(await kind(x.provider.waitForDownload(x.ref, { timeoutMs: 500, maxBytes: 10 }))).toBe("budget_exceeded");
|
||||
});
|
||||
});
|
||||
|
||||
describe("异常路径", () => {
|
||||
it("交互时上游 401 → auth;超时 → timeout;不重试", async () => {
|
||||
const x = await setup({ accessKey: "k-1" });
|
||||
await x.provider.snapshot(x.ref);
|
||||
x.mock.fault(/\/click$/, { status: 401 });
|
||||
expect(await kind(x.provider.click(x.ref, { selector: "a" }))).toBe("auth");
|
||||
x.mock.fault(/\/type$/, { delayMs: 800 });
|
||||
expect(await kind(x.provider.type(x.ref, { selector: "input" }, { kind: "text", text: "x" }))).toBe("timeout");
|
||||
expect(x.mock.callsTo(/\/click$/)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("关闭会话:DELETE /sessions/{userId},上游标签页清空,之后动作报 not_found", async () => {
|
||||
const x = await setup();
|
||||
await x.provider.closeSession(x.session.id, "done");
|
||||
expect(x.mock.callsTo(/^\/sessions\//)[0]?.method).toBe("DELETE");
|
||||
expect(x.mock.tabs.size).toBe(0);
|
||||
expect(await x.provider.listSessions()).toEqual([]);
|
||||
expect(await kind(x.provider.snapshot(x.ref))).toBe("not_found");
|
||||
});
|
||||
|
||||
it("恢复会话(服务重启):引用失效,可继续读取", async () => {
|
||||
const x = await setup();
|
||||
const info = x.provider.sessionInfo(x.session.id)!;
|
||||
const fresh = createCamofoxBrowserProvider({
|
||||
providerId: PROVIDER,
|
||||
baseUrl: x.mock.url,
|
||||
secrets: staticBrowserSecrets({}),
|
||||
timeoutMs: 500,
|
||||
});
|
||||
fresh.restoreSession({ session: info.session, tabs: info.tabs, currentTabId: info.currentTabId, budget: budget() });
|
||||
expect(fresh.sessionInfo(x.session.id)!.refsValid).toBe(false);
|
||||
expect(await kind(fresh.click(x.ref, { ref: "e6" }))).toBe("stale_ref");
|
||||
expect((await fresh.snapshot(x.ref)).text).toContain("登录");
|
||||
});
|
||||
});
|
||||
@@ -42,5 +42,20 @@ export function createNoneBrowserProvider(): BrowserProvider {
|
||||
waitForDownload: unavailable,
|
||||
acquireControl: unavailable,
|
||||
releaseControl: unavailable,
|
||||
openTab: unavailable,
|
||||
closeTab: unavailable,
|
||||
history: unavailable,
|
||||
select: unavailable,
|
||||
scroll: unavailable,
|
||||
press: unavailable,
|
||||
setController() {
|
||||
throw new BrowserError("unavailable", "未配置浏览器服务");
|
||||
},
|
||||
restoreSession() {
|
||||
throw new BrowserError("unavailable", "未配置浏览器服务");
|
||||
},
|
||||
reconcileTabs: unavailable,
|
||||
sessionInfo: () => null,
|
||||
drain: async () => {},
|
||||
};
|
||||
}
|
||||
@@ -35,12 +35,51 @@ export interface BrowserProvider {
|
||||
screenshot(ref: TabRef, opts?: { maxBytes?: number }): Promise<BrowserScreenshot>;
|
||||
click(ref: TabRef, target: ElementTarget): Promise<void>;
|
||||
/** text 为明文或凭证引用;凭证引用由服务端解析后直接下发,不进入模型与日志 */
|
||||
type(ref: TabRef, target: ElementTarget, text: TypeInput): Promise<void>;
|
||||
type(ref: TabRef, target: ElementTarget | null, text: TypeInput): Promise<void>;
|
||||
extract(ref: TabRef, schema: Record<string, unknown>): Promise<Record<string, unknown>>;
|
||||
waitForDownload(ref: TabRef, opts: { timeoutMs: number; maxBytes: number }): Promise<DownloadArtifact>;
|
||||
|
||||
acquireControl(req: AcquireControlRequest): Promise<BrowserControlLease>;
|
||||
releaseControl(leaseId: string, reason?: string): Promise<void>;
|
||||
|
||||
// ── C1-E:标签页、历史导航、其余交互与状态同步 ──
|
||||
openTab(sessionId: string, opts: { url?: string; budget: NavigationBudget; as?: ActorKind }): Promise<BrowserTab>;
|
||||
closeTab(ref: TabRef): Promise<void>;
|
||||
/** 后退/前进/刷新(camofox /back /forward /refresh) */
|
||||
history(ref: TabRef, action: "back" | "forward" | "reload", budget: NavigationBudget): Promise<NavigationResult>;
|
||||
select(ref: TabRef, target: ElementTarget, value: string): Promise<void>;
|
||||
scroll(ref: TabRef, opts: { direction: "up" | "down" | "left" | "right"; amount: number }): Promise<void>;
|
||||
press(ref: TabRef, key: string): Promise<void>;
|
||||
/** 服务层把控制权变更同步给适配器;任何变更都让旧元素引用失效 */
|
||||
setController(sessionId: string, controller: BrowserSession["controller"]): void;
|
||||
/** 服务重启后按数据库记录恢复内存状态(旧元素引用一律失效) */
|
||||
restoreSession(state: RestoreSessionState): void;
|
||||
/** 对账:上游已不存在的标签页标 lost */
|
||||
reconcileTabs(sessionId: string): Promise<{ alive: string[]; lost: string[] }>;
|
||||
/** 会话内存状态摘要(不含未脱敏地址) */
|
||||
sessionInfo(sessionId: string): BrowserSessionInfo | null;
|
||||
/** 等待进行中的操作收尾(接管前调用) */
|
||||
drain(sessionId: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** 动作发起方:自动化(Agent/任务)或持有接管租约的人 */
|
||||
export type ActorKind = "automation" | "human";
|
||||
|
||||
export interface RestoreSessionState {
|
||||
session: BrowserSession;
|
||||
tabs: BrowserTab[];
|
||||
currentTabId: string | null;
|
||||
budget: NavigationBudget;
|
||||
}
|
||||
|
||||
export interface BrowserSessionInfo {
|
||||
session: BrowserSession;
|
||||
tabs: BrowserTab[];
|
||||
currentTabId: string | null;
|
||||
/** 最近一次快照之后没有导航/交接:eN 引用可用 */
|
||||
refsValid: boolean;
|
||||
/** 当前页面是登录页 */
|
||||
loginPage: boolean;
|
||||
}
|
||||
|
||||
export interface OpenSessionRequest {
|
||||
@@ -64,6 +103,8 @@ export interface OpenSessionResult {
|
||||
export interface TabRef {
|
||||
sessionId: string;
|
||||
tabId?: string;
|
||||
/** 动作发起方,缺省 automation;与会话 controller 不符时报 control_conflict */
|
||||
as?: ActorKind;
|
||||
}
|
||||
|
||||
export interface NavigationResult {
|
||||
@@ -95,11 +136,19 @@ export interface BrowserScreenshot {
|
||||
capturedAt: string;
|
||||
}
|
||||
|
||||
export type ElementTarget = { ref: string } | { selector: string };
|
||||
/** 元素定位:快照引用 / CSS 选择器 / 视口像素坐标(原站视图点击;camofox 坐标点击待核验) */
|
||||
export type ElementTarget = { ref: string } | { selector: string } | { x: number; y: number };
|
||||
|
||||
export type TypeInput =
|
||||
| { kind: "text"; text: string }
|
||||
| { kind: "secret"; siteId: string; accountId: string | null; field: "username" | "password" | "totp" };
|
||||
| { kind: "text"; text: string; submit?: boolean; clear?: boolean }
|
||||
| {
|
||||
kind: "secret";
|
||||
siteId: string;
|
||||
accountId: string | null;
|
||||
field: "username" | "password" | "totp";
|
||||
submit?: boolean;
|
||||
clear?: boolean;
|
||||
};
|
||||
|
||||
export interface AcquireControlRequest {
|
||||
sessionId: string;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
import * as agent from "./agent.ts";
|
||||
import * as assets from "./assets.ts";
|
||||
import * as auth from "./auth.ts";
|
||||
import * as browser from "./browser.ts";
|
||||
import { CapabilityDescriptor, KnownCapabilityId } from "./capability.ts";
|
||||
import { type ApiScope, CursorQuery, cursorPage, HealthStatus, Id, IdParam } from "./common.ts";
|
||||
import * as content from "./content.ts";
|
||||
@@ -2008,6 +2009,167 @@ export const Api = {
|
||||
}),
|
||||
},
|
||||
|
||||
// ── 浏览器(C1-E):站点浏览器会话、原站视图、人工接管;写动作需人工持有控制权(接管) ──
|
||||
browser: {
|
||||
providers: ep({
|
||||
method: "get",
|
||||
path: "/browser/providers",
|
||||
scope: "admin",
|
||||
summary: "浏览器服务列表(地址、凭证引用、启用、能力 supported/verified);probe=true 现场探测并写回",
|
||||
query: browser.BrowserProvidersQuery,
|
||||
response: z.object({ items: z.array(browser.BrowserProviderView) }),
|
||||
}),
|
||||
sessions: ep({
|
||||
method: "get",
|
||||
path: "/browser/sessions",
|
||||
scope: "admin",
|
||||
summary: "浏览器会话列表(以数据库为准;默认只列未关闭的)",
|
||||
query: browser.BrowserSessionsQuery,
|
||||
response: z.object({ items: z.array(browser.BrowserSessionView) }),
|
||||
}),
|
||||
open: ep({
|
||||
method: "post",
|
||||
path: "/browser/sessions",
|
||||
scope: "admin",
|
||||
summary: "打开站点浏览器会话(同一站点/账号已有未关闭会话时直接返回它)",
|
||||
body: browser.BrowserSessionOpen,
|
||||
response: browser.BrowserSessionView,
|
||||
}),
|
||||
get: ep({
|
||||
method: "get",
|
||||
path: "/browser/sessions/{id}",
|
||||
scope: "admin",
|
||||
summary: "浏览器会话详情(控制者、标签页、租约、是否需重新读取页面)",
|
||||
params: browser.BrowserSessionParams,
|
||||
response: browser.BrowserSessionView,
|
||||
}),
|
||||
close: ep({
|
||||
method: "delete",
|
||||
path: "/browser/sessions/{id}",
|
||||
scope: "admin",
|
||||
summary: "关闭浏览器会话(关闭上游标签页,撤销租约)",
|
||||
params: browser.BrowserSessionParams,
|
||||
response: browser.BrowserSessionView,
|
||||
}),
|
||||
openTab: ep({
|
||||
method: "post",
|
||||
path: "/browser/sessions/{id}/tabs",
|
||||
scope: "admin",
|
||||
summary: "新建标签页并设为当前(需持有控制权)",
|
||||
params: browser.BrowserSessionParams,
|
||||
body: browser.BrowserTabOpen,
|
||||
response: browser.BrowserSessionView,
|
||||
}),
|
||||
closeTab: ep({
|
||||
method: "delete",
|
||||
path: "/browser/sessions/{id}/tabs/{tabId}",
|
||||
scope: "admin",
|
||||
summary: "关闭标签页(需持有控制权)",
|
||||
params: browser.BrowserTabParams,
|
||||
response: browser.BrowserSessionView,
|
||||
}),
|
||||
navigate: ep({
|
||||
method: "post",
|
||||
path: "/browser/sessions/{id}/navigate",
|
||||
scope: "admin",
|
||||
summary: "地址栏导航或后退/前进/刷新(需持有控制权;限站点域名)",
|
||||
params: browser.BrowserSessionParams,
|
||||
body: browser.BrowserNavigate,
|
||||
response: browser.BrowserNavigation,
|
||||
}),
|
||||
snapshot: ep({
|
||||
method: "get",
|
||||
path: "/browser/sessions/{id}/snapshot",
|
||||
scope: "admin",
|
||||
summary: "结构化页面快照(凭证已遮蔽)",
|
||||
params: browser.BrowserSessionParams,
|
||||
query: browser.BrowserSnapshotQuery,
|
||||
response: browser.BrowserSnapshot,
|
||||
}),
|
||||
screenshot: ep({
|
||||
method: "get",
|
||||
path: "/browser/sessions/{id}/screenshot",
|
||||
scope: "admin",
|
||||
summary: "当前画面截图(base64,只给原站视图,不进模型)",
|
||||
params: browser.BrowserSessionParams,
|
||||
query: browser.BrowserScreenshotQuery,
|
||||
response: browser.BrowserScreenshotView,
|
||||
}),
|
||||
click: ep({
|
||||
method: "post",
|
||||
path: "/browser/sessions/{id}/click",
|
||||
scope: "admin",
|
||||
summary: "点击(快照引用 / 选择器 / 截图坐标;需持有控制权)",
|
||||
params: browser.BrowserSessionParams,
|
||||
body: browser.BrowserClick,
|
||||
response: browser.BrowserActionResult,
|
||||
}),
|
||||
type: ep({
|
||||
method: "post",
|
||||
path: "/browser/sessions/{id}/type",
|
||||
scope: "admin",
|
||||
summary: "输入文本 / 站点账号凭证 / 按键(需持有控制权;凭证由服务端注入)",
|
||||
params: browser.BrowserSessionParams,
|
||||
body: browser.BrowserType,
|
||||
response: browser.BrowserActionResult,
|
||||
}),
|
||||
scroll: ep({
|
||||
method: "post",
|
||||
path: "/browser/sessions/{id}/scroll",
|
||||
scope: "admin",
|
||||
summary: "滚动页面(需持有控制权)",
|
||||
params: browser.BrowserSessionParams,
|
||||
body: browser.BrowserScroll,
|
||||
response: browser.BrowserActionResult,
|
||||
}),
|
||||
select: ep({
|
||||
method: "post",
|
||||
path: "/browser/sessions/{id}/select",
|
||||
scope: "admin",
|
||||
summary: "下拉框选择(需持有控制权)",
|
||||
params: browser.BrowserSessionParams,
|
||||
body: browser.BrowserSelect,
|
||||
response: browser.BrowserActionResult,
|
||||
}),
|
||||
extract: ep({
|
||||
method: "post",
|
||||
path: "/browser/sessions/{id}/extract",
|
||||
scope: "admin",
|
||||
summary: "按 JSON Schema(x-ref)提取页面字段",
|
||||
params: browser.BrowserSessionParams,
|
||||
body: browser.BrowserExtract,
|
||||
response: browser.BrowserExtractResult,
|
||||
}),
|
||||
download: ep({
|
||||
method: "post",
|
||||
path: "/browser/sessions/{id}/download",
|
||||
scope: "organize.execute",
|
||||
summary:
|
||||
"捕获浏览器下载 → 后端暂存并核验 infohash、匹配本地资源;给出 submit 时经 POST /acquisition-plans 同一流程提交",
|
||||
params: browser.BrowserSessionParams,
|
||||
body: browser.BrowserDownload,
|
||||
response: browser.BrowserDownloadResult,
|
||||
}),
|
||||
acquire: ep({
|
||||
method: "post",
|
||||
path: "/browser/sessions/{id}/control/acquire",
|
||||
scope: "admin",
|
||||
summary: "人工接管:先停止该会话新的自动化输入并等进行中的操作收尾,再发放人工租约",
|
||||
params: browser.BrowserSessionParams,
|
||||
body: browser.BrowserControlAcquire,
|
||||
response: browser.BrowserControlResult,
|
||||
}),
|
||||
release: ep({
|
||||
method: "post",
|
||||
path: "/browser/sessions/{id}/control/release",
|
||||
scope: "admin",
|
||||
summary: "交还控制权:Agent 恢复前必须重新读取页面,旧元素引用失效",
|
||||
params: browser.BrowserSessionParams,
|
||||
body: browser.BrowserControlRelease,
|
||||
response: browser.BrowserControlResult,
|
||||
}),
|
||||
},
|
||||
|
||||
// ── 定时任务看板(W7-C):统一注册表 + Agent 巡检;全部只接受控制台会话 ──
|
||||
schedules: {
|
||||
list: ep({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { EntityBase, Id, JsonObject, NonNegInt, Timestamp } from "./common.ts";
|
||||
import { ManualAcquisitionResult } from "./execution.ts";
|
||||
import { InstanceHealth } from "./instances.ts";
|
||||
|
||||
// 浏览器能力合同(REQ §4.4、§4.7.2–4.7.4、§12 浏览器实体)。
|
||||
@@ -192,6 +193,8 @@ export const DownloadArtifact = z.object({
|
||||
bytes: BlobRef,
|
||||
/** 十六进制 sha256 */
|
||||
sha256: z.string().min(1),
|
||||
/** 已脱敏的下载来源地址(上游提供时) */
|
||||
sourceUrl: z.string().nullable().optional(),
|
||||
capturedAt: Timestamp,
|
||||
});
|
||||
export type DownloadArtifact = z.infer<typeof DownloadArtifact>;
|
||||
@@ -219,5 +222,221 @@ export const BrowserErrorKind = z.enum([
|
||||
"budget_exceeded",
|
||||
"control_conflict",
|
||||
"lock_timeout",
|
||||
/** 元素引用来自导航/交接之前的快照,已失效:先重新读取页面 */
|
||||
"stale_ref",
|
||||
]);
|
||||
export type BrowserErrorKind = z.infer<typeof BrowserErrorKind>;
|
||||
|
||||
// ── C1-E:浏览器 API 与 Agent 能力的请求/响应(docs/runbook/browser.md) ──
|
||||
|
||||
/** 查询串布尔 */
|
||||
const QueryBool = z.enum(["true", "false"]).optional();
|
||||
|
||||
/** 浏览器服务(GET /browser/providers);凭证只给引用是否存在,不给明文 */
|
||||
export const BrowserProviderView = z.object({
|
||||
id: Id,
|
||||
name: z.string(),
|
||||
kind: BrowserProviderKind,
|
||||
baseUrl: z.string().nullable(),
|
||||
/** 访问凭据的 secret 引用(明文永不出 API) */
|
||||
secretRef: Id.nullable(),
|
||||
enabled: z.boolean(),
|
||||
/** 最近一次声明/探测到的能力;verified 只由人工核验写入 config.verified */
|
||||
capabilities: BrowserCapabilities.nullable(),
|
||||
verifiedAt: Timestamp.nullable(),
|
||||
healthStatus: InstanceHealth,
|
||||
lastHealthAt: Timestamp.nullable(),
|
||||
/** probe=true 时的即时健康结果 */
|
||||
health: BrowserHealth.nullable(),
|
||||
});
|
||||
export type BrowserProviderView = z.infer<typeof BrowserProviderView>;
|
||||
|
||||
export const BrowserProvidersQuery = z.object({
|
||||
/** true = 现场探测 /health 与 /openapi.json 并写回 */
|
||||
probe: QueryBool,
|
||||
});
|
||||
export type BrowserProvidersQuery = z.infer<typeof BrowserProvidersQuery>;
|
||||
|
||||
/** 会话视图:会话 + 标签页 + 当前有效租约 + 界面可用能力 */
|
||||
export const BrowserSessionView = BrowserSession.extend({
|
||||
tabs: z.array(BrowserTab),
|
||||
currentTabId: z.string().nullable(),
|
||||
lease: BrowserControlLease.nullable(),
|
||||
/** 控制权交还后到重新读取页面之前为 true:旧元素引用一律失效 */
|
||||
needsSnapshot: z.boolean(),
|
||||
/** 等待处理/接管原因(login_required / captcha / human_takeover / lease_expired …) */
|
||||
waitingReason: z.string().nullable(),
|
||||
});
|
||||
export type BrowserSessionView = z.infer<typeof BrowserSessionView>;
|
||||
|
||||
export const BrowserSessionOpen = z.object({
|
||||
siteId: Id,
|
||||
accountId: Id.nullable().optional(),
|
||||
/** 缺省取第一个启用的浏览器服务 */
|
||||
providerId: Id.optional(),
|
||||
/** 缺省打开站点首页;必须在站点域名内 */
|
||||
url: z.url().optional(),
|
||||
});
|
||||
export type BrowserSessionOpen = z.infer<typeof BrowserSessionOpen>;
|
||||
|
||||
export const BrowserSessionsQuery = z.object({
|
||||
siteId: Id.optional(),
|
||||
includeClosed: QueryBool,
|
||||
});
|
||||
export type BrowserSessionsQuery = z.infer<typeof BrowserSessionsQuery>;
|
||||
|
||||
export const BrowserSessionParams = z.object({ id: Id });
|
||||
export const BrowserTabParams = z.object({ id: Id, tabId: z.string().min(1).max(200) });
|
||||
|
||||
export const BrowserTabOpen = z.object({ url: z.url().optional() });
|
||||
|
||||
/** 导航:地址栏(url)或导航按钮(action),二选一(url 优先) */
|
||||
export const BrowserNavigate = z.object({
|
||||
tabId: z.string().min(1).optional(),
|
||||
url: z.url().max(4000).optional(),
|
||||
action: z.enum(["back", "forward", "reload"]).optional(),
|
||||
});
|
||||
export type BrowserNavigate = z.infer<typeof BrowserNavigate>;
|
||||
|
||||
export const BrowserNavigation = z.object({
|
||||
/** 已脱敏的最终地址 */
|
||||
url: z.string(),
|
||||
httpStatus: z.number().int().nullable(),
|
||||
ok: z.boolean(),
|
||||
attempts: z.number().int().min(0),
|
||||
/** 落在登录页(登录失效) */
|
||||
loginRequired: z.boolean(),
|
||||
session: BrowserSessionView,
|
||||
});
|
||||
export type BrowserNavigation = z.infer<typeof BrowserNavigation>;
|
||||
|
||||
export const BrowserSnapshotQuery = z.object({
|
||||
tabId: z.string().min(1).optional(),
|
||||
offset: z.coerce.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
export const BrowserScreenshotQuery = z.object({ tabId: z.string().min(1).optional() });
|
||||
|
||||
/** 截图:base64 直接给原站视图;不落盘、不进模型(modelSafe 标注) */
|
||||
export const BrowserScreenshotView = z.object({
|
||||
mimeType: z.string(),
|
||||
dataBase64: z.string(),
|
||||
/** 图片像素尺寸(PNG/JPEG 头解析;解析不出为 null) */
|
||||
width: z.number().int().nullable(),
|
||||
height: z.number().int().nullable(),
|
||||
url: z.string().nullable(),
|
||||
modelSafe: z.boolean(),
|
||||
capturedAt: Timestamp,
|
||||
controller: BrowserController,
|
||||
});
|
||||
export type BrowserScreenshotView = z.infer<typeof BrowserScreenshotView>;
|
||||
|
||||
/** 元素定位:快照引用 / CSS 选择器 / 截图像素坐标(原站视图点击) */
|
||||
export const BrowserTarget = z.union([
|
||||
/** 快照元素引用,形如 e12 */
|
||||
z.object({ ref: z.string().min(1).max(40) }),
|
||||
z.object({ selector: z.string().min(1).max(500) }),
|
||||
z.object({ x: z.number().min(0), y: z.number().min(0) }),
|
||||
]);
|
||||
export type BrowserTarget = z.infer<typeof BrowserTarget>;
|
||||
|
||||
export const BrowserClick = z.object({ tabId: z.string().min(1).optional(), target: BrowserTarget });
|
||||
export type BrowserClick = z.infer<typeof BrowserClick>;
|
||||
|
||||
/** 输入:按键(key)> 站点账号凭证字段(credential,服务端解析后直接下发)> 明文(text);target 缺省 = 当前焦点 */
|
||||
export const BrowserType = z.object({
|
||||
tabId: z.string().min(1).optional(),
|
||||
target: BrowserTarget.optional(),
|
||||
text: z.string().max(4000).optional(),
|
||||
credential: z.enum(["username", "password", "totp"]).optional(),
|
||||
key: z.string().min(1).max(40).optional(),
|
||||
submit: z.boolean().optional(),
|
||||
clear: z.boolean().optional(),
|
||||
});
|
||||
export type BrowserType = z.infer<typeof BrowserType>;
|
||||
|
||||
export const BrowserScroll = z.object({
|
||||
tabId: z.string().min(1).optional(),
|
||||
direction: z.enum(["up", "down", "left", "right"]),
|
||||
amount: z.number().int().min(1).max(10_000).default(600),
|
||||
});
|
||||
export type BrowserScroll = z.infer<typeof BrowserScroll>;
|
||||
|
||||
export const BrowserSelect = z.object({
|
||||
tabId: z.string().min(1).optional(),
|
||||
target: BrowserTarget,
|
||||
value: z.string().max(500),
|
||||
});
|
||||
export type BrowserSelect = z.infer<typeof BrowserSelect>;
|
||||
|
||||
export const BrowserActionResult = z.object({ ok: z.literal(true), session: BrowserSessionView });
|
||||
export type BrowserActionResult = z.infer<typeof BrowserActionResult>;
|
||||
|
||||
export const BrowserExtract = z.object({
|
||||
tabId: z.string().min(1).optional(),
|
||||
/** JSON Schema;属性用 x-ref 指向快照元素(camofox /extract) */
|
||||
schema: JsonObject,
|
||||
});
|
||||
export const BrowserExtractResult = z.object({ data: JsonObject });
|
||||
|
||||
/** 捕获下载:可先点击 target 触发;submit 给出时经 POST /acquisition-plans 同一流程提交 */
|
||||
export const BrowserDownload = z.object({
|
||||
tabId: z.string().min(1).optional(),
|
||||
target: BrowserTarget.optional(),
|
||||
timeoutMs: z.number().int().min(500).max(120_000).default(15_000),
|
||||
submit: z
|
||||
.object({ slotId: Id.optional(), entityId: Id.optional(), note: z.string().max(2000).optional() })
|
||||
.optional(),
|
||||
});
|
||||
export type BrowserDownload = z.infer<typeof BrowserDownload>;
|
||||
|
||||
/** 暂存核验结果:种子解析出的 infohash、与本地资源的匹配 */
|
||||
export const BrowserDownloadVerification = z.object({
|
||||
valid: z.boolean(),
|
||||
infohashV1: z.string().nullable(),
|
||||
infohashV2: z.string().nullable(),
|
||||
name: z.string().nullable(),
|
||||
totalSize: NonNegInt.nullable(),
|
||||
private: z.boolean().nullable(),
|
||||
error: z.string().nullable(),
|
||||
});
|
||||
export type BrowserDownloadVerification = z.infer<typeof BrowserDownloadVerification>;
|
||||
|
||||
export const BrowserDownloadResult = z.object({
|
||||
artifact: DownloadArtifact,
|
||||
verification: BrowserDownloadVerification,
|
||||
/** 按 infohash / 站点资源 ID 匹配到的本地资源;null = 不在本地索引 */
|
||||
sourceReleaseId: Id.nullable(),
|
||||
/** 同一会话已捕获过同一文件(sha256 相同):不重复提交 */
|
||||
duplicate: z.boolean(),
|
||||
/** submit 给出且匹配到资源时的提交结果(与 POST /acquisition-plans 相同) */
|
||||
submission: ManualAcquisitionResult.nullable(),
|
||||
note: z.string().nullable(),
|
||||
});
|
||||
export type BrowserDownloadResult = z.infer<typeof BrowserDownloadResult>;
|
||||
|
||||
export const BrowserControlAcquire = z.object({
|
||||
reason: z.string().min(1).max(200).default("人工接管"),
|
||||
/** 租约时长;到期未交还 → 会话转"等待处理",不自动恢复自动化 */
|
||||
ttlMs: z
|
||||
.number()
|
||||
.int()
|
||||
.min(60_000)
|
||||
.max(4 * 3_600_000)
|
||||
.default(30 * 60_000),
|
||||
});
|
||||
export type BrowserControlAcquire = z.infer<typeof BrowserControlAcquire>;
|
||||
|
||||
export const BrowserControlRelease = z.object({
|
||||
leaseId: Id.optional(),
|
||||
reason: z.string().min(1).max(200).default("人工交还"),
|
||||
/** 交还给自动化(缺省)或留在等待处理 */
|
||||
to: z.enum(["automation", "waiting"]).default("automation"),
|
||||
});
|
||||
export type BrowserControlRelease = z.infer<typeof BrowserControlRelease>;
|
||||
|
||||
export const BrowserControlResult = z.object({
|
||||
lease: BrowserControlLease.nullable(),
|
||||
session: BrowserSessionView,
|
||||
});
|
||||
export type BrowserControlResult = z.infer<typeof BrowserControlResult>;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { MediaAsset } from "./assets.ts";
|
||||
import { BrowserController, BrowserDownloadVerification, BrowserSnapshot } from "./browser.ts";
|
||||
import { CAPABILITY_CATALOG, type CapabilitySpec, type KnownCapabilityId } from "./capability.ts";
|
||||
import { Id, JsonObject, NonNegInt } from "./common.ts";
|
||||
import { MediaEntity, MediaKind } from "./content.ts";
|
||||
@@ -318,6 +319,93 @@ export const SiteUserDataOutput = z.object({
|
||||
history: z.array(SiteUserDataPoint),
|
||||
});
|
||||
|
||||
// C1-E 浏览器能力:会话按站点/账号隔离;元素引用只来自最近一次 browser.snapshot,
|
||||
// 控制权交还或导航后旧引用失效;凭证用 credential 字段由服务端注入,不进入模型。
|
||||
const BrowserAgentTarget = z.union([
|
||||
z.object({ ref: z.string().min(1).max(40) }),
|
||||
z.object({ selector: z.string().min(1).max(500) }),
|
||||
]);
|
||||
|
||||
export const BrowserSnapshotInput = z
|
||||
.object({
|
||||
/** 会话 ID;缺省用 siteId 对应的未关闭会话 */
|
||||
sessionId: Id.optional(),
|
||||
siteId: Id.optional(),
|
||||
tabId: z.string().min(1).optional(),
|
||||
offset: z.number().int().min(0).optional(),
|
||||
})
|
||||
.refine((v) => !!v.sessionId || !!v.siteId, { message: "sessionId 与 siteId 至少给一个" });
|
||||
export const BrowserSnapshotOutput = z.object({
|
||||
snapshot: BrowserSnapshot,
|
||||
controller: BrowserController,
|
||||
/** 会话在等待处理(验证码 / 二次认证 / 登录失效)时的原因;此时自动化动作被拒,需人工在原站视图处理 */
|
||||
waitingReason: z.string().nullable(),
|
||||
loginRequired: z.boolean(),
|
||||
});
|
||||
|
||||
export const BrowserNavigateInput = z
|
||||
.object({
|
||||
sessionId: Id.optional(),
|
||||
/** 无会话时按站点(和账号)打开一个 */
|
||||
siteId: Id.optional(),
|
||||
accountId: Id.optional(),
|
||||
tabId: z.string().min(1).optional(),
|
||||
/** 必须在站点域名内 */
|
||||
url: z.url().max(4000),
|
||||
})
|
||||
.refine((v) => !!v.sessionId || !!v.siteId, { message: "sessionId 与 siteId 至少给一个" });
|
||||
export const BrowserNavigateOutput = z.object({
|
||||
sessionId: Id,
|
||||
url: z.string(),
|
||||
httpStatus: z.number().int().nullable(),
|
||||
ok: z.boolean(),
|
||||
attempts: z.number().int(),
|
||||
loginRequired: z.boolean(),
|
||||
controller: BrowserController,
|
||||
});
|
||||
|
||||
export const BrowserInteractInput = z.object({
|
||||
sessionId: Id,
|
||||
tabId: z.string().min(1).optional(),
|
||||
/** request_human:转"等待处理",由人在原站视图接管(验证码、二次认证等) */
|
||||
action: z.enum(["click", "type", "select", "scroll", "press", "request_human"]),
|
||||
target: BrowserAgentTarget.optional(),
|
||||
text: z.string().max(4000).optional(),
|
||||
/** 站点账号凭证字段:服务端解析后直接下发到输入框 */
|
||||
credential: z.enum(["username", "password", "totp"]).optional(),
|
||||
key: z.string().min(1).max(40).optional(),
|
||||
value: z.string().max(500).optional(),
|
||||
direction: z.enum(["up", "down", "left", "right"]).optional(),
|
||||
amount: z.number().int().min(1).max(10_000).optional(),
|
||||
submit: z.boolean().optional(),
|
||||
reason: z.string().max(200).optional(),
|
||||
});
|
||||
export const BrowserInteractOutput = z.object({
|
||||
ok: z.literal(true),
|
||||
controller: BrowserController,
|
||||
/** true = 页面可能已变化,下一步先 browser.snapshot 再用新引用 */
|
||||
needsSnapshot: z.boolean(),
|
||||
});
|
||||
|
||||
export const BrowserDownloadInput = z.object({
|
||||
sessionId: Id,
|
||||
tabId: z.string().min(1).optional(),
|
||||
/** 触发下载的元素(快照引用);缺省只等待已触发的下载 */
|
||||
target: BrowserAgentTarget.optional(),
|
||||
timeoutMs: z.number().int().min(500).max(120_000).default(15_000),
|
||||
});
|
||||
export const BrowserDownloadOutput = z.object({
|
||||
artifactId: Id,
|
||||
filename: z.string(),
|
||||
sizeBytes: NonNegInt,
|
||||
sha256: z.string(),
|
||||
verification: BrowserDownloadVerification,
|
||||
/** 匹配到的本地资源;下一步用 download.plan / download.submit 走同一提交流程 */
|
||||
sourceReleaseId: Id.nullable(),
|
||||
duplicate: z.boolean(),
|
||||
note: z.string().nullable(),
|
||||
});
|
||||
|
||||
/** 能力 ID → 输入/输出 schema(server 注册处理器、agent 生成工具定义时共用) */
|
||||
export const CAPABILITY_IO = {
|
||||
"catalog.lookup": { input: CatalogLookupInput, output: CatalogLookupOutput },
|
||||
@@ -341,6 +429,10 @@ export const CAPABILITY_IO = {
|
||||
"site.health_check": { input: SiteHealthCheckInput, output: SiteHealthCheckOutput },
|
||||
"site.refresh_login": { input: SiteLoginRefreshInput, output: SiteLoginRefreshOutput },
|
||||
"site.user_data": { input: SiteUserDataInput, output: SiteUserDataOutput },
|
||||
"browser.snapshot": { input: BrowserSnapshotInput, output: BrowserSnapshotOutput },
|
||||
"browser.navigate": { input: BrowserNavigateInput, output: BrowserNavigateOutput },
|
||||
"browser.interact": { input: BrowserInteractInput, output: BrowserInteractOutput },
|
||||
"browser.download": { input: BrowserDownloadInput, output: BrowserDownloadOutput },
|
||||
} as const satisfies Record<KnownCapabilityId, { input: z.ZodType; output: z.ZodType }>;
|
||||
|
||||
type CapabilityIo = typeof CAPABILITY_IO;
|
||||
@@ -404,3 +496,11 @@ export type SiteLoginRefreshOutput = z.infer<typeof SiteLoginRefreshOutput>;
|
||||
export type SiteUserDataInput = z.infer<typeof SiteUserDataInput>;
|
||||
export type SiteUserDataOutput = z.infer<typeof SiteUserDataOutput>;
|
||||
export type SubagentBudget = z.infer<typeof SubagentBudget>;
|
||||
export type BrowserSnapshotInput = z.infer<typeof BrowserSnapshotInput>;
|
||||
export type BrowserSnapshotOutput = z.infer<typeof BrowserSnapshotOutput>;
|
||||
export type BrowserNavigateInput = z.infer<typeof BrowserNavigateInput>;
|
||||
export type BrowserNavigateOutput = z.infer<typeof BrowserNavigateOutput>;
|
||||
export type BrowserInteractInput = z.infer<typeof BrowserInteractInput>;
|
||||
export type BrowserInteractOutput = z.infer<typeof BrowserInteractOutput>;
|
||||
export type BrowserDownloadInput = z.infer<typeof BrowserDownloadInput>;
|
||||
export type BrowserDownloadOutput = z.infer<typeof BrowserDownloadOutput>;
|
||||
@@ -72,6 +72,10 @@ export const CAPABILITY_IDS = [
|
||||
"site.health_check",
|
||||
"site.refresh_login",
|
||||
"site.user_data",
|
||||
"browser.snapshot",
|
||||
"browser.navigate",
|
||||
"browser.interact",
|
||||
"browser.download",
|
||||
] as const;
|
||||
export const KnownCapabilityId = z.enum(CAPABILITY_IDS);
|
||||
export type KnownCapabilityId = z.infer<typeof KnownCapabilityId>;
|
||||
@@ -348,6 +352,52 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
|
||||
true,
|
||||
false,
|
||||
),
|
||||
// C1-E 浏览器能力(REQ §4.4、§4.7.3):经 BrowserProvider 访问用户已有的 camofox-browser;
|
||||
// 导航限站点域名,凭证由服务端注入,人工接管期间自动化动作被拒;下载产物只进后端暂存核验
|
||||
"browser.snapshot": d(
|
||||
"browser.snapshot",
|
||||
"读取原站页面",
|
||||
"读取站点浏览器会话当前页面的结构化快照(可访问性文本 + eN 元素引用,凭证已遮蔽);控制权交还或导航后必须重新读取",
|
||||
"read",
|
||||
"BrowserSnapshot",
|
||||
"read",
|
||||
"low",
|
||||
true,
|
||||
false,
|
||||
),
|
||||
"browser.navigate": d(
|
||||
"browser.navigate",
|
||||
"打开原站页面",
|
||||
"在站点浏览器会话里打开站点域名内的地址(没有会话时按站点打开);受站点限流、超时与重试预算约束",
|
||||
"execute",
|
||||
"BrowserNavigate",
|
||||
"admin",
|
||||
"low",
|
||||
false,
|
||||
false,
|
||||
),
|
||||
"browser.interact": d(
|
||||
"browser.interact",
|
||||
"操作原站页面",
|
||||
"在站点浏览器会话里点击 / 输入 / 选择 / 滚动 / 按键;登录字段用 credential 由服务端注入凭证;验证码或二次认证用 request_human 转人工",
|
||||
"execute",
|
||||
"BrowserInteract",
|
||||
"admin",
|
||||
"medium",
|
||||
false,
|
||||
false,
|
||||
),
|
||||
"browser.download": d(
|
||||
"browser.download",
|
||||
"捕获原站下载",
|
||||
"捕获浏览器会话里的种子下载,交后端暂存并核验 infohash、匹配本地资源;提交下载仍用 download.plan / download.submit",
|
||||
"execute",
|
||||
"BrowserDownload",
|
||||
"organize.execute",
|
||||
"medium",
|
||||
false,
|
||||
false,
|
||||
),
|
||||
};
|
||||
|
||||
function d(
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user