import { hostAllowed, type PageMetrics } from "@umt/adapters"; import type { BrowserControlLease, BrowserController, BrowserCoordinateSpace, BrowserEgressProxy, BrowserEvidenceRef, BrowserSession, BrowserTab, NavigationBudget, } from "@umt/contracts"; import type { BrowserControlLeaseRow, BrowserSessionRow, BrowserTabRow } from "@umt/db"; // C1-E 浏览器服务层:BrowserProvider(camofox)之上的会话/标签/控制权持久化(数据库为准)、 // 站点域名白名单 + 站点限流、凭证注入、接管状态机、下载产物暂存核验。 // 适配器只是内存镜像:服务重启后按数据库恢复,旧元素引用一律失效。 export const iso = (d: Date | string | null | undefined) => (d ? new Date(d).toISOString() : null); export type Json = Record; export const obj = (v: unknown): Json => (v && typeof v === "object" && !Array.isArray(v) ? (v as Json) : {}); export const REASON_TEXT: Record = { cf_challenge: "站点要求人机验证(Cloudflare),需要你过一次", captcha: "登录页需要验证码", challenge: "页面需要人工验证", two_factor: "需要二次验证或本人设备确认", challenge_unsolved: "页面验证没能自动通过", login_required: "登录已失效:接管后在画面里登录一次,交还后全站生效", lease_expired: "人工接管超时未交还", agent_request: "智能体请求人工处理", human_released_waiting: "人工交还时选择继续等待", }; /** 重连回到的地址:会话记录的地址是 http(s)、在站点域名内、没有被脱敏过才用;否则 null(调用方退回站点首页) */ export function restoreUrl(url: string | null, budget: NavigationBudget): string | null { const u = url ? safeUrl(url) : null; if (!u || !/^https?:$/.test(u.protocol) || !hostAllowed(u.hostname, budget.domainAllowlist)) return null; if (url!.includes("[REDACTED")) return null; return url; } export function safeUrl(s: string): URL | null { try { return new URL(s); } catch { return null; } } export function holderName(holder: string): string { return holder.includes(":") ? holder.slice(holder.indexOf(":") + 1) : holder; } export function toSession(r: BrowserSessionRow): BrowserSession { return { id: r.id, providerId: r.provider_id, siteId: r.site_id, accountId: r.account_id, profileKey: r.profile_key, controller: r.controller as BrowserController, currentUrl: r.current_url, lastActivityAt: iso(r.last_activity_at)!, upstreamRef: obj(r.upstream_ref), recoveryState: obj(r.recovery_state), closedAt: iso(r.closed_at), closeReason: r.close_reason, createdAt: iso(r.created_at)!, updatedAt: iso(r.updated_at)!, }; } export function toTab(r: BrowserTabRow): BrowserTab { return { id: r.id, sessionId: r.session_id, upstreamTabId: r.upstream_tab_id, url: r.url, title: r.title, status: r.status as BrowserTab["status"], lastSnapshotAt: iso(r.last_snapshot_at), closedAt: iso(r.closed_at), createdAt: iso(r.created_at)!, updatedAt: iso(r.updated_at)!, }; } export function toLease(r: BrowserControlLeaseRow): BrowserControlLease { return { id: r.id, sessionId: r.session_id, holderKind: r.holder_kind as BrowserControlLease["holderKind"], holder: r.holder, reason: r.reason, status: r.status as BrowserControlLease["status"], acquiredAt: iso(r.acquired_at)!, expiresAt: iso(r.expires_at)!, releasedAt: iso(r.released_at), releaseReason: r.release_reason, createdAt: iso(r.created_at)!, updatedAt: iso(r.updated_at)!, }; } /** 视觉子流程可以接手的等待原因(验证码 / 页面挑战 / 上次没处理成功) */ export const SOLVABLE = ["captcha", "challenge", "challenge_unsolved", "cf_challenge"]; /** capture / visionAct 的结果(能力输出的公共部分) */ export interface VisionShot { evidence: BrowserEvidenceRef | null; url: string | null; controller: BrowserController; sensitive: boolean; imageForModel: boolean; note: string | null; textSnapshot: string | null; coordinates: BrowserCoordinateSpace | null; waitingReason: string | null; modelImage: { mimeType: "image/png" | "image/jpeg"; data: string; width: number; height: number } | null; } /** 模型坐标系:缩放后的截图 ↔ 页面视口;取不到页面度量时按原始截图尺寸、DPR 1 推定 */ export function coordinateSpace( src: { width: number; height: number }, img: { width: number; height: number }, m: PageMetrics | null, ): BrowserCoordinateSpace { return { imageWidth: img.width, imageHeight: img.height, sourceWidth: src.width, sourceHeight: src.height, viewportWidth: m?.viewportWidth ?? src.width, viewportHeight: m?.viewportHeight ?? src.height, devicePixelRatio: m?.devicePixelRatio ?? 1, assumed: !m, }; } /** 截图像素(交给模型的缩放图)→ 页面 CSS 像素:按像素中心换算,四舍五入 */ export function screenToPage(sp: BrowserCoordinateSpace, x: number, y: number): { pageX: number; pageY: number } { return { pageX: Math.round(((x + 0.5) * sp.viewportWidth) / sp.imageWidth - 0.5), pageY: Math.round(((y + 0.5) * sp.viewportHeight) / sp.imageHeight - 0.5), }; } /** 截图像素尺寸:PNG IHDR / JPEG SOF */ export function imageSize(data: Uint8Array): { width: number | null; height: number | null } { if (data.length > 24 && data[0] === 0x89 && data[1] === 0x50) { const v = new DataView(data.buffer, data.byteOffset, data.byteLength); return { width: v.getUint32(16), height: v.getUint32(20) }; } if (data[0] === 0xff && data[1] === 0xd8) { let i = 2; while (i + 9 < data.length) { if (data[i] !== 0xff) break; const marker = data[i + 1]!; const len = (data[i + 2]! << 8) | data[i + 3]!; if (marker >= 0xc0 && marker <= 0xc3) return { width: (data[i + 7]! << 8) | data[i + 8]!, height: (data[i + 5]! << 8) | data[i + 6]! }; i += 2 + len; } } return { width: null, height: null }; } /** 代理地址 → 浏览器上下文代理(账号口令拆出来;Playwright 不认 URL 里的口令) */ export function egressProxy(url: string): BrowserEgressProxy | null { try { const u = new URL(url); if (!["http:", "https:", "socks4:", "socks5:"].includes(u.protocol) || !u.hostname) return null; return { server: `${u.protocol}//${u.host}`, ...(u.username ? { username: decodeURIComponent(u.username) } : {}), ...(u.password ? { password: decodeURIComponent(u.password) } : {}), }; } catch { return null; } }