feat(subtitles): 字幕站(SubHD、字幕库)在线搜索与下载;rar / 7z 解压;验证码与页面解析在沙箱里
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
92d456d123
commit
59b3680f35
32 files changed
+1579
-41
No files matched your search
@@ -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,7 +93,8 @@ 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) => ({
|
||||
items: [
|
||||
...listSiteDefinitions().map((d) => ({
|
||||
key: d.id,
|
||||
name: d.umt.kind === "nexusphp" ? (d.umt.displayName ?? d.name) : d.umt.displayName,
|
||||
kind: definitionKind(d),
|
||||
@@ -105,6 +107,21 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
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 {
|
||||
|
||||
@@ -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(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/"/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]>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(/已停用/);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,34 @@ 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,
|
||||
{
|
||||
key: "proxy",
|
||||
header: "出口",
|
||||
hideBelow: "sm",
|
||||
cell: (s) => <span className="whitespace-nowrap text-muted-foreground">{s.proxy ? "代理" : "直连"}</span>,
|
||||
},
|
||||
health,
|
||||
actions,
|
||||
]
|
||||
: variant === "public"
|
||||
? [
|
||||
name,
|
||||
{
|
||||
@@ -545,7 +610,9 @@ export function SiteTable({
|
||||
header: "资源搜索",
|
||||
hideBelow: "sm",
|
||||
cell: (s) => (
|
||||
<span className="whitespace-nowrap text-muted-foreground">{s.searchDefault ? "默认参与" : "手动选"}</span>
|
||||
<span className="whitespace-nowrap text-muted-foreground">
|
||||
{s.searchDefault ? "默认参与" : "手动选"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
snapshot,
|
||||
|
||||
@@ -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,6 +154,13 @@ export function SubtitleDialog({
|
||||
<EmptyState title="媒体库里还没有这部作品的视频" description="整理入库后才能补字幕。" />
|
||||
) : (
|
||||
<div className="min-w-0 space-y-4">
|
||||
<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) => {
|
||||
@@ -176,6 +196,16 @@ export function SubtitleDialog({
|
||||
}}
|
||||
/>
|
||||
</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]);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -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 }[] = [
|
||||
|
||||
@@ -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]",
|
||||
|
||||
@@ -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
@@ -31,7 +31,8 @@
|
||||
|
||||
### 进行中
|
||||
|
||||
- 补字幕:[subtitles.md](subtitles.md),第 1 步(手动上传)已完成,下一步字幕站(SubHD、字幕库)在线搜索。
|
||||
- 补字幕:[subtitles.md](subtitles.md),第 1、2 步已完成;待办:SubHD 隔天复测下载、站点验证码失败时接智能体视觉兜底、以后接进整理 / 订阅工作流。
|
||||
- 沙箱第二批:站点网页、RSS、网页抓取的 HTML / XML 解析迁入常驻子进程池(ADR-005)。
|
||||
|
||||
### 业务(需要真实环境或决定)
|
||||
|
||||
|
||||
@@ -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 对本机限流(直连与代理都超时),需要隔天复测。
|
||||
@@ -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",
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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)};`,
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
@@ -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=" 简体中文字幕" title=" 简体中文字幕" 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> </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=" 简体中文字幕 繁體中文字幕 双语字幕 日语字幕" title=" 简体中文字幕 繁體中文字幕 双语字幕 日语字幕" 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> </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=" 简体中文字幕 双语字幕 日语字幕" title=" 简体中文字幕 双语字幕 日语字幕" 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> </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> <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> <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>网站防火墙</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">网站访问认证页面</div><br/><br/><div style="text-align: center; font-size: 14px;"><table class="c1"><tr><td style="width: 168px; font-size: 14px;">请输入验证码后继续访问:</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
|
||||
Reference in new issue
Block a user