/** * Archive passwords that spotters put in the title or description instead of * the NZB's ``: * `Title{{secret}}` (the newznab/SABnzbd convention), * `Wachtwoord: KKKK2`, `[b]Password[/b]: x`, `WW: p3IU5Rz9R`, `ww= 0112358`, * a few words between keyword and separator (`Het wachtwoord voor de nzb is: dw0067`, * `ww in nzb: X`, `Wachtwoord inbegrepen: wysiwyg`), * the value on the next line (`WW:` / `tijdloos`), * `wachtwoord is arn2`, `… voorzien van een wachtwoord. Deze is: X`, * `WW FC0401` (abbreviation without separator: with a digit in it, or as the whole line). * The value must be the rest of its line and a single token, so sentences * such as "Wachtwoord: deze download is zonder wachtwoord geplaatst" or * "met een password beschermd" don't count (LOG.md 2026-10-08 (10)). */ const KEYWORD = String.raw`(?:wachtwoord|wachtw|password|passwort|passwd|pwd|pass|ww|pw)(?![a-z])` const WITH_SEPARATOR = new RegExp(String.raw`(?:^|[^a-z0-9])${KEYWORD}(?:\s+[a-zà-ÿ]+){0,3}?\s*(?:is\s*)?[:=]\s*(.*)$`, 'i') const IS_VALUE = new RegExp(String.raw`(?:^|[^a-z0-9])${KEYWORD}\s+is\s+(\S+)\s*$`, 'i') /** "Deze is voorzien van een wachtwoord. Deze is: X": the value in the next sentence. */ const NEXT_SENTENCE = /(?:^|[.!?]\s*)(?:deze|dit|het|die|this|it)\s+is\s*[:=]\s*(\S+)\s*$/i const MENTIONS = new RegExp(String.raw`(?:^|[^a-z0-9])${KEYWORD}`, 'i') const ABBREVIATION = /(?:^|[^a-z0-9])(?:ww|pw)\s+(\S+)\s*$/i /** "WW carrie" as a line of its own; "WW II" is the war. */ const WHOLE_LINE_ABBREVIATION = /^(?:ww|pw)\s+(\S+)$/i const NOT_PASSWORD_TOKEN = /^(?:i{1,3}|iv|v|[12])$/i const NO_PASSWORD = /\b(zonder|geen|no|without|none|niet|kein|ohne)\b.{0,20}\b(wachtwoord|password|passwort|ww|pw)\b|\b(wachtwoord|password|passwort)\b.{0,20}\b(niet nodig|not needed|none|geen|nvt|n\/a)\b/i const NOT_A_VALUE = /^(?:-+|geen|none|no|nee|nvt|n\/a|niet|not|leeg|empty|zonder|without|onbekend|unknown|zie|see|in|staat|volgt|nzb|spot)$/i /** Lines of a spot description: UBB/HTML line breaks split, other tags dropped. */ function lines(description: string): string[] { return description .replace(/\[br\]|/gi, '\n') .replace(/\[\/?[a-z]+(?:=[^\]]*)?\]|<\/?[a-z][^>]*>/gi, '') .split(/\r?\n/) .map((line) => line.trim()) } function clean(value: string): string | null { const token = value .trim() // "Wachtwoord : yakowyke !!" .replace(/^[!*~#]+\s*|\s*[!*~#]+$/g, '') .replace(/^["'“‘«(<]+|["'”’»)>.,;!]+$/g, '') if (!token || /\s/.test(token) || token.length < 2 || token.length > 64 || NOT_A_VALUE.test(token)) return null return token } export function passwordFromSpot(title: string, description?: string | null): string | null { const braces = /\{\{([^{}]{1,64})\}\}/.exec(title) if (braces && clean(braces[1])) return clean(braces[1]) const all = lines(description ?? '') for (const [i, line] of all.entries()) { if (!line || NO_PASSWORD.test(line)) continue const separated = WITH_SEPARATOR.exec(line) if (separated) { const value = separated[1].trim() ? clean(separated[1]) : nextLineValue(all, i) if (value) return value continue } const is = IS_VALUE.exec(line) if (is) { const value = clean(is[1]) if (value) return value } const sentence = MENTIONS.test(line) ? NEXT_SENTENCE.exec(line) : null if (sentence) { const value = clean(sentence[1]) if (value) return value } const whole = WHOLE_LINE_ABBREVIATION.exec(line) const abbreviated = whole ?? ABBREVIATION.exec(line) if (abbreviated && (whole ? !NOT_PASSWORD_TOKEN.test(abbreviated[1]) : /\d/.test(abbreviated[1]))) { const value = clean(abbreviated[1]) if (value) return value } } return null } /** `WW:` with the value alone on the next non-empty line. */ function nextLineValue(all: string[], i: number): string | null { const next = all.slice(i + 1).find((line) => line !== '') return next ? clean(next) : null }