/** * 站点索引解析差分:MP `SiteSpider.parse`(PyQuery 回退)vs 本项目 `SiteSpider.parse`。 * 同一 indexer 配置(本项目 definitions/*.json 或内联配置)+ 同一 HTML;期望由 scripts/mp-sync/gen-differential.py rss|indexer 生成(indexer.json)。 * search_type=subtitles 的样本走字幕分支(MP get_subtitle_info)。 */ import { describe, expect, it } from "vitest"; import { BUILTIN_SITE_DEFINITIONS, type IndexerDefinition, SiteSpider } from "../../src/sites/index.ts"; import { diffAgainstRegistry, dumpMismatches, FieldCollector, formatMismatches, loadDoc, readInput, } from "./diff-util.ts"; interface IndexerSample { id: string; definition: | { source: "definition"; id: string } | { source: "inline"; config: unknown; }; input: { file?: string; inline?: string }; query: { keyword: string | null; search_type: "torrents" | "subtitles" }; mp: { is_error?: boolean; torrents?: Array>; status?: string; error?: string; }; } const doc = loadDoc("indexer.json"); function definitionOf(s: IndexerSample): IndexerDefinition { if (s.definition.source === "definition") { return BUILTIN_SITE_DEFINITIONS[s.definition.id] as IndexerDefinition; } return s.definition.config as IndexerDefinition; } /** * 行对齐:条数相同按下标;条数不同(MP 整行丢弃等)且双方都有 page_url 时按 page_url 配对, * 避免一处丢行导致后续所有行错位。行号 i 取 MP 侧下标。 */ function alignRows( mp: Array>, ours: Array>, ): Array<[number, Record, Record]> { const byUrl = mp.length !== ours.length && [...mp, ...ours].every((r) => typeof r.page_url === "string"); if (!byUrl) { return mp.slice(0, Math.min(mp.length, ours.length)).map((m, i) => [i, m, ours[i]!]); } const index = new Map(ours.map((o) => [o.page_url as string, o])); const out: Array<[number, Record, Record]> = []; mp.forEach((m, i) => { const o = index.get(m.page_url as string); if (o) out.push([i, m, o]); }); return out; } describe("站点索引差分(MP SiteSpider Python 回退)", () => { const c = new FieldCollector(); for (const s of doc.samples) { if (s.mp.status === "error") { throw new Error(`${s.id}: MP 运行失败 ${s.mp.error}`); } const subtitles = s.query.search_type === "subtitles"; const html = s.input.file ? readInput(s.input.file) : (s.input.inline ?? ""); const spider = new SiteSpider(definitionOf(s), { ...(s.query.keyword ? { keyword: s.query.keyword } : {}), ...(subtitles ? { searchType: "subtitles" as const } : {}), }); const ours = spider.parse(html); const mpRows = s.mp.torrents ?? []; if (Boolean(s.mp.is_error) !== ours.isError) { c.mismatches.push({ key: `${s.id}.status`, mp: s.mp.is_error, ours: ours.isError, }); } if (mpRows.length !== ours.torrents.length) { c.mismatches.push({ key: `${s.id}.count`, mp: mpRows.length, ours: ours.torrents.length, }); } for (const [i, m, o] of alignRows(mpRows, ours.torrents as Array>)) { // 字幕分支输出字段与种子不同(language_icon/report_url/uploader/torrent_id/subtitle_id…),按双方键并集比较 const fields = subtitles ? [...new Set([...Object.keys(m), ...Object.keys(o)])].sort() : doc.fields; for (const f of fields) c.check(`${s.id}#${i}.${f}`, m[f], o[f]); } } it("有样本且记录了生成来源", () => { expect(doc.samples.length).toBeGreaterThan(10); expect(doc.generatedFrom.commit).toMatch(/^[0-9a-f]{40}$/); }); it("实际不一致集合 == 登记的 deviations", () => { dumpMismatches("indexer", c.mismatches, { compared: c.compared, equal: c.equal, }); const { unregistered, stale } = diffAgainstRegistry(c.mismatches, doc.deviations); expect(formatMismatches(unregistered), "未登记的不一致").toBe(""); expect(stale.join("\n"), "登记了但已不再出现的偏差(请从 indexer.json deviations 删除)").toBe(""); }); it("登记项分类合法", () => { for (const [key, d] of Object.entries(doc.deviations)) { expect(["intentional", "missing", "our_bug", "mp_bug"], key).toContain(d.kind); expect(d.note.length, key).toBeGreaterThan(0); } }); });