Files

85 lines
3.4 KiB
TypeScript

import { inflateRawSync } from 'node:zlib'
export interface SpotXml {
title: string
category: number | null
subcats: string[]
size: number
website: string
/** Message ids (without <>) of the compressed NZB in alt.binaries.ftd. */
nzbSegments: string[]
created: number | null
}
const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }
function text(value: string | undefined): string {
if (value === undefined) return ''
const cdata = /^\s*<!\[CDATA\[([\s\S]*)\]\]>\s*$/.exec(value)
if (cdata) return cdata[1]
return value.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
})
}
const element = (xml: string, name: string) => new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)</${name}>`, 'i').exec(xml)?.[1]
/** The spot XML from the X-XML header lines (a tolerant scanner, as spots are often invalid XML). */
export function parseSpotXml(xml: string): SpotXml | null {
const posting = element(xml, 'Posting')
if (!posting) return null
const categoryXml = element(posting, 'Category') ?? ''
const key = Number.parseInt(element(posting, 'Key') ?? '', 10)
const rawCategory = Number.parseInt(categoryXml.replace(/<[\s\S]*$/, ''), 10)
const nzb = element(posting, 'NZB') ?? ''
return {
title: text(element(posting, 'Title')).trim(),
// Newer keys number categories from 1.
category: Number.isNaN(rawCategory) ? null : key !== 1 ? rawCategory - 1 : rawCategory,
subcats: [...categoryXml.matchAll(/<(?:Sub|SubCat)>([^<]*)<\/(?:Sub|SubCat)>/gi)].map((m) => m[1].trim()),
size: Number.parseInt(element(posting, 'Size') ?? '', 10) || 0,
website: text(element(posting, 'Website')).trim(),
nzbSegments: [...nzb.matchAll(/<Segment>([^<>]*)<\/Segment>/gi)].map((m) => m[1].trim()).filter(Boolean),
created: Number.parseInt(element(posting, 'Created') ?? '', 10) || null,
}
}
/** Header lines of a raw HEAD response; X-XML (split over many lines) joined. */
export function parseSpotArticleHeaders(raw: string): { headers: Record<string, string>; xml: string } {
const headers: Record<string, string> = {}
let xml = ''
for (const line of raw.split(/\r?\n/)) {
const colon = line.indexOf(':')
if (colon <= 0) continue
const name = line.slice(0, colon).toLowerCase()
const value = line.slice(colon + 1).replace(/^ /, '')
if (name === 'x-xml') xml += value
else headers[name] ??= value
}
return { headers, xml }
}
/**
* Spot binaries (the NZB, images): article bodies joined without line
* breaks, Spotnet's escapes undone (=A NUL, =B CR, =C LF, =D =), then raw
* deflate (PHP gzinflate).
*/
export function decodeSpotBinary(bodies: Buffer[], compressed = true): Buffer {
const joined = bodies
.map((body) =>
body
.toString('latin1')
.split('\r\n')
// NNTP dot-stuffing.
.map((line) => (line.startsWith('..') ? line.slice(1) : line))
.join('')
)
.join('')
const unescaped = Buffer.from(joined.replace(/=([ABCD])/g, (_, c: string) => ({ A: '\0', B: '\r', C: '\n', D: '=' })[c]!), 'latin1')
return compressed ? inflateRawSync(unescaped) : unescaped
}