passwordFromSpot finds 30 more passwords in the stored descriptions (480 → 510): words between keyword and separator, trailing "!!", the value on the next line, "wachtwoord is x", "… wachtwoord. Deze is: x", "WW x" as a whole line. Listed spots were read from their overview only, so the NZB's own password and the description were unknown for 2.27M of them. SpotService.readListing reads the spot's article and NZB without importing files: details, the NZB password (over a spot-text one), the spot-text password, placeholder NZBs. worker:spots does a batch when idle; spot:postprocess works through the history (nzb_read_at). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
84 lines
3.9 KiB
TypeScript
84 lines
3.9 KiB
TypeScript
/**
|
|
* Archive passwords that spotters put in the title or description instead of
|
|
* the NZB's `<meta type="password">`:
|
|
* `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\]|<br\s*\/?>/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
|
|
}
|