import type { BrowserCapabilities, BrowserCapabilityFlag, BrowserCapabilityKey } from "@umt/contracts"; /** * camofox-browser 能力声明。依据:jo-inc/camofox-browser v1.17.0(commit 389c996)的 openapi.json、README 与 server.js; * 用户实际部署可能是其他 fork/版本,运行时以 /openapi.json 探测结果为准(REQ §4.7.4)。 * * supported = 本适配器当前能对该上游提供的能力(上游缺端点的记 false 并注明); * verified 只来自人工真机核验:CAMOFOX_VERIFIED_RELEASES(按上游版本登记的核验记录,只在探测到的 info.version * 完全一致时生效)与 browser_provider.config.verified(逐项覆盖,可写 false 撤销)。探测本身永远不会把它设为 true。 */ /** 每项能力依赖的上游端点(OpenAPI paths 键,小写方法) */ export const CAMOFOX_ENDPOINTS: Record = { 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"], // 坐标点击经 evaluate(elementFromPoint)转成选择器:1.17.0 的 click 不接受 {x, y} interact: [ "post /tabs/{tabId}/click", "post /tabs/{tabId}/type", "post /tabs/{tabId}/select", "post /tabs/{tabId}/evaluate", ], extract: ["post /tabs/{tabId}/extract"], downloadCapture: ["get /tabs/{tabId}/downloads"], // 原站视图接管 = 同一会话的定时截图 + 点击/输入映射(不另起浏览器);VNC 插件未接入 takeover: [ "get /tabs/{tabId}/screenshot", "post /tabs/{tabId}/click", "post /tabs/{tabId}/type", "post /tabs/{tabId}/evaluate", ], // 持久化插件默认开启,无需端点;storage_state 端点属插件,不在 OpenAPI 中 persistentProfile: [], }; /** 适配器已实现的能力(C1-E 起全部实现;是否可用仍取决于 verified) */ const ADAPTER_IMPLEMENTED: ReadonlySet = new Set([ "sessions", "tabs", "navigate", "snapshot", "screenshot", "interact", "extract", "downloadCapture", "takeover", "persistentProfile", ]); const STATIC_NOTES: Partial> = { sessions: "上游无显式建会话端点:userId 即会话(BrowserContext),首个 POST /tabs 隐式创建;会话以数据库为准", tabs: "新建/关闭/对账(GET /tabs?userId);重启或空闲回收后旧标签页 404/410,对账标 lost 后在同一会话开新标签页", snapshot: "ARIA 快照文本 + [eN] 元素引用,支持 offset 分页;密码框值经 evaluate 取值后遮蔽", interact: "click/type/select/scroll/press/后退/前进/刷新;坐标点击经 evaluate 定位元素,焦点输入用 mode=keyboard", extract: "POST /extract 需先 snapshot 建 ref 表(x-ref)", downloadCapture: "GET /downloads?includeData&consume&maxBytes → dataBase64;点击下载链接后捕获", takeover: "原站视图:同一会话同一标签页的截图 + 坐标点击/焦点输入(API 层核验);VNC 插件未接入", persistentProfile: "cookies+localStorage 按 profile 落盘:会话空闲回收、关闭与 SIGTERM 停止时写入;SIGKILL/OOM 丢上次落盘后的新登录", }; /** * 真机核验记录(docs/runbook/browser.md「真机核验」)。只登记核验通过的能力;新版本必须重新核验后再加一行。 */ export const CAMOFOX_VERIFIED_RELEASES: Readonly< Record > = { "1.17.0": { verifiedAt: "2026-09-28", source: "jo-inc/camofox-browser v1.17.0 (389c996),docker/camofox/Dockerfile 构建", keys: [ "sessions", "tabs", "navigate", "snapshot", "screenshot", "interact", "extract", "downloadCapture", "takeover", "persistentProfile", ], }, }; /** 某上游版本的已核验能力 + 配置覆盖(配置里显式 false 可撤销) */ export function verifiedFor(version: string | null, config: VerifiedMap = {}): VerifiedMap { const rec = version ? CAMOFOX_VERIFIED_RELEASES[version] : undefined; const base: VerifiedMap = Object.fromEntries((rec?.keys ?? []).map((k) => [k, true])); return { ...base, ...config }; } export type VerifiedMap = Partial>; 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; 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> }; const have = new Set(); 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 version = typeof d.info?.version === "string" ? d.info.version : null; const effective = verifiedFor(version, verified); 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", effective, `上游缺少端点:${missing.join(", ")}`)]; return [k, flag(k, ADAPTER_IMPLEMENTED.has(k), CAMOFOX_ENDPOINTS[k].length > 0 ? "probed" : "static", effective)]; }), ) as Record; return { ...flags, upstreamVersion: version, probedAt: now.toISOString(), }; }