- GET /discover/{tmdb,douban,bangumi},bgm 趋势页在沙箱任务 bangumi.trends 里解析
- 豆瓣 / bgm 每个列表 / 搜索请求走 douban.request / bangumi.request 缓存(TTL 取 storage.cacheTtl),每源限流
- 条目按 TMDB 身份标已订阅 / 媒体库已有 / 下载中;豆瓣 / bgm 条目确定匹配后记在 discover.match
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
142 lines
5.4 KiB
TypeScript
142 lines
5.4 KiB
TypeScript
// 沙箱任务表(docs/adr/ADR-005-sandbox.md):只放「外部不可信字节 → 结构化数据」的纯函数。
|
|
// 子进程加载完本表后撤销全部权限(文件 / 网络 / 环境变量 / 子进程),所以任务里不能有任何 I/O;
|
|
// 输入输出必须能 JSON 化(二进制用 base64)。新增任务:在这里加一项,调用方用 runSandbox(名字, 输入)。
|
|
import { parseBangumiBrowser, parseTorrent } from "@umt/adapters";
|
|
import { decodeSubtitle, detectSubtitleLanguage, subtitleEpisode, subtitleExtOf } from "@umt/domain";
|
|
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;
|
|
|
|
export const b64encode = (bytes: Uint8Array) => {
|
|
let s = "";
|
|
for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
return btoa(s);
|
|
};
|
|
export const b64decode = (s: string) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
|
|
|
|
export interface ParsedSubtitleFile {
|
|
name: string;
|
|
archive: string | null;
|
|
ext: string;
|
|
language: string;
|
|
season: number | null;
|
|
seasonInferred: boolean;
|
|
episode: number | null;
|
|
encoding: string;
|
|
sizeBytes: number;
|
|
/** 转成 UTF-8 后的内容(base64) */
|
|
content: 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) => {
|
|
const ext = subtitleExtOf(name);
|
|
if (!ext) return skipped.push({ name, reason: "不是字幕文件" });
|
|
if (bytes.length > MAX_SUBTITLE_BYTES) return skipped.push({ name, reason: "文件超过 10 MB" });
|
|
const { text, encoding } = decodeSubtitle(bytes);
|
|
const ep = subtitleEpisode(name);
|
|
items.push({
|
|
name,
|
|
archive,
|
|
ext,
|
|
language: detectSubtitleLanguage(name, text),
|
|
season: ep.season,
|
|
seasonInferred: ep.seasonInferred,
|
|
episode: ep.episode,
|
|
encoding,
|
|
sizeBytes: bytes.length,
|
|
content: b64encode(new TextEncoder().encode(text)),
|
|
});
|
|
};
|
|
for (const f of input.files) {
|
|
let bytes: Uint8Array;
|
|
try {
|
|
bytes = b64decode(f.base64);
|
|
} catch {
|
|
skipped.push({ name: f.name, reason: "内容不是有效的 base64" });
|
|
continue;
|
|
}
|
|
const kind = archiveKindOf(f.name);
|
|
if (kind) {
|
|
let entries: Record<string, Uint8Array>;
|
|
try {
|
|
let n = 0;
|
|
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;
|
|
}
|
|
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 one(f.name, bytes, null);
|
|
}
|
|
return { items, skipped };
|
|
}
|
|
|
|
/** 种子文件:info hash、名称、文件清单(TorrentMeta,可 JSON 化) */
|
|
function torrentParse(input: { base64: string }) {
|
|
return parseTorrent(b64decode(input.base64));
|
|
}
|
|
|
|
/** 测试用:按指令卡死 / 吃内存 / 抛错,验证父进程的超时强杀与内存上限 */
|
|
function selfTest(input: { mode: "ok" | "hang" | "memory" | "throw" | "io" }) {
|
|
if (input.mode === "hang") for (;;) {}
|
|
if (input.mode === "memory") {
|
|
const keep: Uint8Array[] = [];
|
|
for (;;) keep.push(new Uint8Array(16 * 1024 * 1024).fill(1));
|
|
}
|
|
if (input.mode === "throw") throw new Error("任务自己抛的错");
|
|
if (input.mode === "io") return { read: Deno.readTextFileSync("/etc/hostname") };
|
|
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);
|
|
}
|
|
|
|
/** bgm.tv 动画浏览页(热门趋势,发现页)→ 条目列表 */
|
|
function bangumiTrends(input: { html: string }) {
|
|
return parseBangumiBrowser(input.html);
|
|
}
|
|
|
|
export const SANDBOX_JOBS = {
|
|
"subtitle.parse": subtitleParse,
|
|
"torrent.parse": torrentParse,
|
|
"file.preview": buildPreview,
|
|
"captcha.solve": captchaSolve,
|
|
"subtitle.page": subtitlePage,
|
|
"bangumi.trends": bangumiTrends,
|
|
"self.test": selfTest,
|
|
} as const;
|
|
|
|
export type SandboxJobName = keyof typeof SANDBOX_JOBS;
|
|
export type SandboxInput<J extends SandboxJobName> = Parameters<(typeof SANDBOX_JOBS)[J]>[0];
|
|
export type SandboxOutput<J extends SandboxJobName> = Awaited<ReturnType<(typeof SANDBOX_JOBS)[J]>>;
|