From 9be98130211d6882d480815a97e401ab098bc5d3 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 29 Sep 2026 20:50:42 +0800 Subject: [PATCH] feat(sites): structure release detail pages and rework the detail drawer (DETAIL-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapters (pure functions + tests): - detail-description: strip HTML/BBCode residue, drop site template blocks and ad/signature lines (configurable rules), parse PT info blocks (◎ / 【】 / 『』 / 「键:」) into infoBlock and body paragraphs - mediainfo: MediaInfo and BDInfo (quick summary + full report) into a summary of container/duration/bitrate/size, video (HDR/DV), audio and subtitle tracks - file-tree: file list into a flattened directory tree with sizes and main/sample/subtitle/extra roles - formatDetailPage combines them; the server computes it when reading the cached detail page, so existing caches benefit without a refetch Contracts: SiteReleasePage gains infoBlock, paragraphs, media, fileTree. Web: drawer now reads top-down: meta row (promo/H&R, size, peers, upload time, IMDb/TMDB/豆瓣 tags) → 资料 → 简介 (collapsible) → 技术规格 (raw folded) → 截图 grid with keyboard lightbox → 文件 tree; inventory overlay moved into the fixed footer next to 下载. Fixtures: 6 desensitized samples from the local cache (site.detail and RSS descriptions) plus synthetic multi-track MediaInfo, BDInfo, bracket blocks and directory file lists. Co-Authored-By: Claude Opus 5.5 --- .../sources-identity/service/detail-page.ts | 9 +- .../test/sources-identity/detail-page.test.ts | 8 + .../app/components/console/detail-sheet.tsx | 2 +- .../workbench/release-detail-sheet.tsx | 179 +++---- .../workbench/release-page-section.tsx | 454 ++++++++++++++---- .../adapters/src/sites/detail-description.ts | 283 +++++++++++ .../adapters/src/sites/detail-format.test.ts | 287 +++++++++++ packages/adapters/src/sites/detail-format.ts | 33 ++ packages/adapters/src/sites/file-tree.ts | 103 ++++ packages/adapters/src/sites/index.ts | 19 + packages/adapters/src/sites/mediainfo.ts | 405 ++++++++++++++++ packages/contracts/src/sources.ts | 59 +++ .../src/sites/fixtures/details/index.ts | 2 + .../src/sites/fixtures/details/real.ts | 87 ++++ .../src/sites/fixtures/details/synthetic.ts | 236 +++++++++ packages/testkit/src/sites/index.ts | 1 + 16 files changed, 1992 insertions(+), 175 deletions(-) create mode 100644 packages/adapters/src/sites/detail-description.ts create mode 100644 packages/adapters/src/sites/detail-format.test.ts create mode 100644 packages/adapters/src/sites/detail-format.ts create mode 100644 packages/adapters/src/sites/file-tree.ts create mode 100644 packages/adapters/src/sites/mediainfo.ts create mode 100644 packages/testkit/src/sites/fixtures/details/index.ts create mode 100644 packages/testkit/src/sites/fixtures/details/real.ts create mode 100644 packages/testkit/src/sites/fixtures/details/synthetic.ts diff --git a/apps/server/src/modules/sources-identity/service/detail-page.ts b/apps/server/src/modules/sources-identity/service/detail-page.ts index 048e51f3..284d820b 100644 --- a/apps/server/src/modules/sources-identity/service/detail-page.ts +++ b/apps/server/src/modules/sources-identity/service/detail-page.ts @@ -1,4 +1,4 @@ -import type { SiteDetailPage } from "@umt/adapters"; +import { formatDetailPage, type SiteDetailPage } from "@umt/adapters"; import type { SiteReleasePage } from "@umt/contracts"; import { sql } from "@umt/db"; import { sharedCache, storageSettings } from "../../../shared-cache.ts"; @@ -99,6 +99,12 @@ export async function detailScreenshotRequest( export async function loadDetailPage(rt: SourcesRuntime, srId: string): Promise { const p = await loadStored(rt, srId); if (!p) return null; + // 展示结构每次现算:旧缓存也能用,解析规则改了不必清缓存 + const formatted = formatDetailPage({ + descriptionText: p.descriptionText ?? null, + mediaInfo: p.mediaInfo ?? null, + files: p.files ?? [], + }); return { fetchedAt: p.fetchedAt, descriptionText: p.descriptionText, @@ -118,6 +124,7 @@ export async function loadDetailPage(rt: SourcesRuntime, srId: string): Promise< files: p.files, tags: p.tags, uploader: p.uploader, + ...formatted, }; } diff --git a/apps/server/test/sources-identity/detail-page.test.ts b/apps/server/test/sources-identity/detail-page.test.ts index 4e7e8715..687434c5 100644 --- a/apps/server/test/sources-identity/detail-page.test.ts +++ b/apps/server/test/sources-identity/detail-page.test.ts @@ -88,6 +88,14 @@ describe.skipIf(!up)("站点详情页扩展内容:按需抓 + 24 小时缓存 + expect(page.mediaInfo).toMatch(/^General\nComplete name : Dune.mkv/); expect(page.descriptionText).toContain("◎片名 沙丘2"); expect(page.descriptionText).not.toContain("Complete name"); + // 展示结构:读缓存时现算 + expect(page.infoBlock).toContainEqual({ key: "片名", value: "沙丘2" }); + expect(page.media).toMatchObject({ + kind: "mediainfo", + general: { fileName: "Dune.mkv", container: "Matroska", size: "78 GiB", duration: "2 h 46 min" }, + video: [{ codec: "HEVC" }], + }); + expect(Array.isArray(page.fileTree)).toBe(true); const row = await h.db .selectFrom("source_release") .select(["raw"]) diff --git a/apps/web/app/components/console/detail-sheet.tsx b/apps/web/app/components/console/detail-sheet.tsx index 6c787c07..1d47bb58 100644 --- a/apps/web/app/components/console/detail-sheet.tsx +++ b/apps/web/app/components/console/detail-sheet.tsx @@ -35,7 +35,7 @@ export function DetailSheet({ className={cn("w-full gap-0 p-0", width === "lg" ? "sm:max-w-2xl" : "sm:max-w-lg", className)} > - {title} + {title} {description ? {description} : null}
{children}
diff --git a/apps/web/app/components/workbench/release-detail-sheet.tsx b/apps/web/app/components/workbench/release-detail-sheet.tsx index 4bbfeb14..ca368481 100644 --- a/apps/web/app/components/workbench/release-detail-sheet.tsx +++ b/apps/web/app/components/workbench/release-detail-sheet.tsx @@ -1,5 +1,5 @@ import type { InventoryEntry, WorkbenchRelease } from "@umt/contracts"; -import { Bot, Download, RefreshCw } from "lucide-react"; +import { ArrowDown, ArrowUp, Bot, Check, Download, RefreshCw } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { Link } from "react-router"; import { toast } from "sonner"; @@ -15,9 +15,9 @@ import { Button } from "~/components/ui/button.tsx"; import { errorMessage } from "~/lib/api.ts"; import { formatBytes, formatDateTime } from "~/lib/utils.ts"; import { DownloadDialog } from "./download-dialog.tsx"; -import { categoryLabel, hrOf, ORIGIN_LABEL, relTime } from "./release-fields.ts"; +import { absTime, categoryLabel, countText, ORIGIN_LABEL, relTime, snatchedOf } from "./release-fields.ts"; import { InventoryOverlay, ReleaseBadges } from "./release-list.tsx"; -import { ReleasePageContent } from "./release-page-section.tsx"; +import { ExternalIdTags, externalLinks, ReleasePageSections } from "./release-page-section.tsx"; const DECISION_LABEL = { accept: { label: "符合,可收录", tone: "success" }, @@ -45,7 +45,6 @@ function LocalInventory({ release, entry }: { release: WorkbenchRelease; entry: const slots = detail.data?.slots ?? []; return (
- {entry?.versions.length ? (
    {entry.versions.map((v) => ( @@ -136,6 +135,19 @@ export function ReleaseDetailSheet({ refresh.mutate({ siteId, rid }); }, [needPage, rid, siteId, refresh]); + const page = detail.data?.page ?? null; + const snatched = r ? snatchedOf(r) : null; + const doRefresh = () => + r && + refresh.mutate( + { siteId, rid: r.id }, + { + onSuccess: (d) => + d.refreshError ? toast.warning(`复核失败,显示上次数据:${d.refreshError}`) : toast.success("已复核"), + onError: (e) => toast.error(errorMessage(e)), + }, + ); + return ( +
    + +
    - } - > - {detail.isPending ? ( - - ) : detail.error ? ( - void detail.refetch()} /> - ) : detail.data ? ( - <> - {detail.data.refreshError ? ( -

    - 最近一次复核失败,以下是过期数据:{detail.data.refreshError} -

    - ) : null} - -

    提交下载时会再次核对免费和做种状态。

    - +
+ {page && (externalLinks(page).length || page.tags.length || page.uploader) ? ( +
+ + {page.tags.map((t) => ( + + {t} + + ))} + {page.uploader ? 发布者 {page.uploader} : null} +
) : null} - - - - {detail.data?.page ? ( - - ) : refresh.isPending || detail.isPending ? ( - - ) : detail.data && !detail.data.detailSupported ? ( -

这个站点不支持抓详情页。

- ) : ( -

- 还没有详情页内容{detail.data?.refreshError ? `(${detail.data.refreshError})` : ""} - 。点「复核详情」抓取。 + {detail.data?.refreshError ? ( +

+ 最近一次复核失败,下面是之前的数据:{detail.data.refreshError}

- )} -
+ ) : null} + + + {page ? ( + + ) : refresh.isPending || detail.isPending ? ( + + ) : detail.error ? ( + void detail.refetch()} /> + ) : detail.data && !detail.data.detailSupported ? ( +

这个站点不支持抓详情页。

+ ) : ( +

还没有详情页内容。点「复核详情」抓取。

+ )} + + {!page?.fileTree.length && r.fileList?.length ? ( + +
    + {r.fileList.map((f) => ( +
  • + {f.path} + {formatBytes(f.size, 1)} +
  • + ))} +
+
+ ) : null} + {detail.data ? ( + + +

提交下载时会再次核对免费和做种状态。

+
+ ) : null} + ) : null} - {r.fileList?.length ? ( - -
    - {r.fileList.map((f) => ( -
  • - {f.path} - {formatBytes(f.size, 1)} -
  • - ))} -
-
- ) : null} - )} diff --git a/apps/web/app/components/workbench/release-page-section.tsx b/apps/web/app/components/workbench/release-page-section.tsx index 565a17fd..8de2306c 100644 --- a/apps/web/app/components/workbench/release-page-section.tsx +++ b/apps/web/app/components/workbench/release-page-section.tsx @@ -1,99 +1,381 @@ -import type { SiteReleasePage } from "@umt/contracts"; -import { ExternalLink } from "lucide-react"; -import { StatusBadge } from "~/components/console/status-badge.tsx"; -import { formatBytes, formatDateTime } from "~/lib/utils.ts"; +import type { SiteFileTreeRow, SiteMediaSummary, SiteReleasePage } from "@umt/contracts"; +import { ChevronDown, ChevronLeft, ChevronRight, ExternalLink, FileVideo, Folder, FolderOpen } from "lucide-react"; +import { type ReactNode, useMemo, useState } from "react"; +import { DetailSection } from "~/components/console/detail-sheet.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { Dialog, DialogContent, DialogDescription, DialogTitle } from "~/components/ui/dialog.tsx"; +import { cn, formatBytes } from "~/lib/utils.ts"; /** 详情页扩展内容里的外部站点链接 */ export function externalLinks(p: Pick) { const out: { label: string; href: string }[] = []; - if (p.tmdb) out.push({ label: `TMDB ${p.tmdb.id}`, href: `https://www.themoviedb.org/${p.tmdb.type}/${p.tmdb.id}` }); - if (p.imdbId) out.push({ label: `IMDb ${p.imdbId}`, href: `https://www.imdb.com/title/${p.imdbId}/` }); - if (p.doubanId) out.push({ label: `豆瓣 ${p.doubanId}`, href: `https://movie.douban.com/subject/${p.doubanId}/` }); + if (p.imdbId) out.push({ label: "IMDb", href: `https://www.imdb.com/title/${p.imdbId}/` }); + if (p.tmdb) out.push({ label: "TMDB", href: `https://www.themoviedb.org/${p.tmdb.type}/${p.tmdb.id}` }); + if (p.doubanId) out.push({ label: "豆瓣", href: `https://movie.douban.com/subject/${p.doubanId}/` }); return out; } -export function ReleasePageContent({ page }: { page: SiteReleasePage }) { +/** IMDb / TMDB / 豆瓣小标签(新窗口打开) */ +export function ExternalIdTags({ page }: { page: Pick }) { const links = externalLinks(page); - const empty = - !links.length && - !page.uploader && - !page.tags.length && - !page.descriptionText && - !page.mediaInfo && - !page.screenshots.length && - !page.files.length; + if (!links.length) return null; return ( -
- {links.length || page.uploader ? ( -
- {links.map((l) => ( - - {l.label} - - - ))} - {page.uploader ? 上传者 {page.uploader} : null} + <> + {links.map((l) => ( + + {l.label} + + + ))} + + ); +} + +/** 长文本:超出时折叠,点「展开」看全部 */ +function Clamp({ children, long, lines = 3 }: { children: ReactNode; long: boolean; lines?: 3 | 6 }) { + const [open, setOpen] = useState(false); + return ( + <> +
{children}
+ {long ? ( + + ) : null} + + ); +} + +function InfoTable({ items }: { items: SiteReleasePage["infoBlock"] }) { + return ( +
+ {items.map((it) => ( +
+
{it.key}
+
+ 90}>{it.value} +
- ) : null} - {page.tags.length ? ( -
- {page.tags.map((t) => ( - {t} - ))} -
- ) : null} - {page.descriptionText ? ( -

- {page.descriptionText} -

- ) : null} - {page.mediaInfo ? ( -
- MediaInfo -
-            {page.mediaInfo}
-          
-
- ) : null} - {page.screenshots.length ? ( -
- {page.screenshots.map((s, i) => ( - - {`截图 - - ))} -
- ) : null} - {page.files.length ? ( -
- 文件清单({page.files.length}) -
    - {page.files.map((f, i) => ( -
  • - {f.name} - - {f.sizeBytes === null ? "" : formatBytes(f.sizeBytes, 1)} - -
  • - ))} -
-
- ) : null} - {empty ?

详情页上没有解析到简介、截图或外部链接。

: null} -

- 抓取于 {formatDateTime(page.fetchedAt)},缓存 24 小时;截图经服务端代理加载。 -

+ ))} +
+ ); +} + +function Synopsis({ paragraphs }: { paragraphs: string[] }) { + const total = paragraphs.reduce((n, p) => n + p.length, 0); + return ( +
+ 320 || paragraphs.length > 4} lines={6}> + {paragraphs.map((p, i) => ( +

0 && "mt-2")}> + {p} +

+ ))} +
); } + +const dot = (xs: (string | null | undefined)[]) => xs.filter(Boolean).join(" · "); + +function TrackTable({ head, rows }: { head: string[]; rows: ReactNode[][] }) { + return ( +
+ + + + {head.map((h) => ( + + ))} + + + + {rows.map((r, i) => ( + + {r.map((c, j) => ( + + ))} + + ))} + +
+ {h} +
+ {c ?? "—"} +
+
+ ); +} + +const flag = (on: boolean | null, label: string) => + on ? {label} : null; + +function TechSpecs({ media, raw }: { media: SiteMediaSummary | null; raw: string | null }) { + return ( +
+ {media ? ( + <> +

+ {dot([media.general.container, media.general.duration, media.general.overallBitrate, media.general.size]) || + "—"} +

+ {media.video.length ? ( + [ + v.codec, + v.resolution, + v.frameRate, + v.bitrate, + v.bitDepth, + v.hdr.length ? v.hdr.join(" + ") : "SDR", + ])} + /> + ) : null} + {media.audio.length ? ( + [ + + {a.language ?? "未标语言"} + {flag(a.isDefault, "默认")} + , + a.codec, + a.channels, + a.bitrate, + a.title, + ])} + /> + ) : null} + {media.subtitles.length ? ( + [ + + {s.language ?? "未标语言"} + {flag(s.isDefault, "默认")} + {flag(s.forced, "强制")} + , + s.format, + s.title, + ])} + /> + ) : null} + + ) : null} + {raw ? ( +
+ + {media?.kind === "bdinfo" ? "BDInfo 原文" : "MediaInfo 原文"} + +
+            {raw}
+          
+
+ ) : null} +
+ ); +} + +/** 截图网格 + 灯箱(左右方向键切换,Esc 关闭) */ +function Screenshots({ shots }: { shots: SiteReleasePage["screenshots"] }) { + const [open, setOpen] = useState(null); + const n = shots.length; + const go = (d: number) => setOpen((i) => (i === null ? i : (i + d + n) % n)); + const cur = open === null ? null : shots[open]; + return ( + <> +
+ {shots.map((s, i) => ( + + ))} +
+ !o && setOpen(null)}> + { + if (e.key === "ArrowLeft") go(-1); + else if (e.key === "ArrowRight") go(1); + }} + > + 截图 + 左右方向键切换 + {cur ? ( + {`截图 + ) : null} +
+ + {(open ?? 0) + 1} / {n} + {cur?.host ? ` · ${cur.host}` : ""} + + {n > 1 ? ( +
+ + +
+ ) : null} +
+
+
+ + ); +} + +const ROLE_TAG: Partial, string>> = { + sample: "Sample", + subtitle: "字幕", + extra: "附件", +}; + +/** 文件目录树:目录可折叠,文件多时默认只展开第一层 */ +export function FileTree({ rows }: { rows: SiteFileTreeRow[] }) { + const [collapsed, setCollapsed] = useState>( + () => new Set(rows.length > 40 ? rows.filter((r) => r.dir && r.depth >= 1).map((r) => r.path) : []), + ); + const visible = useMemo( + () => rows.filter((r) => ![...collapsed].some((c) => r.path.startsWith(`${c}/`))), + [rows, collapsed], + ); + const toggle = (p: string) => + setCollapsed((s) => { + const n = new Set(s); + if (n.has(p)) n.delete(p); + else n.add(p); + return n; + }); + return ( +
    + {visible.map((r) => { + const pad = { paddingLeft: `${r.depth * 1.25}rem` }; + if (r.dir) { + const shut = collapsed.has(r.path); + const Icon = shut ? Folder : FolderOpen; + return ( +
  • + +
  • + ); + } + const main = r.role === "main"; + const tag = r.role ? ROLE_TAG[r.role] : undefined; + return ( +
  • + + {main ? ( + + ) : ( + + )} + + {r.name} + + {tag ? ( + + {tag} + + ) : null} + + {r.sizeBytes === null ? "" : formatBytes(r.sizeBytes, 1)} + +
  • + ); + })} +
+ ); +} + +/** 资料 → 简介 → 技术规格 → 截图 → 文件;没内容的节不出现 */ +export function ReleasePageSections({ page }: { page: SiteReleasePage }) { + const fileCount = page.fileTree.filter((r) => !r.dir).length; + const empty = + !page.infoBlock.length && + !page.paragraphs.length && + !page.media && + !page.mediaInfo && + !page.screenshots.length && + !page.fileTree.length; + return ( + <> + {page.infoBlock.length ? ( + + + + ) : null} + {page.paragraphs.length ? ( + + + + ) : null} + {page.media || page.mediaInfo ? ( + + + + ) : null} + {page.screenshots.length ? ( + + + + ) : null} + {page.fileTree.length ? ( + + + + ) : null} + {empty ? ( +

详情页上没有简介、技术规格、截图和文件清单。

+ ) : null} + + ); +} diff --git a/packages/adapters/src/sites/detail-description.ts b/packages/adapters/src/sites/detail-description.ts new file mode 100644 index 00000000..7628d1ea --- /dev/null +++ b/packages/adapters/src/sites/detail-description.ts @@ -0,0 +1,283 @@ +/** + * 详情页简介清洗与结构化(DETAIL-1):纯函数,输入是抓取时得到的简介文本(可能残留 HTML / BBCode), + * 输出「信息块」键值对(◎片名 / 【年代】/ 『导演』/ 片名: 等)与正文段落。 + * 清洗规则可配置:站点模板段、广告签名按正则整段或整行去掉,调用方可在默认规则上追加。 + */ + +export interface DetailInfoItem { + key: string; + value: string; +} + +export interface StructuredDescription { + infoBlock: DetailInfoItem[]; + paragraphs: string[]; +} + +export interface DescriptionRules { + /** 整段去掉(对整篇文本匹配,可跨行):站点模板段、更新提示等 */ + dropBlocks: RegExp[]; + /** 整行去掉:广告、群号、转载声明、感谢签名、分隔线 */ + dropLines: RegExp[]; +} + +export const DEFAULT_DESCRIPTION_RULES: DescriptionRules = { + dropBlocks: [ + // 憨憨豆瓣信息块末尾:「注意:该信息最后更新于…点击 这里 更新信息」 + /注意[::]\s*该信息最后更新于[\s\S]{0,80}?更新信息/g, + ], + dropLines: [ + /^[-=_*~—─━·.]{5,}$/, + /^(?:QQ|qq|微信|TG|Telegram|电报|Discord)\s*群?(?:号)?\s*[::]/, + /(?:禁止|严禁|请勿|不得).{0,12}转载|转载.{0,8}(?:请注明|须注明)/, + /^(?:感谢|鸣谢|Thanks?\s+to)\s*.{0,20}(?:分享|发布|制作|压制|上传)/i, + /^(?:本资源|本种子?|资源)?由\s*\S{1,24}\s*(?:小组|组)?\s*(?:制作|压制|发布|分享)\S{0,4}$/, + /^(?:点击|更多).{0,10}(?:下载|资源).{0,20}(?:访问|请到|见)/, + ], +}; + +/** 常见信息块键(去掉空白后比较);冒号式 / 【】/『』 只认这些,避免把正文里的「【第一版】」当键 */ +const KNOWN_KEYS = [ + "译名", + "片名", + "原名", + "又名", + "别名", + "中文名", + "英文名", + "年代", + "年份", + "产地", + "国家", + "地区", + "国家地区", + "制片国家/地区", + "类别", + "类型", + "语言", + "对白语言", + "字幕", + "上映日期", + "首播", + "首播日期", + "IMDb评分", + "IMDB评分", + "IMDb链接", + "IMDB链接", + "豆瓣评分", + "豆瓣链接", + "TMDB", + "TMDb链接", + "评分", + "票数", + "集数", + "季数", + "片长", + "单集片长", + "视频尺寸", + "文件格式", + "文件大小", + "导演", + "编剧", + "主演", + "演员", + "配音", + "制片人", + "标签", + "标语", + "简介", + "剧情简介", + "内容简介", + "剧情概要", + "剧情介绍", + "获奖情况", +]; +const KNOWN = new Set(KNOWN_KEYS.map((k) => k.toLowerCase())); +/** 值就是正文的键:内容进段落 */ +const DESC_KEYS = new Set(["简介", "剧情简介", "内容简介", "剧情概要", "剧情介绍"]); +/** 人名列表:续行合并,修复「Kai Xu周也」这类元素拼接 */ +const PEOPLE_KEYS = new Set(["导演", "编剧", "主演", "演员", "配音", "制片人"]); + +const WS = "[\\s\\u3000]"; +const KEY_ALTERNATION = [...KNOWN_KEYS] + .sort((a, b) => b.length - a.length) + .map((k) => [...k].map((c) => c.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")).join(`${WS}*`)) + .join("|"); +/** ◎ 标记:认已知键,否则取到第一个空白 / 冒号为止的短词 */ +const MARK_KNOWN = new RegExp(`^◎${WS}*(${KEY_ALTERNATION})${WS}*[::]?${WS}*(.*)$`, "i"); +const MARK_ANY = /^◎[\s ]*([^\s ::]{1,12})[\s ]*[::]?[\s ]*(.*)$/; +const BRACKET = new RegExp(`^(?:【|『|\\[)${WS}*(${KEY_ALTERNATION})${WS}*(?:】|』|\\])${WS}*[::]?${WS}*(.*)$`, "i"); +const COLON = new RegExp(`^(${KEY_ALTERNATION})${WS}*[::]${WS}*(.*)$`, "i"); + +const ENTITIES: Record = { + nbsp: " ", + amp: "&", + lt: "<", + gt: ">", + quot: '"', + apos: "'", + ensp: " ", + emsp: " ", +}; + +function decodeEntities(s: string): string { + return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (all, e: string) => { + if (e[0] === "#") { + const code = e[1] === "x" || e[1] === "X" ? Number.parseInt(e.slice(2), 16) : Number.parseInt(e.slice(1), 10); + return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : all; + } + return ENTITIES[e.toLowerCase()] ?? all; + }); +} + +/** 去 HTML / BBCode 残留:图片整段去掉,链接留文字,块级标签换行,实体解码 */ +export function stripMarkup(text: string): string { + return decodeEntities( + text + .replace(/\r/g, "") + .replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, "") + // 站点截断在标签中间:「…\n]*…?$/i, "…") + .replace(/]*>/gi, "") + .replace(/[ \t]*\n?/gi, "\n") + .replace(/<\/(?:p|div|li|tr|h[1-6]|blockquote|fieldset|pre)>[ \t]*\n?/gi, "\n") + .replace(/<[a-z/!][^>]*>/gi, "") + .replace(/\[img(?:=[^\]]*)?\][^[]*\[\/img\]|\[img=[^\]]+\]/gi, "") + .replace(/\[url=[^\]]*\]([\s\S]*?)\[\/url\]/gi, "$1") + .replace( + /\[\/?(?:b|i|u|s|color|size|font|align|center|left|right|url|quote|code|spoiler|hide|box|list|\*|pre|table|tr|td)(?:=[^\]]*)?\]/gi, + "", + ), + ).replace(/ /g, " "); +} + +/** 清洗:去标记、按规则删段删行、行尾空白、连续空行压成一行 */ +export function cleanDescription(text: string, rules: DescriptionRules = DEFAULT_DESCRIPTION_RULES): string { + let t = stripMarkup(text); + for (const re of rules.dropBlocks) t = t.replace(re, ""); + return t + .split("\n") + .map((l) => l.replace(/[\s ]+$/, "")) + .filter((l) => { + const s = l.trim().replace(/^ +/, ""); + return !s || !rules.dropLines.some((re) => re.test(s)); + }) + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +interface KeyHit { + key: string; + value: string; +} + +const normKey = (k: string) => k.replace(/[\s ]+/g, ""); + +function matchKey(line: string): KeyHit | null { + const m = MARK_KNOWN.exec(line) ?? MARK_ANY.exec(line) ?? BRACKET.exec(line) ?? COLON.exec(line); + if (!m) return null; + const key = normKey(m[1]!); + if (!key) return null; + // 冒号式:键后面要么没东西(值在下一行),要么是冒号分隔的值;「片名」开头的正文句子不当键 + if (!line.startsWith("◎") && !KNOWN.has(key.toLowerCase())) return null; + return { key, value: (m[2] ?? "").trim() }; +} + +const isSeparator = (l: string) => /^[-=_*~—─━·.]{5,}$/.test(l); +const SENTENCE_END = /[。!?!?.…」』"”))]$/; + +/** 相邻行合并成段:英文硬换行(下一行小写开头且上一行没有句末标点)接成一行,其余每行一段 */ +function toParagraphs(lines: string[]): string[] { + const out: string[] = []; + let prevJoinable = false; + for (const raw of lines) { + const l = raw.replace(/^[\s ]+/, "").trim(); + if (!l) { + prevJoinable = false; + continue; + } + if (prevJoinable && /^[a-z]/.test(l) && out.length) out[out.length - 1] += ` ${l}`; + else out.push(l); + prevJoinable = /[A-Za-z,]$/.test(l) && !SENTENCE_END.test(l); + } + return out; +} + +function tidyValue(key: string, lines: string[]): string { + const parts = lines.map((l) => l.replace(/[\s ]+/g, " ").trim()).filter(Boolean); + if (PEOPLE_KEYS.has(key)) { + return parts + .flatMap((p) => p.replace(/([a-z])(?=[一-鿿])/g, "$1\u0000").split("\u0000")) + .map((p) => p.trim()) + .filter(Boolean) + .join(" / "); + } + return parts.every((p) => p.length <= 40) ? parts.join(" / ") : parts.join("\n"); +} + +const URL_ONLY = /^(?:https?:\/\/\S+|www\.\S+)$/i; +const isLinkKey = (k: string) => /(?:链接|网址|link)$/i.test(k) || /^tmdb$/i.test(k); + +/** + * 信息块 + 段落。信息块的键:◎ 开头任意短键;【】『』[] 包裹或「键:」开头只认常见键。 + * 键后的值可在同一行,也可在下一个非空行(憨憨豆瓣块);续行(主演多行)并入上一键,空行结束。 + * 简介类键的内容进段落;信息块之前的零碎短行(海报卡片的片名 / 年份 / 评分)在有信息块时丢弃。 + */ +export function structureDescription( + text: string | null | undefined, + rules: DescriptionRules = DEFAULT_DESCRIPTION_RULES, +): StructuredDescription { + if (!text?.trim()) return { infoBlock: [], paragraphs: [] }; + const lines = cleanDescription(text, rules).split("\n"); + const info: { key: string; lines: string[] }[] = []; + const lead: string[] = []; + const body: string[] = []; + let cur: { key: string; lines: string[] } | "desc" | null = null; + const pushBody = (l: string) => (info.length ? body : lead).push(l); + + for (const line of lines) { + const trimmed = line.trim(); + if (isSeparator(trimmed)) { + if (cur && cur !== "desc" && cur.lines.length) cur = null; + if (cur === "desc") body.push(""); + continue; + } + const hit = trimmed ? matchKey(trimmed) : null; + if (hit) { + if (DESC_KEYS.has(hit.key)) { + cur = "desc"; + body.push(""); + if (hit.value) body.push(hit.value); + } else { + const entry = { key: hit.key, lines: hit.value ? [hit.value] : [] }; + info.push(entry); + cur = entry; + } + continue; + } + if (!trimmed) { + if (cur === "desc") body.push(""); + else if (cur?.lines.length) cur = null; + else if (!cur) pushBody(""); + continue; + } + if (cur && cur !== "desc") cur.lines.push(line); + else pushBody(line); + } + + const seen = new Set(); + const infoBlock: DetailInfoItem[] = []; + for (const e of info) { + const value = tidyValue(e.key, e.lines); + if (!value || seen.has(e.key)) continue; + if (isLinkKey(e.key) && value.split(/\s+/).every((v) => URL_ONLY.test(v) || !v)) continue; + seen.add(e.key); + infoBlock.push({ key: e.key, value }); + } + // 信息块前面只有零碎短行(卡片摘要)就丢掉;有像样的句子则保留为正文 + const leadParas = toParagraphs(lead); + const keepLead = !infoBlock.length || leadParas.some((p) => p.length > 24 || /[。!?!?]$/.test(p)); + const paragraphs = [...(keepLead ? leadParas : []), ...toParagraphs(body)]; + return { infoBlock, paragraphs }; +} diff --git a/packages/adapters/src/sites/detail-format.test.ts b/packages/adapters/src/sites/detail-format.test.ts new file mode 100644 index 00000000..3a45ef18 --- /dev/null +++ b/packages/adapters/src/sites/detail-format.test.ts @@ -0,0 +1,287 @@ +import { + REAL_DETAIL_HH_DV_ONLY, + REAL_DETAIL_HH_INFO_CARD, + REAL_RSS_DESCR_AWARDS, + REAL_RSS_DESCR_CAST, + REAL_RSS_DESCR_INFO_BLOCK, + REAL_RSS_DESCR_PLAIN, + SYNTH_BDINFO_FULL, + SYNTH_BDINFO_QUICK, + SYNTH_DESCR_BRACKETS, + SYNTH_FILES_BDMV, + SYNTH_FILES_DIRECTORY, + SYNTH_MEDIAINFO_MULTI_TRACK, +} from "@umt/testkit"; +import { describe, expect, it } from "vitest"; +import { + cleanDescription, + DEFAULT_DESCRIPTION_RULES, + stripMarkup, + structureDescription, +} from "./detail-description.ts"; +import { formatDetailPage } from "./detail-format.ts"; +import { buildFileTree } from "./file-tree.ts"; +import { parseMediaSummary } from "./mediainfo.ts"; + +const info = (text: string | null) => + Object.fromEntries(structureDescription(text).infoBlock.map((x) => [x.key, x.value])); + +describe("简介结构化:真实缓存样本", () => { + it("憨憨豆瓣信息块:「片名:」换行取值,卡片摘要与更新提示去掉,主演拆开", () => { + const d = structureDescription(REAL_DETAIL_HH_INFO_CARD.descriptionText); + expect(Object.fromEntries(d.infoBlock.map((x) => [x.key, x.value]))).toMatchObject({ + 片名: "一瓯春", + 年代: "2026", + 片长: "45分钟", + 类别: "爱情,古装", + 导演: "潘安子 Anzi Pan", + 编剧: "尤四姐", + 主演: "许凯 Kai Xu / 周也 Ye Zhou / 白澍 Shu Bai", + }); + // 空值键(别名 / 标语)不出现 + expect(d.infoBlock.map((x) => x.key)).not.toContain("别名"); + expect(d.infoBlock.map((x) => x.key)).not.toContain("标语"); + expect(d.paragraphs).toHaveLength(2); + expect(d.paragraphs[0]).toMatch(/^“杀伐千面腹黑男”/); + expect(d.paragraphs.join("\n")).not.toMatch(/该信息最后更新于|更新信息|^-{5,}|\(11053\)/m); + }); + + it("◎ 信息块 + ◎简介:链接键去掉,简介进段落", () => { + const d = structureDescription(REAL_RSS_DESCR_INFO_BLOCK); + expect(d.infoBlock.map((x) => x.key)).toEqual([ + "译名", + "片名", + "年代", + "产地", + "类别", + "语言", + "上映日期", + "集数", + "片长", + "主演", + ]); + expect(info(REAL_RSS_DESCR_INFO_BLOCK).译名).toBe("Stay Low Profile, Sect Chief / 掌门低调点 3D动画版"); + expect(d.paragraphs).toHaveLength(1); + expect(d.paragraphs[0]).toMatch(/^大雪覆门/); + expect(JSON.stringify(d)).not.toMatch(/<| |douban\.com/); + }); + + it("主演多行续行合并;◎获奖情况进信息块;站点截断的半个标签去掉", () => { + const i = info(REAL_RSS_DESCR_AWARDS); + expect(i.主演).toBe( + "张维为 Weiwei Zhang / 亚历山大·杜金 Alexander Dugin / 李世默 Eric Li / 范勇鹏 Yongpeng Fan / 余亮 Liang Yu", + ); + expect(i.获奖情况).toBe("第28届上海电视节白玉兰奖 (2023) / 综艺节目 最佳电视综艺节目(提名)…"); + const d = structureDescription(REAL_RSS_DESCR_AWARDS); + expect(d.paragraphs).toHaveLength(2); + expect(d.paragraphs[1]).toMatch(/^《这就是中国》/); + }); + + it("豆瓣评分 / 导演 / 编剧,长主演列表", () => { + const i = info(REAL_RSS_DESCR_CAST); + expect(i).toMatchObject({ 豆瓣评分: "5.4/10 from 11075 users", 导演: "潘安子 Anzi Pan", 编剧: "尤四姐" }); + expect(i.主演!.split(" / ")).toHaveLength(16); + expect(structureDescription(REAL_RSS_DESCR_CAST).paragraphs).toEqual([]); + }); + + it("没有信息块:整段是正文,海报图去掉", () => { + const d = structureDescription(REAL_RSS_DESCR_PLAIN); + expect(d.infoBlock).toEqual([]); + expect(d.paragraphs).toHaveLength(1); + expect(d.paragraphs[0]).toMatch(/^21岁的玉雕师方雾/); + }); + + it("只有 MediaInfo 的详情页:信息块和段落都为空", () => { + expect(structureDescription(REAL_DETAIL_HH_DV_ONLY.descriptionText)).toEqual({ infoBlock: [], paragraphs: [] }); + }); +}); + +describe("简介清洗:【】『』、BBCode / HTML 残留、广告签名", () => { + const d = structureDescription(SYNTH_DESCR_BRACKETS); + + it("【】『』键认得,正文里的「【第一版】」不当键", () => { + expect(Object.fromEntries(d.infoBlock.map((x) => [x.key, x.value]))).toMatchObject({ + 译名: "示例电影 / Example Movie", + 字幕: "中英双语", + IMDb评分: "8.1/10 from 12,345 users", + 导演: "某导演 Some Director", + 主演: "演员甲 Actor A / 演员乙 Actor B", + }); + expect(d.infoBlock.map((x) => x.key)).not.toContain("IMDb链接"); + expect(structureDescription("◎简  介\n\n  【第一版】仙界至暗,众生皆材。").paragraphs).toEqual([ + "【第一版】仙界至暗,众生皆材。", + ]); + }); + + it("段落:标记、实体、多余空行、转载声明 / 群号 / 感谢签名 / 分隔线都去掉", () => { + expect(d.paragraphs).toEqual([ + "一位科学家在沙漠深处发现了改变世界的秘密。<他必须>在时间耗尽前做出选择。", + "影片获得多项提名,查看获奖名单。", + ]); + const cleaned = cleanDescription(SYNTH_DESCR_BRACKETS); + expect(cleaned).not.toMatch(/\[\/?\w+|<\/?\w+| |QQ群|转载|感谢|\n{3,}|-{5,}/); + }); + + it("规则可配置:追加整行规则", () => { + const rules = { ...DEFAULT_DESCRIPTION_RULES, dropLines: [...DEFAULT_DESCRIPTION_RULES.dropLines, /^影片获得/] }; + expect(structureDescription(SYNTH_DESCR_BRACKETS, rules).paragraphs).toHaveLength(1); + }); + + it("英文硬换行接回一段", () => { + expect( + structureDescription("A scientist discovers a secret\nthat changes the world.\n\nSecond one.").paragraphs, + ).toEqual(["A scientist discovers a secret that changes the world.", "Second one."]); + }); + + it("stripMarkup:实体与数字引用", () => { + expect(stripMarkup("a&bAB c[b]d[/b]")).toBe("a&bAB cd"); + }); +}); + +describe("MediaInfo / BDInfo 概要", () => { + it("真实样本:中文摘要头 + General / Video / Audio", () => { + expect(parseMediaSummary(REAL_DETAIL_HH_INFO_CARD.mediaInfo)).toEqual({ + kind: "mediainfo", + general: { + fileName: "Spring.of.the.Blade.S01E18.2026.2160p.YK.WEB-DL.H.265.HFR.HQ.AAC2.0-HHWEB.mkv", + container: "Matroska", + duration: "43 min 55 s", + overallBitrate: "15.6 Mb/s", + size: "4.79 GiB", + }, + video: [ + { + codec: "HEVC", + resolution: "3840×1608", + frameRate: "60 fps", + bitrate: "15.3 Mb/s", + bitDepth: "10 bit", + hdr: [], + }, + ], + audio: [ + { + language: "中文", + codec: "AAC", + channels: "2.0", + bitrate: "256 kb/s", + title: "中文 [AAC 2.0]", + isDefault: true, + }, + ], + subtitles: [], + }); + }); + + it("真实样本:杜比视界 Profile 5", () => { + const m = parseMediaSummary(REAL_DETAIL_HH_DV_ONLY.mediaInfo)!; + expect(m.video[0]).toMatchObject({ hdr: ["Dolby Vision P5"], frameRate: "25 fps" }); + }); + + it("多音轨多字幕:编码短名、声道、默认 / 强制、语言译名", () => { + const m = parseMediaSummary(SYNTH_MEDIAINFO_MULTI_TRACK)!; + expect(m.video[0]).toMatchObject({ + resolution: "3840×2160", + frameRate: "23.976 fps", + hdr: ["Dolby Vision P7.6", "HDR10"], + }); + expect(m.audio.map((a) => [a.language, a.codec, a.channels, a.bitrate, a.isDefault])).toEqual([ + ["英语", "TrueHD Atmos", "7.1", "4561 kb/s", true], + ["中文", "AC-3", "5.1", "640 kb/s", false], + ["英语", "AAC", "2.0", "192 kb/s", false], + ]); + expect(m.subtitles.map((s) => [s.language, s.format, s.isDefault, s.forced])).toEqual([ + ["英语", "PGS", false, true], + ["中文", "UTF-8", true, false], + ["繁体中文", "ASS", false, false], + ["英语", "PGS", false, false], + ]); + }); + + it("BDInfo 简版", () => { + const m = parseMediaSummary(SYNTH_BDINFO_QUICK)!; + expect(m.kind).toBe("bdinfo"); + expect(m.general).toMatchObject({ + container: "Blu-ray", + duration: "2 h 22 min", + size: "57.57 GiB", + overallBitrate: "55.20 Mbps", + }); + expect(m.video.map((v) => [v.resolution, v.hdr])).toEqual([ + ["2160p", ["HDR10"]], + ["1080p", ["Dolby Vision FEL"]], + ]); + expect(m.audio[0]).toMatchObject({ + language: "英语", + codec: "TrueHD Atmos", + channels: "7.1", + bitrate: "4561 kbps", + }); + expect(m.subtitles.map((s) => s.language)).toEqual(["英语", "中文", "中文"]); + }); + + it("BDInfo 完整报告表格", () => { + const m = parseMediaSummary(SYNTH_BDINFO_FULL)!; + expect(m.general.duration).toBe("52 min 10 s"); + expect(m.video).toEqual([ + { + codec: "MPEG-4 AVC", + resolution: "1080p", + frameRate: "23.976 fps", + bitrate: "31456 kbps", + bitDepth: null, + hdr: [], + }, + ]); + expect(m.audio.map((a) => [a.language, a.codec, a.channels])).toEqual([ + ["日语", "DTS-HD MA", "5.1"], + ["日语", "LPCM", "2.0"], + ]); + expect(m.subtitles).toHaveLength(2); + }); + + it("认不出返回 null", () => { + expect(parseMediaSummary("just some text")).toBeNull(); + expect(parseMediaSummary(null)).toBeNull(); + }); +}); + +describe("文件清单 → 目录树", () => { + it("真实样本:8 集平铺,全是主视频", () => { + const t = buildFileTree(REAL_DETAIL_HH_INFO_CARD.files); + expect(t).toHaveLength(REAL_DETAIL_HH_INFO_CARD.files.length); + expect(t.every((r) => !r.dir && r.depth === 0 && r.role === "main")).toBe(true); + }); + + it("目录型:目录在前、大小求和,主视频 / sample / 字幕 / 附件", () => { + const t = buildFileTree(SYNTH_FILES_DIRECTORY); + expect(t[0]).toMatchObject({ dir: true, depth: 0, fileCount: 6, sizeBytes: 15_085_574_144 }); + const role = (name: string) => t.find((r) => r.name === name)?.role; + expect(role("Example.Movie.2023.1080p.BluRay.x264-GROUP.mkv")).toBe("main"); + expect(role("example.movie.sample.mkv")).toBe("sample"); + expect(role("Example.Movie.2023.chs.srt")).toBe("subtitle"); + expect(role("poster.jpg")).toBe("extra"); + expect(t.map((r) => `${" ".repeat(r.depth)}${r.name}`).slice(0, 4)).toEqual([ + "Example.Movie.2023.1080p.BluRay.x264-GROUP", + " Sample", + " example.movie.sample.mkv", + " Subs", + ]); + }); + + it("原盘:最大的 m2ts 是正片,小片段不是", () => { + const t = buildFileTree(SYNTH_FILES_BDMV); + expect(t.find((r) => r.name === "00001.m2ts")?.role).toBe("main"); + expect(t.find((r) => r.name === "00002.m2ts")?.role).toBe("video"); + }); +}); + +describe("formatDetailPage", () => { + it("组合四部分", () => { + const f = formatDetailPage(REAL_DETAIL_HH_INFO_CARD); + expect(f.infoBlock.length).toBeGreaterThan(5); + expect(f.paragraphs).toHaveLength(2); + expect(f.media?.kind).toBe("mediainfo"); + expect(f.fileTree).toHaveLength(8); + }); +}); diff --git a/packages/adapters/src/sites/detail-format.ts b/packages/adapters/src/sites/detail-format.ts new file mode 100644 index 00000000..28834dbf --- /dev/null +++ b/packages/adapters/src/sites/detail-format.ts @@ -0,0 +1,33 @@ +/** + * 详情页扩展内容的展示结构(DETAIL-1):简介 → 信息块 + 段落,MediaInfo / BDInfo → 技术规格概要,文件清单 → 目录树。 + * 纯函数;服务端读缓存时现算(旧缓存也能用),原文(descriptionText / mediaInfo / files)照旧保留。 + */ +import { + DEFAULT_DESCRIPTION_RULES, + type DescriptionRules, + type DetailInfoItem, + structureDescription, +} from "./detail-description.ts"; +import { buildFileTree, type FileTreeRow } from "./file-tree.ts"; +import { type MediaSummary, parseMediaSummary } from "./mediainfo.ts"; +import type { SiteDetailPage } from "./types.ts"; + +export interface FormattedDetailPage { + infoBlock: DetailInfoItem[]; + paragraphs: string[]; + media: MediaSummary | null; + fileTree: FileTreeRow[]; +} + +export function formatDetailPage( + page: Pick, + rules: DescriptionRules = DEFAULT_DESCRIPTION_RULES, +): FormattedDetailPage { + const { infoBlock, paragraphs } = structureDescription(page.descriptionText, rules); + return { + infoBlock, + paragraphs, + media: parseMediaSummary(page.mediaInfo), + fileTree: buildFileTree(page.files ?? []), + }; +} diff --git a/packages/adapters/src/sites/file-tree.ts b/packages/adapters/src/sites/file-tree.ts new file mode 100644 index 00000000..88f03a5b --- /dev/null +++ b/packages/adapters/src/sites/file-tree.ts @@ -0,0 +1,103 @@ +/** + * 详情页文件清单 → 目录树(DETAIL-1,纯函数)。按「/」或「\」拆路径,目录大小为子项之和; + * 标出主视频(最大视频的 30% 以上,排除 sample)、sample、字幕与附件。输出按先序展平,前端按 depth 缩进。 + */ +import type { SiteDetailFile } from "./types.ts"; + +export type FileRole = "main" | "sample" | "subtitle" | "extra" | "video" | "other"; + +export interface FileTreeRow { + /** 完整路径(目录也有),作 key 与折叠状态 */ + path: string; + name: string; + depth: number; + dir: boolean; + /** 目录:子项之和;有未知大小的子项时仍给已知部分之和 */ + sizeBytes: number | null; + /** 目录为 null */ + role: FileRole | null; + /** 目录下的文件数(递归);文件为 null */ + fileCount: number | null; +} + +const VIDEO = /\.(mkv|mp4|m2ts|ts|avi|wmv|mov|m4v|iso|rmvb|flv|webm|vob|mpg|mpeg)$/i; +const SUB = /\.(srt|ass|ssa|sup|sub|idx|vtt|smi)$/i; +const EXTRA = /\.(nfo|txt|jpe?g|png|gif|webp|bmp|md5|sfv|sha1|sha256|url|html?|pdf|cue|log|torrent)$/i; +const SAMPLE = /(^|[\\/._\-\s])sample([\\/._\-\s]|$)/i; + +interface Node { + name: string; + path: string; + children: Map | null; + sizeBytes: number | null; +} + +export function buildFileTree(files: SiteDetailFile[]): FileTreeRow[] { + const root: Node = { name: "", path: "", children: new Map(), sizeBytes: null }; + for (const f of files) { + const parts = f.name.split(/[\\/]+/).filter(Boolean); + if (!parts.length) continue; + let node = root; + parts.forEach((part, i) => { + const last = i === parts.length - 1; + const path = node.path ? `${node.path}/${part}` : part; + let child = node.children!.get(part); + if (!child) { + child = { name: part, path, children: last ? null : new Map(), sizeBytes: last ? f.sizeBytes : null }; + node.children!.set(part, child); + } else if (!last && !child.children) { + child.children = new Map(); + } + node = child; + }); + } + + const videoSizes = files.filter((f) => VIDEO.test(f.name) && !SAMPLE.test(f.name)).map((f) => f.sizeBytes ?? 0); + const maxVideo = Math.max(0, ...videoSizes); + const roleOf = (n: Node): FileRole => { + if (SUB.test(n.name)) return "subtitle"; + if (VIDEO.test(n.name)) { + if (SAMPLE.test(n.path)) return "sample"; + // 大小未知时所有视频都当主视频;已知时按最大视频的 30% 划线(剧集各集、原盘正片) + if (!maxVideo || n.sizeBytes === null || n.sizeBytes >= maxVideo * 0.3) return "main"; + return "video"; + } + if (EXTRA.test(n.name)) return "extra"; + return "other"; + }; + + const out: FileTreeRow[] = []; + const walk = (n: Node, depth: number): { size: number | null; count: number } => { + const kids = [...n.children!.values()].sort((a, b) => + !!a.children === !!b.children ? a.name.localeCompare(b.name, "zh-CN", { numeric: true }) : a.children ? -1 : 1, + ); + let size: number | null = null; + let count = 0; + for (const k of kids) { + if (k.children) { + const idx = out.length; + out.push({ path: k.path, name: k.name, depth, dir: true, sizeBytes: null, role: null, fileCount: null }); + const sub = walk(k, depth + 1); + out[idx]!.sizeBytes = sub.size; + out[idx]!.fileCount = sub.count; + if (sub.size !== null) size = (size ?? 0) + sub.size; + count += sub.count; + } else { + out.push({ + path: k.path, + name: k.name, + depth, + dir: false, + sizeBytes: k.sizeBytes, + role: roleOf(k), + fileCount: null, + }); + if (k.sizeBytes !== null) size = (size ?? 0) + k.sizeBytes; + count += 1; + } + } + return { size, count }; + }; + walk(root, 0); + return out; +} diff --git a/packages/adapters/src/sites/index.ts b/packages/adapters/src/sites/index.ts index 03be54dc..484623a1 100644 --- a/packages/adapters/src/sites/index.ts +++ b/packages/adapters/src/sites/index.ts @@ -16,12 +16,23 @@ export { SpiderFilterSchema, } from "./definitions/index.ts"; export type { SiteAdapterDeps } from "./deps.ts"; +export { + cleanDescription, + DEFAULT_DESCRIPTION_RULES, + type DescriptionRules, + type DetailInfoItem, + type StructuredDescription, + stripMarkup, + structureDescription, +} from "./detail-description.ts"; +export { type FormattedDetailPage, formatDetailPage } from "./detail-format.ts"; export { type FeedMediaType, normalizeSiteFeedItem, type SiteFeedItemFields, siteCategoryMediaType, } from "./feed-item.ts"; +export { buildFileTree, type FileRole, type FileTreeRow } from "./file-tree.ts"; export { classifyLoginError, loginFailureText, @@ -33,6 +44,14 @@ export { type SiteLoginFailure, totpCode, } from "./login/nexusphp.ts"; +export { + type MediaAudioTrack, + type MediaGeneral, + type MediaSummary, + type MediaTextTrack, + type MediaVideoTrack, + parseMediaSummary, +} from "./mediainfo.ts"; export { createMteamAdapter, mteamTorrentToRelease } from "./mteam/adapter.ts"; export { type MteamApiPath, mteamPost } from "./mteam/client.ts"; export { type MteamDefinition, mteamApiBase, mteamDefinition } from "./mteam/definition.ts"; diff --git a/packages/adapters/src/sites/mediainfo.ts b/packages/adapters/src/sites/mediainfo.ts new file mode 100644 index 00000000..66a007fd --- /dev/null +++ b/packages/adapters/src/sites/mediainfo.ts @@ -0,0 +1,405 @@ +/** + * MediaInfo / BDInfo 文本 → 技术规格概要(DETAIL-1,纯函数)。原文另存,这里只取展示需要的字段: + * 概要(容器、时长、总码率、大小)、视频轨、音频轨、字幕轨。认不出的字段留 null,不猜。 + */ + +export interface MediaGeneral { + fileName: string | null; + container: string | null; + duration: string | null; + overallBitrate: string | null; + size: string | null; +} + +export interface MediaVideoTrack { + codec: string | null; + resolution: string | null; + frameRate: string | null; + bitrate: string | null; + bitDepth: string | null; + /** HDR10 / HDR10+ / Dolby Vision P5 / HLG … 空数组 = SDR 或未知 */ + hdr: string[]; +} + +export interface MediaAudioTrack { + language: string | null; + codec: string | null; + channels: string | null; + bitrate: string | null; + title: string | null; + isDefault: boolean | null; +} + +export interface MediaTextTrack { + language: string | null; + format: string | null; + title: string | null; + isDefault: boolean | null; + forced: boolean | null; +} + +export interface MediaSummary { + kind: "mediainfo" | "bdinfo"; + general: MediaGeneral; + video: MediaVideoTrack[]; + audio: MediaAudioTrack[]; + subtitles: MediaTextTrack[]; +} + +const LANG: Record = { + chinese: "中文", + mandarin: "国语", + cantonese: "粤语", + zh: "中文", + chi: "中文", + zho: "中文", + "zh-cn": "简体中文", + "zh-hans": "简体中文", + "zh-tw": "繁体中文", + "zh-hk": "繁体中文", + "zh-hant": "繁体中文", + english: "英语", + en: "英语", + eng: "英语", + japanese: "日语", + ja: "日语", + jpn: "日语", + korean: "韩语", + ko: "韩语", + kor: "韩语", + french: "法语", + fr: "法语", + german: "德语", + de: "德语", + spanish: "西班牙语", + es: "西班牙语", + italian: "意大利语", + russian: "俄语", + thai: "泰语", + portuguese: "葡萄牙语", + hindi: "印地语", +}; + +function language(v: string | null | undefined): string | null { + if (!v) return null; + const t = v.trim(); + return LANG[t.toLowerCase()] ?? t; +} + +function yesNo(v: string | undefined): boolean | null { + if (!v) return null; + if (/^(yes|是)$/i.test(v.trim())) return true; + if (/^(no|否)$/i.test(v.trim())) return false; + return null; +} + +/** 「3 840 pixels」→ 3840 */ +const num = (v: string | undefined): number | null => { + const m = v ? /([\d\s]+)/.exec(v.replace(/(\d)\s+(?=\d)/g, "$1")) : null; + return m ? Number.parseInt(m[1]!.replace(/\s/g, ""), 10) || null : null; +}; + +/** 码率:去掉数字里的千分位空格 「4 561 kb/s」→「4561 kb/s」 */ +const rate = (v: string | undefined): string | null => (v ? v.replace(/(\d)\s+(?=\d)/g, "$1").trim() : null); + +function frameRate(v: string | undefined): string | null { + const m = v ? /([\d.]+)/.exec(v) : null; + if (!m) return null; + const n = Number.parseFloat(m[1]!); + return Number.isFinite(n) ? `${Number.isInteger(n) ? n : Number(n.toFixed(3))} fps` : null; +} + +/** 声道数 + 布局 → 2.0 / 5.1 / 7.1 */ +function channels(count: string | undefined, layout: string | undefined): string | null { + const n = num(count); + if (!n) return null; + const lfe = layout ? /\bLFE\b/.test(layout) : n === 6 || n === 8; + return lfe ? `${n - 1}.1` : `${n}.0`; +} + +function hdrOf(fields: Record): string[] { + const out: string[] = []; + const f = `${fields["HDR format"] ?? ""} ${fields.HDR_Format ?? ""}`; + const dv = /Dolby Vision[^,]*,[^,]*,\s*Profile\s*([\d.]+)/i.exec(f); + if (dv) out.push(`Dolby Vision P${dv[1]}`); + else if (/Dolby Vision/i.test(f)) out.push("Dolby Vision"); + if (/HDR10\+|SMPTE ST 2094/i.test(f)) out.push("HDR10+"); + if ( + /HDR10(?!\+)|SMPTE ST 2086/i.test(f) || + (!out.length && /PQ|SMPTE ST 2084/.test(fields["Transfer characteristics"] ?? "")) + ) + out.push("HDR10"); + if (/HLG/i.test(`${f} ${fields["Transfer characteristics"] ?? ""}`)) out.push("HLG"); + return [...new Set(out)]; +} + +/** 音频编码短名:MLP FBA → TrueHD,E-AC-3 → DDP,DTS XLL → DTS-HD MA;商业名带 Atmos 的加上 Atmos */ +function audioCodec(f: Record): string | null { + const fmt = f.Format?.trim(); + if (!fmt) return f["Commercial name"] ?? null; + const commercial = f["Commercial name"] ?? ""; + let name = fmt; + if (/^MLP FBA/i.test(fmt)) name = "TrueHD"; + else if (/^E-AC-3/i.test(fmt)) name = "DDP"; + else if (/^AC-3/i.test(fmt)) name = "AC-3"; + else if (/^DTS/i.test(fmt)) + name = /XLL X|DTS:X/i.test(`${fmt} ${commercial}`) + ? "DTS:X" + : /XLL|Master Audio/i.test(`${fmt} ${commercial}`) + ? "DTS-HD MA" + : "DTS"; + else if (/^AAC/i.test(fmt)) name = "AAC"; + else if (/^PCM/i.test(fmt)) name = "LPCM"; + if (/Atmos/i.test(`${fmt} ${commercial}`) && !/Atmos/i.test(name)) name += " Atmos"; + return name; +} + +/** 中文版 MediaInfo 键 → 英文键 */ +const ZH_KEYS: Record = { + 完整名称: "Complete name", + 格式: "Format", + 文件大小: "File size", + 时长: "Duration", + 总体码率: "Overall bit rate", + 码率: "Bit rate", + 宽度: "Width", + 高度: "Height", + 帧率: "Frame rate", + 位深: "Bit depth", + 声道数: "Channel(s)", + 声道布局: "Channel layout", + 语言: "Language", + 标题: "Title", + 默认: "Default", + 强制: "Forced", + 商业名称: "Commercial name", + "HDR 格式": "HDR format", + 传输特质: "Transfer characteristics", +}; + +type Section = { type: "general" | "video" | "audio" | "text" | "other"; fields: Record }; + +const SECTION_HEAD = + /^(General|Video|Audio|Text|Menu|Chapters|Image|Other|概览|常规|概要|视频|音频|文本|字幕|菜单|章节)(?:\s*#\s*\d+)?\s*$/i; + +function sectionType(name: string): Section["type"] { + const n = name.toLowerCase(); + if (/^(general|概览|常规|概要)/.test(n)) return "general"; + if (/^(video|视频)/.test(n)) return "video"; + if (/^(audio|音频)/.test(n)) return "audio"; + if (/^(text|文本|字幕)/.test(n)) return "text"; + return "other"; +} + +function parseSections(text: string): Section[] { + const out: Section[] = []; + let cur: Section | null = null; + for (const raw of text.replace(/\r/g, "").split("\n")) { + const line = raw.trim(); + if (!line) continue; + const head = SECTION_HEAD.exec(line); + if (head) { + cur = { type: sectionType(head[1]!), fields: {} }; + out.push(cur); + continue; + } + if (!cur) continue; + const m = /^(.+?)\s*[::]\s(.*)$/.exec(line) ?? /^(.+?)\s{2,}[::]\s*(.*)$/.exec(line); + if (!m) continue; + const key = ZH_KEYS[m[1]!.trim()] ?? m[1]!.trim(); + if (!(key in cur.fields)) cur.fields[key] = m[2]!.trim(); + } + return out; +} + +function baseName(p: string | undefined): string | null { + return p ? (p.split(/[\\/]/).pop() ?? p).trim() || null : null; +} + +function parseMediaInfoText(text: string): MediaSummary | null { + const sections = parseSections(text); + const g = sections.find((s) => s.type === "general")?.fields; + const videos = sections.filter((s) => s.type === "video"); + if (!g && !videos.length) return null; + const general: MediaGeneral = { + fileName: baseName(g?.["Complete name"] ?? g?.["Movie name"]), + container: g?.Format ?? null, + duration: g?.Duration ?? null, + overallBitrate: rate(g?.["Overall bit rate"]), + size: g?.["File size"] ?? null, + }; + const video = videos.map(({ fields: f }): MediaVideoTrack => { + const w = num(f.Width); + const h = num(f.Height); + return { + codec: f.Format ?? null, + resolution: w && h ? `${w}×${h}` : null, + frameRate: frameRate(f["Frame rate"] ?? f["Original frame rate"]), + bitrate: rate(f["Bit rate"] ?? f["Nominal bit rate"]), + bitDepth: num(f["Bit depth"]) ? `${num(f["Bit depth"])} bit` : null, + hdr: hdrOf(f), + }; + }); + const audio = sections + .filter((s) => s.type === "audio") + .map( + ({ fields: f }): MediaAudioTrack => ({ + language: language(f.Language), + codec: audioCodec(f), + channels: channels(f["Channel(s)"], f["Channel layout"] ?? f["Channel positions"]), + bitrate: rate(f["Bit rate"]), + title: f.Title ?? null, + isDefault: yesNo(f.Default), + }), + ); + const subtitles = sections + .filter((s) => s.type === "text") + .map( + ({ fields: f }): MediaTextTrack => ({ + language: language(f.Language), + format: f.Format ?? null, + title: f.Title ?? null, + isDefault: yesNo(f.Default), + forced: yesNo(f.Forced), + }), + ); + return { kind: "mediainfo", general, video, audio, subtitles }; +} + +// ---------------- BDInfo ---------------- + +function bdVideo(codec: string, desc: string, bitrate: string | null): MediaVideoTrack { + const parts = desc.split(/\s+\/\s+/).map((s) => s.trim()); + const res = parts.find((p) => /^\d{3,4}[pi]$/i.test(p)) ?? null; + const hdr: string[] = []; + for (const p of parts) { + if (/^Dolby Vision/i.test(p)) hdr.push(p.replace(/^Dolby Vision\s*/i, "Dolby Vision ").trim()); + else if (/^HDR10\+?$/i.test(p) || /^HLG$/i.test(p)) hdr.push(p.toUpperCase()); + } + return { + codec: codec.replace(/\s*Video$/i, "").trim() || null, + resolution: res, + frameRate: frameRate(parts.find((p) => /fps$/i.test(p))), + bitrate: bitrate ?? parts.find((p) => /bps$/i.test(p)) ?? null, + bitDepth: parts.find((p) => /^\d+ bits?$/i.test(p))?.replace(/bits?$/i, "bit") ?? null, + hdr, + }; +} + +function bdAudioCodec(codec: string): string | null { + const c = codec.replace(/\s*Audio$/i, "").trim(); + if (!c) return null; + if (/TrueHD\/Atmos/i.test(c)) return "TrueHD Atmos"; + if (/TrueHD/i.test(c)) return "TrueHD"; + if (/Digital Plus/i.test(c)) return /Atmos/i.test(c) ? "DDP Atmos" : "DDP"; + if (/^Dolby Digital$/i.test(c)) return "AC-3"; + if (/DTS:X/i.test(c)) return "DTS:X"; + if (/DTS-HD Master/i.test(c)) return "DTS-HD MA"; + return c; +} + +function bdAudio(codec: string, lang: string | null, desc: string, bitrate: string | null): MediaAudioTrack { + const parts = desc.split(/\s+\/\s+/).map((s) => s.trim()); + return { + language: language(lang), + codec: bdAudioCodec(codec), + channels: parts.find((p) => /^\d\.\d(?:\s|$|-)/.test(p))?.slice(0, 3) ?? null, + bitrate: bitrate ?? parts.find((p) => /kbps$/i.test(p)) ?? null, + title: null, + isDefault: null, + }; +} + +/** 「61,812,345,678 bytes」→「57.57 GiB」;认不出原样返回 */ +function bdSize(v: string | null): string | null { + const m = v ? /^([\d,]+)\s*bytes$/i.exec(v.trim()) : null; + if (!m) return v; + const n = Number(m[1]!.replace(/,/g, "")); + const units = ["B", "KiB", "MiB", "GiB", "TiB"]; + let x = n; + let i = 0; + while (x >= 1024 && i < units.length - 1) { + x /= 1024; + i++; + } + return `${i ? x.toFixed(2) : x} ${units[i]}`; +} + +/** 「2:22:48.521」→「2 h 22 min」,与 MediaInfo 写法一致 */ +function bdDuration(v: string | null): string | null { + const m = v ? /^(\d+):(\d{2}):(\d{2})/.exec(v.trim()) : null; + if (!m) return v; + const h = Number(m[1]); + const min = Number(m[2]); + return h ? `${h} h ${min} min` : `${min} min ${Number(m[3])} s`; +} + +function parseBdInfoText(text: string): MediaSummary | null { + const lines = text.replace(/\r/g, "").split("\n"); + const kv = (label: RegExp) => { + for (const l of lines) { + const m = new RegExp(`^\\s*${label.source}\\s*:\\s*(.+)$`, "i").exec(l); + if (m) return m[1]!.trim(); + } + return null; + }; + const general: MediaGeneral = { + fileName: kv(/(?:Disc Title|Disc Label)/), + container: "Blu-ray", + duration: bdDuration(kv(/Length/)), + overallBitrate: kv(/Total Bitrate/), + size: bdSize(kv(/(?:Disc Size|Size)/)), + }; + const video: MediaVideoTrack[] = []; + const audio: MediaAudioTrack[] = []; + const subtitles: MediaTextTrack[] = []; + + // 简版:「Video: codec / bitrate / 2160p / …」「Audio: lang / codec / 7.1 / …」「Subtitle: lang / bitrate」 + for (const l of lines) { + const m = /^\s*(Video|Audio|Subtitle)\s*:\s*(.+)$/i.exec(l); + if (!m) continue; + const parts = m[2]!.split(/\s+\/\s+/).map((s) => s.trim()); + const kind = m[1]!.toLowerCase(); + if (kind === "video") video.push(bdVideo(parts[0] ?? "", parts.slice(1).join(" / "), parts[1] ?? null)); + else if (kind === "audio") audio.push(bdAudio(parts[1] ?? "", parts[0] ?? null, parts.slice(2).join(" / "), null)); + else subtitles.push({ language: language(parts[0]), format: "PGS", title: null, isDefault: null, forced: null }); + } + + // 完整报告:VIDEO: / AUDIO: / SUBTITLES: 下的表格,列之间两个以上空格 + if (!video.length && !audio.length) { + let section: "video" | "audio" | "subs" | null = null; + for (const l of lines) { + const head = + /^\s*(VIDEO|AUDIO|SUBTITLES|FILES|CHAPTERS|STREAM DIAGNOSTICS|PLAYLIST REPORT|DISC INFO)\s*:\s*$/.exec(l); + if (head) { + section = + head[1] === "VIDEO" ? "video" : head[1] === "AUDIO" ? "audio" : head[1] === "SUBTITLES" ? "subs" : null; + continue; + } + if (!section || !l.trim() || /^\s*(Codec|-----)/.test(l)) continue; + const cols = l.trim().split(/\s{2,}/); + if (section === "video") video.push(bdVideo(cols[0] ?? "", cols[2] ?? "", cols[1] ?? null)); + else if (section === "audio") audio.push(bdAudio(cols[0] ?? "", cols[1] ?? null, cols[3] ?? "", cols[2] ?? null)); + else + subtitles.push({ + language: language(cols[1]), + format: /Presentation Graphics/i.test(cols[0] ?? "") ? "PGS" : (cols[0] ?? null), + title: null, + isDefault: null, + forced: null, + }); + } + } + if (!video.length && !audio.length) return null; + return { kind: "bdinfo", general, video, audio, subtitles }; +} + +const isBdInfo = (t: string) => + /(^|\n)\s*(DISC INFO:|Disc Title\s*:|Disc Label\s*:|BDInfo\s*:|PLAYLIST REPORT:)/i.test(t); + +/** MediaInfo 或 BDInfo 原文 → 概要;两种都认不出返回 null(前端只显示原文) */ +export function parseMediaSummary(text: string | null | undefined): MediaSummary | null { + if (!text?.trim()) return null; + if (isBdInfo(text)) return parseBdInfoText(text) ?? parseMediaInfoText(text); + return parseMediaInfoText(text); +} diff --git a/packages/contracts/src/sources.ts b/packages/contracts/src/sources.ts index 639b4d92..4330a556 100644 --- a/packages/contracts/src/sources.ts +++ b/packages/contracts/src/sources.ts @@ -636,6 +636,60 @@ export const WorkbenchReleasePage = z.object({ }); export type WorkbenchReleasePage = z.infer; +/** MediaInfo / BDInfo 概要;字段认不出为 null */ +export const SiteMediaSummary = z.object({ + kind: z.enum(["mediainfo", "bdinfo"]), + general: z.object({ + fileName: z.string().nullable(), + container: z.string().nullable(), + duration: z.string().nullable(), + overallBitrate: z.string().nullable(), + size: z.string().nullable(), + }), + video: z.array( + z.object({ + codec: z.string().nullable(), + resolution: z.string().nullable(), + frameRate: z.string().nullable(), + bitrate: z.string().nullable(), + bitDepth: z.string().nullable(), + hdr: z.array(z.string()), + }), + ), + audio: z.array( + z.object({ + language: z.string().nullable(), + codec: z.string().nullable(), + channels: z.string().nullable(), + bitrate: z.string().nullable(), + title: z.string().nullable(), + isDefault: z.boolean().nullable(), + }), + ), + subtitles: z.array( + z.object({ + language: z.string().nullable(), + format: z.string().nullable(), + title: z.string().nullable(), + isDefault: z.boolean().nullable(), + forced: z.boolean().nullable(), + }), + ), +}); +export type SiteMediaSummary = z.infer; + +/** 文件目录树(先序展平,按 depth 缩进);role:主视频 / sample / 字幕 / 附件 / 其他视频 / 其他,目录为 null */ +export const SiteFileTreeRow = z.object({ + path: z.string(), + name: z.string(), + depth: z.number().int(), + dir: z.boolean(), + sizeBytes: z.number().int().nullable(), + role: z.enum(["main", "sample", "subtitle", "extra", "video", "other"]).nullable(), + fileCount: z.number().int().nullable(), +}); +export type SiteFileTreeRow = z.infer; + /** * 站点详情页扩展内容(按需抓详情时得到,服务端缓存 24 小时;过期后需再次复核详情)。 * 截图地址是服务端图片代理路径(/api/v1/img/site-detail/{releaseId}/{index}),不直连外站。 @@ -651,6 +705,11 @@ export const SiteReleasePage = z.object({ files: z.array(z.object({ name: z.string(), sizeBytes: z.number().int().nullable() })), tags: z.array(z.string()), uploader: z.string().nullable(), + /** 展示结构(读缓存时由上面的原文现算):信息块、正文段落、技术规格概要、文件目录树 */ + infoBlock: z.array(z.object({ key: z.string(), value: z.string() })), + paragraphs: z.array(z.string()), + media: SiteMediaSummary.nullable(), + fileTree: z.array(SiteFileTreeRow), }); export type SiteReleasePage = z.infer; diff --git a/packages/testkit/src/sites/fixtures/details/index.ts b/packages/testkit/src/sites/fixtures/details/index.ts new file mode 100644 index 00000000..14bbf021 --- /dev/null +++ b/packages/testkit/src/sites/fixtures/details/index.ts @@ -0,0 +1,2 @@ +export * from "./real.ts"; +export * from "./synthetic.ts"; diff --git a/packages/testkit/src/sites/fixtures/details/real.ts b/packages/testkit/src/sites/fixtures/details/real.ts new file mode 100644 index 00000000..6558fd2a --- /dev/null +++ b/packages/testkit/src/sites/fixtures/details/real.ts @@ -0,0 +1,87 @@ +/** + * DETAIL-1 详情格式化样本:取自用户库里已缓存的真实数据(2026-09-29,憨憨),已脱敏—— + * MediaInfo 的 Unique ID 换成占位值,图床 / 站点域名换成 example 域;片名、公开的 IMDb / 豆瓣 / TMDB 编号保留。 + * - REAL_DETAIL_*:kv_cache site.detail 里的详情页扩展内容(SiteDetailPage 形状,去掉 fetchedAt / probe) + * - REAL_RSS_DESCR_*:observation.raw.description(RSS 条目简介原样 HTML,与详情页 #kdescr 同源,站点截断在 1000 字) + */ + +/** 憨憨详情页:没有 #kdescr,简介来自「豆瓣」信息块(「片名:」下一行是值),MediaInfo 带中文摘要头;8 集文件清单 */ +export const REAL_DETAIL_HH_INFO_CARD = { + tags: ["官方", "国语", "中字"], + tmdb: { + id: "294990", + type: "tv", + }, + files: [ + { + name: "Spring.of.the.Blade.S01E18.2026.2160p.YK.WEB-DL.H.265.HFR.HQ.AAC2.0-HHWEB.mkv", + sizeBytes: 5143223337, + }, + { + name: "Spring.of.the.Blade.S01E19.2026.2160p.YK.WEB-DL.H.265.HFR.HQ.AAC2.0-HHWEB.mkv", + sizeBytes: 5121748500, + }, + { + name: "Spring.of.the.Blade.S01E20.2026.2160p.YK.WEB-DL.H.265.HFR.HQ.AAC2.0-HHWEB.mkv", + sizeBytes: 5025111736, + }, + { + name: "Spring.of.the.Blade.S01E21.2026.2160p.YK.WEB-DL.H.265.HFR.HQ.AAC2.0-HHWEB.mkv", + sizeBytes: 5121748500, + }, + { + name: "Spring.of.the.Blade.S01E22.2026.2160p.YK.WEB-DL.H.265.HFR.HQ.AAC2.0-HHWEB.mkv", + sizeBytes: 5293547192, + }, + { + name: "Spring.of.the.Blade.S01E23.2026.2160p.YK.WEB-DL.H.265.HFR.HQ.AAC2.0-HHWEB.mkv", + sizeBytes: 5400921375, + }, + { + name: "Spring.of.the.Blade.S01E24.2026.2160p.YK.WEB-DL.H.265.HFR.HQ.AAC2.0-HHWEB.mkv", + sizeBytes: 4982162063, + }, + { + name: "Spring.of.the.Blade.S01E25.2026.2160p.YK.WEB-DL.H.265.HFR.HQ.AAC2.0-HHWEB.mkv", + sizeBytes: 4928474972, + }, + ], + imdbId: "tt45956347", + doubanId: "35644140", + uploader: "匿名", + mediaInfo: + "文件名:Spring.of.the.Blade.S01E18.2026.2160p.YK.WEB-DL.H.265.HFR.HQ.AAC2.0-HHWEB.mkv\n体 积: 4.79 GiB\n时 长: 43 min 55 s\n分辨率: 3 840 pixels x 1 608 pixels\n码 率: 15.3 Mb/s\n帧 率: 60.000 FPS\n\n音 频: 01: AAC LC 2 channels 256 kb/s 中文 [AAC 2.0]\n\nGeneral\nUnique ID : 100000000000000000000000000000000001 (0x0000000000000000000000000000000A)\nComplete name : Spring.of.the.Blade.S01E18.2026.2160p.YK.WEB-DL.H.265.HFR.HQ.AAC2.0-HHWEB.mkv\nFormat : Matroska\nFormat version : Version 4\nFile size : 4.79 GiB\nDuration : 43 min 55 s\nOverall bit rate : 15.6 Mb/s\nFrame rate : 60.000 FPS\nWriting application : mkvmerge v99.0 (\\'Buka\\') 64-bit\nWriting library : libebml v1.4.5 + libmatroska v1.7.1\nGroup : HHWEB\nIMDB : tt45956347\nTMDB : tv/294990\nTVDB2 : series/476903\n\nVideo\nID : 1\nFormat : HEVC\nFormat/Info : High Efficiency Video Coding\nFormat profile : Main 10@L5.1@Main\nCodec ID : V_MPEGH/ISO/HEVC\nDuration : 43 min 55 s\nBit rate : 15.3 Mb/s\nWidth : 3 840 pixels\nHeight : 1 608 pixels\nDisplay aspect ratio : 2.39:1\nFrame rate mode : Constant\nFrame rate : 60.000 FPS\nColor space : YUV\nChroma subsampling : 4:2:0\nBit depth : 10 bits\nBits/(Pixel*Frame) : 0.041\nStream size : 4.71 GiB (98%)\nLanguage : Chinese\nDefault : Yes\nForced : No\nColor range : Limited\nColor primaries : BT.709\nTransfer characteristics : BT.709\nMatrix coefficients : BT.709\n\nAudio\nID : 2\nFormat : AAC LC\nFormat/Info : Advanced Audio Codec Low Complexity\nCodec ID : A_AAC-2\nDuration : 43 min 55 s\nBit rate : 256 kb/s\nChannel(s) : 2 channels\nChannel layout : L R\nSampling rate : 44.1 kHz\nFrame rate : 43.066 FPS (1024 SPF)\nCompression mode : Lossy\nStream size : 80.4 MiB (2%)\nTitle : 中文 [AAC 2.0]\nLanguage : Chinese\nDefault : Yes\nForced : No", + screenshots: ["https://img.example.org/img/2026/09/29/6abb943050a2e.jpg"], + descriptionText: + "一瓯春\n2026\n\n电视剧\n\n5.4\n\n(11053)\n\n------------------------------------------------------------------------------------------------------------------------------------\n\n片名:\n一瓯春\n\n别名:\n\n年代:\n2026\n\n片长:\n45分钟\n\n票数:\n11053\n\n评分:\n5.4/10 from 11053 users\n\n对白语言:\n汉语普通话\n\n国家:\n中国大陆\n\n类别:\n爱情,古装\n\n标语:\n\n导演:\n\n潘安子 Anzi Pan\n\n编剧:\n尤四姐\n\n------------------------------------------------------------------------------------------------------------------------------------\n\n剧情概要:\n\n------------------------------------------------------------------------------------------------------------------------------------\n\n“杀伐千面腹黑男”沈润与“人间清醒黑莲花”谢清圆互为刀刃交错,在理智与情感中携手复仇,于朝堂官场上搅弄风云,于高门大户里明争暗斗。最终他们走出暗流深渊,奔赴璀璨新生。\n该剧改编自尤四姐的同名小说。\n\n------------------------------------------------------------------------------------------------------------------------------------\n\n主演:\n\n------------------------------------------------------------------------------------------------------------------------------------\n\n许凯 Kai Xu周也 Ye Zhou白澍 Shu Bai\n注意:该信息最后更新于2026-09-29 18:34:54,可能已过期,点击\n这里\n更新信息", +}; + +/** 憨憨详情页:只有 MediaInfo(杜比视界 Profile 5),没有简介和截图 */ +export const REAL_DETAIL_HH_DV_ONLY = { + tags: ["官方", "国语", "中字", "杜比视界"], + tmdb: null, + files: [], + imdbId: null, + doubanId: "35644140", + uploader: "匿名", + mediaInfo: + "文件名:Spring.of.the.Blade.S01E18.2026.2160p.YK.WEB-DL.H.265.DV.HQ.AAC2.0-HHWEB.mkv\n体 积: 5.04 GiB\n时 长: 43 min 55 s\n分辨率: 3 840 pixels x 1 608 pixels\n码 率: 16.2 Mb/s\n帧 率: 25.000 FPS\n\n音 频: 01: AAC LC 2 channels 256 kb/s 中文 [AAC 2.0]\n\nGeneral\nUnique ID : 100000000000000000000000000000000001 (0x0000000000000000000000000000000A)\nComplete name : Spring.of.the.Blade.S01E18.2026.2160p.YK.WEB-DL.H.265.DV.HQ.AAC2.0-HHWEB.mkv\nFormat : Matroska\nFormat version : Version 4\nFile size : 5.04 GiB\nDuration : 43 min 55 s\nOverall bit rate : 16.4 Mb/s\nFrame rate : 25.000 FPS\nWriting application : mkvmerge v99.0 (\\'Buka\\') 64-bit\nWriting library : libebml v1.4.5 + libmatroska v1.7.1\nGroup : HHWEB\nIMDB : tt45956347\nTMDB : tv/294990\nTVDB2 : series/476903\n\nVideo\nID : 1\nFormat : HEVC\nFormat/Info : High Efficiency Video Coding\nFormat profile : Main 10@L5.1@Main\nHDR format : Dolby Vision, Version 1.0, Profile 5, dvhe.05.06, BL+RPU, no metadata compression\nCodec ID : V_MPEGH/ISO/HEVC\nDuration : 43 min 55 s\nBit rate : 16.2 Mb/s\nWidth : 3 840 pixels\nHeight : 1 608 pixels\nDisplay aspect ratio : 2.39:1\nFrame rate mode : Constant\nFrame rate : 25.000 FPS\nColor space : YUV\nChroma subsampling : 4:2:0\nBit depth : 10 bits\nBits/(Pixel*Frame) : 0.105\nStream size : 4.96 GiB (98%)\nLanguage : Chinese\nDefault : Yes\nForced : No\nColor range : Full\n\nAudio\nID : 2\nFormat : AAC LC\nFormat/Info : Advanced Audio Codec Low Complexity\nCodec ID : A_AAC-2\nDuration : 43 min 55 s\nBit rate : 256 kb/s\nChannel(s) : 2 channels\nChannel layout : L R\nSampling rate : 44.1 kHz\nFrame rate : 43.066 FPS (1024 SPF)\nCompression mode : Lossy\nStream size : 80.4 MiB (2%)\nTitle : 中文 [AAC 2.0]\nLanguage : Chinese\nDefault : Yes\nForced : No", + screenshots: [], + descriptionText: null, +}; + +/** ◎ 信息块 + ◎简介 段落(动画剧集) */ +export const REAL_RSS_DESCR_INFO_BLOCK = + 'image
\n
\n◎译  名 Stay Low Profile, Sect Chief / 掌门低调点 3D动画版
\n◎片  名 掌门低调点
\n◎年  代 2026
\n◎产  地 中国大陆
\n◎类  别 喜剧 / 动作 / 动画
\n◎语  言 汉语普通话
\n◎上映日期 2026-08-07(中国大陆)
\n◎豆瓣链接 https://movie.douban.com/subject/37842527/
\n◎集  数 40
\n◎片  长 7分钟
\n◎主  演 宁冀荣 Lemon
\n
\n◎简  介
\n
\n    大雪覆门,妖修突袭,屠尽墨门,少年路朝歌痛失至亲,绝境之中觉醒绑定逆天系统,靠水系启灵死里逃生,立誓肃清世间妖邪。十年蛰伏苦修,他困于初境寸步难行,一朝契机重启系统,携妹妹路冬梨下山寻灵草破境。途中巧破异兽难题,逆天引九道天道敕令启灵,连破五重境界,还直面剑宗天骄邀战,昔日废柴掌门,自此强势逆袭。
'; + +/** ◎ 信息块,主演多行续行,◎简介 两段 + ◎获奖情况(站点截断) */ +export const REAL_RSS_DESCR_AWARDS = + 'image
\n
\n◎译  名 China Now
\n◎片  名 这就是中国
\n◎年  代 2019
\n◎产  地 中国大陆
\n◎类  别 脱口秀
\n◎语  言 汉语普通话
\n◎上映日期 2019-01-07(中国大陆)
\n◎豆瓣链接 https://movie.douban.com/subject/30425880/
\n◎集  数 45
\n◎片  长 42分钟
\n◎主  演 张维为 Weiwei Zhang
\n    亚历山大·杜金 Alexander Dugin
\n    李世默 Eric Li
\n    范勇鹏 Yongpeng Fan
\n    余亮 Liang Yu
\n
\n◎简  介
\n
\n    该节目创新性地采用“演讲+真人秀”的模式,从当下国内外老百姓关心的一个个热点、难点时政问题切入讨论,作为主讲人的张维为教授,则以自己深刻的政治观和独到的视角,通过演讲的方式为观众答疑解惑,并在现场与年轻人们展开热烈讨论甚至辩论,最终把中国制度、中国理论、中国道路、中国文化的优势和先进性讲清楚,传达出“民族自信”的相关核心精神。
\n    《这就是中国》并没有将节目形式局限于纸上谈兵。相反,该节目突破了传统理论宣讲节目的单向形式,由三个“角色组合”搭配两条主线来完成节目的多角度呈现。
\n
\n◎获奖情况
\n
\n    第28届上海电视节白玉兰奖  (2023)
\n综艺节目 最佳电视综艺节目(提名)
\n
\n◎片  名 一瓯春
\n◎年  代 2026
\n◎产  地 中国大陆
\n◎类  别 爱情 / 古装
\n◎语  言 汉语普通话
\n◎上映日期 2026-09-17(中国大陆)
\n◎豆瓣评分 5.4/10 from 11075 users
\n◎豆瓣链接 https://movie.douban.com/subject/35644140/
\n◎集  数 30
\n◎片  长 45分钟
\n◎导  演 潘安子 Anzi Pan
\n◎编  剧 尤四姐
\n◎主  演 许凯 Kai Xu
\n    周也 Ye Zhou
\n    白澍 Shu Bai
\n    闵春晓 Chunxiao Min
\n    柯颖 Ying Ke
\n    陈意涵 Estelle Chen
\n    张瑶 Yao Zhang
\n    苗圃 Pu Miao
\n    徐振轩 Zhenxuan Xu
\n    黄晓娟 Xiaojuan Huang
\n    富大龙 Dalong Fu
\n    赫子铭 Lanpeng He
\n    苏棋 Suqi Wang
\n    王祖一 Zuyi Wang
\n    孙毅 Yi Sun
\n    纪燃 Ran …'; + +/** 没有信息块:海报图 + 一段剧情 */ +export const REAL_RSS_DESCR_PLAIN = + 'image
21岁的玉雕师方雾,十一岁时被荣景御从暗市救下,后来寄养在沈家,却被长期霸凌和操控。十年后,两人在京市重逢,方雾早已不记得荣景御。荣景御隐瞒身份接近她,替她出头、支持她创业,也一步步帮她摆脱沈家的控制。随着护身玉牌、沈家收养真相和旧日离别被揭开,方雾终于找回勇敢的自己,在玉雕事业与感情中重新选择人生。'; diff --git a/packages/testkit/src/sites/fixtures/details/synthetic.ts b/packages/testkit/src/sites/fixtures/details/synthetic.ts new file mode 100644 index 00000000..a49b6ebb --- /dev/null +++ b/packages/testkit/src/sites/fixtures/details/synthetic.ts @@ -0,0 +1,236 @@ +/** + * DETAIL-1 详情格式化样本(构造):用户库缓存里没有的形态——多音轨多字幕 MediaInfo、BDInfo(简版 / 完整报告)、 + * 【】『』信息块夹 BBCode / HTML 残留与广告签名、带目录的文件清单。格式照 MediaInfo CLI / BDInfo 0.7.x 输出与 + * 常见 PT 简介模板手写,数值均为示例。 + */ + +/** 电影 remux:HDR10 + 杜比视界双层,三条音轨(一条默认),四条字幕(一条强制) */ +export const SYNTH_MEDIAINFO_MULTI_TRACK = `General +Unique ID : 100000000000000000000000000000000002 (0x0000000000000000000000000000000B) +Complete name : Example.Movie.2023.2160p.BluRay.REMUX.HEVC.DV.HDR.TrueHD.7.1.Atmos-GROUP.mkv +Format : Matroska +Format version : Version 4 +File size : 71.3 GiB +Duration : 2 h 22 min +Overall bit rate mode : Variable +Overall bit rate : 71.5 Mb/s +Frame rate : 23.976 FPS + +Video +ID : 1 +Format : HEVC +Format/Info : High Efficiency Video Coding +Format profile : Main 10@L5.1@High +HDR format : Dolby Vision, Version 1.0, Profile 7.6, dvhe.07.06, BL+EL+RPU, Blu-ray compatible / SMPTE ST 2086, HDR10 compatible +Codec ID : V_MPEGH/ISO/HEVC +Duration : 2 h 22 min +Bit rate : 62.4 Mb/s +Width : 3 840 pixels +Height : 2 160 pixels +Display aspect ratio : 16:9 +Frame rate mode : Constant +Frame rate : 23.976 (24000/1001) FPS +Bit depth : 10 bits +Language : English +Default : Yes +Forced : No +Color primaries : BT.2020 +Transfer characteristics : PQ +Mastering display color primaries : Display P3 +Mastering display luminance : min: 0.0001 cd/m2, max: 1000 cd/m2 + +Audio #1 +ID : 2 +Format : MLP FBA 16-ch +Format/Info : Meridian Lossless Packing FBA with 16-channel presentation +Commercial name : Dolby TrueHD with Dolby Atmos +Codec ID : A_TRUEHD +Duration : 2 h 22 min +Bit rate mode : Variable +Bit rate : 4 561 kb/s +Channel(s) : 8 channels +Channel layout : L R C LFE Ls Rs Lb Rb +Sampling rate : 48.0 kHz +Title : TrueHD 7.1 Atmos +Language : English +Default : Yes +Forced : No + +Audio #2 +ID : 3 +Format : AC-3 +Format/Info : Audio Coding 3 +Commercial name : Dolby Digital +Codec ID : A_AC3 +Duration : 2 h 22 min +Bit rate mode : Constant +Bit rate : 640 kb/s +Channel(s) : 6 channels +Channel layout : L R C LFE Ls Rs +Sampling rate : 48.0 kHz +Title : 国语 DD 5.1 +Language : Chinese +Default : No +Forced : No + +Audio #3 +ID : 4 +Format : AAC LC +Codec ID : A_AAC-2 +Bit rate : 192 kb/s +Channel(s) : 2 channels +Sampling rate : 48.0 kHz +Title : Commentary +Language : English +Default : No +Forced : No + +Text #1 +ID : 5 +Format : PGS +Codec ID : S_HDMV/PGS +Title : Forced +Language : English +Default : No +Forced : Yes + +Text #2 +ID : 6 +Format : UTF-8 +Codec ID : S_TEXT/UTF8 +Title : 简体中文 +Language : Chinese +Default : Yes +Forced : No + +Text #3 +ID : 7 +Format : ASS +Codec ID : S_TEXT/ASS +Title : 繁體中文 +Language : zh-Hant +Default : No +Forced : No + +Text #4 +ID : 8 +Format : PGS +Codec ID : S_HDMV/PGS +Title : SDH +Language : English +Default : No +Forced : No + +Menu +00:00:00.000 : en:Chapter 01 +00:07:12.140 : en:Chapter 02`; + +/** BDInfo 简版(Quick Summary) */ +export const SYNTH_BDINFO_QUICK = `Disc Title: EXAMPLE_MOVIE +Disc Size: 61,812,345,678 bytes +Protection: AACS2 +Extras: Ultra HD, BD-Java +BDInfo: 0.7.5.5 + +Playlist: 00800.MPLS +Size: 59,123,456,789 bytes +Length: 2:22:48.521 +Total Bitrate: 55.20 Mbps +Video: MPEG-H HEVC Video / 43981 kbps / 2160p / 23.976 fps / 16:9 / Main 10 @ Level 5.1 @ High / 10 bits / HDR10 / BT.2020 +Video: MPEG-H HEVC Video / 3526 kbps / 1080p / 23.976 fps / 16:9 / Main 10 @ Level 5.1 @ High / 10 bits / Dolby Vision FEL / BT.2020 +Audio: English / Dolby TrueHD/Atmos Audio / 7.1 / 48 kHz / 4561 kbps / 24-bit (AC3 Embedded: 5.1 / 48 kHz / 640 kbps / DN -31dB) +Audio: Chinese / Dolby Digital Audio / 5.1 / 48 kHz / 640 kbps / DN -31dB +Audio: English / Dolby Digital Audio / 2.0 / 48 kHz / 192 kbps / DN -27dB +Subtitle: English / 32.145 kbps +Subtitle: Chinese / 25.012 kbps +Subtitle: Chinese / 24.100 kbps`; + +/** BDInfo 完整报告(DISC INFO / PLAYLIST REPORT / VIDEO / AUDIO / SUBTITLES 表格) */ +export const SYNTH_BDINFO_FULL = `DISC INFO: + +Disc Title: EXAMPLE_SHOW_S01_D1 +Disc Size: 45,678,901,234 bytes +Protection: AACS +BDInfo: 0.7.5.5 + +PLAYLIST REPORT: + +Name: 00001.MPLS +Length: 0:52:10.211 (h:m:s.ms) +Size: 18,234,567,890 bytes +Total Bitrate: 46.61 Mbps + +VIDEO: + +Codec Bitrate Description +----- ------- ----------- +MPEG-4 AVC Video 31456 kbps 1080p / 23.976 fps / 16:9 / High Profile 4.1 + +AUDIO: + +Codec Language Bitrate Description +----- -------- ------- ----------- +DTS-HD Master Audio Japanese 3456 kbps 5.1 / 48 kHz / 3456 kbps / 24-bit (DTS Core: 5.1 / 48 kHz / 1509 kbps / 24-bit) +LPCM Audio Japanese 2304 kbps 2.0 / 48 kHz / 2304 kbps / 24-bit + +SUBTITLES: + +Codec Language Bitrate Description +----- -------- ------- ----------- +Presentation Graphics Chinese 28.431 kbps +Presentation Graphics English 30.021 kbps`; + +/** 【】/『』信息块混 BBCode 与 HTML 残留、站点模板和广告签名 */ +export const SYNTH_DESCR_BRACKETS = `[quote][b]本资源由 EXAMPLE 小组制作,禁止转载至其他站点[/b][/quote] +[img]https://img.example.net/poster.jpg[/img] +[size=3][color=Red]【译  名】示例电影 / Example Movie[/color][/size] +【片  名】Example Movie +【年  代】2023 +【产  地】美国 +【类  别】剧情 / 科幻 +【语  言】英语 +【字  幕】中英双语 +【IMDb评分】8.1/10 from 12,345 users +【IMDb链接】https://www.imdb.com/title/tt0000001/ +【片  长】142 分钟 +『导  演』某导演 Some Director +『主  演』演员甲 Actor A +      演员乙 Actor B +  +【简  介】 +

  一位科学家在沙漠深处发现了改变世界的秘密。<他必须>在时间耗尽前做出选择。

+ +


+  影片获得多项提名,[url=https://example.org/awards]查看获奖名单[/url]。 + + + +QQ群:123456789 +----------------------------------------- +[i]感谢原发布者分享[/i]`; + +/** 带目录的文件清单:主视频、Sample、字幕与附件、原盘结构 */ +export const SYNTH_FILES_DIRECTORY = [ + { + name: "Example.Movie.2023.1080p.BluRay.x264-GROUP/Example.Movie.2023.1080p.BluRay.x264-GROUP.mkv", + sizeBytes: 15_032_385_536, + }, + { name: "Example.Movie.2023.1080p.BluRay.x264-GROUP/Sample/example.movie.sample.mkv", sizeBytes: 52_428_800 }, + { + name: "Example.Movie.2023.1080p.BluRay.x264-GROUP/Example.Movie.2023.1080p.BluRay.x264-GROUP.nfo", + sizeBytes: 6_144, + }, + { name: "Example.Movie.2023.1080p.BluRay.x264-GROUP/Subs/Example.Movie.2023.chs.srt", sizeBytes: 98_304 }, + { name: "Example.Movie.2023.1080p.BluRay.x264-GROUP/Subs/Example.Movie.2023.cht.ass", sizeBytes: 131_072 }, + { name: "Example.Movie.2023.1080p.BluRay.x264-GROUP/poster.jpg", sizeBytes: 524_288 }, +]; + +/** 原盘:BDMV/STREAM 下一个大 m2ts 是正片,其余小文件 */ +export const SYNTH_FILES_BDMV = [ + { name: "EXAMPLE_MOVIE/BDMV/index.bdmv", sizeBytes: 280 }, + { name: "EXAMPLE_MOVIE/BDMV/PLAYLIST/00800.mpls", sizeBytes: 3_072 }, + { name: "EXAMPLE_MOVIE/BDMV/STREAM/00001.m2ts", sizeBytes: 58_999_000_000 }, + { name: "EXAMPLE_MOVIE/BDMV/STREAM/00002.m2ts", sizeBytes: 120_000_000 }, + { name: "EXAMPLE_MOVIE/BDMV/STREAM/00003.m2ts", sizeBytes: 90_000_000 }, + { name: "EXAMPLE_MOVIE/CERTIFICATE/id.bdmv", sizeBytes: 100 }, +]; diff --git a/packages/testkit/src/sites/index.ts b/packages/testkit/src/sites/index.ts index b2d44270..7f427ec6 100644 --- a/packages/testkit/src/sites/index.ts +++ b/packages/testkit/src/sites/index.ts @@ -1,4 +1,5 @@ export * from "./fixtures/definitions/index.ts"; +export * from "./fixtures/details/index.ts"; export * from "./fixtures/spiders/index.ts"; export * from "./fixtures/user-parsers/index.ts"; export {