test(porting): differential tests against MoviePilot Rust-accelerated parsers

Snapshot differential tests (known deviations registered, current state passes):
- title/path recognition: 216 samples vs MP MetaInfo/MetaInfoPath
- filter rules: 42 rules x 28 torrents vs MP FilterModule
- RSS/indexer parsing: 15 feeds, 72 indexer samples
- zhconv: 69 samples
Generator: scripts/mp-sync/gen-differential.py; report: docs/porting/mp-rust-logic-parity.md

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 22:44:11 +08:00
1 parent b86f75c011
commit 591cc78c7d
44 files changed
+32206

No files matched your search

@@ -0,0 +1,89 @@
/**
* MP 差分测试共用工具:读取 scripts/mp-sync/gen-differential.py 生成的期望 JSON,登记偏差集合与实际偏差集合比对。
* 偏差 key:`<sampleId>.status` / `<sampleId>.count` / `<sampleId>#<行号>.<字段>`。
*/
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
export const HERE = dirname(fileURLToPath(import.meta.url));
export type DeviationKind = "intentional" | "missing" | "our_bug" | "mp_bug";
export interface Deviation {
kind: DeviationKind;
note: string;
/** 仅在条件成立时才出现的偏差;local_tz_not_utc = 宿主本地时区偏移非 0 */
condition?: "local_tz_not_utc";
}
function conditionHolds(d: Deviation): boolean {
if (d.condition === "local_tz_not_utc") {
return new Date("2026-09-26T00:00:00").getTimezoneOffset() !== 0;
}
return true;
}
export interface DifferentialDoc<S> {
kind: string;
generatedFrom: Record<string, string>;
fields: string[];
samples: S[];
deviations: Record<string, Deviation>;
}
export function loadDoc<S>(name: string): DifferentialDoc<S> {
return JSON.parse(readFileSync(join(HERE, name), "utf8")) as DifferentialDoc<S>;
}
export function readInput(rel: string): string {
return readFileSync(join(HERE, rel), "utf8");
}
export interface Mismatch {
key: string;
mp: unknown;
ours: unknown;
}
/** null / undefined 视为同一"缺省";其余按 JSON 结构比较 */
export function sameValue(a: unknown, b: unknown): boolean {
if (a == null && b == null) return true;
return JSON.stringify(a) === JSON.stringify(b);
}
export class FieldCollector {
readonly mismatches: Mismatch[] = [];
compared = 0;
equal = 0;
check(key: string, mp: unknown, ours: unknown): void {
this.compared++;
if (sameValue(mp, ours)) this.equal++;
else this.mismatches.push({ key, mp, ours });
}
}
/** 实际不一致集合 vs 登记集合:返回两个方向的差集(均为空才通过)。 */
export function diffAgainstRegistry(
actual: Mismatch[],
registry: Record<string, Deviation>,
): { unregistered: Mismatch[]; stale: string[] } {
const actualKeys = new Set(actual.map((m) => m.key));
return {
unregistered: actual.filter((m) => !(m.key in registry)),
stale: Object.entries(registry)
.filter(([k, d]) => !actualKeys.has(k) && conditionHolds(d))
.map(([k]) => k),
};
}
/** 设置环境变量 DIFF_DUMP_DIR 时把实际不一致写成 <name>.mismatches.json(整理 deviations 用) */
export function dumpMismatches(name: string, ms: Mismatch[], stats?: { compared: number; equal: number }): void {
const dir = process.env.DIFF_DUMP_DIR;
if (dir) {
writeFileSync(join(dir, `${name}.mismatches.json`), `${JSON.stringify({ stats, mismatches: ms }, null, 2)}\n`);
}
}
export function formatMismatches(ms: Mismatch[]): string {
return ms.map((m) => `${m.key}\n mp: ${JSON.stringify(m.mp)}\n ours: ${JSON.stringify(m.ours)}`).join("\n");
}
@@ -0,0 +1,73 @@
/**
* 把 @umt/testkit 的 mock RSS / NexusPHP fixture 导出成文件,供 MP 差分生成脚本(scripts/mp-sync/gen-differential.py rss|indexer)读取。
* 运行:deno run -A packages/adapters/test/differential/export-inputs.ts
* 只写 `inputs/rss/tk-*.xml` 与 `inputs/indexer/tk-*.html.txt`;手写样本(hw-*)不受影响。
*/
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
AUDIENCES_FIXTURE_RELEASES,
CHDBITS_LIST_PAGE,
CHDBITS_LOGIN_PAGE,
createMockNexusPhpSite,
HHANCLUB_LIST_PAGE,
type MockFeedItem,
renderAtom,
renderRss,
} from "@umt/testkit";
const here = dirname(fileURLToPath(import.meta.url));
const rssDir = join(here, "inputs", "rss");
const idxDir = join(here, "inputs", "indexer");
mkdirSync(rssDir, { recursive: true });
mkdirSync(idxDir, { recursive: true });
const items: MockFeedItem[] = [
{
guid: "audiences-812345",
title: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.Atmos.7.1-FraMeSToR",
link: "https://audiences.me/details.php?id=812345",
enclosureUrl: "https://audiences.me/download.php?id=812345&passkey=0123456789abcdef",
size: 84203386470,
pubDate: new Date(Date.UTC(2026, 8, 26, 13, 14, 3)),
categories: ["电影", "401"],
description: "沙丘2 / 沙丘:第二部 | <b>杜比视界</b> 国英双语 中字",
},
{
guid: "audiences-812346",
title: "Keep.Running.S12E01.20240426.2160p.WEB-DL.H265.AAC-LelveTV & Friends",
link: "https://audiences.me/details.php?id=812346",
enclosureUrl: "https://audiences.me/download.php?id=812346",
size: 5497558138,
pubDate: new Date(Date.UTC(2026, 8, 26, 12, 0, 0)),
},
{
guid: "audiences-812347",
title: "The.Bear.S03.2160p.DSNP.WEB-DL.DDP5.1.Atmos.DV.H.265-FLUX",
link: "https://audiences.me/details.php?id=812347",
},
];
writeFileSync(join(rssDir, "tk-rss-basic.xml"), renderRss("观众 Torrents", items));
writeFileSync(join(rssDir, "tk-atom-basic.xml"), renderAtom("观众 Atom", items));
const site = await createMockNexusPhpSite({
releases: AUDIENCES_FIXTURE_RELEASES,
});
try {
const res = await fetch(`${site.baseUrl}/torrents.php?page=0`, {
headers: { cookie: site.cookie },
});
writeFileSync(join(idxDir, "tk-audiences-list.html.txt"), await res.text());
site.setLoggedIn(false);
const login = await fetch(`${site.baseUrl}/torrents.php`, {
headers: { cookie: site.cookie },
});
writeFileSync(join(idxDir, "tk-audiences-login.html.txt"), await login.text());
} finally {
await site.close();
}
writeFileSync(join(idxDir, "tk-hhanclub-list.html.txt"), HHANCLUB_LIST_PAGE);
writeFileSync(join(idxDir, "tk-chdbits-list.html.txt"), CHDBITS_LIST_PAGE);
writeFileSync(join(idxDir, "tk-chdbits-login.html.txt"), CHDBITS_LOGIN_PAGE);
console.log("exported to", here);
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,123 @@
/**
* 站点索引解析差分:MP `SiteSpider.parse`(PyQuery 回退)vs 本项目 `SiteSpider.parse`。
* 同一 indexer 配置(本项目 definitions/*.json 或内联配置)+ 同一 HTML;期望由 scripts/mp-sync/gen-differential.py rss|indexer 生成(indexer.json)。
*/
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}`);
}
if (s.query.search_type === "subtitles") {
// 本项目 SiteSpider 未移植字幕分支(parse_indexer_subtitles)
c.mismatches.push({
key: `${s.id}.status`,
mp: `ok:${s.mp.torrents?.length ?? 0}`,
ours: "unsupported",
});
continue;
}
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 } : {});
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>>)) {
for (const f of doc.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);
}
});
});
@@ -0,0 +1,49 @@
{
"id": "unit-case",
"name": "Unit(case)",
"domain": "https://pt.example.com/",
"search": { "paths": [{ "path": "torrents.php" }] },
"category": { "movie": [{ "id": "401" }], "tv": [{ "id": "402" }] },
"torrents": {
"list": { "selector": "table.torrents > tr:has(\"table.torrentname\")" },
"fields": {
"title_default": { "selector": "a[href*=\"details.php?id=\"]" },
"title_optional": { "selector": "a[title][href*=\"details.php?id=\"]", "attribute": "title" },
"title": {
"text": "{% if fields['title_optional'] %}{{ fields['title_optional'] }}{% else %}{{ fields['title_default'] }}{% endif %}"
},
"details": { "selector": "a[href*=\"details.php?id=\"]", "attribute": "href" },
"download": { "selector": "a[href*=\"download.php?id=\"]", "attribute": "href" },
"imdbid": {
"selector": "a[href*=\"imdb.com/title/tt\"]",
"attribute": "href",
"filters": [{ "name": "re_search", "args": ["tt\\d+", 0] }]
},
"date_elapsed": { "selector": "td:nth-child(4) > span" },
"date_added": { "selector": "td:nth-child(4) > span", "attribute": "title" },
"date": {
"text": "{% if fields['date_elapsed'] or fields['date_added'] %}{{ fields['date_added'] if fields['date_added'] else fields['date_elapsed'] }}{% else %}now{% endif %}",
"filters": [{ "name": "dateparse", "args": "%Y-%m-%d %H:%M:%S" }]
},
"size": { "selector": "td:nth-child(5)" },
"seeders": { "selector": "td:nth-child(6)" },
"leechers": { "selector": "td:nth-child(7)" },
"grabs": { "selector": "td:nth-child(8)" },
"downloadvolumefactor": { "case": { "img.pro_free": 0, "img.pro_50pctdown2up": 0.5, "*": 1 } },
"uploadvolumefactor": { "case": { "img.pro_50pctdown2up": 2, "img.pro_2up": 2, "*": 1 } },
"description": { "selector": "font.subtitle", "remove": "span, a" },
"labels": { "selector": "span.label" },
"freedate": {
"selector": "img[class^=\"pro_\"]",
"attribute": "title",
"filters": [{ "name": "re_search", "args": ["\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}", 0] }]
},
"hr": { "selector": "img.hitandrun" },
"category": {
"selector": "a[href*=\"?cat=\"]",
"attribute": "href",
"filters": [{ "name": "querystring", "args": "cat" }]
}
}
}
}
@@ -0,0 +1,95 @@
<!DOCTYPE html>
<html><head><title>Unit :: 种子</title></head><body>
<a href="logout.php">退出</a>
<table class="torrents">
<tr><td class="colhead">类型</td><td class="colhead">标题</td><td class="colhead">评论</td><td class="colhead">时间</td><td class="colhead">大小</td><td class="colhead">种子</td><td class="colhead">下载</td><td class="colhead">完成</td></tr>
<tr>
<td><a href="?cat=402">TV</a></td>
<td>
<table class="torrentname">
<tr>
<td class="embedded">
<a href="details.php?id=100" title="Optional.Title">Default.Title</a>
<a href="download.php?id=100">DL</a>
<a href="https://www.imdb.com/title/tt1234567/">IMDb</a>
<font class="subtitle">Main description <span>remove</span><a>link</a></font>
<span class="label">FREE</span>
<img class="hitandrun" />
<img class="pro_free" title="Free until 2026-10-01 12:00:00" />
<span class="factor">x0.5</span><span class="upfactor">2.0X</span>
</td>
</tr>
</table>
</td>
<td></td>
<td><span title="2025-05-01 12:13:14">1 hour ago</span></td>
<td>1.5 GB</td>
<td>1,234</td>
<td>5/7</td>
<td>9</td>
</tr>
<tr>
<td><a href="?cat=401&amp;x=1">Movie</a></td>
<td>
<table class="torrentname">
<tr>
<td class="embedded">
<a href="//pt.example.com/details.php?id=101"><b>No.Title.Attr.2026</b></a>
<a href="magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567">magnet-download.php?id=101</a>
<font class="subtitle">第一行<br/>第二行 多空格</font>
<span class="label">国语</span><span class="label"></span><span class="label">中字</span>
<img class="pro_50pctdown2up" title="2026-10-02 08:00:00" />
<span class="factor">50%</span>
</td>
</tr>
</table>
</td>
<td>0</td>
<td><span>3小时前</span></td>
<td>1,234.56 MiB</td>
<td>-</td>
<td>0</td>
<td></td>
</tr>
<tr>
<td><a href="?cat=499">Other</a></td>
<td>
<table class="torrentname">
<tr>
<td class="embedded">
<a href="/details.php?id=102" title="Slash.Link.Title">Slash</a>
<a href="pt.example.com/download.php?id=102">DL</a>
<font class="subtitle"> 前后空格 </font>
<img class="pro_2up" />
</td>
</tr>
</table>
</td>
<td>2</td>
<td>2026/09/26 21:14</td>
<td>850 KB</td>
<td>12 / 30</td>
<td>abc</td>
<td>1,000</td>
</tr>
<tr>
<td><a href="?cat=401">Movie</a></td>
<td>
<table class="torrentname">
<tr>
<td class="embedded">
<a href="details.php?id=103" title="Tiny.Size">Tiny</a>
<a href="/download.php?id=103&amp;passkey=abc">DL</a>
</td>
</tr>
</table>
</td>
<td>0</td>
<td>2026-09-26 21:14:03</td>
<td>2.5 B</td>
<td>0</td>
<td>0</td>
<td>0</td>
</tr>
</table>
</body></html>
@@ -0,0 +1,32 @@
{
"id": "unit-selector",
"name": "Unit(selector)",
"domain": "https://pt.example.com",
"media_type": "movie",
"search": { "paths": [{ "path": "torrents.php" }] },
"torrents": {
"list": { "selector": "table.torrents tr:has(table.torrentname)" },
"fields": {
"title": {
"selector": "a[href*=\"details.php\"]",
"filters": [{ "name": "replace", "args": [".", " "] }, { "name": "appendleft", "args": "[U] " }]
},
"description": { "selector": "font.subtitle", "contents": -1 },
"details": {
"selector": "a[href*=\"details.php\"]",
"attribute": "href",
"filters": [{ "name": "split", "args": ["?", -1] }, { "name": "appendleft", "args": "details.php?" }]
},
"download": { "selector": "a[href*=\"download.php\"]", "attribute": "href" },
"date_added": { "selector": "td:nth-child(4)" },
"size": { "selector": "td", "index": 4 },
"seeders": { "selector": "td:nth-child(6)", "filters": [{ "name": "re_search", "args": ["(\\d+)\\s*/", 1] }] },
"leechers": { "selector": "td:nth-child(7)" },
"grabs": { "selector": "td:nth-child(8)", "filters": [{ "name": "strip" }] },
"downloadvolumefactor": { "selector": "span.factor" },
"uploadvolumefactor": { "selector": "span.upfactor" },
"labels": { "selector": "span.label", "attribute": "class" },
"hr": { "selector": "img.nonexistent" }
}
}
}
@@ -0,0 +1,6 @@
<html><head><title>Unit :: 种子</title></head><body>
<table class="torrents">
<tr class="sticky free_bg"><td><a href="details.php?id=200" title="Row.Class.Free">Row</a></td><td>1 GB</td></tr>
<tr class="normal"><td><a href="details.php?id=201" title="Row.Plain">Row</a></td><td>2 GB</td></tr>
</table>
</body></html>
@@ -0,0 +1,17 @@
{
"id": "unit-self",
"name": "Unit(self)",
"domain": "https://pt.example.com/",
"search": { "paths": [{ "path": "torrents.php" }] },
"torrents": {
"list": { "selector": "table.torrents > tr" },
"fields": {
"title": { "selector": "a[title]", "attribute": "title" },
"details": { "selector": "a[title]", "attribute": "href" },
"size": { "selector": "td:nth-child(2)" },
"downloadvolumefactor": { "case": { "tr.free_bg": 0, "*": 1 } },
"hr": { "selector": "tr.sticky" },
"labels": { "selector": "tr.sticky", "attribute": "class" }
}
}
}
@@ -0,0 +1,75 @@
<!DOCTYPE html><html><head><meta charset="utf-8"><title>观众 :: 种子</title></head>
<body><div id="info_block"><a href="userdetails.php?id=10001">mockuser</a>
<span class="site-userbar__compact-metric--uploaded">12.5 TB</span>
<span class="site-userbar__compact-metric--downloaded">3.2 TB</span>
<a href="logout.php">退出</a></div><div id="outer"><p align="center"><a href="?page=0"><b>1</b></a></p>
<table class="torrents torrents-table" cellspacing="0" cellpadding="5" width="100%"><tbody><tr><td class="colhead">类型</td><td class="colhead">标题</td><td class="colhead">评论</td><td class="colhead">存活时间</td><td class="colhead">大小</td><td class="colhead">种子</td><td class="colhead">下载</td><td class="colhead">完成</td><td class="colhead">发布者</td></tr><tr>
<td class="rowfollow nowrap" valign="middle"><a href="?cat=401"><img class="c_401" src="pic/cattrans.gif" alt="电影" title="电影"></a></td>
<td class="rowfollow" width="100%" align="left"><table class="torrentname" width="100%"><tr>
<td class="embedded"><a title="Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.Atmos.7.1-FraMeSToR" href="details.php?id=812345&amp;hit=1"><b>Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.Atmos.7.1-FraMeSToR</b></a> <img class="pro_free" src="pic/trans.gif" alt="Free" title="2026-10-01 12:00:00"><br><span class="torrent-subtitle-line">沙丘2 / 沙丘:第二部 | 杜比视界 国英双语 中字</span></td>
<td class="embedded" width="20"><a href="download.php?id=812345"><img class="download" src="pic/trans.gif" alt="download"></a></td>
</tr></table></td>
<td class="rowfollow"><a href="comment.php?id=812345">0</a></td>
<td class="rowfollow nowrap"><span title="2026-09-26 21:14:03">3小时前</span></td>
<td class="rowfollow">78.42 GB</td>
<td class="rowfollow" align="center"><b><a href="details.php?id=812345&amp;dllist=1#seeders">1,204</a></b></td>
<td class="rowfollow" align="center"><b><a href="details.php?id=812345&amp;dllist=1#leechers">17</a></b></td>
<td class="rowfollow"><a href="viewsnatches.php?id=812345"><b>3,388</b></a></td>
<td class="rowfollow"><a href="userdetails.php?id=2">uploader1</a></td>
</tr>
<tr>
<td class="rowfollow nowrap" valign="middle"><a href="?cat=403"><img class="c_403" src="pic/cattrans.gif" alt="综艺" title="综艺"></a></td>
<td class="rowfollow" width="100%" align="left"><table class="torrentname" width="100%"><tr>
<td class="embedded"><a title="Keep.Running.S12E01.20240426.2160p.WEB-DL.H265.AAC-LelveTV" href="details.php?id=812346&amp;hit=1"><b>Keep.Running.S12E01.20240426.2160p.WEB-DL.H265.AAC-LelveTV</b></a> <img class="hitandrun" src="pic/trans.gif" alt="H&amp;R" title="H&amp;R"><br><span class="torrent-subtitle-line">奔跑吧 第十二季 第1期</span></td>
<td class="embedded" width="20"><a href="download.php?id=812346"><img class="download" src="pic/trans.gif" alt="download"></a></td>
</tr></table></td>
<td class="rowfollow"><a href="comment.php?id=812346">0</a></td>
<td class="rowfollow nowrap"><span title="2026-09-26 20:00:00">2026-09-26 20:00:00</span></td>
<td class="rowfollow">5.12 GB</td>
<td class="rowfollow" align="center"><b><a href="details.php?id=812346&amp;dllist=1#seeders">88</a></b></td>
<td class="rowfollow" align="center"><b><a href="details.php?id=812346&amp;dllist=1#leechers">2</a></b></td>
<td class="rowfollow"><a href="viewsnatches.php?id=812346"><b>410</b></a></td>
<td class="rowfollow"><a href="userdetails.php?id=2">anonymous</a></td>
</tr>
<tr>
<td class="rowfollow nowrap" valign="middle"><a href="?cat=402"><img class="c_402" src="pic/cattrans.gif" alt="剧集" title="剧集"></a></td>
<td class="rowfollow" width="100%" align="left"><table class="torrentname" width="100%"><tr>
<td class="embedded"><a title="The.Bear.S03.2160p.DSNP.WEB-DL.DDP5.1.Atmos.DV.H.265-FLUX" href="details.php?id=812347&amp;hit=1"><b>The.Bear.S03.2160p.DSNP.WEB-DL.DDP5.1.Atmos.DV.H.265-FLUX</b></a> <img class="pro_50pctdown2up" src="pic/trans.gif" alt="2X 50%" title="2026-09-29 08:30:00"><br><span class="torrent-subtitle-line">熊家餐馆 第三季</span></td>
<td class="embedded" width="20"><a href="download.php?id=812347"><img class="download" src="pic/trans.gif" alt="download"></a></td>
</tr></table></td>
<td class="rowfollow"><a href="comment.php?id=812347">0</a></td>
<td class="rowfollow nowrap"><span title="2026-09-25 08:30:00">2026-09-25 08:30:00</span></td>
<td class="rowfollow">1,234.56 MiB</td>
<td class="rowfollow" align="center"><b><a href="details.php?id=812347&amp;dllist=1#seeders">35</a></b></td>
<td class="rowfollow" align="center"><b><a href="details.php?id=812347&amp;dllist=1#leechers">0</a></b></td>
<td class="rowfollow"><a href="viewsnatches.php?id=812347"><b>120</b></a></td>
<td class="rowfollow"><a href="userdetails.php?id=2">anonymous</a></td>
</tr>
<tr>
<td class="rowfollow nowrap" valign="middle"><a href="?cat=402"><img class="c_402" src="pic/cattrans.gif" alt="剧集" title="剧集"></a></td>
<td class="rowfollow" width="100%" align="left"><table class="torrentname" width="100%"><tr>
<td class="embedded"><a href="details.php?id=812348&amp;hit=1"><b>The.Last.of.Us.S02.2160p.MAX.WEB-DL.DDP5.1.Atmos.D…</b></a></td>
<td class="embedded" width="20"><a href="download.php?id=812348"><img class="download" src="pic/trans.gif" alt="download"></a></td>
</tr></table></td>
<td class="rowfollow"><a href="comment.php?id=812348">0</a></td>
<td class="rowfollow nowrap"><span title="2026-09-24 10:00:00">2026-09-24 10:00:00</span></td>
<td class="rowfollow">2.1 TB</td>
<td class="rowfollow" align="center"><b><a href="details.php?id=812348&amp;dllist=1#seeders">12</a></b></td>
<td class="rowfollow" align="center"><b><a href="details.php?id=812348&amp;dllist=1#leechers">1</a></b></td>
<td class="rowfollow"><a href="viewsnatches.php?id=812348"><b>44</b></a></td>
<td class="rowfollow"><a href="userdetails.php?id=2">anonymous</a></td>
</tr>
<tr>
<td class="rowfollow nowrap" valign="middle"><a href="?cat=406"><img class="c_406" src="pic/cattrans.gif" alt="纪录片" title="纪录片"></a></td>
<td class="rowfollow" width="100%" align="left"><table class="torrentname" width="100%"><tr>
<td class="embedded"><a title="Planet.Earth.III.2023.2160p.BluRay.REMUX.HEVC.DTS-HD.MA.5.1-GRP" href="details.php?id=812349&amp;hit=1"><b>Planet.Earth.III.2023.2160p.BluRay.REMUX.HEVC.DTS-HD.MA.5.1-GRP</b></a> <img class="pro_2up" src="pic/trans.gif" alt="2X" title=""><br><span class="torrent-subtitle-line">地球脉动 第三季</span></td>
<td class="embedded" width="20"><a href="download.php?id=812349"><img class="download" src="pic/trans.gif" alt="download"></a></td>
</tr></table></td>
<td class="rowfollow"><a href="comment.php?id=812349">0</a></td>
<td class="rowfollow nowrap"><span title="2026-09-23 10:00:00">2026-09-23 10:00:00</span></td>
<td class="rowfollow">850 KB</td>
<td class="rowfollow" align="center"><b><a href="details.php?id=812349&amp;dllist=1#seeders">5</a></b></td>
<td class="rowfollow" align="center"><b><a href="details.php?id=812349&amp;dllist=1#leechers">0</a></b></td>
<td class="rowfollow"><a href="viewsnatches.php?id=812349"><b>9</b></a></td>
<td class="rowfollow"><a href="userdetails.php?id=2">anonymous</a></td>
</tr></tbody></table></div></body></html>
@@ -0,0 +1,3 @@
<!DOCTYPE html><html><head><meta charset="utf-8"><title>观众 :: 登录</title></head>
<body><form method="post" action="takelogin.php"><p>你需要启用cookies才能登录</p>
<input type="text" name="username"><input type="password" name="password"><input type="submit" value="登录"></form></body></html>
@@ -0,0 +1,35 @@
<!DOCTYPE html><html><head><title>CHDBits :: 种子</title></head><body>
<table id="info_block"><tr><td><a href="userdetails.php?id=42" class="VIP_Name">carol</a> <a href="logout.php">退出</a>
<font class="color_uploaded">上传量:</font> 25.052 TB <font class="color_downloaded">下载量:</font> 1.2 TB</td></tr></table>
<table class="torrents" cellspacing="0" cellpadding="5" width="100%">
<tr><td class="colhead">类型</td><td class="colhead">标题</td><td class="colhead">评论</td><td class="colhead">时间</td><td class="colhead">大小</td><td class="colhead">种子</td><td class="colhead">下载</td><td class="colhead">完成</td><td class="colhead">发布者</td></tr>
<tr>
<td class="rowfollow nowrap"><a href="?cat=402"><img class="c_tvseries" src="pic/cattrans.gif" alt="TV Series"/></a></td>
<td class="rowfollow"><table class="torrentname" width="100%"><tr>
<td class="embedded"><a title="The.Last.of.Us.S02.2025.1080p.BluRay.x264.DTS-HD.MA.5.1-CHD" href="details.php?id=555001&amp;hit=1"><b>The.Last.of.Us.S02.2025.1080p.BluRay...</b></a>
<img class="pro_free2up" src="pic/trans.gif" alt="2X Free"/> <span title="2026-09-30 12:00:00">[限时]</span><br/><font class="subtitle">最后生还者 第二季 <span class="tags">中字</span></font></td>
<td class="embedded" width="20"><a href="download.php?id=555001"><img class="download" alt="download"/></a></td>
</tr></table></td>
<td class="rowfollow">3</td>
<td class="rowfollow nowrap"><span title="2026-09-25 08:00:00">2天</span></td>
<td class="rowfollow">45.20<br/>GB</td>
<td class="rowfollow"><b><a href="details.php?id=555001#peers">42</a></b></td>
<td class="rowfollow">3</td>
<td class="rowfollow"><a href="viewsnatches.php?id=555001"><b>100</b></a></td>
<td class="rowfollow"><a href="userdetails.php?id=1">uploader</a></td>
</tr>
<tr>
<td class="rowfollow nowrap"><a href="?cat=401"><img class="c_movie" src="pic/cattrans.gif" alt="Movies"/></a></td>
<td class="rowfollow"><table class="torrentname" width="100%"><tr>
<td class="embedded"><a title="Oppenheimer.2023.2160p.UHD.BluRay.REMUX-CHD" href="details.php?id=555002&amp;hit=1"><b>Oppenheimer.2023...</b></a><br/><font class="subtitle">奥本海默</font></td>
<td class="embedded" width="20"><a href="download.php?id=555002"><img class="download" alt="download"/></a></td>
</tr></table></td>
<td class="rowfollow">0</td>
<td class="rowfollow nowrap"><span title="2026-09-01 10:00:00">26天</span></td>
<td class="rowfollow">80.00<br/>GB</td>
<td class="rowfollow">7</td>
<td class="rowfollow">0</td>
<td class="rowfollow">55</td>
<td class="rowfollow"><a href="userdetails.php?id=1">uploader</a></td>
</tr>
</table></body></html>
@@ -0,0 +1,3 @@
<!DOCTYPE html><html><head><title>CHDBits :: 登录</title></head><body>
<form method="post" action="takelogin.php"><input name="username"/><input type="password" name="password"/></form>
<p>未登录!</p></body></html>
@@ -0,0 +1,33 @@
<!DOCTYPE html><html><head><title>HHCLUB :: 种子</title></head><body>
<div id="user-info-panel"><a href="userdetails.php?id=7" class="EliteUser_Name">bob</a> <a href="logout.php">退出</a></div>
<div class="torrent-table">
<div class="torrent-table-sub-info">
<div class="torrent-cat"><a href="?cat[]=403"><img src="pic/cattrans.gif" alt="综艺"/></a></div>
<div class="torrent-title"><a class="torrent-info-text-name" href="details.php?id=90001&amp;hit=1">Go.Fighting.S10E01.2026.2160p.WEB-DL.H265.AAC-HHWEB</a>
<span class="promotion-tag promotion-tag-free">免费</span></div>
<div class="torrent-info-text-small_name">极限挑战 第十季 第1期</div>
<div class="torrent-manage"><a href="download.php?id=90001">下载</a></div>
<div class="torrent-info-text-added"><span title="2026-09-20 20:30:00">6天23时</span></div>
<div class="torrent-info-text-size">6.12 GB</div>
<div class="torrent-info-text-seeders">88</div>
<div class="torrent-info-text-leechers">5</div>
<div class="torrent-info-text-finished">1,024</div>
<div class="torrent-info-text-author">anon</div>
<div class="torrent-info-hr">30</div>
</div>
<div class="torrent-table-sub-info">
<div class="torrent-cat"><a href="?cat[]=401"><img src="pic/cattrans.gif" alt="电影"/></a></div>
<div class="torrent-title"><a class="torrent-info-text-name" href="details.php?id=90002&amp;hit=1">Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-HHWEB</a>
<span class="promotion-tag promotion-tag-50pctdown2up">50%</span></div>
<div class="torrent-info-text-small_name">沙丘2</div>
<div class="torrent-manage"><a href="download.php?id=90002">下载</a></div>
<div class="torrent-info-text-added">2026-09-01 08:00:00</div>
<div class="torrent-info-text-size">78.3 GB</div>
<div class="torrent-info-text-seeders">12</div>
<div class="torrent-info-text-leechers">0</div>
<div class="torrent-info-text-finished">300</div>
<div class="torrent-info-text-author">anon</div>
</div>
</div>
<div class="pagination"><a href="?page=0">1-100</a><a href="?page=3">301-400</a></div>
</body></html>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<feed><title>Atom without namespace</title>
<entry><id>tag:e.x,2026:1</id><title>Atom.Entry.One</title><link rel="alternate" href="https://e.x/details?id=1"/><link rel="enclosure" href="https://e.x/dl?id=1" length="4096"/><updated>2026-09-26T13:14:03Z</updated><summary>atom summary</summary></entry>
<entry><id>tag:e.x,2026:2</id><title type="html">&lt;b&gt;Atom.Entry.Two&lt;/b&gt;</title><link href="https://e.x/details?id=2"/><published>2026-09-26T21:14:03+08:00</published><updated>2026-09-27T00:00:00Z</updated><content>content only</content></entry>
</feed>
@@ -0,0 +1 @@
RSS Link has expired, You need to get a new one!
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://purl.org/rss/1.0/" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel rdf:about="https://e.x/"><title>RDF feed</title><link>https://e.x/</link></channel>
<item rdf:about="https://e.x/1"><title>RDF.Item.One</title><link>https://e.x/1</link><description>rdf desc</description><dc:date>2026-09-26T13:14:03Z</dc:date><dc:creator>alice</dc:creator></item>
</rdf:RDF>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel><title>broken</title>
<item><title>Good.One</title><link>https://e.x/1</link></item>
<item><title>Unclosed<link>https://e.x/2</link></item>
</channel>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/"><channel><title>CDATA feed</title>
<item><title><![CDATA[[BDMV] 奥本海默 Oppenheimer 2023 <UHD> & more]]></title><link>https://pt.example.com/details.php?id=1</link><description><![CDATA[<p>Size: 78.42 GB</p><img src="x.jpg"/>]]></description><enclosure url="https://pt.example.com/download.php?id=1&amp;passkey=abcdef0123456789" length="84203386470" type="application/x-bittorrent"/><pubDate>Sat, 26 Sep 2026 21:14:03 +0800</pubDate><dc:creator>豆瓣用户A</dc:creator><guid>pt-1</guid></item>
<item><title>Plain &amp; Escaped &lt;Title&gt; &#x4E2D;&#25991;</title><link>https://pt.example.com/details.php?id=2&amp;hit=1</link><description>desc &amp; more</description><enclosure url="https://pt.example.com/download.php?id=2" length="1024"/><pubDate>Sat, 26 Sep 2026 13:14:03 GMT</pubDate></item>
</channel></rss>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE rss [<!ENTITY brand "EvilCorp">]>
<rss version="2.0"><channel><title>dtd</title>
<item><title>&brand; Title</title><link>https://e.x/1</link></item>
</channel></rss>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel><title>entities</title>
<item><title>Nbsp&nbsp;Title&copy;2026</title><link>https://e.x/1</link></item>
<item><title>Normal.After.Bad</title><link>https://e.x/2</link></item>
</channel></rss>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel><title>fallback</title>
<item><title>Only.Link.Item</title><link>https://pt.example.com/download.php?id=10</link></item>
<item><title>No.Link.No.Enclosure</title><description>skip me</description></item>
<item><title></title><link>https://pt.example.com/details.php?id=11</link></item>
<item><link>https://pt.example.com/details.php?id=12</link></item>
<item><title>Enclosure.Only</title><enclosure url="magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&amp;dn=x" length="0"/></item>
</channel></rss>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/"><channel><title>nested</title>
<item><source url="https://e.x/src"><title>Source.Title.First</title></source><title>Real.Title</title><link>https://e.x/1</link><media:title>Media.Title</media:title></item>
<item><title>Two.Enclosures</title><enclosure url="https://e.x/a.torrent" length="100"/><enclosure url="https://e.x/b.torrent" length="200"/></item>
</channel></rss>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" xmlns:nyaa="https://nyaa.si/xmlns/nyaa" version="2.0"><channel><title>Nyaa-like</title>
<atom:link href="https://nyaa.example/?page=rss" rel="self" type="application/rss+xml"/>
<item><title>[Group] Show - 01 [1080p].mkv</title><link>https://nyaa.example/download/1.torrent</link><guid isPermaLink="true">https://nyaa.example/view/1</guid><pubDate>Sat, 26 Sep 2026 13:14:03 -0000</pubDate><nyaa:seeders>12</nyaa:seeders><nyaa:leechers>3</nyaa:leechers><nyaa:size>1.2 GiB</nyaa:size><nyaa:infoHash>0123456789abcdef0123456789abcdef01234567</nyaa:infoHash><description><![CDATA[<a href="https://nyaa.example/view/1">#1</a> | 1.2 GiB]]></description></item>
</channel></rss>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel><title>pubdate formats</title>
<item><title>rfc822-gmt</title><link>https://e.x/1</link><pubDate>Sat, 26 Sep 2026 13:14:03 GMT</pubDate></item>
<item><title>rfc822-offset</title><link>https://e.x/2</link><pubDate>Sat, 26 Sep 2026 21:14:03 +0800</pubDate></item>
<item><title>rfc822-no-seconds</title><link>https://e.x/3</link><pubDate>Sat, 26 Sep 2026 21:14 +0800</pubDate></item>
<item><title>rfc822-cst</title><link>https://e.x/4</link><pubDate>Sat, 26 Sep 2026 21:14:03 CST</pubDate></item>
<item><title>iso-z</title><link>https://e.x/5</link><pubDate>2026-09-26T13:14:03Z</pubDate></item>
<item><title>iso-offset</title><link>https://e.x/6</link><pubDate>2026-09-26T21:14:03+08:00</pubDate></item>
<item><title>naive-space</title><link>https://e.x/7</link><pubDate>2026-09-26 21:14:03</pubDate></item>
<item><title>date-only</title><link>https://e.x/8</link><pubDate>2026-09-26</pubDate></item>
<item><title>chinese</title><link>https://e.x/9</link><pubDate>2026年09月26日 21:14:03</pubDate></item>
<item><title>garbage</title><link>https://e.x/10</link><pubDate>3小时前</pubDate></item>
<item><title>no-weekday</title><link>https://e.x/11</link><pubDate>26 Sep 2026 13:14:03 +0000</pubDate></item>
<item><title>wrong-weekday</title><link>https://e.x/12</link><pubDate>Mon, 26 Sep 2026 13:14:03 GMT</pubDate></item>
<item><title>epoch-like</title><link>https://e.x/13</link><pubDate>1790428443</pubDate></item>
</channel></rss>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel><title>size</title>
<item><title>Size.In.Description</title><link>https://pt.example.com/d?id=20</link><description>[Size: 1.50 GB] [Seeders: 12]</description></item>
<item><title>Length.NonDigit</title><enclosure url="https://pt.example.com/dl?id=21" length="1.5GB"/></item>
<item><title>Length.Empty</title><enclosure url="https://pt.example.com/dl?id=22" length=""/></item>
<item><title>Length.Missing</title><enclosure url="https://pt.example.com/dl?id=23"/></item>
<item><title>Length.Big</title><enclosure url="https://pt.example.com/dl?id=24" length="2199023255552"/></item>
<item><title>Length.Negative</title><enclosure url="https://pt.example.com/dl?id=25" length="-1"/></item>
</channel></rss>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel><title>ws</title>
<item>
<title>
Spaced.Title.2026.1080p.WEB-DL
</title>
<link>
https://pt.example.com/details.php?id=3
</link>
<description>
line one
line two
</description>
<enclosure url=" https://pt.example.com/download.php?id=3 " length=" 2048 "/>
<pubDate>
Sat, 26 Sep 2026 13:14:03 GMT
</pubDate>
</item>
</channel></rss>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"><title>观众 Atom</title>
<entry><id>audiences-812345</id><title>Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.Atmos.7.1-FraMeSToR</title><link rel="alternate" href="https://audiences.me/details.php?id=812345"/><link rel="enclosure" href="https://audiences.me/download.php?id=812345&amp;passkey=0123456789abcdef" length="84203386470"/><published>2026-09-26T13:14:03.000Z</published><category term="电影"/><category term="401"/><summary>沙丘2 / 沙丘:第二部 | &lt;b&gt;杜比视界&lt;/b&gt; 国英双语 中字</summary></entry>
<entry><id>audiences-812346</id><title>Keep.Running.S12E01.20240426.2160p.WEB-DL.H265.AAC-LelveTV &amp; Friends</title><link rel="alternate" href="https://audiences.me/details.php?id=812346"/><link rel="enclosure" href="https://audiences.me/download.php?id=812346" length="5497558138"/><published>2026-09-26T12:00:00.000Z</published></entry>
<entry><id>audiences-812347</id><title>The.Bear.S03.2160p.DSNP.WEB-DL.DDP5.1.Atmos.DV.H.265-FLUX</title><link rel="alternate" href="https://audiences.me/details.php?id=812347"/></entry>
</feed>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel><title>观众 Torrents</title>
<item><title>Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.Atmos.7.1-FraMeSToR</title><guid isPermaLink="false">audiences-812345</guid><link>https://audiences.me/details.php?id=812345</link><enclosure url="https://audiences.me/download.php?id=812345&amp;passkey=0123456789abcdef" length="84203386470" type="application/x-bittorrent"/><pubDate>Sat, 26 Sep 2026 13:14:03 GMT</pubDate><category>电影</category><category>401</category><description><![CDATA[沙丘2 / 沙丘:第二部 | <b>杜比视界</b> 国英双语 中字]]></description></item>
<item><title>Keep.Running.S12E01.20240426.2160p.WEB-DL.H265.AAC-LelveTV &amp; Friends</title><guid isPermaLink="false">audiences-812346</guid><link>https://audiences.me/details.php?id=812346</link><enclosure url="https://audiences.me/download.php?id=812346" length="5497558138" type="application/x-bittorrent"/><pubDate>Sat, 26 Sep 2026 12:00:00 GMT</pubDate></item>
<item><title>The.Bear.S03.2160p.DSNP.WEB-DL.DDP5.1.Atmos.DV.H.265-FLUX</title><guid isPermaLink="false">audiences-812347</guid><link>https://audiences.me/details.php?id=812347</link></item>
</channel></rss>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,121 @@
/**
* RSS 解析差分:MP `RssHelper._parse_impl`(lxml 回退)vs 本项目 `parseFeed`。
* 期望由 scripts/mp-sync/gen-differential.py rss|indexer 生成(rss.json);不一致必须登记在 rss.json `deviations`。
*/
import { describe, expect, it } from "vitest";
import { FeedError, type FeedItem, parseFeed } from "../../src/rss/index.ts";
import {
diffAgainstRegistry,
dumpMismatches,
FieldCollector,
formatMismatches,
loadDoc,
readInput,
} from "./diff-util.ts";
interface MpPubdate {
kind: "absent" | "unparsed" | "aware" | "naive";
text?: string;
wall?: string;
utc?: string;
}
interface MpRssItem {
title: string;
description: string;
link: string;
enclosure: string;
size: number;
nickname?: string;
pubdate: MpPubdate;
}
interface RssSample {
id: string;
input: { file: string; sha256: string };
mp: { status: "ok" | "false_error" | "none_expired"; items: MpRssItem[] };
}
const doc = loadDoc<RssSample>("rss.json");
/** MP 字段 → 可与本项目比较的值:空串即缺省;pubdate 统一为 UTC ISO */
function mpPubdate(p: MpPubdate): string | undefined {
if (p.kind === "aware") return p.utc;
// naive:MP astimezone(None) 以宿主本地时区解释墙上时间;JS 对无偏移的日期时间同样按本地解释
if (p.kind === "naive") return new Date(p.wall!).toISOString();
return undefined;
}
const blank = (s: string | null | undefined) => (s === "" || s == null ? undefined : s);
function runOurs(xml: string): { status: string; items: FeedItem[] } {
try {
return {
status: "ok",
items: parseFeed(xml, { fetchedAt: new Date("2026-09-27T00:00:00Z") }).items,
};
} catch (e) {
if (e instanceof FeedError) return { status: `error:${e.kind}`, items: [] };
throw e;
}
}
function statusEquivalent(mp: RssSample["mp"]["status"], ours: string): boolean {
if (mp === "ok") return ours === "ok";
if (mp === "none_expired") return ours === "error:expired";
return ours.startsWith("error:") && ours !== "error:expired";
}
describe("RSS 差分(MP RssHelper Python 回退)", () => {
const c = new FieldCollector();
for (const s of doc.samples) {
const ours = runOurs(readInput(s.input.file));
if (!statusEquivalent(s.mp.status, ours.status)) {
c.mismatches.push({
key: `${s.id}.status`,
mp: s.mp.status,
ours: ours.status,
});
}
if (s.mp.status !== "ok" || ours.status !== "ok") continue;
if (s.mp.items.length !== ours.items.length) {
c.mismatches.push({
key: `${s.id}.count`,
mp: s.mp.items.length,
ours: ours.items.length,
});
}
const n = Math.min(s.mp.items.length, ours.items.length);
for (let i = 0; i < n; i++) {
const m = s.mp.items[i]!;
const o = ours.items[i]!;
const k = (f: string) => `${s.id}#${i}.${f}`;
c.check(k("title"), m.title, o.title);
c.check(k("description"), blank(m.description), blank(o.description));
c.check(k("link"), blank(m.link), blank(o.link));
c.check(k("enclosure"), blank(m.enclosure), blank(o.enclosureUrl));
c.check(k("size"), m.size, o.size);
c.check(k("pubdate"), mpPubdate(m.pubdate), o.publishedAt);
c.check(k("nickname"), blank(m.nickname), blank(o.nickname));
}
}
it("有样本且记录了生成来源", () => {
expect(doc.samples.length).toBeGreaterThan(10);
expect(doc.generatedFrom.commit).toMatch(/^[0-9a-f]{40}$/);
});
it("实际不一致集合 == 登记的 deviations", () => {
dumpMismatches("rss", c.mismatches, {
compared: c.compared,
equal: c.equal,
});
const { unregistered, stale } = diffAgainstRegistry(c.mismatches, doc.deviations);
expect(formatMismatches(unregistered), "未登记的不一致").toBe("");
expect(stale.join("\n"), "登记了但已不再出现的偏差(请从 rss.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);
}
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,207 @@
/**
* MoviePilot 过滤规则 vs Slot 差分测试。
*
* filter-cases.json 由 MP 的 Python 回退实现生成(见 generatedFrom)。这里把可表达的规则
* 手工翻译成 SlotSpec(每个 `>` 优先级层级一个 Slot),用 parseRelease + evaluateCandidate
* 求"首个 accept 的层级",与 MP 的 level 比对。
*
* 断言:不一致集合 == deviations 登记集合。补迁消除差异后测试会失败,提醒删登记。
*/
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import {
evaluateCandidate,
type ParsedRelease,
parseRelease,
type ReleaseSource,
rankForSlot,
type SlotSpec,
} from "../../src/index.ts";
interface Torrent {
id: string;
title: string;
description: string;
size: number;
seeders: number;
}
interface Rule {
id: string;
rule: string;
verdict: "full" | "partial" | "none";
}
interface Case {
caseId: string;
ruleId: string;
torrentId: string;
mp: { matched: boolean; priOrder: number | null; level: number | null };
}
type DeviationKind = "intentional" | "missing" | "our_bug" | "mp_bug" | "not_expressible";
interface Deviation {
kind: DeviationKind;
note: string;
pairs?: string[];
}
interface Fixture {
generatedFrom: { commit: string; rustCrossCheck: { mismatches: unknown[] } };
torrents: Torrent[];
rules: Rule[];
cases: Case[];
deviations: Record<string, Deviation>;
}
const fx = JSON.parse(readFileSync(new URL("./filter-cases.json", import.meta.url), "utf8")) as Fixture;
// ---------------------------------------------------------------- MP 关键字 → Slot 片段
const MB = 1024 * 1024; // MP size_range 单位 MB = 1024*1024 字节
const ALL: ReleaseSource[] = ["REMUX", "BluRay", "BluRay-Disc", "WEB-DL", "WEBRip", "HDTV", "HDRip", "DVD", "DVDRip"];
const without = (...xs: ReleaseSource[]) => ALL.filter((s) => !xs.includes(s));
type Frag = Pick<SlotSpec, "hard"> & { unknown?: Partial<Record<string, "reject" | "accept">> };
const RES_4K: Frag = { hard: { resolutions: ["2160p"] }, unknown: { resolution: "reject" } };
const RES_1080: Frag = { hard: { resolutions: ["1080p", "1080i"] }, unknown: { resolution: "reject" } };
const RES_720: Frag = { hard: { resolutions: ["720p"] }, unknown: { resolution: "reject" } };
/** 正向来源条件:来源未知 = MP 正则不命中 = 拒绝 */
const srcIs = (...s: ReleaseSource[]): Frag => ({ hard: { sources: s }, unknown: { source: "reject" } });
/** 取反来源条件:来源未知 = MP 正则不命中 = !X 为真 = 放行 */
const srcNot = (...s: ReleaseSource[]): Frag => ({ hard: { sources: without(...s) }, unknown: { source: "accept" } });
const CNSUB: Frag = { hard: { requiredSubtitles: ["zh"] }, unknown: { subtitles: "reject" } };
const size = (min?: number, max?: number): Frag => ({
hard: {
minSizeBytes: min !== undefined ? min * MB : undefined,
maxSizeBytes: max !== undefined ? max * MB : undefined,
},
});
/** AND 合并片段(同字段取交集由调用方保证不冲突) */
function and(...fs: Frag[]): SlotSpec {
const hard: SlotSpec["hard"] = {};
const unknown: Record<string, "reject" | "accept"> = {};
for (const f of fs) {
for (const [k, v] of Object.entries(f.hard)) if (v !== undefined) (hard as Record<string, unknown>)[k] = v;
Object.assign(unknown, f.unknown);
}
return { name: "mp", hard, unknownPolicy: { ...unknown } };
}
/** 每条规则 → 各优先级层级的 SlotSpec(verdict=none 的规则不翻译) */
const TRANSLATE: Record<string, SlotSpec[]> = {
R01: [and(RES_4K)],
R02: [and(RES_1080)],
R03: [and(RES_720)],
R04: [and({ hard: { resolutions: ["2160p", "1080p", "1080i"] }, unknown: { resolution: "reject" } })],
R05: [and(RES_4K), and(RES_1080), and(RES_720)],
R06: [and(srcIs("BluRay-Disc"))],
R07: [and(srcNot("BluRay-Disc"))],
R08: [and(srcIs("REMUX"))],
R09: [and(srcNot("REMUX"))],
R10: [and(srcIs("WEB-DL", "WEBRip"))],
R11: [and(srcNot("BluRay-Disc", "REMUX"))],
R12: [and(RES_4K, srcNot("REMUX")), and(RES_1080, srcIs("WEB-DL", "WEBRip"))],
R13: [and(CNSUB)],
R14: [and(CNSUB, RES_4K, srcNot("BluRay-Disc"))],
R15: [and(CNSUB, RES_1080), and(RES_1080)],
R16: [and(size(2000, 30000))],
R17: [and(size(undefined, 8000))],
R18: [and(RES_4K, size(2000, 30000))],
R19: [and(srcNot("BluRay-Disc", "REMUX", "WEB-DL", "WEBRip"))],
R20: [and({ hard: { sources: ["BluRay"], requireEncode: true }, unknown: { source: "reject" } })],
R21: [and(RES_4K, srcNot("BluRay-Disc", "REMUX")), and(RES_1080, srcNot("BluRay-Disc", "REMUX"))],
R22: [
and(
{ hard: { resolutions: ["2160p", "1080p", "1080i"] }, unknown: { resolution: "reject" } },
srcIs("WEB-DL", "WEBRip"),
),
],
// partial:近似翻译
R23: [and(CNSUB)], // 无 TMDB 原语种放行
R24: [and(RES_4K)], // 无 !DOLBY
R25: [and(CNSUB, srcNot("BluRay-Disc"))], // 无 SPECSUB
R26: [and(size(20000))],
};
const parsed = new Map<string, ParsedRelease>(
fx.torrents.map((t) => [
t.id,
{ ...parseRelease(t.title, t.description ? { subtitle: t.description } : {}), sizeBytes: t.size },
]),
);
function ourLevel(ruleId: string, torrentId: string): number | null {
const levels = TRANSLATE[ruleId]!;
const r = parsed.get(torrentId)!;
for (let i = 0; i < levels.length; i++) if (evaluateCandidate(levels[i]!, r).decision === "accept") return i + 1;
return null;
}
// ---------------------------------------------------------------- 排序语义:单 Slot + rankForSlot vs MP 优先级
/** MP `4K > 1080P > 720P` 用单个 Slot(允许三种分辨率)+ rankForSlot 表达时的逆序对 */
const RANK_CHECKS: Record<string, { rule: string; spec: SlotSpec }> = {
"R05~rank": {
rule: "R05",
spec: and({ hard: { resolutions: ["2160p", "1080p", "1080i", "720p"] }, unknown: { resolution: "reject" } }),
},
};
function rankInversions(key: string): string[] {
const { rule, spec } = RANK_CHECKS[key]!;
const level = new Map(fx.cases.filter((c) => c.ruleId === rule).map((c) => [c.torrentId, c.mp.level]));
const ranked = rankForSlot(
spec,
fx.torrents.map((t) => ({ ...parsed.get(t.id)!, raw: t.id })),
).filter((e) => e.evaluation.decision === "accept");
const order = ranked.map((e) => e.candidate.raw);
const out: string[] = [];
for (let i = 0; i < order.length; i++)
for (let j = i + 1; j < order.length; j++) {
const a = level.get(order[i]!);
const b = level.get(order[j]!);
// 我们把 a 排在 b 前,MP 却认为 b 的层级更优
if (a != null && b != null && b < a) out.push(`${order[j]}>${order[i]}`);
}
return out.sort();
}
// ---------------------------------------------------------------- 断言
describe("MP 过滤规则差分(filter-cases.json)", () => {
it("fixture 完整:≥20 种子,Python/Rust 一致,每条可表达规则都有翻译", () => {
expect(fx.torrents.length).toBeGreaterThanOrEqual(20);
expect(fx.generatedFrom.rustCrossCheck.mismatches).toEqual([]);
const translatable = fx.rules.filter((r) => r.verdict !== "none").map((r) => r.id);
expect(Object.keys(TRANSLATE).sort()).toEqual(translatable.sort());
});
it("不一致集合 == deviations 登记集合", () => {
const actual: Record<string, { mp: number | null; ours: number | null }> = {};
for (const c of fx.cases) {
if (!TRANSLATE[c.ruleId]) continue;
const ours = ourLevel(c.ruleId, c.torrentId);
if (ours !== c.mp.level) actual[c.caseId] = { mp: c.mp.level, ours };
}
const registered = Object.keys(fx.deviations).filter((k) => !k.includes("~"));
expect(Object.keys(actual).sort()).toEqual(registered.sort());
for (const k of registered)
expect(["intentional", "missing", "our_bug", "mp_bug", "not_expressible"]).toContain(fx.deviations[k]!.kind);
});
it("单 Slot + rankForSlot 的排序逆序对 == 登记", () => {
for (const key of Object.keys(RANK_CHECKS)) {
const inv = rankInversions(key);
const dev = fx.deviations[key];
if (inv.length === 0) expect(dev).toBeUndefined();
else expect(dev?.pairs?.slice().sort()).toEqual(inv);
}
});
});
/** 生成/更新登记时用:UMT_FILTER_DUMP=1 deno task test 打印当前不一致 */
if (process.env.UMT_FILTER_DUMP) {
const rows: Record<string, unknown> = {};
for (const c of fx.cases) {
if (!TRANSLATE[c.ruleId]) continue;
const ours = ourLevel(c.ruleId, c.torrentId);
if (ours !== c.mp.level) rows[c.caseId] = { mp: c.mp.level, ours };
}
for (const key of Object.keys(RANK_CHECKS)) rows[key] = rankInversions(key);
console.log(`UMT_FILTER_DUMP ${JSON.stringify(rows)}`);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,233 @@
/**
* 标题识别差分:把 MP MetaInfo 输出与我们的 ParsedRelease 映射到同一"规范形",逐字段比较。
* 规范化只抹平表示差异(大小写、分隔符、同义写法),不抹平语义差异;语义差异登记在 meta-deviations.json。
*/
import { readFileSync } from "node:fs";
import { type ParsedRelease, parseRelease, parseReleasePath } from "../../src/release-parser/index.ts";
export interface MetaSample {
id: string;
kind: "title" | "path";
input: string;
subtitle?: string;
source: string;
ourNote?: string;
}
export type MpMeta = Record<string, string | number | null> & { error?: string };
export type DeviationKind = "intentional" | "missing" | "our_bug" | "mp_bug" | "representation";
export interface Deviation {
kind: DeviationKind;
mp: string | null;
ours: string | null;
note: string;
}
/** 参与比较的字段(media_id = find_metainfo 显式 ID 标签;edition = resource_type + resource_effect,由这两项覆盖;customization 双方均未配置) */
export const FIELDS = [
"name",
"cn_name",
"en_name",
"year",
"type",
"begin_season",
"end_season",
"begin_episode",
"end_episode",
"resource_pix",
"resource_type",
"resource_effect",
"video_encode",
"audio_encode",
"resource_team",
"part",
"web_source",
"media_id",
] as const;
export type Field = (typeof FIELDS)[number];
export type Canon = Record<Field, string | null>;
const readJson = <T>(name: string): T => JSON.parse(readFileSync(new URL(`./${name}`, import.meta.url), "utf8")) as T;
export function loadMeta() {
const samples = readJson<{ samples: MetaSample[] }>("meta-samples.json").samples;
const mp = readJson<{ python: Record<string, MpMeta>; rustMismatch: Record<string, unknown> }>("meta-mp.json");
const deviations = readJson<{ deviations: Record<string, Partial<Record<Field, Deviation>>> }>(
"meta-deviations.json",
).deviations;
return { samples, mp, deviations };
}
const str = (v: unknown): string | null => (v === null || v === undefined || v === "" ? null : String(v));
/** 名称:忽略大小写与标点/空白(MP 英文名做 Title Case,我们保留原大小写) */
const canonName = (v: unknown) => {
const s = str(v);
return s === null
? null
: s
.normalize("NFKC")
.toLowerCase()
.replace(/[\s._\-::·'’!!??,,&]+/g, "") || null;
};
function canonSource(v: string | null): string | null {
if (!v) return null;
const u = v.toUpperCase();
const cls = /REMUX/.test(u)
? "REMUX"
: /BLURAY|BDRIP|\bBD\b|BLU/.test(u)
? "BluRay"
: /WEB-?DL|^WEB$|\bWEB\b(?!RIP)/.test(u)
? "WEB-DL"
: /WEBRIP/.test(u)
? "WEBRip"
: /HDTV|UHDTV/.test(u)
? "HDTV"
: /HDRIP/.test(u)
? "HDRip"
: /DVD/.test(u)
? "DVD"
: /HDDVD/.test(u)
? "HDDVD"
: null;
const uhd = /\bUHD\b/.test(u.replace(/UHDTV/g, "")) ? "UHD " : "";
return `${uhd}${cls ?? `?${u}`}`.trim();
}
const HDR_CANON: Record<string, string> = {
HDR: "HDR10",
HDR10: "HDR10",
"HDR10+": "HDR10+",
HDR10PLUS: "HDR10+",
HDR10P: "HDR10+",
DV: "DV",
DOVI: "DV",
HLG: "HLG",
HDRVIVID: "HDR Vivid",
VIVID: "HDR Vivid",
"HDR VIVID": "HDR Vivid",
};
function canonHdrList(tokens: string[]): string | null {
const set = new Set<string>();
for (const t of tokens) {
const k = t.toUpperCase().replace(/\s+/g, "");
set.add(HDR_CANON[k] ?? HDR_CANON[t.toUpperCase()] ?? `?${t}`);
}
return set.size ? [...set].sort().join(",") : null;
}
function canonCodec(v: string | null): string | null {
if (!v) return null;
const u = v.toUpperCase().replace(/[\s.]/g, "");
if (/265|HEVC/.test(u)) return "H.265";
if (/264|AVC/.test(u)) return "H.264";
if (/AV1/.test(u)) return "AV1";
if (/VC-?1/.test(u)) return "VC-1";
if (/MPEG-?2/.test(u)) return "MPEG-2";
if (/XVID/.test(u)) return "XVID";
if (/DIVX/.test(u)) return "DIVX";
return `?${u}`;
}
const canonAudio = (v: string | null) =>
v
? v
.toUpperCase()
.replace(/DD\+/g, "DDP")
.replace(/[\s\-_.]/g, "") || null
: null;
const canonGroups = (v: string | null) =>
v
? v
.split(/[@&]/)
.map((g) => g.trim().toLowerCase())
.filter(Boolean)
.sort()
.join("@")
: null;
function canonPix(v: string | null): string | null {
if (!v) return null;
const u = v.toLowerCase();
return ({ "4k": "2160p", "8k": "4320p", "2k": "1440p" } as Record<string, string>)[u] ?? u;
}
const canonPart = (v: string | null) => (v ? v.toUpperCase().replace(/[\s._-]/g, "") : null);
const canonType = (v: string | null) =>
v === "电视剧" ? "tv" : v === "电影" ? "movie" : v === "未知" || v === null ? "unknown" : v;
export function canonMp(m: MpMeta): Canon {
const effect = str(m.resource_effect);
const hdrTokens = effect ? effect.split(/\s+/).filter((t) => /^(HDR|DV|DOVI|HLG|VIVID|HDRVIVID)/i.test(t)) : [];
return {
name: canonName(m.name),
cn_name: canonName(m.cn_name),
en_name: canonName(m.en_name),
year: str(m.year),
type: canonType(str(m.type)),
begin_season: str(m.begin_season),
end_season: str(m.end_season),
begin_episode: str(m.begin_episode),
end_episode: str(m.end_episode),
resource_pix: canonPix(str(m.resource_pix)),
resource_type: canonSource(str(m.resource_type)),
resource_effect: canonHdrList(hdrTokens),
video_encode: canonCodec(str(m.video_encode)),
audio_encode: canonAudio(str(m.audio_encode)),
resource_team: canonGroups(str(m.resource_team)),
part: canonPart(str(m.part)),
web_source: canonName(m.web_source),
media_id: m.media_id ? `${m.media_source}:${m.media_id}` : null,
};
}
const PROVIDER: Record<string, string> = { tmdb: "themoviedb", douban: "douban", imdb: "imdb", tvdb: "tvdb" };
export function oursParse(s: MetaSample): ParsedRelease {
return s.kind === "path"
? parseReleasePath(s.input)
: parseRelease(s.input, s.subtitle ? { subtitle: s.subtitle } : {});
}
export function canonOurs(r: ParsedRelease): Canon {
const src = r.source
? `${r.uhd ? "UHD " : ""}${r.source === "BluRay-Disc" ? "BluRay" : r.source === "DVDRip" ? "DVD" : r.source}`
: r.uhd
? "UHD ?"
: null;
const eps = r.episodes;
return {
name: canonName(r.title),
cn_name: canonName(r.titleCn),
en_name: canonName(r.titleEn),
year: str(r.year),
type: r.mediaTypeHint,
begin_season: str(r.season),
end_season: str(r.seasonEnd),
begin_episode: str(eps?.start),
end_episode: eps && eps.end !== eps.start ? String(eps.end) : null,
resource_pix: canonPix(str(r.resolution)),
resource_type: src,
resource_effect: r.hdr.length ? canonHdrList(r.hdr) : null,
video_encode: canonCodec(str(r.videoCodec)),
audio_encode: r.audio.length ? canonAudio(r.audio.join(" ")) : null,
resource_team: canonGroups(str(r.releaseGroup?.name)),
part: canonPart(str(r.part)),
web_source: canonName(r.webSource),
media_id: r.explicitIds[0] ? `${PROVIDER[r.explicitIds[0].provider]}:${r.explicitIds[0].id}` : null,
};
}
/** 逐字段差异(规范形不等) */
export function diffSample(
s: MetaSample,
mp: MpMeta,
): Partial<Record<Field, { mp: string | null; ours: string | null }>> {
const a = canonMp(mp);
const b = canonOurs(oursParse(s));
const out: Partial<Record<Field, { mp: string | null; ours: string | null }>> = {};
for (const f of FIELDS) if (a[f] !== b[f]) out[f] = { mp: a[f], ours: b[f] };
return out;
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,35 @@
/**
* 标题识别差分测试:MP MetaInfo/MetaInfoPath(Python 参考实现)vs parseRelease/parseReleasePath。
* 期望输出由 `scripts/mp-sync/gen-differential.py meta` 生成(meta-mp.json),已知差异登记在 meta-deviations.json。
* 断言:实际差异集合 == 登记集合,且规范形取值与快照一致。补迁某条后测试会失败,提示删除对应登记。
*/
import { describe, expect, it } from "vitest";
import { diffSample, loadMeta } from "./meta-map.ts";
const { samples, mp, deviations } = loadMeta();
describe("标题识别差分(MP MetaInfo ↔ parseRelease)", () => {
it("样本 ≥ 150 且每条都有 MP 输出", () => {
expect(samples.length).toBeGreaterThanOrEqual(150);
for (const s of samples) {
expect(mp.python[s.id], s.id).toBeDefined();
expect(mp.python[s.id]?.error, s.id).toBeUndefined();
}
});
it("登记项都对应存在的样本", () => {
const ids = new Set(samples.map((s) => s.id));
for (const id of Object.keys(deviations)) expect(ids.has(id), id).toBe(true);
});
for (const s of samples) {
it(`${s.id} ${s.input}`, () => {
const actual = diffSample(s, mp.python[s.id]!);
const registered = deviations[s.id] ?? {};
const snapshot = Object.fromEntries(
Object.entries(registered).map(([f, d]) => [f, { mp: d!.mp, ours: d!.ours }]),
);
expect(actual, `${s.id} 差异与 meta-deviations.json 登记不一致`).toEqual(snapshot);
});
}
});
@@ -0,0 +1,21 @@
{
"note": "繁→简差分已知差异:key=输入。kind 同 meta-deviations.json。",
"deviations": {
"咒術迴戰": { "kind": "missing", "mp": "咒术回战", "ours": "咒术迴战", "note": "字表缺 迴→回" },
"孤獨搖滾": { "kind": "missing", "mp": "孤独摇滚", "ours": "孤独摇滾", "note": "字表缺 滾→滚" },
"魷魚遊戲": { "kind": "missing", "mp": "鱿鱼游戏", "ours": "魷鱼游戏", "note": "字表缺 魷→鱿" },
"瑯琊榜": { "kind": "missing", "mp": "琅琊榜", "ours": "瑯琊榜", "note": "字表缺 瑯→琅(异体)" },
"慶餘年": {
"kind": "mp_bug",
"mp": "庆馀年",
"ours": "庆余年",
"note": "MediaWiki 词表把 餘 转成 馀,官方简体片名为 庆余年"
},
"臥虎藏龍": { "kind": "missing", "mp": "卧虎藏龙", "ours": "臥虎藏龙", "note": "字表缺 臥→卧" },
"駭客任務": { "kind": "missing", "mp": "骇客任务", "ours": "駭客任务", "note": "字表缺 駭→骇" },
"乾杯": { "kind": "missing", "mp": "干杯", "ours": "乾杯", "note": "一对多字 乾 刻意不收;MP 有词组表" },
"瞭解": { "kind": "missing", "mp": "了解", "ours": "瞭解", "note": "一对多字 瞭 刻意不收;MP 有词组表" },
"頭髮": { "kind": "missing", "mp": "头发", "ours": "头髮", "note": "髮→发 未收(发/發 合并字)" },
"理髮": { "kind": "missing", "mp": "理发", "ours": "理髮", "note": "髮→发 未收" }
}
}
@@ -0,0 +1,78 @@
{
"generatedFrom": {
"repo": "MoviePilot",
"commit": "e6820a8",
"entry": "moviepilot_rust.zhconv_fast(text, 'zh-hans')"
},
"hans": {
"葬送的芙莉蓮": "葬送的芙莉莲",
"處刑少女的生存之道": "处刑少女的生存之道",
"夏日重現": "夏日重现",
"間諜家家酒": "间谍家家酒",
"進擊的巨人": "进击的巨人",
"鬼滅之刃": "鬼灭之刃",
"咒術迴戰": "咒术回战",
"我的英雄學院": "我的英雄学院",
"藥師少女的獨語": "药师少女的独语",
"無職轉生": "无职转生",
"關於我轉生變成史萊姆這檔事": "关于我转生变成史莱姆这档事",
"輝夜姬想讓人告白": "辉夜姬想让人告白",
"排球少年!!": "排球少年!!",
"鏈鋸人": "链锯人",
"孤獨搖滾": "孤独摇滚",
"黑暗榮耀": "黑暗荣耀",
"與神同行": "与神同行",
"魷魚遊戲": "鱿鱼游戏",
"機智醫生生活": "机智医生生活",
"請回答1988": "请回答1988",
"陽光先生": "阳光先生",
"鬼怪": "鬼怪",
"來自星星的你": "来自星星的你",
"後宮甄嬛傳": "后宫甄嬛传",
"瑯琊榜": "琅琊榜",
"慶餘年": "庆馀年",
"琅琊榜之風起長林": "琅琊榜之风起长林",
"甄嬛傳": "甄嬛传",
"雍正王朝": "雍正王朝",
"乾隆王朝": "乾隆王朝",
"康熙王朝": "康熙王朝",
"大明王朝1566": "大明王朝1566",
"頭文字D": "头文字D",
"無間道": "无间道",
"少林足球": "少林足球",
"功夫": "功夫",
"臥虎藏龍": "卧虎藏龙",
"英雄本色": "英雄本色",
"倩女幽魂": "倩女幽魂",
"喋血雙雄": "喋血双雄",
"阿飛正傳": "阿飞正传",
"花樣年華": "花样年华",
"重慶森林": "重庆森林",
"復仇者聯盟:終局之戰": "复仇者联盟:终局之战",
"蜘蛛人:無家日": "蜘蛛人:无家日",
"星際效應": "星际效应",
"全面啟動": "全面启动",
"駭客任務": "骇客任务",
"魔戒首部曲:魔戒現身": "魔戒首部曲:魔戒现身",
"神隱少女": "神隐少女",
"霍爾的移動城堡": "霍尔的移动城堡",
"乾杯": "干杯",
"著作": "著作",
"瞭解": "了解",
"頭髮": "头发",
"理髮": "理发",
"發現": "发现",
"後來": "后来",
"皇后": "皇后",
"範圍": "范围",
"姓范": "姓范",
"裡面": "里面",
"計畫": "计画",
"週末": "周末",
"臺灣": "台湾",
"台灣": "台湾",
"麵包": "面包",
"鬱金香": "郁金香",
"舞台劇": "舞台剧"
}
}
@@ -0,0 +1,74 @@
{
"note": "繁→简差分样本(影视标题/人名/常见一对多字)。期望由 MP moviepilot_rust.zhconv_fast(text, 'zh-hans') 生成",
"samples": [
"葬送的芙莉蓮",
"處刑少女的生存之道",
"夏日重現",
"間諜家家酒",
"進擊的巨人",
"鬼滅之刃",
"咒術迴戰",
"我的英雄學院",
"藥師少女的獨語",
"無職轉生",
"關於我轉生變成史萊姆這檔事",
"輝夜姬想讓人告白",
"排球少年!!",
"鏈鋸人",
"孤獨搖滾",
"黑暗榮耀",
"與神同行",
"魷魚遊戲",
"機智醫生生活",
"請回答1988",
"陽光先生",
"鬼怪",
"來自星星的你",
"後宮甄嬛傳",
"瑯琊榜",
"慶餘年",
"琅琊榜之風起長林",
"甄嬛傳",
"雍正王朝",
"乾隆王朝",
"康熙王朝",
"大明王朝1566",
"頭文字D",
"無間道",
"少林足球",
"功夫",
"臥虎藏龍",
"英雄本色",
"倩女幽魂",
"喋血雙雄",
"阿飛正傳",
"花樣年華",
"重慶森林",
"復仇者聯盟:終局之戰",
"蜘蛛人:無家日",
"星際效應",
"全面啟動",
"駭客任務",
"魔戒首部曲:魔戒現身",
"神隱少女",
"霍爾的移動城堡",
"乾杯",
"著作",
"瞭解",
"頭髮",
"理髮",
"發現",
"後來",
"皇后",
"範圍",
"姓范",
"裡面",
"計畫",
"週末",
"臺灣",
"台灣",
"麵包",
"鬱金香",
"舞台劇"
]
}
@@ -0,0 +1,22 @@
/**
* 繁→简差分:MP moviepilot_rust.zhconv_fast(zh-hans) vs identity/toSimplified。
* 期望由 `scripts/mp-sync/gen-differential.py zhconv` 生成;已知差异登记在 zhconv-deviations.json。
*/
import { readFileSync } from "node:fs";
import { expect, it } from "vitest";
import { toSimplified } from "../../src/identity/index.ts";
const read = (n: string) => JSON.parse(readFileSync(new URL(`./${n}`, import.meta.url), "utf8"));
const mp: Record<string, string> = read("zhconv-mp.json").hans;
const dev: Record<string, { mp: string; ours: string }> = read("zhconv-deviations.json").deviations;
it("toSimplified 与 MP zhconv 的差异集合 == 登记集合", () => {
const actual: Record<string, { mp: string; ours: string }> = {};
for (const [src, want] of Object.entries(mp)) {
const ours = toSimplified(src);
if (ours !== want) actual[src] = { mp: want, ours };
}
const snapshot = Object.fromEntries(Object.entries(dev).map(([k, d]) => [k, { mp: d.mp, ours: d.ours }]));
expect(actual).toEqual(snapshot);
expect(Object.keys(mp).length).toBeGreaterThanOrEqual(60);
});