/** * 站点 RSS 条目规范化 —— 本项目扩展(站点适配层)。 * NexusPHP `torrentrss.php` 按用户勾选拼标题:`[分类]` + 种子名 + `[副标题]` + `[大小]` + `[上传者]` * (憨憨:`[音乐]Travis - L.A. Times (2024) - WEB-DL - 24bit ALAC-HHWEB[Travis - L.A. Times | ALAC][697.64 MB]`)。 * 这里把它拆成与列表快照一致的字段:干净标题、副标题、站点分类(ID + 名称 + 媒体大类)、大小。 * 只认站点定义里登记过的分类名作前缀;不改标题解析器(release-parser)。 */ import { getSiteDefinition } from "./registry.ts"; import { parseSizeMp } from "./spider/spider.ts"; export type FeedMediaType = "movie" | "tv" | "music"; export interface SiteFeedItemFields { /** 去掉分类前缀、副标题、大小、上传者、「[新]」后的种子名 */ title: string; subtitle: string | null; /** 站点原始分类:ID 取站点定义里同名分类(与列表快照的 category_raw 一致);没有登记为 null */ category: { id: string | null; name: string | null }; /** 站点分类所属的媒体大类(定义 category.movie / tv / music);不属于任何一组为 null */ mediaType: FeedMediaType | null; /** 标题尾部的大小(站点四舍五入后的显示值);enclosure 有 length 时调用方应优先用它 */ sizeBytes: number | null; uploader: string | null; } const NEW_MARK = /\s*[[((【]\s*新\s*[\]))】]\s*/g; const SIZE = /^\s*([\d.,]+\s*[KMGTP]i?B)\s*$/i; interface CategoryDefLike { category?: Partial>; umt?: { categoryNames?: Record }; } /** 站点定义里登记的「分类名 → ID」 */ function categoryIndex(siteKey: string | null | undefined): Map { const out = new Map(); const def = (siteKey ? getSiteDefinition(siteKey) : undefined) as CategoryDefLike | undefined; if (!def) return out; for (const [id, name] of Object.entries(def.umt?.categoryNames ?? {})) if (name) out.set(name, id); for (const k of ["movie", "tv", "music"] as const) { for (const c of def.category?.[k] ?? []) { if (typeof c.desc === "string" && c.desc && c.id != null && !out.has(c.desc)) out.set(c.desc, String(c.id)); } } return out; } /** 站点分类 ID → 媒体大类(只在唯一一组里出现时成立) */ export function siteCategoryMediaType(siteKey: string | null | undefined, id: string | null): FeedMediaType | null { if (!id || !siteKey) return null; const def = getSiteDefinition(siteKey) as CategoryDefLike | undefined; const hits = (["movie", "tv", "music"] as const).filter((k) => (def?.category?.[k] ?? []).some((c) => c.id != null && String(c.id) === id), ); return hits.length === 1 ? hits[0]! : null; } /** 从尾部剥一个配平的 `[...]`;不以 `]` 结尾或不配平返回 null */ function popTrailingGroup(s: string): { rest: string; inner: string } | null { const t = s.trimEnd(); if (!t.endsWith("]")) return null; let depth = 0; for (let i = t.length - 1; i >= 0; i--) { const ch = t[i]; if (ch === "]") depth++; else if (ch === "[") { depth--; if (depth === 0) return { rest: t.slice(0, i), inner: t.slice(i + 1, -1) }; } } return null; } /** * 规范化站点 RSS 条目标题。没有站点定义时只去「[新]」,其余原样返回。 * 只有标题尾部出现大小段(`[697.64 MB]`)时才把它前面的一段当副标题,避免误伤本身以 `[...]` 结尾的种子名。 */ export function normalizeSiteFeedItem(siteKey: string | null | undefined, rawTitle: string): SiteFeedItemFields { const cats = categoryIndex(siteKey); let s = rawTitle.replace(/\s+/g, " ").trim(); let category: SiteFeedItemFields["category"] = { id: null, name: null }; const prefix = /^\s*\[([^\][]{1,16})\]\s*/.exec(s); if (prefix && cats.has(prefix[1]!.trim())) { const name = prefix[1]!.trim(); category = { id: cats.get(name) ?? null, name }; s = s.slice(prefix[0].length); } let sizeBytes: number | null = null; let subtitle: string | null = null; let uploader: string | null = null; const groups: string[] = []; let rest = s; // 最多看尾部 3 段:[副标题][大小][上传者] for (let i = 0; i < 3; i++) { const g = popTrailingGroup(rest); if (!g) break; groups.unshift(g.inner); rest = g.rest; } const sizeIdx = groups.findIndex((g) => SIZE.test(g)); if (sizeIdx >= 0) { const bytes = parseSizeMp(SIZE.exec(groups[sizeIdx]!)![1]!); sizeBytes = bytes > 0 ? bytes : null; const before = groups.slice(0, sizeIdx); const after = groups.slice(sizeIdx + 1); // 大小之前最后一段是副标题,再往前的属于种子名本身 if (before.length) subtitle = before.pop()!.trim() || null; if (after.length === 1 && after[0]!.trim().length <= 32) uploader = after[0]!.trim() || null; s = `${rest}${before.map((b) => `[${b}]`).join("")}${after.length > 1 ? after.map((a) => `[${a}]`).join("") : ""}`; } const title = s.replace(NEW_MARK, " ").replace(/\s+/g, " ").trim() || rawTitle.trim(); return { title, subtitle: subtitle ? subtitle.replace(NEW_MARK, " ").trim() || null : null, category, mediaType: siteCategoryMediaType(siteKey, category.id), sizeBytes, uploader, }; }