feat(subtitles): 字幕库验证码由智能体视觉接管;字幕站原站视图下载当前字幕
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
023063edf4
commit
c30a758a74
24 files changed
+466
-41
No files matched your search
@@ -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 ?? "?"}` };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { Api, type SubtitleApplyRequest, type SubtitleFetchRequest, type SubtitleSource } from "@umt/contracts";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import { route } from "../agent/http.ts";
|
||||
import { human } from "../browser/routes.ts";
|
||||
import { sourcesIdentityModule } from "../sources-identity/index.ts";
|
||||
import type { ServerModule } from "../types.ts";
|
||||
import { downloadOnline, SubtitleSiteError, searchOnline, subtitleSearchQuery } from "./online/index.ts";
|
||||
import {
|
||||
downloadFromSession,
|
||||
downloadOnline,
|
||||
SubtitleSiteError,
|
||||
searchOnline,
|
||||
subtitleSearchQuery,
|
||||
} from "./online/index.ts";
|
||||
import { applySubtitles, parseSubtitles, subtitleTargets, subtitleWrites, undoSubtitleWrite } from "./service.ts";
|
||||
|
||||
/** 补字幕(docs/plan/subtitles.md):上传 / 在线下载的字幕解析、配对、写入、历史与撤销 */
|
||||
@@ -32,10 +39,12 @@ export function createSubtitlesModule(): ServerModule {
|
||||
route(
|
||||
app,
|
||||
Api.subtitles.fetch,
|
||||
({ body }) =>
|
||||
({ body, c }) =>
|
||||
siteErr(async () => {
|
||||
const b = body as SubtitleFetchRequest;
|
||||
const f = await downloadOnline(ctx, rt(), b.siteId, b.ref);
|
||||
const f = b.sessionId
|
||||
? await downloadFromSession(ctx, rt(), b.sessionId, human(c))
|
||||
: await downloadOnline(ctx, rt(), b.siteId!, b.ref!);
|
||||
const parsed = await parseSubtitles(ctx, [{ name: f.name, base64: f.base64 }], b.entityId);
|
||||
// 在线来源:每项带上字幕站与条目地址,写入时记进历史
|
||||
return {
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { SiteRow } from "@umt/db";
|
||||
import { subtitleEpisode } from "@umt/domain";
|
||||
import { runSandbox } from "../../../sandbox/client.ts";
|
||||
import { b64encode } from "../../../sandbox/jobs.ts";
|
||||
import { browserServiceFor } from "../../browser/index.ts";
|
||||
import type { Actor } from "../../browser/service.ts";
|
||||
import type { SourcesRuntime } from "../../sources-identity/runtime.ts";
|
||||
import type { ServerContext } from "../../types.ts";
|
||||
import type { PageEntry, PageKind, PageResult } from "./parse.ts";
|
||||
@@ -22,6 +24,7 @@ export class SubtitleSiteError extends Error {
|
||||
const jars = new Map<string, Map<string, string>>();
|
||||
|
||||
interface Env {
|
||||
ctx: ServerContext;
|
||||
rt: SourcesRuntime;
|
||||
site: SiteRow;
|
||||
}
|
||||
@@ -206,22 +209,53 @@ async function subhdDownload(env: Env, ref: string): Promise<{ name: string; byt
|
||||
|
||||
// ── 字幕库 ──
|
||||
|
||||
/** 打开页面;遇到云锁验证码页就在沙箱里识别、提交验证后重开(最多 3 次) */
|
||||
/** 云锁验证码页上的字样(浏览器里 innerText 能读到) */
|
||||
const ZIMUKU_CAPTCHA_MARKER = "网站访问认证页面";
|
||||
|
||||
/**
|
||||
* 打开页面;遇到云锁验证码页:先在沙箱里识别、提交验证后重开(最多 3 次);
|
||||
* 还没过 → 智能体视觉在浏览器里接管(读图输码),通过后把浏览器里的站点 Cookie 并进 Cookie 罐再开一次。
|
||||
*/
|
||||
async function zimukuPage(env: Env, url: string, referer?: string): Promise<string> {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
/** 开一次页面:正常页返回 HTML,验证码页返回 BMP */
|
||||
const open = async (): Promise<{ html: string } | { bmp: string }> => {
|
||||
const res = await request(env, url, referer ? { referer } : {});
|
||||
const html = await res.text();
|
||||
const cap = await page("zimuku.captcha", html);
|
||||
if (!cap.bmpBase64) {
|
||||
if (!res.ok) throw new SubtitleSiteError(`${env.site.name} 返回 HTTP ${res.status}`);
|
||||
return html;
|
||||
}
|
||||
const { code } = await runSandbox("captcha.solve", { kind: "zimuku", bmpBase64: cap.bmpBase64 });
|
||||
const bmp = (await page("zimuku.captcha", html)).bmpBase64;
|
||||
if (bmp) return { bmp };
|
||||
if (!res.ok) throw new SubtitleSiteError(`${env.site.name} 返回 HTTP ${res.status}`);
|
||||
return { html };
|
||||
};
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const o = await open();
|
||||
if ("html" in o) return o.html;
|
||||
const { code } = await runSandbox("captcha.solve", { kind: "zimuku", bmpBase64: o.bmp });
|
||||
if (!code) break;
|
||||
const hex = [...code].map((c) => c.charCodeAt(0).toString(16)).join("");
|
||||
await (await request(env, `${url}${url.includes("?") ? "&" : "?"}security_verify_img=${hex}`)).body?.cancel();
|
||||
}
|
||||
throw new SubtitleSiteError("字幕库的验证码没能自动通过:可在站点的原站视图里打开一次再试");
|
||||
const browser = browserServiceFor(env.ctx);
|
||||
const r = browser
|
||||
? await browser.solvePageCaptcha({
|
||||
siteId: env.site.id,
|
||||
url,
|
||||
marker: ZIMUKU_CAPTCHA_MARKER,
|
||||
goal: "这是字幕库的网站防火墙验证页:截图看清验证码图片里的字符,在下面的输入框里输入,再点「点击继续访问网站」,等页面跳转。只做这些;看不清就刷新一次图片再试,仍不行就停下说明需要人工。",
|
||||
})
|
||||
: { status: "unavailable" as const, reason: "浏览器模块未启用" };
|
||||
if (r.status === "passed") {
|
||||
const jar = jars.get(env.site.id) ?? new Map<string, string>();
|
||||
jars.set(env.site.id, jar);
|
||||
for (const c of r.cookies) jar.set(c.name, c.value);
|
||||
const o = await open();
|
||||
if ("html" in o) return o.html;
|
||||
throw new SubtitleSiteError("字幕库的验证码在浏览器里过了,但验证结果带不回站点请求:稍后再试");
|
||||
}
|
||||
if (r.status === "needs_human")
|
||||
throw new SubtitleSiteError(
|
||||
"字幕库的验证码代码识别和智能体都没过:已开待处理事项,去站点的原站视图输一次验证码后再搜",
|
||||
);
|
||||
throw new SubtitleSiteError(`字幕库的验证码没能自动通过(${r.reason}):可在站点的原站视图里打开一次再试`);
|
||||
}
|
||||
|
||||
async function zimukuSearch(env: Env, q: SearchQuery): Promise<PageEntry[]> {
|
||||
@@ -326,7 +360,7 @@ export async function searchOnline(
|
||||
const errors: SubtitleSearchResponse["errors"] = [];
|
||||
await Promise.all(
|
||||
sites.map(async (site) => {
|
||||
const env = { rt, site };
|
||||
const env = { ctx, rt, site };
|
||||
try {
|
||||
const entries = await PROVIDERS[site.key]!.search(env, q);
|
||||
const seen = new Set<string>();
|
||||
@@ -350,10 +384,40 @@ export async function downloadOnline(
|
||||
rt: SourcesRuntime,
|
||||
siteId: string,
|
||||
ref: string,
|
||||
cookies: { name: string; value: string }[] = [],
|
||||
): Promise<{ name: string; base64: string; siteKey: string; pageUrl: string }> {
|
||||
const site = (await subtitleSites(ctx)).find((s) => s.id === siteId);
|
||||
if (!site || !PROVIDERS[site.key]) throw new SubtitleSiteError("字幕站不存在或已停用");
|
||||
const env = { rt, site };
|
||||
const env = { ctx, rt, site };
|
||||
if (cookies.length) {
|
||||
const jar = jars.get(site.id) ?? new Map<string, string>();
|
||||
jars.set(site.id, jar);
|
||||
for (const c of cookies) jar.set(c.name, c.value);
|
||||
}
|
||||
const f = await PROVIDERS[site.key]!.download(env, ref);
|
||||
return { name: f.name, base64: b64encode(f.bytes), siteKey: site.key, pageUrl: abs(env, ref) };
|
||||
}
|
||||
|
||||
/** 字幕详情页地址 → 条目 ref(SubHD /a/<sid>、字幕库 /detail/<id>.html);不是详情页为 null */
|
||||
const DETAIL_PAGE: Record<string, RegExp> = {
|
||||
subhd: /^\/a\/[A-Za-z0-9_-]+$/,
|
||||
zimuku: /^\/detail\/\d+\.html$/,
|
||||
};
|
||||
|
||||
/** 原站视图里人工打开的字幕页:取当前页地址与浏览器里的 Cookie,按同一流程下载 */
|
||||
export async function downloadFromSession(
|
||||
ctx: ServerContext,
|
||||
rt: SourcesRuntime,
|
||||
sessionId: string,
|
||||
actor: Actor,
|
||||
): Promise<{ name: string; base64: string; siteKey: string; pageUrl: string }> {
|
||||
const browser = browserServiceFor(ctx);
|
||||
if (!browser) throw new SubtitleSiteError("浏览器模块未启用");
|
||||
const p = await browser.currentPage(sessionId, actor);
|
||||
const site = (await subtitleSites(ctx)).find((s) => s.id === p.siteId);
|
||||
if (!site || !PROVIDERS[site.key]) throw new SubtitleSiteError("这个会话不是已启用的字幕站");
|
||||
const url = new URL(p.url);
|
||||
if (url.hostname !== new URL(site.base_url).hostname || !DETAIL_PAGE[site.key]?.test(url.pathname))
|
||||
throw new SubtitleSiteError("先在原站视图里打开一条字幕的详情页,再点下载");
|
||||
return downloadOnline(ctx, rt, site.id, url.pathname, p.cookies);
|
||||
}
|
||||
Reference in new issue
Block a user