Files
UHD_Media_Tool/packages/adapters/src/sites/mteam/spider.ts
T
rootandClaude Opus 5.5 04cffcd1f1 feat(sites): on-demand site detail page content (INT-2)
- SiteReleaseDetail.page: description, MediaInfo/BDInfo, screenshots,
  IMDb / Douban / TMDB ids, file list, tags, uploader, plus a parse probe
  (labels found) for layout-change diagnosis. Generic NexusPHP rules
  (field label -> next sibling) starting from the MovieClaw detail
  selectors; site overrides under umt.detail.page (HHClub: div labels).
  M-Team builds the same structure from descr BBCode + mediainfo.
- Fetched only on demand (drawer open without cache, "复核详情",
  release.inspect); content in kv_cache region site.detail for the
  site detail cache period (>= 24 h); within the window the site is not
  requested again. External ids persist in source_release.raw._detail,
  which list snapshots do not overwrite.
- Screenshots only as proxy paths /api/v1/img/site-detail/{rid}/{i}:
  public-address check on every hop, no site credentials, magic-byte
  image check, 8 MB cap. Poster proxy shares the same helper.
- Identity: site ids count as explicit-id evidence; when TMDB is
  queried, a TMDB id goes straight to details and an IMDb id to
  /find (no title search on a hit). Douban only for local recall.
- Release drawer "站点详情页" section; fixtures and tests for HHClub div
  layout, rowhead layout, BBCode, cache/proxy/identity end to end.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 16:21:17 +08:00

161 lines
6.8 KiB
TypeScript

