Files
UHD_Media_Tool/packages/adapters/test/differential/indexer.test.ts
T
rootandClaude Opus 5.5 36dc048e89 feat(parser,sites): W5-D port title/zhconv/site parsing gaps found by MP differential
Title recognition (parseRelease / parseReleasePath):
- anime prefix tags (★N月新番, [国漫], 【22年日剧】), category lead bracket, `A / B` cn aliases
- `Name [01]`, `[TV 04]`, `#13`, `名称 12.mkv`, `S01 13-14`, `E13-14` episodes
- lead bracket as title vs group, trailing [Group], YTS.MX/Hall_of_C style groups, 字幕组 suffix
- `[dot.release.name][…]` unbracketing, `Season 2017` as year, leading date not airDate,
  date-only parent dir does not merge as TV
- original disc: Blu-ray + AVC/HEVC/VC-1/MPEG-2 without x26x/H.26x -> BluRay-Disc
- `{[tmdbid=;type=;s=;e=]}` explicit block, bangumi/anilist ids
- bare [1080]/[5.1], DTS-HD MA5.1, £ multi-group, 合集, pinyin title -> cn from subtitle,
  streaming platform table, CHS/CHT/GB/BIG5 in subtitle
zh-map: add 迴滾魷瑯臥駭; document one-to-many chars kept out.
Sites: tbody branch + header-row tightening via movieclaw-import rowSelector (18 definitions),
row-level case/hr/labels include the row itself, subtitle branch (searchType: "subtitles"),
parseSizeMp banker's rounding.
Differential snapshots regenerated; MP test sample sources registered in ledger.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 23:09:14 +08:00

122 lines
4.4 KiB
TypeScript

/**
* 站点索引解析差分: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<Record<string, unknown>>;
status?: string;
error?: string;
};
}
const doc = loadDoc<IndexerSample>("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<Record<string, unknown>>,
ours: Array<Record<string, unknown>>,
): Array<[number, Record<string, unknown>, Record<string, unknown>]> {
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<string, unknown>, Record<string, unknown>]> = [];
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<Record<string, unknown>>)) {
// 字幕分支输出字段与种子不同(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);
}
});
});