Files
rootandClaude Opus 5.5 8c4c7d028a feat(scrape): 多集文件写多个 episodedetails 块,综艺日期期数按播出日期对集
- 多集文件(文件名 E01-E02 或覆盖多个集单元)按 Kodi 约定在一个 NFO 里每集写一块,MP 只写首集,偏离登记在 nfo-deviations.json;
- 综艺按日期命名的期数按本地季表或 TMDB 季详情的播出日期对上集,写季号、集号与 aired,不再 scrape_no_episode 跳过;
- 认不出集号、集数待确认的跳过让记录记部分完成(已存在 / 没变 / 被人改过仍算完成)。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-03 23:13:47 +08:00

169 lines
6.7 KiB
TypeScript

import { readFileSync } from "node:fs";
import { fetchRichDetail, fetchRichSeason } from "@umt/adapters";
import { episodeNfo, multiEpisodeNfo, planScrapeTargets, seasonNfo, seasonPosterName, workNfo } from "@umt/domain";
import { describe, expect, it } from "vitest";
import { nfoEpisode, nfoSeason, nfoWork } from "../../src/modules/acquisition-organize/services/scrape-input.ts";
import { richEntityFields } from "../../src/modules/sources-identity/service/metadata-rich.ts";
// SCR-1 NFO 差分:同一份 TMDB 原始响应(tmdb-raw.json),MP 走 set_tmdb_info + TmdbScraper(gen-scrape-mp.py → scrape-mp.json),
// 我们走 fetchRichDetail / fetchRichSeason → 字段投影(metadata)→ scrape-input → domain NFO 生成器。
// 双方去掉空元素后逐行比较,已登记的差异(nfo-deviations.json)按元素排除并要求确实不同。
const read = (f: string) => JSON.parse(readFileSync(new URL(f, import.meta.url), "utf8"));
const raw = read("./tmdb-raw.json") as Record<string, unknown>;
const mp = read("./scrape-mp.json") as Record<string, string>;
const devFile = read("./nfo-deviations.json");
const devs = devFile.deviations as { case: string; tag: string }[];
const LANGS = ["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"];
const client = {
async getJson<T>(path: string): Promise<T> {
const r = raw[path];
if (!r) throw new Error(`fixture 缺 ${path}`);
return structuredClone(r) as T;
},
};
const final = (fields: Record<string, { value: unknown }>) =>
Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, { value: v.value }]));
async function ours(): Promise<Record<string, string>> {
const out: Record<string, string> = {};
const movie = await fetchRichDetail(client, "movie", 157336, LANGS);
out.movie = workNfo(
nfoWork(
{
kind: "movie",
title: movie.title,
original_title: movie.originalTitle,
year: movie.year,
metadata: final(richEntityFields(movie) as never),
},
{ tmdb: "157336" },
),
);
const tv = await fetchRichDetail(client, "tv", 95396, LANGS);
out.tvshow = workNfo(
nfoWork(
{
kind: "series",
title: tv.title,
original_title: tv.originalTitle,
year: tv.year,
metadata: final(richEntityFields(tv) as never),
},
{ tmdb: "95396" },
),
);
const sd = await fetchRichSeason(client, 95396, 1, LANGS, { now: new Date("2026-01-01") });
out.season1 = seasonNfo(
nfoSeason(1, {
season_number: 1,
episode_number: null,
title: sd.name,
air_date: sd.airDate,
metadata: { name: { value: sd.name }, overview: { value: sd.overview }, airDate: { value: sd.airDate } },
}),
);
const eps = sd.episodes.map((e) =>
nfoEpisode(1, e.episodeNumber, {
season_number: 1,
episode_number: e.episodeNumber,
title: e.name,
air_date: e.airDate,
metadata: {
name: { value: e.name },
overview: { value: e.overview },
airDate: { value: e.airDate },
runtime: { value: e.runtime },
voteAverage: { value: e.voteAverage },
},
}),
);
for (const e of eps) out[`s01e${String(e.episode).padStart(2, "0")}`] = episodeNfo(e);
out["s01e01-e02"] = multiEpisodeNfo(eps.slice(0, 2));
return out;
}
/** 去空元素(MP 缺值写 <x/>、空 CDATA、评分 0) */
const normalize = (xml: string) =>
xml
.split("\n")
.filter((l) => l.trim() !== "")
.filter((l) => !/^\s*<[\w]+(\s[^>]*)?\/>$/.test(l))
.filter((l) => !/<!\[CDATA\[\]\]>/.test(l))
.filter((l) => !/^\s*<rating>0<\/rating>$/.test(l));
const tagOf = (l: string) => /^ {2}<(\w+)/.exec(l)?.[1] ?? null;
describe("NFO 与 MP 差分", async () => {
const mine = await ours();
const cases = ["movie", "tvshow", "season1", "s01e01", "s01e02"];
it.each(cases)("%s:结构与取值一致(已登记差异除外)", (c) => {
const skip = new Set(devs.filter((d) => d.case === c).map((d) => d.tag));
const a = normalize(mp[c]!).filter((l) => !skip.has(tagOf(l) ?? ""));
const b = normalize(mine[c]!).filter((l) => !skip.has(tagOf(l) ?? ""));
expect(b).toEqual(a);
for (const tag of skip) {
const ma = normalize(mp[c]!).filter((l) => tagOf(l) === tag);
const mb = normalize(mine[c]!).filter((l) => tagOf(l) === tag);
expect(mb, `登记的差异 ${c}.${tag} 已消失,删掉登记`).not.toEqual(ma);
}
});
it("多集文件:按登记的偏离写多个 <episodedetails> 块,每块与 MP 对应单集一致(MP 只写首集)", () => {
expect(devFile.multiEpisode?.reason).toBeTruthy();
const xml = mine["s01e01-e02"]!;
const [decl, ...body] = xml.split("\n");
expect(decl).toBe(mp.s01e01!.split("\n")[0]);
const blocks = body
.join("\n")
.split(/(?=<episodedetails>)/)
.filter((b) => b.trim());
expect(blocks).toHaveLength(2);
for (const [i, c] of ["s01e01", "s01e02"].entries()) {
const skip = new Set(devs.filter((d) => d.case === c).map((d) => d.tag));
const a = normalize(mp[c]!).filter((l) => !skip.has(tagOf(l) ?? "") && !l.startsWith("<?xml"));
const b = normalize(blocks[i]!).filter((l) => !skip.has(tagOf(l) ?? ""));
expect(b).toEqual(a);
}
});
it("编码与声明:UTF-8、两空格缩进、末尾换行,和 MP 一致", () => {
for (const c of cases) {
expect(mine[c]!.split("\n")[0]).toBe(mp[c]!.split("\n")[0]);
expect(mine[c]!.endsWith("\n")).toBe(true);
}
});
it("季海报命名与 MP get_season_poster 一致", () => {
expect(seasonPosterName(1)).toBe(mp.seasonPosterName);
expect(seasonPosterName(0)).toBe(mp.seasonSpecialsPosterName);
});
it("文件命名:对照 MP scraping.py 的目标路径(差异见 nfo-deviations.json images)", () => {
const t = planScrapeTargets({
kind: "tv",
workDir: "人生切割术 (2022)",
videos: [{ path: "人生切割术 (2022)/Season 1/人生切割术 - S01E01 - 第 1 集.mkv", season: 1, episode: 1 }],
images: {
posterPath: "/p.jpg",
backdropPath: "/b.jpg",
seasonPosters: new Map([[1, "/s.jpg"]]),
stills: new Map([["1:1", "/e.jpg"]]),
},
options: { images: true, episodeThumbs: true },
});
// MP:tvshow.nfo(TV)、season.nfo(SEASON)、<stem>.nfo(EPISODE);季海报根目录 seasonXX-poster + 季目录 poster
expect(t.map((x) => x.path)).toEqual([
"人生切割术 (2022)/tvshow.nfo",
"人生切割术 (2022)/poster.jpg",
"人生切割术 (2022)/fanart.jpg",
"人生切割术 (2022)/Season 1/season.nfo",
"人生切割术 (2022)/season01-poster.jpg",
"人生切割术 (2022)/Season 1/poster.jpg",
"人生切割术 (2022)/Season 1/人生切割术 - S01E01 - 第 1 集.nfo",
"人生切割术 (2022)/Season 1/人生切割术 - S01E01 - 第 1 集-thumb.jpg",
]);
});
});