/**
* M-Team API 解析。
*
* 迁移自 MoviePilot e6820a8 `app/modules/indexer/spider/mtorrent.py` `MTorrentSpider`:
* - `_movie_category/_tv_category/_music_category/_labels` → 同名常量
* - `__get_params` → `mteamSearchParams`
* - `__parse_result` → `parseMteamResult`
* - `__find_imdbid / __get_downloadvolumefactor / __get_uploadvolumefactor` → 同名函数
* - `_pageurl` → `page_url`
*
* 与 MP 的差异:
* 1. 不生成 `[base64(json 含 x-api-key)]genDlToken` 形式的 enclosure(凭据不得进入候选对象,迁移账本 MT3),
* 下载时由 adapter 现场调用 genDlToken(行为同 MP `_resolve_indirect_download_url`)。
* 2. 额外输出 `category_id`(原始分类 ID),`category` 仍是 MP 的媒体类型投影。
* 3. 额外保留 `status` 原文到 raw,便于核验。
*/
import type { MediaKind, MpMediaType, SpiderTorrent } from "../spider/spider.ts";
export const MOVIE_CATEGORY = ["401", "419", "420", "421", "439", "405", "404"];
export const TV_CATEGORY = ["403", "402", "435", "438", "404", "405"];
export const MUSIC_CATEGORY = ["434", "406"];
export const LABELS: Record<string, string> = {
"0": "",
"1": "DIY",
"2": "国配",
"3": "DIY 国配",
"4": "中字",
"5": "DIY 中字",
"6": "国配 中字",
"7": "DIY 国配 中字",
};
export const PAGE_SIZE = 100;
/** M-Team 种子 JSON(字段名按公开 API,待真实账号核验)。 */
export interface MteamTorrentJson {
id: string | number;
name?: string | null;
smallDescr?: string | null;
category?: string | number | null;
size?: string | number | null;
createdDate?: string | null;
imdb?: string | null;
douban?: string | null;
labels?: string | number | null;
labelsNew?: string[] | null;
descr?: string | null;
mediainfo?: string | null;
status?: {
seeders?: string | number | null;
leechers?: string | number | null;
timesCompleted?: string | number | null;
discount?: string | null;
discountEndTime?: string | null;
promotionRule?: { discount?: string | null; endTime?: string | null } | null;
mallSingleFree?: { status?: string | null; endDate?: string | null } | null;
} | null;
[k: string]: unknown;
}
/** MP `__get_params`(page 为 0 起) */
export function mteamSearchParams(keyword: string | null | undefined, mtype: MediaKind | undefined, page = 0) {
let categories: string[];
if (!mtype) categories = [];
else if (mtype === "tv") categories = TV_CATEGORY;
else if (mtype === "music") categories = MUSIC_CATEGORY;
else categories = MOVIE_CATEGORY;
// mtorrent 搜索 imdb 需要输入完整 imdb 链接(MP 注释引 wiki.m-team.cc/zh-tw/imdbtosearch)
const kw = keyword?.startsWith("tt") ? `https://www.imdb.com/title/${keyword}` : (keyword ?? null);
return { keyword: kw, categories, pageNumber: Number(page) + 1, pageSize: PAGE_SIZE, visible: 1 };
}
/** MP `__find_imdbid` */
export function findImdbid(imdb: string | null | undefined): string {
return imdb ? (/tt\d+/.exec(imdb)?.[0] ?? "") : "";
}
/** MP `__get_downloadvolumefactor` */
export function getDownloadVolumeFactor(discount: string | null | undefined): number {
const map: Record<string, number> = { FREE: 0, PERCENT_50: 0.5, PERCENT_70: 0.3, _2X_FREE: 0, _2X_PERCENT_50: 0.5 };
return discount ? (map[discount] ?? 1) : 1;
}
/** MP `__get_uploadvolumefactor` */
export function getUploadVolumeFactor(discount: string | null | undefined): number {
const map: Record<string, number> = { _2X: 2.0, _2X_FREE: 2.0, _2X_PERCENT_50: 2.0 };
return discount ? (map[discount] ?? 1) : 1;
}
/**
* MP `time_tools.format_timestamp`:非纯数字字符串原样返回;Unix 秒 → 本地墙钟 "YYYY-MM-DD HH:MM:SS"。
* MP 用进程本地时区(容器默认 Asia/Shanghai);这里固定 +08:00,与 adapter 按 `umt.timezone`(+08:00)转 UTC 配套。
*/
function formatTimestamp(v: string | number | null | undefined): string | undefined {
if (v == null) return undefined;
if (typeof v === "string" && !/^\d+$/.test(v)) return v;
const n = Number(v);
if (!Number.isFinite(n)) return String(v);
const d = new Date(Math.trunc(n) * 1000 + 8 * 3600_000);
const p = (x: number) => String(x).padStart(2, "0");
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`;
}
function toInt(v: unknown): number {
const n = Number.parseInt(String(v ?? "0") || "0", 10);
return Number.isFinite(n) ? n : 0;
}
/** MP `__parse_result` */
export function parseMteamResult(results: MteamTorrentJson[] | null | undefined, webUrl: string): SpiderTorrent[] {
const out: SpiderTorrent[] = [];
if (!results) return out;
const url = webUrl.endsWith("/") ? webUrl : `${webUrl}/`;
for (const result of results) {
const categoryValue = result.category == null ? undefined : String(result.category);
let category: MpMediaType;
if (categoryValue && MUSIC_CATEGORY.includes(categoryValue)) category = "音乐";
else if (categoryValue && TV_CATEGORY.includes(categoryValue) && !MOVIE_CATEGORY.includes(categoryValue))
category = "电视剧";
else if (categoryValue && MOVIE_CATEGORY.includes(categoryValue)) category = "电影";
else category = "未知";
let labels: string[] = [];
if (result.labelsNew && result.labelsNew.length > 0) labels = result.labelsNew.map(String);
else {
const v = LABELS[String(result.labels || "0")] ?? "";
if (v) labels = v.split(/\s+/);
}
const status = result.status ?? {};
const torrent: SpiderTorrent = {
title: result.name ?? undefined,
description: result.smallDescr ?? undefined,
size: toInt(result.size),
seeders: toInt(status.seeders),
peers: toInt(status.leechers),
grabs: toInt(status.timesCompleted),
downloadvolumefactor: getDownloadVolumeFactor(status.discount),
uploadvolumefactor: getUploadVolumeFactor(status.discount),
page_url: `${url}detail/${result.id}`,
imdbid: findImdbid(result.imdb),
labels,
category,
};
const pubdate = formatTimestamp(result.createdDate);
if (pubdate) torrent.pubdate = pubdate;
if (categoryValue) torrent.category_id = categoryValue;
if (status.discountEndTime) torrent.freedate = formatTimestamp(status.discountEndTime);
// 全站促销规则(当前馒头只有下载促销)
if (status.promotionRule) {
torrent.downloadvolumefactor = getDownloadVolumeFactor(status.promotionRule.discount ?? "NORMAL");
if (status.promotionRule.endTime) torrent.freedate = formatTimestamp(status.promotionRule.endTime);
}
if (status.mallSingleFree && status.mallSingleFree.status === "ONGOING") {
torrent.downloadvolumefactor = getDownloadVolumeFactor("FREE");
if (status.mallSingleFree.endDate) torrent.freedate = formatTimestamp(status.mallSingleFree.endDate);
}
out.push(torrent);
}
return out;
}