- 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]>
101 lines
3.8 KiB
TypeScript
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;
|
|
}
|
|
}
|