生产 v0.3.8~v0.3.13 均由 8013196 加工作区修改构建部署,此前未提交;本提交补齐源码,
并含未部署的 v0.3.14 原地入库。中间各版本源码状态未保存,无法逐版拆分,详见
docs/plan/17-full-upgrade.md 与 docs/plan/18-organize-release.md。
- v0.3.8 全面升级:Deno workspace 依赖、Router 8 / Vite 8、camofox 1.18.0 / Node 24、
PostgreSQL 18.6、备份客户端 18.6、演示 qB 5.2.4
- v0.3.9 媒体整理:分类规则、整理来源与目录快照(0059)、文件管理整理入口
- v0.3.10 手动下载与入库:独立手动下载、详情页资源下载、TMDB / Local 元数据兜底
(metadata.resolve_organize)、正常多版本 / 一次性洗版(0060)
- v0.3.11 搜索详情与桌面黑边修复
- v0.3.12 列表分页、搜索历史、首页概况
- v0.3.13 统一整理弹窗与队列
- v0.3.14 原地入库(0061,未部署)
同批审计修复:
- 刮削入队:确认原记录存在后才删重放产生的新记录;写回 run 关联失败时保持 queued,
由处理器补关联(对齐 MP 先登记、只在入队失败时记失败)
- 识别升级链只在结果更确定时采用,不再用 no_match 顶替原有歧义候选
- bluray_dir 资产以原盘目录本身为刮削根(MP 同做法)
- metadata.resolve_organize 登记到入库排障 Skill 与能力地图
- db/src/types.ts 按迁移重新生成;contracts 整理动作枚举顺序与数据库对齐
- 测试:原地入库用例改为直接查记录表;domain 测试模板补 version
- 前端:通知铃铛弹层与用户菜单统一样式;站点卡片共用卡头、底栏贴底、分页间距
依赖升级证据只提交摘要文件,registry-snapshot.json 移至 .local/(SHA-256 记于文档)。
Co-Authored-By: Claude Opus 5.5 <[email protected]>
288 lines
12 KiB
TypeScript
288 lines
12 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 只给服务端解析器,不进模型与日志。
|
|
* currentVisible 仅用于用户从桌面详情页交接资源:未指定 tabId 时发现唯一可见标签,不能确定则拒绝。
|
|
*/
|
|
pageContent?(ref: TabRef, opts?: { maxHtmlBytes?: number; currentVisible?: boolean }): 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;
|