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

@@ -26,6 +26,7 @@ import {
} from "../types.ts";
import { inspectTorrent } from "../util.ts";
import { parseDetailPage } from "./detail.ts";
import { filesFromFileListHtml } from "./detail-page.ts";
import { spiderTorrentToRelease } from "./map.ts";
import type { IndexerDefinition } from "./schema.ts";
import { loadHtml, normalizeSelector, SiteSpider } from "./spider.ts";
@@ -178,6 +179,17 @@ export function createSpiderSiteAdapter(input: IndexerDefinition, deps: SiteAdap
if (r.kind === "login") throw new SiteError(def.id, "auth_expired", `详情 ${siteReleaseId}:返回登录页`);
if (r.kind === "not_found") throw new SiteError(def.id, "not_found", `详情 ${siteReleaseId}:资源不存在`);
if (!r.detail) throw new SiteError(def.id, "parse_error", `详情 ${siteReleaseId}:页面结构无法识别`);
// 文件清单不在页面上(NexusPHP 点「查看列表」才 AJAX 取)→ 按需 1 次;失败只是没有文件清单
const flPath = ext.detail.page?.fileListPath;
if (flPath && r.detail.page && !r.detail.page.files.length) {
try {
const flUrl = new URL(flPath.replace("{id}", siteReleaseId), base).toString();
const frag = await fetchPage(flUrl, `文件清单 ${siteReleaseId}`, opts);
r.detail.page.files = filesFromFileListHtml(loadHtml(frag));
} catch (e) {
if (e instanceof SiteError && e.isAuthError) throw e;
}
}
return r.detail;
},
@@ -1,8 +1,11 @@
import { HHANCLUB_DETAIL_PAGE_FULL } from "@umt/testkit";
import { HHANCLUB_DETAIL_PAGE_2026, HHANCLUB_DETAIL_PAGE_FULL, HHANCLUB_FILELIST_FRAGMENT } from "@umt/testkit";
import { describe, expect, it } from "vitest";
import { hhanclubDefinition } from "../definitions/index.ts";
import { looksLikeMediaInfo, pageFromBbcode } from "../detail-text.ts";
import { createSiteRateLimiter } from "../rate-limit.ts";
import { getSiteDefinition } from "../registry.ts";
import { staticSecretResolver } from "../types.ts";
import { createSpiderSiteAdapter } from "./adapter.ts";
import { parseDetailPage } from "./detail.ts";
import type { IndexerDefinition } from "./schema.ts";
@@ -105,3 +108,58 @@ Format : AVC[/quote]
expect(looksLikeMediaInfo("General\n一段文字\nVideo")).toBe(false);
});
});
describe("详情页扩展内容(憨憨 2026 真实结构:分节 div,无 #kdescr)", () => {
const { kind, detail } = parseDetailPage(HHANCLUB_DETAIL_PAGE_2026, hhanclubDefinition, "91003", OBSERVED);
const page = detail!.page!;
it("MediaInfo 取「mediainfo」一节;IMDb / TMDB 从 MediaInfo 行兜底,豆瓣取页面链接", () => {
expect(kind).toBe("ok");
expect(page.mediaInfo).toContain("Complete name");
expect(page).toMatchObject({ imdbId: "tt45950001", tmdb: { type: "tv", id: "290001" }, doubanId: "36000002" });
expect(detail!.imdbId).toBe("tt45950001");
});
it("简介取豆瓣信息块,小标签补冒号、一项一行", () => {
expect(page.descriptionText).toContain("片名:示例剧集");
expect(page.descriptionText).toContain("类别:爱情 / 古装");
expect(page.descriptionText).toContain("一段剧情简介");
});
it("截图取「截图」一节(优先 data-src),不含海报与占位图", () => {
expect(page.screenshots).toEqual([
"https://pic.hhanclub.net/img/2026/09/29/shot1.png",
"https://img3.pixhost.cc/images/1/shot2.jpg",
]);
});
it("上传者、标签、发布时间(字段文本兜底)", () => {
expect(page.uploader).toBe("匿名");
expect(page.tags).toEqual(["官方", "国语", "中字", "杜比视界"]);
expect(detail!.uploadedAt).toBe("2026-09-29T08:42:57.000Z");
expect(page.files).toEqual([]);
});
it("文件清单不在页面上 → 按需请求 viewfilelist.php 一次", async () => {
const paths: string[] = [];
const fetch = (async (input: RequestInfo | URL) => {
const u = new URL(String(input));
paths.push(`${u.pathname}${u.search}`);
const body = u.pathname === "/viewfilelist.php" ? HHANCLUB_FILELIST_FRAGMENT : HHANCLUB_DETAIL_PAGE_2026;
return new Response(body, { status: 200, headers: { "content-type": "text/html; charset=utf-8" } });
}) as typeof globalThis.fetch;
const limiter = createSiteRateLimiter({ minIntervalMs: 0 });
const a = createSpiderSiteAdapter(hhanclubDefinition, {
fetch,
limiter,
secrets: staticSecretResolver({ hhanclub: { cookie: "c=1" } }),
});
const d = await a.detail("91003");
expect(paths).toEqual(["/details.php?id=91003&hit=1", "/viewfilelist.php?id=91003"]);
expect(limiter.stats.granted).toBe(2);
expect(d.page!.files).toEqual([
{ name: "Example.Show.S01E18.2026.2160p.mkv", sizeBytes: Math.round(5.04 * 1024 ** 3) },
{ name: "Example.Show.S01E19.2026.2160p.mkv", sizeBytes: Math.round(4.98 * 1024 ** 3) },
]);
});
});
@@ -25,10 +25,13 @@ type El = ReturnType<CheerioAPI>;
const LABELS = {
description: ["简介", "Description"],
/** 没有简介容器时的次选:豆瓣 / IMDb 信息块(憨憨把豆瓣资料拆成独立一节) */
descriptionFallback: ["豆瓣", "豆瓣信息", "影片信息", "IMDb信息", "IMDb"],
screenshots: ["截图", "Screenshots", "Screenshot"],
mediaInfo: ["MediaInfo", "媒体信息", "BDInfo"],
tags: ["标签", "Tags"],
uploader: ["上传者", "发布者", "发布人", "上传人", "Uploader"],
files: ["文件列表", "文件清单", "File list", "Files"],
files: ["文件列表", "文件清单", "种子文件", "File list", "Files"],
};
/** 简介里可能装 MediaInfo 的块 */
@@ -62,6 +65,19 @@ function fileFromText(text: string): SiteDetailFile | null {
return HAS_EXT.test(t) ? { name: t, sizeBytes: null } : null;
}
/** 文本里的 IMDb ID:imdb.com/title 链接或「IMDB : tt…」一行;不接受孤立的 tt 串(避免误中哈希) */
export function imdbFromText(text: string): string | null {
const m =
/imdb\.com\/title\/(tt\d{7,8})\b/i.exec(text) ??
/\bIMDb\s*(?:ID|链接|編號|编号)?\s*[::]?\s*(tt\d{7,8})\b/i.exec(text);
return m?.[1] ?? null;
}
/** NexusPHP viewfilelist.php 返回的文件表(按需 AJAX 取到的 HTML 片段) */
export function filesFromFileListHtml($: CheerioAPI): SiteDetailFile[] {
return filesFrom($, $("tr"));
}
function filesFrom($: CheerioAPI, rows: El): SiteDetailFile[] {
const out: SiteDetailFile[] = [];
rows.each((_, n) => {
@@ -70,7 +86,7 @@ function filesFrom($: CheerioAPI, rows: El): SiteDetailFile[] {
const cells = row.children("td");
if (cells.length >= 2) {
const name = cells.first().text().replace(/\s+/g, " ").trim();
const size = cells.eq(1).text().trim();
const size = cells.eq(1).text().replace(/\s+/g, " ").trim();
if (name && HAS_EXT.test(name) && /\d/.test(size)) out.push({ name, sizeBytes: parseSizeMp(size) || null });
return;
}
@@ -106,6 +122,7 @@ export function extractDetailPage($: CheerioAPI, conf: DetailConf, base: string)
let desc: El | null = descSel ? $(descSel).first() : null;
if (!desc?.length) desc = byLabel(LABELS.description);
page.probe.hasDescription = !!desc?.length;
const infoBlock = desc?.length ? null : byLabel(LABELS.descriptionFallback);
// MediaInfo:站点选择器 → 字段标签 → 简介里的引用 / 代码块(取最内层文本,整块从简介里去掉)
let mediaInfoEl: El | null = null;
@@ -137,12 +154,19 @@ export function extractDetailPage($: CheerioAPI, conf: DetailConf, base: string)
// 截图
const imgs: string[] = [];
const imgEls = p.screenshots ? $(p.screenshots) : desc?.length ? desc.find("img") : null;
imgEls?.each((_, n) => {
const e = $(n);
const u = screenshotUrl(e.attr("data-src") ?? e.attr("data-original") ?? e.attr("src"), base);
if (u && !imgs.includes(u)) imgs.push(u);
});
const pushImgs = (els: El | null) =>
els?.each((_, n) => {
const e = $(n);
const u = screenshotUrl(e.attr("data-src") ?? e.attr("data-original") ?? e.attr("src"), base);
if (u && !imgs.includes(u)) imgs.push(u);
});
if (p.screenshots) pushImgs($(p.screenshots));
else {
// 独立的「截图」一节(憨憨)优先;没有再取简介里的图
const shotBlock = byLabel(LABELS.screenshots);
if (shotBlock) pushImgs(shotBlock.is("img") ? shotBlock : shotBlock.find("img"));
if (!imgs.length && desc?.length) pushImgs(desc.find("img"));
}
page.probe.imageCount = imgs.length;
page.screenshots = imgs.slice(0, MAX_SCREENSHOTS);
@@ -153,18 +177,36 @@ export function extractDetailPage($: CheerioAPI, conf: DetailConf, base: string)
c.find("img").remove();
const t = blockText($, c);
if (t) page.descriptionText = t.slice(0, 20_000);
} else if (infoBlock) {
const c = infoBlock.clone();
c.find("img").remove();
// 块里的「标签」span 与值之间补上冒号 / 换行,读起来是「片名:xxx」一行一项
c.find(p.labelSelectors?.join(", ") || "span.font-bold").each((_, n) => {
const e = $(n);
const t = e.text().trim();
if (t && t.length <= 16 && !/[::]$/.test(t)) e.text(`${t}:`);
e.before("\n");
});
const t = blockText($, c)
.replace(/[ \t]*\n[ \t]*/g, "\n")
.replace(/:\s*\n(?=\S)/g, ":");
if (t) page.descriptionText = t.slice(0, 20_000);
}
// 外部 ID:页面上的链接优先,其次简介正文
const descHtml = desc?.html() ?? "";
// 外部 ID:页面上的链接优先,其次简介 / 信息块正文,再次 MediaInfo 里的 IMDB / TMDB 行(憨憨的 MediaInfo 带这两行)
const descHtml = `${desc?.html() ?? ""}\n${infoBlock?.html() ?? ""}`;
const idText = `${descHtml}\n${page.mediaInfo ?? ""}`;
const f = conf.fields ?? {};
page.imdbId =
extractImdbId(mcSelect($, null, f.imdb ?? "a[href*='imdb.com']::attr(href)")) ?? extractImdbId(descHtml) ?? null;
extractImdbId(mcSelect($, null, f.imdb ?? "a[href*='imdb.com']::attr(href)")) ??
imdbFromText(idText) ??
extractImdbId(descHtml) ??
null;
page.doubanId =
extractDoubanId(mcSelect($, null, f.douban ?? "a[href*='douban.com']::attr(href)")) ??
extractDoubanId(descHtml) ??
extractDoubanId(idText) ??
null;
page.tmdb = extractTmdb($("a[href*='themoviedb.org']").first().attr("href")) ?? extractTmdb(descHtml);
page.tmdb = extractTmdb($("a[href*='themoviedb.org']").first().attr("href")) ?? extractTmdb(idText);
// 文件清单:站点选择器 → MovieClaw file_list(去伪元素)→ 字段标签 → #filelist 表格
const fileSel = p.files ?? f.fileList?.replace(/::text$/, "");
+12 -4
View File
@@ -83,10 +83,15 @@ export function parseDetailPage(
browse: undefined,
};
const promo = new SiteSpider(promoDef).infoFrom($, $(conf.headerScope).first());
const timeTitle = $("span[title]")
.filter((_, el) => /\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test($(el).attr("title") ?? ""))
.first()
.attr("title");
// 发布时间:带完整时间的 span[title] → 「发布时间 / 上传时间 / 添加时间」字段的文本(憨憨是 div 标签 + 值)
const timeTitle =
$("span[title]")
.filter((_, el) => /\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test($(el).attr("title") ?? ""))
.first()
.attr("title") ??
/(?:发布时间|上传时间|添加时间|Added)\s*[::]?\s*(\d{4}-\d{2}-\d{2} \d{2}:\d{2}(?::\d{2})?)/.exec(
$("body").text().replace(/\s+/g, " "),
)?.[1];
const detail: SiteReleaseDetail = {
siteId: def.id,
@@ -119,6 +124,9 @@ export function parseDetailPage(
const page = extractDetailPage($, conf, def.domain);
page.imdbId ??= detail.imdbId ?? null;
page.doubanId ??= detail.doubanId ?? null;
// 详情页扩展内容里兜底出来的外部 ID(MediaInfo 的 IMDB 行、豆瓣信息块)也回填到资源本身
if (!detail.imdbId && page.imdbId) detail.imdbId = page.imdbId;
if (!detail.doubanId && page.doubanId) detail.doubanId = page.doubanId;
if (page.tags.length) detail.tags = page.tags;
detail.page = page;
return { kind: "ok", detail };
@@ -211,6 +211,11 @@ export interface UmtSiteExtension {
uploader?: string;
/** 文件清单行(每行一个文件,文本形如「名称 (大小)」或两列表格) */
files?: string;
/**
* 页面上没有内联文件清单时,按需再取一次的 AJAX 端点(`{id}` 替换为资源 ID,NexusPHP 为 `viewfilelist.php?id={id}`);
* 每次详情刷新最多 1 次,失败不影响详情。
*/
fileListPath?: string;
};
};
/** 下载路径(按资源 ID 拼接) */