feat(subtitles): 字幕库验证码由智能体视觉接管;字幕站原站视图下载当前字幕

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-03 11:43:08 +08:00
1 parent 023063edf4
commit c30a758a74
24 files changed
+466 -41

No files matched your search

+1 -1
View File
@@ -75,7 +75,7 @@ function route<D extends EndpointDef>(app: ModuleApp, def: D, handler: (input: I
}
/** 人工操作者:控制台会话 / API Key 的身份(租约 holder) */
function human(c: Context<AuthEnv>): Actor {
export function human(c: Context<AuthEnv>): Actor {
const p = c.get("principal");
return { kind: "human", holder: `${p?.kind ?? "unknown"}:${p?.name ?? p?.id ?? "?"}` };
}
+112 -1
View File
@@ -217,6 +217,12 @@ export type ChallengeVision = (req: {
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);
}
@@ -2012,7 +2018,7 @@ export class BrowserService {
*/
private async cfSession(
site: SiteRow,
accountId: string,
accountId: string | null,
url: string,
): Promise<{ id: string } | { busy: BrowserSessionRow }> {
const live = await this.liveSessionFor(site.id, accountId);
@@ -2194,6 +2200,111 @@ export class BrowserService {
}
}
/**
* 站点自己的页面验证码(字幕库的云锁)代码识别没过时,由智能体视觉接管(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);