Merge branch 'hh2'
# Conflicts: # apps/server/src/modules/browser/service.ts # apps/web/app/components/browser/desktop-view.tsx # apps/web/app/components/browser/site-origin-view.tsx # packages/contracts/src/browser.ts
This commit is contained in:
commit
f41cd96fee
33 files changed
+1919
-302
No files matched your search
@@ -260,7 +260,8 @@
|
||||
"fileList": "td.rowfollow > ul > li::text"
|
||||
},
|
||||
"page": {
|
||||
"labelSelectors": ["div.font-bold.leading-6", "span.font-bold"]
|
||||
"labelSelectors": ["div.font-bold.leading-6", "span.font-bold"],
|
||||
"fileListPath": "viewfilelist.php?id={id}"
|
||||
}
|
||||
},
|
||||
"vipLevels": ["贵宾", "VIP"],
|
||||
|
||||
@@ -149,6 +149,7 @@ const UmtNexusSchema = z.strictObject({
|
||||
tags: z.string().optional(),
|
||||
uploader: z.string().optional(),
|
||||
files: z.string().optional(),
|
||||
fileListPath: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
@@ -18,9 +18,12 @@ export function looksLikeMediaInfo(text: string): boolean {
|
||||
return (text.match(/^[^\n::]{2,40}[ \t]*[::][ \t]*\S/gm) ?? []).length >= 5;
|
||||
}
|
||||
|
||||
/** TMDB:themoviedb.org 链接,或 MediaInfo / 简介里的「TMDB : tv/294990」一行 */
|
||||
export function extractTmdb(text: string | null | undefined): SiteDetailPage["tmdb"] {
|
||||
const m = text ? /themoviedb\.org\/(movie|tv)\/(\d+)/.exec(text) : null;
|
||||
return m ? { type: m[1] as "movie" | "tv", id: m[2]! } : null;
|
||||
const m = text
|
||||
? (/themoviedb\.org\/(movie|tv)\/(\d+)/.exec(text) ?? /\bTMDB\s*(?:ID)?\s*[::]\s*(movie|tv)\/(\d+)/i.exec(text))
|
||||
: null;
|
||||
return m ? { type: m[1]!.toLowerCase() as "movie" | "tv", id: m[2]! } : null;
|
||||
}
|
||||
|
||||
/** 图片地址转绝对 http(s) 地址并过滤非截图;无效返回 null */
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeSiteFeedItem } from "./feed-item.ts";
|
||||
|
||||
// 标题取自 2026-09-29 憨憨 RSS 真实条目(公开种子名,无凭据)
|
||||
describe("站点 RSS 条目规范化(憨憨)", () => {
|
||||
it("[分类]种子名[副标题][大小] → 分类 / 标题 / 副标题 / 大小", () => {
|
||||
const r = normalizeSiteFeedItem(
|
||||
"hhanclub",
|
||||
"[音乐]Travis - L.A. Times (2024) - WEB-DL - 24bit ALAC-HHWEB[Travis - L.A. Times | ALAC | 苹果音乐 | 现场版专辑][697.64 MB]",
|
||||
);
|
||||
expect(r).toEqual({
|
||||
title: "Travis - L.A. Times (2024) - WEB-DL - 24bit ALAC-HHWEB",
|
||||
subtitle: "Travis - L.A. Times | ALAC | 苹果音乐 | 现场版专辑",
|
||||
category: { id: "410", name: "音乐" },
|
||||
mediaType: "music",
|
||||
sizeBytes: Math.round(697.64 * 1024 ** 2),
|
||||
uploader: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("电视剧:副标题里的 | 与 / 保留,GB 换算", () => {
|
||||
const r = normalizeSiteFeedItem(
|
||||
"hhanclub",
|
||||
"[电视剧]Spring of the Blade S01 2026 2160p YK WEB-DL H.265 DV HFR HQ AAC2.0-HHWEB[一瓯春 | 第20-24集 | 4K 高码 杜比视界 | 类型: 爱情/古装][26.37 GB]",
|
||||
);
|
||||
expect(r.title).toBe("Spring of the Blade S01 2026 2160p YK WEB-DL H.265 DV HFR HQ AAC2.0-HHWEB");
|
||||
expect(r.subtitle).toBe("一瓯春 | 第20-24集 | 4K 高码 杜比视界 | 类型: 爱情/古装");
|
||||
expect(r.category).toEqual({ id: "402", name: "电视剧" });
|
||||
expect(r.mediaType).toBe("tv");
|
||||
expect(r.sizeBytes).toBe(Math.round(26.37 * 1024 ** 3));
|
||||
});
|
||||
|
||||
it("副标题内嵌方括号、[新] 标记、不在任何媒体大类的分类(纪录片)", () => {
|
||||
const r = normalizeSiteFeedItem(
|
||||
"hhanclub",
|
||||
"[纪录片]Thirteen Talks S09 2016 2160p WEB-DL H.265 AAC2.0-HHWEB[新][十三邀 | 第14集 [内嵌中字]][3.6 GB]",
|
||||
);
|
||||
expect(r.title).toBe("Thirteen Talks S09 2016 2160p WEB-DL H.265 AAC2.0-HHWEB");
|
||||
expect(r.subtitle).toBe("十三邀 | 第14集 [内嵌中字]");
|
||||
expect(r.category).toEqual({ id: "404", name: "纪录片" });
|
||||
expect(r.mediaType).toBeNull();
|
||||
});
|
||||
|
||||
it("带上传者段:[副标题][大小][上传者]", () => {
|
||||
const r = normalizeSiteFeedItem("hhanclub", "[电影]Some Movie 2024 1080p WEB-DL-HHWEB[某电影][8.5 GB][uploader01]");
|
||||
expect(r).toMatchObject({
|
||||
title: "Some Movie 2024 1080p WEB-DL-HHWEB",
|
||||
subtitle: "某电影",
|
||||
uploader: "uploader01",
|
||||
});
|
||||
});
|
||||
|
||||
it("没有大小段时不拆尾部方括号;未登记的前缀不当分类", () => {
|
||||
const r = normalizeSiteFeedItem("hhanclub", "[BDMV]Some.Disc.2024[Extras]");
|
||||
expect(r).toMatchObject({ title: "[BDMV]Some.Disc.2024[Extras]", subtitle: null, sizeBytes: null });
|
||||
expect(r.category).toEqual({ id: null, name: null });
|
||||
});
|
||||
|
||||
it("未知站点只去 [新]", () => {
|
||||
const r = normalizeSiteFeedItem(null, "[电影]Foo 2024[新][foo][1 GB]");
|
||||
expect(r.category.id).toBeNull();
|
||||
expect(r.title).toBe("[电影]Foo 2024");
|
||||
expect(r.subtitle).toBe("foo");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,12 @@ export {
|
||||
SpiderFilterSchema,
|
||||
} from "./definitions/index.ts";
|
||||
export type { SiteAdapterDeps } from "./deps.ts";
|
||||
export {
|
||||
type FeedMediaType,
|
||||
normalizeSiteFeedItem,
|
||||
type SiteFeedItemFields,
|
||||
siteCategoryMediaType,
|
||||
} from "./feed-item.ts";
|
||||
export {
|
||||
classifyLoginError,
|
||||
loginFailureText,
|
||||
|
||||
@@ -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$/, "");
|
||||
|
||||
@@ -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 拼接) */
|
||||
|
||||
@@ -356,6 +356,8 @@ export const BrowserSessionView = BrowserSession.extend({
|
||||
needsReconnect: z.boolean().optional(),
|
||||
/** 最近一次自动重连的结果 */
|
||||
reconnect: BrowserReconnectState.nullable().optional(),
|
||||
/** 最近一次自动化(Agent 能力)动作成功的时间;从未有过为 null。原站视图据此显示「Agent」或「自动化」 */
|
||||
lastAutomationAt: Timestamp.nullable().optional(),
|
||||
});
|
||||
export type BrowserSessionView = z.infer<typeof BrowserSessionView>;
|
||||
|
||||
|
||||
@@ -108,3 +108,49 @@ export const HHANCLUB_DETAIL_PAGE_FULL = `<!DOCTYPE html><html><head><title>HHCL
|
||||
<div class="font-bold leading-6">文件列表</div>
|
||||
<div class="font-bold leading-6"><ul><li>Example.Show.S01E01.mkv (1.04 GB)</li><li>Example.Show.S01E02.mkv (1.02 GB)</li></ul></div>
|
||||
</div></body></html>`;
|
||||
|
||||
/**
|
||||
* HH-2:按 2026-09-29 真实详情页的解析诊断(`page.probe.labels`)重建的最小片段。字段标签名称与顺序来自真实页面
|
||||
* (下载 / 标题 / 副标题 / 标签 / 基本信息 / 字幕 / mediainfo / 海报 / 豆瓣 / 截图 / 种子文件 / 同伴 …,豆瓣块里是
|
||||
* span.font-bold 小标签 片名 / 年代 / 类别 / 导演 …);没有 #kdescr 简介容器;MediaInfo 头部与 IMDB / TMDB 两行
|
||||
* 摘自真实缓存(ID 已替换)。标签与值的 DOM 嵌套是按标题 / 副标题已验证的「标签 div + 相邻值 div」推定的。
|
||||
* 文件清单不在页面上,点「查看列表」才 AJAX 取 viewfilelist.php(见 HHANCLUB_FILELIST_FRAGMENT)。
|
||||
*/
|
||||
export const HHANCLUB_DETAIL_PAGE_2026 = `<!DOCTYPE html><html><head><title>HHCLUB :: 种子详情</title></head><body>
|
||||
<div id="user-info-panel"><a href="userdetails.php?id=7" class="VIP_Name">bob</a> <a href="logout.php">退出</a></div>
|
||||
<div class="grid grid-cols-[80px_1fr]">
|
||||
<div class="font-bold leading-6">下载</div><div class="font-bold leading-6"><a href="download.php?id=91003">Example.Show.S01E18.torrent</a></div>
|
||||
<div class="font-bold leading-6">标题</div><div class="font-bold leading-6">Example Show S01 2026 2160p YK WEB-DL H.265 DV HQ AAC2.0-HHWEB</div>
|
||||
<div class="font-bold leading-6">副标题</div><div class="font-bold leading-6">示例剧集 | 第18-24集 | 4K 高码 杜比视界</div>
|
||||
<div class="font-bold leading-6">标签</div><div class="font-bold leading-6"><span>官方</span><span>国语</span><span>中字</span><span>杜比视界</span></div>
|
||||
<div class="font-bold leading-6">基本信息</div>
|
||||
<div class="font-bold leading-6">
|
||||
<span class="font-bold">大小:</span><span>33.62 GB</span>
|
||||
<span class="font-bold">类型:</span><span>电视剧</span>
|
||||
<span class="font-bold">发布者:</span><span>匿名</span>
|
||||
<span class="font-bold">发布时间:</span><span>2026-09-29 16:42:57</span>
|
||||
</div>
|
||||
<div class="font-bold leading-6">mediainfo</div>
|
||||
<div class="font-bold leading-6"><div class="nexus-media-info-raw">文件名:Example.Show.S01E18.2026.2160p.YK.WEB-DL.H.265.DV.HQ.AAC2.0-HHWEB.mkv<br>体 积: 5.04 GiB<br><br>General<br>Unique ID : 1580976599<br>Complete name : Example.Show.S01E18.mkv<br>Format : Matroska<br>File size : 5.04 GiB<br>Duration : 43 min 55 s<br>IMDB : tt45950001<br>TMDB : tv/290001<br><br>Video<br>ID : 1<br>Format : HEVC</div></div>
|
||||
<div class="font-bold leading-6">海报</div><div class="font-bold leading-6"><img src="https://proxy-cover.hhanclub.net/douban/p0000000001.jpg" alt="poster"></div>
|
||||
<div class="font-bold leading-6">豆瓣</div>
|
||||
<div class="font-bold leading-6">
|
||||
<div><span class="font-bold">片名</span><span>示例剧集</span></div>
|
||||
<div><span class="font-bold">年代</span><span>2026</span></div>
|
||||
<div><span class="font-bold">类别</span><span>爱情 / 古装</span></div>
|
||||
<div><span class="font-bold">导演</span><span>某导演</span></div>
|
||||
<div>一段剧情简介……</div>
|
||||
<a href="https://movie.douban.com/subject/36000002/">豆瓣</a>
|
||||
</div>
|
||||
<div class="font-bold leading-6">截图</div>
|
||||
<div class="font-bold leading-6"><img src="https://pic.hhanclub.net/img/2026/09/29/shot1.png"><img data-src="https://img3.pixhost.cc/images/1/shot2.jpg" src="pic/trans.gif"></div>
|
||||
<div class="font-bold leading-6">种子文件</div><div class="font-bold leading-6"><a href="javascript: viewfilelist(91003)">[查看列表]</a></div>
|
||||
<div class="font-bold leading-6">同伴</div><div class="font-bold leading-6">22个做种者 | 66个下载者</div>
|
||||
</div></body></html>`;
|
||||
|
||||
/** NexusPHP viewfilelist.php 返回的文件表片段(标准结构,文件名 / 大小为示例) */
|
||||
export const HHANCLUB_FILELIST_FRAGMENT = `<table class="main" border="1" cellspacing="0" cellpadding="5">
|
||||
<tr><td class="colhead">路径</td><td class="colhead" align="center"><img class="size" src="pic/trans.gif" alt="size"></td></tr>
|
||||
<tr><td class="rowfollow">Example.Show.S01E18.2026.2160p.mkv</td><td class="rowfollow" align="right">5.04 GB</td></tr>
|
||||
<tr><td class="rowfollow">Example.Show.S01E19.2026.2160p.mkv</td><td class="rowfollow" align="right">4.98 GB</td></tr>
|
||||
</table>`;
|
||||
Reference in new issue
Block a user