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

+12 -3
View File
@@ -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);
}