- root deno.json workspace (nodeModulesDir auto), npm versions registered once in imports, deno.lock; remove pnpm-lock/pnpm-workspace/root package.json; members keep minimal package.json (name/type/exports) - tasks: dev, typecheck (tsc 7.0.2 per member + deno check), lint/fmt (biome, useImportExtensions), test (vitest via deno), check:tokens, build (web + deno compile), ci, db:migrate/reset, gen:types - tsconfig base moved to config/ (no root tsconfig.json); deno types -> types/deno.d.ts; web drops baseUrl - server: Deno.serve entry with graceful shutdown, --role migrate|preflight|probe, WEB_ROOT static SPA hosting; drop @hono/node-server, tsx, esbuild build.mjs - logger: redact child() bindings and serialize err by message/stack; sync stdout destination; tests - relative imports get explicit extensions (sites/mp-sync paths left for follow-up) - crash-recovery tests spawn `deno run -A`; tasks tests use unique per-file schemas - single-container Dockerfile + entrypoint (migrate -> preflight -> api+worker watchdog), docker-compose.yml - docs: README, runbook, ADR-001 superseded, plan §1 validation row Co-Authored-By: Claude Fable 5.1 <[email protected]>
297 lines
14 KiB
TypeScript
297 lines
14 KiB
TypeScript
import { createMockRssServer, type MockFeedItem, type MockRssServer } from "@umt/testkit";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import {
|
|
applyFeedItems,
|
|
buildRssLinkRequest,
|
|
computeBackoffMs,
|
|
createRssFetcher,
|
|
emptyFeedCursor,
|
|
extractRssLink,
|
|
FeedError,
|
|
type FeedFetchResult,
|
|
initialFeedState,
|
|
parseFeed,
|
|
parseRetryAfter,
|
|
redactText,
|
|
redactUrl,
|
|
resetFeedCircuit,
|
|
unredactUrl,
|
|
} from "./index.ts";
|
|
|
|
const PASSKEY = "abcdef0123456789secret";
|
|
|
|
function sample(n: number, host = "https://pt.example"): MockFeedItem[] {
|
|
return Array.from({ length: n }, (_, i) => ({
|
|
guid: `${host}/details.php?id=${1000 + i}`,
|
|
title: `Show.S01E${String(i + 1).padStart(2, "0")}.2160p.WEB-DL.H265-GRP`,
|
|
link: `${host}/details.php?id=${1000 + i}`,
|
|
enclosureUrl: `${host}/download.php?id=${1000 + i}&passkey=${PASSKEY}`,
|
|
size: 1_000_000 * (i + 1),
|
|
pubDate: new Date(Date.UTC(2026, 8, 20, 12, i)),
|
|
categories: ["402"],
|
|
}));
|
|
}
|
|
|
|
function okFeed(r: FeedFetchResult) {
|
|
if (r.kind !== "ok") throw new Error(`expected ok, got ${r.kind}`);
|
|
return r.feed;
|
|
}
|
|
|
|
let server: MockRssServer | undefined;
|
|
afterEach(async () => {
|
|
await server?.close();
|
|
server = undefined;
|
|
});
|
|
|
|
describe("parseFeed 安全解析", () => {
|
|
it("解析 RSS 2.0 字段并脱敏 enclosure 中的 passkey", () => {
|
|
const xml = `<?xml version="1.0"?><rss version="2.0"><channel><title>T & F</title>
|
|
<item><title>Dune & Part.Two.2024.2160p</title><guid>https://pt.example/details.php?id=1</guid>
|
|
<link>https://pt.example/details.php?id=1</link>
|
|
<enclosure url="https://pt.example/download.php?id=1&passkey=${PASSKEY}" length="84202833838" type="application/x-bittorrent"/>
|
|
<pubDate>Sat, 26 Sep 2026 13:14:03 GMT</pubDate><category>401</category><category>电影</category></item>
|
|
</channel></rss>`;
|
|
const feed = parseFeed(xml, { fetchedAt: new Date("2026-09-27T00:00:00Z") });
|
|
expect(feed.format).toBe("rss");
|
|
expect(feed.title).toBe("T & F");
|
|
const item = feed.items[0]!;
|
|
expect(item.title).toBe("Dune & Part.Two.2024.2160p");
|
|
expect(item.guid).toBe("https://pt.example/details.php?id=1");
|
|
expect(item.enclosureUrl).toBe("https://pt.example/download.php?id=1&passkey=[REDACTED]");
|
|
expect(item.enclosureHasSecret).toBe(true);
|
|
expect(item.size).toBe(84202833838);
|
|
expect(item.publishedAt).toBe("2026-09-26T13:14:03.000Z");
|
|
expect(item.categories).toEqual(["401", "电影"]);
|
|
expect(item.rawDigest).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(item.fetchedAt).toBe("2026-09-27T00:00:00.000Z");
|
|
expect(JSON.stringify(feed)).not.toContain(PASSKEY);
|
|
});
|
|
|
|
it("解析 Atom(MP 语义:enclosure 缺失回退首个 link)", () => {
|
|
const xml = `<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"><title>A</title>
|
|
<entry><id>urn:x:1</id><title>X</title><link rel="alternate" href="https://a/1"/>
|
|
<link rel="enclosure" href="https://a/dl/1?token=zzz" length="42"/><published>2026-09-01T00:00:00Z</published>
|
|
<category term="tv"/></entry></feed>`;
|
|
const feed = parseFeed(xml);
|
|
expect(feed.format).toBe("atom");
|
|
expect(feed.items[0]).toMatchObject({
|
|
guid: "urn:x:1",
|
|
link: "https://a/1",
|
|
enclosureUrl: "https://a/1",
|
|
size: 0,
|
|
categories: ["tv"],
|
|
});
|
|
});
|
|
|
|
it("拒绝外部实体(XXE)", () => {
|
|
const xml = `<?xml version="1.0"?><!DOCTYPE rss [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
|
|
<rss><channel><item><title>&xxe;</title></item></channel></rss>`;
|
|
expect(() => parseFeed(xml)).toThrowError(FeedError);
|
|
try {
|
|
parseFeed(xml);
|
|
} catch (e) {
|
|
expect((e as FeedError).kind).toBe("forbidden_dtd");
|
|
}
|
|
});
|
|
|
|
it("拒绝实体膨胀(billion laughs)与内部 DTD 子集", () => {
|
|
const lol = `<?xml version="1.0"?><!DOCTYPE lolz [<!ENTITY lol "lol"><!ENTITY lol2 "&lol;&lol;&lol;">]><rss><channel><title>&lol2;</title></channel></rss>`;
|
|
expect(() => parseFeed(lol)).toThrow(/ENTITY/);
|
|
const subset = `<?xml version="1.0"?><!DOCTYPE rss [ <!ELEMENT rss ANY> ]><rss><channel/></rss>`;
|
|
expect(() => parseFeed(subset)).toThrow(/DTD/);
|
|
});
|
|
|
|
it("条目数超过上限截断,非 Feed 报 not_feed", () => {
|
|
const xml = `<rss><channel>${Array.from({ length: 20 }, (_, i) => `<item><guid>g${i}</guid><title>t${i}</title><link>https://x/${i}</link></item>`).join("")}</channel></rss>`;
|
|
const feed = parseFeed(xml, { maxItems: 5 });
|
|
expect(feed.items).toHaveLength(5);
|
|
expect(feed.truncated).toBe(true);
|
|
expect(() => parseFeed("<html><body>hi</body></html>")).toThrow(/不是 RSS/);
|
|
});
|
|
});
|
|
|
|
describe("MP RssHelper 条目语义", () => {
|
|
it("无标题跳过、无 enclosure 且无 link 跳过、creator → nickname、过期提示识别", () => {
|
|
const xml = `<rss xmlns:dc="http://purl.org/dc/elements/1.1/"><channel>
|
|
<item><title></title><link>https://x/1</link></item>
|
|
<item><title>no link</title></item>
|
|
<item><title>ok</title><link>https://x/3</link><dc:creator>豆友</dc:creator><pubDate>Sat, 26 Sep 2026 13:14:03 GMT</pubDate></item>
|
|
</channel></rss>`;
|
|
const feed = parseFeed(xml);
|
|
expect(feed.items).toHaveLength(1);
|
|
expect(feed.items[0]).toMatchObject({ title: "ok", enclosureUrl: "https://x/3", nickname: "豆友", size: 0 });
|
|
expect(() => parseFeed("RSS Link has expired, You need to get a new one!")).toThrow(/过期/);
|
|
expect(() => parseFeed("not xml")).toThrow(/不是有效的 XML/);
|
|
});
|
|
});
|
|
|
|
describe("站点 RSS 地址获取(MP get_rss_link)", () => {
|
|
it("观众使用专用参数,默认站点回退 default;取最后一个 faqlink", () => {
|
|
const req = buildRssLinkRequest("https://audiences.me/");
|
|
expect(req.url).toBe("https://audiences.me/getrss.php");
|
|
expect(req.form).toMatchObject({ torrent_type: "1", exp: "180", showrows: "50" });
|
|
expect(buildRssLinkRequest("https://pt.example.org").form.torrent_type).toBeUndefined();
|
|
const html = `<a class="faqlink" href="https://a/rss?x=1">1</a><a class="faqlink" href="https://a/torrentrss.php?passkey=${PASSKEY}">2</a>`;
|
|
expect(extractRssLink("https://audiences.me/", html)).toContain("torrentrss.php");
|
|
});
|
|
});
|
|
|
|
describe("redact", () => {
|
|
it("redactUrl/redactText/unredactUrl", () => {
|
|
const u = `https://user:[email protected]/rss.php?rows=50&passkey=${PASSKEY}&linktype=dl`;
|
|
const r = redactUrl(u);
|
|
expect(r).toBe("https://pt.example/rss.php?rows=50&passkey=[REDACTED]&linktype=dl");
|
|
expect(redactText(`fetch failed for ${u}: ECONNRESET`)).not.toContain(PASSKEY);
|
|
expect(redactText(`fetch failed for ${u}`)).not.toContain("pw@");
|
|
expect(unredactUrl(r, { passkey: PASSKEY })).toBe(
|
|
`https://pt.example/rss.php?rows=50&passkey=${PASSKEY}&linktype=dl`,
|
|
);
|
|
expect(redactUrl("https://api.m-team.cc/api/rss/dlv2?sign=abc&t=1&tid=9")).toContain("sign=[REDACTED]");
|
|
});
|
|
});
|
|
|
|
describe("createRssFetcher + mock RSS", () => {
|
|
it("ETag/Last-Modified 条件请求命中 304", async () => {
|
|
server = await createMockRssServer({ items: sample(3), passkey: PASSKEY });
|
|
const fetcher = createRssFetcher();
|
|
const r1 = await fetcher.fetchFeed(server.feedUrl);
|
|
expect(okFeed(r1).items).toHaveLength(3);
|
|
expect(r1.state.etag).toBeTruthy();
|
|
const r2 = await fetcher.fetchFeed(server.feedUrl, r1.state);
|
|
expect(r2.kind).toBe("not_modified");
|
|
expect(server.notModifiedCount).toBe(1);
|
|
expect(server.requests[1]!.headers["if-none-match"]).toBe(r1.state.etag);
|
|
server.addItem(sample(1, "https://other.example")[0]!);
|
|
const r3 = await fetcher.fetchFeed(server.feedUrl, r2.state);
|
|
expect(okFeed(r3).items).toHaveLength(4);
|
|
});
|
|
|
|
it("5xx + Retry-After:退避窗口取 max(指数退避, Retry-After),窗口内跳过", async () => {
|
|
server = await createMockRssServer({ items: sample(2) });
|
|
let now = new Date("2026-09-27T00:00:00Z");
|
|
const fetcher = createRssFetcher({ now: () => now, backoff: { baseMs: 10_000, maxMs: 3600_000 } });
|
|
server.failNext(1, { status: 503, retryAfter: "120" });
|
|
const r1 = await fetcher.fetchFeed(server.feedUrl);
|
|
expect(r1.kind).toBe("error");
|
|
if (r1.kind !== "error") return;
|
|
expect(r1.error.kind).toBe("rate_limited");
|
|
expect(r1.error.retryAfterMs).toBe(120_000);
|
|
expect(r1.state.nextAttemptAt).toBe("2026-09-27T00:02:00.000Z");
|
|
const r2 = await fetcher.fetchFeed(server.feedUrl, r1.state);
|
|
expect(r2).toMatchObject({ kind: "skipped", reason: "backoff" });
|
|
expect(server.requests).toHaveLength(1);
|
|
now = new Date("2026-09-27T00:02:01Z");
|
|
const r3 = await fetcher.fetchFeed(server.feedUrl, r2.state);
|
|
expect(r3.kind).toBe("ok");
|
|
expect(r3.state.consecutiveFailures).toBe(0);
|
|
expect(r3.state.nextAttemptAt).toBeUndefined();
|
|
});
|
|
|
|
it("computeBackoffMs 指数增长封顶,parseRetryAfter 支持 HTTP-date", () => {
|
|
expect([1, 2, 3, 4].map((n) => computeBackoffMs(n, 1000, 5000))).toEqual([1000, 2000, 4000, 5000]);
|
|
const now = new Date("2026-09-27T00:00:00Z");
|
|
expect(parseRetryAfter("Sun, 27 Sep 2026 00:00:30 GMT", now)).toBe(30_000);
|
|
expect(parseRetryAfter("garbage", now)).toBeUndefined();
|
|
});
|
|
|
|
it("连续失败 N 次进入熔断,手动恢复后可继续抓取", async () => {
|
|
server = await createMockRssServer({ items: sample(1) });
|
|
const fetcher = createRssFetcher({ circuitThreshold: 3, backoff: { baseMs: 0, maxMs: 0 } });
|
|
server.failNext(3, { status: 500 });
|
|
let state = initialFeedState();
|
|
for (let i = 0; i < 3; i++) state = (await fetcher.fetchFeed(server.feedUrl, state)).state;
|
|
expect(state.circuit).toBe("open");
|
|
expect(state.consecutiveFailures).toBe(3);
|
|
const skipped = await fetcher.fetchFeed(server.feedUrl, state, { ignoreBackoff: true });
|
|
expect(skipped).toMatchObject({ kind: "skipped", reason: "circuit_open" });
|
|
expect(server.requests).toHaveLength(3);
|
|
const ok = await fetcher.fetchFeed(server.feedUrl, resetFeedCircuit(state));
|
|
expect(ok.kind).toBe("ok");
|
|
expect(ok.state.circuit).toBe("closed");
|
|
});
|
|
|
|
it("熔断冷却后半开探测", async () => {
|
|
server = await createMockRssServer({ items: sample(1) });
|
|
let now = new Date("2026-09-27T00:00:00Z");
|
|
const fetcher = createRssFetcher({ now: () => now, circuitThreshold: 1, circuitCooldownMs: 60_000 });
|
|
server.failNext(1, { status: 502 });
|
|
const r1 = await fetcher.fetchFeed(server.feedUrl);
|
|
expect(r1.state.circuit).toBe("open");
|
|
expect((await fetcher.fetchFeed(server.feedUrl, r1.state)).kind).toBe("skipped");
|
|
now = new Date("2026-09-27T00:01:00Z");
|
|
expect((await fetcher.fetchFeed(server.feedUrl, r1.state)).kind).toBe("ok");
|
|
});
|
|
|
|
it("错误信息与状态中不含 passkey(HTTP 错误与网络错误)", async () => {
|
|
server = await createMockRssServer({ items: sample(1), passkey: "the-right-one" });
|
|
const fetcher = createRssFetcher();
|
|
const wrong = `${server.baseUrl}/rss.xml?passkey=${PASSKEY}`;
|
|
const r = await fetcher.fetchFeed(wrong);
|
|
expect(r.kind).toBe("error");
|
|
expect(JSON.stringify(r)).not.toContain(PASSKEY);
|
|
if (r.kind === "error") expect(r.error.message).toContain("[REDACTED]");
|
|
const dead = `http://127.0.0.1:1/rss.xml?passkey=${PASSKEY}`;
|
|
const r2 = await fetcher.fetchFeed(dead);
|
|
expect(r2.kind).toBe("error");
|
|
expect(JSON.stringify(r2)).not.toContain(PASSKEY);
|
|
});
|
|
|
|
it("响应体超过上限拒绝;恶意 DTD 响应计为失败", async () => {
|
|
server = await createMockRssServer({ items: sample(1) });
|
|
server.setRawBody(`<rss><channel><title>${"x".repeat(10_000)}</title></channel></rss>`);
|
|
const fetcher = createRssFetcher({ maxBytes: 1024 });
|
|
const r = await fetcher.fetchFeed(server.feedUrl);
|
|
expect(r.kind === "error" && r.error.kind).toBe("too_large");
|
|
server.setRawBody(`<!DOCTYPE r [<!ENTITY e SYSTEM "file:///etc/passwd">]><rss><channel/></rss>`);
|
|
const r2 = await createRssFetcher().fetchFeed(server.feedUrl);
|
|
expect(r2.kind === "error" && r2.error.kind).toBe("forbidden_dtd");
|
|
expect(r2.state.consecutiveFailures).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("重叠窗口去重", () => {
|
|
it("乱序/置顶不产生新条目,同 GUID 内容变化视为更新", async () => {
|
|
server = await createMockRssServer({ items: sample(5) });
|
|
server.setConditional(false);
|
|
const fetcher = createRssFetcher();
|
|
const a = applyFeedItems(emptyFeedCursor(), okFeed(await fetcher.fetchFeed(server.feedUrl)).items);
|
|
expect(a.newItems).toHaveLength(5);
|
|
server.shuffle(7);
|
|
server.pinItem("https://pt.example/details.php?id=1003");
|
|
const b = applyFeedItems(a.cursor, okFeed(await fetcher.fetchFeed(server.feedUrl)).items);
|
|
expect(b.newItems).toHaveLength(0);
|
|
expect(b.updatedItems).toHaveLength(0);
|
|
expect(b.unchangedCount).toBe(5);
|
|
server.updateItem("https://pt.example/details.php?id=1001", { title: "Show.S01E02.REPACK.2160p.WEB-DL.H265-GRP" });
|
|
server.addItem({
|
|
guid: "https://pt.example/details.php?id=2000",
|
|
title: "New.Movie.2026.1080p",
|
|
link: "https://pt.example/details.php?id=2000",
|
|
});
|
|
const c = applyFeedItems(b.cursor, okFeed(await fetcher.fetchFeed(server.feedUrl)).items);
|
|
expect(c.newItems.map((i) => i.guid)).toEqual(["https://pt.example/details.php?id=2000"]);
|
|
expect(c.updatedItems.map((i) => i.title)).toEqual(["Show.S01E02.REPACK.2160p.WEB-DL.H265-GRP"]);
|
|
expect(c.possibleGap).toBe(false);
|
|
expect(c.cursor.lastGuidSet).toHaveLength(6);
|
|
});
|
|
|
|
it("与上次窗口零重叠标记疑似缺口;窗口按上限裁剪", () => {
|
|
const mk = (g: string) => ({
|
|
guid: g,
|
|
title: g,
|
|
link: null,
|
|
size: 0,
|
|
categories: [],
|
|
rawDigest: g,
|
|
fetchedAt: "2026-09-27T00:00:00.000Z",
|
|
});
|
|
const a = applyFeedItems(emptyFeedCursor(), [mk("a"), mk("b"), mk("c")], { windowSize: 2 });
|
|
expect(a.possibleGap).toBe(false);
|
|
expect(a.cursor.lastGuidSet).toEqual(["a", "b"]);
|
|
expect(Object.keys(a.cursor.digests)).toEqual(["a", "b"]);
|
|
const b = applyFeedItems(a.cursor, [mk("x"), mk("y")]);
|
|
expect(b.possibleGap).toBe(true);
|
|
expect(b.cursor.lastFetchedAt).toBe("2026-09-27T00:00:00.000Z");
|
|
});
|
|
});
|