Files
UHD_Media_Tool/apps/server/src/modules/browser/service.ts
T

2405 lines
104 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { mkdir, readdir, rm, stat, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import {
BrowserError,
type BrowserProvider,
type BrowserSecretResolver,
CAMOFOX_VERIFIED_RELEASES,
classifyChallengePage,
cookiesFromHeader,
createCamofoxBrowserProvider,
type DownloadSink,
type ElementTarget,
getSiteDefinition,
headerFromCookies,
hostAllowed,
InMemoryProfileLock,
imageDimensions,
type PageContent,
parseTorrentIsolated,
prepareImage,
profileKeyFor,
type TabRef,
toBase64,
totpCode,
} from "@umt/adapters";
import type {
BrowserCapabilities,
BrowserControlLease,
BrowserController,
BrowserCookie,
BrowserCoordinateSpace,
BrowserCredentialState,
BrowserDownloadVerification,
BrowserEvidenceRef,
BrowserHealth,
BrowserIdentity,
BrowserProviderView,
BrowserReconnectState,
BrowserSessionView,
DownloadArtifact,
NavigationBudget,
} from "@umt/contracts";
import { secretInput } from "@umt/contracts";
import {
type BrowserControlLeaseRow,
type BrowserProviderRow,
type BrowserSessionRow,
type BrowserTabRow,
type SiteRow,
sql,
} from "@umt/db";
import { egressKey, siteEgress } from "../../egress.ts";
import type { SourcesRuntime } from "../sources-identity/runtime.ts";
import { refreshSiteAccount } from "../sources-identity/service/account.ts";
import {
type BrowserFetchOutcome,
type ChallengeSolveOutcome,
registerChallengeBrowser,
} from "../sources-identity/service/cloudflare.ts";
import {
readAccountCookie,
registerBrowserCookieReader,
writeBrowserCookie,
} from "../sources-identity/service/credentials.ts";
import { registerBrowserIdentityReader } from "../sources-identity/service/user-agent.ts";
import type { ServerContext } from "../types.ts";
import { AGENT_KEEPALIVE_MAX_IDLE_MS, BROWSER_KEEPALIVE_MS, BrowserKeepalive } from "./keepalive.ts";
// C1-E 浏览器服务层:BrowserProvider(camofox)之上的会话/标签/控制权持久化(数据库为准)、
// 站点域名白名单 + 站点限流、凭证注入、接管状态机、下载产物暂存核验。
// 适配器只是内存镜像:服务重启后按数据库恢复,旧元素引用一律失效。
import {
coordinateSpace,
egressProxy,
holderName,
iso,
obj,
REASON_TEXT,
restoreUrl,
SOLVABLE,
safeUrl,
screenToPage,
toLease,
toSession,
toTab,
type VisionShot,
} from "./session-view.ts";
export type { VisionShot } from "./session-view.ts";
export { coordinateSpace, egressProxy, imageSize, screenToPage, toLease, toSession, toTab } from "./session-view.ts";
export type Actor = {
kind: "automation" | "human";
holder: string;
/** 智能体会话发起的动作(AG-4):会话还在跑期间保活这个浏览器会话 */
agentRunId?: 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;
/** 视觉自动化每步证据缩略图目录;缺省 DATA_DIR/tmp/evidence */
evidenceDir?: string;
/** 坐标动作之后等页面稳定再截图的毫秒数;缺省 400 */
settleMs?: number;
/** CF-2 等挑战页放行时读页面的间隔(毫秒);缺省 1500 */
challengePollMs?: number;
/** CF-2 等 Cloudflare 自己放行的最长时间(毫秒);缺省 CF_AUTO_WAIT_MS(15 秒) */
challengeAutoWaitMs?: number;
/** 保活间隔(AG-4);缺省 BROWSER_KEEPALIVE_MS 配置(120 秒),0 = 关闭 */
keepaliveMs?: 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"
| "reconnect_failed",
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;
/** 二次验证 / 短信 / 本人设备确认:任何情况下都不自动处理(视觉子流程里也一样),立即转人工 */
export const TWO_FACTOR =
/二次验证|两步验证|二步验证|双重验证|短信验证码|验证码已发送|本人设备|设备确认|身份验证器|2fa|two-?step|two-?factor|one-time (?:code|password)|authenticator app|verification code (?:has been |was )?sent|confirm (?:it'?s|this is) you|approve (?:the )?sign.?in/i;
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 }[];
/** BR-2:打开时的凭据注入结果与最近一次回写检查 */
credential?: BrowserCredentialState;
/** BR-3:上游上下文已不在(浏览器服务重启),下一次操作前先重连 */
needsReconnect?: boolean;
reconnectTrigger?: BrowserReconnectState["trigger"];
/** 打开 / 重连时浏览器服务的实例标识;变了 = 浏览器服务重启过 */
instanceId?: string | null;
reconnect?: BrowserReconnectState;
/** 最近一次自动化(Agent 能力)动作成功的时间;原站视图据此区分「Agent 正在操作」与「无人操作」 */
lastAutomationAt?: string;
/** CF-2:打开 / 重连时下发给浏览器的出口键(egressKey);插件没按站点设出口(旧镜像 / env 模式)为 null */
egress?: string | null;
}
/**
* CF-2 视觉子流程钩子(agent 模块注册):系统发起、没有父会话,预算取设置;只点「我是人类」复选框 / Turnstile 一步。
* 返回子流程结论(solved 仍要由调用方按页面核实)。
*/
export type ChallengeVision = (req: {
sessionId: string;
goal: string;
/** 通过后页面上不再出现的文字(成功条件) */
textAbsent: string | null;
maxSteps: number;
}) => Promise<{ status: string; reason: string; childRunId: string | null }>;
const visions = new WeakMap<object, ChallengeVision>();
/** 页面验证码接管结果:passed 带回站点 Cookie;needs_human 已开待处理事项;unavailable 没有接管条件(自动化关 / 没有浏览器能力) */
export type PageCaptchaOutcome =
| { status: "passed"; by: "browser" | "vision"; cookies: { name: string; value: string }[] }
| { status: "needs_human"; reason: string; decisionId: string | null }
| { status: "unavailable"; reason: string };
export function registerChallengeVision(ctx: object, fn: ChallengeVision): void {
visions.set(ctx, fn);
}
/** 系统(Cloudflare 处理)发起的浏览器动作 */
const CF_ACTOR: Actor = { kind: "automation", holder: "system:cloudflare" };
/** 等 Cloudflare 自己放行的最长时间 */
export const CF_AUTO_WAIT_MS = 15_000;
/** 视觉子流程点过之后再等多久 */
const CF_AFTER_CLICK_WAIT_MS = 10_000;
/** 视觉子流程最多派几次 */
const CF_VISION_ATTEMPTS = 2;
/** 视觉子流程步数上限:截图 → 点击 → 等待 */
const CF_VISION_STEPS = 4;
/** 同一浏览器服务的实例标识多久查一次 */
const INSTANCE_CHECK_MS = 5_000;
/** 自动重连失败后,操作触发的重连至少间隔多久 */
const RECONNECT_BACKOFF_MS = 10_000;
/** 上游说标签页 / 会话不在了:404 / 410,或适配器内存里已没有打开的标签页 */
function isGone(e: unknown): boolean {
return (
e instanceof BrowserError &&
e.kind === "not_found" &&
(e.httpStatus === undefined || e.httpStatus === 404 || e.httpStatus === 410)
);
}
/** 有活动的会话每隔多久从浏览器读回一次 Cookie */
export const COOKIE_SYNC_INTERVAL_MS = 10 * 60_000;
/** 环境变量(BROWSER_PROVIDER_URL)登记的浏览器服务 */
export const ENV_PROVIDER_NAME = "camofox-env";
const ENV_MANAGED = "env";
export class BrowserService {
private readonly providers = new Map<string, { sig: string; provider: BrowserProvider }>();
private envSync: Promise<void> | undefined;
readonly now: () => Date;
/** AG-4 保活:桌面 / 人工接管 / 智能体正在用的会话定期做一次只读调用 */
readonly keepalive: BrowserKeepalive;
constructor(
readonly ctx: ServerContext,
readonly opts: BrowserModuleOptions = {},
) {
this.now = opts.now ?? (() => new Date());
this.keepalive = new BrowserKeepalive(
{ keepAlive: (id) => this.keepAlive(id) },
{
intervalMs: opts.keepaliveMs ?? ctx.config?.BROWSER_KEEPALIVE_MS ?? BROWSER_KEEPALIVE_MS,
log: (level, msg, extra) => ctx.logger[level]({ component: "browser", ...extra }, msg),
},
);
// refresh-login 发现 Cookie 失效时从浏览器 profile 读回一次(sources-identity 经钩子调用,不直接依赖本模块)
registerBrowserCookieReader(ctx, (siteId, accountId) => this.readProfileCookie(siteId, accountId));
// 站点请求 UA 跟随浏览器固定身份(BR-4):sources-identity 经钩子取
registerBrowserIdentityReader(ctx, () => this.identityUserAgent());
// CF-2:站点 HTTP 遇 Cloudflare 挑战时由浏览器过挑战 / 代抓页面
registerChallengeBrowser(ctx, {
solve: (req) => this.solveSiteChallenge(req),
fetchHtml: (req) => this.fetchSiteHtml(req),
});
}
// ---------- BR-4 浏览器身份与出口 ----------
/** 身份读取失败后多久再试(浏览器服务没起 / 旧镜像时不每条站点请求都去问) */
private identityRetryAt = 0;
/**
* 浏览器固定身份的 UA:库里 browser_provider.identity 6 小时内读过就用它;否则问浏览器服务并写回。
* 没有启用的浏览器服务 / 插件不支持时返回库里的旧值或 null。
*/
async identityUserAgent(): Promise<string | null> {
const prow = await this.providerRow().catch(() => null);
if (!prow) return null;
const stored = obj(prow.identity) as { userAgent?: string; fetchedAt?: string };
const age = stored.fetchedAt ? Date.now() - Date.parse(stored.fetchedAt) : Number.POSITIVE_INFINITY;
if (stored.userAgent && age < 6 * 3_600_000) return stored.userAgent;
if (Date.now() < this.identityRetryAt) return stored.userAgent ?? null;
const fresh = await this.refreshIdentity(prow).catch(() => null);
return fresh?.userAgent ?? stored.userAgent ?? null;
}
/**
* 读浏览器服务的固定身份写进 browser_provider.identity(UA / OS / 版本 / 生成时间 + 读取时间)。
* maxAgeMs:库里的值比这新就不问。失败 5 分钟内不重试。
*/
async refreshIdentity(prow: BrowserProviderRow, opts: { maxAgeMs?: number } = {}): Promise<BrowserIdentity | null> {
const stored = obj(prow.identity) as Partial<BrowserIdentity> & { fetchedAt?: string };
if (opts.maxAgeMs !== undefined && stored.userAgent && stored.fetchedAt) {
if (Date.now() - Date.parse(stored.fetchedAt) < opts.maxAgeMs) return stored as BrowserIdentity;
}
const provider = this.providerFor(prow);
if (!provider.identity) return null;
let id: BrowserIdentity | null;
try {
id = await provider.identity();
} catch (e) {
this.identityRetryAt = Date.now() + 5 * 60_000;
throw e;
}
if (!id) {
this.identityRetryAt = Date.now() + 5 * 60_000;
return null;
}
this.identityRetryAt = 0;
await this.ctx.db
.updateTable("browser_provider")
.set({ identity: JSON.stringify({ ...id, fetchedAt: this.now().toISOString() }) })
.where("id", "=", prow.id)
.execute();
if (stored.userAgent !== id.userAgent)
this.ctx.logger.info(
{ component: "browser", providerId: prow.id, os: id.os, firefoxVersion: id.firefoxVersion },
"浏览器身份已更新,站点请求 UA 跟随",
);
return id;
}
/**
* 出口按站点统一(BR-4):站点开了代理且设置了代理地址 → 该 profile 走同一代理;否则直连。
* 旧镜像(插件没有出口接口)只记日志,浏览器照旧走容器环境变量里的代理。
*/
private async applyEgress(provider: BrowserProvider, site: SiteRow, profileKey: string): Promise<string | null> {
if (!provider.setEgress) return null;
const rt = this.opts.runtime?.(this.ctx);
const proxyUrl = rt
? (await rt.siteDefaults()).proxyUrl
: ((await this.ctx.settings.get("sites").catch(() => null))?.proxyUrl ?? null);
const proxy = site.proxy && proxyUrl ? egressProxy(proxyUrl) : null;
try {
const r = await provider.setEgress(profileKey, proxy);
if (r.restarted)
this.ctx.logger.info(
{ component: "browser", siteId: site.id, egress: proxy ? "proxy" : "direct" },
"浏览器会话出口已变更,按新出口重建",
);
// 插件 env 模式:浏览器按容器环境变量出网,与站点 HTTP(明确直连 / 站点代理)不一定一致
return r.mode && r.mode !== "site" ? null : egressKey(siteEgress(site.proxy, proxyUrl));
} catch (e) {
this.ctx.logger.warn(
{
component: "browser",
siteId: site.id,
egress: proxy ? "proxy" : "direct",
err: String((e as Error)?.message ?? e),
},
"设置浏览器会话出口失败,浏览器按容器环境变量出网",
);
return null;
}
}
// ---------- 浏览器服务 ----------
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") {
if (row.secret_ref) return secrets.resolve(row.secret_ref);
// 环境变量登记的服务:凭据只在进程内(BROWSER_ACCESS_KEY),不入库
return obj(row.config).managedBy === ENV_MANAGED ? (this.ctx.config?.BROWSER_ACCESS_KEY ?? null) : 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;
}
/**
* BROWSER_PROVIDER_URL / BROWSER_ACCESS_KEY / BROWSER_ENABLED → 名为 camofox-env 的浏览器服务(每个进程同步一次)。
* 地址与启用由环境变量决定;config 里的其它键(verified、超时等)保留。没设地址时已有的 camofox-env 置为停用。
*/
syncEnvProvider(): Promise<void> {
this.envSync ??= this.doSyncEnvProvider().then(
(probe) => {
// 首次登记后在后台探测一次,写回能力与上游版本(不阻塞当前请求;camofox 未就绪时下次"测试连通"再写)
if (probe) void this.listProviders(true).catch(() => {});
},
(e) => {
this.envSync = undefined;
this.ctx.logger.warn({ component: "browser", err: String(e) }, "登记环境变量浏览器服务失败");
},
);
return this.envSync;
}
/** 返回是否需要后台探测(已启用且还没有探测过能力) */
private async doSyncEnvProvider(): Promise<boolean> {
const c = this.ctx.config;
const url = c?.BROWSER_PROVIDER_URL;
const enabled = !!url && c.BROWSER_ENABLED !== false;
const db = this.ctx.db;
const row = await db
.selectFrom("browser_provider")
.selectAll()
.where("name", "=", ENV_PROVIDER_NAME)
.executeTakeFirst();
if (!url) {
if (row?.enabled)
await db.updateTable("browser_provider").set({ enabled: false }).where("id", "=", row.id).execute();
return false;
}
const config = JSON.stringify({ ...obj(row?.config), managedBy: ENV_MANAGED });
if (row)
await db
.updateTable("browser_provider")
.set({ base_url: url, enabled, kind: "camofox", secret_ref: null, config })
.where("id", "=", row.id)
.execute();
else
await db
.insertInto("browser_provider")
.values({ name: ENV_PROVIDER_NAME, kind: "camofox", base_url: url, enabled, config })
.onConflict((oc) => oc.column("name").doNothing())
.execute();
return enabled && !Object.keys(obj(row?.capabilities)).length;
}
/** 设置页登记浏览器服务:访问凭据加密存 secret;名称 camofox-env 保留给环境变量 */
async createProvider(body: {
name: string;
baseUrl: string;
accessKey?: string | undefined;
enabled?: boolean | undefined;
}): Promise<BrowserProviderView> {
if (body.name === ENV_PROVIDER_NAME)
throw new BrowserServiceError("bad_request", `名称 ${ENV_PROVIDER_NAME} 保留给环境变量`);
const exists = await this.ctx.db
.selectFrom("browser_provider")
.select("id")
.where("name", "=", body.name)
.executeTakeFirst();
if (exists) throw new BrowserServiceError("conflict", `浏览器服务「${body.name}」已存在`);
const accessKey = secretInput(body.accessKey);
const secretRef = accessKey
? await this.ctx.secrets.create({ purpose: "other", plaintext: accessKey, label: `browser:${body.name}` })
: null;
const row = await this.ctx.db
.insertInto("browser_provider")
.values({
name: body.name,
kind: "camofox",
base_url: body.baseUrl,
secret_ref: secretRef,
enabled: body.enabled ?? true,
})
.returning("id")
.executeTakeFirstOrThrow();
return this.providerView(row.id);
}
/** 改地址 / 凭据 / 启用;accessKey = null 清除凭据。camofox-env 的地址与凭据归环境变量管,只能改启用 */
async updateProvider(
id: string,
input: { baseUrl?: string | undefined; accessKey?: string | null | undefined; enabled?: boolean | undefined },
): Promise<BrowserProviderView> {
// 只写凭证统一语义:省略或空串 = 不变,null = 清除
const body = { ...input, accessKey: secretInput(input.accessKey) };
const row = await this.ctx.db.selectFrom("browser_provider").selectAll().where("id", "=", id).executeTakeFirst();
if (!row) throw new BrowserServiceError("not_found", "浏览器服务不存在");
const changesAddr = body.baseUrl !== undefined && body.baseUrl !== row.base_url;
if (obj(row.config).managedBy === ENV_MANAGED && (changesAddr || body.accessKey !== undefined))
throw new BrowserServiceError(
"conflict",
"该浏览器服务由环境变量 BROWSER_PROVIDER_URL / BROWSER_ACCESS_KEY 管理,改部署配置后重启",
);
let secretRef = row.secret_ref;
if (body.accessKey === null) secretRef = null;
else if (body.accessKey) {
if (secretRef) await this.ctx.secrets.replace(secretRef, body.accessKey);
else
secretRef = await this.ctx.secrets.create({
purpose: "other",
plaintext: body.accessKey,
label: `browser:${row.name}`,
});
}
await this.ctx.db
.updateTable("browser_provider")
.set({
...(body.baseUrl !== undefined ? { base_url: body.baseUrl } : {}),
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
secret_ref: secretRef,
})
.where("id", "=", id)
.execute();
if (row.secret_ref && secretRef === null) await this.ctx.secrets.remove(row.secret_ref).catch(() => {});
// 凭据换了但 secret_ref 不变:签名不变,显式丢弃缓存的适配器
this.providers.delete(id);
return this.providerView(id);
}
/** 删除设置页登记的服务:有未关闭会话时拒绝(先关会话);环境变量登记的只能停用 */
async deleteProvider(id: string): Promise<{ id: string }> {
const row = await this.ctx.db.selectFrom("browser_provider").selectAll().where("id", "=", id).executeTakeFirst();
if (!row) throw new BrowserServiceError("not_found", "浏览器服务不存在");
if (obj(row.config).managedBy === ENV_MANAGED)
throw new BrowserServiceError("conflict", "该浏览器服务由环境变量登记,只能停用(BROWSER_ENABLED=false)");
const open = await this.ctx.db
.selectFrom("browser_session")
.select("id")
.where("provider_id", "=", id)
.where("controller", "<>", "closed")
.executeTakeFirst();
if (open) throw new BrowserServiceError("conflict", "该浏览器服务还有未关闭的会话,先关闭会话");
await this.ctx.db.deleteFrom("browser_provider").where("id", "=", id).execute();
if (row.secret_ref) await this.ctx.secrets.remove(row.secret_ref).catch(() => {});
this.providers.delete(id);
return { id };
}
private async providerView(id: string): Promise<BrowserProviderView> {
const v = (await this.listProviders(false, id))[0];
if (!v) throw new BrowserServiceError("not_found", "浏览器服务不存在");
return v;
}
async providerRow(id?: string): Promise<BrowserProviderRow> {
await this.syncEnvProvider();
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, onlyId?: string): Promise<BrowserProviderView[]> {
await this.syncEnvProvider();
let rows = await this.ctx.db.selectFrom("browser_provider").selectAll().orderBy("created_at").execute();
if (onlyId) rows = rows.filter((r) => r.id === onlyId);
const out: BrowserProviderView[] = [];
for (let row of rows) {
let health: BrowserHealth | null = null;
if (probe && (row.enabled || onlyId) && 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 (e) {
caps = await p.capabilities();
// /health 免认证,只看它会把"凭据错了"显示成健康:探测被 401/403 拒绝时标为离线并注明
if (e instanceof BrowserError && e.kind === "auth")
health = { ...health, status: "down", detail: { ...health.detail, error: "auth" } };
}
row = await this.ctx.db
.updateTable("browser_provider")
.set({
capabilities: JSON.stringify(caps),
// 上游版本有真机核验记录时,verified_at 取核验日期(不是探测时间)
...(caps.upstreamVersion && CAMOFOX_VERIFIED_RELEASES[caps.upstreamVersion]
? { verified_at: new Date(`${CAMOFOX_VERIFIED_RELEASES[caps.upstreamVersion]!.verifiedAt}T00:00:00Z`) }
: {}),
health_status: HEALTH[health.status],
last_health_at: new Date(health.checkedAt),
})
.where("id", "=", row.id)
.returningAll()
.executeTakeFirstOrThrow();
// 探测时顺便读一次浏览器身份(站点请求 UA 跟随它,BR-4)
if (health.status !== "down" && row.enabled) await this.refreshIdentity(row).catch(() => null);
}
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,
managedBy: obj(row.config).managedBy === ENV_MANAGED ? "env" : null,
hasAccessKey:
!!row.secret_ref || (obj(row.config).managedBy === ENV_MANAGED && !!this.ctx.config?.BROWSER_ACCESS_KEY),
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);
// api / worker 各有一份适配器内存状态:另一个进程换了当前标签页(回收后重开)时,按数据库重新载入
const info = provider.sessionInfo(row.id);
const dbTab = (obj(row.recovery_state) as RecoveryState).currentTabId ?? null;
if (!info || (dbTab !== null && info.currentTabId !== dbTab)) {
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);
// 原站视图轮询会话详情:顺带发现浏览器服务重启(节流,失败不影响详情)
if (row.controller !== "closed" && (await this.checkInstance(row.provider_id).catch(() => false)))
row = await this.sessionRow(id);
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,
credential: rs.credential ?? null,
needsReconnect: rs.needsReconnect === true,
reconnect: rs.reconnect ?? null,
lastAutomationAt: rs.lastAutomationAt ?? 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);
// 数据库里已没有未关闭的会话:本进程内存里同 profile 还开着的是别的进程关掉的,标关闭,否则适配器拒绝新开
for (const m of await provider.listSessions())
if (m.profileKey === profileKey && m.controller !== "closed") provider.setController(m.id, "closed");
const prep = await this.prepareCookies(site, accountId);
const egress = await this.applyEgress(provider, site, profileKey);
await this.refreshIdentity(prow, { maxAgeMs: 10 * 60_000 }).catch(() => null);
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,
...(prep.cookies.length ? { cookies: prep.cookies } : {}),
});
} catch (e) {
throw fromBrowserError(e);
}
const s = res.session;
const instanceId = await this.currentInstance(prow, true).catch(() => null);
const credential: BrowserCredentialState = { ...prep.state };
if (res.cookies) {
credential.status = res.cookies.status;
credential.count = res.cookies.count;
if (res.cookies.status === "unsupported")
credential.message = "浏览器服务没有 Cookie 接口(镜像未带 umt-cookies 插件),没有注入账号登录态";
else if (res.cookies.status === "failed") credential.message = "注入账号 Cookie 失败";
}
// 注入了 Cookie 仍落在登录页:Cookie 过期。Cookie 账号(没有密码可自动登录)转"需要人工登录":在画面里登录一次,
// 交还 / 关闭时回写,全站生效;账密账号留给 Agent 用凭证注入登录(browser.interact credential),只记状态
const loginPage = provider.sessionInfo(s.id)?.loginPage === true;
const needHuman = credential.status === "injected" && loginPage && prep.authKind === "cookie";
if (credential.status === "injected" && loginPage) {
credential.status = "expired";
credential.message = "账号登录态已失效:接管后在画面里登录一次,交还后 HTTP 抓取、RSS、快照都用新的登录态";
}
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,
credential,
instanceId,
egress,
}),
})
.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();
});
if (needHuman) await this.enterWaiting(await this.sessionRow(s.id), "login_required");
return s.id;
}
// ---------- 凭据统一(BR-2) ----------
/**
* 打开会话前准备要注入的 Cookie:匿名会话与 API Key 类站点(馒头:网页登录态与 API Key 无关)不注入;
* 账密账号还没有会话 Cookie 时先走一次 refresh-login(与站点中心"刷新登录"同一流程)再取。
*/
private async prepareCookies(
site: SiteRow,
accountId: string | null,
): Promise<{ cookies: BrowserCookie[]; state: BrowserCredentialState; authKind: string | null }> {
const state = (
status: BrowserCredentialState["status"],
message: string | null,
extra: Partial<BrowserCredentialState> = {},
): BrowserCredentialState => ({
status,
source: null,
sourceAt: null,
count: 0,
message,
syncedAt: null,
...extra,
});
if (!accountId)
return { cookies: [], authKind: null, state: state("not_applicable", "匿名会话,不注入账号登录态") };
const kind = (getSiteDefinition(site.key)?.umt as { kind?: string } | undefined)?.kind;
let acc = await readAccountCookie(this.ctx, accountId);
if (!acc?.applicable || kind === "mteam")
return {
cookies: [],
authKind: acc?.authKind ?? null,
state: state("not_applicable", "API Key 类站点 / 账号:网页登录态与 API Key 无关,不注入;需要时在桌面里登录"),
};
const rt = this.opts.runtime?.(this.ctx);
if (!acc.header && acc.authKind === "password" && rt) {
await refreshSiteAccount(rt, accountId, { mode: "login" }).catch(() => null);
acc = (await readAccountCookie(this.ctx, accountId)) ?? acc;
}
if (!acc.header)
return { cookies: [], authKind: acc.authKind, state: state("none", "账号还没有可用的登录态(Cookie)") };
const cookies = cookiesFromHeader(acc.header, site.base_url, Math.floor(this.now().getTime() / 1000));
return {
cookies,
authKind: acc.authKind,
state: state("injected", null, {
source: acc.source,
sourceAt: acc.sourceAt ? acc.sourceAt.toISOString() : null,
count: cookies.length,
}),
};
}
/** 从该站点 / 账号的浏览器 profile 读回站点域名的 Cookie 头串(没有启用的浏览器服务或插件时 null) */
async readProfileCookie(siteId: string, accountId: string): Promise<string | null> {
const prow = await this.providerRow().catch(() => null);
if (!prow) return null;
const provider = this.providerFor(prow);
if (!provider.getCookies) return null;
const site = await this.liveSite(siteId);
const list = await provider.getCookies(profileKeyFor(siteId, accountId), [site.base_url]);
return headerFromCookies(list, site.base_url, Math.floor(this.now().getTime() / 1000));
}
/**
* 回写:从会话的浏览器 profile 读回站点域名的 Cookie,与凭据存储比较,有变化则更新(来源 browser)。
* 当前页面是登录页时不回写(未登录的 Cookie 不能覆盖可能仍有效的登录态)。失败只记在会话状态里,不抛出。
*/
async syncCookies(id: string, why: "release" | "close" | "periodic"): Promise<{ changed: boolean } | null> {
try {
const row = await this.sessionRow(id);
if (!row.account_id) return null;
const provider = this.providers.get(row.provider_id)?.provider;
if (!provider?.getCookies) return null;
const rs = obj(row.recovery_state) as RecoveryState;
if (rs.credential?.status === "not_applicable") return null;
const at = this.now();
const patch = async (c: Partial<BrowserCredentialState>) => {
const cur = obj((await this.sessionRow(id)).recovery_state) as RecoveryState;
const base: BrowserCredentialState = cur.credential ?? {
status: "none",
source: null,
sourceAt: null,
count: 0,
message: null,
syncedAt: null,
};
await this.ctx.db
.updateTable("browser_session")
.set({
recovery_state: JSON.stringify({ ...cur, credential: { ...base, syncedAt: at.toISOString(), ...c } }),
})
.where("id", "=", id)
.execute();
};
// 先按上游同步当前地址(桌面里的导航不经 API),再判断是否停在登录页
if (provider.sessionInfo(id)) {
await provider.reconcileTabs(id).catch(() => null);
await this.persist(id, provider).catch(() => {});
}
if (provider.sessionInfo(id)?.loginPage === true) {
await patch({});
return { changed: false };
}
const site = await this.liveSite(row.site_id);
let header: string | null;
try {
header = headerFromCookies(
await provider.getCookies(row.profile_key, [site.base_url]),
site.base_url,
Math.floor(at.getTime() / 1000),
);
} catch (e) {
if (e instanceof BrowserError && e.kind === "not_implemented")
await patch({ message: "浏览器服务不支持读回 Cookie(镜像未带 umt-cookies 插件),浏览器里的登录不会回写" });
return null;
}
if (!header) {
await patch({});
return { changed: false };
}
const r = await writeBrowserCookie(this.ctx, row.account_id, header, at);
await patch(
r.changed
? {
source: "browser",
sourceAt: at.toISOString(),
...(rs.credential?.status === "expired" ? { status: "injected" as const, message: null } : {}),
}
: {},
);
if (r.changed)
this.ctx.logger.info(
{ component: "browser", sessionId: id, why, count: r.count },
"浏览器登录态已回写站点账号",
);
return { changed: r.changed };
} catch (e) {
this.ctx.logger.warn(
{ component: "browser", sessionId: id, why, err: String((e as Error)?.message ?? e) },
"浏览器 Cookie 回写失败",
);
return null;
}
}
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) && reason !== "site_disabled" && reason !== "provider_disabled")
await this.syncCookies(row.id, "close");
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> {
this.keepalive.drop(id);
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,
text?: { title: string; summary: 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: text?.title ?? `${site?.name ?? "站点"}:原站需要人工处理`,
summary: text?.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;
});
// 接管期间人的操作不经上游 API(桌面模式),由 app 保活;交还 / 租约过期 / 会话关闭后停
this.keepalive.ensure(id, "takeover", "takeover", () => this.humanHolding(id));
return toLease(lease);
}
/** 会话仍在人工接管中(租约有效) */
private async humanHolding(id: string): Promise<boolean> {
const row = await this.sessionRow(id).catch(() => null);
if (row?.controller !== "human") return false;
const lease = await this.activeLease(id);
return lease?.holder_kind === "human" && new Date(lease.expires_at).getTime() > this.now().getTime();
}
/**
* 保活一次(AG-4):对当前标签页做最轻的只读调用。不要求控制权、不记自动化活动;浏览器重启过 / 标签页已被回收时
* 按 BR-3 自愈重连(注入 Cookie、回到原地址)。会话已关闭返回 false;上游不支持保活时只做实例检查与对账。
*/
async keepAlive(id: string): Promise<boolean> {
const row = await this.sessionRow(id).catch(() => null);
if (!row || row.controller === "closed") return false;
await this.run(
id,
{ kind: "automation", holder: "system:keepalive" },
async (p, ref) => {
if (p.keepAlive) await p.keepAlive(ref);
else await p.reconcileTabs(id);
},
{ write: false, persist: false, background: true },
);
return true;
}
/** 交还:回到自动化(或留在等待处理);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();
this.keepalive.unhold(id, "takeover");
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);
// 人工在桌面 / 截图模式里登录过:交还时读回登录态写进站点账号,全站(HTTP 抓取 / RSS / 快照)生效
await this.syncCookies(id, "release");
// CF-2:等人过 Cloudflare 验证的会话交还后,读回 cf_clearance / UA,站点请求继续
if (opts.to === "automation")
await this.afterHumanChallenge(id).catch((e) =>
this.ctx.logger.warn(
{ component: "browser", sessionId: id, err: String((e as Error)?.message ?? e) },
"交还后读回 Cloudflare 验证结果失败",
),
);
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;
/** 后台维护调用(保活):不记自动化活动 */
background?: boolean;
} = {},
): 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 ?? "未知原因"}),自动化动作已停止`,
);
}
}
// BR-3:浏览器服务重启过(实例标识变了)→ 先重建上游上下文再操作
if (await this.checkInstance(row.provider_id).catch(() => false)) row = await this.sessionRow(id);
const pending = obj(row.recovery_state) as RecoveryState;
if (pending.needsReconnect) {
// 刚失败过:短时间内不反复重连(原站视图 1.5 秒一次截图),直接报上次的原因;显式 POST .../reconnect 不受限
const last = pending.reconnect;
if (last?.status === "failed" && this.now().getTime() - new Date(last.at).getTime() < RECONNECT_BACKOFF_MS)
throw new BrowserServiceError("reconnect_failed", `浏览器已重启,重新连接失败:${last.reason ?? "未知原因"}`);
await this.reconnect(id, pending.reconnectTrigger ?? "provider_restarted");
row = await this.sessionRow(id);
}
let 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 (!isGone(e)) {
await this.persist(id, loaded.provider).catch(() => {});
throw fromBrowserError(e);
}
// 上游说标签页 / 会话不在了:先对账。当前标签页还活着 → 指定的旧标签页没了照旧报错,否则在当前标签页重试一次;
// 全没了(浏览器服务重启 / 回收)→ 注入 Cookie、新开标签页回到原地址,再重试一次(失败 503 browser_reconnect_failed)
const r = await loaded.provider.reconcileTabs(id).catch(() => null);
await this.persist(id, loaded.provider, { needsSnapshot: true }).catch(() => {});
const alive = r ? (loaded.provider.sessionInfo(id)?.currentTabId ?? null) : null;
if (alive && opts.tabId && opts.tabId !== alive) {
if (r?.lost.includes(opts.tabId))
throw new BrowserServiceError("conflict", "标签页已被浏览器服务回收,重新打开页面");
throw fromBrowserError(e);
}
if (!alive) {
await this.markReconnect(id, "tab_gone");
await this.reconnect(id, "tab_gone");
loaded = await this.loaded(await this.sessionRow(id));
row = loaded.row;
}
const retry: TabRef = { sessionId: id, as: actor.kind };
try {
out = await fn(loaded.provider, retry, { row, site, budget });
} catch (e2) {
await this.persist(id, loaded.provider).catch(() => {});
throw fromBrowserError(e2);
}
}
const automation: RecoveryState =
actor.kind === "automation" && !opts.background ? { lastAutomationAt: this.now().toISOString() } : {};
if (actor.agentRunId) this.holdForAgent(id, actor.agentRunId);
if (opts.persist !== false)
await this.persist(id, loaded.provider, { ...(opts.patch?.(out) ?? {}), ...automation });
else if (automation.lastAutomationAt) await this.patchRecovery(await this.sessionRow(id), automation);
this.maybeSyncCookies(row);
return out;
}
/** 智能体会话最后一次用各浏览器会话的时间(保活复查用) */
private readonly agentUse = new Map<string, number>();
/** 智能体会话在用这个浏览器会话:会话还在排队 / 运行期间保活(最后一次使用后最多 AGENT_KEEPALIVE_MAX_IDLE_MS) */
holdForAgent(sessionId: string, runId: string): void {
const key = `agent:${runId}`;
this.agentUse.set(`${sessionId}|${runId}`, Date.now());
this.keepalive.ensure(sessionId, key, "agent", async () => {
const last = this.agentUse.get(`${sessionId}|${runId}`) ?? 0;
const r = await this.ctx.db.selectFrom("agent_run").select("status").where("id", "=", runId).executeTakeFirst();
const ok =
!!r && (r.status === "running" || r.status === "queued") && Date.now() - last < AGENT_KEEPALIVE_MAX_IDLE_MS;
if (!ok) this.agentUse.delete(`${sessionId}|${runId}`);
return ok;
});
}
// ---------- BR-3 浏览器服务重启后自愈 ----------
/** 各浏览器服务最近一次读到的实例标识(节流缓存;只在进程内) */
private readonly instances = new Map<string, { at: number; id: string | null; pending?: Promise<string | null> }>();
/** 各浏览器服务已按哪个实例标识对过会话(同一标识不重复写库) */
private readonly applied = new Map<string, string>();
/** 进行中的重连:同一会话并发的操作共用一次 */
private readonly reconnecting = new Map<string, Promise<void>>();
/** 浏览器服务当前实例标识:INSTANCE_CHECK_MS 内复用上次的结果;取不到(旧镜像)为 null */
private async currentInstance(prow: BrowserProviderRow, force = false): Promise<string | null> {
const provider = this.providerFor(prow);
if (!provider.instanceId) return null;
const hit = this.instances.get(prow.id);
if (hit?.pending) return hit.pending;
if (hit && !force && Date.now() - hit.at < INSTANCE_CHECK_MS) return hit.id;
const pending = provider.instanceId().then(
(id) => {
this.instances.set(prow.id, { at: Date.now(), id });
return id;
},
(e) => {
// 连不上:沿用旧值,不当成重启(真重启时标签页 404/410 会被动发现)
this.instances.set(prow.id, { at: Date.now(), id: hit?.id ?? null });
throw e;
},
);
this.instances.set(prow.id, { at: hit?.at ?? 0, id: hit?.id ?? null, pending });
return pending;
}
/**
* 重启检测:浏览器服务实例标识变了 → 该服务下全部未关闭会话标 needsReconnect(下一次操作 / 原站视图触发重连)。
* 没记录实例标识的旧会话补记当前值。返回本次是否有会话被标记。
*/
async checkInstance(providerId: string, force = false): Promise<boolean> {
const prow = await this.ctx.db
.selectFrom("browser_provider")
.selectAll()
.where("id", "=", providerId)
.executeTakeFirst();
if (!prow?.enabled || prow.kind === "none") return false;
const id = await this.currentInstance(prow, force);
if (!id || (!force && this.applied.get(providerId) === id)) return false;
const db = this.ctx.db;
await db
.updateTable("browser_session")
.set({ recovery_state: sql`recovery_state || ${JSON.stringify({ instanceId: id })}::jsonb` })
.where("provider_id", "=", providerId)
.where("controller", "<>", "closed")
.where(sql<boolean>`recovery_state->>'instanceId' is null`)
.execute();
const r = await db
.updateTable("browser_session")
.set({
recovery_state: sql`recovery_state || ${JSON.stringify({ needsReconnect: true, reconnectTrigger: "provider_restarted" })}::jsonb`,
updated_at: this.now(),
})
.where("provider_id", "=", providerId)
.where("controller", "<>", "closed")
.where(sql<boolean>`recovery_state->>'instanceId' <> ${id}`)
.where(sql<boolean>`coalesce(recovery_state->>'needsReconnect', 'false') <> 'true'`)
.executeTakeFirst();
this.applied.set(providerId, id);
const n = Number(r.numUpdatedRows ?? 0);
if (n > 0)
this.ctx.logger.info(
{ component: "browser", providerId, sessions: n },
"浏览器服务重启过,未关闭的会话标为需要重新连接",
);
return n > 0;
}
private async markReconnect(id: string, trigger: BrowserReconnectState["trigger"]): Promise<void> {
await this.patchRecovery(await this.sessionRow(id), { needsReconnect: true, reconnectTrigger: trigger });
}
/**
* POST /browser/sessions/{id}/reconnect(幂等):先查实例标识;没标需要重连时按上游对账,当前标签页还活着就什么都不做;
* 否则重建。失败抛 reconnect_failed(503)。
*/
async reconnectIfNeeded(id: string): Promise<void> {
let row = await this.sessionRow(id);
if (row.controller === "closed") throw new BrowserServiceError("conflict", "会话已关闭");
if (await this.checkInstance(row.provider_id, true).catch(() => false)) row = await this.sessionRow(id);
const rs = obj(row.recovery_state) as RecoveryState;
if (!rs.needsReconnect) {
const { provider } = await this.loaded(row);
try {
await provider.reconcileTabs(id);
} catch (e) {
throw new BrowserServiceError(
"reconnect_failed",
`浏览器服务不可用:${e instanceof Error ? e.message : String(e)}`,
);
}
await this.persist(id, provider).catch(() => {});
if (provider.sessionInfo(id)?.currentTabId) return;
}
await this.reconnect(id, rs.needsReconnect ? (rs.reconnectTrigger ?? "provider_restarted") : "manual");
}
/** 重建会话的上游上下文(同一会话并发调用只做一次) */
reconnect(id: string, trigger: BrowserReconnectState["trigger"]): Promise<void> {
const running = this.reconnecting.get(id);
if (running) return running;
const p = this.doReconnect(id, trigger).finally(() => this.reconnecting.delete(id));
this.reconnecting.set(id, p);
return p;
}
/**
* 按会话的站点 / 账号:注入账号 Cookie(BR-2 同一套准备)→ 同一 userId 开新标签页 → 导航到会话记录的地址(没有或不在站点域名内
* 则站点首页)→ 旧标签页标 lost、换当前标签页。控制者与租约不变;人工接管中重连记 humanNotice(页面已重新加载,请核对)。
*/
private async doReconnect(id: string, trigger: BrowserReconnectState["trigger"]): Promise<void> {
let row = await this.sessionRow(id);
if (row.controller === "closed") throw new BrowserServiceError("conflict", "会话已关闭");
const loaded = await this.loaded(row);
row = loaded.row;
const provider = loaded.provider;
const prow = await this.providerRow(row.provider_id);
const site = await this.liveSite(row.site_id);
const budget = this.budgetFor(site, prow);
const at = this.now().toISOString();
const fail = async (reason: string) => {
const cur = await this.sessionRow(id);
await this.patchRecovery(cur, {
needsReconnect: true,
reconnectTrigger: trigger,
reconnect: { status: "failed", trigger, at, reason, humanNotice: false },
});
this.ctx.logger.warn({ component: "browser", sessionId: id, trigger, reason }, "浏览器会话重新连接失败");
return new BrowserServiceError("reconnect_failed", `浏览器已重启,重新连接失败:${reason}`);
};
if (!provider.reopenSession) throw await fail("浏览器服务不支持重新连接");
const url = restoreUrl(row.current_url, budget) ?? site.base_url;
const prep = await this.prepareCookies(site, row.account_id).catch(() => null);
const egress = await this.applyEgress(provider, site, row.profile_key);
// 浏览器重启过(可能换了镜像版本):身份重新读
await this.refreshIdentity(prow).catch(() => null);
let res: Awaited<ReturnType<NonNullable<BrowserProvider["reopenSession"]>>>;
try {
await this.rateLimit(site);
res = await provider.reopenSession(id, {
url,
budget,
...(prep?.cookies.length ? { cookies: prep.cookies } : {}),
});
} catch (e) {
throw await fail(e instanceof Error ? e.message : String(e));
}
const instanceId = await this.currentInstance(prow, true).catch(() => null);
const rs = obj((await this.sessionRow(id)).recovery_state) as RecoveryState;
await this.persist(id, provider, {
needsReconnect: false,
needsSnapshot: true,
instanceId: instanceId ?? rs.instanceId ?? null,
egress,
reconnect: { status: "ok", trigger, at, reason: null, humanNotice: row.controller === "human" },
});
this.coordSpaces.delete(id);
this.ctx.logger.info(
{
component: "browser",
sessionId: id,
trigger,
lostTabs: res.lost.length,
cookies: res.cookies?.status ?? "none",
cookieCount: res.cookies?.count ?? 0,
controller: row.controller,
},
"浏览器会话已重新连接",
);
}
/** 有活动时每 COOKIE_SYNC_INTERVAL_MS 读回一次登录态(后台,不拖慢动作;同一会话同时只跑一个) */
private readonly syncing = new Set<string>();
private maybeSyncCookies(row: BrowserSessionRow): void {
if (!row.account_id || this.syncing.has(row.id)) return;
const c = (obj(row.recovery_state) as RecoveryState).credential;
if (c?.status === "not_applicable") return;
const last = c?.syncedAt ? new Date(c.syncedAt).getTime() : new Date(row.created_at).getTime();
if (this.now().getTime() - last < COOKIE_SYNC_INTERVAL_MS) return;
this.syncing.add(row.id);
void this.syncCookies(row.id, "periodic").finally(() => this.syncing.delete(row.id));
}
/** 导航前按站点限流取令牌 */
async limited(site: SiteRow): Promise<void> {
await this.rateLimit(site);
}
/**
* 快照后判断是否需要人工:只在自动化控制下。二次验证 / 短信 / 本人设备确认一律转等待处理;
* 验证码 / 挑战页在视觉子流程(browser.solve_challenge)进行中不转,由子流程处理,失败再转。
*/
async afterAutomationSnapshot(id: string, text: string, loginPage: boolean): Promise<void> {
const row = await this.sessionRow(id);
if (row.controller !== "automation") return;
if (TWO_FACTOR.test(text)) await this.enterWaiting(row, "two_factor");
else if (HUMAN_CHALLENGE.test(text) && !this.challenges.has(id))
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");
}
// ---------- VIS-1 视觉自动化 ----------
/** 每个会话最近一次截图的坐标系(截图像素 → 页面 CSS 像素);只在进程内 */
private readonly coordSpaces = new Map<string, BrowserCoordinateSpace>();
/** 进行中的视觉子流程:会话 → 已写的证据 */
private readonly challenges = new Map<string, { evidence: BrowserEvidenceRef[] }>();
private evidenceDir(): string {
if (this.opts.evidenceDir) return this.opts.evidenceDir;
const dataDir = (this.ctx.config as { DATA_DIR?: string }).DATA_DIR ?? "./data";
return join(resolve(dataDir), "tmp", "evidence");
}
/** 证据缩略图(≤480 宽 JPEG)落 DATA_DIR/tmp/evidence(0600),超过 7 天的顺手清掉;返回引用 */
async writeEvidence(sessionId: string, shot: Uint8Array): Promise<BrowserEvidenceRef | null> {
try {
const thumb = prepareImage(shot, { maxWidth: 480, format: "jpeg", quality: 60 });
const dir = this.evidenceDir();
await mkdir(dir, { recursive: true, mode: 0o700 });
if (Math.random() < 0.05) await this.cleanupDir(dir, 7 * 24 * 3_600_000);
const id = crypto.randomUUID();
await writeFile(join(dir, `${id}.jpg`), thumb.data, { mode: 0o600 });
const ref: BrowserEvidenceRef = { id, ref: `evidence/${id}.jpg`, capturedAt: this.now().toISOString() };
this.challenges.get(sessionId)?.evidence.push(ref);
return ref;
} catch (err) {
this.ctx.logger.warn({ sessionId, err: { message: (err as Error).message } }, "截图证据写入失败");
return null;
}
}
evidencePath(ref: string): string {
return join(this.evidenceDir(), ref.replace(/^evidence\//, "").replace(/[/\\]/g, "_"));
}
private async cleanupDir(dir: string, maxAgeMs: number): Promise<void> {
const cutoff = Date.now() - maxAgeMs;
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(() => {});
}
}
/** 交给模型的截图参数:browser_provider.config 的 screenshotMaxWidth(≤1280)/ screenshotFormat / screenshotQuality */
private imageOptions(prow: BrowserProviderRow | null): {
maxWidth: number;
format: "png" | "jpeg";
quality: number;
} {
const cfg = obj(prow?.config);
const w = typeof cfg.screenshotMaxWidth === "number" ? cfg.screenshotMaxWidth : 1280;
const q = typeof cfg.screenshotQuality === "number" ? cfg.screenshotQuality : 70;
return {
maxWidth: Math.max(320, Math.min(1280, Math.round(w))),
format: cfg.screenshotFormat === "png" ? "png" : "jpeg",
quality: Math.max(20, Math.min(95, Math.round(q))),
};
}
/**
* 截图 + 页面度量 + 证据:缩放后的截图作为模型坐标系登记在会话上;判定敏感(登录页 / 密码框 / 地址带凭据);
* 页面文字出现二次验证 / 短信 / 本人设备确认时,自动化控制下的会话立即转等待处理。
* wantImage=false(上游不支持图片)或敏感且未授权时不给图片,改给已脱敏的文字快照。
*/
async capture(
id: string,
actor: Actor,
opts: { tabId?: string; wantImage: boolean; sensitiveAllowed: boolean },
): Promise<VisionShot> {
const got = await this.run(
id,
actor,
async (p, ref, x) => {
const shot = await p.screenshot(ref, { maxBytes: 8 * 1024 * 1024 });
const metrics = await p.pageMetrics(ref).catch(() => null);
const prow = await this.providerRow(x.row.provider_id).catch(() => null);
return { shot, metrics, prow };
},
{ write: false, persist: false, ...(opts.tabId ? { tabId: opts.tabId } : {}) },
);
const { shot, metrics } = got;
const src = imageDimensions(shot.data);
const img = prepareImage(shot.data, this.imageOptions(got.prow));
const space = coordinateSpace(src ?? { width: img.width, height: img.height }, img, metrics);
this.coordSpaces.set(id, space);
const sensitive = !shot.modelSafe || metrics?.passwordField === true;
let row = await this.sessionRow(id);
if (row.controller === "automation" && metrics && TWO_FACTOR.test(metrics.textSample))
row = await this.enterWaiting(row, "two_factor");
const waitingReason =
row.controller === "waiting" ? ((obj(row.recovery_state) as RecoveryState).waitingReason ?? null) : null;
const evidence = await this.writeEvidence(id, shot.data);
const blocked = waitingReason === "two_factor";
const give = opts.wantImage && !blocked && (!sensitive || opts.sensitiveAllowed);
let note: string | null = null;
if (blocked) note = "页面要求二次验证 / 短信 / 本人设备确认:不自动处理,会话已转等待处理,由人在原站视图接管";
else if (!opts.wantImage) note = "当前模型不支持图片输入,截图没有交给模型;下面是页面的文字快照";
else if (sensitive && !opts.sensitiveAllowed)
note =
"登录页 / 含密码框的截图属于敏感截图,默认不交给模型(需要 browser.sensitive_screenshot 持续授权并传 allowSensitive);下面是已脱敏的文字快照";
let textSnapshot: string | null = null;
if (!give && !blocked) {
const snap = await this.run(id, actor, (p, ref) => p.snapshot(ref), {
write: false,
persist: false,
...(opts.tabId ? { tabId: opts.tabId } : {}),
}).catch(() => null);
textSnapshot = snap ? snap.text.slice(0, 6000) : null;
}
return {
evidence,
url: shot.url,
controller: row.controller,
sensitive,
imageForModel: give,
note,
textSnapshot,
coordinates: space,
waitingReason,
modelImage: give
? { mimeType: img.mimeType, data: toBase64(img.data), width: img.width, height: img.height }
: null,
};
}
/** 截图像素 → 页面 CSS 像素;没有截图过(或坐标越界)报错 */
toPage(id: string, x: number, y: number): { pageX: number; pageY: number } {
const sp = this.coordSpaces.get(id);
if (!sp) throw new BrowserServiceError("precondition", "先用 browser.screenshot 截图,坐标以截图像素为准");
if (x < 0 || y < 0 || x >= sp.imageWidth || y >= sp.imageHeight)
throw new BrowserServiceError("bad_request", `坐标 (${x}, ${y}) 超出截图范围 ${sp.imageWidth}×${sp.imageHeight}`);
return screenToPage(sp, x, y);
}
/**
* 坐标类动作:自动化控制权 + 当前页面在站点域名内;动作之后旧坐标系作废(页面可能已变化),
* 等 settleMs 再截一张作为新坐标系与证据。
*/
async visionAct(
id: string,
actor: Actor,
act: (p: BrowserProvider, ref: TabRef) => Promise<void>,
opts: { wantImage: boolean; sensitiveAllowed: boolean },
): Promise<VisionShot> {
await this.run(id, actor, async (p, ref, x) => {
const url = p.sessionInfo(id)?.session.currentUrl ?? null;
const host = url ? safeUrl(url)?.hostname : undefined;
if (!host || !hostAllowed(host, x.budget.domainAllowlist))
throw new BrowserServiceError("precondition", "当前页面不在站点域名内,先用 browser.navigate 回到站点页面");
await act(p, ref);
});
this.coordSpaces.delete(id);
await new Promise((r) => setTimeout(r, this.opts.settleMs ?? 400));
return this.capture(id, actor, opts);
}
/**
* 开始视觉子流程:会话须在自动化控制下,或因验证码 / 页面挑战在等待处理(此时收回自动化控制,待处理事项保留,
* 成功后关闭);人工接管中、二次验证、租约过期等其它等待原因一律拒绝。
*/
async beginChallenge(id: string): Promise<{ row: BrowserSessionRow; url: string | null }> {
if (this.challenges.has(id)) throw new BrowserServiceError("conflict", "这个会话已有进行中的挑战处理");
let row = await this.expireLeases(await this.sessionRow(id));
const rs = obj(row.recovery_state) as RecoveryState;
if (row.controller === "waiting" && SOLVABLE.includes(rs.waitingReason ?? "")) {
const { provider } = await this.loaded(row);
provider.setController(id, "automation");
row = await this.ctx.db
.updateTable("browser_session")
.set({
controller: "automation",
recovery_state: JSON.stringify({ ...rs, waitingReason: null, needsSnapshot: true }),
updated_at: this.now(),
})
.where("id", "=", id)
.where("controller", "=", "waiting")
.returningAll()
.executeTakeFirstOrThrow();
} else if (row.controller !== "automation") {
throw new BrowserServiceError(
"conflict",
row.controller === "human"
? "会话正由人工接管,不能自动处理"
: row.controller === "closed"
? "会话已关闭"
: `会话在等待人工处理(${REASON_TEXT[rs.waitingReason ?? ""] ?? rs.waitingReason ?? "未知原因"}),不能自动处理`,
);
}
this.challenges.set(id, { evidence: [] });
return { row, url: row.current_url };
}
challengeEvidence(id: string): BrowserEvidenceRef[] {
return [...(this.challenges.get(id)?.evidence ?? [])];
}
/**
* 结束视觉子流程:成功 → 关闭之前的等待处理事项;否则会话转等待处理(原因 two_factor / challenge_unsolved),
* 待处理事项附上每步证据与子流程运行,供人在原站视图接管。
*/
async endChallenge(
id: string,
out: { solved: boolean; reason: string; summary: string; childRunId: string | null },
): Promise<{ decisionId: string | null; controller: BrowserController }> {
const evidence = this.challengeEvidence(id);
this.challenges.delete(id);
let row = await this.sessionRow(id);
if (out.solved) {
if (row.controller === "automation") {
await this.resolveWaiting(row, "system:browser.solve_challenge");
await this.patchRecovery(row, { waitingDecisionId: null });
}
return { decisionId: null, controller: row.controller };
}
if (row.controller === "automation") row = await this.enterWaiting(row, out.reason);
const decisionId = (obj(row.recovery_state) as RecoveryState).waitingDecisionId ?? null;
if (decisionId)
await this.ctx.db
.updateTable("decision")
.set({
evidence: sql`evidence || ${JSON.stringify({
challenge: {
reason: out.reason,
summary: out.summary.slice(0, 1000),
childRunId: out.childRunId,
screenshots: evidence.slice(-12),
},
})}::jsonb`,
})
.where("id", "=", decisionId)
.execute();
return { decisionId, controller: row.controller };
}
// ---------- CF-2 Cloudflare 挑战 ----------
/** 过挑战 / 代抓同一 profile 串行(本进程;跨进程的同站互斥靠 site.config.cloudflare.lastAttemptAt 条件更新) */
private readonly cfLock = new InMemoryProfileLock(240_000);
/** 读当前页面内容(只读,不要求自动化控制权);上游不支持为 null */
private async readPage(id: string): Promise<PageContent | null> {
return this.run(id, CF_ACTOR, (p, ref) => (p.pageContent ? p.pageContent(ref) : Promise.resolve(null)), {
write: false,
persist: false,
background: true,
});
}
/** 轮询页面直到不是挑战页(或变成真验证码 / 超时);返回最后一次读到的页面与分类 */
private async waitChallenge(
id: string,
page: PageContent | null,
ms: number,
): Promise<{
page: PageContent | null;
kind: ReturnType<typeof classifyChallengePage>["kind"];
rule: string | null;
}> {
let cur = page ?? (await this.readPage(id));
let c = classifyChallengePage(cur ?? {});
const deadline = Date.now() + ms;
while (cur && c.kind !== "none" && c.kind !== "captcha" && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, this.opts.challengePollMs ?? 1_500));
cur = await this.readPage(id);
c = classifyChallengePage(cur ?? {});
}
return { page: cur, kind: cur ? c.kind : "none", rule: c.rule };
}
/**
* 该账号 profile 的自动化会话:已有且在自动化控制下就用它(导航到 url,取站点令牌);没有就打开一个(打开即导航)。
* 人工接管中 / 等人处理中返回 null(不打扰人)。
*/
private async cfSession(
site: SiteRow,
accountId: string | null,
url: string,
): Promise<{ id: string } | { busy: BrowserSessionRow }> {
const live = await this.liveSessionFor(site.id, accountId);
if (live && live.controller !== "automation") return { busy: live };
if (!live) return { id: await this.open({ siteId: site.id, accountId, url }) };
await this.run(live.id, CF_ACTOR, async (p, ref, x) => {
await this.rateLimit(x.site);
await p.navigate(ref, url, x.budget);
});
return { id: live.id };
}
/** 会话转"等你过验证":待处理事项写明是哪个站点、去哪里处理 */
private async cfNeedsHuman(id: string, site: SiteRow, reason: string): Promise<ChallengeSolveOutcome> {
const text = {
title: `${site.name}需要你在原站视图里过一次验证`,
summary:
"站点要求人机验证(Cloudflare),系统没能自动通过。在站点详情的原站视图接管会话,完成验证后交还,站点请求会继续。",
};
let row = await this.sessionRow(id);
if (row.controller === "automation") row = await this.enterWaiting(row, "cf_challenge", text);
else if (row.controller === "waiting") {
const rs = obj(row.recovery_state) as RecoveryState;
if (!rs.waitingDecisionId) row = await this.enterWaiting(row, "cf_challenge", text);
else {
// 视觉子流程没成功时已转等待(challenge_unsolved):事项改成 Cloudflare 的说法
await this.ctx.db
.updateTable("decision")
.set({ title: text.title, summary: text.summary })
.where("id", "=", rs.waitingDecisionId)
.execute();
row = await this.patchRecovery(row, { waitingReason: "cf_challenge" });
}
}
const decisionId = (obj(row.recovery_state) as RecoveryState).waitingDecisionId ?? null;
if (decisionId)
await this.ctx.db
.updateTable("decision")
.set({
evidence: sql`evidence || ${JSON.stringify({ cloudflare: { siteId: site.id, reason } })}::jsonb`,
})
.where("id", "=", decisionId)
.execute();
this.ctx.logger.info({ component: "browser", sessionId: id, siteId: site.id, reason }, "Cloudflare 验证转人工");
return { status: "needs_human", sessionId: id, decisionId, reason };
}
/** 读回 profile 里的 cf_clearance 值(只取作用于站点主机的) */
private async cfClearance(profileKey: string, siteUrl: string): Promise<string | null> {
const prow = await this.providerRow().catch(() => null);
const provider = prow ? this.providerFor(prow) : null;
if (!provider?.getCookies) return null;
const list = await provider.getCookies(profileKey, [siteUrl]).catch(() => []);
const host = new URL(siteUrl).hostname;
const hit = list.find(
(c) =>
c.name === "cf_clearance" &&
(host === c.domain.replace(/^\./, "") || host.endsWith(`.${c.domain.replace(/^\./, "")}`)) &&
!(c.expires !== undefined && c.expires > 0 && c.expires * 1000 <= this.now().getTime()),
);
return hit?.value ?? null;
}
/**
* 过挑战(CF-2 级联):用该账号 profile 打开同一地址 → ① 等它自己过(≤15 秒)→ ② 复选框 / Turnstile 派视觉子流程点一下
* (最多 2 次,系统发起,预算取设置;自动化总开关关闭时跳过)→ ③ 选图 / 输码类或仍没过:会话转等待,开待处理事项。
* 通过后读回 cf_clearance 与 navigator.userAgent,连同浏览器出口交给调用方。
*/
async solveSiteChallenge(req: { siteId: string; accountId: string; url: string }): Promise<ChallengeSolveOutcome> {
const site = await this.liveSite(req.siteId);
if (!site.enabled) return { status: "failed", reason: "站点已停用" };
const profileKey = profileKeyFor(site.id, req.accountId);
return this.cfLock.run(profileKey, async () => {
const got = await this.cfSession(site, req.accountId, req.url);
if ("busy" in got) {
const rs = obj(got.busy.recovery_state) as RecoveryState;
if (got.busy.controller === "waiting" && rs.waitingReason === "cf_challenge")
return {
status: "needs_human",
sessionId: got.busy.id,
decisionId: rs.waitingDecisionId ?? null,
reason: "已在等你过验证",
};
return { status: "failed", reason: "浏览器会话正由人工处理,没有打扰" };
}
const id = got.id;
const opened = await this.sessionRow(id);
if (opened.controller !== "automation")
return { status: "failed", reason: "浏览器会话打开后在等人处理(登录已失效)" };
// ① 等它自己过
let w = await this.waitChallenge(id, null, this.opts.challengeAutoWaitMs ?? CF_AUTO_WAIT_MS);
if (!w.page) return { status: "failed", reason: "浏览器服务不支持读取页面内容" };
let by: "auto" | "vision" = "auto";
// ② 复选框 / Turnstile:视觉子流程点一下
if (w.kind === "interactive") {
const vision = visions.get(this.ctx);
const auto = await this.ctx.settings.get("automation").catch(() => null);
if (vision && auto?.enabled && !auto.readOnlyReconcile) {
const marker = /verify you are human|确认您是真人|验证您是真人|我是人类/i.exec(w.page.text)?.[0] ?? null;
for (let i = 0; i < CF_VISION_ATTEMPTS && w.kind === "interactive"; i++) {
const r = await vision({
sessionId: id,
goal: "这是 Cloudflare 人机验证页。只点一次「确认您是真人 / Verify you are human」复选框(Turnstile),然后等待页面跳转;不要做其它操作。如果页面要求选图片、输入验证码或别的操作,立即停止并说明需要人工。",
textAbsent: marker,
maxSteps: CF_VISION_STEPS,
}).catch((e) => ({ status: "failed", reason: String((e as Error)?.message ?? e), childRunId: null }));
this.ctx.logger.info(
{ component: "browser", sessionId: id, siteId: site.id, attempt: i + 1, status: r.status },
"Cloudflare 复选框视觉子流程结束",
);
// 子流程没成功时会话已转等待(challenge_unsolved);下一次子流程开始时自己收回自动化控制
w = await this.waitChallenge(
id,
null,
Math.min(CF_AFTER_CLICK_WAIT_MS, this.opts.challengeAutoWaitMs ?? CF_AFTER_CLICK_WAIT_MS),
);
if (w.kind === "none") by = "vision";
}
}
}
// ③ 没过
if (w.kind !== "none")
return this.cfNeedsHuman(
id,
site,
w.kind === "captcha" ? "选图 / 输入验证码类验证,不自动处理" : `没能自动通过(${w.rule ?? w.kind})`,
);
// 通过:子流程成功时关掉它留下的待处理;读回 cf_clearance 与 UA
const row = await this.sessionRow(id);
if (row.controller === "waiting") {
await this.resolveWaiting(row, "system:cloudflare");
const { provider } = await this.loaded(row);
provider.setController(id, "automation");
await this.ctx.db
.updateTable("browser_session")
.set({
controller: "automation",
recovery_state: JSON.stringify({
...obj(row.recovery_state),
waitingReason: null,
waitingDecisionId: null,
}),
updated_at: this.now(),
})
.where("id", "=", id)
.execute();
}
const rs = obj((await this.sessionRow(id)).recovery_state) as RecoveryState;
return {
status: "passed",
by,
clearance: await this.cfClearance(profileKey, site.base_url),
userAgent: w.page?.userAgent ?? (await this.identityUserAgent().catch(() => null)),
browserEgress: rs.egress ?? null,
};
});
}
/**
* 浏览器代抓一页(CF-2 回退):该账号 profile 的自动化会话导航到 url(取站点令牌,受 profile 锁约束),读 outerHTML 交给原解析器。
* 站点停用、会话正由人工处理 / 等人处理、仍是挑战页时不返回页面。
*/
async fetchSiteHtml(req: { siteId: string; accountId: string; url: string }): Promise<BrowserFetchOutcome> {
const site = await this.liveSite(req.siteId).catch(() => null);
if (!site?.enabled) return { status: "unavailable", reason: "站点已停用" };
const profileKey = profileKeyFor(site.id, req.accountId);
try {
return await this.cfLock.run(profileKey, async () => {
const got = await this.cfSession(site, req.accountId, req.url);
if ("busy" in got) return { status: "unavailable" as const, reason: "浏览器会话正由人工处理或在等人处理" };
const w = await this.waitChallenge(got.id, null, this.opts.challengeAutoWaitMs ?? CF_AUTO_WAIT_MS);
if (!w.page) return { status: "unavailable" as const, reason: "浏览器服务不支持读取页面内容" };
if (w.kind !== "none") return { status: "challenge" as const, reason: w.rule ?? w.kind };
return { status: "ok" as const, html: w.page.html, url: w.page.url };
});
} catch (e) {
const err = fromBrowserError(e);
return { status: "unavailable", reason: String((err as Error)?.message ?? err).slice(0, 300) };
}
}
/**
* 站点自己的页面验证码(字幕库的云锁)代码识别没过时,由智能体视觉接管(docs/plan/subtitles.md):
* 匿名会话打开 url → 已不是验证码页就直接算过 → 否则派视觉子流程(系统发起,读图、输入、提交,≤ 6 步,最多 1 次)
* → 按页面核实 marker 消失 → 读回站点 Cookie,交调用方并入它自己的 Cookie 罐后重试原请求。
* 没过:会话转等待,待处理事项写明去原站视图手动过一次;人过完交还后,下一次站点请求再走这里时就直接读回 Cookie。
*/
async solvePageCaptcha(req: {
siteId: string;
url: string;
marker: string;
goal: string;
}): Promise<PageCaptchaOutcome> {
const site = await this.liveSite(req.siteId);
const vision = visions.get(this.ctx);
const auto = await this.ctx.settings.get("automation").catch(() => null);
if (!vision || !auto?.enabled || auto.readOnlyReconcile)
return { status: "unavailable", reason: "自动化未开启,智能体不接管验证码" };
const profileKey = profileKeyFor(site.id, null);
return this.cfLock.run(profileKey, async () => {
const got = await this.cfSession(site, null, req.url);
if ("busy" in got)
return {
status: "needs_human" as const,
reason: "已在等你在原站视图里过验证码",
decisionId: (obj(got.busy.recovery_state) as RecoveryState).waitingDecisionId ?? null,
};
const id = got.id;
let page = await this.readPage(id).catch(() => null);
if (!page) return { status: "unavailable" as const, reason: "浏览器服务不支持读取页面内容" };
let by: "browser" | "vision" = "browser";
if (page.text.includes(req.marker)) {
const r = await vision({ sessionId: id, goal: req.goal, textAbsent: req.marker, maxSteps: 6 }).catch((e) => ({
status: "failed",
reason: String((e as Error)?.message ?? e),
childRunId: null,
}));
this.ctx.logger.info(
{ component: "browser", sessionId: id, siteId: site.id, status: r.status },
"页面验证码视觉接管结束",
);
page = await this.readPage(id).catch(() => null);
if (!page || page.text.includes(req.marker)) return this.captchaNeedsHuman(id, site, r.reason);
by = "vision";
}
const cookies = await this.siteCookies(profileKey, site.base_url);
return { status: "passed" as const, by, cookies };
});
}
/** profile 里作用于站点主机、未过期的 Cookie(只取名和值) */
private async siteCookies(profileKey: string, siteUrl: string): Promise<{ name: string; value: string }[]> {
const prow = await this.providerRow().catch(() => null);
const provider = prow ? this.providerFor(prow) : null;
const host = new URL(siteUrl).hostname;
const now = this.now().getTime();
return ((await provider?.getCookies?.(profileKey, [siteUrl]).catch(() => [])) ?? [])
.filter((c) => {
const d = c.domain.replace(/^\./, "");
return (
(host === d || host.endsWith(`.${d}`)) &&
!(c.expires !== undefined && c.expires > 0 && c.expires * 1000 <= now)
);
})
.map((c) => ({ name: c.name, value: c.value }));
}
/** 原站视图当前页的地址与站点 Cookie:字幕站从人工打开的字幕页直接下载(带上人在浏览器里过的验证) */
async currentPage(
id: string,
actor: Actor,
): Promise<{ siteId: string; url: string; cookies: { name: string; value: string }[] }> {
const row = await this.sessionRow(id);
const page = await this.run(
id,
actor,
(p, ref) => (p.pageContent ? p.pageContent(ref, { currentVisible: true }) : Promise.resolve(null)),
{
write: false,
},
);
if (!page?.url) throw new BrowserServiceError("precondition", "无法读取当前页面,请重新连接浏览器");
const site = await this.liveSite(row.site_id);
return { siteId: site.id, url: page.url, cookies: await this.siteCookies(row.profile_key, site.base_url) };
}
private async captchaNeedsHuman(id: string, site: SiteRow, reason: string): Promise<PageCaptchaOutcome> {
const text = {
title: `${site.name}需要你在原站视图里过一次验证码`,
summary: `站点要求输入验证码,代码识别和智能体都没能通过(${reason.slice(0, 200) || "未说明原因"})。在站点详情的原站视图接管会话,输完验证码后交还,再搜索一次即可。`,
};
let row = await this.sessionRow(id);
const rs = obj(row.recovery_state) as RecoveryState;
if (row.controller === "automation" || !rs.waitingDecisionId)
row = await this.enterWaiting(row, "challenge_unsolved", text);
else
await this.ctx.db
.updateTable("decision")
.set({ title: text.title, summary: text.summary })
.where("id", "=", rs.waitingDecisionId)
.execute();
const decisionId = (obj(row.recovery_state) as RecoveryState).waitingDecisionId ?? null;
this.ctx.logger.info({ component: "browser", sessionId: id, siteId: site.id }, "页面验证码转人工");
return { status: "needs_human", reason, decisionId };
}
/** 人过完验证交还后:站点在等人过验证、会话是那一个 → 读回 cf_clearance / UA,通知站点请求继续 */
private async afterHumanChallenge(id: string): Promise<void> {
const row = await this.sessionRow(id);
if (!row.account_id) return;
const rt = this.opts.runtime?.(this.ctx);
if (!rt) return;
const st = await rt.cloudflare.state(row.site_id, true);
if (st.pending !== "human") return;
const site = await this.liveSite(row.site_id);
const page = await this.readPage(id).catch(() => null);
if (page && classifyChallengePage(page).kind !== "none") return; // 还在挑战页:继续等
const rs = obj(row.recovery_state) as RecoveryState;
await rt.cloudflare.afterHuman(row.site_id, row.account_id, {
clearance: await this.cfClearance(row.profile_key, site.base_url),
userAgent: page?.userAgent ?? (await this.identityUserAgent().catch(() => null)),
browserEgress: rs.egress ?? null,
});
}
// ---------- 下载 ----------
/** 暂存产物核验:解析种子、按 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 = await parseTorrentIsolated(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) });
}
}
export type { ElementTarget };