Merge branch 'det2'
Co-Authored-By: Claude Fable 5.1 <[email protected]> # Conflicts: # packages/adapters/src/sites/definitions/audiences.json # scripts/mp-sync/movieclaw-overrides/audiences.json
This commit is contained in:
commit
389c49dd12
17 files changed
+1301
-69
No files matched your search
@@ -43,17 +43,35 @@ const normLabel = (t: string) =>
|
||||
.replace(/[::]+$/, "")
|
||||
.toLowerCase();
|
||||
|
||||
/** 保留换行的文本:<br> 与块级元素换行 */
|
||||
/** 换行占位:先把 <br> / 块级换行记成它,再去掉 HTML 源码里的换行(浏览器按空白处理),最后还原 */
|
||||
const BR = "\u0000";
|
||||
|
||||
/**
|
||||
* 保留换行的文本:<br> 与块级元素换行。源码换行不算换行(观众的简介是 `<br />\r\n`,
|
||||
* 两者都算会让每行之间多一空行);<pre> 里的换行保留。
|
||||
*/
|
||||
function blockText($: CheerioAPI, el: El): string {
|
||||
const c = el.clone();
|
||||
c.find("script, style").remove();
|
||||
c.find("br").replaceWith("\n");
|
||||
c.find("div, p, li, tr, pre, fieldset, blockquote, h1, h2, h3, h4, h5, h6").each((_, n) => {
|
||||
$(n).append("\n");
|
||||
c.find("pre, textarea").each((_, n) => {
|
||||
$(n).text($(n).text().replace(/\r?\n/g, BR));
|
||||
});
|
||||
return tidyText(c.text());
|
||||
c.find("br").replaceWith(BR);
|
||||
c.find("div, p, li, tr, pre, fieldset, blockquote, h1, h2, h3, h4, h5, h6").each((_, n) => {
|
||||
$(n).append(BR);
|
||||
});
|
||||
const text = c
|
||||
.text()
|
||||
.replace(/\r?\n/g, " ")
|
||||
// `<br />\r\n下一行` 的源码换行变成了行首一个空格
|
||||
.replace(new RegExp(`${BR} `, "g"), BR)
|
||||
.replaceAll(BR, "\n");
|
||||
return tidyText(text);
|
||||
}
|
||||
|
||||
/** NexusPHP MediaInfo 控件的外壳:标题栏 div.codetop、摘要表 div.show、原文 div.hide(观众三者相邻) */
|
||||
const MI_WIDGET = "div.codetop, div.show, div.hide";
|
||||
|
||||
const SIZE_TAIL = /^(.*?)\s*[(([]\s*([\d.,]+\s*[KMGTP]?i?B)\s*[))\]]\s*$/i;
|
||||
const HAS_EXT = /\.[A-Za-z0-9]{2,5}$/;
|
||||
|
||||
@@ -113,8 +131,9 @@ export function extractDetailPage($: CheerioAPI, conf: DetailConf, base: string)
|
||||
};
|
||||
addLabels(conf.rowLabel, (e) => e.nextAll(conf.rowValue).first());
|
||||
for (const sel of p.labelSelectors ?? []) addLabels(sel, (e) => e.next());
|
||||
const byLabel = (names: string[]): El | null =>
|
||||
pairs.find((x) => names.some((n) => x.label.startsWith(normLabel(n))))?.value ?? null;
|
||||
const byLabel = (names: string[], exact = false): El | null =>
|
||||
pairs.find((x) => names.some((n) => (exact ? x.label === normLabel(n) : x.label.startsWith(normLabel(n)))))
|
||||
?.value ?? null;
|
||||
page.probe.labels = [...new Set(pairs.map((x) => x.label))].slice(0, 40);
|
||||
|
||||
// 简介容器
|
||||
@@ -170,10 +189,30 @@ export function extractDetailPage($: CheerioAPI, conf: DetailConf, base: string)
|
||||
page.probe.imageCount = imgs.length;
|
||||
page.screenshots = imgs.slice(0, MAX_SCREENSHOTS);
|
||||
|
||||
// 简介纯文本:去掉 MediaInfo 块与图片
|
||||
// 简介纯文本:去掉站点固定内容、MediaInfo 块(连同控件标题栏 / 摘要表)与图片
|
||||
if (desc?.length) {
|
||||
const c = desc.clone();
|
||||
if (miBlockIndex >= 0) c.find(MI_BLOCKS).eq(miBlockIndex).remove();
|
||||
if (miBlockIndex >= 0) {
|
||||
const isMi = (n: Parameters<typeof $>[0]) => looksLikeMediaInfo(blockText($, $(n)));
|
||||
// 同一份 MediaInfo 常出现两次(摘要 div.show 与原文 div.hide):另外去掉最内层的 MediaInfo 块,
|
||||
// 在控件外壳(div.codetop / div.show / div.hide)里时连同相邻外壳一起去掉
|
||||
const extra = c
|
||||
.find(MI_BLOCKS)
|
||||
.filter((_, n) => isMi(n) && !$(n).find(MI_BLOCKS).toArray().some(isMi))
|
||||
.map((_, n) => {
|
||||
const e = $(n);
|
||||
const shell = e.parent().is(MI_WIDGET) ? e.parent() : e;
|
||||
const out = [shell.get(0)!];
|
||||
if (!shell.is(MI_WIDGET)) return out;
|
||||
for (let x = shell.prev(); x.is(MI_WIDGET) && out.length < 3; x = x.prev()) out.push(x.get(0)!);
|
||||
for (let x = shell.next(); x.is(MI_WIDGET) && out.length < 4; x = x.next()) out.push(x.get(0)!);
|
||||
return out;
|
||||
})
|
||||
.get();
|
||||
c.find(MI_BLOCKS).eq(miBlockIndex).remove();
|
||||
for (const w of extra) $(w).remove();
|
||||
}
|
||||
if (p.descriptionStrip) c.find(p.descriptionStrip).remove();
|
||||
c.find("img").remove();
|
||||
const t = blockText($, c);
|
||||
if (t) page.descriptionText = t.slice(0, 20_000);
|
||||
@@ -221,17 +260,16 @@ export function extractDetailPage($: CheerioAPI, conf: DetailConf, base: string)
|
||||
// 标签
|
||||
const tagEl = p.tags ? $(p.tags) : byLabel(LABELS.tags);
|
||||
if (tagEl?.length) {
|
||||
const parts = tagEl.find("span, a").length
|
||||
? tagEl
|
||||
.find("span, a")
|
||||
.map((_, n) => $(n).text().trim())
|
||||
.get()
|
||||
: tagEl.text().split(/[\s,,|/]+/);
|
||||
// 只取最内层的 span / a(观众外面还包了一层 span.detail-tag-list,取外层会把全部标签连成一个)
|
||||
const leaves = tagEl.find("span, a").filter((_, n) => $(n).find("span, a").length === 0);
|
||||
const parts = leaves.length ? leaves.map((_, n) => $(n).text().trim()).get() : tagEl.text().split(/[\s,,|/]+/);
|
||||
page.tags = [...new Set(parts.map((t) => t.trim()).filter((t) => t && t.length <= 20))].slice(0, 20);
|
||||
}
|
||||
|
||||
// 上传者
|
||||
const upEl = p.uploader ? $(p.uploader).first() : byLabel(LABELS.uploader);
|
||||
// 上传者标签精确匹配:观众另有「发布者带宽」一行
|
||||
const upSel = p.uploader ? $(p.uploader).first() : null;
|
||||
const upEl = upSel?.length ? upSel : byLabel(LABELS.uploader, true);
|
||||
if (upEl?.length) {
|
||||
const a = upEl.is("a") ? upEl : upEl.find("a[href*='userdetails.php']").first();
|
||||
const name = (a.length ? a.text() : upEl.text()).replace(/\s+/g, " ").trim();
|
||||
|
||||
@@ -51,8 +51,15 @@ export function parseDetailPage(
|
||||
.text()
|
||||
)
|
||||
.replace(/\s+/g, " ")
|
||||
// 促销到期的「剩余时间:」是 h1 的直接文本节点(观众),不属于标题
|
||||
.replace(/\s*(?:剩余时间|剩餘時間|Remaining(?: time)?)\s*[::]?\s*$/i, "")
|
||||
.trim();
|
||||
const subtitle = f.subtitle ? (mcSelect($, null, f.subtitle) ?? undefined) : row(conf.labels.subtitle)?.text().trim();
|
||||
const subtitleEl = f.subtitle ? null : row(conf.labels.subtitle)?.clone();
|
||||
// 副标题行右侧的豆瓣 / IMDb / 烂番茄等评分徽标(观众)不算副标题
|
||||
subtitleEl?.find("[title*='评分'], [title*='Rating'], img").remove();
|
||||
const subtitle = f.subtitle
|
||||
? (mcSelect($, null, f.subtitle) ?? undefined)
|
||||
: subtitleEl?.text().replace(/\s+/g, " ").trim();
|
||||
const basicText = row(conf.labels.basicInfo)?.text().replace(/\s+/g, " ") ?? "";
|
||||
const sizeText = f.size
|
||||
? (/([\d.,]+\s*[KMGTP]?i?B)/i.exec(mcSelect($, null, f.size) ?? "")?.[1] ?? null)
|
||||
@@ -64,6 +71,9 @@ export function parseDetailPage(
|
||||
const peersText = row(conf.labels.peers)?.text().replace(/\s+/g, " ") ?? "";
|
||||
const seeders = /(\d[\d,]*)\s*个?做种者|(\d[\d,]*)\s*seeder/i.exec(peersText);
|
||||
const leechers = /(\d[\d,]*)\s*个?下载者|(\d[\d,]*)\s*leecher/i.exec(peersText);
|
||||
// 站点把做种 / 下载 / 完成数放在其它位置时(观众:基本信息行右侧的 #seeders / #leechers / 完成链接)按选择器取
|
||||
const countOf = (sel: string | undefined) => (sel ? parseCount(mcSelect($, null, sel) ?? undefined) : null);
|
||||
const infoHash = mcSelect($, null, f.infoHash)?.toLowerCase();
|
||||
const descEl = $(conf.description).first();
|
||||
const descText = descEl.text().trim();
|
||||
const descHtml = descEl.html() ?? "";
|
||||
@@ -76,17 +86,22 @@ export function parseDetailPage(
|
||||
list: def.torrents.list,
|
||||
fields: Object.fromEntries(
|
||||
(["downloadvolumefactor", "uploadvolumefactor", "freedate", "hr"] as const)
|
||||
.filter((k) => fields[k])
|
||||
.map((k) => [k, fields[k]]),
|
||||
.map((k) => [k, conf.promo?.[k] ?? fields[k]] as const)
|
||||
.filter(([, v]) => v),
|
||||
),
|
||||
},
|
||||
browse: undefined,
|
||||
};
|
||||
const promo = new SiteSpider(promoDef).infoFrom($, $(conf.headerScope).first());
|
||||
// 发布时间:带完整时间的 span[title] → 「发布时间 / 上传时间 / 添加时间」字段的文本(憨憨是 div 标签 + 值)
|
||||
// 标题区(headerScope)里的 span[title] 是促销到期时间,不是发布时间
|
||||
const timeTitle =
|
||||
$("span[title]")
|
||||
.filter((_, el) => /\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test($(el).attr("title") ?? ""))
|
||||
.filter(
|
||||
(_, el) =>
|
||||
/\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test($(el).attr("title") ?? "") &&
|
||||
$(el).closest(conf.headerScope).length === 0,
|
||||
)
|
||||
.first()
|
||||
.attr("title") ??
|
||||
/(?:发布时间|上传时间|添加时间|Added)\s*[::]?\s*(\d{4}-\d{2}-\d{2} \d{2}:\d{2}(?::\d{2})?)/.exec(
|
||||
@@ -99,9 +114,9 @@ export function parseDetailPage(
|
||||
title,
|
||||
category: { id: catId, name: catText ?? categoryName(def, catId ?? undefined) },
|
||||
sizeBytes: sizeText ? parseSizeMp(sizeText) : null,
|
||||
seeders: seeders ? parseCount(seeders[1] ?? seeders[2]) : null,
|
||||
leechers: leechers ? parseCount(leechers[1] ?? leechers[2]) : null,
|
||||
snatched: null,
|
||||
seeders: seeders ? parseCount(seeders[1] ?? seeders[2]) : countOf(f.seeders),
|
||||
leechers: leechers ? parseCount(leechers[1] ?? leechers[2]) : countOf(f.leechers),
|
||||
snatched: countOf(f.snatched),
|
||||
promotion: promotionFromFactors(
|
||||
promo.downloadvolumefactor,
|
||||
promo.uploadvolumefactor,
|
||||
@@ -116,6 +131,7 @@ export function parseDetailPage(
|
||||
observedAt,
|
||||
};
|
||||
if (subtitle) detail.subtitle = subtitle;
|
||||
if (infoHash && /^[0-9a-f]{40}$/.test(infoHash)) detail.infoHash = infoHash;
|
||||
if (descText) detail.descriptionText = descText.slice(0, 20_000);
|
||||
const imdb = extractImdbId(mcSelect($, null, f.imdb)) ?? extractImdbId(descHtml) ?? extractImdbId(descText);
|
||||
if (imdb) detail.imdbId = imdb;
|
||||
|
||||
@@ -196,8 +196,27 @@ export interface UmtSiteExtension {
|
||||
* 用于 HHClub 这类 div 详情页。
|
||||
*/
|
||||
fields?: Partial<
|
||||
Record<"title" | "subtitle" | "size" | "description" | "download" | "imdb" | "douban" | "fileList", string>
|
||||
Record<
|
||||
| "title"
|
||||
| "subtitle"
|
||||
| "size"
|
||||
| "description"
|
||||
| "download"
|
||||
| "imdb"
|
||||
| "douban"
|
||||
| "fileList"
|
||||
| "seeders"
|
||||
| "leechers"
|
||||
| "snatched"
|
||||
| "infoHash",
|
||||
string
|
||||
>
|
||||
>;
|
||||
/**
|
||||
* 详情页促销 / H&R 字段(downloadvolumefactor / uploadvolumefactor / freedate / hr),覆盖同名列表字段。
|
||||
* 详情页头部的促销标记常与列表不同(列表 img.pro_free,详情 h1 里 font.free + 「剩余时间」span)。
|
||||
*/
|
||||
promo?: Record<string, SpiderFieldSelector>;
|
||||
/**
|
||||
* 详情页扩展内容(简介 / MediaInfo / 截图 / 文件 / 标签 / 上传者)的站点特化,均可省略(走通用规则,见 detail-page.ts)。
|
||||
* 选择器为普通 CSS。
|
||||
@@ -216,6 +235,8 @@ export interface UmtSiteExtension {
|
||||
* 每次详情刷新最多 1 次,失败不影响详情。
|
||||
*/
|
||||
fileListPath?: string;
|
||||
/** 从简介里去掉的站点固定内容(如站点声明),普通 CSS */
|
||||
descriptionStrip?: string;
|
||||
};
|
||||
};
|
||||
/** 下载路径(按资源 ID 拼接) */
|
||||
|
||||
Reference in new issue
Block a user