refactor: 拆分大文件(contracts 端点、整理服务、浏览器纯函数、整理弹窗来源)
- contracts/src/api.ts 2733 行 → api/*.ts 13 个领域文件 + shared.ts;api.ts 按原顺序合并 Api。 新旧对比:354 个端点内容与顺序一致,分组键与对外导出一致 - acquisition-organize/services/organize.ts 2645 行 → organize-types / -plan / -exec / -finalize / -records, organize.ts 保留为汇总入口,外部引用不变 - browser/service.ts 类外的纯函数(视图映射、原因文案、坐标换算、图片尺寸、出口代理)移到 session-view.ts, service.ts 照旧转出 - organize-wizard:来源解析 useResolvedSource 移到 source.ts,FormSelect 移到 form-select.tsx 只挪位置不改逻辑;typecheck、lint、test:all、web build、server 编译与编译产物自检通过。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
c9e8120fc6
commit
8bec4bad5d
26 files changed
+5906
-5696
No files matched your search
@@ -0,0 +1,181 @@
|
||||
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<string, unknown>;
|
||||
export const obj = (v: unknown): Json => (v && typeof v === "object" && !Array.isArray(v) ? (v as Json) : {});
|
||||
|
||||
export const REASON_TEXT: Record<string, string> = {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user