export interface ParsedNzbFile { poster: string /** Posting time in seconds (as in the NZB). */ date: number subject: string groups: string[] segments: { number: number; bytes: number; id: string }[] } export interface ParsedNzb { /** values, e.g. name, password, category. */ meta: Record files: ParsedNzbFile[] } const ENTITIES: Record = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" } function decode(text: string): string { return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, entity: string) => { if (entity[0] === '#') { const code = entity[1].toLowerCase() === 'x' ? Number.parseInt(entity.slice(2), 16) : Number.parseInt(entity.slice(1), 10) return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match } return ENTITIES[entity.toLowerCase()] ?? match }) } function attributes(tag: string): Record { const result: Record = {} for (const m of tag.matchAll(/([\w:-]+)\s*=\s*("([^"]*)"|'([^']*)')/g)) result[m[1]] = decode(m[3] ?? m[4] ?? '') return result } /** * Parses an NZB (1.0/1.1 and the older 0.9 DTD). A tolerant scanner rather * than a full XML parser: NZBs are flat, and this never resolves DTDs or * external entities. Returns null when there is no root. */ export function parseNzb(xml: string, limits = { maxFiles: 20_000 }): ParsedNzb | null { if (!/]/i.test(xml)) return null const meta: Record = {} const head = /([\s\S]*?)<\/head>/i.exec(xml)?.[1] ?? '' for (const m of head.matchAll(/]*)>([\s\S]*?)<\/meta>/gi)) { const type = attributes(m[1]).type if (type) meta[type.toLowerCase()] = decode(m[2].trim()) } const files: ParsedNzbFile[] = [] for (const m of xml.matchAll(/]*)>([\s\S]*?)<\/file>/gi)) { if (files.length >= limits.maxFiles) break const attrs = attributes(m[1]) const body = m[2] const groups = [...body.matchAll(/([^<]*)<\/group>/gi)].map((g) => decode(g[1].trim())).filter(Boolean) const segments = [...body.matchAll(/]*)>([^<]*)<\/segment>/gi)] .map((s) => { const a = attributes(s[1]) return { number: Number(a.number), bytes: Number(a.bytes) || 0, id: decode(s[2].trim()).replace(/^<|>$/g, '') } }) .filter((s) => Number.isInteger(s.number) && s.number > 0 && s.id) .sort((x, y) => x.number - y.number) if (segments.length === 0) continue files.push({ poster: attrs.poster ?? '', date: Number(attrs.date) || 0, subject: attrs.subject ?? '', groups, segments, }) } return { meta, files } }