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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@umt/server",
|
"name": "@umt/server",
|
||||||
"version": "0.3.24",
|
"version": "0.3.25",
|
||||||
"exports": { ".": "./src/main.ts" },
|
"exports": { ".": "./src/main.ts" },
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"dev": "deno run -A --watch --env-file=../../.env src/main.ts",
|
"dev": "deno run -A --watch --env-file=../../.env src/main.ts",
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ function route<D extends EndpointDef>(app: ModuleApp, def: D, handler: (input: I
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 人工操作者:控制台会话 / API Key 的身份(租约 holder) */
|
/** 人工操作者:控制台会话 / API Key 的身份(租约 holder) */
|
||||||
function human(c: Context<AuthEnv>): Actor {
|
export function human(c: Context<AuthEnv>): Actor {
|
||||||
const p = c.get("principal");
|
const p = c.get("principal");
|
||||||
return { kind: "human", holder: `${p?.kind ?? "unknown"}:${p?.name ?? p?.id ?? "?"}` };
|
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>();
|
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 {
|
export function registerChallengeVision(ctx: object, fn: ChallengeVision): void {
|
||||||
visions.set(ctx, fn);
|
visions.set(ctx, fn);
|
||||||
}
|
}
|
||||||
@@ -2012,7 +2018,7 @@ export class BrowserService {
|
|||||||
*/
|
*/
|
||||||
private async cfSession(
|
private async cfSession(
|
||||||
site: SiteRow,
|
site: SiteRow,
|
||||||
accountId: string,
|
accountId: string | null,
|
||||||
url: string,
|
url: string,
|
||||||
): Promise<{ id: string } | { busy: BrowserSessionRow }> {
|
): Promise<{ id: string } | { busy: BrowserSessionRow }> {
|
||||||
const live = await this.liveSessionFor(site.id, accountId);
|
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,通知站点请求继续 */
|
/** 人过完验证交还后:站点在等人过验证、会话是那一个 → 读回 cf_clearance / UA,通知站点请求继续 */
|
||||||
private async afterHumanChallenge(id: string): Promise<void> {
|
private async afterHumanChallenge(id: string): Promise<void> {
|
||||||
const row = await this.sessionRow(id);
|
const row = await this.sessionRow(id);
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { Api, type SubtitleApplyRequest, type SubtitleFetchRequest, type SubtitleSource } from "@umt/contracts";
|
import { Api, type SubtitleApplyRequest, type SubtitleFetchRequest, type SubtitleSource } from "@umt/contracts";
|
||||||
import { ApiException } from "../../http/errors.ts";
|
import { ApiException } from "../../http/errors.ts";
|
||||||
import { route } from "../agent/http.ts";
|
import { route } from "../agent/http.ts";
|
||||||
|
import { human } from "../browser/routes.ts";
|
||||||
import { sourcesIdentityModule } from "../sources-identity/index.ts";
|
import { sourcesIdentityModule } from "../sources-identity/index.ts";
|
||||||
import type { ServerModule } from "../types.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";
|
import { applySubtitles, parseSubtitles, subtitleTargets, subtitleWrites, undoSubtitleWrite } from "./service.ts";
|
||||||
|
|
||||||
/** 补字幕(docs/plan/subtitles.md):上传 / 在线下载的字幕解析、配对、写入、历史与撤销 */
|
/** 补字幕(docs/plan/subtitles.md):上传 / 在线下载的字幕解析、配对、写入、历史与撤销 */
|
||||||
@@ -32,10 +39,12 @@ export function createSubtitlesModule(): ServerModule {
|
|||||||
route(
|
route(
|
||||||
app,
|
app,
|
||||||
Api.subtitles.fetch,
|
Api.subtitles.fetch,
|
||||||
({ body }) =>
|
({ body, c }) =>
|
||||||
siteErr(async () => {
|
siteErr(async () => {
|
||||||
const b = body as SubtitleFetchRequest;
|
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);
|
const parsed = await parseSubtitles(ctx, [{ name: f.name, base64: f.base64 }], b.entityId);
|
||||||
// 在线来源:每项带上字幕站与条目地址,写入时记进历史
|
// 在线来源:每项带上字幕站与条目地址,写入时记进历史
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import type { SiteRow } from "@umt/db";
|
|||||||
import { subtitleEpisode } from "@umt/domain";
|
import { subtitleEpisode } from "@umt/domain";
|
||||||
import { runSandbox } from "../../../sandbox/client.ts";
|
import { runSandbox } from "../../../sandbox/client.ts";
|
||||||
import { b64encode } from "../../../sandbox/jobs.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 { SourcesRuntime } from "../../sources-identity/runtime.ts";
|
||||||
import type { ServerContext } from "../../types.ts";
|
import type { ServerContext } from "../../types.ts";
|
||||||
import type { PageEntry, PageKind, PageResult } from "./parse.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>>();
|
const jars = new Map<string, Map<string, string>>();
|
||||||
|
|
||||||
interface Env {
|
interface Env {
|
||||||
|
ctx: ServerContext;
|
||||||
rt: SourcesRuntime;
|
rt: SourcesRuntime;
|
||||||
site: SiteRow;
|
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> {
|
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 res = await request(env, url, referer ? { referer } : {});
|
||||||
const html = await res.text();
|
const html = await res.text();
|
||||||
const cap = await page("zimuku.captcha", html);
|
const bmp = (await page("zimuku.captcha", html)).bmpBase64;
|
||||||
if (!cap.bmpBase64) {
|
if (bmp) return { bmp };
|
||||||
if (!res.ok) throw new SubtitleSiteError(`${env.site.name} 返回 HTTP ${res.status}`);
|
if (!res.ok) throw new SubtitleSiteError(`${env.site.name} 返回 HTTP ${res.status}`);
|
||||||
return html;
|
return { html };
|
||||||
}
|
};
|
||||||
const { code } = await runSandbox("captcha.solve", { kind: "zimuku", bmpBase64: cap.bmpBase64 });
|
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;
|
if (!code) break;
|
||||||
const hex = [...code].map((c) => c.charCodeAt(0).toString(16)).join("");
|
const hex = [...code].map((c) => c.charCodeAt(0).toString(16)).join("");
|
||||||
await (await request(env, `${url}${url.includes("?") ? "&" : "?"}security_verify_img=${hex}`)).body?.cancel();
|
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[]> {
|
async function zimukuSearch(env: Env, q: SearchQuery): Promise<PageEntry[]> {
|
||||||
@@ -326,7 +360,7 @@ export async function searchOnline(
|
|||||||
const errors: SubtitleSearchResponse["errors"] = [];
|
const errors: SubtitleSearchResponse["errors"] = [];
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
sites.map(async (site) => {
|
sites.map(async (site) => {
|
||||||
const env = { rt, site };
|
const env = { ctx, rt, site };
|
||||||
try {
|
try {
|
||||||
const entries = await PROVIDERS[site.key]!.search(env, q);
|
const entries = await PROVIDERS[site.key]!.search(env, q);
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -350,10 +384,40 @@ export async function downloadOnline(
|
|||||||
rt: SourcesRuntime,
|
rt: SourcesRuntime,
|
||||||
siteId: string,
|
siteId: string,
|
||||||
ref: string,
|
ref: string,
|
||||||
|
cookies: { name: string; value: string }[] = [],
|
||||||
): Promise<{ name: string; base64: string; siteKey: string; pageUrl: string }> {
|
): Promise<{ name: string; base64: string; siteKey: string; pageUrl: string }> {
|
||||||
const site = (await subtitleSites(ctx)).find((s) => s.id === siteId);
|
const site = (await subtitleSites(ctx)).find((s) => s.id === siteId);
|
||||||
if (!site || !PROVIDERS[site.key]) throw new SubtitleSiteError("字幕站不存在或已停用");
|
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);
|
const f = await PROVIDERS[site.key]!.download(env, ref);
|
||||||
return { name: f.name, base64: b64encode(f.bytes), siteKey: site.key, pageUrl: abs(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);
|
||||||
|
}
|
||||||
@@ -69,6 +69,7 @@ export async function createBrowserHarness(
|
|||||||
/** CF-2:mock 站点的 Cloudflare 挑战与页面 HTML */
|
/** CF-2:mock 站点的 Cloudflare 挑战与页面 HTML */
|
||||||
cloudflare?: MockCamofoxSite["cloudflare"];
|
cloudflare?: MockCamofoxSite["cloudflare"];
|
||||||
html?: MockCamofoxSite["html"];
|
html?: MockCamofoxSite["html"];
|
||||||
|
pageText?: MockCamofoxSite["pageText"];
|
||||||
/** 浏览器模块的额外选项(挑战轮询间隔等) */
|
/** 浏览器模块的额外选项(挑战轮询间隔等) */
|
||||||
browserOptions?: Partial<BrowserModuleOptions>;
|
browserOptions?: Partial<BrowserModuleOptions>;
|
||||||
} = {},
|
} = {},
|
||||||
@@ -82,6 +83,7 @@ export async function createBrowserHarness(
|
|||||||
...(opts.sessionCookie ? { sessionCookie: opts.sessionCookie } : {}),
|
...(opts.sessionCookie ? { sessionCookie: opts.sessionCookie } : {}),
|
||||||
...(opts.cloudflare ? { cloudflare: opts.cloudflare } : {}),
|
...(opts.cloudflare ? { cloudflare: opts.cloudflare } : {}),
|
||||||
...(opts.html ? { html: opts.html } : {}),
|
...(opts.html ? { html: opts.html } : {}),
|
||||||
|
...(opts.pageText ? { pageText: opts.pageText } : {}),
|
||||||
origin: ORIGIN,
|
origin: ORIGIN,
|
||||||
username: "fixture-user",
|
username: "fixture-user",
|
||||||
password: PASSWORD,
|
password: PASSWORD,
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "~/api/browser.ts";
|
} from "~/api/browser.ts";
|
||||||
import { LoadingState } from "~/components/console/empty-state.tsx";
|
import { LoadingState } from "~/components/console/empty-state.tsx";
|
||||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.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 { Button } from "~/components/ui/button.tsx";
|
||||||
import { apiData, errorMessage } from "~/lib/api.ts";
|
import { apiData, errorMessage } from "~/lib/api.ts";
|
||||||
import { qk } from "~/lib/query-keys.ts";
|
import { qk } from "~/lib/query-keys.ts";
|
||||||
@@ -209,7 +210,7 @@ function DesktopView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<ResourceDownload session={s} />
|
{site.kind === "subtitle" ? <SubtitleFromPage session={s} /> : <ResourceDownload session={s} />}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<StatusBadge tone={conn.tone}>{conn.text}</StatusBadge>
|
<StatusBadge tone={conn.tone}>{conn.text}</StatusBadge>
|
||||||
<StatusBadge tone={CONTROLLER_TONE[s.controller] ?? "neutral"}>
|
<StatusBadge tone={CONTROLLER_TONE[s.controller] ?? "neutral"}>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
} from "~/api/browser.ts";
|
} from "~/api/browser.ts";
|
||||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.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 { Button } from "~/components/ui/button.tsx";
|
||||||
import { Input } from "~/components/ui/input.tsx";
|
import { Input } from "~/components/ui/input.tsx";
|
||||||
import { errorMessage } from "~/lib/api.ts";
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
@@ -154,7 +155,7 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
|
|||||||
<p className="text-caption text-muted-foreground">验证码等需本人操作在此完成。</p>
|
<p className="text-caption text-muted-foreground">验证码等需本人操作在此完成。</p>
|
||||||
<ReloadNotice session={s} />
|
<ReloadNotice session={s} />
|
||||||
|
|
||||||
<ResourceDownload session={s} />
|
{site.kind === "subtitle" ? <SubtitleFromPage session={s} /> : <ResourceDownload session={s} />}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<StatusBadge tone={conn.tone}>{conn.text}</StatusBadge>
|
<StatusBadge tone={conn.tone}>{conn.text}</StatusBadge>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
SubtitleWriteView,
|
SubtitleWriteView,
|
||||||
} from "@umt/contracts";
|
} from "@umt/contracts";
|
||||||
import { Download, ExternalLink, FileUp, LoaderCircle, Search, Undo2 } from "lucide-react";
|
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 { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
useApplySubtitles,
|
useApplySubtitles,
|
||||||
@@ -68,11 +68,14 @@ export function SubtitleDialog({
|
|||||||
title,
|
title,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
fromSession,
|
||||||
}: {
|
}: {
|
||||||
entityId: string;
|
entityId: string;
|
||||||
title: string;
|
title: string;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (o: boolean) => void;
|
onOpenChange: (o: boolean) => void;
|
||||||
|
/** 原站视图会话:打开时自动下载它当前页的字幕并配对 */
|
||||||
|
fromSession?: string;
|
||||||
}) {
|
}) {
|
||||||
const inputId = useId();
|
const inputId = useId();
|
||||||
const input = useRef<HTMLInputElement>(null);
|
const input = useRef<HTMLInputElement>(null);
|
||||||
@@ -86,7 +89,20 @@ export function SubtitleDialog({
|
|||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const [tab, setTab] = useState<"upload" | "online">("upload");
|
const [tab, setTab] = useState<"upload" | "online">("upload");
|
||||||
const list = targets.data?.targets ?? [];
|
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<string | null>(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[]) => {
|
const load = async (files: File[]) => {
|
||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
@@ -207,6 +223,12 @@ export function SubtitleDialog({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{fetchPage.isPending ? (
|
||||||
|
<p className="flex items-center gap-2 text-caption text-muted-foreground">
|
||||||
|
<LoaderCircle className="size-4 animate-spin" />
|
||||||
|
正在下载原站视图当前页的字幕…
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{rows.length ? (
|
{rows.length ? (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{rows.map((r) => (
|
{rows.map((r) => (
|
||||||
|
|||||||
@@ -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<PickedEntity | null>(null);
|
||||||
|
const [writing, setWriting] = useState<PickedEntity | null>(null);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button size="sm" disabled={session.controller === "closed"} onClick={() => setPicking(true)}>
|
||||||
|
<Download />
|
||||||
|
下载当前字幕
|
||||||
|
</Button>
|
||||||
|
<Dialog open={picking} onOpenChange={setPicking}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>下载当前字幕</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
先在原站视图里打开一条字幕的详情页。选这条字幕属于哪部作品,下一步再选写到哪个视频。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<EntityPicker value={entity} onChange={setEntity} />
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
disabled={!entity}
|
||||||
|
onClick={() => {
|
||||||
|
setWriting(entity);
|
||||||
|
setPicking(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
下载并配对
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
{writing ? (
|
||||||
|
<SubtitleDialog
|
||||||
|
key={writing.id}
|
||||||
|
entityId={writing.id}
|
||||||
|
title={writing.title}
|
||||||
|
fromSession={session.id}
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => !o && setWriting(null)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@umt/web",
|
"name": "@umt/web",
|
||||||
"version": "0.3.24",
|
"version": "0.3.25",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+1
-1
@@ -31,7 +31,7 @@
|
|||||||
|
|
||||||
### 进行中
|
### 进行中
|
||||||
|
|
||||||
- 补字幕:[subtitles.md](subtitles.md),第 1、2 步已完成;待办:以后接进整理 / 订阅工作流;站点验证码识别真出现失败时再接智能体视觉兜底。
|
- 补字幕:[subtitles.md](subtitles.md),第 1、2 步已完成;待办:以后接进整理 / 订阅工作流。
|
||||||
- 沙箱第二批:站点网页、RSS、网页抓取的 HTML / XML 解析迁入常驻子进程池(ADR-005)。
|
- 沙箱第二批:站点网页、RSS、网页抓取的 HTML / XML 解析迁入常驻子进程池(ADR-005)。
|
||||||
|
|
||||||
### 业务(需要真实环境或决定)
|
### 业务(需要真实环境或决定)
|
||||||
|
|||||||
@@ -26,15 +26,19 @@
|
|||||||
## 在线(SubHD、字幕库)
|
## 在线(SubHD、字幕库)
|
||||||
|
|
||||||
- 搜索词:作品中文名、原名、年份,剧集带季号;结果按标题与季集匹配度排序,对不上的不列。
|
- 搜索词:作品中文名、原名、年份,剧集带季号;结果按标题与季集匹配度排序,对不上的不列。
|
||||||
- 验证码三层兜底:① 代码识别(SubHD SVG 路径长度表、字幕库 BMP 采样模板,插件里各 80 行左右);② 浏览器服务打开页面(与 PT 站过人机验证同一流程,可人工接管);③ 智能体视觉处理页面挑战。
|
- 验证码:① 代码识别(SubHD SVG 路径长度表、字幕库 BMP 采样模板,在沙箱里);② 没过就由智能体视觉在浏览器里接管;③ 智能体也没过就转人工。
|
||||||
|
- 何时接管:字幕库请求遇到云锁验证码页,沙箱识别提交 3 次仍是验证码页(或识别不出字符),且自动化总开关开着。SubHD 现在的下载页前端已没有验证码这一步(2026-10-03 抓页确认),只保留旧 SVG 识别,不接管。
|
||||||
|
- 怎么接管:用该字幕站的匿名浏览器会话打开同一地址;页面已不是验证码页(例如人刚在原站视图里过过)就直接算过;否则派视觉子流程(系统发起,截图读码、输入、点「点击继续访问网站」,≤ 6 步、1 次,预算取智能体设置),按页面上「网站访问认证页面」字样消失核实。
|
||||||
|
- 结果:过了 → 读回浏览器里该站的 Cookie 并进字幕库请求的 Cookie 罐,重开原页面,搜索 / 下载照常继续,用户无感;没过 → 会话转等待处理,待处理事项「字幕库需要你在原站视图里过一次验证码」(附子流程每步截图),本次搜索报错并提示去原站视图;人过完交还后再搜,接管时发现页面已放行,直接带回 Cookie。
|
||||||
|
- 原站视图手动下载(与 PT 站「下载当前资源」同一思路):字幕站的原站视图工具栏是「下载当前字幕」。人在原站视图里打开一条字幕详情页(SubHD `/a/<sid>`、字幕库 `/detail/<id>.html`)→ 点按钮 → 选作品 → 补字幕对话框自动带着浏览器里的 Cookie 下载这一条、解压、按季集配对到视频,可逐个改写到哪个视频后写入,历史记来源。
|
||||||
- 下载结果走与上传相同的解析 → 预览 → 写入。
|
- 下载结果走与上传相同的解析 → 预览 → 写入。
|
||||||
|
|
||||||
## 分步
|
## 分步
|
||||||
|
|
||||||
1. 手动上传:解析、配对预览、写入、历史与撤销、作品页入口。(v0.3.22 完成)
|
1. 手动上传:解析、配对预览、写入、历史与撤销、作品页入口。(v0.3.22 完成)
|
||||||
2. 字幕站类型 + SubHD / 字幕库搜索下载(第 ① 层验证码),站点页与添加站点;rar / 7z。(v0.3.24 完成;解压、页面解析、验证码都在沙箱里,见 ADR-005)
|
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 搜索通,下载流程与单独实测一致,但当晚测试请求较多后 SubHD 对本机限流(直连与代理都超时),需要隔天复测。
|
||||||
|
|
||||||
复测(2026-10-03 晚):SubHD 按豆瓣 ID 搜索、prepare-download、下载全通,连续下载 7 个未触发验证码。验证码第 ② ③ 层先不做:第 ① 层失败时已提示去原站视图手动下载再上传,等实际出现识别失败再接。
|
复测(2026-10-03 晚):SubHD 按豆瓣 ID 搜索、prepare-download、下载全通,连续下载 7 个未触发验证码。
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@umt/adapters",
|
"name": "@umt/adapters",
|
||||||
"version": "0.3.24",
|
"version": "0.3.25",
|
||||||
"exports": { ".": "./src/index.ts", "./fsworker": "./src/fs/fsworker.ts" },
|
"exports": { ".": "./src/index.ts", "./fsworker": "./src/fs/fsworker.ts" },
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"test": "deno run -A npm:vitest run",
|
"test": "deno run -A npm:vitest run",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@umt/agent",
|
"name": "@umt/agent",
|
||||||
"version": "0.3.24",
|
"version": "0.3.25",
|
||||||
"exports": { ".": "./src/index.ts" },
|
"exports": { ".": "./src/index.ts" },
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"test": "deno run -A npm:vitest run",
|
"test": "deno run -A npm:vitest run",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@umt/contracts",
|
"name": "@umt/contracts",
|
||||||
"version": "0.3.24",
|
"version": "0.3.25",
|
||||||
"exports": { ".": "./src/index.ts" },
|
"exports": { ".": "./src/index.ts" },
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"test": "deno run -A npm:vitest run",
|
"test": "deno run -A npm:vitest run",
|
||||||
|
|||||||
@@ -188,10 +188,14 @@ export const SubtitleSearchResponse = z.object({
|
|||||||
});
|
});
|
||||||
export type SubtitleSearchResponse = z.infer<typeof SubtitleSearchResponse>;
|
export type SubtitleSearchResponse = z.infer<typeof SubtitleSearchResponse>;
|
||||||
|
|
||||||
export const SubtitleFetchRequest = z.object({
|
/** 下载一条在线字幕:搜索结果给 siteId + ref;原站视图里人工打开的字幕页给 sessionId(取当前页与浏览器里的 Cookie) */
|
||||||
entityId: Id,
|
export const SubtitleFetchRequest = z
|
||||||
siteId: Id,
|
.object({
|
||||||
ref: z.string().min(1).max(500),
|
entityId: Id,
|
||||||
title: z.string().max(500).default(""),
|
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<typeof SubtitleFetchRequest>;
|
export type SubtitleFetchRequest = z.input<typeof SubtitleFetchRequest>;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@umt/db",
|
"name": "@umt/db",
|
||||||
"version": "0.3.24",
|
"version": "0.3.25",
|
||||||
"exports": { ".": "./src/index.ts" },
|
"exports": { ".": "./src/index.ts" },
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"test": "deno run -A npm:vitest run",
|
"test": "deno run -A npm:vitest run",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@umt/domain",
|
"name": "@umt/domain",
|
||||||
"version": "0.3.24",
|
"version": "0.3.25",
|
||||||
"exports": { ".": "./src/index.ts" },
|
"exports": { ".": "./src/index.ts" },
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"test": "deno run -A npm:vitest run",
|
"test": "deno run -A npm:vitest run",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@umt/mapping",
|
"name": "@umt/mapping",
|
||||||
"version": "0.3.24",
|
"version": "0.3.25",
|
||||||
"exports": { ".": "./src/index.ts" },
|
"exports": { ".": "./src/index.ts" },
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"test": "deno run -A npm:vitest run",
|
"test": "deno run -A npm:vitest run",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@umt/tasks",
|
"name": "@umt/tasks",
|
||||||
"version": "0.3.24",
|
"version": "0.3.25",
|
||||||
"exports": { ".": "./src/index.ts" },
|
"exports": { ".": "./src/index.ts" },
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"test": "deno run -A npm:vitest run",
|
"test": "deno run -A npm:vitest run",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@umt/testkit",
|
"name": "@umt/testkit",
|
||||||
"version": "0.3.24",
|
"version": "0.3.25",
|
||||||
"exports": { ".": "./src/index.ts" },
|
"exports": { ".": "./src/index.ts" },
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"test": "deno run -A npm:vitest run",
|
"test": "deno run -A npm:vitest run",
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export interface MockCamofoxSite {
|
|||||||
cloudflare?: { mode: "auto" | "interactive" | "captcha"; autoPolls?: number; clearance?: string };
|
cloudflare?: { mode: "auto" | "interactive" | "captcha"; autoPolls?: number; clearance?: string };
|
||||||
/** 页面 HTML(umt-page 读页面内容时返回);缺省按快照行拼一段简单 HTML */
|
/** 页面 HTML(umt-page 读页面内容时返回);缺省按快照行拼一段简单 HTML */
|
||||||
html?: (url: string) => string;
|
html?: (url: string) => string;
|
||||||
|
/** 页面 innerText(缺省按页面行拼);返回 null 时用缺省 */
|
||||||
|
pageText?: (url: string) => string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** mock 的 Cookie 接口形态:plugin = umt-cookies 插件(读写);upstream = 只有上游导入端点(要求 CAMOFOX_API_KEY);none = 都没有 */
|
/** mock 的 Cookie 接口形态:plugin = umt-cookies 插件(读写);upstream = 只有上游导入端点(要求 CAMOFOX_API_KEY);none = 都没有 */
|
||||||
@@ -819,7 +821,7 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
|||||||
url: t.url,
|
url: t.url,
|
||||||
title,
|
title,
|
||||||
html,
|
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,
|
ua: MOCK_CAMOFOX_IDENTITY.userAgent,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user