Auto-fix of 602 string literals across 74 files (half-width punctuation next to Chinese, parenthesised Chinese, quotes); biome reflow; e2/e6 assertions follow the new full-width summaries. check:copy now runs with --strict in ci. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1005 lines
38 KiB
TypeScript
1005 lines
38 KiB
TypeScript
import { mkdir, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
import { join, resolve } from "node:path";
|
|
import {
|
|
BrowserError,
|
|
type BrowserProvider,
|
|
type BrowserSecretResolver,
|
|
createCamofoxBrowserProvider,
|
|
type DownloadSink,
|
|
type ElementTarget,
|
|
parseTorrent,
|
|
profileKeyFor,
|
|
type TabRef,
|
|
totpCode,
|
|
} from "@umt/adapters";
|
|
import type {
|
|
BrowserCapabilities,
|
|
BrowserControlLease,
|
|
BrowserController,
|
|
BrowserDownloadVerification,
|
|
BrowserHealth,
|
|
BrowserProviderView,
|
|
BrowserSession,
|
|
BrowserSessionView,
|
|
BrowserTab,
|
|
DownloadArtifact,
|
|
NavigationBudget,
|
|
} from "@umt/contracts";
|
|
import type { BrowserControlLeaseRow, BrowserProviderRow, BrowserSessionRow, BrowserTabRow, SiteRow } from "@umt/db";
|
|
import type { SourcesRuntime } from "../sources-identity/runtime.ts";
|
|
import type { ServerContext } from "../types.ts";
|
|
|
|
// C1-E 浏览器服务层:BrowserProvider(camofox)之上的会话/标签/控制权持久化(数据库为准)、
|
|
// 站点域名白名单 + 站点限流、凭证注入、接管状态机、下载产物暂存核验。
|
|
// 适配器只是内存镜像:服务重启后按数据库恢复,旧元素引用一律失效。
|
|
|
|
export type Actor = { kind: "automation" | "human"; holder: string };
|
|
|
|
export interface BrowserModuleOptions {
|
|
/** camofox 请求用的 fetch(测试指向 mock) */
|
|
fetch?: typeof globalThis.fetch;
|
|
/** 站点限流器所在的运行时(与快照/实时搜索共享) */
|
|
runtime?: (ctx: ServerContext) => SourcesRuntime;
|
|
now?: () => Date;
|
|
/** 下载产物暂存目录;缺省 DATA_DIR/tmp/browser-downloads */
|
|
stagingDir?: string;
|
|
downloadPollMs?: number;
|
|
/** 测试替换 provider 实现 */
|
|
providerFactory?: (row: BrowserProviderRow, deps: ProviderDeps) => BrowserProvider;
|
|
}
|
|
|
|
export interface ProviderDeps {
|
|
secrets: BrowserSecretResolver;
|
|
sink: DownloadSink;
|
|
fetch?: typeof globalThis.fetch;
|
|
downloadPollMs?: number;
|
|
}
|
|
|
|
/** 业务错误(路由层转 ApiException,能力层转 CapabilityError) */
|
|
export class BrowserServiceError extends Error {
|
|
constructor(
|
|
readonly code: "not_found" | "conflict" | "precondition" | "bad_request" | "upstream" | "rate_limited",
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
this.name = "BrowserServiceError";
|
|
}
|
|
}
|
|
|
|
/** BrowserError → 业务错误;消息只含方法/路径/状态,不含凭证 */
|
|
export function fromBrowserError(e: unknown): unknown {
|
|
if (!(e instanceof BrowserError)) return e;
|
|
switch (e.kind) {
|
|
case "control_conflict":
|
|
case "lock_timeout":
|
|
return new BrowserServiceError("conflict", e.message);
|
|
case "stale_ref":
|
|
return new BrowserServiceError("conflict", e.message);
|
|
case "not_found":
|
|
return new BrowserServiceError("not_found", e.message);
|
|
case "domain_blocked":
|
|
case "bad_request":
|
|
case "budget_exceeded":
|
|
return new BrowserServiceError("bad_request", e.message);
|
|
case "auth":
|
|
return new BrowserServiceError("precondition", e.message);
|
|
case "rate_limited":
|
|
return new BrowserServiceError("rate_limited", e.message);
|
|
default:
|
|
return new BrowserServiceError("upstream", `浏览器服务不可用:${e.message}`);
|
|
}
|
|
}
|
|
|
|
const HEALTH: Record<BrowserHealth["status"], "healthy" | "degraded" | "offline" | "unknown"> = {
|
|
ok: "healthy",
|
|
degraded: "degraded",
|
|
down: "offline",
|
|
disabled: "unknown",
|
|
};
|
|
|
|
/** 验证码 / 二次认证 / Cloudflare 挑战:自动化不能完成,转等待处理 */
|
|
const HUMAN_CHALLENGE =
|
|
/验证码|captcha|二次验证|两步验证|二步验证|2fa|two-?step|one-time code|cf-challenge|just a moment|checking your browser/i;
|
|
|
|
const iso = (d: Date | string | null | undefined) => (d ? new Date(d).toISOString() : null);
|
|
type Json = Record<string, unknown>;
|
|
const obj = (v: unknown): Json => (v && typeof v === "object" && !Array.isArray(v) ? (v as Json) : {});
|
|
|
|
export interface RecoveryState {
|
|
currentTabId?: string | null;
|
|
needsSnapshot?: boolean;
|
|
waitingReason?: string | null;
|
|
waitingDecisionId?: string | null;
|
|
releasedAt?: string;
|
|
downloads?: { sha256: string; artifactId: string; sourceReleaseId: string | null; planId: string | null }[];
|
|
}
|
|
|
|
export class BrowserService {
|
|
private readonly providers = new Map<string, { sig: string; provider: BrowserProvider }>();
|
|
readonly now: () => Date;
|
|
|
|
constructor(
|
|
readonly ctx: ServerContext,
|
|
readonly opts: BrowserModuleOptions = {},
|
|
) {
|
|
this.now = opts.now ?? (() => new Date());
|
|
}
|
|
|
|
// ---------- 浏览器服务 ----------
|
|
|
|
private stagingDir(): string {
|
|
if (this.opts.stagingDir) return this.opts.stagingDir;
|
|
const dataDir = (this.ctx.config as { DATA_DIR?: string }).DATA_DIR ?? "./data";
|
|
return join(resolve(dataDir), "tmp", "browser-downloads");
|
|
}
|
|
|
|
/** 下载产物落 DATA_DIR/tmp/browser-downloads(0600);超过 24 小时的顺手清掉 */
|
|
private sink(): DownloadSink {
|
|
return async (bytes, meta) => {
|
|
const dir = this.stagingDir();
|
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
await this.cleanupStaging(dir);
|
|
const name = `${meta.id}.bin`;
|
|
await writeFile(join(dir, name), bytes, { mode: 0o600 });
|
|
return { kind: "blob", ref: `browser-downloads/${name}` };
|
|
};
|
|
}
|
|
|
|
private async cleanupStaging(dir: string): Promise<void> {
|
|
const cutoff = Date.now() - 24 * 3_600_000;
|
|
for (const f of await readdir(dir).catch(() => [] as string[])) {
|
|
const p = join(dir, f);
|
|
const st = await stat(p).catch(() => null);
|
|
if (st && st.mtimeMs < cutoff) await rm(p, { force: true }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
stagedPath(ref: string): string {
|
|
return join(this.stagingDir(), ref.replace(/^browser-downloads\//, "").replace(/[/\\]/g, "_"));
|
|
}
|
|
|
|
/** 凭证解析:浏览器服务访问凭据与站点账号登录字段;明文不缓存、不入日志 */
|
|
private secretsFor(row: BrowserProviderRow): BrowserSecretResolver {
|
|
const { db, secrets } = this.ctx;
|
|
return {
|
|
resolve: async (req) => {
|
|
if (req.kind === "provider_access_key") return row.secret_ref ? secrets.resolve(row.secret_ref) : null;
|
|
let q = db
|
|
.selectFrom("site_account")
|
|
.selectAll()
|
|
.where("site_id", "=", req.siteId)
|
|
.where("enabled", "=", true)
|
|
.where("auth_kind", "=", "password");
|
|
if (req.accountId) q = q.where("id", "=", req.accountId);
|
|
const acc = await q.orderBy("created_at").executeTakeFirst();
|
|
if (!acc) return null;
|
|
if (req.field === "username")
|
|
return acc.login_name ?? (obj(acc.user_info).username as string | undefined) ?? null;
|
|
if (req.field === "password") return acc.secret_ref ? secrets.resolve(acc.secret_ref) : null;
|
|
return acc.totp_secret_ref ? totpCode(await secrets.resolve(acc.totp_secret_ref), this.now()) : null;
|
|
},
|
|
};
|
|
}
|
|
|
|
providerFor(row: BrowserProviderRow): BrowserProvider {
|
|
const sig = JSON.stringify([row.base_url, row.secret_ref, row.config, row.kind]);
|
|
const hit = this.providers.get(row.id);
|
|
if (hit && hit.sig === sig) return hit.provider;
|
|
const deps: ProviderDeps = {
|
|
secrets: this.secretsFor(row),
|
|
sink: this.sink(),
|
|
...(this.opts.fetch ? { fetch: this.opts.fetch } : {}),
|
|
...(this.opts.downloadPollMs ? { downloadPollMs: this.opts.downloadPollMs } : {}),
|
|
};
|
|
const cfg = obj(row.config);
|
|
const provider =
|
|
this.opts.providerFactory?.(row, deps) ??
|
|
createCamofoxBrowserProvider({
|
|
providerId: row.id,
|
|
baseUrl: row.base_url ?? "",
|
|
secrets: deps.secrets,
|
|
downloadSink: deps.sink,
|
|
verified: obj(cfg.verified) as Record<string, boolean>,
|
|
...(typeof cfg.requestTimeoutMs === "number" ? { timeoutMs: cfg.requestTimeoutMs } : {}),
|
|
...(deps.fetch ? { fetch: deps.fetch } : {}),
|
|
...(deps.downloadPollMs ? { downloadPollMs: deps.downloadPollMs } : {}),
|
|
now: this.now,
|
|
logger: (e) => this.ctx.logger[e.level === "warn" ? "warn" : "debug"]({ component: "browser", ...e }, e.msg),
|
|
});
|
|
this.providers.set(row.id, { sig, provider });
|
|
return provider;
|
|
}
|
|
|
|
async providerRow(id?: string): Promise<BrowserProviderRow> {
|
|
let q = this.ctx.db
|
|
.selectFrom("browser_provider")
|
|
.selectAll()
|
|
.where("enabled", "=", true)
|
|
.where("kind", "<>", "none");
|
|
if (id) q = q.where("id", "=", id);
|
|
const row = await q.orderBy("created_at").executeTakeFirst();
|
|
if (!row)
|
|
throw new BrowserServiceError("precondition", "没有启用的浏览器服务(camofox),见 docs/runbook/browser.md");
|
|
return row;
|
|
}
|
|
|
|
async listProviders(probe: boolean): Promise<BrowserProviderView[]> {
|
|
const rows = await this.ctx.db.selectFrom("browser_provider").selectAll().orderBy("created_at").execute();
|
|
const out: BrowserProviderView[] = [];
|
|
for (let row of rows) {
|
|
let health: BrowserHealth | null = null;
|
|
if (probe && row.enabled && row.kind !== "none") {
|
|
const p = this.providerFor(row);
|
|
health = await p.health();
|
|
let caps: BrowserCapabilities | null = null;
|
|
try {
|
|
caps = await p.capabilities({ probe: health.status !== "down" });
|
|
} catch {
|
|
caps = await p.capabilities();
|
|
}
|
|
row = await this.ctx.db
|
|
.updateTable("browser_provider")
|
|
.set({
|
|
capabilities: JSON.stringify(caps),
|
|
health_status: HEALTH[health.status],
|
|
last_health_at: new Date(health.checkedAt),
|
|
})
|
|
.where("id", "=", row.id)
|
|
.returningAll()
|
|
.executeTakeFirstOrThrow();
|
|
}
|
|
const caps = obj(row.capabilities);
|
|
out.push({
|
|
id: row.id,
|
|
name: row.name,
|
|
kind: row.kind as "camofox" | "none",
|
|
baseUrl: row.base_url,
|
|
secretRef: row.secret_ref,
|
|
enabled: row.enabled,
|
|
capabilities: Object.keys(caps).length ? (caps as unknown as BrowserCapabilities) : null,
|
|
verifiedAt: iso(row.verified_at),
|
|
healthStatus: row.health_status as BrowserProviderView["healthStatus"],
|
|
lastHealthAt: iso(row.last_health_at),
|
|
health,
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ---------- 站点与预算 ----------
|
|
|
|
async liveSite(siteId: string): Promise<SiteRow> {
|
|
const s = await this.ctx.db
|
|
.selectFrom("site")
|
|
.selectAll()
|
|
.where("id", "=", siteId)
|
|
.where("deleted_at", "is", null)
|
|
.executeTakeFirst();
|
|
if (!s) throw new BrowserServiceError("not_found", "站点不存在");
|
|
return s;
|
|
}
|
|
|
|
/** 导航预算:站点域名(+ config.browserDomains)、超时、重试、字节上限(浏览器服务 config 可覆盖) */
|
|
budgetFor(site: SiteRow, provider: BrowserProviderRow): NavigationBudget {
|
|
const cfg = obj(provider.config);
|
|
const extra = obj(site.config).browserDomains;
|
|
const hosts = [new URL(site.base_url).hostname, ...(Array.isArray(extra) ? extra.map(String) : [])];
|
|
return {
|
|
domainAllowlist: hosts,
|
|
timeoutMs: typeof cfg.navigationTimeoutMs === "number" ? cfg.navigationTimeoutMs : 30_000,
|
|
maxRetries: typeof cfg.maxRetries === "number" ? cfg.maxRetries : 2,
|
|
maxBytes: typeof cfg.snapshotMaxBytes === "number" ? cfg.snapshotMaxBytes : 512 * 1024,
|
|
};
|
|
}
|
|
|
|
/** 站点限流对快照、实时搜索和浏览器统一生效(REQ §4.6):导航前取同一个限流令牌 */
|
|
private async rateLimit(site: SiteRow): Promise<void> {
|
|
const rt = this.opts.runtime?.(this.ctx);
|
|
if (!rt) return;
|
|
try {
|
|
rt.siteAdapter(site);
|
|
} catch {
|
|
return; // 没有随产品发布的适配器定义:无站点限流器
|
|
}
|
|
await rt.limiterOf(site.id)?.acquire();
|
|
}
|
|
|
|
// ---------- 会话 ----------
|
|
|
|
private async sessionRow(id: string): Promise<BrowserSessionRow> {
|
|
const row = await this.ctx.db.selectFrom("browser_session").selectAll().where("id", "=", id).executeTakeFirst();
|
|
if (!row) throw new BrowserServiceError("not_found", "浏览器会话不存在");
|
|
return row;
|
|
}
|
|
|
|
private async activeLease(sessionId: string): Promise<BrowserControlLeaseRow | undefined> {
|
|
return this.ctx.db
|
|
.selectFrom("browser_control_lease")
|
|
.selectAll()
|
|
.where("session_id", "=", sessionId)
|
|
.where("status", "=", "active")
|
|
.executeTakeFirst();
|
|
}
|
|
|
|
/** 会话加载进适配器(服务重启后按数据库恢复);返回 provider 与最新行 */
|
|
private async loaded(row: BrowserSessionRow): Promise<{ provider: BrowserProvider; row: BrowserSessionRow }> {
|
|
const prow = await this.ctx.db
|
|
.selectFrom("browser_provider")
|
|
.selectAll()
|
|
.where("id", "=", row.provider_id)
|
|
.executeTakeFirst();
|
|
if (!prow?.enabled) {
|
|
await this.closeRow(row, "provider_disabled");
|
|
throw new BrowserServiceError("precondition", "浏览器服务已停用,会话已关闭");
|
|
}
|
|
const site = await this.liveSite(row.site_id).catch(() => null);
|
|
if (!site?.enabled) {
|
|
// 站点关闭即停止其网络访问(REQ §4.6、§4.7.3)
|
|
await this.closeRow(row, "site_disabled", prow);
|
|
throw new BrowserServiceError("precondition", "站点已停用,浏览器会话已关闭");
|
|
}
|
|
const provider = this.providerFor(prow);
|
|
if (!provider.sessionInfo(row.id)) {
|
|
const tabs = await this.tabRows(row.id);
|
|
provider.restoreSession({
|
|
session: toSession(row),
|
|
tabs: tabs.map(toTab),
|
|
currentTabId: (obj(row.recovery_state) as RecoveryState).currentTabId ?? null,
|
|
budget: this.budgetFor(site, prow),
|
|
});
|
|
// 重启后旧引用失效:Agent 必须重新读取页面
|
|
row = await this.patchRecovery(row, { needsSnapshot: true });
|
|
}
|
|
return { provider, row };
|
|
}
|
|
|
|
private tabRows(sessionId: string): Promise<BrowserTabRow[]> {
|
|
return this.ctx.db
|
|
.selectFrom("browser_tab")
|
|
.selectAll()
|
|
.where("session_id", "=", sessionId)
|
|
.orderBy("created_at")
|
|
.execute();
|
|
}
|
|
|
|
private async patchRecovery(row: BrowserSessionRow, patch: RecoveryState): Promise<BrowserSessionRow> {
|
|
return this.ctx.db
|
|
.updateTable("browser_session")
|
|
.set({ recovery_state: JSON.stringify({ ...obj(row.recovery_state), ...patch }), updated_at: this.now() })
|
|
.where("id", "=", row.id)
|
|
.returningAll()
|
|
.executeTakeFirstOrThrow();
|
|
}
|
|
|
|
/** 适配器内存状态 → 数据库(会话地址/活动时间/控制者、标签页) */
|
|
private async persist(sessionId: string, provider: BrowserProvider, patch: RecoveryState = {}): Promise<void> {
|
|
const info = provider.sessionInfo(sessionId);
|
|
if (!info) return;
|
|
const row = await this.sessionRow(sessionId);
|
|
await this.ctx.db.transaction().execute(async (trx) => {
|
|
await trx
|
|
.updateTable("browser_session")
|
|
.set({
|
|
current_url: info.session.currentUrl,
|
|
last_activity_at: new Date(info.session.lastActivityAt),
|
|
recovery_state: JSON.stringify({
|
|
...obj(row.recovery_state),
|
|
currentTabId: info.currentTabId,
|
|
...patch,
|
|
}),
|
|
updated_at: this.now(),
|
|
})
|
|
.where("id", "=", sessionId)
|
|
.execute();
|
|
for (const t of info.tabs)
|
|
await trx
|
|
.insertInto("browser_tab")
|
|
.values({
|
|
id: t.id,
|
|
session_id: sessionId,
|
|
upstream_tab_id: t.upstreamTabId,
|
|
url: t.url,
|
|
title: t.title,
|
|
status: t.status,
|
|
last_snapshot_at: t.lastSnapshotAt ? new Date(t.lastSnapshotAt) : null,
|
|
closed_at: t.closedAt ? new Date(t.closedAt) : null,
|
|
})
|
|
.onConflict((oc) =>
|
|
oc.columns(["session_id", "upstream_tab_id"]).doUpdateSet({
|
|
url: t.url,
|
|
status: t.status,
|
|
last_snapshot_at: t.lastSnapshotAt ? new Date(t.lastSnapshotAt) : null,
|
|
closed_at: t.closedAt ? new Date(t.closedAt) : null,
|
|
updated_at: this.now(),
|
|
}),
|
|
)
|
|
.execute();
|
|
});
|
|
}
|
|
|
|
async view(id: string): Promise<BrowserSessionView> {
|
|
let row = await this.sessionRow(id);
|
|
row = await this.expireLeases(row);
|
|
const [tabs, lease] = await Promise.all([this.tabRows(id), this.activeLease(id)]);
|
|
const rs = obj(row.recovery_state) as RecoveryState;
|
|
return {
|
|
...toSession(row),
|
|
tabs: tabs.map(toTab),
|
|
currentTabId: rs.currentTabId ?? tabs.find((t) => t.status === "open")?.upstream_tab_id ?? null,
|
|
lease: lease ? toLease(lease) : null,
|
|
needsSnapshot: rs.needsSnapshot === true,
|
|
waitingReason: row.controller === "waiting" || row.controller === "human" ? (rs.waitingReason ?? null) : null,
|
|
};
|
|
}
|
|
|
|
async list(q: { siteId?: string; includeClosed?: boolean }): Promise<BrowserSessionView[]> {
|
|
let sel = this.ctx.db.selectFrom("browser_session").select("id").orderBy("last_activity_at", "desc").limit(100);
|
|
if (q.siteId) sel = sel.where("site_id", "=", q.siteId);
|
|
if (!q.includeClosed) sel = sel.where("controller", "<>", "closed");
|
|
const rows = await sel.execute();
|
|
return Promise.all(rows.map((r) => this.view(r.id)));
|
|
}
|
|
|
|
/** 站点(账号)对应的未关闭会话 */
|
|
async liveSessionFor(siteId: string, accountId?: string | null): Promise<BrowserSessionRow | undefined> {
|
|
let q = this.ctx.db
|
|
.selectFrom("browser_session")
|
|
.selectAll()
|
|
.where("site_id", "=", siteId)
|
|
.where("controller", "<>", "closed");
|
|
if (accountId !== undefined)
|
|
q = accountId === null ? q.where("account_id", "is", null) : q.where("account_id", "=", accountId);
|
|
return q.orderBy("last_activity_at", "desc").executeTakeFirst();
|
|
}
|
|
|
|
/** 站点缺省账号:第一个启用的非 passkey 账号 */
|
|
private async defaultAccount(siteId: string): Promise<string | null> {
|
|
const a = await this.ctx.db
|
|
.selectFrom("site_account")
|
|
.select("id")
|
|
.where("site_id", "=", siteId)
|
|
.where("enabled", "=", true)
|
|
.where("auth_kind", "<>", "passkey")
|
|
.orderBy("created_at")
|
|
.executeTakeFirst();
|
|
return a?.id ?? null;
|
|
}
|
|
|
|
async open(req: { siteId: string; accountId?: string | null; providerId?: string; url?: string }): Promise<string> {
|
|
const site = await this.liveSite(req.siteId);
|
|
if (!site.enabled) throw new BrowserServiceError("precondition", "站点已停用");
|
|
const prow = await this.providerRow(req.providerId);
|
|
const accountId = req.accountId === undefined ? await this.defaultAccount(site.id) : req.accountId;
|
|
const profileKey = profileKeyFor(site.id, accountId);
|
|
const existing = await this.ctx.db
|
|
.selectFrom("browser_session")
|
|
.selectAll()
|
|
.where("provider_id", "=", prow.id)
|
|
.where("profile_key", "=", profileKey)
|
|
.where("controller", "<>", "closed")
|
|
.executeTakeFirst();
|
|
if (existing) {
|
|
await this.loaded(existing);
|
|
return existing.id;
|
|
}
|
|
const budget = this.budgetFor(site, prow);
|
|
const provider = this.providerFor(prow);
|
|
await this.rateLimit(site);
|
|
let res: Awaited<ReturnType<BrowserProvider["openSession"]>>;
|
|
try {
|
|
res = await provider.openSession({
|
|
providerId: prow.id,
|
|
siteId: site.id,
|
|
accountId,
|
|
profileKey,
|
|
initialUrl: req.url ?? site.base_url,
|
|
budget,
|
|
});
|
|
} catch (e) {
|
|
throw fromBrowserError(e);
|
|
}
|
|
const s = res.session;
|
|
await this.ctx.db.transaction().execute(async (trx) => {
|
|
await trx
|
|
.insertInto("browser_session")
|
|
.values({
|
|
id: s.id,
|
|
provider_id: prow.id,
|
|
site_id: site.id,
|
|
account_id: accountId,
|
|
profile_key: profileKey,
|
|
controller: "automation",
|
|
current_url: s.currentUrl,
|
|
last_activity_at: new Date(s.lastActivityAt),
|
|
upstream_ref: JSON.stringify(s.upstreamRef),
|
|
recovery_state: JSON.stringify({ currentTabId: res.tab.upstreamTabId, needsSnapshot: true }),
|
|
})
|
|
.execute();
|
|
await trx
|
|
.insertInto("browser_tab")
|
|
.values({
|
|
id: res.tab.id,
|
|
session_id: s.id,
|
|
upstream_tab_id: res.tab.upstreamTabId,
|
|
url: res.tab.url,
|
|
status: "open",
|
|
})
|
|
.execute();
|
|
});
|
|
return s.id;
|
|
}
|
|
|
|
private async closeRow(row: BrowserSessionRow, reason: string, prow?: BrowserProviderRow): Promise<void> {
|
|
const provider = prow ? this.providerFor(prow) : this.providers.get(row.provider_id)?.provider;
|
|
if (provider?.sessionInfo(row.id)) await provider.closeSession(row.id, reason).catch(() => {});
|
|
const at = this.now();
|
|
await this.ctx.db.transaction().execute(async (trx) => {
|
|
await trx
|
|
.updateTable("browser_session")
|
|
.set({ controller: "closed", closed_at: at, close_reason: reason, updated_at: at })
|
|
.where("id", "=", row.id)
|
|
.where("controller", "<>", "closed")
|
|
.execute();
|
|
await trx
|
|
.updateTable("browser_tab")
|
|
.set({ status: "closed", closed_at: at, updated_at: at })
|
|
.where("session_id", "=", row.id)
|
|
.where("status", "=", "open")
|
|
.execute();
|
|
await trx
|
|
.updateTable("browser_control_lease")
|
|
.set({ status: "revoked", released_at: at, release_reason: "session_closed", updated_at: at })
|
|
.where("session_id", "=", row.id)
|
|
.where("status", "=", "active")
|
|
.execute();
|
|
});
|
|
await this.resolveWaiting(row, "session_closed");
|
|
}
|
|
|
|
async close(id: string, reason = "closed"): Promise<void> {
|
|
const row = await this.sessionRow(id);
|
|
if (row.controller === "closed") return;
|
|
const prow = await this.ctx.db
|
|
.selectFrom("browser_provider")
|
|
.selectAll()
|
|
.where("id", "=", row.provider_id)
|
|
.executeTakeFirst();
|
|
if (prow?.enabled) {
|
|
const provider = this.providerFor(prow);
|
|
if (!provider.sessionInfo(row.id)) {
|
|
const site = await this.liveSite(row.site_id).catch(() => null);
|
|
if (site)
|
|
provider.restoreSession({
|
|
session: toSession(row),
|
|
tabs: (await this.tabRows(row.id)).map(toTab),
|
|
currentTabId: null,
|
|
budget: this.budgetFor(site, prow),
|
|
});
|
|
}
|
|
}
|
|
await this.closeRow(row, reason, prow);
|
|
}
|
|
|
|
// ---------- 控制权 ----------
|
|
|
|
/** 过期的人工租约 → expired,会话转等待处理(不自动从未知页面继续) */
|
|
async expireLeases(row: BrowserSessionRow): Promise<BrowserSessionRow> {
|
|
if (row.controller === "closed") return row;
|
|
const lease = await this.activeLease(row.id);
|
|
if (!lease || new Date(lease.expires_at).getTime() > this.now().getTime()) return row;
|
|
const at = this.now();
|
|
await this.ctx.db
|
|
.updateTable("browser_control_lease")
|
|
.set({ status: "expired", released_at: at, release_reason: "ttl", updated_at: at })
|
|
.where("id", "=", lease.id)
|
|
.where("status", "=", "active")
|
|
.execute();
|
|
if (lease.holder_kind !== "human") return row;
|
|
return this.enterWaiting(row, "lease_expired");
|
|
}
|
|
|
|
/** 转等待处理:自动化动作被拒,生成待处理事项,等人在原站视图接管 */
|
|
async enterWaiting(row: BrowserSessionRow, reason: string): Promise<BrowserSessionRow> {
|
|
const provider = this.providers.get(row.provider_id)?.provider;
|
|
if (provider?.sessionInfo(row.id)) provider.setController(row.id, "waiting");
|
|
const site = await this.liveSite(row.site_id).catch(() => null);
|
|
const rs = obj(row.recovery_state) as RecoveryState;
|
|
let decisionId = rs.waitingDecisionId ?? null;
|
|
if (!decisionId) {
|
|
const d = await this.ctx.db
|
|
.insertInto("decision")
|
|
.values({
|
|
kind: "other",
|
|
status: "awaiting_user",
|
|
severity: "warning",
|
|
title: `${site?.name ?? "站点"}:原站需要人工处理`,
|
|
summary: `${REASON_TEXT[reason] ?? reason}。在站点详情的原站视图接管会话,完成后交还。`,
|
|
subject_type: "browser_session",
|
|
subject_id: row.id,
|
|
dedupe_key: `browser.waiting:${row.id}`,
|
|
evidence: JSON.stringify({ siteId: row.site_id, reason, url: row.current_url }),
|
|
})
|
|
.onConflict((oc) =>
|
|
oc.column("dedupe_key").where("status", "in", ["open", "investigating", "awaiting_user"]).doNothing(),
|
|
)
|
|
.returning("id")
|
|
.executeTakeFirst();
|
|
decisionId = d?.id ?? null;
|
|
}
|
|
return this.ctx.db
|
|
.updateTable("browser_session")
|
|
.set({
|
|
controller: "waiting",
|
|
recovery_state: JSON.stringify({ ...rs, waitingReason: reason, waitingDecisionId: decisionId }),
|
|
updated_at: this.now(),
|
|
})
|
|
.where("id", "=", row.id)
|
|
.where("controller", "<>", "closed")
|
|
.returningAll()
|
|
.executeTakeFirstOrThrow();
|
|
}
|
|
|
|
private async resolveWaiting(row: BrowserSessionRow, by: string): Promise<void> {
|
|
const id = (obj(row.recovery_state) as RecoveryState).waitingDecisionId;
|
|
if (!id) return;
|
|
await this.ctx.db
|
|
.updateTable("decision")
|
|
.set({
|
|
status: "resolved",
|
|
resolved_at: this.now(),
|
|
resolved_by: by,
|
|
resolution: JSON.stringify({ via: "browser" }),
|
|
})
|
|
.where("id", "=", id)
|
|
.where("status", "in", ["open", "investigating", "awaiting_user"])
|
|
.execute();
|
|
}
|
|
|
|
/**
|
|
* 人工接管(REQ §4.7.3):先把适配器控制者切到 human(新的自动化输入立即被拒),
|
|
* 等该 profile 上进行中的操作收尾,再写租约;其他会话不受影响。
|
|
*/
|
|
async acquire(id: string, holder: string, reason: string, ttlMs: number): Promise<BrowserControlLease> {
|
|
let row = await this.expireLeases(await this.sessionRow(id));
|
|
if (row.controller === "closed") throw new BrowserServiceError("conflict", "会话已关闭");
|
|
const current = await this.activeLease(id);
|
|
if (current?.holder_kind === "human") {
|
|
if (current.holder === holder) return toLease(current);
|
|
throw new BrowserServiceError("conflict", `会话已由 ${holderName(current.holder)} 接管`);
|
|
}
|
|
const { provider } = await this.loaded(row);
|
|
provider.setController(id, "human");
|
|
try {
|
|
await provider.drain(id);
|
|
} catch (e) {
|
|
throw fromBrowserError(e);
|
|
}
|
|
const at = this.now();
|
|
row = await this.sessionRow(id);
|
|
const lease = await this.ctx.db.transaction().execute(async (trx) => {
|
|
if (current)
|
|
await trx
|
|
.updateTable("browser_control_lease")
|
|
.set({ status: "revoked", released_at: at, release_reason: "human_takeover", updated_at: at })
|
|
.where("id", "=", current.id)
|
|
.execute();
|
|
const l = await trx
|
|
.insertInto("browser_control_lease")
|
|
.values({
|
|
session_id: id,
|
|
holder_kind: "human",
|
|
holder,
|
|
reason,
|
|
status: "active",
|
|
acquired_at: at,
|
|
expires_at: new Date(at.getTime() + ttlMs),
|
|
})
|
|
.returningAll()
|
|
.executeTakeFirstOrThrow();
|
|
await trx
|
|
.updateTable("browser_session")
|
|
.set({
|
|
controller: "human",
|
|
recovery_state: JSON.stringify({
|
|
...obj(row.recovery_state),
|
|
takeoverAt: at.toISOString(),
|
|
previousController: row.controller,
|
|
}),
|
|
updated_at: at,
|
|
})
|
|
.where("id", "=", id)
|
|
.execute();
|
|
return l;
|
|
});
|
|
return toLease(lease);
|
|
}
|
|
|
|
/** 交还:回到自动化(或留在等待处理);Agent 恢复前必须重新读取页面,不复用旧引用、不重放旧点击 */
|
|
async release(
|
|
id: string,
|
|
opts: { leaseId?: string; reason: string; to: "automation" | "waiting" },
|
|
): Promise<BrowserControlLease> {
|
|
const row = await this.sessionRow(id);
|
|
const lease = await this.activeLease(id);
|
|
if (lease?.holder_kind !== "human" || (opts.leaseId && lease.id !== opts.leaseId))
|
|
throw new BrowserServiceError("conflict", "没有可交还的人工接管");
|
|
const at = this.now();
|
|
const { provider } = await this.loaded(row);
|
|
provider.setController(id, opts.to);
|
|
const rs = obj(row.recovery_state) as RecoveryState;
|
|
const released = await this.ctx.db.transaction().execute(async (trx) => {
|
|
const l = await trx
|
|
.updateTable("browser_control_lease")
|
|
.set({ status: "released", released_at: at, release_reason: opts.reason, updated_at: at })
|
|
.where("id", "=", lease.id)
|
|
.returningAll()
|
|
.executeTakeFirstOrThrow();
|
|
await trx
|
|
.updateTable("browser_session")
|
|
.set({
|
|
controller: opts.to,
|
|
recovery_state: JSON.stringify({
|
|
...rs,
|
|
needsSnapshot: true,
|
|
releasedAt: at.toISOString(),
|
|
waitingReason: opts.to === "waiting" ? (rs.waitingReason ?? "human_released_waiting") : null,
|
|
...(opts.to === "automation" ? { waitingDecisionId: null } : {}),
|
|
}),
|
|
updated_at: at,
|
|
})
|
|
.where("id", "=", id)
|
|
.execute();
|
|
return l;
|
|
});
|
|
if (opts.to === "automation") await this.resolveWaiting(row, lease.holder);
|
|
await this.persist(id, provider);
|
|
return toLease(released);
|
|
}
|
|
|
|
// ---------- 动作 ----------
|
|
|
|
/**
|
|
* 执行一次会话动作:会话存活 → 租约过期检查 → 发起方与控制者一致(人工须持有自己的租约)→ 适配器 → 回写数据库。
|
|
* 上游标签页已被回收(404)时对账标 lost 并报冲突,不盲目重放。
|
|
*/
|
|
async run<T>(
|
|
id: string,
|
|
actor: Actor,
|
|
fn: (
|
|
p: BrowserProvider,
|
|
ref: TabRef,
|
|
ctx: { row: BrowserSessionRow; site: SiteRow; budget: NavigationBudget },
|
|
) => Promise<T>,
|
|
opts: { tabId?: string; write?: boolean; persist?: boolean; patch?: (out: T) => RecoveryState } = {},
|
|
): Promise<T> {
|
|
let row = await this.expireLeases(await this.sessionRow(id));
|
|
if (row.controller === "closed") throw new BrowserServiceError("conflict", "会话已关闭");
|
|
if (opts.write !== false) {
|
|
if (actor.kind === "human") {
|
|
const lease = await this.activeLease(id);
|
|
if (row.controller !== "human" || !lease || lease.holder !== actor.holder)
|
|
throw new BrowserServiceError("conflict", "先接管会话再操作");
|
|
} else if (row.controller !== "automation") {
|
|
const rs = obj(row.recovery_state) as RecoveryState;
|
|
throw new BrowserServiceError(
|
|
"conflict",
|
|
row.controller === "human"
|
|
? "会话正由人工接管,自动化动作已停止"
|
|
: `会话在等待人工处理(${REASON_TEXT[rs.waitingReason ?? ""] ?? rs.waitingReason ?? "未知原因"}),自动化动作已停止`,
|
|
);
|
|
}
|
|
}
|
|
const loaded = await this.loaded(row);
|
|
row = loaded.row;
|
|
const prow = await this.providerRow(row.provider_id).catch(() => null);
|
|
const site = await this.liveSite(row.site_id);
|
|
const budget = prow
|
|
? this.budgetFor(site, prow)
|
|
: { domainAllowlist: [], timeoutMs: 30_000, maxRetries: 0, maxBytes: 1 };
|
|
const ref: TabRef = { sessionId: id, as: actor.kind, ...(opts.tabId ? { tabId: opts.tabId } : {}) };
|
|
let out: T;
|
|
try {
|
|
out = await fn(loaded.provider, ref, { row, site, budget });
|
|
} catch (e) {
|
|
if (e instanceof BrowserError && e.kind === "not_found" && e.httpStatus === 404) {
|
|
const r = await loaded.provider.reconcileTabs(id).catch(() => null);
|
|
await this.persist(id, loaded.provider, { needsSnapshot: true });
|
|
if (r?.lost.length) throw new BrowserServiceError("conflict", "标签页已被浏览器服务回收,重新打开页面");
|
|
}
|
|
await this.persist(id, loaded.provider).catch(() => {});
|
|
throw fromBrowserError(e);
|
|
}
|
|
if (opts.persist !== false) await this.persist(id, loaded.provider, opts.patch?.(out) ?? {});
|
|
return out;
|
|
}
|
|
|
|
/** 导航前按站点限流取令牌 */
|
|
async limited(site: SiteRow): Promise<void> {
|
|
await this.rateLimit(site);
|
|
}
|
|
|
|
/** 快照后判断是否需要人工:只在自动化控制下、页面出现验证码/二次认证/挑战时转等待处理 */
|
|
async afterAutomationSnapshot(id: string, text: string, loginPage: boolean): Promise<void> {
|
|
const row = await this.sessionRow(id);
|
|
if (row.controller !== "automation") return;
|
|
if (HUMAN_CHALLENGE.test(text)) await this.enterWaiting(row, loginPage ? "captcha" : "challenge");
|
|
}
|
|
|
|
async requestHuman(id: string, reason: string): Promise<void> {
|
|
const row = await this.sessionRow(id);
|
|
if (row.controller === "closed") throw new BrowserServiceError("conflict", "会话已关闭");
|
|
if (row.controller !== "automation") return;
|
|
await this.enterWaiting(row, reason || "agent_request");
|
|
}
|
|
|
|
// ---------- 下载 ----------
|
|
|
|
/** 暂存产物核验:解析种子、按 infohash / 站点资源 ID 匹配本地资源、会话内去重 */
|
|
async verifyDownload(
|
|
id: string,
|
|
artifact: DownloadArtifact,
|
|
): Promise<{
|
|
verification: BrowserDownloadVerification;
|
|
sourceReleaseId: string | null;
|
|
duplicate: { planId: string | null } | null;
|
|
}> {
|
|
const row = await this.sessionRow(id);
|
|
const { readFile } = await import("node:fs/promises");
|
|
let verification: BrowserDownloadVerification = {
|
|
valid: false,
|
|
infohashV1: null,
|
|
infohashV2: null,
|
|
name: null,
|
|
totalSize: null,
|
|
private: null,
|
|
error: null,
|
|
};
|
|
try {
|
|
const bytes = new Uint8Array(await readFile(this.stagedPath(artifact.bytes.ref)));
|
|
const meta = parseTorrent(bytes);
|
|
verification = {
|
|
valid: true,
|
|
infohashV1: meta.infohashV1,
|
|
infohashV2: meta.infohashV2,
|
|
name: meta.name,
|
|
totalSize: meta.totalSize,
|
|
private: meta.private,
|
|
error: null,
|
|
};
|
|
} catch (e) {
|
|
verification.error = e instanceof Error ? e.message.slice(0, 200) : "无法解析";
|
|
}
|
|
let sourceReleaseId: string | null = null;
|
|
if (verification.valid) {
|
|
const hashes = [verification.infohashV1, verification.infohashV2].filter((h): h is string => !!h);
|
|
const hit = await this.ctx.db
|
|
.selectFrom("source_release")
|
|
.select("id")
|
|
.where("site_id", "=", row.site_id)
|
|
.where((eb) => eb.or([eb("infohash_v1", "in", hashes), eb("infohash_v2", "in", hashes)]))
|
|
.executeTakeFirst();
|
|
sourceReleaseId = hit?.id ?? null;
|
|
if (!sourceReleaseId && artifact.sourceUrl) {
|
|
const rid = safeUrl(artifact.sourceUrl)?.searchParams.get("id");
|
|
if (rid) {
|
|
const byId = await this.ctx.db
|
|
.selectFrom("source_release")
|
|
.select("id")
|
|
.where("site_id", "=", row.site_id)
|
|
.where("site_release_id", "=", rid)
|
|
.executeTakeFirst();
|
|
sourceReleaseId = byId?.id ?? null;
|
|
}
|
|
}
|
|
}
|
|
const rs = obj(row.recovery_state) as RecoveryState;
|
|
const prev = (rs.downloads ?? []).find((d) => d.sha256 === artifact.sha256);
|
|
return { verification, sourceReleaseId, duplicate: prev ? { planId: prev.planId } : null };
|
|
}
|
|
|
|
async recordDownload(
|
|
id: string,
|
|
entry: { sha256: string; artifactId: string; sourceReleaseId: string | null; planId: string | null },
|
|
): Promise<void> {
|
|
const row = await this.sessionRow(id);
|
|
const rs = obj(row.recovery_state) as RecoveryState;
|
|
const list = (rs.downloads ?? []).filter((d) => d.sha256 !== entry.sha256);
|
|
list.push(entry);
|
|
await this.patchRecovery(row, { downloads: list.slice(-50) });
|
|
}
|
|
}
|
|
|
|
const REASON_TEXT: Record<string, string> = {
|
|
captcha: "登录页需要验证码",
|
|
challenge: "页面需要人工验证",
|
|
login_required: "登录已失效",
|
|
lease_expired: "人工接管超时未交还",
|
|
agent_request: "Agent 请求人工处理",
|
|
human_released_waiting: "人工交还时选择继续等待",
|
|
};
|
|
|
|
function safeUrl(s: string): URL | null {
|
|
try {
|
|
return new URL(s);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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)!,
|
|
};
|
|
}
|
|
|
|
/** 截图像素尺寸: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 };
|
|
}
|
|
|
|
export type { ElementTarget };
|