feat(server): browser module with DB-backed sessions, takeover lease and browser.* capabilities
/browser/providers, /browser/sessions (open/close/list), navigate/snapshot/screenshot/ click/type/scroll/select/extract/download and control acquire/release. Human input needs an active lease; expiry or captcha moves the session to waiting with a decision; release forces a fresh snapshot. Downloads are staged under DATA_DIR/tmp, verified by infohash and submitted through POST /acquisition-plans. Capability count 21 -> 25. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
c17d4b90bd
commit
adffe36846
12 files changed
+2133
-5
No files matched your search
@@ -0,0 +1,292 @@
|
||||
import { createRoute, type RouteConfig } from "@hono/zod-openapi";
|
||||
import {
|
||||
Api,
|
||||
type ApiScope,
|
||||
apiResponse,
|
||||
type BrowserDownloadResult,
|
||||
type BrowserNavigation,
|
||||
type BrowserScreenshotView,
|
||||
type EndpointDef,
|
||||
type ManualAcquisitionResult,
|
||||
} from "@umt/contracts";
|
||||
import type { Context } from "hono";
|
||||
import type { z } from "zod";
|
||||
import { type AuthEnv, requireScope } from "../../http/auth.ts";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import { InternalDispatcher } from "../search/dispatch.ts";
|
||||
import type { ModuleApp } from "../types.ts";
|
||||
import { type Actor, type BrowserService, BrowserServiceError, imageSize } from "./service.ts";
|
||||
|
||||
// C1-E 浏览器 HTTP 端点:原站视图与外部程序共用。读(会话/快照/截图)任何时候可用;
|
||||
// 输入类动作(导航/点击/输入/滚动/选择/标签页/触发下载)只接受持有人工租约的调用者(先接管)。
|
||||
|
||||
type Infer<T> = T extends z.ZodType ? z.infer<T> : Record<string, never>;
|
||||
interface Input<D extends EndpointDef> {
|
||||
params: Infer<D["params"]>;
|
||||
query: Infer<D["query"]>;
|
||||
body: Infer<D["body"]>;
|
||||
c: Context<AuthEnv>;
|
||||
}
|
||||
|
||||
const CODE = {
|
||||
not_found: "not_found",
|
||||
conflict: "conflict",
|
||||
precondition: "precondition_failed",
|
||||
bad_request: "bad_request",
|
||||
upstream: "upstream_unavailable",
|
||||
rate_limited: "rate_limited",
|
||||
} as const;
|
||||
|
||||
function route<D extends EndpointDef>(app: ModuleApp, def: D, handler: (input: Input<D>) => Promise<unknown>) {
|
||||
const request: RouteConfig["request"] = {};
|
||||
if (def.params) request.params = def.params as never;
|
||||
if (def.query) request.query = def.query as never;
|
||||
if (def.body) request.body = { content: { "application/json": { schema: def.body as never } }, required: true };
|
||||
const r = createRoute({
|
||||
method: def.method,
|
||||
path: def.path,
|
||||
summary: def.summary,
|
||||
tags: ["browser"],
|
||||
middleware: [requireScope(def.scope as ApiScope)] as never,
|
||||
request,
|
||||
responses: {
|
||||
200: { description: def.summary, content: { "application/json": { schema: apiResponse(def.response) } } },
|
||||
},
|
||||
});
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 路由泛型在封装里擦除,入参已由 zod-openapi 校验
|
||||
app.openapi(r, (async (c: any) => {
|
||||
const input = {
|
||||
params: def.params ? c.req.valid("param") : {},
|
||||
query: def.query ? c.req.valid("query") : {},
|
||||
body: def.body ? c.req.valid("json") : {},
|
||||
c,
|
||||
} as Input<D>;
|
||||
try {
|
||||
const data = def.response.parse(await handler(input));
|
||||
return c.json({ ok: true, data }, 200);
|
||||
} catch (e) {
|
||||
if (e instanceof BrowserServiceError) throw new ApiException(CODE[e.code], e.message);
|
||||
throw e;
|
||||
}
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
}) as any);
|
||||
}
|
||||
|
||||
/** 人工操作者:控制台会话 / API Key 的身份(租约 holder) */
|
||||
function human(c: Context<AuthEnv>): Actor {
|
||||
const p = c.get("principal");
|
||||
return { kind: "human", holder: `${p?.kind ?? "unknown"}:${p?.name ?? p?.id ?? "?"}` };
|
||||
}
|
||||
|
||||
const b64 = (data: Uint8Array) => Buffer.from(data).toString("base64");
|
||||
|
||||
export function registerBrowserRoutes(app: ModuleApp, svc: () => BrowserService) {
|
||||
const dispatch = new InternalDispatcher(app);
|
||||
const B = Api.browser;
|
||||
|
||||
route(app, B.providers, async ({ query }) => ({ items: await svc().listProviders(query.probe === "true") }));
|
||||
|
||||
route(app, B.sessions, async ({ query }) => ({
|
||||
items: await svc().list({
|
||||
...(query.siteId ? { siteId: query.siteId } : {}),
|
||||
includeClosed: query.includeClosed === "true",
|
||||
}),
|
||||
}));
|
||||
|
||||
route(app, B.open, async ({ body }) => {
|
||||
const id = await svc().open({
|
||||
siteId: body.siteId,
|
||||
...(body.accountId !== undefined ? { accountId: body.accountId } : {}),
|
||||
...(body.providerId ? { providerId: body.providerId } : {}),
|
||||
...(body.url ? { url: body.url } : {}),
|
||||
});
|
||||
return svc().view(id);
|
||||
});
|
||||
|
||||
route(app, B.get, async ({ params }) => svc().view(params.id));
|
||||
|
||||
route(app, B.close, async ({ params }) => {
|
||||
await svc().close(params.id, "closed_by_user");
|
||||
return svc().view(params.id);
|
||||
});
|
||||
|
||||
route(app, B.openTab, async ({ params, body, c }) => {
|
||||
await svc().run(params.id, human(c), async (p, ref, x) => {
|
||||
if (body.url) await svc().limited(x.site);
|
||||
return p.openTab(ref.sessionId, { ...(body.url ? { url: body.url } : {}), budget: x.budget, as: "human" });
|
||||
});
|
||||
return svc().view(params.id);
|
||||
});
|
||||
|
||||
route(app, B.closeTab, async ({ params, c }) => {
|
||||
await svc().run(params.id, human(c), (p, ref) => p.closeTab(ref), { tabId: params.tabId });
|
||||
return svc().view(params.id);
|
||||
});
|
||||
|
||||
route(app, B.navigate, async ({ params, body, c }): Promise<BrowserNavigation> => {
|
||||
if (!body.url && !body.action) throw new ApiException("bad_request", "url 与 action 二选一");
|
||||
const out = await svc().run(
|
||||
params.id,
|
||||
human(c),
|
||||
async (p, ref, x) => {
|
||||
await svc().limited(x.site);
|
||||
const nav = body.url ? await p.navigate(ref, body.url, x.budget) : await p.history(ref, body.action!, x.budget);
|
||||
return { ...nav, loginRequired: p.sessionInfo(ref.sessionId)?.loginPage ?? false };
|
||||
},
|
||||
body.tabId ? { tabId: body.tabId } : {},
|
||||
);
|
||||
return { ...out, session: await svc().view(params.id) };
|
||||
});
|
||||
|
||||
route(app, B.snapshot, async ({ params, query, c }) =>
|
||||
svc().run(
|
||||
params.id,
|
||||
human(c),
|
||||
(p, ref) => p.snapshot(ref, query.offset !== undefined ? { offset: query.offset } : {}),
|
||||
{ write: false, ...(query.tabId ? { tabId: query.tabId } : {}) },
|
||||
),
|
||||
);
|
||||
|
||||
route(app, B.screenshot, async ({ params, query, c }): Promise<BrowserScreenshotView> => {
|
||||
const shot = await svc().run(params.id, human(c), (p, ref) => p.screenshot(ref, { maxBytes: 8 * 1024 * 1024 }), {
|
||||
write: false,
|
||||
persist: false,
|
||||
...(query.tabId ? { tabId: query.tabId } : {}),
|
||||
});
|
||||
const view = await svc().view(params.id);
|
||||
return {
|
||||
mimeType: shot.mimeType,
|
||||
dataBase64: b64(shot.data),
|
||||
...imageSize(shot.data),
|
||||
url: shot.url,
|
||||
modelSafe: shot.modelSafe,
|
||||
capturedAt: shot.capturedAt,
|
||||
controller: view.controller,
|
||||
};
|
||||
});
|
||||
|
||||
const ok = async (id: string) => ({ ok: true as const, session: await svc().view(id) });
|
||||
|
||||
route(app, B.click, async ({ params, body, c }) => {
|
||||
await svc().run(
|
||||
params.id,
|
||||
human(c),
|
||||
(p, ref) => p.click(ref, body.target),
|
||||
body.tabId ? { tabId: body.tabId } : {},
|
||||
);
|
||||
return ok(params.id);
|
||||
});
|
||||
|
||||
route(app, B.type, async ({ params, body, c }) => {
|
||||
await svc().run(
|
||||
params.id,
|
||||
human(c),
|
||||
async (p, ref, x) => {
|
||||
if (body.key) return p.press(ref, body.key);
|
||||
if (!body.credential && body.text === undefined)
|
||||
throw new BrowserServiceError("bad_request", "text / credential / key 至少给一个");
|
||||
const extra = {
|
||||
...(body.submit !== undefined ? { submit: body.submit } : {}),
|
||||
...(body.clear !== undefined ? { clear: body.clear } : {}),
|
||||
};
|
||||
return p.type(
|
||||
ref,
|
||||
body.target ?? null,
|
||||
body.credential
|
||||
? { kind: "secret", siteId: x.row.site_id, accountId: x.row.account_id, field: body.credential, ...extra }
|
||||
: { kind: "text", text: body.text ?? "", ...extra },
|
||||
);
|
||||
},
|
||||
body.tabId ? { tabId: body.tabId } : {},
|
||||
);
|
||||
return ok(params.id);
|
||||
});
|
||||
|
||||
route(app, B.scroll, async ({ params, body, c }) => {
|
||||
await svc().run(
|
||||
params.id,
|
||||
human(c),
|
||||
(p, ref) => p.scroll(ref, { direction: body.direction, amount: body.amount }),
|
||||
body.tabId ? { tabId: body.tabId } : {},
|
||||
);
|
||||
return ok(params.id);
|
||||
});
|
||||
|
||||
route(app, B.select, async ({ params, body, c }) => {
|
||||
await svc().run(
|
||||
params.id,
|
||||
human(c),
|
||||
(p, ref) => p.select(ref, body.target, body.value),
|
||||
body.tabId ? { tabId: body.tabId } : {},
|
||||
);
|
||||
return ok(params.id);
|
||||
});
|
||||
|
||||
route(app, B.extract, async ({ params, body, c }) => ({
|
||||
data: await svc().run(params.id, human(c), (p, ref) => p.extract(ref, body.schema), {
|
||||
write: false,
|
||||
...(body.tabId ? { tabId: body.tabId } : {}),
|
||||
}),
|
||||
}));
|
||||
|
||||
// 下载:给 target 视为人工输入(需接管);不给 target 只等待已触发的下载。
|
||||
// 产物暂存核验后,给 submit 时转调 POST /acquisition-plans(同一鉴权、同一计划/去重/阈值/授权检查)。
|
||||
route(app, B.download, async ({ params, body, c }): Promise<BrowserDownloadResult> => {
|
||||
const artifact = await svc().run(
|
||||
params.id,
|
||||
human(c),
|
||||
async (p, ref) => {
|
||||
if (body.target) await p.click(ref, body.target);
|
||||
return p.waitForDownload(ref, { timeoutMs: body.timeoutMs, maxBytes: 10 * 1024 * 1024 });
|
||||
},
|
||||
{ write: !!body.target, ...(body.tabId ? { tabId: body.tabId } : {}) },
|
||||
);
|
||||
const v = await svc().verifyDownload(params.id, artifact);
|
||||
let submission: ManualAcquisitionResult | null = null;
|
||||
let note: string | null = null;
|
||||
if (v.duplicate) note = "同一会话已捕获过这个文件,未重复提交";
|
||||
else if (!v.verification.valid) note = "不是有效的种子文件,未提交";
|
||||
else if (!v.sourceReleaseId) note = "资源不在本地索引(先快照或搜索该站点),未提交";
|
||||
else if (body.submit) {
|
||||
try {
|
||||
submission = await dispatch.call<ManualAcquisitionResult>(c, "POST", "/acquisition-plans", {
|
||||
...body.submit,
|
||||
sourceReleaseId: v.sourceReleaseId,
|
||||
});
|
||||
if (!submission.submitted) note = "提交未生成下载计划,见 rejected";
|
||||
} catch (e) {
|
||||
// 与 POST /acquisition-plans 相同的拒绝(未识别、无授权等):产物已暂存,结果照常返回
|
||||
if (!(e instanceof ApiException) || e.code === "internal") throw e;
|
||||
note = `提交未通过:${e.message}`;
|
||||
}
|
||||
}
|
||||
await svc().recordDownload(params.id, {
|
||||
sha256: artifact.sha256,
|
||||
artifactId: artifact.id,
|
||||
sourceReleaseId: v.sourceReleaseId,
|
||||
planId: submission?.plan?.id ?? v.duplicate?.planId ?? null,
|
||||
});
|
||||
return {
|
||||
artifact,
|
||||
verification: v.verification,
|
||||
sourceReleaseId: v.sourceReleaseId,
|
||||
duplicate: !!v.duplicate,
|
||||
submission,
|
||||
note,
|
||||
};
|
||||
});
|
||||
|
||||
route(app, B.acquire, async ({ params, body, c }) => {
|
||||
const lease = await svc().acquire(params.id, human(c).holder, body.reason, body.ttlMs);
|
||||
return { lease, session: await svc().view(params.id) };
|
||||
});
|
||||
|
||||
route(app, B.release, async ({ params, body }) => {
|
||||
const lease = await svc().release(params.id, {
|
||||
...(body.leaseId ? { leaseId: body.leaseId } : {}),
|
||||
reason: body.reason,
|
||||
to: body.to,
|
||||
});
|
||||
return { lease, session: await svc().view(params.id) };
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user