/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]>
293 lines
11 KiB
TypeScript
293 lines
11 KiB
TypeScript
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) };
|
|
});
|
|
}
|