feat(browser): W4-C BrowserProvider contract and camofox-browser adapter skeleton
- contracts/browser.ts: provider kind, per-capability {supported, verified} flags,
session/tab/control-lease rows, NavigationBudget, redacted BrowserSnapshot,
DownloadArtifact with blob/secret refs, BrowserErrorKind
- adapters/browser: BrowserProvider interface, camofox client (health, capabilities
static + /openapi.json probe, openSession/navigate/snapshot/screenshot; rest
not_implemented for M2), none provider, redactSnapshot, profile serial lock,
domain allowlist budget; Bearer access key via BrowserSecretResolver
- testkit/browser: mock camofox server (auth, fixtures with secrets, faults)
- db 0023: browser_provider/session/tab/control_lease with one-active-lease and
one-live-session-per-profile partial unique indexes; types regenerated
- docs/design/browser-provider.md: contract, camofox OpenAPI 1.17.0 mapping,
unverified items and M2 prototype checklist; Playwright is not a product browser
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
269a2269a1
commit
effaa32b70
23 files changed
+2290
No files matched your search
@@ -0,0 +1,95 @@
|
||||
import type { BrowserCapabilities, BrowserCapabilityFlag, BrowserCapabilityKey } from "@umt/contracts";
|
||||
|
||||
/**
|
||||
* camofox-browser 能力声明。依据:jo-inc/camofox-browser master 的 openapi.json(info.version 1.17.0)与 README,
|
||||
* 2026-09-27 取得;用户实际部署可能是其他 fork/版本,运行时以 /openapi.json 探测结果为准(REQ §4.7.4)。
|
||||
*
|
||||
* supported = 本适配器当前能对该上游提供的能力(上游有端点但适配器尚未实现的记 false 并注明 M2);
|
||||
* verified 只能由真实实例原型验证结果置 true(配置注入),探测永远不会把它设为 true。
|
||||
*/
|
||||
|
||||
/** 每项能力依赖的上游端点(OpenAPI paths 键,小写方法) */
|
||||
export const CAMOFOX_ENDPOINTS: Record<BrowserCapabilityKey, readonly string[]> = {
|
||||
sessions: ["post /tabs", "delete /sessions/{userId}"],
|
||||
tabs: ["post /tabs", "get /tabs", "delete /tabs/{tabId}"],
|
||||
navigate: ["post /tabs/{tabId}/navigate"],
|
||||
snapshot: ["get /tabs/{tabId}/snapshot"],
|
||||
screenshot: ["get /tabs/{tabId}/screenshot"],
|
||||
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: [],
|
||||
// 持久化插件默认开启,无需端点;storage_state 端点属插件,不在 OpenAPI 中
|
||||
persistentProfile: [],
|
||||
};
|
||||
|
||||
/** 适配器已实现的能力;其余上游虽有端点,适配器在 M2 实现 */
|
||||
const ADAPTER_IMPLEMENTED: ReadonlySet<BrowserCapabilityKey> = new Set([
|
||||
"sessions",
|
||||
"tabs",
|
||||
"navigate",
|
||||
"snapshot",
|
||||
"screenshot",
|
||||
"persistentProfile",
|
||||
]);
|
||||
|
||||
const STATIC_NOTES: Partial<Record<BrowserCapabilityKey, string>> = {
|
||||
sessions: "上游无显式建会话端点:userId 即会话(BrowserContext),首个 POST /tabs 隐式创建;关闭会话适配器 M2",
|
||||
tabs: "仅建首个标签页;列举/关闭适配器 M2",
|
||||
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 插件画面/输入与同会话连续操作待原型验证",
|
||||
persistentProfile: "上游持久化插件默认保存 cookies+localStorage(~/.camofox/profiles),重启恢复待验证",
|
||||
};
|
||||
|
||||
export type VerifiedMap = Partial<Record<BrowserCapabilityKey, boolean>>;
|
||||
|
||||
const KEYS = Object.keys(CAMOFOX_ENDPOINTS) as BrowserCapabilityKey[];
|
||||
|
||||
function flag(
|
||||
key: BrowserCapabilityKey,
|
||||
supported: boolean,
|
||||
source: "static" | "probed",
|
||||
verified: VerifiedMap,
|
||||
note?: string,
|
||||
): BrowserCapabilityFlag {
|
||||
const n = note ?? STATIC_NOTES[key];
|
||||
return { supported, verified: supported && verified[key] === true, source, ...(n ? { note: n } : {}) };
|
||||
}
|
||||
|
||||
export function camofoxStaticCapabilities(verified: VerifiedMap = {}): BrowserCapabilities {
|
||||
const flags = Object.fromEntries(
|
||||
KEYS.map((k) => [k, flag(k, ADAPTER_IMPLEMENTED.has(k), "static", verified)]),
|
||||
) as Record<BrowserCapabilityKey, BrowserCapabilityFlag>;
|
||||
return { ...flags, upstreamVersion: null, probedAt: null };
|
||||
}
|
||||
|
||||
/** 从上游 openapi.json 文档推导能力:缺端点的能力降为 false 并注明 */
|
||||
export function camofoxCapabilitiesFromOpenApi(
|
||||
doc: unknown,
|
||||
verified: VerifiedMap = {},
|
||||
now = new Date(),
|
||||
): BrowserCapabilities {
|
||||
const d = (doc ?? {}) as { info?: { version?: unknown }; paths?: Record<string, Record<string, unknown>> };
|
||||
const have = new Set<string>();
|
||||
for (const [p, ops] of Object.entries(d.paths ?? {}))
|
||||
for (const m of Object.keys(ops ?? {})) have.add(`${m.toLowerCase()} ${p}`);
|
||||
// 路径参数名不同的 fork 也视为同一端点
|
||||
const norm = (s: string) => s.replace(/\{[^}]+\}/g, "{}");
|
||||
const haveNorm = new Set([...have].map(norm));
|
||||
const flags = Object.fromEntries(
|
||||
KEYS.map((k) => {
|
||||
const missing = CAMOFOX_ENDPOINTS[k].filter((e) => !haveNorm.has(norm(e)));
|
||||
if (missing.length > 0) return [k, flag(k, false, "probed", verified, `上游缺少端点:${missing.join(", ")}`)];
|
||||
return [k, flag(k, ADAPTER_IMPLEMENTED.has(k), CAMOFOX_ENDPOINTS[k].length > 0 ? "probed" : "static", verified)];
|
||||
}),
|
||||
) as Record<BrowserCapabilityKey, BrowserCapabilityFlag>;
|
||||
return {
|
||||
...flags,
|
||||
upstreamVersion: typeof d.info?.version === "string" ? d.info.version : null,
|
||||
probedAt: now.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
/**
|
||||
* camofox-browser 适配器(BrowserProvider 首个实现)。
|
||||
* 对照:jo-inc/camofox-browser master openapi.json(info.version 1.17.0)与 README,仅读文档,未连接真实实例。
|
||||
*
|
||||
* 上游模型与本项目合同的映射:
|
||||
* - camofox userId = 一个隔离 BrowserContext(cookies/storage),本项目用 profileKey(站点/账号)充当;
|
||||
* - camofox sessionKey = 标签组,本项目用 browser_session.id;
|
||||
* - 上游没有"建会话"端点,首个 POST /tabs 隐式创建;会话由本适配器在内存登记,持久化由服务层写 browser_session。
|
||||
* 认证:README/OpenAPI 为 `Authorization: Bearer <CAMOFOX_ACCESS_KEY>`(设置后除 /health 外全部路由要求);
|
||||
* 凭据每次请求经 BrowserSecretResolver 解出,不缓存、不入日志、不进错误信息。
|
||||
*
|
||||
* v0.1 实现:health、capabilities(静态 + /openapi.json 探测)、openSession、navigate、snapshot、screenshot;
|
||||
* 其余方法抛 BrowserError('not_implemented'),见 README 的 M2 清单。
|
||||
*/
|
||||
import type {
|
||||
BrowserCapabilities,
|
||||
BrowserControlLease,
|
||||
BrowserHealth,
|
||||
BrowserSession,
|
||||
BrowserSnapshot,
|
||||
BrowserTab,
|
||||
DownloadArtifact,
|
||||
NavigationBudget,
|
||||
} from "@umt/contracts";
|
||||
import { assertNavigable, hostAllowed } from "../budget.ts";
|
||||
import { BrowserError, classifyHttpStatus, notImplemented } from "../errors.ts";
|
||||
import { InMemoryProfileLock, type ProfileLock } from "../profile-lock.ts";
|
||||
import {
|
||||
type AcquireControlRequest,
|
||||
type BrowserLogger,
|
||||
type BrowserProvider,
|
||||
type BrowserScreenshot,
|
||||
type BrowserSecretResolver,
|
||||
type ElementTarget,
|
||||
type NavigationResult,
|
||||
type OpenSessionRequest,
|
||||
type OpenSessionResult,
|
||||
profileKeyFor,
|
||||
type SnapshotOptions,
|
||||
type TabRef,
|
||||
type TypeInput,
|
||||
} from "../provider.ts";
|
||||
import { redactBrowserUrl, redactSnapshot, urlHasCredential } from "../redact.ts";
|
||||
import { camofoxCapabilitiesFromOpenApi, camofoxStaticCapabilities, type VerifiedMap } from "./capabilities.ts";
|
||||
|
||||
export interface CamofoxConfig {
|
||||
/** browser_provider.id */
|
||||
providerId: string;
|
||||
/** 例 http://camofox:9377 */
|
||||
baseUrl: string;
|
||||
secrets: BrowserSecretResolver;
|
||||
/** 单次请求默认超时;导航用 budget.timeoutMs */
|
||||
timeoutMs?: number;
|
||||
/** 同一 profile 串行锁;缺省进程内内存锁 */
|
||||
lock?: ProfileLock;
|
||||
lockWaitMs?: number;
|
||||
/** 已对真实实例验证通过的能力(来自原型验证记录);缺省全部未验证 */
|
||||
verified?: VerifiedMap;
|
||||
/** 截图/快照默认字节上限(未传 budget 时) */
|
||||
defaultMaxBytes?: number;
|
||||
fetch?: typeof fetch;
|
||||
logger?: BrowserLogger;
|
||||
now?: () => Date;
|
||||
newId?: () => string;
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
session: BrowserSession;
|
||||
tabs: Map<string, BrowserTab>;
|
||||
currentTabId: string;
|
||||
/** 未脱敏地址只留在内存,用于判断截图能否进入模型;不对外返回 */
|
||||
rawUrl: string | null;
|
||||
budget: NavigationBudget;
|
||||
}
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
const str = (v: unknown) => (typeof v === "string" ? v : undefined);
|
||||
const int = (v: unknown) => (typeof v === "number" && Number.isInteger(v) ? v : undefined);
|
||||
|
||||
const LOGIN_PATH = /(?:^|\/)(?:login|takelogin|signin|sign_in|auth)(?:\.php)?(?:\/|$|\?)/i;
|
||||
|
||||
export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
readonly kind = "camofox" as const;
|
||||
private readonly base: string;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly lock: ProfileLock;
|
||||
private readonly sessions = new Map<string, SessionState>();
|
||||
private readonly now: () => Date;
|
||||
private readonly newId: () => string;
|
||||
|
||||
constructor(private readonly cfg: CamofoxConfig) {
|
||||
this.base = cfg.baseUrl.replace(/\/+$/, "");
|
||||
this.fetchImpl = cfg.fetch ?? fetch;
|
||||
this.lock = cfg.lock ?? new InMemoryProfileLock(cfg.lockWaitMs);
|
||||
this.now = cfg.now ?? (() => new Date());
|
||||
this.newId = cfg.newId ?? (() => crypto.randomUUID());
|
||||
}
|
||||
|
||||
// ---------- HTTP ----------
|
||||
|
||||
private async authHeader(): Promise<Record<string, string>> {
|
||||
const key = await this.cfg.secrets.resolve({ kind: "provider_access_key", providerId: this.cfg.providerId });
|
||||
return key ? { authorization: `Bearer ${key}` } : {};
|
||||
}
|
||||
|
||||
private async request(
|
||||
method: "GET" | "POST" | "DELETE",
|
||||
path: string,
|
||||
opts: { query?: Record<string, string | undefined>; body?: Json; timeoutMs?: number; auth?: boolean } = {},
|
||||
): Promise<Response> {
|
||||
const u = new URL(this.base + path);
|
||||
for (const [k, v] of Object.entries(opts.query ?? {})) if (v !== undefined) u.searchParams.set(k, v);
|
||||
const headers: Record<string, string> = {
|
||||
accept: "application/json",
|
||||
...(opts.auth === false ? {} : await this.authHeader()),
|
||||
};
|
||||
if (opts.body) headers["content-type"] = "application/json";
|
||||
const timeoutMs = opts.timeoutMs ?? this.cfg.timeoutMs ?? 30_000;
|
||||
const started = Date.now();
|
||||
let res: Response;
|
||||
try {
|
||||
res = await this.fetchImpl(u, {
|
||||
method,
|
||||
headers,
|
||||
...(opts.body ? { body: JSON.stringify(opts.body) } : {}),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
} catch (e) {
|
||||
const name = (e as { name?: string })?.name;
|
||||
const kind = name === "TimeoutError" || name === "AbortError" ? "timeout" : "network";
|
||||
this.cfg.logger?.({ level: "warn", msg: `camofox ${kind}`, method, path, ms: Date.now() - started });
|
||||
throw new BrowserError(
|
||||
kind,
|
||||
`camofox ${method} ${path} ${kind === "timeout" ? `超时(${timeoutMs}ms)` : "网络错误"}`,
|
||||
);
|
||||
}
|
||||
this.cfg.logger?.({ level: "debug", msg: "camofox", method, path, status: res.status, ms: Date.now() - started });
|
||||
if (!res.ok) {
|
||||
await res.body?.cancel();
|
||||
throw new BrowserError(classifyHttpStatus(res.status), `camofox ${method} ${path} → HTTP ${res.status}`, {
|
||||
httpStatus: res.status,
|
||||
});
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
private async json(res: Response, path: string): Promise<Json> {
|
||||
try {
|
||||
const v = (await res.json()) as unknown;
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) return v as Json;
|
||||
} catch {
|
||||
/* fallthrough */
|
||||
}
|
||||
throw new BrowserError("protocol", `camofox ${path} 返回非 JSON 对象`);
|
||||
}
|
||||
|
||||
// ---------- 状态 ----------
|
||||
|
||||
private state(sessionId: string): SessionState {
|
||||
const s = this.sessions.get(sessionId);
|
||||
if (!s) throw new BrowserError("not_found", "会话不存在或已关闭");
|
||||
return s;
|
||||
}
|
||||
|
||||
private tabOf(s: SessionState, ref: TabRef): BrowserTab {
|
||||
const t = s.tabs.get(ref.tabId ?? s.currentTabId);
|
||||
if (t?.status !== "open") throw new BrowserError("not_found", "标签页不存在或已关闭");
|
||||
return t;
|
||||
}
|
||||
|
||||
/** 接管/等待/关闭中的会话拒绝自动化动作(REQ §4.7.3) */
|
||||
private assertAutomation(s: SessionState): void {
|
||||
if (s.session.controller !== "automation")
|
||||
throw new BrowserError("control_conflict", `会话当前由 ${s.session.controller} 控制,自动化动作已停止`);
|
||||
}
|
||||
|
||||
private touch(s: SessionState, rawUrl?: string | null): void {
|
||||
const at = this.now().toISOString();
|
||||
if (rawUrl !== undefined) {
|
||||
s.rawUrl = rawUrl;
|
||||
s.session.currentUrl = rawUrl === null ? null : redactBrowserUrl(rawUrl);
|
||||
}
|
||||
s.session.lastActivityAt = at;
|
||||
s.session.updatedAt = at;
|
||||
}
|
||||
|
||||
private withProfile<T>(s: SessionState | string, fn: () => Promise<T>): Promise<T> {
|
||||
const key = typeof s === "string" ? s : s.session.profileKey;
|
||||
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);
|
||||
s.session.controller = controller;
|
||||
this.touch(s);
|
||||
}
|
||||
|
||||
// ---------- 健康与能力 ----------
|
||||
|
||||
async health(): Promise<BrowserHealth> {
|
||||
const checkedAt = this.now().toISOString();
|
||||
const u = `${this.base}/health`;
|
||||
try {
|
||||
// /health 在 CAMOFOX_ACCESS_KEY 下也免认证(README);503 表示恢复中
|
||||
const res = await this.fetchImpl(u, { signal: AbortSignal.timeout(this.cfg.timeoutMs ?? 10_000) });
|
||||
const body = (await res.json().catch(() => ({}))) as Json;
|
||||
const detail: Json = {
|
||||
httpStatus: res.status,
|
||||
engine: str(body.engine) ?? null,
|
||||
browserConnected: body.browserConnected ?? null,
|
||||
browserRunning: body.browserRunning ?? null,
|
||||
activeTabs: int(body.activeTabs) ?? null,
|
||||
activeSessions: int(body.activeSessions) ?? null,
|
||||
consecutiveFailures: int(body.consecutiveFailures) ?? null,
|
||||
recovering: body.recovering ?? null,
|
||||
};
|
||||
if (res.status === 503) return { status: "degraded", kind: "camofox", detail, checkedAt };
|
||||
if (!res.ok || body.ok === false) return { status: "down", kind: "camofox", detail, checkedAt };
|
||||
return { status: "ok", kind: "camofox", detail, checkedAt };
|
||||
} catch (e) {
|
||||
const name = (e as { name?: string })?.name;
|
||||
return {
|
||||
status: "down",
|
||||
kind: "camofox",
|
||||
detail: { error: name === "TimeoutError" ? "timeout" : "network" },
|
||||
checkedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** probe=true 时读取上游 /openapi.json 推导;失败回退静态声明(note 标注探测失败) */
|
||||
async capabilities(opts: { probe?: boolean } = {}): Promise<BrowserCapabilities> {
|
||||
const verified = this.cfg.verified ?? {};
|
||||
if (!opts.probe) return camofoxStaticCapabilities(verified);
|
||||
try {
|
||||
const res = await this.request("GET", "/openapi.json");
|
||||
return camofoxCapabilitiesFromOpenApi(await this.json(res, "/openapi.json"), verified, this.now());
|
||||
} catch (e) {
|
||||
if (e instanceof BrowserError && e.kind === "auth") throw e;
|
||||
return camofoxStaticCapabilities(verified);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 会话 ----------
|
||||
|
||||
async openSession(req: OpenSessionRequest): Promise<OpenSessionResult> {
|
||||
const profileKey = req.profileKey ?? profileKeyFor(req.siteId, req.accountId);
|
||||
if (req.initialUrl) assertNavigable(req.initialUrl, req.budget);
|
||||
return this.withProfile(profileKey, async () => {
|
||||
for (const s of this.sessions.values())
|
||||
if (s.session.profileKey === profileKey && s.session.controller !== "closed")
|
||||
throw new BrowserError("control_conflict", "该站点/账号已有未关闭的浏览器会话");
|
||||
const sessionId = this.newId();
|
||||
const res = await this.request("POST", "/tabs", {
|
||||
body: { userId: profileKey, sessionKey: sessionId, ...(req.initialUrl ? { url: req.initialUrl } : {}) },
|
||||
timeoutMs: req.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) ?? req.initialUrl ?? null;
|
||||
const at = this.now().toISOString();
|
||||
const session: BrowserSession = {
|
||||
id: sessionId,
|
||||
providerId: this.cfg.providerId,
|
||||
siteId: req.siteId,
|
||||
accountId: req.accountId,
|
||||
profileKey,
|
||||
controller: "automation",
|
||||
currentUrl: rawUrl === null ? null : redactBrowserUrl(rawUrl),
|
||||
lastActivityAt: at,
|
||||
upstreamRef: { userId: profileKey, sessionKey: sessionId },
|
||||
recoveryState: {},
|
||||
closedAt: null,
|
||||
closeReason: null,
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
};
|
||||
const tab: BrowserTab = {
|
||||
id: this.newId(),
|
||||
sessionId,
|
||||
upstreamTabId: tabId,
|
||||
url: session.currentUrl,
|
||||
title: null,
|
||||
status: "open",
|
||||
lastSnapshotAt: null,
|
||||
closedAt: null,
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
};
|
||||
const st: SessionState = {
|
||||
session,
|
||||
tabs: new Map([[tabId, tab]]),
|
||||
currentTabId: tabId,
|
||||
rawUrl,
|
||||
budget: req.budget,
|
||||
};
|
||||
this.sessions.set(sessionId, st);
|
||||
let navigation: NavigationResult | null = null;
|
||||
if (req.initialUrl) {
|
||||
navigation = {
|
||||
url: session.currentUrl ?? "",
|
||||
httpStatus: int(body.httpStatus) ?? null,
|
||||
ok: body.navigationOk !== false,
|
||||
attempts: 1,
|
||||
};
|
||||
this.assertLanded(rawUrl, req.budget);
|
||||
}
|
||||
return { session: { ...session }, tab: { ...tab }, navigation };
|
||||
});
|
||||
}
|
||||
|
||||
/** 重定向落到白名单外:报 domain_blocked(页面已打开,调用方应停止后续动作并转人工) */
|
||||
private assertLanded(rawUrl: string | null, budget: NavigationBudget): void {
|
||||
if (!rawUrl) return;
|
||||
let host: string;
|
||||
try {
|
||||
host = new URL(rawUrl).hostname;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!hostAllowed(host, budget.domainAllowlist))
|
||||
throw new BrowserError("domain_blocked", `导航被重定向到白名单外:${redactBrowserUrl(new URL(rawUrl).origin)}`);
|
||||
}
|
||||
|
||||
async closeSession(_sessionId: string, _reason?: string): Promise<void> {
|
||||
throw notImplemented("closeSession");
|
||||
}
|
||||
|
||||
async listSessions(): Promise<BrowserSession[]> {
|
||||
throw notImplemented("listSessions");
|
||||
}
|
||||
|
||||
// ---------- 页面 ----------
|
||||
|
||||
async navigate(ref: TabRef, url: string, budget: NavigationBudget): Promise<NavigationResult> {
|
||||
const s = this.state(ref.sessionId);
|
||||
this.assertAutomation(s);
|
||||
assertNavigable(url, budget);
|
||||
return this.withProfile(s, async () => {
|
||||
this.assertAutomation(s);
|
||||
const tab = this.tabOf(s, ref);
|
||||
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/navigate`;
|
||||
let attempts = 0;
|
||||
for (;;) {
|
||||
attempts++;
|
||||
try {
|
||||
const res = await this.request("POST", path, {
|
||||
body: { userId: s.session.profileKey, url },
|
||||
timeoutMs: budget.timeoutMs,
|
||||
});
|
||||
const body = await this.json(res, path);
|
||||
const rawUrl = str(body.url) ?? url;
|
||||
this.touch(s, rawUrl);
|
||||
tab.url = s.session.currentUrl;
|
||||
tab.updatedAt = s.session.updatedAt;
|
||||
s.budget = budget;
|
||||
this.assertLanded(rawUrl, budget);
|
||||
return {
|
||||
url: redactBrowserUrl(rawUrl),
|
||||
httpStatus: int(body.httpStatus) ?? null,
|
||||
ok: body.ok !== false && body.navigationOk !== false,
|
||||
attempts,
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof BrowserError && e.retryable && attempts <= budget.maxRetries) continue;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async snapshot(ref: TabRef, opts: SnapshotOptions = {}): Promise<BrowserSnapshot> {
|
||||
const s = this.state(ref.sessionId);
|
||||
return this.withProfile(s, async () => {
|
||||
const tab = this.tabOf(s, ref);
|
||||
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/snapshot`;
|
||||
const res = await this.request("GET", path, {
|
||||
query: {
|
||||
userId: s.session.profileKey,
|
||||
format: "text",
|
||||
...(opts.offset !== undefined ? { offset: String(opts.offset) } : {}),
|
||||
},
|
||||
timeoutMs: s.budget.timeoutMs,
|
||||
});
|
||||
const body = await this.json(res, path);
|
||||
let text = str(body.snapshot) ?? "";
|
||||
let truncated = body.truncated === true || body.hasMore === true;
|
||||
const maxBytes = opts.maxBytes ?? s.budget.maxBytes;
|
||||
const enc = new TextEncoder();
|
||||
if (enc.encode(text).byteLength > maxBytes) {
|
||||
// 按字节预算截断;在字符边界上截,避免半个 UTF-8 字符
|
||||
let lo = 0;
|
||||
let hi = text.length;
|
||||
while (lo < hi) {
|
||||
const mid = Math.ceil((lo + hi) / 2);
|
||||
if (enc.encode(text.slice(0, mid)).byteLength <= maxBytes) lo = mid;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
text = text.slice(0, lo);
|
||||
truncated = true;
|
||||
}
|
||||
const rawUrl = str(body.url) ?? s.rawUrl ?? "";
|
||||
const structure =
|
||||
body.structure && typeof body.structure === "object" && !Array.isArray(body.structure)
|
||||
? (body.structure as Json)
|
||||
: null;
|
||||
const red = redactSnapshot({ text, url: rawUrl, structure }, { extra: opts.extraRedactions ?? [] });
|
||||
this.touch(s, rawUrl || s.rawUrl);
|
||||
const at = s.session.lastActivityAt;
|
||||
tab.lastSnapshotAt = at;
|
||||
tab.url = s.session.currentUrl;
|
||||
const refsCount = int(body.refsCount) ?? new Set(text.match(/\be\d+\b/g) ?? []).size;
|
||||
return {
|
||||
sessionId: s.session.id,
|
||||
tabId: tab.upstreamTabId,
|
||||
url: red.url,
|
||||
format: "a11y_text",
|
||||
text: red.text,
|
||||
structure: red.structure,
|
||||
refsCount,
|
||||
truncated,
|
||||
nextOffset: truncated ? (int(body.nextOffset) ?? null) : null,
|
||||
redactions: red.redactions,
|
||||
capturedAt: at,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async screenshot(ref: TabRef, opts: { maxBytes?: number } = {}): Promise<BrowserScreenshot> {
|
||||
const s = this.state(ref.sessionId);
|
||||
return this.withProfile(s, async () => {
|
||||
const tab = this.tabOf(s, ref);
|
||||
const path = `/tabs/${encodeURIComponent(tab.upstreamTabId)}/screenshot`;
|
||||
const res = await this.request("GET", path, {
|
||||
query: { userId: s.session.profileKey },
|
||||
timeoutMs: s.budget.timeoutMs,
|
||||
});
|
||||
const maxBytes = opts.maxBytes ?? s.budget.maxBytes ?? this.cfg.defaultMaxBytes ?? 8 * 1024 * 1024;
|
||||
const declared = Number(res.headers.get("content-length") ?? "NaN");
|
||||
if (Number.isFinite(declared) && declared > maxBytes * 1.4) {
|
||||
await res.body?.cancel();
|
||||
throw new BrowserError("budget_exceeded", `截图超出字节预算(${maxBytes})`);
|
||||
}
|
||||
const ctype = res.headers.get("content-type") ?? "";
|
||||
let data: Uint8Array;
|
||||
let mimeType: string;
|
||||
if (ctype.startsWith("image/")) {
|
||||
// 部分版本/fork 直接返回二进制图片(待核验)
|
||||
data = new Uint8Array(await res.arrayBuffer());
|
||||
mimeType = ctype.split(";")[0]!.trim();
|
||||
} else {
|
||||
// OpenAPI 1.17.0:{ screenshot: { data: base64, mimeType } }
|
||||
const body = await this.json(res, path);
|
||||
const shot = (body.screenshot && typeof body.screenshot === "object" ? body.screenshot : body) as Json;
|
||||
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));
|
||||
} catch {
|
||||
throw new BrowserError("protocol", "camofox 截图不是合法 base64");
|
||||
}
|
||||
mimeType = str(shot.mimeType) ?? "image/png";
|
||||
}
|
||||
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 */
|
||||
}
|
||||
return {
|
||||
mimeType,
|
||||
data,
|
||||
url: raw === null ? null : redactBrowserUrl(raw),
|
||||
modelSafe: raw !== null && !urlHasCredential(raw) && !loginPage,
|
||||
capturedAt: s.session.lastActivityAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- M2 ----------
|
||||
|
||||
async click(_ref: TabRef, _target: ElementTarget): Promise<void> {
|
||||
throw notImplemented("click");
|
||||
}
|
||||
async type(_ref: TabRef, _target: ElementTarget, _text: TypeInput): Promise<void> {
|
||||
throw notImplemented("type");
|
||||
}
|
||||
async extract(_ref: TabRef, _schema: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
throw notImplemented("extract");
|
||||
}
|
||||
async waitForDownload(_ref: TabRef, _opts: { timeoutMs: number; maxBytes: number }): Promise<DownloadArtifact> {
|
||||
throw notImplemented("waitForDownload");
|
||||
}
|
||||
async acquireControl(_req: AcquireControlRequest): Promise<BrowserControlLease> {
|
||||
throw notImplemented("acquireControl");
|
||||
}
|
||||
async releaseControl(_leaseId: string, _reason?: string): Promise<void> {
|
||||
throw notImplemented("releaseControl");
|
||||
}
|
||||
}
|
||||
|
||||
export function createCamofoxBrowserProvider(cfg: CamofoxConfig): CamofoxBrowserProvider {
|
||||
return new CamofoxBrowserProvider(cfg);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./capabilities.ts";
|
||||
export * from "./client.ts";
|
||||
Reference in new issue
Block a user