fix(sites): normalize HHClub RSS items, complete detail page parsing (HH-2)

- 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]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 17:30:25 +08:00
1 parent 4a212ed19e
commit cabd772243
32 files changed
+816 -65

No files matched your search

+123
View File
@@ -0,0 +1,123 @@
/**
* 站点 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,
};
}