feat(subtitles): 字幕站(SubHD、字幕库)在线搜索与下载;rar / 7z 解压;验证码与页面解析在沙箱里

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-03 02:28:29 +08:00
1 parent 92d456d123
commit 59b3680f35
32 files changed
+1704 -166

No files matched your search

+4
View File
@@ -4,6 +4,10 @@
## [Unreleased]
### 新增
- 补字幕第 2 步:字幕站(站点类型「字幕站」,内置 SubHD、字幕库,站点页一键添加,复用站点的代理 / UA / Cloudflare 处理与健康检测)。作品页「补字幕 → 在线搜索」:按作品名、原名、别名搜(SubHD 有豆瓣 ID 时直接开条目页),结果按季集过滤、双语 / 简体优先、按下载量排序;点下载后进同一个预览,确认后写入,历史里记来源。字幕库的云锁数字验证码、SubHD 的 SVG 验证码在沙箱里自动识别。迁移 `0065_subtitle_sites`。
- 字幕压缩包支持 rar 与 7z(沙箱里用 WASM 解压)。
## [0.3.23] - 2026-10-03
### 调整(安全)
@@ -25,6 +25,7 @@ import {
SiteUserDataPoint,
SiteUserDataQuery,
type SourceRelease,
SUBTITLE_SITES,
} from "@umt/contracts";
import { type FeedSourceRow, sql } from "@umt/db";
import { z } from "zod";
@@ -92,19 +93,35 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
const rows = await db().selectFrom("site").select(["id", "key"]).where("deleted_at", "is", null).execute();
const byKey = new Map(rows.map((r) => [r.key, r.id]));
return {
items: listSiteDefinitions().map((d) => ({
key: d.id,
name: d.umt.kind === "nexusphp" ? (d.umt.displayName ?? d.name) : d.umt.displayName,
kind: definitionKind(d),
adapter: adapterOf(d),
baseUrl: d.domain,
shippedEnabled: d.enabled !== false,
definitionVersion: d.umt.version,
capabilities: d.umt.capabilities,
defaultRateLimit: d.umt.rateLimit,
authSupported: ("authSupported" in d.umt ? d.umt.authSupported : undefined) ?? [],
siteId: byKey.get(d.id) ?? null,
})),
items: [
...listSiteDefinitions().map((d) => ({
key: d.id,
name: d.umt.kind === "nexusphp" ? (d.umt.displayName ?? d.name) : d.umt.displayName,
kind: definitionKind(d),
adapter: adapterOf(d),
baseUrl: d.domain,
shippedEnabled: d.enabled !== false,
definitionVersion: d.umt.version,
capabilities: d.umt.capabilities,
defaultRateLimit: d.umt.rateLimit,
authSupported: ("authSupported" in d.umt ? d.umt.authSupported : undefined) ?? [],
siteId: byKey.get(d.id) ?? null,
})),
// 内置字幕站:不是资源适配器,只用于补字幕(subtitles 模块)
...SUBTITLE_SITES.map((d) => ({
key: d.key,
name: d.name,
kind: "subtitle" as const,
adapter: "subtitle" as const,
baseUrl: d.baseUrl,
shippedEnabled: true,
definitionVersion: "builtin",
capabilities: { list: false, search: false, detail: false, download: true, rss: false },
defaultRateLimit: { minIntervalMs: 2000, burst: 2 },
authSupported: [],
siteId: byKey.get(d.key) ?? null,
})),
],
};
},
);
@@ -159,6 +176,9 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
throw new ApiException("bad_request", `注册表中没有站点 ${body.key};未适配站点只能以 generic_rss 登记`);
}
if (def && definitionKind(def) !== "pt") throw new ApiException("bad_request", `${body.key} 不是 PT 站`);
} else if (kind === "subtitle") {
if (!SUBTITLE_SITES.some((d) => d.key === body.key) || body.adapter !== "subtitle")
throw new ApiException("bad_request", "字幕站要从内置字幕站里选(SubHD、字幕库)");
} else if (publicDef) {
if (body.adapter !== adapterOf(publicDef))
throw new ApiException("bad_request", `站点 ${body.key} 的适配器类型应为 ${adapterOf(publicDef)}`);
@@ -810,7 +810,20 @@ export async function checkSiteHealth(
let message: string | null = null;
let detail: SiteHealthDetail | null = null;
const def = getSiteDefinition(site.key);
if (!def) {
if (site.kind === "subtitle") {
// 字幕站:用站点出口(代理 / UA / Cloudflare 处理)打开首页,2xx 即可用
const t0 = Date.now();
try {
const res = await rt.siteFetch(site)(site.base_url, { signal: AbortSignal.timeout(20_000) });
await res.body?.cancel();
ok = res.ok;
status = res.ok ? "ok" : "unreachable";
latencyMs = Date.now() - t0;
message = res.ok ? null : `首页返回 HTTP ${res.status}`;
} catch (e) {
message = (e as Error).message.slice(0, 300);
}
} else if (!def) {
message = "站点没有适配器定义,无法检测";
status = "unsupported";
} else {
+35 -1
View File
@@ -1,6 +1,9 @@
import { Api, type SubtitleApplyRequest } from "@umt/contracts";
import { Api, type SubtitleApplyRequest, type SubtitleFetchRequest, type SubtitleSource } from "@umt/contracts";
import { ApiException } from "../../http/errors.ts";
import { route } from "../agent/http.ts";
import { sourcesIdentityModule } from "../sources-identity/index.ts";
import type { ServerModule } from "../types.ts";
import { downloadOnline, SubtitleSiteError, searchOnline, subtitleSearchQuery } from "./online/index.ts";
import { applySubtitles, parseSubtitles, subtitleTargets, subtitleWrites, undoSubtitleWrite } from "./service.ts";
/** 补字幕(docs/plan/subtitles.md):上传 / 在线下载的字幕解析、配对、写入、历史与撤销 */
@@ -9,8 +12,39 @@ export function createSubtitlesModule(): ServerModule {
name: "subtitles",
registerRoutes(app, ctx) {
const tag = { tag: "subtitles" };
const rt = () => sourcesIdentityModule.runtime(ctx);
const siteErr = async <T>(fn: () => Promise<T>): Promise<T> => {
try {
return await fn();
} catch (e) {
if (e instanceof SubtitleSiteError) throw new ApiException("upstream_unavailable", e.message);
throw e;
}
};
route(app, Api.subtitles.targets, ({ query }) => subtitleTargets(ctx, query.entityId), tag);
route(app, Api.subtitles.parse, ({ body }) => parseSubtitles(ctx, body.files, body.entityId), tag);
route(
app,
Api.subtitles.search,
({ body }) => siteErr(async () => searchOnline(ctx, rt(), await subtitleSearchQuery(ctx, body.entityId, body))),
tag,
);
route(
app,
Api.subtitles.fetch,
({ body }) =>
siteErr(async () => {
const b = body as SubtitleFetchRequest;
const f = await downloadOnline(ctx, rt(), b.siteId, b.ref);
const parsed = await parseSubtitles(ctx, [{ name: f.name, base64: f.base64 }], b.entityId);
// 在线来源:每项带上字幕站与条目地址,写入时记进历史
return {
...parsed,
items: parsed.items.map((it) => ({ ...it, source: f.siteKey as SubtitleSource, sourceRef: f.pageUrl })),
};
}),
tag,
);
route(
app,
Api.subtitles.apply,
@@ -0,0 +1,359 @@
// 在线字幕(docs/plan/subtitles.md 第 2 步):在已启用的字幕站(site.kind = subtitle)搜索、下载。
// 请求走站点出口 rt.siteFetch(站点代理、UA、Cloudflare 挑战走浏览器);页面解析与验证码识别在沙箱里(ADR-005)。
// 流程对照 MP 插件 online_subtitles/providers/{subhd,zimuku}.py,页面结构 2026-10-03 实测。
import type { SubtitleSearchResponse, SubtitleSearchResult } from "@umt/contracts";
import type { SiteRow } from "@umt/db";
import { subtitleEpisode } from "@umt/domain";
import { runSandbox } from "../../../sandbox/client.ts";
import { b64encode } from "../../../sandbox/jobs.ts";
import type { SourcesRuntime } from "../../sources-identity/runtime.ts";
import type { ServerContext } from "../../types.ts";
import type { PageEntry, PageKind, PageResult } from "./parse.ts";
const MAX_DOWNLOAD_BYTES = 30 * 1024 * 1024;
/** 站内操作失败(给用户看的原因) */
export class SubtitleSiteError extends Error {
override name = "SubtitleSiteError";
}
// ── 请求:站点出口 + 每站一个 Cookie 罐(字幕库的验证 Cookie、SubHD 的会话) ──
const jars = new Map<string, Map<string, string>>();
interface Env {
rt: SourcesRuntime;
site: SiteRow;
}
async function request(
env: Env,
url: string,
init: { referer?: string; json?: unknown; signal?: AbortSignal } = {},
): Promise<Response> {
const jar = jars.get(env.site.id) ?? new Map<string, string>();
jars.set(env.site.id, jar);
const headers: Record<string, string> = { Accept: "text/html,application/json;q=0.9,*/*;q=0.8" };
if (jar.size) headers.cookie = [...jar].map(([k, v]) => `${k}=${v}`).join("; ");
if (init.referer) headers.referer = init.referer;
if (init.json !== undefined) headers["content-type"] = "application/json";
let res: Response;
try {
res = await env.rt.siteFetch(env.site)(url, {
method: init.json === undefined ? "GET" : "POST",
headers,
...(init.json !== undefined ? { body: JSON.stringify(init.json) } : {}),
signal: init.signal ?? AbortSignal.timeout(30_000),
});
} catch (e) {
const n = (e as Error).name;
if (n === "TimeoutError" || n === "AbortError")
throw new SubtitleSiteError(`连接${env.site.name}超时,稍后再试(站点可能在限流或网络不通)`);
throw new SubtitleSiteError(`连接${env.site.name}失败:${(e as Error).message.slice(0, 200)}`);
}
for (const c of res.headers.getSetCookie()) {
const kv = c.split(";")[0] ?? "";
const i = kv.indexOf("=");
if (i > 0) jar.set(kv.slice(0, i).trim(), kv.slice(i + 1).trim());
}
return res;
}
async function getText(env: Env, url: string, referer?: string): Promise<string> {
const res = await request(env, url, referer ? { referer } : {});
if (!res.ok) throw new SubtitleSiteError(`${env.site.name} 返回 HTTP ${res.status}`);
return await res.text();
}
const page = <K extends PageKind>(kind: K, html: string) =>
runSandbox("subtitle.page", { kind, html }, { timeoutMs: 15_000 }) as Promise<PageResult<K>>;
const abs = (env: Env, path: string) => new URL(path, env.site.base_url).href;
/** content-disposition 里的文件名:常见是 UTF-8 字节被当成 latin1 发出,按字节还原 */
function fileNameOf(res: Response, url: string): string {
const cd = res.headers.get("content-disposition") ?? "";
const star = /filename\*=UTF-8''([^;]+)/i.exec(cd)?.[1];
if (star) return decodeURIComponent(star);
const plain = /filename="?([^";]+)"?/i.exec(cd)?.[1];
if (plain) {
const bytes = Uint8Array.from(plain, (c) => c.charCodeAt(0) & 0xff);
const utf8 = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
return utf8.includes("�") ? plain : utf8;
}
return decodeURIComponent(new URL(url).pathname.split("/").pop() || "subtitle.zip");
}
async function getFile(env: Env, url: string, referer: string): Promise<{ name: string; bytes: Uint8Array }> {
const res = await request(env, url, { referer });
if (!res.ok) throw new SubtitleSiteError(`下载返回 HTTP ${res.status}`);
const len = Number(res.headers.get("content-length") ?? 0);
if (len > MAX_DOWNLOAD_BYTES) throw new SubtitleSiteError("字幕包超过 30 MB");
const bytes = new Uint8Array(await res.arrayBuffer());
if (bytes.length > MAX_DOWNLOAD_BYTES) throw new SubtitleSiteError("字幕包超过 30 MB");
const head = new TextDecoder().decode(bytes.subarray(0, 200)).toLowerCase();
if (/<!doctype html|<html/.test(head)) throw new SubtitleSiteError("下载地址返回的是网页,不是字幕文件");
return { name: fileNameOf(res, res.url || url), bytes };
}
// ── 搜索词与匹配 ──
export interface SearchQuery {
keywords: string[];
doubanId: string | null;
season: number | null;
episode: number | null;
}
const norm = (s: string) => s.toLowerCase().replace(/[\s\p{P}\p{S}]/gu, "");
/** 标题里含任一搜索词(去掉空白与标点比较) */
function relevant(title: string, keywords: string[]): boolean {
const t = norm(title);
return keywords.some((k) => {
const n = norm(k);
return n.length >= 2 && t.includes(n);
});
}
/** 季集不符的丢掉;合集 / 季包保留 */
function episodeOk(title: string, q: SearchQuery): { ok: boolean; season: number | null; episode: number | null } {
const ep = subtitleEpisode(`${title}.srt`);
if (q.season !== null && !ep.seasonInferred && ep.season !== null && ep.season !== q.season)
return { ok: false, season: ep.season, episode: ep.episode };
if (q.episode !== null && ep.episode !== null && ep.episode !== q.episode)
return { ok: false, season: ep.season, episode: ep.episode };
return { ok: true, season: ep.seasonInferred ? null : ep.season, episode: ep.episode };
}
const LANG_SCORE: Record<string, number> = { 双语: 3, 简体: 2, 繁体: 1 };
function toResult(env: Env, e: PageEntry, q: SearchQuery): SubtitleSearchResult | null {
const ep = episodeOk(e.title, q);
if (!ep.ok) return null;
const lang = Math.max(0, ...e.languages.map((l) => LANG_SCORE[l] ?? 0));
const exact = q.episode !== null && ep.episode === q.episode ? 2 : 0;
return {
siteId: env.site.id,
siteKey: env.site.key,
siteName: env.site.name,
ref: e.ref,
title: e.title,
pageUrl: abs(env, e.ref),
languages: e.languages,
format: e.format,
downloads: e.downloads,
uploader: e.uploader,
publishedAt: e.publishedAt,
season: ep.season,
episode: ep.episode,
score: lang * 10 + exact * 5 + Math.log10((e.downloads ?? 0) + 1),
};
}
// ── SubHD ──
async function subhdSearch(env: Env, q: SearchQuery): Promise<PageEntry[]> {
// SubHD 的条目 ID 就是豆瓣条目 ID:有豆瓣 ID 直接打开条目页
if (q.doubanId) {
const d = await page("subhd.list", await getText(env, abs(env, `/d/${q.doubanId}`), env.site.base_url));
if (d.entries.length) return d.entries;
}
for (const kw of q.keywords.slice(0, 3)) {
const html = await getText(env, abs(env, `/search/${encodeURIComponent(kw)}`), env.site.base_url);
const r = await page("subhd.list", html);
const hits = r.entries.filter((e) => relevant(e.title, q.keywords));
if (hits.length) return hits;
for (const d of r.details.filter((x) => relevant(x.title, q.keywords)).slice(0, 2)) {
const dd = await page("subhd.list", await getText(env, abs(env, `/d/${d.id}`), env.site.base_url));
if (dd.entries.length) return dd.entries;
}
}
return [];
}
async function subhdDownload(env: Env, ref: string): Promise<{ name: string; bytes: Uint8Array }> {
const subUrl = abs(env, ref);
const sub = await page("subhd.sub", await getText(env, subUrl, env.site.base_url));
let down = sub.down;
if (!down && sub.dataSid) {
const prep = (await (
await request(env, abs(env, "/api/sub/prepare-download"), { referer: subUrl, json: { sid: sub.dataSid } })
).json()) as { success?: boolean; url?: string };
if (prep.success && prep.url?.includes("/down/")) down = prep.url;
}
if (!down) throw new SubtitleSiteError("SubHD 字幕页没有下载按钮");
const downUrl = abs(env, down);
const sid = new URL(downUrl).pathname.split("/").pop() ?? "";
await (await request(env, downUrl, { referer: subUrl })).body?.cancel();
const api = async (cap: string) =>
(await (await request(env, abs(env, "/api/sub/down"), { referer: downUrl, json: { sid, cap } })).json()) as {
success?: boolean;
pass?: boolean;
msg?: string;
url?: string;
};
let r = await api("");
if (r.pass === false) {
// SVG 文字验证码:沙箱里识别后带上再提交一次
const { code } = await runSandbox("captcha.solve", { kind: "subhd", svg: String(r.msg ?? "") });
r = await api(code ?? "");
}
if (!r.success || !r.url)
throw new SubtitleSiteError("SubHD 下载验证没通过:可在站点的原站视图里打开这条字幕手动下载后上传");
return await getFile(env, abs(env, r.url), downUrl);
}
// ── 字幕库 ──
/** 打开页面;遇到云锁验证码页就在沙箱里识别、提交验证后重开(最多 3 次) */
async function zimukuPage(env: Env, url: string, referer?: string): Promise<string> {
for (let i = 0; i < 3; i++) {
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 });
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("字幕库的验证码没能自动通过:可在站点的原站视图里打开一次再试");
}
async function zimukuSearch(env: Env, q: SearchQuery): Promise<PageEntry[]> {
for (const kw of q.keywords.slice(0, 3)) {
const s = await page("zimuku.search", await zimukuPage(env, abs(env, `/search?q=${encodeURIComponent(kw)}`)));
const subs = s.subs.filter((x) => !x.title || relevant(x.title, q.keywords)).slice(0, 3);
const out: PageEntry[] = [];
for (const x of subs) {
const p = await page("zimuku.subs", await zimukuPage(env, abs(env, x.path), env.site.base_url));
out.push(...p.entries);
}
if (out.length) return out;
}
return [];
}
async function zimukuDownload(env: Env, ref: string): Promise<{ name: string; bytes: Uint8Array }> {
const detailUrl = abs(env, ref);
const d = await page("zimuku.detail", await zimukuPage(env, detailUrl, env.site.base_url));
if (!d.dl) throw new SubtitleSiteError("字幕库字幕页没有下载入口");
const dlUrl = new URL(d.dl, detailUrl).href;
const links = (await page("zimuku.dl", await zimukuPage(env, dlUrl, detailUrl))).links;
let last = "字幕库没有可用的下载线路";
for (const l of links) {
try {
const f = await getFile(env, new URL(l, dlUrl).href, dlUrl);
if (f.bytes.length > 64) return f;
} catch (e) {
last = (e as Error).message;
}
}
throw new SubtitleSiteError(last);
}
const PROVIDERS: Record<
string,
{
search(env: Env, q: SearchQuery): Promise<PageEntry[]>;
download(env: Env, ref: string): Promise<{ name: string; bytes: Uint8Array }>;
}
> = {
subhd: { search: subhdSearch, download: subhdDownload },
zimuku: { search: zimukuSearch, download: zimukuDownload },
};
// ── 对外 ──
export async function subtitleSearchQuery(
ctx: ServerContext,
entityId: string,
opts: { season?: number | undefined; episode?: number | undefined; keyword?: string | undefined },
): Promise<SearchQuery> {
const e = await ctx.db
.selectFrom("media_entity")
.select(["title", "original_title"])
.where("id", "=", entityId)
.executeTakeFirst();
if (!e) throw new SubtitleSiteError("作品不存在");
const aliases = await ctx.db
.selectFrom("title_alias")
.select("alias")
.where("media_entity_id", "=", entityId)
.where("content_unit_id", "is", null)
.limit(5)
.execute();
const douban = await ctx.db
.selectFrom("external_identity")
.select("external_id")
.where("media_entity_id", "=", entityId)
.where("provider", "=", "douban")
.where("status", "=", "accepted")
.executeTakeFirst();
const keywords = [opts.keyword, e.title, e.original_title, ...aliases.map((a) => a.alias)]
.map((x) => x?.trim())
.filter((x): x is string => !!x && x.length >= 2);
return {
keywords: [...new Set(keywords)],
doubanId: douban?.external_id ?? null,
season: opts.season ?? null,
episode: opts.episode ?? null,
};
}
async function subtitleSites(ctx: ServerContext): Promise<SiteRow[]> {
return await ctx.db
.selectFrom("site")
.selectAll()
.where("kind", "=", "subtitle")
.where("enabled", "=", true)
.where("deleted_at", "is", null)
.orderBy("name")
.execute();
}
export async function searchOnline(
ctx: ServerContext,
rt: SourcesRuntime,
q: SearchQuery,
): Promise<SubtitleSearchResponse> {
const sites = (await subtitleSites(ctx)).filter((s) => PROVIDERS[s.key]);
const results: SubtitleSearchResult[] = [];
const errors: SubtitleSearchResponse["errors"] = [];
await Promise.all(
sites.map(async (site) => {
const env = { rt, site };
try {
const entries = await PROVIDERS[site.key]!.search(env, q);
const seen = new Set<string>();
for (const e of entries) {
if (seen.has(e.ref)) continue;
seen.add(e.ref);
const r = toResult(env, e, q);
if (r) results.push(r);
}
} catch (e) {
errors.push({ siteName: site.name, reason: (e as Error).message.slice(0, 300) });
}
}),
);
results.sort((a, b) => b.score - a.score);
return { results: results.slice(0, 60), keywords: q.keywords, errors, noSites: sites.length === 0 };
}
export async function downloadOnline(
ctx: ServerContext,
rt: SourcesRuntime,
siteId: string,
ref: 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 f = await PROVIDERS[site.key]!.download(env, ref);
return { name: f.name, base64: b64encode(f.bytes), siteKey: site.key, pageUrl: abs(env, ref) };
}
@@ -0,0 +1,169 @@
// 字幕站页面解析(纯函数,在沙箱任务 subtitle.page 里跑):SubHD / 字幕库的搜索页、条目页、字幕页、下载页 → 结构化数据。
// 页面结构 2026-10-03 实测(样本在 .local/site-samples/subs/),对照 MP 插件 online_subtitles/providers/{subhd,zimuku}.py。
export interface PageEntry {
/** 站内引用:SubHD 为 /a/<sid>,字幕库为 /detail/<id>.html */
ref: string;
title: string;
/** 简体 / 繁体 / 双语 / 英文 …(站点标注或从标题、说明里猜) */
languages: string[];
format: string | null;
downloads: number | null;
uploader: string | null;
publishedAt: string | null;
}
const strip = (s: string) =>
s
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&#39;/g, "'")
.replace(/&quot;/g, '"')
.replace(/\s+/g, " ")
.trim();
const href = (q: string) => `href=["']${q}["']`;
/** 从标题 / 说明里猜语言 */
export function guessLanguages(text: string): string[] {
const t = text.toLowerCase();
const out: string[] = [];
if (/双语|中英|简英|繁英|chs&eng|cht&eng|bilingual/.test(t)) out.push("双语");
if (/简体|简中|chs|gb|简繁|简日/.test(t)) out.push("简体");
if (/繁体|繁體|繁中|cht|big5|简繁/.test(t)) out.push("繁体");
if (/英文|英语|\beng?\b|english/.test(t) && !out.includes("双语")) out.push("英文");
return [...new Set(out)];
}
const formatOf = (text: string) => /\b(ass|ssa|srt|sup|vtt|sub|idx)\b/i.exec(text)?.[1]?.toUpperCase() ?? null;
const num = (s: string | undefined) => {
if (!s) return null;
const m = /([\d.]+)\s*(万)?/.exec(s.replace(/,/g, ""));
return m ? Math.round(Number(m[1]) * (m[2] ? 10_000 : 1)) : null;
};
// ── SubHD ──
/** 搜索页 / 条目页(/d/<豆瓣 ID>):结果区里的字幕(/a/<sid>),不含侧栏热门 */
function subhdList(html: string): { entries: PageEntry[]; details: { id: string; title: string }[] } {
const hot = html.search(/<h\d[^>]*>\s*热门|class="[^"]*hot[^"]*"/);
const body = hot > 0 ? html.slice(0, hot) : html;
const entries: PageEntry[] = [];
const seen = new Set<string>();
for (const m of body.matchAll(new RegExp(`<a[^>]+${href("/a/([A-Za-z0-9_-]+)")}[^>]*>([\\s\\S]*?)</a>`, "g"))) {
const sid = m[1]!;
const title = strip(m[2]!);
if (seen.has(sid) || !title) continue;
seen.add(sid);
const around = strip(body.slice(Math.max(0, m.index! - 600), m.index! + 1200));
entries.push({
ref: `/a/${sid}`,
title,
languages: guessLanguages(`${title} ${around}`),
format: formatOf(`${title} ${around}`),
downloads: null,
uploader: /发布人\s*(\S+)/.exec(around)?.[1] ?? null,
publishedAt: /<time datetime="([^"]+)"/.exec(body.slice(m.index!, m.index! + 2500))?.[1] ?? null,
});
}
const details: { id: string; title: string }[] = [];
for (const m of body.matchAll(new RegExp(`<a[^>]+${href("/d/(\\d+)")}[^>]*>([\\s\\S]*?)</a>`, "g"))) {
const alt = /alt=["']([^"']+)["']/.exec(m[2]!)?.[1];
const title = strip(m[2]!) || alt || "";
if (title && !details.some((d) => d.id === m[1])) details.push({ id: m[1]!, title });
}
return { entries, details };
}
/** 字幕页(/a/<sid>):下载按钮 /down/<sid> 或 data-sid(要先 prepare-download) */
function subhdSub(html: string): { down: string | null; dataSid: string | null } {
return {
down: new RegExp(`${href("([^\"']*/down/[A-Za-z0-9_-]+[^\"']*)")}`).exec(html)?.[1] ?? null,
dataSid: /\bdata-sid=["']([A-Za-z0-9_-]+)["']/.exec(html)?.[1] ?? null,
};
}
// ── 字幕库 ──
/** 字幕库语言旗帜的说明(「简体中文字幕」「双语字幕」…)→ 统一叫法 */
function langLabel(t: string): string {
if (/双语/.test(t)) return "双语";
if (/繁/.test(t)) return "繁体";
if (/简/.test(t)) return "简体";
if (/英/.test(t)) return "英文";
if (/日/.test(t)) return "日文";
if (/韩|韓/.test(t)) return "韩文";
return t.replace(/字幕$/, "");
}
function zimukuSearch(html: string): { subs: { path: string; title: string }[] } {
const subs: { path: string; title: string }[] = [];
for (const m of html.matchAll(/<a[^>]+href=["']([^"']*\/subs\/\d+\.html)["'][^>]*>([\s\S]*?)<\/a>/g)) {
// 链接可能写成 //zimuku.org/subs/…(别的域名):只取路径,拼到站点地址上
const path = /\/subs\/\d+\.html/.exec(m[1]!)![0];
const title =
strip(m[2]!) || /title=["']([^"']+)["']/.exec(m[0])?.[1] || /alt=["']([^"']+)["']/.exec(m[2]!)?.[1] || "";
const prev = subs.find((s) => s.path === path);
if (!prev) subs.push({ path, title });
else if (!prev.title && title) prev.title = title;
}
return { subs };
}
/** 条目页(/subs/<id>.html)的字幕表 */
function zimukuSubs(html: string): { entries: PageEntry[] } {
const entries: PageEntry[] = [];
for (const row of html.matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/g)) {
const r = row[1]!;
const a = /<a href=["'](\/detail\/\d+\.html)["'][^>]*title=["']([^"']+)["']/.exec(r);
if (!a) continue;
const langs = [...r.matchAll(/<img[^>]+title=["']([^"']+字幕)["']/g)].map((m) => langLabel(m[1]!));
const cells = [...r.matchAll(/<td[^>]*>([\s\S]*?)<\/td>/g)].map((m) => m[1]!);
entries.push({
ref: a[1]!,
title: strip(a[2]!),
languages: langs.length ? [...new Set(langs)] : guessLanguages(a[2]!),
format: /<span class="label label-info">([^<]+)<\/span>/.exec(r)?.[1]?.trim() ?? formatOf(a[2]!),
downloads: num(strip(cells[4] ?? "")),
uploader: /<a href=["']\/u\/[^"']+["']>([^<]+)<\/a>/.exec(r)?.[1] ?? null,
publishedAt: strip(/glyphicon-time"><\/span>([^<]+)</.exec(r)?.[1] ?? "") || null,
});
}
return { entries };
}
/** 字幕页:下载入口(li.dlsub 里的链接) */
function zimukuDetail(html: string): { dl: string | null } {
return {
dl: /<li[^>]+class=["'][^"']*dlsub[^"']*["'][^>]*>[\s\S]*?<a[^>]+href=["']([^"']+)["']/.exec(html)?.[1] ?? null,
};
}
/** 下载跳转页:各线路的 /download/ 链接 */
function zimukuDl(html: string): { links: string[] } {
return { links: [...new Set([...html.matchAll(/href=["']([^"']*\/download\/[^"']+)["']/g)].map((m) => m[1]!))] };
}
/** 是否是字幕库(云锁)的验证码页;是则带回 BMP */
function zimukuCaptcha(html: string): { bmpBase64: string | null } {
return {
bmpBase64:
/class=["']verifyimg["'][^>]*src=["']data:image\/bmp;base64,([^"']+)["']/.exec(html)?.[1] ??
/data:image\/bmp;base64,([^"']+)/.exec(html)?.[1] ??
null,
};
}
export const PAGE_PARSERS = {
"subhd.list": subhdList,
"subhd.sub": subhdSub,
"zimuku.search": zimukuSearch,
"zimuku.subs": zimukuSubs,
"zimuku.detail": zimukuDetail,
"zimuku.dl": zimukuDl,
"zimuku.captcha": zimukuCaptcha,
} as const;
export type PageKind = keyof typeof PAGE_PARSERS;
export type PageResult<K extends PageKind> = ReturnType<(typeof PAGE_PARSERS)[K]>;
+1 -1
View File
@@ -269,7 +269,7 @@ export async function applySubtitles(
library_root_id: t.libraryRootId,
relative_path: rel,
language: it.language,
source: body.source ?? "upload",
source: it.source ?? body.source ?? "upload",
source_name: it.name,
source_ref: it.sourceRef ?? null,
backup_path: backup,
+70
View File
@@ -0,0 +1,70 @@
// 压缩包解压(沙箱内):zip 用 fflate,rar 用 node-unrar-js(WASM),7z 用 7z-wasm。
// WASM 库要读自己包里的 .wasm 文件:prepareArchives() 在撤销权限前预热(载入内存),之后只在内存里解压。
import SevenZip from "7z-wasm";
import { unzipSync } from "fflate";
import { createExtractorFromData } from "node-unrar-js";
type Seven = Awaited<ReturnType<typeof SevenZip>>;
let seven: Seven | null = null;
let warmed = false;
export async function prepareArchives(): Promise<void> {
if (warmed) return;
warmed = true;
try {
await createExtractorFromData({ data: new Uint8Array([0x52, 0x61, 0x72, 0x21]).buffer });
} catch {
// 只为载入 WASM,内容无效报错是预期的
}
seven = await SevenZip({ print: () => {}, printErr: () => {} });
}
export type AcceptEntry = (name: string, size: number) => boolean;
export function archiveKindOf(name: string): "zip" | "rar" | "7z" | null {
const m = /\.(zip|rar|7z)$/i.exec(name);
return m ? (m[1]!.toLowerCase() as "zip" | "rar" | "7z") : null;
}
/** 解出通过 accept 的条目;压缩包损坏 / 加密抛错 */
export async function extractArchive(
kind: "zip" | "rar" | "7z",
bytes: Uint8Array,
accept: AcceptEntry,
): Promise<Record<string, Uint8Array>> {
if (kind === "zip")
return unzipSync(bytes, { filter: (e) => !e.name.endsWith("/") && accept(e.name, e.originalSize) });
if (kind === "rar") {
const ex = await createExtractorFromData({ data: bytes.slice().buffer });
const out: Record<string, Uint8Array> = {};
for (const f of ex.extract({ files: (h) => !h.flags.directory && accept(h.name, h.unpSize) }).files)
if (f.extraction) out[f.fileHeader.name] = f.extraction;
return out;
}
await prepareArchives();
const z = seven!;
const dir = `/x${crypto.randomUUID().slice(0, 8)}`;
z.FS.writeFile(`${dir}.7z`, bytes);
// callMain 出错时抛 ExitStatus 或静默不写文件:两种都当成损坏处理
z.callMain(["x", `${dir}.7z`, `-o${dir}`, "-y", "-p-"]);
try {
z.FS.stat(dir);
} catch {
throw new Error("7z 解压失败");
}
const out: Record<string, Uint8Array> = {};
const walk = (d: string, rel: string) => {
for (const n of z.FS.readdir(d) as string[]) {
if (n === "." || n === "..") continue;
const p = `${d}/${n}`;
const r = rel ? `${rel}/${n}` : n;
if (z.FS.isDir(z.FS.stat(p).mode)) walk(p, r);
else {
const data = z.FS.readFile(p) as Uint8Array;
if (accept(r, data.length)) out[r] = data;
}
}
};
walk(dir, "");
return out;
}
+233
View File
@@ -0,0 +1,233 @@
// SubHD 下载验证码(SVG 文字):每个字符一条长 path,按 path 长度查表,长度撞车时看最高点 / 起笔 / 宽度区分
// (移植自 MP 插件 captcha/subhd.py 的长度表与判别规则)
const LENGTH_MAP: Record<number, string> = {
986: "I",
998: "1",
1068: "I",
1081: "1",
1082: "v",
1130: "Y",
1134: "Y",
1172: "v",
1224: "Y",
1274: "L",
1298: "V",
1311: "V",
1360: "i",
1380: "L",
1406: "V",
1473: "i",
1478: "T",
1491: "r",
1598: "N",
1601: "T",
1604: "X",
1610: "J",
1613: "x",
1614: "N",
1615: "N",
1616: "N",
1617: "N",
1618: "N",
1634: "k",
1637: "k",
1694: "t",
1706: "K",
1709: "K",
1731: "N",
1744: "J",
1754: "F",
1770: "k",
1835: "t",
1838: "u",
1840: "A",
1844: "A",
1848: "K",
1850: "Z",
1853: "Z",
1886: "h",
1900: "F",
1922: "H",
1928: "H",
1960: "P",
1991: "u",
1993: "A",
1996: "D",
2004: "Z",
2018: "w",
2035: "w",
2042: "7",
2043: "h",
2080: "j",
2082: "H",
2104: "R",
2107: "R",
2123: "P",
2140: "4",
2162: "D",
2164: "O",
2183: "w",
2198: "C",
2199: "C",
2200: "C",
2201: "C",
2202: "C",
2210: "f",
2212: "7",
2246: "E",
2253: "j",
2260: "o",
2272: "d",
2279: "M",
2282: "M",
2294: "U",
2301: "U",
2310: "W",
2318: "4",
2321: "M",
2332: "a",
2344: "O",
2345: "W",
2346: "W",
2366: "s",
2380: "b",
2381: "C",
2382: "0",
2394: "f",
2433: "E",
2448: "o",
2461: "d",
2464: "p",
2466: "M",
2485: "U",
2498: "c",
2501: "e",
2503: "W",
2512: "q",
2526: "a",
2546: "2",
2563: "s",
2578: "b",
2580: "0",
2606: "5",
2632: "6",
2669: "p",
2706: "c",
2709: "e",
2721: "q",
2758: "2",
2800: "9",
2823: "5",
2851: "6",
3033: "9",
3038: "S",
3054: "B",
3160: "g",
3244: "Q",
3254: "Q",
3266: "G",
3291: "S",
3308: "B",
3414: "8",
3423: "g",
3514: "Q",
3538: "G",
3663: "m",
3667: "m",
3698: "8",
3878: "3",
3968: "m",
4201: "3",
};
const nums = (p: string) => (p.match(/\d+(?:\.\d*)?/g) ?? []).map(Number);
const minY = (p: string) => {
const v = nums(p).filter((_, i) => i % 2 === 1);
return v.length ? Math.min(...v) : 0;
};
const moveY = (p: string) => Number(/M(\d+(?:\.\d*)?)\s+(\d+(?:\.\d*)?)/.exec(p)?.[2] ?? 0);
const width = (p: string) => {
const v = nums(p).filter((_, i) => i % 2 === 0);
return v.length ? Math.max(...v) - Math.min(...v) : 0;
};
function collision(len: number, p: string): string {
if (len === 986 || len === 1068) return minY(p) > 13 ? "I" : "l";
if (len === 1274 || len === 1380) return moveY(p) > 30 ? "y" : "L";
if (len === 1610 || len === 1744) return minY(p) > 19 ? "x" : "J";
if (len === 1615) return minY(p) > 18 ? "r" : "N";
if (len === 2198 || len === 2381) return minY(p) > 19 ? "n" : "C";
if (len === 2318) return width(p) > 30 ? "W" : "4";
if (len === 1598 || len === 1731) return minY(p) > 13 ? "X" : "N";
if (len === 1694 || len === 1835) return minY(p) > 22 ? "z" : "t";
if (len === 2279) return minY(p) > 13 ? "R" : "M";
return "";
}
export function solveSubhdCaptcha(svg: string): string {
const paths: [number, string][] = [];
for (const m of svg.matchAll(/d=["']([^"']+)["']/g)) {
const p = m[1]!;
if (p.length <= 500) continue;
paths.push([Number(/\d+(?:\.\d*)?/.exec(p)?.[0] ?? 0), p]);
}
return paths
.sort((a, b) => a[0] - b[0])
.map(([, p]) => collision(p.length, p) || LENGTH_MAP[p.length] || "")
.join("");
}
// 字幕库(云锁)BMP 数字验证码:100×27、5 位、每位 20 像素宽;按 9 个采样点比对 0-9 模板(移植自 MP 插件 captcha/zimuku.py)
const W = 100,
H = 27,
CHAR_W = 20,
N = 5,
OFFSET = 54;
const SAMPLES: [number, number][] = [
[10, 7],
[7, 8],
[12, 8],
[10, 13],
[7, 19],
[12, 19],
[10, 20],
[6, 13],
[14, 13],
];
const TEMPLATES: Record<string, number[]> = {
"0": [1, 1, 1, 1, 1, 1, 1, 1, 0],
"1": [0, 1, 0, 0, 0, 0, 1, 0, 0],
"2": [1, 0, 1, 0, 1, 0, 1, 0, 0],
"3": [1, 0, 1, 1, 0, 1, 1, 0, 0],
"4": [0, 0, 1, 0, 0, 1, 0, 0, 0],
"5": [1, 1, 0, 0, 0, 1, 1, 0, 0],
"6": [1, 0, 1, 1, 1, 1, 1, 1, 0],
"7": [1, 0, 1, 0, 0, 0, 0, 0, 0],
"8": [1, 1, 1, 1, 1, 1, 1, 0, 0],
"9": [1, 1, 1, 0, 1, 0, 1, 0, 0],
};
export function solveZimukuCaptcha(bmp: Uint8Array): string | null {
if (bmp.length < OFFSET || bmp[0] !== 0x42 || bmp[1] !== 0x4d) return null;
const dv = new DataView(bmp.buffer, bmp.byteOffset, bmp.byteLength);
if (dv.getInt32(18, true) !== W || dv.getInt32(22, true) !== H) return null;
const stride = (W * 3 + 3) & ~3;
const fg = (x: number, y: number) => {
const o = OFFSET + (H - 1 - y) * stride + x * 3;
if (o + 2 >= bmp.length || x < 0) return false;
return (bmp[o]! + bmp[o + 1]! + bmp[o + 2]!) / 3 < 70;
};
let out = "",
shift = 0;
for (let i = 0; i < N; i++) {
const f = SAMPLES.map(([px, py]) => (fg(i * CHAR_W + px - shift, py) ? 1 : 0));
let best = "?",
min = 99;
for (const [d, t] of Object.entries(TEMPLATES)) {
const diff = t.reduce((n, v, k) => n + (v !== f[k] ? 1 : 0), 0);
if (diff < min) {
min = diff;
best = d;
}
}
if (best === "1") shift += 1;
else if (best === "4") shift -= 1;
out += best;
}
return out.includes("?") ? null : out;
}
+2 -1
View File
@@ -3,7 +3,7 @@
// 起不来(权限 / 运行时不支持)时退回进程内执行并记日志:仍有超时,但卡死的同步代码无法收回(只在本机调试时出现)。
import { basename } from "node:path";
import { fileURLToPath } from "node:url";
import { SANDBOX_JOBS, type SandboxInput, type SandboxJobName, type SandboxOutput } from "./jobs.ts";
import { prepareJobs, SANDBOX_JOBS, type SandboxInput, type SandboxJobName, type SandboxOutput } from "./jobs.ts";
export type SandboxErrorKind = "timeout" | "memory" | "crash" | "job" | "spawn";
@@ -144,6 +144,7 @@ async function runInProcess<J extends SandboxJobName>(
input: SandboxInput<J>,
timeoutMs: number,
): Promise<unknown> {
await prepareJobs();
const fn = SANDBOX_JOBS[job] as (i: SandboxInput<J>) => unknown;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
+33 -13
View File
@@ -3,8 +3,10 @@
// 输入输出必须能 JSON 化(二进制用 base64)。新增任务:在这里加一项,调用方用 runSandbox(名字, 输入)。
import { parseTorrent } from "@umt/adapters";
import { decodeSubtitle, detectSubtitleLanguage, subtitleEpisode, subtitleExtOf } from "@umt/domain";
import { unzipSync } from "fflate";
import { buildPreview } from "../modules/file-manager/preview-build.ts";
import { PAGE_PARSERS, type PageKind } from "../modules/subtitles/online/parse.ts";
import { archiveKindOf, extractArchive, prepareArchives } from "./archives.ts";
import { solveSubhdCaptcha, solveZimukuCaptcha } from "./captcha.ts";
const MAX_SUBTITLE_BYTES = 10 * 1024 * 1024;
const MAX_ARCHIVE_ENTRIES = 300;
@@ -30,8 +32,13 @@ export interface ParsedSubtitleFile {
content: string;
}
/** 字幕 / zip:解压、解码转 UTF-8、识别语言与季集 */
function subtitleParse(input: { files: { name: string; base64: string }[] }) {
/** 子进程撤销权限前调用:预热要读文件的 WASM 库 */
export async function prepareJobs(): Promise<void> {
await prepareArchives();
}
/** 字幕 / zip / rar / 7z:解压、解码转 UTF-8、识别语言与季集 */
async function subtitleParse(input: { files: { name: string; base64: string }[] }) {
const items: ParsedSubtitleFile[] = [];
const skipped: { name: string; reason: string }[] = [];
const one = (name: string, bytes: Uint8Array, archive: string | null) => {
@@ -61,17 +68,16 @@ function subtitleParse(input: { files: { name: string; base64: string }[] }) {
skipped.push({ name: f.name, reason: "内容不是有效的 base64" });
continue;
}
if (/\.zip$/i.test(f.name)) {
const kind = archiveKindOf(f.name);
if (kind) {
let entries: Record<string, Uint8Array>;
try {
let n = 0;
entries = unzipSync(bytes, {
filter: (e) =>
!e.name.endsWith("/") &&
!!subtitleExtOf(e.name) &&
e.originalSize <= MAX_SUBTITLE_BYTES &&
++n <= MAX_ARCHIVE_ENTRIES,
});
entries = await extractArchive(
kind,
bytes,
(name, size) => !!subtitleExtOf(name) && size <= MAX_SUBTITLE_BYTES && ++n <= MAX_ARCHIVE_ENTRIES,
);
} catch {
skipped.push({ name: f.name, reason: "压缩包损坏或加密" });
continue;
@@ -79,8 +85,6 @@ function subtitleParse(input: { files: { name: string; base64: string }[] }) {
const names = Object.keys(entries);
if (!names.length) skipped.push({ name: f.name, reason: "压缩包里没有字幕文件" });
for (const n of names) one(n, entries[n]!, f.name);
} else if (/\.(rar|7z)$/i.test(f.name)) {
skipped.push({ name: f.name, reason: "暂不支持 rar / 7z,请解压后上传或改用 zip" });
} else one(f.name, bytes, null);
}
return { items, skipped };
@@ -103,10 +107,26 @@ function selfTest(input: { mode: "ok" | "hang" | "memory" | "throw" | "io" }) {
return { ok: true };
}
/** SubHD SVG / 字幕库 BMP 验证码 */
function captchaSolve(input: { kind: "subhd"; svg: string } | { kind: "zimuku"; bmpBase64: string }) {
return {
code: input.kind === "subhd" ? solveSubhdCaptcha(input.svg) : solveZimukuCaptcha(b64decode(input.bmpBase64)),
};
}
/** 字幕站页面 → 结构化数据(网页同样不可信) */
function subtitlePage(input: { kind: PageKind; html: string }) {
const fn = PAGE_PARSERS[input.kind] as (html: string) => unknown;
if (!fn) throw new Error(`没有这种页面解析:${input.kind}`);
return fn(input.html);
}
export const SANDBOX_JOBS = {
"subtitle.parse": subtitleParse,
"torrent.parse": torrentParse,
"file.preview": buildPreview,
"captcha.solve": captchaSolve,
"subtitle.page": subtitlePage,
"self.test": selfTest,
} as const;
+2 -1
View File
@@ -1,6 +1,6 @@
// 沙箱子进程入口:先加载任务表,再撤销全部权限,然后从 stdin 读一条 {job, input},把 {ok, result | error} 写到 stdout。
// 开发 / 测试由 deno run 直接跑本文件;编译二进制以 `<自身> --role sandbox` 拉起(main.ts 在加载其余模块前分派)。
import { SANDBOX_JOBS } from "./jobs.ts";
import { prepareJobs, SANDBOX_JOBS } from "./jobs.ts";
const PERMISSIONS = ["read", "write", "net", "env", "run", "ffi", "sys"] as const;
@@ -10,6 +10,7 @@ async function writeAll(bytes: Uint8Array) {
}
export async function sandboxWorkerMain(): Promise<void> {
await prepareJobs();
for (const name of PERMISSIONS) {
try {
Deno.permissions.revokeSync({ name } as Deno.PermissionDescriptor);
+33 -1
View File
@@ -1,4 +1,4 @@
import { buildFakeTorrent } from "@umt/testkit";
import { buildFakeTorrent, RAR5_SAMPLE_BASE64, SEVENZIP_SUBTITLE_BASE64, ZIMUKU_CAPTCHA } from "@umt/testkit";
import { zipSync } from "fflate";
import { describe, expect, it } from "vitest";
import { runSandbox, SandboxError } from "../../src/sandbox/client.ts";
@@ -48,4 +48,36 @@ describe("沙箱子进程", () => {
expect(f.files.map((x) => x.path)).toEqual(["Show.S01E01.mkv"]);
expect(f.hash).toBe(t.infoHash);
});
it("rar / 7z 在沙箱里解压(撤销权限后 WASM 仍可用);非字幕条目跳过", async () => {
const r = await runSandbox("subtitle.parse", {
files: [
{ name: "pack.7z", base64: SEVENZIP_SUBTITLE_BASE64 },
{ name: "texts.rar", base64: RAR5_SAMPLE_BASE64 },
],
});
expect(r.items).toEqual([
expect.objectContaining({ name: "s7/a.chs.srt", archive: "pack.7z", language: "zh-Hans" }),
]);
expect(r.skipped).toEqual([{ name: "texts.rar", reason: "压缩包里没有字幕文件" }]);
});
it("字幕库验证码:真实样本识别正确", async () => {
expect(await runSandbox("captcha.solve", { kind: "zimuku", bmpBase64: ZIMUKU_CAPTCHA.bmpBase64 })).toEqual({
code: ZIMUKU_CAPTCHA.code,
});
});
});
describe("压缩包解压(进程内,直接测解压模块)", () => {
it("rar 列出全部条目", async () => {
const { extractArchive } = await import("../../src/sandbox/archives.ts");
const out = await extractArchive(
"rar",
Uint8Array.from(atob(RAR5_SAMPLE_BASE64), (c) => c.charCodeAt(0)),
() => true,
);
expect(Object.keys(out).sort()).toEqual(["stest1.txt", "stest2.txt"]);
await expect(extractArchive("rar", new Uint8Array([1, 2, 3]), () => true)).rejects.toThrow();
});
});
@@ -129,7 +129,15 @@ describe.skipIf(!up)("站点类型:API、调度与 RSS", () => {
.map((d) => d.key)
.sort(),
).toEqual([...pub].sort());
expect(items.filter((d) => !pub.includes(d.key)).every((d) => d.kind === "pt")).toBe(true);
// 内置字幕站(SubHD、字幕库)也在定义列表里,类型 subtitle
const subs = ["subhd", "zimuku"];
expect(
items
.filter((d) => d.kind === "subtitle")
.map((d) => d.key)
.sort(),
).toEqual(subs);
expect(items.filter((d) => !pub.includes(d.key) && !subs.includes(d.key)).every((d) => d.kind === "pt")).toBe(true);
const nyaa = await json(
await h.req("/sites", { body: { key: "nyaa", name: "Nyaa", adapter: "api", baseUrl: "https://nyaa.si/" } }),
);
@@ -203,4 +211,23 @@ describe.skipIf(!up)("站点类型:API、调度与 RSS", () => {
reason: "unsupported",
});
});
it("字幕站:只能从内置字幕站添加;不排快照、不进搜索、能力为空", async () => {
const ok = await json(
await h.req("/sites", {
body: { kind: "subtitle", key: "zimuku", name: "字幕库", adapter: "subtitle", baseUrl: "https://zmk.pw/" },
}),
);
expect(ok.status).toBe(200);
expect(ok.body.data).toMatchObject({
kind: "subtitle",
snapshotEnabled: false,
searchDefault: false,
capabilities: [],
});
const bad = await h.req("/sites", {
body: { kind: "subtitle", key: "random", name: "x", adapter: "subtitle", baseUrl: "https://x.example/" },
});
expect(bad.status).toBe(400);
});
});
@@ -0,0 +1,122 @@
import {
SUBHD_DETAIL_PAGE,
SUBHD_SUB_PAGE,
ZIMUKU_CAPTCHA,
ZIMUKU_CAPTCHA_PAGE,
ZIMUKU_DETAIL_PAGE,
ZIMUKU_DL_PAGE,
ZIMUKU_SEARCH_PAGE,
ZIMUKU_SUBS_PAGE,
} from "@umt/testkit";
import { zipSync } from "fflate";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { downloadOnline, searchOnline } from "../../src/modules/subtitles/online/index.ts";
import { createHarness, type Harness, pgAvailable } from "../sources-identity/harness.ts";
// 在线字幕(docs/plan/subtitles.md 第 2 步):站点请求全部走注入的 fetch,页面是 2026-10-03 的真实截取;
// 字幕库先回验证码页,沙箱识别后带 security_verify_img 提交,再开搜索页;SubHD 走 prepare-download → down → api/sub/down
const pg = await pgAvailable();
const enc = (s: string) => new TextEncoder().encode(s);
describe.skipIf(!pg)("在线字幕:SubHD / 字幕库(注入 fetch)", () => {
let h: Harness;
const requests: string[] = [];
let verified = false;
const siteIds: Record<string, string> = {};
const html = (body: string, status = 200) =>
new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8" } });
const zip = zipSync({
"The.Shawshank.Redemption.1994.chs&eng.ass": enc(
"[Script Info]\nDialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,你好 Hello",
),
});
beforeAll(async () => {
h = await createHarness("subonline", {
fetch: async (input, init) => {
const u = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
const method = init?.method ?? "GET";
requests.push(`${method} ${u.host}${u.pathname}${u.search}`);
if (u.host === "zmk.pw") {
if (u.searchParams.has("security_verify_img")) {
const want = [...ZIMUKU_CAPTCHA.code].map((c) => c.charCodeAt(0).toString(16)).join("");
verified = u.searchParams.get("security_verify_img") === want;
return html("ok", 302);
}
if (!verified) return html(ZIMUKU_CAPTCHA_PAGE, 404);
if (u.pathname === "/search") return html(ZIMUKU_SEARCH_PAGE);
if (/^\/subs\/\d+\.html$/.test(u.pathname)) return html(ZIMUKU_SUBS_PAGE);
if (u.pathname === "/detail/203404.html") return html(ZIMUKU_DETAIL_PAGE);
if (u.pathname === "/dld/203404.html") return html(ZIMUKU_DL_PAGE);
if (u.pathname.startsWith("/download/"))
return new Response(zip, {
headers: {
"content-type": "application/octet-stream",
"content-disposition": 'attachment; filename="sub.zip"',
},
});
}
if (u.host === "subhd.tv") {
if (u.pathname === "/d/1292052") return html(SUBHD_DETAIL_PAGE);
if (u.pathname.startsWith("/a/")) return html(SUBHD_SUB_PAGE);
if (u.pathname === "/api/sub/prepare-download") return Response.json({ success: true, url: "/down/Fp90EG" });
if (u.pathname === "/down/Fp90EG") return html("<html></html>");
if (u.pathname === "/api/sub/down")
return Response.json({ success: true, pass: true, url: "https://dl.subhd.example/x/1.srt" });
}
if (u.host === "dl.subhd.example")
return new Response(enc("1\n00:00:01,000 --> 00:00:02,000\n你好\n"), {
headers: { "content-disposition": 'attachment; filename="Shawshank.chs.srt"' },
});
return html("not found", 404);
},
});
for (const [key, name, url] of [
["subhd", "SubHD", "https://subhd.tv/"],
["zimuku", "字幕库", "https://zmk.pw/"],
] as const) {
const r = await h.db
.insertInto("site")
.values({ key, name, kind: "subtitle", adapter: "subtitle", base_url: url } as never)
.returning("id")
.executeTakeFirstOrThrow();
siteIds[key] = r.id;
}
}, 120_000);
afterAll(async () => {
await h?.close();
});
// 字幕库样例是《葬送的芙莉莲》的搜索结果;SubHD 按豆瓣 ID 开条目页,不按标题过滤
const q = { keywords: ["葬送的芙莉莲"], doubanId: "1292052", season: null, episode: null };
it("搜索:SubHD 有豆瓣 ID 直接开条目页;字幕库先过验证码再搜;结果按语言与下载量排序", async () => {
const r = await searchOnline(h.ctx, h.rt, q);
expect(r.errors).toEqual([]);
expect(r.noSites).toBe(false);
expect(verified).toBe(true);
expect(requests).toContain("GET subhd.tv/d/1292052");
const zim = r.results.filter((x) => x.siteKey === "zimuku");
const sub = r.results.filter((x) => x.siteKey === "subhd");
expect(zim.length).toBeGreaterThan(0);
expect(sub.length).toBeGreaterThan(0);
// 双语排在前面
expect(r.results[0]!.languages).toContain("双语");
expect(zim.find((x) => x.ref === "/detail/203404.html")).toMatchObject({ siteName: "字幕库", downloads: 17000 });
});
it("下载:字幕库走下载页取文件;SubHD 走 prepare-download → api/sub/down", async () => {
const z = await downloadOnline(h.ctx, h.rt, siteIds.zimuku!, "/detail/203404.html");
expect(z).toMatchObject({ name: "sub.zip", siteKey: "zimuku", pageUrl: "https://zmk.pw/detail/203404.html" });
const s = await downloadOnline(h.ctx, h.rt, siteIds.subhd!, "/a/Fp90EG");
expect(s.name).toBe("Shawshank.chs.srt");
expect(requests).toContain("POST subhd.tv/api/sub/prepare-download");
});
it("字幕站停用:不搜;下载报错", async () => {
await h.db.updateTable("site").set({ enabled: false }).where("kind", "=", "subtitle").execute();
const r = await searchOnline(h.ctx, h.rt, q);
expect(r.noSites).toBe(true);
await expect(downloadOnline(h.ctx, h.rt, siteIds.subhd!, "/a/x")).rejects.toThrow(/已停用/);
});
});
+18
View File
@@ -2,7 +2,10 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
type SubtitleApplyRequest,
SubtitleApplyResult,
type SubtitleFetchRequest,
SubtitleParseResponse,
type SubtitleSearchRequest,
SubtitleSearchResponse,
SubtitleTargetsResponse,
SubtitleWritesResponse,
SubtitleWriteView,
@@ -82,3 +85,18 @@ export function useUndoSubtitle(entityId: string) {
},
});
}
export function useSearchSubtitles() {
return useMutation({
mutationFn: async (body: SubtitleSearchRequest) =>
parseOrThrow(SubtitleSearchResponse, await api.post<unknown>("/subtitles/search", body), "/subtitles/search"),
});
}
/** 下载一条在线字幕并解析(同上传的预览,不写文件) */
export function useFetchSubtitle() {
return useMutation({
mutationFn: async (body: SubtitleFetchRequest) =>
parseOrThrow(SubtitleParseResponse, await api.post<unknown>("/subtitles/fetch", body), "/subtitles/fetch"),
});
}
@@ -6,7 +6,7 @@ import {
type SiteListItem,
type SiteLoginResult,
} from "@umt/contracts";
import { ArrowLeft, Check, Globe, LoaderCircle, Rss, Search, Server, ShieldCheck } from "lucide-react";
import { ArrowLeft, Captions, Check, Globe, LoaderCircle, Rss, Search, Server, ShieldCheck } from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
@@ -48,8 +48,8 @@ const PT_STEPS = ["选择类型", "选择站点", "填写凭证", "测试"] as c
const FEED_STEPS = ["选择类型", "选择站点"] as const;
const GROUP_ORDER = ["nexusphp", "mteam_api", "api", "generic_rss"] as const;
/** 自定义 RSS 源与索引器入口暂时不开放(已有的 RSS 源站点照常显示) */
const KIND_ORDER: SiteKind[] = ["pt", "public_bt"];
const KIND_ICON = { pt: ShieldCheck, public_bt: Globe, rss_feed: Rss, torznab: Server } as const;
const KIND_ORDER: SiteKind[] = ["pt", "public_bt", "subtitle"];
const KIND_ICON = { pt: ShieldCheck, public_bt: Globe, rss_feed: Rss, torznab: Server, subtitle: Captions } as const;
/** 馒头默认 API Key,其余默认 Cookie(验证码站点账密大概率失败) */
function defaultKind(kinds: LoginKind[]): LoginKind {
@@ -105,13 +105,14 @@ export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; i
})).filter((g) => g.items.length);
}, [items, q]);
const publicDefs = items.filter((d) => d.kind === "public_bt");
// 公开 BT 站 / 字幕站:都是内置站,一键添加
const publicDefs = items.filter((d) => d.kind === kind);
/** 内置公开 BT 站:一步建好(专用适配器、定时抓最新),进站点页 */
const addPublic = async (d: SiteDefinitionView) => {
try {
setPhase("保存中…");
const site = await create.mutateAsync({
kind: "public_bt",
kind: d.kind,
key: d.key,
adapter: d.adapter,
name: d.name,
@@ -187,7 +188,9 @@ export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; i
<LoadingState rows={2} />
) : null}
<p className="mt-3 text-meta text-muted-foreground">
不需要账号;添加后默认走代理,定时抓取最新资源,也能在资源搜索里选用。
{kind === "subtitle"
? "不需要账号;在作品页「补字幕」里搜索、下载。"
: "不需要账号;添加后默认走代理,定时抓取最新资源,也能在资源搜索里选用。"}
</p>
</div>
);
+151 -84
View File
@@ -336,7 +336,46 @@ function PublicSiteCard({ site, onDialog }: Omit<Props, "checks">) {
);
}
/** 字幕站(SubHD、字幕库):出口与状态;搜索 / 下载在作品页「补字幕」里 */
function SubtitleSiteCard({ site, checks, onDialog }: Props) {
const health = healthLine(site.lastHealth, site.lastHealthAt);
const busy = checks.isBusy(site.id, "health");
return (
<article className={cn("pg-slab flex min-w-0 flex-col rounded-xl p-4", !site.enabled && "opacity-75")}>
<SiteCardHeader site={site} />
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 rounded-lg border border-border/60 px-3 py-2.5 text-caption">
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">出口</dt>
<dd className="text-foreground">{site.proxy ? "代理" : "直连"}</dd>
</div>
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">状态</dt>
<dd className="truncate" title={healthMessage(site.lastHealth) ?? undefined}>
<StatusDot tone={health.tone} />
{health.text}
</dd>
</div>
</dl>
<p className="mt-2 text-meta text-muted-foreground">在作品页「补字幕」里搜索、下载。</p>
<div aria-hidden className="min-h-4 flex-1" />
<footer className="mt-auto flex flex-wrap items-center justify-end gap-2 border-t border-border/60 pt-3">
<Button
variant="outline"
size="sm"
disabled={busy || !site.enabled}
onClick={() => void checks.checkHealth(site)}
>
{busy ? <LoaderCircle className="animate-spin" /> : <Activity />}
检测健康
</Button>
<SiteMoreMenu site={site} onDialog={onDialog} />
</footer>
</article>
);
}
export function SiteCard({ site, checks, onDialog }: Props) {
if (site.kind === "subtitle") return <SubtitleSiteCard site={site} checks={checks} onDialog={onDialog} />;
if (!hasLogin(site) && siteCan(site, "list")) return <PublicSiteCard site={site} onDialog={onDialog} />;
if (!hasLogin(site)) return <FeedSiteCard site={site} onDialog={onDialog} />;
return <AccountSiteCard site={site} checks={checks} onDialog={onDialog} />;
@@ -480,8 +519,8 @@ export function SiteTable({
sites: SiteListItem[];
checks: Checks;
onDialog: (d: SiteDialog) => void;
/** pt = 带账号与流量列;public = 公开站(无账号),只列状态与资源更新 */
variant?: "pt" | "public";
/** pt = 带账号与流量列;public = 公开站(无账号),只列状态与资源更新;subtitle = 字幕站,只列出口与状态 */
variant?: "pt" | "public" | "subtitle";
}) {
const [params] = useSearchParams();
const name: DataColumn<SiteListItem> = {
@@ -530,8 +569,22 @@ export function SiteTable({
</div>
),
};
const health: DataColumn<SiteListItem> = {
key: "health",
header: "状态",
hideBelow: "sm",
cell: (s) => {
const h = healthLine(s.lastHealth, s.lastHealthAt);
return (
<span className="whitespace-nowrap text-caption" title={healthMessage(s.lastHealth) ?? h.text}>
<StatusDot tone={h.tone} />
{h.text}
</span>
);
},
};
const columns: DataColumn<SiteListItem>[] =
variant === "public"
variant === "subtitle"
? [
name,
{
@@ -540,94 +593,108 @@ export function SiteTable({
hideBelow: "sm",
cell: (s) => <span className="whitespace-nowrap text-muted-foreground">{s.proxy ? "代理" : "直连"}</span>,
},
{
key: "search",
header: "资源搜索",
hideBelow: "sm",
cell: (s) => (
<span className="whitespace-nowrap text-muted-foreground">{s.searchDefault ? "默认参与" : "手动选"}</span>
),
},
snapshot,
health,
actions,
]
: [
name,
{
key: "account",
header: "账号",
hideBelow: "xl",
cell: (s) => (
<div className="flex flex-wrap items-center gap-1">
<AccountBadges site={s} />
</div>
),
},
{
key: "login",
header: "上次登录",
hideBelow: "xl",
cell: (s) => (
<span className="whitespace-nowrap text-muted-foreground">
{relTime(primaryAccount(s.accounts)?.lastLoginAt)}
</span>
),
},
{
key: "traffic",
header: "上传 / 下载",
hideBelow: "md",
cell: (s) => {
const u = dataAccount(s.accounts)?.userData;
return u ? (
<span className="whitespace-nowrap tabular-nums">
{bytes(u.upload)} / {bytes(u.download)}
</span>
) : (
"—"
);
: variant === "public"
? [
name,
{
key: "proxy",
header: "出口",
hideBelow: "sm",
cell: (s) => <span className="whitespace-nowrap text-muted-foreground">{s.proxy ? "代理" : "直连"}</span>,
},
},
{
key: "ratio",
header: "分享率",
align: "right",
cell: (s) => (
<span className="whitespace-nowrap tabular-nums">{ratioText(dataAccount(s.accounts)?.userData)}</span>
),
},
{
key: "level",
header: "等级 · 做种",
hideBelow: "lg",
cell: (s) => {
const u = dataAccount(s.accounts)?.userData;
const t = u ? `${u.userLevel ?? "—"} · ${u.seeding}` : "—";
return (
<span className="block max-w-40 truncate whitespace-nowrap" title={t}>
{t}
{
key: "search",
header: "资源搜索",
hideBelow: "sm",
cell: (s) => (
<span className="whitespace-nowrap text-muted-foreground">
{s.searchDefault ? "默认参与" : "手动选"}
</span>
);
),
},
},
{
key: "health",
header: "状态",
hideBelow: "md",
cell: (s) => {
if (!hasLogin(s)) return <span className="text-muted-foreground">—</span>;
const h = healthLine(s.lastHealth, s.lastHealthAt);
return (
<span className="whitespace-nowrap text-caption" title={healthMessage(s.lastHealth) ?? h.text}>
<StatusDot tone={h.tone} />
{h.text}
snapshot,
actions,
]
: [
name,
{
key: "account",
header: "账号",
hideBelow: "xl",
cell: (s) => (
<div className="flex flex-wrap items-center gap-1">
<AccountBadges site={s} />
</div>
),
},
{
key: "login",
header: "上次登录",
hideBelow: "xl",
cell: (s) => (
<span className="whitespace-nowrap text-muted-foreground">
{relTime(primaryAccount(s.accounts)?.lastLoginAt)}
</span>
);
),
},
},
snapshot,
actions,
];
{
key: "traffic",
header: "上传 / 下载",
hideBelow: "md",
cell: (s) => {
const u = dataAccount(s.accounts)?.userData;
return u ? (
<span className="whitespace-nowrap tabular-nums">
{bytes(u.upload)} / {bytes(u.download)}
</span>
) : (
"—"
);
},
},
{
key: "ratio",
header: "分享率",
align: "right",
cell: (s) => (
<span className="whitespace-nowrap tabular-nums">{ratioText(dataAccount(s.accounts)?.userData)}</span>
),
},
{
key: "level",
header: "等级 · 做种",
hideBelow: "lg",
cell: (s) => {
const u = dataAccount(s.accounts)?.userData;
const t = u ? `${u.userLevel ?? "—"} · ${u.seeding}` : "—";
return (
<span className="block max-w-40 truncate whitespace-nowrap" title={t}>
{t}
</span>
);
},
},
{
key: "health",
header: "状态",
hideBelow: "md",
cell: (s) => {
if (!hasLogin(s)) return <span className="text-muted-foreground">—</span>;
const h = healthLine(s.lastHealth, s.lastHealthAt);
return (
<span className="whitespace-nowrap text-caption" title={healthMessage(s.lastHealth) ?? h.text}>
<StatusDot tone={h.tone} />
{h.text}
</span>
);
},
},
snapshot,
actions,
];
// 站点数量不多:分组表格不分页
return (
<DataTable
@@ -24,6 +24,7 @@ export const ADAPTER_LABEL: Record<SiteAdapter, string> = {
mteam_api: "M-Team API",
api: "站点 API",
generic_rss: "通用 RSS",
subtitle: "字幕站",
};
/** 站点类型名(IA-2);旧数据缺 kind 时按 PT 站 */
@@ -1,10 +1,18 @@
import type { SubtitleLanguage, SubtitleParsedItem, SubtitleTarget, SubtitleWriteView } from "@umt/contracts";
import { FileUp, LoaderCircle, Undo2 } from "lucide-react";
import type {
SubtitleLanguage,
SubtitleParsedItem,
SubtitleSearchResult,
SubtitleTarget,
SubtitleWriteView,
} from "@umt/contracts";
import { Download, ExternalLink, FileUp, LoaderCircle, Search, Undo2 } from "lucide-react";
import { useId, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
useApplySubtitles,
useFetchSubtitle,
useParseSubtitles,
useSearchSubtitles,
useSubtitleTargets,
useSubtitleWrites,
useUndoSubtitle,
@@ -20,7 +28,9 @@ import {
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
@@ -74,6 +84,7 @@ export function SubtitleDialog({
const [rows, setRows] = useState<Row[]>([]);
const [skipped, setSkipped] = useState<{ name: string; reason: string }[]>([]);
const [dragging, setDragging] = useState(false);
const [tab, setTab] = useState<"upload" | "online">("upload");
const list = targets.data?.targets ?? [];
const busy = parse.isPending || apply.isPending;
@@ -112,6 +123,8 @@ export function SubtitleDialog({
language: x.language,
content: x.content,
targetLocationId: x.target,
...(x.source ? { source: x.source } : {}),
...(x.sourceRef ? { sourceRef: x.sourceRef } : {}),
})),
});
if (r.written.length) toast.success(`已写入 ${r.written.length} 个字幕`);
@@ -141,41 +154,58 @@ export function SubtitleDialog({
<EmptyState title="媒体库里还没有这部作品的视频" description="整理入库后才能补字幕。" />
) : (
<div className="min-w-0 space-y-4">
<label
htmlFor={inputId}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
void load([...e.dataTransfer.files]);
}}
className={cn(
"flex cursor-pointer flex-col items-center gap-1 rounded-lg border border-dashed border-border px-4 py-6 text-center transition-colors hover:border-foreground/40",
dragging && "border-foreground/60 bg-accent",
)}
>
{parse.isPending ? <LoaderCircle className="size-5 animate-spin" /> : <FileUp className="size-5" />}
<span className="text-caption text-foreground">选择或拖入字幕文件</span>
<span className="text-meta text-muted-foreground">
支持 srt / ass / ssa / vtt / sub / sbv 和 zip,可一次选多个
</span>
<input
ref={input}
id={inputId}
type="file"
multiple
accept={ACCEPT}
className="sr-only"
onChange={(e) => {
void load([...(e.target.files ?? [])]);
if (input.current) input.current.value = "";
<Tabs value={tab} onValueChange={(v) => setTab(v as "upload" | "online")}>
<TabsList>
<TabsTrigger value="upload">上传</TabsTrigger>
<TabsTrigger value="online">在线搜索</TabsTrigger>
</TabsList>
</Tabs>
{tab === "upload" ? (
<label
htmlFor={inputId}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
void load([...e.dataTransfer.files]);
}}
className={cn(
"flex cursor-pointer flex-col items-center gap-1 rounded-lg border border-dashed border-border px-4 py-6 text-center transition-colors hover:border-foreground/40",
dragging && "border-foreground/60 bg-accent",
)}
>
{parse.isPending ? <LoaderCircle className="size-5 animate-spin" /> : <FileUp className="size-5" />}
<span className="text-caption text-foreground">选择或拖入字幕文件</span>
<span className="text-meta text-muted-foreground">
支持 srt / ass / ssa / vtt / sub / sbv 和 zip,可一次选多个
</span>
<input
ref={input}
id={inputId}
type="file"
multiple
accept={ACCEPT}
className="sr-only"
onChange={(e) => {
void load([...(e.target.files ?? [])]);
if (input.current) input.current.value = "";
}}
/>
</label>
) : (
<OnlinePanel
entityId={entityId}
seasons={[...new Set(list.map((t) => t.season).filter((x): x is number => x !== null))]}
onFetched={(items, skip) => {
setRows((prev) => [...prev, ...items.map((it) => ({ ...it, target: it.targetLocationId ?? SKIP }))]);
setSkipped((prev) => [...prev, ...skip]);
}}
/>
</label>
)}
{rows.length ? (
<ul className="space-y-2">
@@ -318,3 +348,145 @@ function History({
</section>
);
}
/** 在线搜索:已启用的字幕站(SubHD、字幕库);下载的字幕进上面同一个预览 */
function OnlinePanel({
entityId,
seasons,
onFetched,
}: {
entityId: string;
seasons: number[];
onFetched: (items: SubtitleParsedItem[], skipped: { name: string; reason: string }[]) => void;
}) {
const search = useSearchSubtitles();
const fetch = useFetchSubtitle();
const [keyword, setKeyword] = useState("");
const [season, setSeason] = useState<string>(seasons.length === 1 ? String(seasons[0]) : "all");
const [episode, setEpisode] = useState("");
const [fetching, setFetching] = useState<string | null>(null);
const data = search.data;
const run = () =>
search.mutate(
{
entityId,
...(keyword.trim() ? { keyword: keyword.trim() } : {}),
...(season !== "all" ? { season: Number(season) } : {}),
...(episode.trim() && /^\d+$/.test(episode.trim()) ? { episode: Number(episode.trim()) } : {}),
},
{ onError: (err) => toast.error(errorMessage(err, "搜索失败")) },
);
const take = async (r: SubtitleSearchResult) => {
setFetching(`${r.siteId}${r.ref}`);
try {
const p = await fetch.mutateAsync({ entityId, siteId: r.siteId, ref: r.ref, title: r.title });
if (!p.items.length) toast.error("这个字幕包里没有能用的字幕文件");
else toast.success(`已下载 ${p.items.length} 个字幕,检查下面的配对后写入`);
onFetched(p.items, p.skipped);
} catch (err) {
toast.error(errorMessage(err, "下载失败"));
} finally {
setFetching(null);
}
};
return (
<div className="min-w-0 space-y-3">
<form
className="flex flex-wrap items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
run();
}}
>
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="默认按作品名搜,可另填关键词"
aria-label="关键词"
className="min-w-48 flex-1"
/>
{seasons.length ? (
<>
<Select value={season} onValueChange={setSeason}>
<SelectTrigger aria-label="季" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="all">全部季</SelectItem>
{seasons.map((n) => (
<SelectItem key={n} value={String(n)}>
第 {n} 季
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={episode}
onChange={(e) => setEpisode(e.target.value)}
placeholder="集(可空)"
aria-label="集"
inputMode="numeric"
className="w-24"
/>
</>
) : null}
<Button type="submit" disabled={search.isPending}>
{search.isPending ? <LoaderCircle className="animate-spin" /> : <Search />}
搜索
</Button>
</form>
{data?.noSites ? (
<p className="text-caption text-muted-foreground">
还没有启用的字幕站。到「站点 → 添加站点 → 字幕站」添加 SubHD 或字幕库。
</p>
) : null}
{data?.errors.length ? (
<p className="text-meta text-warning">{data.errors.map((e) => `${e.siteName}:${e.reason}`).join(";")}</p>
) : null}
{data && !data.noSites && !data.results.length ? (
<p className="text-caption text-muted-foreground">
没搜到(用了:{data.keywords.join("、") || "无"})。可以换个关键词,或去掉季集限制再试。
</p>
) : null}
{data?.results.length ? (
<ul className="max-h-80 space-y-1.5 overflow-y-auto">
{data.results.map((r) => {
const key = `${r.siteId}${r.ref}`;
return (
<li key={key} className="flex min-w-0 items-center gap-2 rounded-lg border border-border/70 px-3 py-2">
<div className="min-w-0 flex-1">
<p className="truncate text-caption text-foreground" title={r.title}>
{r.title}
</p>
<p className="truncate text-meta text-muted-foreground">
{[
r.siteName,
r.languages.join(" / ") || null,
r.format,
r.downloads !== null ? `下载 ${r.downloads}` : null,
r.publishedAt ? (r.publishedAt.includes("T") ? relTime(r.publishedAt) : r.publishedAt) : null,
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
<Button asChild variant="ghost" size="icon-sm" title="在字幕站打开">
<a href={r.pageUrl} target="_blank" rel="noreferrer" aria-label="在字幕站打开">
<ExternalLink />
</a>
</Button>
<Button size="sm" variant="outline" disabled={fetching !== null} onClick={() => void take(r)}>
{fetching === key ? <LoaderCircle className="animate-spin" /> : <Download />}
下载
</Button>
</li>
);
})}
</ul>
) : null}
</div>
);
}
+2 -1
View File
@@ -21,11 +21,12 @@ const VIEW_KEY = "umt.sites.view";
type View = "cards" | "list";
type Filter = "all" | "enabled" | "attention";
/** 分组顺序;公开 BT 站的表只列它用得到的列 */
const GROUPS: { kind: SiteKind; title: string; variant: "pt" | "public" }[] = [
const GROUPS: { kind: SiteKind; title: string; variant: "pt" | "public" | "subtitle" }[] = [
{ kind: "pt", title: "PT 站", variant: "pt" },
{ kind: "public_bt", title: "公开 BT 站", variant: "public" },
{ kind: "rss_feed", title: "RSS 源", variant: "public" },
{ kind: "torznab", title: "索引器", variant: "public" },
{ kind: "subtitle", title: "字幕站", variant: "subtitle" },
];
const FILTERS: { value: Filter; label: string }[] = [
+2
View File
@@ -37,10 +37,12 @@
"jpeg-js": "npm:[email protected]",
"kysely": "npm:[email protected]",
"kysely/": "npm:/[email protected]/",
"node-unrar-js": "npm:[email protected]",
"nunjucks": "npm:[email protected]",
"openworkflow": "npm:[email protected]",
"openworkflow/": "npm:/[email protected]/",
"pg": "npm:[email protected]",
"7z-wasm": "npm:[email protected]",
"pino": "npm:[email protected]",
"pino-pretty": "npm:[email protected]",
"playwright-core": "npm:[email protected]",
Generated
+17
View File
@@ -3,6 +3,7 @@
"specifiers": {
"jsr:@std/internal@^1.0.14": "1.0.14",
"jsr:@std/path@1": "1.1.6",
"npm:[email protected]": "1.2.0",
"npm:@babel/[email protected]": "7.29.9",
"npm:@biomejs/biome@*": "2.5.15",
"npm:@biomejs/[email protected]": "2.5.15",
@@ -38,6 +39,7 @@
"npm:[email protected]": "0.29.6",
"npm:lucide-react@^1.49.0": "1.49.0_@[email protected][email protected]",
"npm:next-themes@~0.4.6": "[email protected][email protected]",
"npm:[email protected]": "2.0.2",
"npm:[email protected]": "3.2.4",
"npm:[email protected]": "[email protected]",
"npm:[email protected]": "8.23.1",
@@ -81,6 +83,13 @@
}
},
"npm": {
"[email protected]": {
"integrity": "sha512-AZjtWXFleAe5nQ27JYISs70cICjO0zZr4LU45I9pXKcrhG0qiEWgq3Vjwr9JHHihFLuqgc7uSHYVn/z3vXCjdw==",
"dependencies": [
"readline-sync"
],
"bin": true
},
"@anthropic-ai/[email protected][email protected]": {
"integrity": "sha512-cN5O8i9UVxHeOQAzj/XjshWXG8KiibJDw9OGpH2Z/eR3n/RBxdoLxDJOcfqAJWvjaMDFfHTBADU04hWRJVkDyA==",
"dependencies": [
@@ -3617,6 +3626,9 @@
"[email protected]": {
"integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw=="
},
"[email protected]": {
"integrity": "sha512-hLNmoJzqaKJnod8yiTVGe9hnlNRHotUi0CreSv/8HtfRi/3JnRC8DvsmKfeGGguRjTEulhZK6zXX5PXoVuDZ2w=="
},
"[email protected]": {
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"dependencies": [
@@ -4107,6 +4119,9 @@
"[email protected]": {
"integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="
},
"[email protected]": {
"integrity": "sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw=="
},
"[email protected]": {
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="
},
@@ -4625,6 +4640,7 @@
},
"workspace": {
"dependencies": [
"npm:[email protected]",
"npm:@biomejs/[email protected]",
"npm:@earendil-works/[email protected]",
"npm:@earendil-works/[email protected]",
@@ -4643,6 +4659,7 @@
"npm:[email protected]",
"npm:[email protected]",
"npm:[email protected]",
"npm:[email protected]",
"npm:[email protected]",
"npm:[email protected]",
"npm:[email protected]",
+2 -1
View File
@@ -31,7 +31,8 @@
### 进行中
- 补字幕:[subtitles.md](subtitles.md),第 1 步(手动上传)已完成,下一步字幕站(SubHD、字幕库)在线搜索。
- 补字幕:[subtitles.md](subtitles.md),第 1、2 步已完成;待办:SubHD 隔天复测下载、站点验证码失败时接智能体视觉兜底、以后接进整理 / 订阅工作流。
- 沙箱第二批:站点网页、RSS、网页抓取的 HTML / XML 解析迁入常驻子进程池(ADR-005)。
### 业务(需要真实环境或决定)
+4 -2
View File
@@ -32,5 +32,7 @@
## 分步
1. 手动上传:解析、配对预览、写入、历史与撤销、作品页入口。(v0.3.22 完成)
2. 字幕站类型 + SubHD / 字幕库搜索下载(第 ① 层验证码),站点页与添加站点。
3. 验证码第 ② ③ 层兜底(浏览器、智能体)。
2. 字幕站类型 + SubHD / 字幕库搜索下载(第 ① 层验证码),站点页与添加站点;rar / 7z。(v0.3.24 完成;解压、页面解析、验证码都在沙箱里,见 ADR-005)
3. 验证码第 ② ③ 层兜底:Cloudflare 挑战已由站点出口自动走浏览器;站点自己的验证码(云锁 / SubHD)识别失败时,目前提示去原站视图手动处理,智能体视觉处理待接。
实测记录(2026-10-03):字幕库搜索、验证码、下载、解压、配对全链路通;SubHD 搜索通,下载流程与单独实测一致,但当晚测试请求较多后 SubHD 对本机限流(直连与代理都超时),需要隔天复测。
+16
View File
@@ -28,6 +28,22 @@ export const subtitlesApi = {
body: subtitles.SubtitleApplyRequest,
response: subtitles.SubtitleApplyResult,
}),
search: ep({
method: "post",
path: "/subtitles/search",
scope: "read",
summary: "在已启用的字幕站(SubHD、字幕库)搜这部作品的字幕;按季集、语言、下载量排序",
body: subtitles.SubtitleSearchRequest,
response: subtitles.SubtitleSearchResponse,
}),
fetch: ep({
method: "post",
path: "/subtitles/fetch",
scope: "read",
summary: "下载一条在线字幕并解析(同上传:解压、转 UTF-8、语言与季集、配对建议;不写文件)",
body: subtitles.SubtitleFetchRequest,
response: subtitles.SubtitleParseResponse,
}),
writes: ep({
method: "get",
path: "/subtitles/writes",
+16 -2
View File
@@ -6,7 +6,7 @@ import { QuickFilterParam, ReleaseQuickFacets } from "./release-facets.ts";
// 含 passkey 的 Feed URL 只以 urlSecretRef 保存,对外只给脱敏的 urlDisplay。
/** api = MP 专用 API spider(Haidan/HDDolby/RousiPro/SunnyPT/TNode/TorrentLeech/Yema) */
export const SiteAdapter = z.enum(["nexusphp", "mteam_api", "api", "generic_rss"]);
export const SiteAdapter = z.enum(["nexusphp", "mteam_api", "api", "generic_rss", "subtitle"]);
export type SiteAdapter = z.infer<typeof SiteAdapter>;
/**
@@ -14,7 +14,7 @@ export type SiteAdapter = z.infer<typeof SiteAdapter>;
* 站点实际可用的方式 = 类型允许的 ∩ 适配器(站点定义)实现的,见 siteCapabilities。
* torznab 只定义结构(Jackett / Prowlarr 的 Torznab 接口),尚未实现:不能添加,也不参与调度。
*/
export const SiteKind = z.enum(["pt", "public_bt", "rss_feed", "torznab"]);
export const SiteKind = z.enum(["pt", "public_bt", "rss_feed", "torznab", "subtitle"]);
export type SiteKind = z.infer<typeof SiteKind>;
/** 获取方式:搜索 / 列表抓取 / RSS / 详情页 / 登录 / 账号数据(上传量、分享率、等级) */
export const SiteCapability = z.enum(["search", "list", "rss", "detail", "login", "userinfo"]);
@@ -62,6 +62,13 @@ export const SITE_KINDS: Record<SiteKind, SiteKindSpec> = {
implemented: false,
fromDefinition: false,
},
subtitle: {
label: "字幕站",
description: "给媒体库里的作品搜字幕、下载字幕:SubHD、字幕库",
capabilities: [],
implemented: true,
fromDefinition: false,
},
};
/** 站点定义(适配器)声明的能力;没有定义的站点传 null */
@@ -71,6 +78,13 @@ export type SiteDefinitionCaps = { search: boolean; list: boolean; detail: boole
* 站点实际可用的获取方式:类型允许的 ∩ 适配器实现的。RSS 走通用解析器,不依赖站点定义;
* 搜索 / 列表 / 详情要站点定义声明支持;登录与账号数据要有站点定义(没有定义就没有登录流程)。
*/
/** 内置字幕站(docs/plan/subtitles.md):站点页「字幕站」一键添加,搜索与下载在 subtitles 模块 */
export const SUBTITLE_SITES = [
{ key: "subhd", name: "SubHD", baseUrl: "https://subhd.tv/" },
{ key: "zimuku", name: "字幕库", baseUrl: "https://zmk.pw/" },
] as const;
export type SubtitleSiteKey = (typeof SUBTITLE_SITES)[number]["key"];
export function siteCapabilities(kind: SiteKind, def: SiteDefinitionCaps): SiteCapability[] {
const spec = SITE_KINDS[kind];
if (!spec.implemented) return [];
+55
View File
@@ -69,6 +69,9 @@ export const SubtitleParsedItem = z.object({
content: z.string(),
/** 建议的视频位置 */
targetLocationId: Id.nullable(),
/** 在线下载的:字幕站与条目地址(写入时记进历史) */
source: SubtitleSource.optional(),
sourceRef: z.string().optional(),
});
export type SubtitleParsedItem = z.infer<typeof SubtitleParsedItem>;
@@ -91,6 +94,8 @@ export const SubtitleApplyRequest = z.object({
targetLocationId: Id,
/** 在线来源的条目地址 */
sourceRef: z.string().max(1000).optional(),
/** 这一项的来源(不给用整批的 source) */
source: SubtitleSource.optional(),
}),
)
.min(1)
@@ -140,3 +145,53 @@ export const SubtitleWriteRow = z.object({
export const SubtitleWritesQuery = z.object({ entityId: Id });
export const SubtitleWritesResponse = z.object({ items: z.array(SubtitleWriteView) });
// ── 在线(字幕站:SubHD、字幕库) ──
export const SubtitleSearchRequest = z.object({
entityId: Id,
/** 剧集:只要这一季(集)的字幕;不给 = 全部 */
season: z.number().int().min(0).optional(),
episode: z.number().int().min(0).optional(),
/** 额外的搜索词(作品名搜不到时手动填) */
keyword: z.string().trim().max(100).optional(),
});
export type SubtitleSearchRequest = z.infer<typeof SubtitleSearchRequest>;
export const SubtitleSearchResult = z.object({
siteId: Id,
siteKey: z.string(),
siteName: z.string(),
/** 站内引用(下载时原样提交) */
ref: z.string(),
title: z.string(),
pageUrl: z.string(),
languages: z.array(z.string()),
format: z.string().nullable(),
downloads: z.number().int().nullable(),
uploader: z.string().nullable(),
publishedAt: z.string().nullable(),
season: z.number().int().nullable(),
episode: z.number().int().nullable(),
score: z.number(),
});
export type SubtitleSearchResult = z.infer<typeof SubtitleSearchResult>;
export const SubtitleSearchResponse = z.object({
results: z.array(SubtitleSearchResult),
/** 用到的搜索词 */
keywords: z.array(z.string()),
/** 各站出错 / 需要人工处理 */
errors: z.array(z.object({ siteName: z.string(), reason: z.string() })),
/** 没有启用的字幕站 */
noSites: z.boolean(),
});
export type SubtitleSearchResponse = z.infer<typeof SubtitleSearchResponse>;
export const SubtitleFetchRequest = z.object({
entityId: Id,
siteId: Id,
ref: z.string().min(1).max(500),
title: z.string().max(500).default(""),
});
export type SubtitleFetchRequest = z.input<typeof SubtitleFetchRequest>;
@@ -0,0 +1,31 @@
import type { Kysely } from "kysely";
import { execDdl, oneOf } from "../src/migration-helpers.ts";
// 字幕站(docs/plan/subtitles.md 第 2 步):站点类型 subtitle、适配器 subtitle(SubHD、字幕库),
// 复用站点的代理 / 限流 / 健康 / 浏览器会话;不参与资源搜索、快照、RSS。
const KINDS = ["pt", "public_bt", "rss_feed", "torznab", "subtitle"];
const ADAPTERS = ["nexusphp", "mteam_api", "api", "generic_rss", "subtitle"];
const OLD_KINDS = ["pt", "public_bt", "rss_feed", "torznab"];
const OLD_ADAPTERS = ["nexusphp", "mteam_api", "api", "generic_rss"];
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`alter table site drop constraint if exists site_kind_check;
alter table site add constraint site_kind_check ${oneOf("kind", KINDS)};
alter table site drop constraint if exists site_adapter_check;
alter table site add constraint site_adapter_check ${oneOf("adapter", ADAPTERS)};`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`delete from site where kind = 'subtitle';
alter table site drop constraint if exists site_kind_check;
alter table site add constraint site_kind_check ${oneOf("kind", OLD_KINDS)};
alter table site drop constraint if exists site_adapter_check;
alter table site add constraint site_adapter_check ${oneOf("adapter", OLD_ADAPTERS)};`,
);
}
+2 -2
View File
@@ -241,10 +241,10 @@ export type SecretPurpose =
| "other";
export type ServerMappingItemType = "movie" | "series" | "season" | "episode" | "other";
export type ServerMappingMatchStatus = "matched" | "ambiguous" | "unmatched" | "stale";
export type SiteAdapter = "nexusphp" | "mteam_api" | "api" | "generic_rss";
export type SiteAdapter = "nexusphp" | "mteam_api" | "api" | "generic_rss" | "subtitle";
export type SiteHealthStatus = "unknown" | "healthy" | "degraded" | "failing" | "disabled";
export type SiteCircuitState = "closed" | "open" | "half_open";
export type SiteKind = "pt" | "public_bt" | "rss_feed" | "torznab";
export type SiteKind = "pt" | "public_bt" | "rss_feed" | "torznab" | "subtitle";
export type SiteAccountAuthKind = "cookie" | "passkey" | "api_key" | "password";
export type SiteAccountStatus = "unknown" | "active" | "invalid" | "expired" | "challenge";
export type SiteSnapshotKind = "periodic" | "backfill" | "live_search" | "manual";
+1
View File
@@ -6,4 +6,5 @@ export * from "./fs/index.ts";
export * from "./qbittorrent/index.ts";
export * from "./rss/index.ts";
export * from "./sites/index.ts";
export * from "./subtitles.ts";
export * from "./tmdb/index.ts";
+32
View File
@@ -0,0 +1,32 @@
/**
* 补字幕 / 沙箱的二进制样本:
* - 字幕库验证码:2026-10-03 从 zmk.pw 取得,识别结果经站点接受;
* - RAR5:rarfile 4.5 测试文件 rar5-solid.rar(ISC 许可,Copyright (c) 2005-2024 Marko Kreen),内含 stest1.txt / stest2.txt;
* - 7z:本机 7z 生成,内含 s7/a.chs.srt(「你好」一条)。
*/
export const ZIMUKU_CAPTCHA = {
bmpBase64:
"Qk3aHwAAAAAAADYAAAAoAAAAZAAAABsAAAABABgAAAAAAKQfAAAAAQAAAAEAAAAAAAAAAAAA3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS29vbrrSupqymtry2rrSuyMjI5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubmrrSupqymtry2rrSupqymtry2rrSupqym5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzct7q3try20tPS0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm0dLRpqymtry2rrSupqym29vb3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzczc3Ntry2rrSupqymtry2zM3M0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS3Nzc0tLS5ubm3Nzc0tLS5ubm1dbVepV6AJkAAJkAAJkAAJkAgJqAzMzM5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLSAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkA0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubmV49XAJkAuL643Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3NzczMzMhqCGAJkAAJkAAJkAAJkAepV639/f3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubmkqOSAJkAAJkAAJkAAJkAc5lzpa6lzMzM5ubm3Nzc0tLS5ubm3Nzc5ubm3Nzc0tLS5ubm3Nzc0tLSzM7MAJkAFJEUQ49DQIxAFJEUAJkAip+K0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3NzcNoo2AJkAFZIVPYo9Q49DQIxAPYo9Q49D3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLSW5NbAJkAqK6o5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubmip+KAJkAFpMWQIxAPYo9FpMWAJkAhJmE5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLSB5YHQIxAPYo9Q49DQIxAFJEUAJkAw8XD0tLS5ubm3Nzc0tLS5ubm0tLS5ubm3Nzc0tLS5ubm3NzcfJV8EZQReph60tLS5ubmeph6D5IPAJkA3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubmy8zLNoo2M48zs7iz0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLSmaqZkqOSi5yLmaqZkqOSN4o3AJkAdZZ1i5yL5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLSAJkAEJMQdJJ05ubm3NzcdJJ0EZQRAJkA0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3NzcVYtV5ubm3Nzc0tLS5ubmeph6D5IPiKGI3Nzc0tLS5ubm3Nzc0tLS3Nzc0tLS5ubm3Nzc0tLS5ubmAJkALosu5ubm3Nzc0tLS5ubmMI0wAJkA5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubmvcC9NIo0P48/t7u30tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3NzcAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkA0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3NzcAJkAM48z3Nzc0tLS5ubm3NzcLosuAJkA3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubmMI0wAJkA5ubm3Nzc0tLS5ubm3Nzc5ubm3Nzc0tLS5ubm3Nzc0tLSAJkAMI0w0tLS5ubm3Nzc0tLSM48zAJkA0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubmsbexMIowSpBKvcC90tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubmAJkAEZIRX5NfW49bV4tXJpAmAJkARYpFX5Nf3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubmAJkALosu5ubm3Nzc0tLS5ubmMI0wAJkA5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLSM48zAJkA0tLS5ubm3Nzc0tLS5ubm0tLS5ubm3Nzc0tLS5ubm3NzcAJkAG5IbmKeY0tLS5ubmmKeYGY8ZAJkA3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubmp6+nKosqU5FTxcfF0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLSXpNeF5EXjp6O5ubm3NzcU4tTAJkAsLaw0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLSXZNdGpAakaCR5ubm3NzckaCRG5IbWY9Z0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3NzcLosuAJkA3Nzc0tLS5ubm3Nzc0tLS3Nzc0tLS5ubm3Nzc0tLS5ubmAJkAAJkAJpAmbpRuaY9pJpAmAJkAXYxd5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubmnqLine truncated
code: "62485",
};
export const RAR5_SAMPLE_BASE64 =
"UmFyIRoHAQAJ78hvCwEFBwQGAQGAgIAA3vVwchwCAroABIAQtoMCoua3xYAbAQpzdGVzdDEudHh0waw3REQj+iP2l/1iU1G+TJGBQQKMwQN5XL9Ho6otoDaT3aBYAEACANRQBHb2xH8y6p8ypcTM14PfABDoLYkcAgKNAASAELaDAqLmt8XAGwEKc3Rlc3QyLnR4dEUVCmABAAgD37f79vwdd1ZRAwUEAA==";
export const SEVENZIP_SUBTITLE_BASE64 =
"N3q8ryccAARZ0i3uJwAAAAAAAABiAAAAAAAAACUGHgPgACYAH10AGIKCXAxmLN3AjXSfYB8suiGnGQuFuRrPg2a0pjCUAAABBAYAAQknAAcLAQABISEBAAwnAAgKAXGBapIAAAUBGQwAAAAAAAAAAAAAAAARGwBzADcALwBhAC4AYwBoAHMALgBzAHIAdAAAABkAFAoBAPAj1DaXUt0BFQYBACCApIEAAA==";
// ── 字幕站页面(2026-10-03 实测截取,只留解析用到的片段) ──
export const SUBHD_DETAIL_PAGE =
'<html><body><div class="float-end">\n <a class="btn btn-sm f11 btn-outline-secondary" href="/upload?d=37068446">上传</a>\n \n <div class="btn-group ms-1">\n <button class="btn btn-sm f11 btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">\n 默认排序\n </button>\n <ul class="dropdown-menu dropdown-menu-end f12">\n <li><a class="dropdown-item active" href="/d/37068446">默认排序</a></li>\n <li><a class="dropdown-item " href="/d/37068446?order=time">按时间</a></li>\n </ul>\n </div>\n \n </div>\n </div>\n\n <div class="bg-white shadow-sm rounded-3 mb-5">\n <div class="rounded-top bg-purple">\n <div class="row">\n <div class="col-8">\n <div class="px-3 py-2">字幕信息</div>\n </div>\n <div class="col-2 d-none d-lg-block">\n <div class="px-3 py-2 text-end">下载次数</div>\n </div>\n <div class="col-2 d-none d-lg-block">\n <div class="px-3 py-2 text-end">上传人</div>\n </div>\n </div>\n </div>\n\n \n \n <div class="row pt-2 mb-2">\n <div class="col-12 col-lg-8">\n <div class="px-3 py-2">\n <div class="view-text">\n <a class="link-dark" href="/a/Fp90EG">生化危机:爆发夜 Resident.Evil.2026.1080p.TELESYNC.MULTi.x264-DKS-GPT 6.0 V4</a>\n \n </div>\n <div class="pt-1 f11">\n <span class="rounded p-1 me-1 text-white" style="background-color:#ae3ec9!important">AI校对</span>\n \n <span class="p-1 fw-bold">简体</span>\n <span class="p-1 text-secondary">SRT</span>\n <span class="p-1 text-primary"><svg class="bi bi-chat align-baseline me-1" xmlns="http://www.w3.org/2000/svg" width="11" height="11" fill="currentColor" viewBox="0 0 16 16"><path d="M2.678 11.894a1 1 0 0 1 .287.801 10.97 10.97 0 0 1-.398 2c1.395-.323 2.247-.697 2.634-.893a1 1 0 0 1 .71-.074A8.06 8.06 0 0 0 8 14c3.996 0 7-2.807 7-6 0-3.192-3.004-6-7-6S1 4.808 1 8c0 1.468.617 2.83 1.678 3.894zm-.493 3.905a21.682 21.682 0 0 1-.713.129c-.2.032-.352-.176-.273-.362a9.68 9.68 0 0 0 .244-.637l.003-.01c.248-.72.45-1.548.524-2.319C.743 11.37 0 9.76 0 8c0-3.866 3.582-7 8-7s8 3.134 8 7-3.582 7-8 7a9.06 9.06 0 0 1-2.347-.306c-.52.263-1.639.742-3.468 1.105z"/></svg>2</span>\n \n <span class="p-1 text-danger">\n <svg class="align-top" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14px" height="14px" fill="currentColor"><path d="M3,11h3v10H3V11z M18.77,11h-4.23l1.52-4.94C16.38,5.03,15.54,4,14.38,4c-0.58,0-1.14,0.24-1.52,0.65L7,11v10h10.43 c1.06,0,1.98-0.67,2.19-1.61l1.34-6C21.23,12.15,20.18,11,18.77,11z"/></svg>\n <span class="align-baseline">1</span>\n </span>\n \n \n </div>\n </div>\n </div>\n <div class="col-2 d-none d-lg-block">\n <div class="px-3 py-2 text-end text-secondary">4165</div>\n </div>\n <div class="col-2 d-none d-lg-block">\n <div class="px-3 py-2 text-end">\n <span class="fw-bold me-1">\n <a class="link-dark" href="/u/彬冷的心">彬冷的心</a>\n </span>\n <div class="text-black-50"><time datetime="2026-09-19T08:53:03.000Z" data-local-time="relative">09-19 08:53</time></div>\n </div>\n </div>\n </div>\n <hr class="my-0">\n \n \n <div class="row pt-2 mb-2">\n <div class="col-12 col-lg-8">\n <div class="px-3 py-2">\n <div class="view-text">\n <a class="link-dark" href="/a/6XeICg">惡靈古堡:爆發夜 Resident.Evil.2026.TS-DKS(clean).1080p【ChatGPT 台灣用語】V2</a>\n \n </div>\n <div class="pt-1 f11">\n <span class="rounded p-1 me-1 text-white" style="background-color:#ae3ec9!important">AI校对</span>\n \n <span class="p-1 fw-bold">繁体</span>\n <span class="p-1 text-secondary">SRT</span>\n <span class="p-1 text-primary"><svg class="bi bi-chat align-baseline me-1" xmlns="http://www.w3.org/2 Line truncated
export const SUBHD_SUB_PAGE =
'<html><body>ton>\n </div>\n </div>\n </div>\n\n\n <div class="mb-3 d-flex align-items-center justify-content-between gap-2 subtitle-detail-actions">\n <div class="d-flex align-items-center flex-wrap gap-2 subtitle-download-action">\n <button type="button"\n class="btn btn-danger down subtitle-prepare-download"\n data-sid="Fp90EG"\n data-loading-text="处理中…"\n data-error-text="准备下载失败,请稍后再试。">\n <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="bi bi-arrow-down-circle" viewBox="0 0 16 16">\n <path fill-rule="evenodd" d="M1 8a7 7 0 1 0 14 0A7 7 0 0 0 1 8zm15 0A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM8.5 4.5a.5.</body></html>';
export const ZIMUKU_SEARCH_PAGE =
'<html><body><div class="tbhd clearfix">\n<ul class="l tb">\n<li class="this"><a href="/search?q=%E8%91%AC%E9%80%81%E7%9A%84%E8%8A%99%E8%8E%89%E8%8E%B2&chost=zimuku.org"><strong>综合搜索</strong></a></li>\n<li ><a href="/search?q=%E8%91%AC%E9%80%81%E7%9A%84%E8%8A%99%E8%8E%89%E8%8E%B2&t=onlyst&chost=zimuku.org"><strong>仅射手老字幕 </strong></a></li>\n</ul>\n<div class="pagination r clearfix">\n <div> <span class="rows">共 2 条记录</span></div> </div>\n</div>\n \n<div class="item prel clearfix">\n<div class="litpic hidden-xs"><a href="//zimuku.org/subs/69877.html" target="_blank"><img src="/images/v2/no_litpic.gif" class="lazy" data-original="/Uploads/Picture/litpic/2024/05Aug2024181110.jpg" alt="葬送的芙莉莲 葬送のフリーレン 字幕下载"/></a></div>\n<div class="title"><p class="tt clearfix"><a href="//zimuku.org/subs/69877.html" target="_blank"><b>葬送的芙莉莲 葬送のフリーレン (2023)</b></a></p>\n<p>又名:<a href="//zimuku.org/subs/69877.html" target="_blank">Frieren: Beyond Journey\'s End</a></p>\n<div class="sublist">\n<table class="table tborder2">\n<tbody>\n<tr class="odd">\n<td class="first">\n<img class="tooltips" data-toggle="tooltip" data-placement="top" alt="&nbsp;简体中文字幕" title="&nbsp;简体中文字幕" border="0" src="/images/v2/flag/china.gif" style="padding-bottom: 4px">\n<a href="//zimuku.org/detail/234732.html" target="_blank" title="Sousou no Frieren.zip"><b>Sousou no Frieren.zip</b></a>\n<span class="label label-info">SRT</span>&nbsp;</td>\n<td class="tac"><div class="hidden-xs"><i class="rating-star allstar00 tooltips" data-toggle="tooltip" data-placement="top" title="字幕质量:0分"></i></div></td>\n<td class="last"><div class="hidden-xs"><span class="glyphicon glyphicon-save"></span>2781</div></td>\n</tr><tr class="even">\n<td class="first">\n<img class="tooltips" data-toggle="tooltip" data-placement="top" alt="&nbsp;简体中文字幕&nbsp;繁體中文字幕&nbsp;双语字幕&nbsp;日语字幕" title="&nbsp;简体中文字幕&nbsp;繁體中文字幕&nbsp;双语字幕&nbsp;日语字幕" border="0" src="/images/v2/flag/jollyroger.gif" style="padding-bottom: 4px">\n<a href="//zimuku.org/detail/203404.html" target="_blank" title="[DBD-Raws][葬送的芙莉莲][01-28TV全集+特典映像][1080P][BDRip][HEVC-10bit][简繁日双语字幕][FLAC][MKV]"><b>[DBD-Raws][葬送的芙莉莲][01-28TV全集+特典映像][1080P][BDRip][HEVC-10bit][简繁日双语字幕][FLAC][MKV]</b></a>\n<span class="label label-info">ASS/SSA</span>&nbsp;</td>\n<td class="tac"><div class="hidden-xs"><i class="rating-star allstar50 tooltips" data-toggle="tooltip" data-placement="top" title="字幕质量:10分"></i></div></td>\n<td class="last"><div class="hidden-xs"><span class="glyphicon glyphicon-save"></span>1.7万</div></td>\n</tr></tbody>\n</table>\n</div>\n</div>\n\n\n</div><div class="item prel clearfix">\n<div class="litpic hidden-xs"><a href="//zimuku.org/subs/76121.html" target="_blank"><img src="/images/v2/no_litpic.gif" class="lazy" data-original="/Uploads/Picture/litpic/2026/01Apr2026102159.jpg" alt="葬送的芙莉莲 第二季 葬送のフリーレン 第2期 字幕下载"/></a></div>\n<div class="title"><p class="tt clearfix"><a href="//zimuku.org/subs/76121.html" target="_blank"><b>葬送的芙莉莲 第二季 葬送のフリーレン 第2期 (2026)</b></a></p>\n<p>又名:<a href="//zimuku.org/subs/76121.html" target="_blank">Frieren: Beyond Journey\'s End Season 2</a></p>\n<div class="sublist">\n<table class="table tborder2">\n<tbody>\n<tr class="odd">\n<td class="first">\n<img class="tooltips" data-toggle="tooltip" data-placement="top" alt="&nbsp;简体中文字幕&nbsp;双语字幕&nbsp;日语字幕" title="&nbsp;简体中文字幕&nbsp;双语字幕&nbsp;日语字幕" border="0" src="/images/v2/flag/jollyroger.gif" style="padding-bottom: 4px">\n<a href="//zimuku.org/detail/235222.html" target="_blank" title="葬送的芙莉莲第二季"><b>葬送的芙莉莲第二季</b></a>\n<span class="label label-info">ASS/SSA</span>&nbsp;</td>\n<td class="tac"><div class="hidden-xs"><i class="rating-star allstar00 tooltips" data-toggle="tooltip" data-placement="top" title="字幕质量:0分"></i></div></body></html>';
export const ZIMUKU_SUBS_PAGE =
'<html><body><table class="table" id="subtb">\n<thead>\n<tr>\n<th class="clr"></th><th class="first">字幕</th>\n<th class="tac lang">语言</th>\n<th class="tac hidden-xs">质量</th>\n<th class="tac hidden-xs">下载</th>\n<th class="last hidden-xs">上传/时间</th>\n</tr>\n</thead>\n<tbody>\n<tr class="odd" >\n<td class="clr clr28"></td>\n<td class="first">\n<a href="/detail/234732.html" target="_blank" title="Sousou no Frieren.zip"><b>Sousou no Frieren.zip</b></a>\n<span class="label label-info">SRT</span>&nbsp;<br>\n<span class="gray"><em>制作:</em>见字幕文件</span>\n<span class="gray"><em>校订:</em>见字幕文件</span>\n<span class="gray"><em>来源:</em><span class="label label-danger">个人原创翻译</span></span>\n</td>\n<td class="tac lang"><img border="0" src="/images/v2/flag/china.gif" alt="简体中文字幕" class="tooltips" data-toggle="tooltip" data-placement="top" title="简体中文字幕" style="padding-bottom: 2px"></td>\n<td class="tac hidden-xs"><i class="rating-star allstar00 tooltips" data-toggle="tooltip" data-placement="top" title="字幕质量:0分"></i></td>\n<td class="tac hidden-xs">2781</td>\n<td class="last hidden-xs">\n<span class="glyphicon glyphicon-user"></span> <a href="/u/A3pbKZkw0">blade0701</a>\n<br>\n<span class="glyphicon glyphicon-time"></span> 8月11日</td>\n</tr><tr class="even" >\n<td class="clr clr28"></td>\n<td class="first">\n<a href="/detail/203404.html" target="_blank" title="[DBD-Raws][葬送的芙莉莲][01-28TV全集+特典映像][1080P][BDRip][HEVC-10bit][简繁日双语字幕][FLAC][MKV]"><b>[DBD-Raws][葬送的芙莉莲][01-28TV全集+特典映像][1080P][BDRip][HEVC-10bit][简繁日双语字幕][FLAC][MKV]</b></a>\n<span class="label label-info">ASS/SSA</span>&nbsp;<br>\n<span class="gray"><em>制作:</em>喵萌奶茶屋</span>\n<span class="gray"><em>校订:</em>DBD制作组</span>\n<span class="gray"><em>来源:</em><span class="">转载/未知/其他</span></span>\n</td>\n<td class="tac lang"><img border="0" src="/images/v2/flag/china.gif" alt="简体中文字幕" class="tooltips" data-toggle="tooltip" data-placement="top" title="简体中文字幕" style="padding-bottom: 2px"><img border="0" src="/images/v2/flag/hongkong.gif" alt="繁體中文字幕" class="tooltips" data-toggle="tooltip" data-placement="top" title="繁體中文字幕" style="padding-bottom: 2px"><img border="0" src="/images/v2/flag/jollyroger.gif" alt="双语字幕" class="tooltips" data-toggle="tooltip" data-placement="top" title="双语字幕" style="padding-bottom: 2px"><img border="0" src="/images/v2/flag/japan.gif" alt="日语字幕" class="tooltips" data-toggle="tooltip" data-placement="top" title="日语字幕" style="padding-bottom: 2px"></td>\n<td class="tac hidden-xs"><i class="rating-star allstar50 tooltips" data-toggle="tooltip" data-placement="top" title="字幕质量:10分"></i></td>\n<td class="tac hidden-xs">1.7万</td>\n<td class="last hidden-xs">\n<span class="glyphicon glyphicon-user"></span> <a href="/u/A34xQmde0">dongzige98</a>\n<br>\n<span class="glyphicon glyphicon-time"></span> 24/8/4</td>\n</tr>\n</tbody>\n</table></body></html>';
export const ZIMUKU_DETAIL_PAGE =
'<html><body><ul><li class="li dlsub">\n<div class="clearfix"><a id="down1" href="/dld/203404.html" target="_blank" rel="nofollow"><span class="dl"><i class="glyphicon glyphicon-save"></i> 下载字幕 |<small style="padding-left:3px">1.13MB</small></span></a>\n<span style="padding:0 0 0 10px"><font color="#ca6445">所有从字幕库下载字幕的人均需同意:</font><br/><font color="#ca6445">仅作为个人学习、研究,不得用于商业用途</font><br/><font color="#ca6445">任何情况均不得去掉字幕文件相关制作人信息,请尊重他人劳动成果</font></span>\n</div>\n\n</li></ul></body></html>';
export const ZIMUKU_DL_PAGE =
'<!DOCTYPE html>\n<html lang="zh-CN">\n<head>\n<meta charset="utf-8">\n<meta http-equiv="Cache-Control" content="no-transform" />\n<meta http-equiv="X-UA-Compatible" content="IE=edge">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n<meta name="keywords" content="" />\n<meta name="description" content="" />\n<title>字幕高速下载页 - 字幕库</title>\n<!-- 最新版本的 Bootstrap 核心 CSS 文件 -->\n<link rel="stylesheet" href="/images/v2/css/bootstrap.min.css">\n<style>\n.clearfix{zoom:1}.clearfix:after{content:".";display:block;height:0;clear:both;visibility:hidden}\n.down{margin:20px 0;}\n.down ul,h4{padding-left:15px;margin-left:0;}\n.down li{list-style: none;padding:3px 15px 2px 0;float:left; }\n </style>\n\n</head>\n<body>\n<main class="main" role="main">\n <div class="container">\n <div class="row">\n\n <div class="col-xs-12 col-sm-12">\n <table style="border:1px solid #dcdcdc; margin:10% auto 0;">\n <tr>\n <td width="400px" align="left" >\n \n\n \n <div class="down clearfix">\n <ul>\n \n <li><a rel="nofollow" href="/download/MjAzNDA0fDIyN2VkNzg0NWU5YTIwNmFlMDhiMzdiM3wxNzkwOTYzMTE2fGRkMWNiZGI1fHJlbW90ZQ%3D%3D/svr/d0" class="btn btn-danger btn-sm"><span class="glyphicon glyphicon-save icon_size"></span> 电信高速下载(一)</a></li>\n <li><a rel="nofollow" href="/download/MjAzNDA0fDIyN2VkNzg0NWU5YTIwNmFlMDhiMzdiM3wxNzkwOTYzMTE2fGRkMWNiZGI1fHJlbW90ZQ%3D%3D/svr/d1" class="btn btn-danger btn-sm"><span class="glyphicon glyphicon-save icon_size"></span> 电信高速下载(二)</a></li>\n <li><a rel="nofollow" href="/download/MjAzNDA0fDIyN2VkNzg0NWU5YTIwNmFlMDhiMzdiM3wxNzkwOTYzMTE2fGRkMWNiZGI1fGxvY2Fs/svr/l0" class="btn btn-default btn-sm"><span class="glyphicon glyphicon-save icon_size"></span> 联通高速下载(新)</a></li>\n <li><a rel="nofollow" href="/download/MjAzNDA0fDIyN2VkNzg0NWU5YTIwNmFlMDhiMzdiM3wxNzkwOTYzMTE2fGRkMWNiZGI1fGxvY2Fs/svr/l1" class="btn btn-default btn-sm"><span class="glyphicon glyphicon-save icon_size"></span> 联通高速下载(新)</a></li>\n <li><a rel="nofollow" href="/download/MjAzNDA0fDIyN2VkNzg0NWU5YTIwNmFlMDhiMzdiM3wxNzkwOTYzMTE2fGRkMWNiZGI1fGxvY2Fs/svr/y0" class="btn btn-default btn-sm"><span class="glyphicon glyphicon-save icon_size"></span> 移动高速下载(新)</a></li>\n\n \n\n \n \n \n </ul>\n </div>\n \n</td>\n<td width="300px" height="250px" align="center">\n\n</td>\n\n</tr>\n</table>\n<div style="text-align:center;margin:15px auto;">\n\n</div> \t\n \t\n \n </div> \n </div>\n </div> \n</main>\n \n</body>\n</html>';
/** 云锁验证码页(BMP 换成 ZIMUKU_CAPTCHA 里那张,答案已知) */
export const ZIMUKU_CAPTCHA_PAGE =
'<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /><meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate, post-check=0, pre-check=0"/><meta http-equiv="Connection" content="Close" /><script type="text/javascript">function EnterJump(){ if (event.keyCode == 13) {YunsuoAutoJump(); } }function stringToHex(str){var val="";for(var i = 0; i < str.length; i++){if(val == "")val = str.charCodeAt(i).toString(16);else val += str.charCodeAt(i).toString(16);}return val;}function YunsuoAutoJump() {var text = document.getElementById("intext").value;if (text == "") {alert(\'验证码不能为空\');}else {var curlocation = window.location.href;if(-1 == curlocation.indexOf("security_verify_")){ document.cookie="srcurl=" + stringToHex(window.location.href) + ";path=/;";}self.location = "/search?q=葬送的芙莉莲&security_verify_img=" + stringToHex(text);}}</script><title>&#x7F51;&#x7AD9;&#x9632;&#x706B;&#x5899;</title><style type="text/css">.c1{margin-left:70px; _margin-left:1px;}body{text-align:center;}</style></head><body onkeypress="EnterJump()"><br/><br/><div style="margin: 0 auto; width: 515px; height: 200px; border: 2px solid #134f7c; background: #e7eff2; font-family:微软雅黑"><div style="text-align: center; color: white; height: 30px; font-size: 14px; background: #074773; line-height:30px">&#x7F51;&#x7AD9;&#x8BBF;&#x95EE;&#x8BA4;&#x8BC1;&#x9875;&#x9762;</div><br/><br/><div style="text-align: center; font-size: 14px;"><table class="c1"><tr><td style="width: 168px; font-size: 14px;">&#x8BF7;&#x8F93;&#x5165;&#x9A8C;&#x8BC1;&#x7801;&#x540E;&#x7EE7;&#x7EED;&#x8BBF;&#x95EE;&#xFF1A;</td><td style="width: 30px; text-align: left;"> <input id="intext" type="text" style="width: 100px; height: 28px;" /></td><td style="letter-spacing: 4; text-align: center; float: left; margin-left: 8px;"><img class="verifyimg" alt="verify_img" src="data:image/bmp;base64,Qk3aHwAAAAAAADYAAAAoAAAAZAAAABsAAAABABgAAAAAAKQfAAAAAQAAAAEAAAAAAAAAAAAA3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS29vbrrSupqymtry2rrSuyMjI5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubmrrSupqymtry2rrSupqymtry2rrSupqym5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzct7q3try20tPS0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm0dLRpqymtry2rrSupqym29vb3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzczc3Ntry2rrSupqymtry2zM3M0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS3Nzc0tLS5ubm3Nzc0tLS5ubm1dbVepV6AJkAAJkAAJkAAJkAgJqAzMzM5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLSAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkA0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubmV49XAJkAuL643Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3NzczMzMhqCGAJkAAJkAAJkAAJkAepV639/f3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubmkqOSAJkAAJkAAJkAAJkAc5lzpa6lzMzM5ubm3Nzc0tLS5ubm3Nzc5ubm3Nzc0tLS5ubm3Nzc0tLSzM7MAJkAFJEUQ49DQIxAFJEUAJkAip+K0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3NzcNoo2AJkAFZIVPYo9Q49DQIxAPYo9Q49D3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0tLS5ubm3Nzc0Line truncated