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; } }