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
@@ -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(/已停用/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user