Files
UHD_Media_Tool/packages/adapters/src/sites/util.ts
T
rootandClaude Fable 5.1 a6d049db81 feat(adapters): RSS and PT site adapters ported from MoviePilot v3 + mocks (W1-3b)
- rss: port of MP RssHelper._parse_impl/get_rss_link semantics with safe
  XML (entities off, DTD/ENTITY rejected, size/item caps), ETag/Last-Modified,
  backoff + Retry-After, circuit breaker with manual reset, passkey redaction,
  overlap-window GUID+digest dedupe
- sites/spider: TS port of MP SiteSpider (indexer schema, search URL builder,
  field DSL, filters, Jinja templates via nunjucks, login-page detection);
  audiences definition hand-written as JSON; raw category id preserved;
  shared per-site token bucket
- sites/mteam: port of MP MTorrentSpider (params, result mapping, promo
  factors, genDlToken flow) without embedding the API key in enclosures;
  business code/message checks
- testkit: mock RSS server, mock NexusPHP site, mock M-Team API
- docs: W1-3b migration mapping appended to porting ledger

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-27 18:10:34 +08:00

101 lines
3.8 KiB
TypeScript

import { createHash } from "node:crypto";
/** "1,204" / "532 / 3" → 1204 / 532;非数字返回 null(未知 ≠ 0)。 */
export function parseCount(text: string | null | undefined): number | null {
if (text == null) return null;
const head = String(text).split("/")[0]!.replace(/[,\s]/g, "");
return /^\d+$/.test(head) ? Number(head) : null;
}
/** 站点本地时间 "YYYY-MM-DD HH:mm:ss" + 站点时区偏移("+08:00")→ UTC ISO。 */
export function parseSiteTime(text: string | null | undefined, tzOffset: string): string | null {
if (!text) return null;
const m = /(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?/.exec(text);
if (!m) return null;
const iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6] ?? "00"}${tzOffset}`;
const t = Date.parse(iso);
return Number.isNaN(t) ? null : new Date(t).toISOString();
}
export function extractImdbId(text: string | null | undefined): string | undefined {
return text ? (/\b(tt\d{6,10})\b/.exec(text)?.[1] ?? undefined) : undefined;
}
export function extractDoubanId(text: string | null | undefined): string | undefined {
return text ? (/douban\.com\/subject\/(\d+)/.exec(text)?.[1] ?? undefined) : undefined;
}
// ---- .torrent 校验与 infohash(最小 bencode 扫描,只为定位 info 字典字节范围) ----
function skipValue(buf: Uint8Array, pos: number, depth = 0): number {
if (depth > 64) throw new Error("bencode 嵌套过深");
const c = buf[pos];
if (c === undefined) throw new Error("bencode 意外结束");
if (c === 0x69 /* i */) {
const end = buf.indexOf(0x65, pos);
if (end < 0) throw new Error("bencode 整数未结束");
return end + 1;
}
if (c === 0x6c /* l */ || c === 0x64 /* d */) {
let p = pos + 1;
while (buf[p] !== 0x65) {
if (p >= buf.length) throw new Error("bencode 容器未结束");
p = skipValue(buf, p, depth + 1);
}
return p + 1;
}
if (c >= 0x30 && c <= 0x39) {
const colon = buf.indexOf(0x3a, pos);
if (colon < 0) throw new Error("bencode 字符串缺少冒号");
const len = Number(new TextDecoder().decode(buf.subarray(pos, colon)));
if (!Number.isInteger(len) || len < 0 || colon + 1 + len > buf.length) throw new Error("bencode 字符串越界");
return colon + 1 + len;
}
throw new Error("bencode 非法前缀");
}
function readString(buf: Uint8Array, pos: number): { value: Uint8Array; next: number } {
const colon = buf.indexOf(0x3a, pos);
const len = Number(new TextDecoder().decode(buf.subarray(pos, colon)));
return { value: buf.subarray(colon + 1, colon + 1 + len), next: colon + 1 + len };
}
export interface TorrentInspection {
infoHash: string;
name: string | null;
}
/** 校验字节是 .torrent(顶层字典且含 info),返回 v1 infohash(info 字典原始字节的 sha1)。非 torrent 返回 null。 */
export function inspectTorrent(bytes: Uint8Array): TorrentInspection | null {
try {
if (bytes[0] !== 0x64) return null;
let p = 1;
while (bytes[p] !== 0x65) {
const key = readString(bytes, p);
const keyText = new TextDecoder().decode(key.value);
const end = skipValue(bytes, key.next);
if (keyText === "info") {
const info = bytes.subarray(key.next, end);
const infoHash = createHash("sha1").update(info).digest("hex");
let name: string | null = null;
// info 字典内找 name
let q = 1;
while (info[q] !== 0x65 && q < info.length) {
const k = readString(info, q);
const vEnd = skipValue(info, k.next);
if (new TextDecoder().decode(k.value) === "name" && info[k.next]! >= 0x30 && info[k.next]! <= 0x39) {
name = new TextDecoder().decode(readString(info, k.next).value);
}
q = vEnd;
}
return { infoHash, name };
}
p = end;
if (p >= bytes.length) return null;
}
return null;
} catch {
return null;
}
}