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

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

No files matched your search

+70
View File
@@ -0,0 +1,70 @@
// 压缩包解压(沙箱内):zip 用 fflate,rar 用 node-unrar-js(WASM),7z 用 7z-wasm。
// WASM 库要读自己包里的 .wasm 文件:prepareArchives() 在撤销权限前预热(载入内存),之后只在内存里解压。
import SevenZip from "7z-wasm";
import { unzipSync } from "fflate";
import { createExtractorFromData } from "node-unrar-js";
type Seven = Awaited<ReturnType<typeof SevenZip>>;
let seven: Seven | null = null;
let warmed = false;
export async function prepareArchives(): Promise<void> {
if (warmed) return;
warmed = true;
try {
await createExtractorFromData({ data: new Uint8Array([0x52, 0x61, 0x72, 0x21]).buffer });
} catch {
// 只为载入 WASM,内容无效报错是预期的
}
seven = await SevenZip({ print: () => {}, printErr: () => {} });
}
export type AcceptEntry = (name: string, size: number) => boolean;
export function archiveKindOf(name: string): "zip" | "rar" | "7z" | null {
const m = /\.(zip|rar|7z)$/i.exec(name);
return m ? (m[1]!.toLowerCase() as "zip" | "rar" | "7z") : null;
}
/** 解出通过 accept 的条目;压缩包损坏 / 加密抛错 */
export async function extractArchive(
kind: "zip" | "rar" | "7z",
bytes: Uint8Array,
accept: AcceptEntry,
): Promise<Record<string, Uint8Array>> {
if (kind === "zip")
return unzipSync(bytes, { filter: (e) => !e.name.endsWith("/") && accept(e.name, e.originalSize) });
if (kind === "rar") {
const ex = await createExtractorFromData({ data: bytes.slice().buffer });
const out: Record<string, Uint8Array> = {};
for (const f of ex.extract({ files: (h) => !h.flags.directory && accept(h.name, h.unpSize) }).files)
if (f.extraction) out[f.fileHeader.name] = f.extraction;
return out;
}
await prepareArchives();
const z = seven!;
const dir = `/x${crypto.randomUUID().slice(0, 8)}`;
z.FS.writeFile(`${dir}.7z`, bytes);
// callMain 出错时抛 ExitStatus 或静默不写文件:两种都当成损坏处理
z.callMain(["x", `${dir}.7z`, `-o${dir}`, "-y", "-p-"]);
try {
z.FS.stat(dir);
} catch {
throw new Error("7z 解压失败");
}
const out: Record<string, Uint8Array> = {};
const walk = (d: string, rel: string) => {
for (const n of z.FS.readdir(d) as string[]) {
if (n === "." || n === "..") continue;
const p = `${d}/${n}`;
const r = rel ? `${rel}/${n}` : n;
if (z.FS.isDir(z.FS.stat(p).mode)) walk(p, r);
else {
const data = z.FS.readFile(p) as Uint8Array;
if (accept(r, data.length)) out[r] = data;
}
}
};
walk(dir, "");
return out;
}
+233
View File
@@ -0,0 +1,233 @@
// SubHD 下载验证码(SVG 文字):每个字符一条长 path,按 path 长度查表,长度撞车时看最高点 / 起笔 / 宽度区分
// (移植自 MP 插件 captcha/subhd.py 的长度表与判别规则)
const LENGTH_MAP: Record<number, string> = {
986: "I",
998: "1",
1068: "I",
1081: "1",
1082: "v",
1130: "Y",
1134: "Y",
1172: "v",
1224: "Y",
1274: "L",
1298: "V",
1311: "V",
1360: "i",
1380: "L",
1406: "V",
1473: "i",
1478: "T",
1491: "r",
1598: "N",
1601: "T",
1604: "X",
1610: "J",
1613: "x",
1614: "N",
1615: "N",
1616: "N",
1617: "N",
1618: "N",
1634: "k",
1637: "k",
1694: "t",
1706: "K",
1709: "K",
1731: "N",
1744: "J",
1754: "F",
1770: "k",
1835: "t",
1838: "u",
1840: "A",
1844: "A",
1848: "K",
1850: "Z",
1853: "Z",
1886: "h",
1900: "F",
1922: "H",
1928: "H",
1960: "P",
1991: "u",
1993: "A",
1996: "D",
2004: "Z",
2018: "w",
2035: "w",
2042: "7",
2043: "h",
2080: "j",
2082: "H",
2104: "R",
2107: "R",
2123: "P",
2140: "4",
2162: "D",
2164: "O",
2183: "w",
2198: "C",
2199: "C",
2200: "C",
2201: "C",
2202: "C",
2210: "f",
2212: "7",
2246: "E",
2253: "j",
2260: "o",
2272: "d",
2279: "M",
2282: "M",
2294: "U",
2301: "U",
2310: "W",
2318: "4",
2321: "M",
2332: "a",
2344: "O",
2345: "W",
2346: "W",
2366: "s",
2380: "b",
2381: "C",
2382: "0",
2394: "f",
2433: "E",
2448: "o",
2461: "d",
2464: "p",
2466: "M",
2485: "U",
2498: "c",
2501: "e",
2503: "W",
2512: "q",
2526: "a",
2546: "2",
2563: "s",
2578: "b",
2580: "0",
2606: "5",
2632: "6",
2669: "p",
2706: "c",
2709: "e",
2721: "q",
2758: "2",
2800: "9",
2823: "5",
2851: "6",
3033: "9",
3038: "S",
3054: "B",
3160: "g",
3244: "Q",
3254: "Q",
3266: "G",
3291: "S",
3308: "B",
3414: "8",
3423: "g",
3514: "Q",
3538: "G",
3663: "m",
3667: "m",
3698: "8",
3878: "3",
3968: "m",
4201: "3",
};
const nums = (p: string) => (p.match(/\d+(?:\.\d*)?/g) ?? []).map(Number);
const minY = (p: string) => {
const v = nums(p).filter((_, i) => i % 2 === 1);
return v.length ? Math.min(...v) : 0;
};
const moveY = (p: string) => Number(/M(\d+(?:\.\d*)?)\s+(\d+(?:\.\d*)?)/.exec(p)?.[2] ?? 0);
const width = (p: string) => {
const v = nums(p).filter((_, i) => i % 2 === 0);
return v.length ? Math.max(...v) - Math.min(...v) : 0;
};
function collision(len: number, p: string): string {
if (len === 986 || len === 1068) return minY(p) > 13 ? "I" : "l";
if (len === 1274 || len === 1380) return moveY(p) > 30 ? "y" : "L";
if (len === 1610 || len === 1744) return minY(p) > 19 ? "x" : "J";
if (len === 1615) return minY(p) > 18 ? "r" : "N";
if (len === 2198 || len === 2381) return minY(p) > 19 ? "n" : "C";
if (len === 2318) return width(p) > 30 ? "W" : "4";
if (len === 1598 || len === 1731) return minY(p) > 13 ? "X" : "N";
if (len === 1694 || len === 1835) return minY(p) > 22 ? "z" : "t";
if (len === 2279) return minY(p) > 13 ? "R" : "M";
return "";
}
export function solveSubhdCaptcha(svg: string): string {
const paths: [number, string][] = [];
for (const m of svg.matchAll(/d=["']([^"']+)["']/g)) {
const p = m[1]!;
if (p.length <= 500) continue;
paths.push([Number(/\d+(?:\.\d*)?/.exec(p)?.[0] ?? 0), p]);
}
return paths
.sort((a, b) => a[0] - b[0])
.map(([, p]) => collision(p.length, p) || LENGTH_MAP[p.length] || "")
.join("");
}
// 字幕库(云锁)BMP 数字验证码:100×27、5 位、每位 20 像素宽;按 9 个采样点比对 0-9 模板(移植自 MP 插件 captcha/zimuku.py)
const W = 100,
H = 27,
CHAR_W = 20,
N = 5,
OFFSET = 54;
const SAMPLES: [number, number][] = [
[10, 7],
[7, 8],
[12, 8],
[10, 13],
[7, 19],
[12, 19],
[10, 20],
[6, 13],
[14, 13],
];
const TEMPLATES: Record<string, number[]> = {
"0": [1, 1, 1, 1, 1, 1, 1, 1, 0],
"1": [0, 1, 0, 0, 0, 0, 1, 0, 0],
"2": [1, 0, 1, 0, 1, 0, 1, 0, 0],
"3": [1, 0, 1, 1, 0, 1, 1, 0, 0],
"4": [0, 0, 1, 0, 0, 1, 0, 0, 0],
"5": [1, 1, 0, 0, 0, 1, 1, 0, 0],
"6": [1, 0, 1, 1, 1, 1, 1, 1, 0],
"7": [1, 0, 1, 0, 0, 0, 0, 0, 0],
"8": [1, 1, 1, 1, 1, 1, 1, 0, 0],
"9": [1, 1, 1, 0, 1, 0, 1, 0, 0],
};
export function solveZimukuCaptcha(bmp: Uint8Array): string | null {
if (bmp.length < OFFSET || bmp[0] !== 0x42 || bmp[1] !== 0x4d) return null;
const dv = new DataView(bmp.buffer, bmp.byteOffset, bmp.byteLength);
if (dv.getInt32(18, true) !== W || dv.getInt32(22, true) !== H) return null;
const stride = (W * 3 + 3) & ~3;
const fg = (x: number, y: number) => {
const o = OFFSET + (H - 1 - y) * stride + x * 3;
if (o + 2 >= bmp.length || x < 0) return false;
return (bmp[o]! + bmp[o + 1]! + bmp[o + 2]!) / 3 < 70;
};
let out = "",
shift = 0;
for (let i = 0; i < N; i++) {
const f = SAMPLES.map(([px, py]) => (fg(i * CHAR_W + px - shift, py) ? 1 : 0));
let best = "?",
min = 99;
for (const [d, t] of Object.entries(TEMPLATES)) {
const diff = t.reduce((n, v, k) => n + (v !== f[k] ? 1 : 0), 0);
if (diff < min) {
min = diff;
best = d;
}
}
if (best === "1") shift += 1;
else if (best === "4") shift -= 1;
out += best;
}
return out.includes("?") ? null : out;
}
+2 -1
View File
@@ -3,7 +3,7 @@
// 起不来(权限 / 运行时不支持)时退回进程内执行并记日志:仍有超时,但卡死的同步代码无法收回(只在本机调试时出现)。
import { basename } from "node:path";
import { fileURLToPath } from "node:url";
import { SANDBOX_JOBS, type SandboxInput, type SandboxJobName, type SandboxOutput } from "./jobs.ts";
import { prepareJobs, SANDBOX_JOBS, type SandboxInput, type SandboxJobName, type SandboxOutput } from "./jobs.ts";
export type SandboxErrorKind = "timeout" | "memory" | "crash" | "job" | "spawn";
@@ -144,6 +144,7 @@ async function runInProcess<J extends SandboxJobName>(
input: SandboxInput<J>,
timeoutMs: number,
): Promise<unknown> {
await prepareJobs();
const fn = SANDBOX_JOBS[job] as (i: SandboxInput<J>) => unknown;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
+33 -13
View File
@@ -3,8 +3,10 @@
// 输入输出必须能 JSON 化(二进制用 base64)。新增任务:在这里加一项,调用方用 runSandbox(名字, 输入)。
import { parseTorrent } from "@umt/adapters";
import { decodeSubtitle, detectSubtitleLanguage, subtitleEpisode, subtitleExtOf } from "@umt/domain";
import { unzipSync } from "fflate";
import { buildPreview } from "../modules/file-manager/preview-build.ts";
import { PAGE_PARSERS, type PageKind } from "../modules/subtitles/online/parse.ts";
import { archiveKindOf, extractArchive, prepareArchives } from "./archives.ts";
import { solveSubhdCaptcha, solveZimukuCaptcha } from "./captcha.ts";
const MAX_SUBTITLE_BYTES = 10 * 1024 * 1024;
const MAX_ARCHIVE_ENTRIES = 300;
@@ -30,8 +32,13 @@ export interface ParsedSubtitleFile {
content: string;
}
/** 字幕 / zip:解压、解码转 UTF-8、识别语言与季集 */
function subtitleParse(input: { files: { name: string; base64: string }[] }) {
/** 子进程撤销权限前调用:预热要读文件的 WASM 库 */
export async function prepareJobs(): Promise<void> {
await prepareArchives();
}
/** 字幕 / zip / rar / 7z:解压、解码转 UTF-8、识别语言与季集 */
async function subtitleParse(input: { files: { name: string; base64: string }[] }) {
const items: ParsedSubtitleFile[] = [];
const skipped: { name: string; reason: string }[] = [];
const one = (name: string, bytes: Uint8Array, archive: string | null) => {
@@ -61,17 +68,16 @@ function subtitleParse(input: { files: { name: string; base64: string }[] }) {
skipped.push({ name: f.name, reason: "内容不是有效的 base64" });
continue;
}
if (/\.zip$/i.test(f.name)) {
const kind = archiveKindOf(f.name);
if (kind) {
let entries: Record<string, Uint8Array>;
try {
let n = 0;
entries = unzipSync(bytes, {
filter: (e) =>
!e.name.endsWith("/") &&
!!subtitleExtOf(e.name) &&
e.originalSize <= MAX_SUBTITLE_BYTES &&
++n <= MAX_ARCHIVE_ENTRIES,
});
entries = await extractArchive(
kind,
bytes,
(name, size) => !!subtitleExtOf(name) && size <= MAX_SUBTITLE_BYTES && ++n <= MAX_ARCHIVE_ENTRIES,
);
} catch {
skipped.push({ name: f.name, reason: "压缩包损坏或加密" });
continue;
@@ -79,8 +85,6 @@ function subtitleParse(input: { files: { name: string; base64: string }[] }) {
const names = Object.keys(entries);
if (!names.length) skipped.push({ name: f.name, reason: "压缩包里没有字幕文件" });
for (const n of names) one(n, entries[n]!, f.name);
} else if (/\.(rar|7z)$/i.test(f.name)) {
skipped.push({ name: f.name, reason: "暂不支持 rar / 7z,请解压后上传或改用 zip" });
} else one(f.name, bytes, null);
}
return { items, skipped };
@@ -103,10 +107,26 @@ function selfTest(input: { mode: "ok" | "hang" | "memory" | "throw" | "io" }) {
return { ok: true };
}
/** SubHD SVG / 字幕库 BMP 验证码 */
function captchaSolve(input: { kind: "subhd"; svg: string } | { kind: "zimuku"; bmpBase64: string }) {
return {
code: input.kind === "subhd" ? solveSubhdCaptcha(input.svg) : solveZimukuCaptcha(b64decode(input.bmpBase64)),
};
}
/** 字幕站页面 → 结构化数据(网页同样不可信) */
function subtitlePage(input: { kind: PageKind; html: string }) {
const fn = PAGE_PARSERS[input.kind] as (html: string) => unknown;
if (!fn) throw new Error(`没有这种页面解析:${input.kind}`);
return fn(input.html);
}
export const SANDBOX_JOBS = {
"subtitle.parse": subtitleParse,
"torrent.parse": torrentParse,
"file.preview": buildPreview,
"captcha.solve": captchaSolve,
"subtitle.page": subtitlePage,
"self.test": selfTest,
} as const;
+2 -1
View File
@@ -1,6 +1,6 @@
// 沙箱子进程入口:先加载任务表,再撤销全部权限,然后从 stdin 读一条 {job, input},把 {ok, result | error} 写到 stdout。
// 开发 / 测试由 deno run 直接跑本文件;编译二进制以 `<自身> --role sandbox` 拉起(main.ts 在加载其余模块前分派)。
import { SANDBOX_JOBS } from "./jobs.ts";
import { prepareJobs, SANDBOX_JOBS } from "./jobs.ts";
const PERMISSIONS = ["read", "write", "net", "env", "run", "ffi", "sys"] as const;
@@ -10,6 +10,7 @@ async function writeAll(bytes: Uint8Array) {
}
export async function sandboxWorkerMain(): Promise<void> {
await prepareJobs();
for (const name of PERMISSIONS) {
try {
Deno.permissions.revokeSync({ name } as Deno.PermissionDescriptor);