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

+33 -1
View File
@@ -1,4 +1,4 @@
import { buildFakeTorrent } from "@umt/testkit";
import { buildFakeTorrent, RAR5_SAMPLE_BASE64, SEVENZIP_SUBTITLE_BASE64, ZIMUKU_CAPTCHA } from "@umt/testkit";
import { zipSync } from "fflate";
import { describe, expect, it } from "vitest";
import { runSandbox, SandboxError } from "../../src/sandbox/client.ts";
@@ -48,4 +48,36 @@ describe("沙箱子进程", () => {
expect(f.files.map((x) => x.path)).toEqual(["Show.S01E01.mkv"]);
expect(f.hash).toBe(t.infoHash);
});
it("rar / 7z 在沙箱里解压(撤销权限后 WASM 仍可用);非字幕条目跳过", async () => {
const r = await runSandbox("subtitle.parse", {
files: [
{ name: "pack.7z", base64: SEVENZIP_SUBTITLE_BASE64 },
{ name: "texts.rar", base64: RAR5_SAMPLE_BASE64 },
],
});
expect(r.items).toEqual([
expect.objectContaining({ name: "s7/a.chs.srt", archive: "pack.7z", language: "zh-Hans" }),
]);
expect(r.skipped).toEqual([{ name: "texts.rar", reason: "压缩包里没有字幕文件" }]);
});
it("字幕库验证码:真实样本识别正确", async () => {
expect(await runSandbox("captcha.solve", { kind: "zimuku", bmpBase64: ZIMUKU_CAPTCHA.bmpBase64 })).toEqual({
code: ZIMUKU_CAPTCHA.code,
});
});
});
describe("压缩包解压(进程内,直接测解压模块)", () => {
it("rar 列出全部条目", async () => {
const { extractArchive } = await import("../../src/sandbox/archives.ts");
const out = await extractArchive(
"rar",
Uint8Array.from(atob(RAR5_SAMPLE_BASE64), (c) => c.charCodeAt(0)),
() => true,
);
expect(Object.keys(out).sort()).toEqual(["stest1.txt", "stest2.txt"]);
await expect(extractArchive("rar", new Uint8Array([1, 2, 3]), () => true)).rejects.toThrow();
});
});
@@ -129,7 +129,15 @@ describe.skipIf(!up)("站点类型:API、调度与 RSS", () => {
.map((d) => d.key)
.sort(),
).toEqual([...pub].sort());
expect(items.filter((d) => !pub.includes(d.key)).every((d) => d.kind === "pt")).toBe(true);
// 内置字幕站(SubHD、字幕库)也在定义列表里,类型 subtitle
const subs = ["subhd", "zimuku"];
expect(
items
.filter((d) => d.kind === "subtitle")
.map((d) => d.key)
.sort(),
).toEqual(subs);
expect(items.filter((d) => !pub.includes(d.key) && !subs.includes(d.key)).every((d) => d.kind === "pt")).toBe(true);
const nyaa = await json(
await h.req("/sites", { body: { key: "nyaa", name: "Nyaa", adapter: "api", baseUrl: "https://nyaa.si/" } }),
);
@@ -203,4 +211,23 @@ describe.skipIf(!up)("站点类型:API、调度与 RSS", () => {
reason: "unsupported",
});
});
it("字幕站:只能从内置字幕站添加;不排快照、不进搜索、能力为空", async () => {
const ok = await json(
await h.req("/sites", {
body: { kind: "subtitle", key: "zimuku", name: "字幕库", adapter: "subtitle", baseUrl: "https://zmk.pw/" },
}),
);
expect(ok.status).toBe(200);
expect(ok.body.data).toMatchObject({
kind: "subtitle",
snapshotEnabled: false,
searchDefault: false,
capabilities: [],
});
const bad = await h.req("/sites", {
body: { kind: "subtitle", key: "random", name: "x", adapter: "subtitle", baseUrl: "https://x.example/" },
});
expect(bad.status).toBe(400);
});
});
@@ -0,0 +1,122 @@
import {
SUBHD_DETAIL_PAGE,
SUBHD_SUB_PAGE,
ZIMUKU_CAPTCHA,
ZIMUKU_CAPTCHA_PAGE,
ZIMUKU_DETAIL_PAGE,
ZIMUKU_DL_PAGE,
ZIMUKU_SEARCH_PAGE,
ZIMUKU_SUBS_PAGE,
} from "@umt/testkit";
import { zipSync } from "fflate";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { downloadOnline, searchOnline } from "../../src/modules/subtitles/online/index.ts";
import { createHarness, type Harness, pgAvailable } from "../sources-identity/harness.ts";
// 在线字幕(docs/plan/subtitles.md 第 2 步):站点请求全部走注入的 fetch,页面是 2026-10-03 的真实截取;
// 字幕库先回验证码页,沙箱识别后带 security_verify_img 提交,再开搜索页;SubHD 走 prepare-download → down → api/sub/down
const pg = await pgAvailable();
const enc = (s: string) => new TextEncoder().encode(s);
describe.skipIf(!pg)("在线字幕:SubHD / 字幕库(注入 fetch)", () => {
let h: Harness;
const requests: string[] = [];
let verified = false;
const siteIds: Record<string, string> = {};
const html = (body: string, status = 200) =>
new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8" } });
const zip = zipSync({
"The.Shawshank.Redemption.1994.chs&eng.ass": enc(
"[Script Info]\nDialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,你好 Hello",
),
});
beforeAll(async () => {
h = await createHarness("subonline", {
fetch: async (input, init) => {
const u = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
const method = init?.method ?? "GET";
requests.push(`${method} ${u.host}${u.pathname}${u.search}`);
if (u.host === "zmk.pw") {
if (u.searchParams.has("security_verify_img")) {
const want = [...ZIMUKU_CAPTCHA.code].map((c) => c.charCodeAt(0).toString(16)).join("");
verified = u.searchParams.get("security_verify_img") === want;
return html("ok", 302);
}
if (!verified) return html(ZIMUKU_CAPTCHA_PAGE, 404);
if (u.pathname === "/search") return html(ZIMUKU_SEARCH_PAGE);
if (/^\/subs\/\d+\.html$/.test(u.pathname)) return html(ZIMUKU_SUBS_PAGE);
if (u.pathname === "/detail/203404.html") return html(ZIMUKU_DETAIL_PAGE);
if (u.pathname === "/dld/203404.html") return html(ZIMUKU_DL_PAGE);
if (u.pathname.startsWith("/download/"))
return new Response(zip, {
headers: {
"content-type": "application/octet-stream",
"content-disposition": 'attachment; filename="sub.zip"',
},
});
}
if (u.host === "subhd.tv") {
if (u.pathname === "/d/1292052") return html(SUBHD_DETAIL_PAGE);
if (u.pathname.startsWith("/a/")) return html(SUBHD_SUB_PAGE);
if (u.pathname === "/api/sub/prepare-download") return Response.json({ success: true, url: "/down/Fp90EG" });
if (u.pathname === "/down/Fp90EG") return html("<html></html>");
if (u.pathname === "/api/sub/down")
return Response.json({ success: true, pass: true, url: "https://dl.subhd.example/x/1.srt" });
}
if (u.host === "dl.subhd.example")
return new Response(enc("1\n00:00:01,000 --> 00:00:02,000\n你好\n"), {
headers: { "content-disposition": 'attachment; filename="Shawshank.chs.srt"' },
});
return html("not found", 404);
},
});
for (const [key, name, url] of [
["subhd", "SubHD", "https://subhd.tv/"],
["zimuku", "字幕库", "https://zmk.pw/"],
] as const) {
const r = await h.db
.insertInto("site")
.values({ key, name, kind: "subtitle", adapter: "subtitle", base_url: url } as never)
.returning("id")
.executeTakeFirstOrThrow();
siteIds[key] = r.id;
}
}, 120_000);
afterAll(async () => {
await h?.close();
});
// 字幕库样例是《葬送的芙莉莲》的搜索结果;SubHD 按豆瓣 ID 开条目页,不按标题过滤
const q = { keywords: ["葬送的芙莉莲"], doubanId: "1292052", season: null, episode: null };
it("搜索:SubHD 有豆瓣 ID 直接开条目页;字幕库先过验证码再搜;结果按语言与下载量排序", async () => {
const r = await searchOnline(h.ctx, h.rt, q);
expect(r.errors).toEqual([]);
expect(r.noSites).toBe(false);
expect(verified).toBe(true);
expect(requests).toContain("GET subhd.tv/d/1292052");
const zim = r.results.filter((x) => x.siteKey === "zimuku");
const sub = r.results.filter((x) => x.siteKey === "subhd");
expect(zim.length).toBeGreaterThan(0);
expect(sub.length).toBeGreaterThan(0);
// 双语排在前面
expect(r.results[0]!.languages).toContain("双语");
expect(zim.find((x) => x.ref === "/detail/203404.html")).toMatchObject({ siteName: "字幕库", downloads: 17000 });
});
it("下载:字幕库走下载页取文件;SubHD 走 prepare-download → api/sub/down", async () => {
const z = await downloadOnline(h.ctx, h.rt, siteIds.zimuku!, "/detail/203404.html");
expect(z).toMatchObject({ name: "sub.zip", siteKey: "zimuku", pageUrl: "https://zmk.pw/detail/203404.html" });
const s = await downloadOnline(h.ctx, h.rt, siteIds.subhd!, "/a/Fp90EG");
expect(s.name).toBe("Shawshank.chs.srt");
expect(requests).toContain("POST subhd.tv/api/sub/prepare-download");
});
it("字幕站停用:不搜;下载报错", async () => {
await h.db.updateTable("site").set({ enabled: false }).where("kind", "=", "subtitle").execute();
const r = await searchOnline(h.ctx, h.rt, q);
expect(r.noSites).toBe(true);
await expect(downloadOnline(h.ctx, h.rt, siteIds.subhd!, "/a/x")).rejects.toThrow(/已停用/);
});
});