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:
1 parent
4a212ed19e
commit
cabd772243
32 files changed
+816
-65
No files matched your search
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user