feat(adapters): port MoviePilot open site framework (7 spiders, 20 user parsers) + MovieClaw v0.27.0 site definitions + mp-sync tools (W1-3c)

- sites/spiders: Haidan/HDDolby/RousiPro/SunnyPT/TNode/TorrentLeech/Yema (+mTorrent wrapper), specialized SiteAdapter, indirect download resolved at download time
- sites/user-parsers: SiteParserBase + all 20 MP SiteSchema parsers (lxml XPath via xpath+xmldom), refreshSiteUserData with schema fallback
- sites/definitions: zod SiteDefinition schemas, 23 MovieClaw v0.27.0 (MIT) definitions via scripts/mp-sync/movieclaw-import.mjs, registry getSiteDefinition/listSiteDefinitions, NOTICE
- scripts/mp-sync: ledger.json (upstream sha256), check.mjs, build-ledger.mjs; docs/porting/mp-sync-ledger.md
- license-and-provenance: add copied-mit kind, re-anchor MovieClaw rows to v0.27.0

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 18:39:27 +08:00
1 parent cf85481d95
commit d9f2af2f3c
166 files changed
+16577 -186

No files matched your search

@@ -0,0 +1,52 @@
import { createMockFetch, TNODE_HOME_HTML, TNODE_HOME_NO_TOKEN, TNODE_SEARCH_OK } from "@umt/testkit";
import { describe, expect, it } from "vitest";
import { adapterWith, expectSiteError, testDef, withTorrent } from "./testing.ts";
import { parseTnodeToken, tnodeSearchParams, tnodeSpider } from "./tnode.ts";
const def = testDef("TNodeSpider", { domain: "https://tnode.example/" });
describe("TNodeSpider(MP tnode.py)", () => {
it("__get_params / token 解析", () => {
expect(tnodeSearchParams("tt0923811", 1)).toMatchObject({
page: 2,
size: 100,
type: "imdbid",
keyword: "tt0923811",
});
expect(tnodeSearchParams(null)).toMatchObject({ type: "title", keyword: "", category: [501, 502, 503, 504] });
expect(parseTnodeToken(TNODE_HOME_HTML)).toBe("CSRF-TOKEN-XYZ");
});
it("列表解析:CSRF token 缓存、系数直取、时间戳转 UTC", async () => {
const m = createMockFetch({
"GET /": { body: TNODE_HOME_HTML },
"POST /api/torrent/advancedSearch": { body: TNODE_SEARCH_OK },
});
const a = adapterWith(def, tnodeSpider, m.fetch);
const page = await a.search("eva");
await a.list(1);
expect(m.requests.filter((r) => r.url.pathname === "/")).toHaveLength(1);
expect(m.requests[1]!.headers["x-csrf-token"]).toBe("CSRF-TOKEN-XYZ");
const [x, y] = page.items;
expect(x).toMatchObject({
siteReleaseId: "3301",
promotion: { type: "free", downloadFactor: 0, uploadFactor: 1 },
uploadedAt: "2024-09-20T00:00:00.000Z",
imdbId: "tt0923811",
detailUrl: "https://tnode.example/torrent/info/3301",
hr: "unknown",
});
expect(y!.promotion.type).toBe("2x50%");
const t = withTorrent(m.fetch, (u) => u.pathname.startsWith("/api/torrent/download/"));
await adapterWith(def, tnodeSpider, t.fetch).downloadTarget("3301");
expect(t.hits[0]!.headers.get("cookie")).toBe("c=COOKIESECRET");
});
it("取不到 token → auth_expired;缺 Cookie → auth_missing;搜索 HTTP 403 → auth_invalid", async () => {
const m = createMockFetch({ "GET /": { body: TNODE_HOME_NO_TOKEN } });
await expectSiteError(adapterWith(def, tnodeSpider, m.fetch).list(1), "auth_expired");
await expectSiteError(adapterWith(def, tnodeSpider, m.fetch, {}).list(1), "auth_missing");
const f = createMockFetch({ "GET /": { body: TNODE_HOME_HTML }, "/api/torrent/advancedSearch": { status: 403 } });
await expectSiteError(adapterWith(def, tnodeSpider, f.fetch).list(1), "auth_invalid");
});
});