feat(adapters): RSS and PT site adapters ported from MoviePilot v3 + mocks (W1-3b)

- rss: port of MP RssHelper._parse_impl/get_rss_link semantics with safe
  XML (entities off, DTD/ENTITY rejected, size/item caps), ETag/Last-Modified,
  backoff + Retry-After, circuit breaker with manual reset, passkey redaction,
  overlap-window GUID+digest dedupe
- sites/spider: TS port of MP SiteSpider (indexer schema, search URL builder,
  field DSL, filters, Jinja templates via nunjucks, login-page detection);
  audiences definition hand-written as JSON; raw category id preserved;
  shared per-site token bucket
- sites/mteam: port of MP MTorrentSpider (params, result mapping, promo
  factors, genDlToken flow) without embedding the API key in enclosures;
  business code/message checks
- testkit: mock RSS server, mock NexusPHP site, mock M-Team API
- docs: W1-3b migration mapping appended to porting ledger

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-27 18:10:34 +08:00
1 parent ef2e504a94
commit a6d049db81
47 files changed
+5414

No files matched your search

@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { createSiteRateLimiter } from "./rate-limit";
import { parseSizeMp as parseSize } from "./spider/spider";
import { parseCount, parseSiteTime } from "./util";
describe("站点令牌桶", () => {
it("burst 内不等待,之后按间隔补充;penalize 顺延;并发按顺序排队", async () => {
let clock = 0;
const sleeps: number[] = [];
const l = createSiteRateLimiter({
minIntervalMs: 1000,
burst: 2,
now: () => clock,
sleep: async (ms) => {
sleeps.push(ms);
clock += ms;
},
});
await Promise.all([l.acquire(), l.acquire(), l.acquire()]);
expect(sleeps).toEqual([1000]);
l.penalize(5000);
expect(l.tryAcquire()).toBe(false);
await l.acquire();
expect(sleeps.at(-1)).toBe(5000);
});
it("取消等待", async () => {
const l = createSiteRateLimiter({ minIntervalMs: 60_000 });
await l.acquire();
const ac = new AbortController();
const p = l.acquire(ac.signal);
ac.abort(new Error("cancelled"));
await expect(p).rejects.toThrow("cancelled");
});
});
describe("解析辅助", () => {
it("parseSize/parseCount/parseSiteTime", () => {
expect(parseSize("78.42 GB")).toBe(84202833838);
expect(parseSize("850 KB")).toBe(870400);
expect(parseSize("n/a")).toBe(0);
expect(parseSize("1,234.56 MiB")).toBe(1294529987);
expect(parseSize("2.1 TB")).toBe(2308974418330);
expect(parseCount("1,204")).toBe(1204);
expect(parseCount("532 / 3")).toBe(532);
expect(parseCount("--")).toBeNull();
expect(parseSiteTime("2026-09-26 21:14:03", "+08:00")).toBe("2026-09-26T13:14:03.000Z");
expect(parseSiteTime("3小时前", "+08:00")).toBeNull();
});
});