Files
UHD_Media_Tool/packages/adapters/src/browser/provider.ts
T
rootandClaude Opus 5.5 6b14b0da45 feat(browser): 插件真实坐标点击与页面内容读取(CF-2)
- umt 插件 POST /umt/tabs/{id}/mouse-click:page.mouse.click,跨域 iframe 的 Turnstile 也点得到;click 坐标优先走它,旧镜像退回 elementFromPoint
- 适配器 pageContent(地址 / 标题 / outerHTML / 正文 / navigator.userAgent);setEgress 返回插件出口模式
- mock camofox 增加 Cloudflare 模式(auto / interactive / captcha)与插件鼠标点击

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 21:26:53 +08:00

287 lines
11 KiB
TypeScript

import type {
BrowserCapabilities,
BrowserControlLease,
BrowserCookie,
BrowserEgressProxy,
BrowserHealth,
BrowserIdentity,
BrowserLeaseHolderKind,
BrowserProviderKind,
BrowserSession,
BrowserSnapshot,
BrowserTab,
DownloadArtifact,
NavigationBudget,
} from "@umt/contracts";
/**
* BrowserProvider:业务侧访问浏览器服务的唯一合同(REQ §4.7.2)。
* 这是本项目接口;上游(camofox-browser 等)的同名能力由各实现映射,不假定上游具备同名端点。
* 约束:
* - 同一 profileKey(站点/账号)的操作串行(ProfileLock);
* - 导航受 NavigationBudget 约束(域名白名单、超时、重试、字节上限);
* - 进入模型的快照/截图先经脱敏钩子;凭证经 BrowserSecretResolver 注入,不落日志、不进模型;
* - controller 不是 automation 的会话拒绝自动化输入(接管期间,REQ §4.7.3);
* - 浏览器没有媒体库写权限,下载产物只交后端暂存核验。
*/
export interface BrowserProvider {
readonly kind: BrowserProviderKind;
health(): Promise<BrowserHealth>;
capabilities(opts?: { probe?: boolean }): Promise<BrowserCapabilities>;
openSession(req: OpenSessionRequest): Promise<OpenSessionResult>;
closeSession(sessionId: string, reason?: string): Promise<void>;
listSessions(): Promise<BrowserSession[]>;
navigate(ref: TabRef, url: string, budget: NavigationBudget): Promise<NavigationResult>;
snapshot(ref: TabRef, opts?: SnapshotOptions): Promise<BrowserSnapshot>;
screenshot(ref: TabRef, opts?: { maxBytes?: number }): Promise<BrowserScreenshot>;
click(ref: TabRef, target: ElementTarget): Promise<void>;
/** text 为明文或凭证引用;凭证引用由服务端解析后直接下发,不进入模型与日志 */
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>;
/**
* 页面度量(VIS-1 坐标换算与敏感判定):视口 CSS 尺寸、DPR、是否有密码框、页面文字样本(只在服务端判定用,不外传)。
* 只读,不要求自动化控制权;上游不支持 evaluate 时返回 null。
*/
pageMetrics(ref: TabRef): Promise<PageMetrics | null>;
/**
* 当前页面内容(CF-2 挑战识别与浏览器代抓):地址(已脱敏)、标题、outerHTML、正文样本、navigator.userAgent。
* 只读,不要求自动化控制权;上游不支持时返回 null。HTML 只给服务端解析器,不进模型与日志。
*/
pageContent?(ref: TabRef, opts?: { maxHtmlBytes?: number }): Promise<PageContent | null>;
// ── BR-2 凭据统一 ──
/**
* 把 Cookie 写进该 profile 的浏览器上下文(并落盘到 profile)。上游没有可用的 Cookie 接口时报
* BrowserError("not_implemented")。Cookie 值不进日志与错误信息。
*/
setCookies?(profileKey: string, cookies: BrowserCookie[]): Promise<{ count: number }>;
/**
* 读该 profile 作用于这些地址的 Cookie(活动上下文优先,否则读 profile 落盘文件);没有时返回空数组。
* 上游没有接口时报 BrowserError("not_implemented")。
*/
getCookies?(profileKey: string, urls: string[]): Promise<BrowserCookie[]>;
// ── BR-3 浏览器服务重启后自愈 ──
/**
* 浏览器服务实例标识:进程重启后会变(camofox 走 umt 插件 `GET /umt/instance`,否则取 `/health` 里的
* instanceId / bootId / startedAt)。取不到(旧镜像)时返回 null,调用方只靠 404/410 被动发现。
*/
instanceId?(): Promise<string | null>;
/**
* 重建会话的上游上下文:(可选)先把 Cookie 写进该 profile,再在同一会话(同一 userId / sessionKey)开新标签页并导航到 url;
* 原有打开中的标签页一律标 lost。不检查控制者(接管中的会话也能重连,控制权不变)。
*/
reopenSession?(sessionId: string, req: ReopenSessionRequest): Promise<ReopenSessionResult>;
// ── AG-4 保活 ──
/**
* 对标签页做一次最轻的只读调用,让上游把它算作"有活动"(camofox 标签页空闲回收 TAB_INACTIVITY_MS 与会话空闲回收
* SESSION_TIMEOUT_MS 都按 API 调用计;桌面模式里人的键鼠操作不经 API,不算活动)。不检查控制者、不改页面。
* 标签页不在了报 not_found(调用方按 BR-3 自愈重连)。
*/
keepAlive?(ref: TabRef): Promise<void>;
// ── BR-4 浏览器 UA 与指纹统一、出口按站点 ──
/** 固定浏览器身份的公开部分(UA、OS、版本);上游没有时返回 null */
identity?(): Promise<BrowserIdentity | null>;
/**
* 该 profile 的出口:proxy = 站点开代理时与 HTTP 同一代理,null = 直连。上游没有接口时报 BrowserError("not_implemented")。
* restarted = 活动上下文出口不同、已被关闭(下次操作按新出口重建)。
*/
setEgress?(profileKey: string, proxy: BrowserEgressProxy | null): Promise<{ restarted: boolean; mode?: string }>;
}
export interface ReopenSessionRequest {
url: string;
budget: NavigationBudget;
cookies?: BrowserCookie[];
}
export interface ReopenSessionResult {
tab: BrowserTab;
/** 被标 lost 的旧标签页(上游 tabId) */
lost: string[];
cookies?: { status: "injected" | "unsupported" | "failed"; count: number };
}
export interface PageContent {
url: string | null;
title: string;
html: string;
/** document.body.innerText 前 2 万字 */
text: string;
userAgent: string | null;
}
export interface PageMetrics {
viewportWidth: number;
viewportHeight: number;
devicePixelRatio: number;
/** 页面上有 input[type=password] */
passwordField: boolean;
/** document.body.innerText 前 4000 字(判定二次验证等,不进模型、不落日志) */
textSample: string;
}
/** 动作发起方:自动化(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 {
providerId: string;
siteId: string;
accountId: string | null;
/** 缺省按 siteId/accountId 生成(profileKeyFor) */
profileKey?: string;
/** 可选初始地址,同样受 budget 约束 */
initialUrl?: string;
budget: NavigationBudget;
/** 打开前先注入的 Cookie(站点账号登录态,BR-2);注入失败不阻止打开,结果见 OpenSessionResult.cookies */
cookies?: BrowserCookie[];
}
export interface OpenSessionResult {
session: BrowserSession;
tab: BrowserTab;
navigation: NavigationResult | null;
/** 传了 cookies 时:injected = 注入个数;unsupported = 上游没有 Cookie 接口;failed = 请求失败 */
cookies?: { status: "injected" | "unsupported" | "failed"; count: number };
}
/** 会话内标签页定位;tabId 缺省 = 会话当前标签页 */
export interface TabRef {
sessionId: string;
tabId?: string;
/** 动作发起方,缺省 automation;与会话 controller 不符时报 control_conflict */
as?: ActorKind;
}
export interface NavigationResult {
/** 已脱敏的最终地址 */
url: string;
httpStatus: number | null;
/** 上游报告的文档状态 < 400 */
ok: boolean;
attempts: number;
}
export interface SnapshotOptions {
offset?: number;
maxBytes?: number;
/** 追加脱敏规则(站点特定字段) */
extraRedactions?: RegExp[];
}
export interface BrowserScreenshot {
mimeType: string;
data: Uint8Array;
/** 已脱敏的页面地址 */
url: string | null;
/**
* 是否可直接进入模型。像素级遮蔽未实现(M2),当前按页面地址/已知密码框保守判断:
* 含凭据的地址或登录页一律 false,调用方只能给人看、不喂模型。
*/
modelSafe: boolean;
capturedAt: string;
}
/** 元素定位:快照引用 / CSS 选择器 / 视口像素坐标(原站视图点击;camofox 坐标点击待核验) */
export type ElementTarget = { ref: string } | { selector: string } | { x: number; y: number };
export type TypeInput =
| { 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;
holderKind: BrowserLeaseHolderKind;
holder: string;
reason: string;
ttlMs: number;
}
/** 凭证注入:服务端从 secret store 按需解出明文;适配器不持久化、不打印、不放进错误信息 */
export type BrowserSecretRequest =
| { kind: "provider_access_key"; providerId: string }
| { kind: "site_login"; siteId: string; accountId: string | null; field: "username" | "password" | "totp" };
export interface BrowserSecretResolver {
resolve(req: BrowserSecretRequest): Promise<string | null>;
}
/** 测试/脚本用固定凭证解析器;键为 `provider:<id>` 或 `site:<siteId>:<accountId|->:<field>` */
export function staticBrowserSecrets(table: Record<string, string>): BrowserSecretResolver {
return {
async resolve(req) {
const key =
req.kind === "provider_access_key"
? `provider:${req.providerId}`
: `site:${req.siteId}:${req.accountId ?? "-"}:${req.field}`;
return table[key] ?? null;
},
};
}
/** 站点/账号维度的 profile 键;同时用作 camofox userId(登录状态隔离单元) */
export function profileKeyFor(siteId: string, accountId: string | null): string {
return `umt-site-${siteId}-acct-${accountId ?? "anon"}`;
}
/** 只记录方法/路径/状态;实现不得把请求头、正文或快照写进日志 */
export type BrowserLogger = (entry: {
level: "debug" | "warn";
msg: string;
method?: string;
path?: string;
status?: number;
ms?: number;
}) => void;