Subjects without a quoted file name: the name from the rest of the subject
Most "obfuscated" subjects of older and smaller groups name their file
without quotes ("DVDFab 8.2.0.5.rar (006/332)", fta's "q3B9…part023.rar
(104/137)", "New_Super_Mario_Bros._[Keygen].exe (1/2)") or with one
quote ("[keira_s04_0037_l.jpg\" yEnc] (2/2)"): 100% of ath 2013's and
fta's, 94% of models' (LOG.md 2026-10-11 (5)). With a segment counter and
no quoted name, unquotedName takes the name before a lone quote (after a
"[", else after the last space), else the text when it ends in a file
name (after "] " or " - "), else its first word that is one; a file
counter stuck in a bracket is dropped. The release identity takes such a
name out of the subject as it would quotes (else every image of a set
was a release: 9,130 instead of 267 on the models fixture).
Both implementations; a fourth fixture (fta, 25k lines) and tests; check
identical on all four. Truly random subjects (ngPost hex = message-id,
random tokens) stay obfuscated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
18b9ffdca3
commit
90f30f445d
13 files changed
+174
-25
No files matched your search
+6
-6
@@ -1,4 +1,4 @@
|
||||
import { fileKey } from '../headers/index.js'
|
||||
import { fileKey, unquotedName } from '../headers/index.js'
|
||||
import { releaseIdentity, type ReleaseIdentity } from '../release/index.js'
|
||||
|
||||
/** A file's details, shared by all of its segments (one object per file, not per article). */
|
||||
@@ -112,8 +112,8 @@ export class SegmentScanner {
|
||||
/** A segment from an article's fields, or null when its subject is obfuscated. */
|
||||
segment(articleNumber: number, subject: string, from: string, date: string, messageId: string, bytes: string, xref: string, group: string): EdgeSegment | null {
|
||||
if (!subject || !messageId) return null
|
||||
// After the last quote; without one, anywhere (unquotedName finds the file name).
|
||||
const nameEnd = subject.lastIndexOf('"')
|
||||
if (nameEnd <= 0) return null
|
||||
const counter = findCounter(subject, nameEnd)
|
||||
if (!counter) return null
|
||||
const key = from + '\u0001' + subject.slice(0, counter.index) + '\u0000' + subject.slice(counter.index + counter.length)
|
||||
@@ -137,10 +137,10 @@ export class SegmentScanner {
|
||||
|
||||
/** A file's details from its first article seen (as parseSubject and segmentFromHeader). */
|
||||
private describe(subject: string, nameEnd: number, counter: Counter, from: string, xref: string, group: string): FileMeta | null {
|
||||
const nameStart = subject.lastIndexOf('"', nameEnd - 1)
|
||||
if (nameStart === -1) return null
|
||||
const name = own(subject.slice(nameStart + 1, nameEnd))
|
||||
if (!name || counter.total < 1) return null
|
||||
const nameStart = nameEnd > 0 ? subject.lastIndexOf('"', nameEnd - 1) : -1
|
||||
const found = nameStart !== -1 ? subject.slice(nameStart + 1, nameEnd) : unquotedName(subject.slice(0, counter.index))
|
||||
if (!found || counter.total < 1) return null
|
||||
const name = own(found)
|
||||
const base = own((subject.slice(0, counter.index) + subject.slice(counter.index + counter.length)).replace(/\s+/g, ' ').trim())
|
||||
const poster = own(from)
|
||||
return {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { parseSubject, type ParsedSubject } from './subject.js'
|
||||
export { parseSubject, unquotedName, type ParsedSubject } from './subject.js'
|
||||
export { fileKey, weakFileKey, counterFileKey, looksLikeFilename } from './file_key.js'
|
||||
export { segmentFromHeader, type IngestSegment, type OverviewHeader, type IdentityCache } from './segment.js'
|
||||
+44
-5
@@ -16,6 +16,46 @@ export interface ParsedSubject {
|
||||
}
|
||||
|
||||
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).
|
||||
*/
|
||||
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
|
||||
}
|
||||
if (EXTENSION.test(text)) {
|
||||
const bracket = text.lastIndexOf('] ')
|
||||
const dash = text.lastIndexOf(' - ')
|
||||
const cut = Math.max(bracket === -1 ? 0 : bracket + 2, dash === -1 ? 0 : dash + 3)
|
||||
const name = text.slice(cut).trim()
|
||||
return name && EXTENSION.test(name) ? name : null
|
||||
}
|
||||
for (const word of text.split(' ')) if (EXTENSION.test(word)) return word
|
||||
return null
|
||||
}
|
||||
const FILE_COUNTER = /\[(\d+)\/(\d+)\]/
|
||||
|
||||
/**
|
||||
@@ -28,16 +68,15 @@ const FILE_COUNTER = /\[(\d+)\/(\d+)\]/
|
||||
export function parseSubject(subject: string): ParsedSubject | null {
|
||||
const nameEnd = subject.lastIndexOf('"')
|
||||
const nameStart = nameEnd > 0 ? subject.lastIndexOf('"', nameEnd - 1) : -1
|
||||
if (nameStart === -1) return null
|
||||
const name = subject.slice(nameStart + 1, nameEnd)
|
||||
if (!name) return null
|
||||
|
||||
// The segment counter is the last "(k/N)" after the filename; a file size may follow it.
|
||||
// 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 name = nameStart !== -1 ? subject.slice(nameStart + 1, nameEnd) : unquotedName(subject.slice(0, counter.index))
|
||||
if (!name) return null
|
||||
|
||||
const segment = Number.parseInt(counter[1], 10)
|
||||
const totalSegments = Number.parseInt(counter[2], 10)
|
||||
@@ -47,7 +86,7 @@ export function parseSubject(subject: string): ParsedSubject | null {
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
const fileCounter = FILE_COUNTER.exec(subject.slice(0, nameStart))
|
||||
const fileCounter = FILE_COUNTER.exec(subject.slice(0, nameStart !== -1 ? nameStart : counter.index))
|
||||
return {
|
||||
name,
|
||||
segment,
|
||||
|
||||
@@ -197,8 +197,10 @@ export function releaseIdentity(input: ReleaseInput): ReleaseIdentity | null {
|
||||
return { key: hash('weak', stem), name: stem, source: 'weak-stem' }
|
||||
}
|
||||
|
||||
// Text outside the quotes that names the release, e.g. "Some.Release [01/45] - "x.rar"".
|
||||
const remainder = subjectRemainder(input.subject)
|
||||
// Text outside the quotes that names the release, e.g. "Some.Release [01/45] - "x.rar"". A name that isn't
|
||||
// quoted (unquotedName: "DVDFab 8.2.0.5.rar (006/332)", "[keira_s04_0037_l.jpg" yEnc] (2/2)") is taken out
|
||||
// as the quotes would have been: otherwise every file of a set would name a release of its own.
|
||||
const remainder = subjectRemainder(input.subject.includes(`"${input.name}"`) ? input.subject : input.subject.replace(input.name, ' ').replace(/"/g, ' '))
|
||||
// A document or comic is a release of its own (collections post hundreds under one subject).
|
||||
if (DOCUMENT.test(input.name)) return { key: hash('stem', input.poster, stem), name: stem.replace(DOCUMENT, '') || stem, fileTotal, source: 'stem' }
|
||||
const image = IMAGE.test(input.name)
|
||||
|
||||
Reference in new issue
Block a user