- RSS: split NexusPHP "[category]name[subtitle][size][uploader]" titles in the site adapter layer (category id/name/media type, clean title, subtitle, size); snapshot fields win on merge; lazily renormalize old RSS rows and re-emit release.observed; unknown list fields show an em dash - Detail: HHClub section layout (douban info block as description, screenshots section, IMDb/TMDB from MediaInfo lines, upload time field, on-demand viewfilelist.php once); site CDN screenshots get a Referer - Site request count now includes site-bound RSS fetches - Origin view shows "自动化" when idle and "Agent" only after recent automation actions (lastAutomationAt); agent step titles no longer get squeezed to a sliver by long notes Co-Authored-By: Claude Opus 5.5 <[email protected]>
124 lines
5.2 KiB
TypeScript
124 lines
5.2 KiB
TypeScript
/**
|
|
* 站点 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<Record<FeedMediaType, { id?: unknown; desc?: unknown }[]>>;
|
|
umt?: { categoryNames?: Record<string, string> };
|
|
}
|
|
|
|
/** 站点定义里登记的「分类名 → ID」 */
|
|
function categoryIndex(siteKey: string | null | undefined): Map<string, string> {
|
|
const out = new Map<string, string>();
|
|
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,
|
|
};
|
|
}
|