diff --git a/apps/server/deno.json b/apps/server/deno.json index 2be977aa..25a56765 100644 --- a/apps/server/deno.json +++ b/apps/server/deno.json @@ -1,6 +1,6 @@ { "name": "@umt/server", - "version": "0.3.24", + "version": "0.3.25", "exports": { ".": "./src/main.ts" }, "tasks": { "dev": "deno run -A --watch --env-file=../../.env src/main.ts", diff --git a/apps/server/src/modules/browser/routes.ts b/apps/server/src/modules/browser/routes.ts index 15b798e9..32cfa9b8 100644 --- a/apps/server/src/modules/browser/routes.ts +++ b/apps/server/src/modules/browser/routes.ts @@ -75,7 +75,7 @@ function route(app: ModuleApp, def: D, handler: (input: I } /** 人工操作者:控制台会话 / API Key 的身份(租约 holder) */ -function human(c: Context): Actor { +export function human(c: Context): Actor { const p = c.get("principal"); return { kind: "human", holder: `${p?.kind ?? "unknown"}:${p?.name ?? p?.id ?? "?"}` }; } diff --git a/apps/server/src/modules/browser/service.ts b/apps/server/src/modules/browser/service.ts index faf92d39..ee50768d 100644 --- a/apps/server/src/modules/browser/service.ts +++ b/apps/server/src/modules/browser/service.ts @@ -217,6 +217,12 @@ export type ChallengeVision = (req: { const visions = new WeakMap(); +/** 页面验证码接管结果: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 { + 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 { + 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 { const row = await this.sessionRow(id); diff --git a/apps/server/src/modules/subtitles/index.ts b/apps/server/src/modules/subtitles/index.ts index af0c1986..3a4cd20c 100644 --- a/apps/server/src/modules/subtitles/index.ts +++ b/apps/server/src/modules/subtitles/index.ts @@ -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 { diff --git a/apps/server/src/modules/subtitles/online/index.ts b/apps/server/src/modules/subtitles/online/index.ts index bb551956..3d73bcd7 100644 --- a/apps/server/src/modules/subtitles/online/index.ts +++ b/apps/server/src/modules/subtitles/online/index.ts @@ -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>(); 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 { - 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(); + 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 { @@ -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(); @@ -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(); + 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/、字幕库 /detail/.html);不是详情页为 null */ +const DETAIL_PAGE: Record = { + 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); +} diff --git a/apps/server/test/browser/harness.ts b/apps/server/test/browser/harness.ts index 433b2656..1cee1db8 100644 --- a/apps/server/test/browser/harness.ts +++ b/apps/server/test/browser/harness.ts @@ -69,6 +69,7 @@ export async function createBrowserHarness( /** CF-2:mock 站点的 Cloudflare 挑战与页面 HTML */ cloudflare?: MockCamofoxSite["cloudflare"]; html?: MockCamofoxSite["html"]; + pageText?: MockCamofoxSite["pageText"]; /** 浏览器模块的额外选项(挑战轮询间隔等) */ browserOptions?: Partial; } = {}, @@ -82,6 +83,7 @@ export async function createBrowserHarness( ...(opts.sessionCookie ? { sessionCookie: opts.sessionCookie } : {}), ...(opts.cloudflare ? { cloudflare: opts.cloudflare } : {}), ...(opts.html ? { html: opts.html } : {}), + ...(opts.pageText ? { pageText: opts.pageText } : {}), origin: ORIGIN, username: "fixture-user", password: PASSWORD, diff --git a/apps/server/test/browser/page-captcha.test.ts b/apps/server/test/browser/page-captcha.test.ts new file mode 100644 index 00000000..5314b338 --- /dev/null +++ b/apps/server/test/browser/page-captcha.test.ts @@ -0,0 +1,141 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { browserServiceFor } from "../../src/modules/browser/index.ts"; +import { type BrowserService, registerChallengeVision } from "../../src/modules/browser/service.ts"; +import { type BrowserHarness, createBrowserHarness, type Loose, ORIGIN, pgAvailable } from "./harness.ts"; + +// 站点自己的页面验证码(字幕库云锁)代码识别没过 → 智能体视觉接管: +// 过了带回站点 Cookie;没过会话转等待、开待处理事项;自动化关闭时不接管。 + +const up = await pgAvailable(); +const MARKER = "网站访问认证页面"; + +describe.skipIf(!up)("页面验证码视觉接管", () => { + let h: BrowserHarness; + let svc: BrowserService; + let captchaUp = true; + let visionSolves = true; + const visionCalls: string[] = []; + const url = `${ORIGIN}/search?q=dune`; + const req = { url, marker: MARKER, goal: "输入验证码" }; + + const sessions = async () => (await h.req("/browser/sessions")).body.data.items as Loose[]; + + beforeAll(async () => { + h = await createBrowserHarness("pagecap", { + pageText: (u) => (captchaUp && u.includes("/search") ? `网站防火墙\n${MARKER}\n请输入验证码后继续访问:` : null), + browserOptions: { challengePollMs: 5, challengeAutoWaitMs: 200, settleMs: 0 }, + }); + await sessions(); + svc = browserServiceFor(h.ctx)!; + // 视觉子流程(假):输对验证码 → 站点种下验证 Cookie、页面放行 + registerChallengeVision(h.ctx, async (r) => { + visionCalls.push(r.sessionId); + if (visionSolves) { + captchaUp = false; + h.mock.cookieJars.set(`umt-site-${h.siteId}-acct-anon`, [ + { + name: "security_session_verify", + value: "ok-from-browser", + domain: new URL(ORIGIN).hostname, + path: "/", + expires: Math.floor(Date.now() / 1000) + 3600, + httpOnly: true, + secure: true, + }, + ]); + } + return { + status: visionSolves ? "solved" : "needs_human", + reason: visionSolves ? "" : "看不清", + childRunId: null, + }; + }); + }, 120_000); + afterAll(async () => { + await h?.close(); + }); + beforeEach(async () => { + for (const s of await sessions()) await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" }); + h.mock.cookieJars.clear(); + captchaUp = true; + visionSolves = true; + visionCalls.length = 0; + await h.db + .insertInto("settings") + .values({ key: "automation", value: JSON.stringify({ enabled: true }) }) + .onConflict((oc) => oc.column("key").doUpdateSet({ value: JSON.stringify({ enabled: true }) })) + .execute(); + }); + + it("视觉接管通过:匿名会话,带回站点 Cookie", async () => { + const r = await svc.solvePageCaptcha({ siteId: h.siteId, ...req }); + expect(r).toEqual({ + status: "passed", + by: "vision", + cookies: [{ name: "security_session_verify", value: "ok-from-browser" }], + }); + expect(visionCalls).toHaveLength(1); + const [s] = await sessions(); + expect(s.accountId ?? null).toBeNull(); + expect(s.controller).toBe("automation"); + }); + + it("浏览器里已经没有验证码页(人工过过):不派子流程,直接带回 Cookie", async () => { + captchaUp = false; + const r = await svc.solvePageCaptcha({ siteId: h.siteId, ...req }); + expect(r).toMatchObject({ status: "passed", by: "browser" }); + expect(visionCalls).toEqual([]); + }); + + it("视觉接管没过:会话转等待,待处理事项写明去原站视图输验证码;再来一次不打扰", async () => { + visionSolves = false; + const r = await svc.solvePageCaptcha({ siteId: h.siteId, ...req }); + expect(r).toMatchObject({ status: "needs_human", reason: "看不清" }); + const d = await h.db + .selectFrom("decision") + .selectAll() + .where("id", "=", (r as Loose).decisionId) + .executeTakeFirstOrThrow(); + expect(d.title).toContain("需要你在原站视图里过一次验证码"); + expect(d.status).toBe("awaiting_user"); + const [s] = await sessions(); + expect(s.controller).toBe("waiting"); + const again = await svc.solvePageCaptcha({ siteId: h.siteId, ...req }); + expect(again).toMatchObject({ status: "needs_human", decisionId: (r as Loose).decisionId }); + expect(visionCalls).toHaveLength(1); + }); + + it("原站视图当前页:人工打开的页面地址与浏览器里的站点 Cookie(字幕站从当前页下载)", async () => { + captchaUp = false; + await svc.solvePageCaptcha({ siteId: h.siteId, ...req }); + h.mock.cookieJars.set(`umt-site-${h.siteId}-acct-anon`, [ + { + name: "security_session_verify", + value: "from-human", + domain: new URL(ORIGIN).hostname, + path: "/", + expires: Math.floor(Date.now() / 1000) + 3600, + httpOnly: true, + secure: true, + }, + ]); + const [s] = await sessions(); + const p = await svc.currentPage(s.id, { kind: "human", holder: "user:tester" }); + expect(p).toEqual({ + siteId: h.siteId, + url, + cookies: [{ name: "security_session_verify", value: "from-human" }], + }); + }); + + it("自动化总开关关闭:不接管", async () => { + await h.db + .updateTable("settings") + .set({ value: JSON.stringify({ enabled: false }) }) + .where("key", "=", "automation") + .execute(); + const r = await svc.solvePageCaptcha({ siteId: h.siteId, ...req }); + expect(r).toMatchObject({ status: "unavailable" }); + expect(visionCalls).toEqual([]); + }); +}); diff --git a/apps/web/app/components/browser/desktop-view.tsx b/apps/web/app/components/browser/desktop-view.tsx index 1a1ef096..9fc5d6b4 100644 --- a/apps/web/app/components/browser/desktop-view.tsx +++ b/apps/web/app/components/browser/desktop-view.tsx @@ -11,6 +11,7 @@ import { } from "~/api/browser.ts"; import { LoadingState } from "~/components/console/empty-state.tsx"; import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx"; +import { SubtitleFromPage } from "~/components/subtitles/subtitle-from-page.tsx"; import { Button } from "~/components/ui/button.tsx"; import { apiData, errorMessage } from "~/lib/api.ts"; import { qk } from "~/lib/query-keys.ts"; @@ -209,7 +210,7 @@ function DesktopView({ return (
- + {site.kind === "subtitle" ? : }
{conn.text} diff --git a/apps/web/app/components/browser/site-origin-view.tsx b/apps/web/app/components/browser/site-origin-view.tsx index 69adc528..7a97b3ce 100644 --- a/apps/web/app/components/browser/site-origin-view.tsx +++ b/apps/web/app/components/browser/site-origin-view.tsx @@ -31,6 +31,7 @@ import { } from "~/api/browser.ts"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx"; +import { SubtitleFromPage } from "~/components/subtitles/subtitle-from-page.tsx"; import { Button } from "~/components/ui/button.tsx"; import { Input } from "~/components/ui/input.tsx"; import { errorMessage } from "~/lib/api.ts"; @@ -154,7 +155,7 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {

验证码等需本人操作在此完成。

- + {site.kind === "subtitle" ? : }
{conn.text} diff --git a/apps/web/app/components/subtitles/subtitle-dialog.tsx b/apps/web/app/components/subtitles/subtitle-dialog.tsx index e7635479..e09093b9 100644 --- a/apps/web/app/components/subtitles/subtitle-dialog.tsx +++ b/apps/web/app/components/subtitles/subtitle-dialog.tsx @@ -6,7 +6,7 @@ import type { SubtitleWriteView, } from "@umt/contracts"; import { Download, ExternalLink, FileUp, LoaderCircle, Search, Undo2 } from "lucide-react"; -import { useId, useMemo, useRef, useState } from "react"; +import { useEffect, useId, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { useApplySubtitles, @@ -68,11 +68,14 @@ export function SubtitleDialog({ title, open, onOpenChange, + fromSession, }: { entityId: string; title: string; open: boolean; onOpenChange: (o: boolean) => void; + /** 原站视图会话:打开时自动下载它当前页的字幕并配对 */ + fromSession?: string; }) { const inputId = useId(); const input = useRef(null); @@ -86,7 +89,20 @@ export function SubtitleDialog({ const [dragging, setDragging] = useState(false); const [tab, setTab] = useState<"upload" | "online">("upload"); const list = targets.data?.targets ?? []; - const busy = parse.isPending || apply.isPending; + const fetchPage = useFetchSubtitle(); + const busy = parse.isPending || apply.isPending || fetchPage.isPending; + const fetchedFrom = useRef(null); + useEffect(() => { + if (!open || !fromSession || fetchedFrom.current === fromSession) return; + fetchedFrom.current = fromSession; + fetchPage + .mutateAsync({ entityId, sessionId: fromSession }) + .then((r) => { + setRows((prev) => [...prev, ...r.items.map((it) => ({ ...it, target: it.targetLocationId ?? SKIP }))]); + setSkipped((prev) => [...prev, ...r.skipped]); + }) + .catch((err) => toast.error(errorMessage(err, "下载当前字幕失败"))); + }, [open, fromSession, entityId, fetchPage]); const load = async (files: File[]) => { if (!files.length) return; @@ -207,6 +223,12 @@ export function SubtitleDialog({ /> )} + {fetchPage.isPending ? ( +

+ + 正在下载原站视图当前页的字幕… +

+ ) : null} {rows.length ? (
    {rows.map((r) => ( diff --git a/apps/web/app/components/subtitles/subtitle-from-page.tsx b/apps/web/app/components/subtitles/subtitle-from-page.tsx new file mode 100644 index 00000000..35805f04 --- /dev/null +++ b/apps/web/app/components/subtitles/subtitle-from-page.tsx @@ -0,0 +1,64 @@ +import type { BrowserSessionView } from "@umt/contracts"; +import { Download } from "lucide-react"; +import { useState } from "react"; +import { EntityPicker, type PickedEntity } from "~/components/library/identity-entity-picker.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog.tsx"; +import { SubtitleDialog } from "./subtitle-dialog.tsx"; + +/** + * 字幕站的原站视图:人在浏览器里打开一条字幕的详情页(验证码已在浏览器里过)→「下载当前字幕」→ 选作品 → + * 补字幕对话框自动下载这条字幕、按季集配对到视频,可改写到哪个视频后写入。 + */ +export function SubtitleFromPage({ session }: { session: BrowserSessionView }) { + const [picking, setPicking] = useState(false); + const [entity, setEntity] = useState(null); + const [writing, setWriting] = useState(null); + return ( + <> + + + + + 下载当前字幕 + + 先在原站视图里打开一条字幕的详情页。选这条字幕属于哪部作品,下一步再选写到哪个视频。 + + + + + + + + + {writing ? ( + !o && setWriting(null)} + /> + ) : null} + + ); +} diff --git a/apps/web/package.json b/apps/web/package.json index 8c46fd11..601c8c38 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@umt/web", - "version": "0.3.24", + "version": "0.3.25", "private": true, "type": "module", "scripts": { diff --git a/docs/plan/README.md b/docs/plan/README.md index dfad6292..8a48f451 100644 --- a/docs/plan/README.md +++ b/docs/plan/README.md @@ -31,7 +31,7 @@ ### 进行中 -- 补字幕:[subtitles.md](subtitles.md),第 1、2 步已完成;待办:以后接进整理 / 订阅工作流;站点验证码识别真出现失败时再接智能体视觉兜底。 +- 补字幕:[subtitles.md](subtitles.md),第 1、2 步已完成;待办:以后接进整理 / 订阅工作流。 - 沙箱第二批:站点网页、RSS、网页抓取的 HTML / XML 解析迁入常驻子进程池(ADR-005)。 ### 业务(需要真实环境或决定) diff --git a/docs/plan/subtitles.md b/docs/plan/subtitles.md index 85539dd9..8a0e6a8f 100644 --- a/docs/plan/subtitles.md +++ b/docs/plan/subtitles.md @@ -26,15 +26,19 @@ ## 在线(SubHD、字幕库) - 搜索词:作品中文名、原名、年份,剧集带季号;结果按标题与季集匹配度排序,对不上的不列。 -- 验证码三层兜底:① 代码识别(SubHD SVG 路径长度表、字幕库 BMP 采样模板,插件里各 80 行左右);② 浏览器服务打开页面(与 PT 站过人机验证同一流程,可人工接管);③ 智能体视觉处理页面挑战。 +- 验证码:① 代码识别(SubHD SVG 路径长度表、字幕库 BMP 采样模板,在沙箱里);② 没过就由智能体视觉在浏览器里接管;③ 智能体也没过就转人工。 + - 何时接管:字幕库请求遇到云锁验证码页,沙箱识别提交 3 次仍是验证码页(或识别不出字符),且自动化总开关开着。SubHD 现在的下载页前端已没有验证码这一步(2026-10-03 抓页确认),只保留旧 SVG 识别,不接管。 + - 怎么接管:用该字幕站的匿名浏览器会话打开同一地址;页面已不是验证码页(例如人刚在原站视图里过过)就直接算过;否则派视觉子流程(系统发起,截图读码、输入、点「点击继续访问网站」,≤ 6 步、1 次,预算取智能体设置),按页面上「网站访问认证页面」字样消失核实。 + - 结果:过了 → 读回浏览器里该站的 Cookie 并进字幕库请求的 Cookie 罐,重开原页面,搜索 / 下载照常继续,用户无感;没过 → 会话转等待处理,待处理事项「字幕库需要你在原站视图里过一次验证码」(附子流程每步截图),本次搜索报错并提示去原站视图;人过完交还后再搜,接管时发现页面已放行,直接带回 Cookie。 +- 原站视图手动下载(与 PT 站「下载当前资源」同一思路):字幕站的原站视图工具栏是「下载当前字幕」。人在原站视图里打开一条字幕详情页(SubHD `/a/`、字幕库 `/detail/.html`)→ 点按钮 → 选作品 → 补字幕对话框自动带着浏览器里的 Cookie 下载这一条、解压、按季集配对到视频,可逐个改写到哪个视频后写入,历史记来源。 - 下载结果走与上传相同的解析 → 预览 → 写入。 ## 分步 1. 手动上传:解析、配对预览、写入、历史与撤销、作品页入口。(v0.3.22 完成) 2. 字幕站类型 + SubHD / 字幕库搜索下载(第 ① 层验证码),站点页与添加站点;rar / 7z。(v0.3.24 完成;解压、页面解析、验证码都在沙箱里,见 ADR-005) -3. 验证码第 ② ③ 层兜底:Cloudflare 挑战已由站点出口自动走浏览器;站点自己的验证码(云锁 / SubHD)识别失败时,目前提示去原站视图手动处理,智能体视觉处理待接。 +3. 验证码智能体视觉接管 + 原站视图「下载当前字幕」。(v0.3.25 完成) 实测记录(2026-10-03):字幕库搜索、验证码、下载、解压、配对全链路通;SubHD 搜索通,下载流程与单独实测一致,但当晚测试请求较多后 SubHD 对本机限流(直连与代理都超时),需要隔天复测。 -复测(2026-10-03 晚):SubHD 按豆瓣 ID 搜索、prepare-download、下载全通,连续下载 7 个未触发验证码。验证码第 ② ③ 层先不做:第 ① 层失败时已提示去原站视图手动下载再上传,等实际出现识别失败再接。 +复测(2026-10-03 晚):SubHD 按豆瓣 ID 搜索、prepare-download、下载全通,连续下载 7 个未触发验证码。 diff --git a/packages/adapters/deno.json b/packages/adapters/deno.json index 560ad8f4..a255cec8 100644 --- a/packages/adapters/deno.json +++ b/packages/adapters/deno.json @@ -1,6 +1,6 @@ { "name": "@umt/adapters", - "version": "0.3.24", + "version": "0.3.25", "exports": { ".": "./src/index.ts", "./fsworker": "./src/fs/fsworker.ts" }, "tasks": { "test": "deno run -A npm:vitest run", diff --git a/packages/agent/deno.json b/packages/agent/deno.json index c68a0d06..b49e39f9 100644 --- a/packages/agent/deno.json +++ b/packages/agent/deno.json @@ -1,6 +1,6 @@ { "name": "@umt/agent", - "version": "0.3.24", + "version": "0.3.25", "exports": { ".": "./src/index.ts" }, "tasks": { "test": "deno run -A npm:vitest run", diff --git a/packages/contracts/deno.json b/packages/contracts/deno.json index e3c02190..5427740e 100644 --- a/packages/contracts/deno.json +++ b/packages/contracts/deno.json @@ -1,6 +1,6 @@ { "name": "@umt/contracts", - "version": "0.3.24", + "version": "0.3.25", "exports": { ".": "./src/index.ts" }, "tasks": { "test": "deno run -A npm:vitest run", diff --git a/packages/contracts/src/subtitles.ts b/packages/contracts/src/subtitles.ts index 64ff0089..2c13e60b 100644 --- a/packages/contracts/src/subtitles.ts +++ b/packages/contracts/src/subtitles.ts @@ -188,10 +188,14 @@ export const SubtitleSearchResponse = z.object({ }); export type SubtitleSearchResponse = z.infer; -export const SubtitleFetchRequest = z.object({ - entityId: Id, - siteId: Id, - ref: z.string().min(1).max(500), - title: z.string().max(500).default(""), -}); +/** 下载一条在线字幕:搜索结果给 siteId + ref;原站视图里人工打开的字幕页给 sessionId(取当前页与浏览器里的 Cookie) */ +export const SubtitleFetchRequest = z + .object({ + entityId: Id, + siteId: Id.optional(), + ref: z.string().min(1).max(500).optional(), + sessionId: Id.optional(), + title: z.string().max(500).default(""), + }) + .refine((v) => !!v.sessionId || (!!v.siteId && !!v.ref), { message: "给 sessionId,或 siteId 加 ref" }); export type SubtitleFetchRequest = z.input; diff --git a/packages/db/deno.json b/packages/db/deno.json index 8e1c15bc..9641c586 100644 --- a/packages/db/deno.json +++ b/packages/db/deno.json @@ -1,6 +1,6 @@ { "name": "@umt/db", - "version": "0.3.24", + "version": "0.3.25", "exports": { ".": "./src/index.ts" }, "tasks": { "test": "deno run -A npm:vitest run", diff --git a/packages/domain/deno.json b/packages/domain/deno.json index c4a9c192..98507f3e 100644 --- a/packages/domain/deno.json +++ b/packages/domain/deno.json @@ -1,6 +1,6 @@ { "name": "@umt/domain", - "version": "0.3.24", + "version": "0.3.25", "exports": { ".": "./src/index.ts" }, "tasks": { "test": "deno run -A npm:vitest run", diff --git a/packages/mapping/deno.json b/packages/mapping/deno.json index 1f259901..d568bf7f 100644 --- a/packages/mapping/deno.json +++ b/packages/mapping/deno.json @@ -1,6 +1,6 @@ { "name": "@umt/mapping", - "version": "0.3.24", + "version": "0.3.25", "exports": { ".": "./src/index.ts" }, "tasks": { "test": "deno run -A npm:vitest run", diff --git a/packages/tasks/deno.json b/packages/tasks/deno.json index a794f1eb..1d28e650 100644 --- a/packages/tasks/deno.json +++ b/packages/tasks/deno.json @@ -1,6 +1,6 @@ { "name": "@umt/tasks", - "version": "0.3.24", + "version": "0.3.25", "exports": { ".": "./src/index.ts" }, "tasks": { "test": "deno run -A npm:vitest run", diff --git a/packages/testkit/deno.json b/packages/testkit/deno.json index 18f23d53..fe6f8776 100644 --- a/packages/testkit/deno.json +++ b/packages/testkit/deno.json @@ -1,6 +1,6 @@ { "name": "@umt/testkit", - "version": "0.3.24", + "version": "0.3.25", "exports": { ".": "./src/index.ts" }, "tasks": { "test": "deno run -A npm:vitest run", diff --git a/packages/testkit/src/browser/mock-camofox.ts b/packages/testkit/src/browser/mock-camofox.ts index 339dbd24..8011a12c 100644 --- a/packages/testkit/src/browser/mock-camofox.ts +++ b/packages/testkit/src/browser/mock-camofox.ts @@ -48,6 +48,8 @@ export interface MockCamofoxSite { cloudflare?: { mode: "auto" | "interactive" | "captcha"; autoPolls?: number; clearance?: string }; /** 页面 HTML(umt-page 读页面内容时返回);缺省按快照行拼一段简单 HTML */ html?: (url: string) => string; + /** 页面 innerText(缺省按页面行拼);返回 null 时用缺省 */ + pageText?: (url: string) => string | null; } /** mock 的 Cookie 接口形态:plugin = umt-cookies 插件(读写);upstream = 只有上游导入端点(要求 CAMOFOX_API_KEY);none = 都没有 */ @@ -819,7 +821,7 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise< url: t.url, title, html, - text: lines.map((l) => l.text).join("\n"), + text: site?.pageText?.(t.url) ?? lines.map((l) => l.text).join("\n"), ua: MOCK_CAMOFOX_IDENTITY.userAgent, }, },