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
+1704
-166
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user