- "Ballers.2014.S01E01.HDTV.x264-KILLERS[ettv] 15_07_2015.rar", "Computer Bild - Ausgabe 152015 - 15_07_2015.rar": the cut after "] " or " - " left "15_07_2015.rar" for every item of a Razor1911 dump (1,335 files for 12); a date alone after the cut means the whole text is the name. - "MDG005b - (01/90) "IMG_2307.JPG"": a counter in parentheses makes no post key, so every "IMG" set of the poster was one (609 for 90); an image under a post token is keyed by it too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
142 lines
7.0 KiB
TypeScript
142 lines
7.0 KiB
TypeScript
export interface ParsedSubject {
|
|
/** Filename from the quoted part of the subject. */
|
|
name: string
|
|
/** Segment number `k` from `(k/N)`. */
|
|
segment: number
|
|
/** Segment count `N` from `(k/N)`. */
|
|
totalSegments: number
|
|
/** File counter `[i/M]`, when present. */
|
|
fileIndex?: number
|
|
fileTotal?: number
|
|
/**
|
|
* The subject with the segment counter removed. Identical for every segment
|
|
* of one uploaded file, so it is part of the file identity (see fileKey).
|
|
*/
|
|
base: string
|
|
}
|
|
|
|
const SEGMENT_COUNTER = /\((\d+)\/(\d+)\)/g
|
|
/** A file name's extension: a dot and 2 to 4 letters or digits at the end. */
|
|
const EXTENSION = /[^.\s]\.[A-Za-z0-9]{2,4}$/
|
|
|
|
/**
|
|
* The file name of a subject without a quoted one, from the text before its
|
|
* segment counter (LOG.md 2026-10-11 (5): most "obfuscated" subjects of older
|
|
* groups): with one quote only, the name before it (after a "[", else after
|
|
* the last space: `[keira_s04_0037_l.jpg" yEnc] (2/2)`); without quotes, the
|
|
* text when it ends in a file name (after a "] " or " - ": `DVDFab
|
|
* 8.2.0.5.rar (006/332)`, `[33/117] 674a37548b09.dat (1/1)`), else its
|
|
* first word that is one (`Emily_18_-_Bed.part01.rar Emily 18 bed video
|
|
* (01/24)`). Null when no file name is found: the subject stays obfuscated.
|
|
* Words are split on spaces only (both implementations alike).
|
|
*/
|
|
/**
|
|
* The file name of a subject whose quoted text has no extension ("My
|
|
* \"Official\" remaining set's of Inna … 025.jpg", "\"Dr. Lust\" - … - Ed_DRL_VDR_LM.part09.rar"):
|
|
* a name with one elsewhere, else null (random quoted names keep theirs).
|
|
*/
|
|
export function nameBesideQuotes(quoted: string, beforeCounter: string): string | null {
|
|
if (EXTENSION.test(quoted)) return null
|
|
// A stray quote took the last pair ("(01/26) "01 Time Machine.mp3" "Silicon Dream_BY_WaRLoRD yEnc",
|
|
// "… "x.nfo" " yEnc", "… "x.nzb" yEnc""): the last quoted part that is a file name.
|
|
const named = [...beforeCounter.matchAll(/"([^"]*)"/g)].map((m) => m[1]).filter((q) => EXTENSION.test(q))
|
|
if (named.length > 0) return named.at(-1)!
|
|
const name = unquotedName(beforeCounter.replace(/"[^"]*"/g, ' '))
|
|
return name && EXTENSION.test(name) ? name : null
|
|
}
|
|
|
|
/** A file name that is a date alone ("15_07_2015.rar"). */
|
|
const DATE_ONLY = /^(?:\d{1,2}[-_.]\d{1,2}[-_.](?:19|20)\d{2}|(?:19|20)\d{2}[-_.]\d{1,2}[-_.]\d{1,2})\.[A-Za-z0-9]{2,4}$/
|
|
const IMAGE_NAME = /\.(jpe?g|png|gif|bmp|webp|tiff?)$/i
|
|
/** A host ("www.libranews.nl", "www.x2l.nl"): its ".nl" is no extension. */
|
|
const HOST = /www\./i
|
|
|
|
export function unquotedName(beforeCounter: string): string | null {
|
|
let text = beforeCounter.trimEnd()
|
|
if (/yenc$/i.test(text)) text = text.slice(0, -4).trimEnd()
|
|
const quote = text.indexOf('"')
|
|
if (quote !== -1) {
|
|
if (text.indexOf('"', quote + 1) !== -1) return null
|
|
const before = text.slice(0, quote)
|
|
const bracket = before.lastIndexOf('[')
|
|
if (bracket !== -1) {
|
|
// A file counter that lost its closing bracket ("[03/21 Name.part01.rar") isn't part of the name.
|
|
const inBracket = before.slice(bracket + 1).trim().replace(/^\d+\/\d+ +/, '')
|
|
if (EXTENSION.test(inBracket)) return inBracket
|
|
}
|
|
const word = before.slice(before.lastIndexOf(' ') + 1)
|
|
return EXTENSION.test(word) ? word : null
|
|
}
|
|
// A single-quoted name at the end ("xlady post You, Me And Dupree www.x2l.nl [001/106] 'You, Me And Dupree.nfo'").
|
|
const single = /'([^']*[^.\s']\.[A-Za-z0-9]{2,4})'$/.exec(text)
|
|
if (single) return single[1]
|
|
if (EXTENSION.test(text)) {
|
|
const bracket = text.lastIndexOf('] ')
|
|
// A track or part number ("Album - CD2 - 07 - Artist - Title.mp3"): the name starts there, so the rest
|
|
// is the album, the same for every track (LOG.md 2026-10-11 (10)); else after the last " - ".
|
|
const track = / - \d{1,3} - /.exec(text)
|
|
const dash = track ? track.index : text.lastIndexOf(' - ')
|
|
// Or after an arrow ("… -> [0001 of 0076] -> Exclusive-tgirls_000000-lg.jpg").
|
|
const arrow = text.lastIndexOf('-> ')
|
|
const cut = Math.max(bracket === -1 ? 0 : bracket + 2, dash === -1 ? 0 : dash + 3, arrow === -1 ? 0 : arrow + 3)
|
|
// A date alone after the cut ("Ballers.2014.S01E01.HDTV.x264-KILLERS[ettv] 15_07_2015.rar", "Computer Bild - Ausgabe
|
|
// 152015 - 15_07_2015.rar": a dump's every item was "15_07_2015.rar"; LOG.md 2026-10-13 (42)): the whole text is the name.
|
|
const name = DATE_ONLY.test(text.slice(cut).trim()) ? text.trim() : text.slice(cut).trim()
|
|
// An image after numbered text without a separator: its last word ("<abm> … AB: part2 0573-1002 043.jpg"; the whole
|
|
// subject was the name, so each image a release). Not an index alone ("Teen Tease Self Shooter sad1 (10).jpg").
|
|
if (cut === 0 && IMAGE_NAME.test(name) && name.includes(' ')) {
|
|
const space = name.lastIndexOf(' ')
|
|
const last = name.slice(space + 1)
|
|
if (/\d$/.test(name.slice(0, space).trimEnd()) && !last.startsWith('(')) return last
|
|
}
|
|
// Ending in a host is no file name: the one in brackets or a word follows.
|
|
if (!HOST.test(name)) return name && EXTENSION.test(name) ? name : null
|
|
}
|
|
// A name in brackets ("[01/77][LIBRATEAM_M2219009.par2] … www.libranews.nl", "-^-[Mnozil Brass - Seven.nzb]-^-").
|
|
const bracketed = [...text.matchAll(/\[([^[\]]*[^.\s[\]]\.[A-Za-z0-9]{2,4})\]/g)].at(-1)
|
|
if (bracketed) return bracketed[1]
|
|
for (const word of text.split(' ')) if (EXTENSION.test(word) && !HOST.test(word)) return word
|
|
return null
|
|
}
|
|
const FILE_COUNTER = /\[(\d+)\/(\d+)\]/
|
|
|
|
/**
|
|
* Parses a binary post subject such as
|
|
* `[07/45] - "Some.Release.part06.rar" yEnc (12/137) 51315352`.
|
|
*
|
|
* Returns null for subjects without a quoted filename and segment counter
|
|
* (obfuscated posts), which need their yEnc header read from the body instead.
|
|
*/
|
|
export function parseSubject(subject: string): ParsedSubject | null {
|
|
const nameEnd = subject.lastIndexOf('"')
|
|
const nameStart = nameEnd > 0 ? subject.lastIndexOf('"', nameEnd - 1) : -1
|
|
|
|
// The segment counter is the last "(k/N)" after the filename (anywhere without a quote); a file size may follow it.
|
|
let counter: RegExpExecArray | undefined
|
|
for (const match of subject.matchAll(SEGMENT_COUNTER)) {
|
|
if (match.index > nameEnd) counter = match
|
|
}
|
|
if (!counter) return null
|
|
const quoted = nameStart !== -1 ? subject.slice(nameStart + 1, nameEnd) : null
|
|
const name = quoted !== null ? (nameBesideQuotes(quoted, subject.slice(0, counter.index)) ?? quoted) : unquotedName(subject.slice(0, counter.index))
|
|
if (!name) return null
|
|
|
|
const segment = Number.parseInt(counter[1], 10)
|
|
const totalSegments = Number.parseInt(counter[2], 10)
|
|
if (totalSegments < 1 || segment < 1 || segment > totalSegments) return null
|
|
|
|
const base = (subject.slice(0, counter.index) + subject.slice(counter.index + counter[0].length))
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
|
|
const fileCounter = FILE_COUNTER.exec(subject.slice(0, nameStart !== -1 ? nameStart : counter.index))
|
|
return {
|
|
name,
|
|
segment,
|
|
totalSegments,
|
|
fileIndex: fileCounter ? Number.parseInt(fileCounter[1], 10) : undefined,
|
|
fileTotal: fileCounter ? Number.parseInt(fileCounter[2], 10) : undefined,
|
|
base,
|
|
}
|
|
}
|