Searches with the readable filter on returned scrambles: with a random
4-letter query nearly every hit was one ("Zx5AjNaK-mh7CFg6Hf8uu",
"Description - Lt5nHjlo3RiuBb3C", "PsNdnYiugfZ", "bd5b...-addon"), and
15% of a 6,000-row sample of the catalog's readable names were. looksRandom
knows single-token shapes only; separators, spaces, a random "extension"
of any length and a packaging word hid the rest.
isUnreadableName (lib/release/readable.ts) and names::is_unreadable_name
read a name token by token: words (one case or Capitalized), scrambles
(letter/digit and case switches, lone letters between capitals, inner
capital runs, few vowels, hex) and neutral tokens (digits, codes like
S02E10 and 1080p, CamelCase, packaging words); a name is unreadable when
its scrambles outweigh its words. Scene tags' "i" for "I" and the
NZBSPLIT hash are read as such. On the evaluation sets: 554 of 559
random-search names caught, 0 of 630 real names flagged (LOG.md (129)).
The edge's readable flag and the prober's inner names use it; the drop
rules keep looksRandom, which is narrower. An unnamed release goes to the
pending store (decision f), so more of the obfuscated groups' releases
wait for a probe now. releases:readable clears the flag on the existing
catalog's rows by name (a dry run by default).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
137 lines
5.6 KiB
TypeScript
137 lines
5.6 KiB
TypeScript
import { fileStem } from './identity.js'
|
|
import { looksRandom } from './random.js'
|
|
|
|
/**
|
|
* Readable or not: whether a name says what a release is (the catalog's readable flag, the prober's inner names).
|
|
* looksRandom (random.ts) knows the single-token shapes; this reads a name token by token, so separators, spaces
|
|
* and random "extensions" don't hide a scramble (LOG.md (129)).
|
|
*/
|
|
/**
|
|
* Tokens that don't make a name readable on their own: packaging, volume numbers and the posting tools' fixed
|
|
* words ("Description - Lt5nHjlo3RiuBb3C", "bd5b4a59f3aa87114dcf75c0-addon").
|
|
*/
|
|
const NOISE =
|
|
/^(addon|samples?|proof|subs?|nfo|part\d+|vol\d+|description|repost|rep|extras?|bonus|cd\d*|dvd\d*|dis[ck]\d*|par2|rar|zip|7z|mkv|mp4|avi|vol)$/i
|
|
/** Mixed tokens that are codes, not scrambles: S02E10, 1080p, 10bit, x264, Win10x64, BDRip720p, 3DMark03, v2, 5th. */
|
|
const CODE =
|
|
/^([sS]\d{1,2}[eE]\d{1,3}|[xh]26[45]|[A-Za-z]+\d+([a-z]{1,3}|x(64|86))?|\d+[A-Za-z]{1,3}|\d+[A-Za-z]+\d+)$/
|
|
/** The NZBSPLIT tools' hash between their markers is no part of the name. */
|
|
const NZBSPLIT = /__NZBSPLIT__[0-9a-f]{32}__NZBSPLIT__/i
|
|
|
|
/** Scene tags write "i" for "I" between capitals (XCiTE, PRESCRiPTiON): read it as a capital there. */
|
|
function sceneCase(t: string): string {
|
|
let out = ''
|
|
for (let i = 0; i < t.length; i++) {
|
|
out +=
|
|
t[i] === 'i' && i > 0 && i < t.length - 1 && /[A-Z]/.test(t[i - 1]) && /[A-Z]/.test(t[i + 1])
|
|
? 'I'
|
|
: t[i]
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* The signals of a scrambled token: letter/digit and lower-to-upper switches; case switches in both directions; lone
|
|
* lowercase letters between capitals ("NtpUgCmS"); an inner run of 2+ capitals with lowercase on both sides
|
|
* ("tBWTuz", "YDoaROv"); the longest consonant run; vowels (a lowercase y too: "McCartney"); runs of one character class.
|
|
*/
|
|
function signals(t: string) {
|
|
let switches = 0
|
|
let caseSwitches = 0
|
|
let lone = 0
|
|
let consonants = 0
|
|
let maxConsonants = 0
|
|
for (let i = 0; i < t.length; i++) {
|
|
const c = t[i]
|
|
if (/[b-df-hj-np-tv-z]/i.test(c)) maxConsonants = Math.max(maxConsonants, ++consonants)
|
|
else consonants = 0
|
|
if (i === 0) continue
|
|
const a = t[i - 1]
|
|
if (/\d/.test(a) !== /\d/.test(c)) switches++
|
|
else if (/[a-z]/.test(a) && /[A-Z]/.test(c)) switches++
|
|
if ((/[a-z]/.test(a) && /[A-Z]/.test(c)) || (/[A-Z]/.test(a) && /[a-z]/.test(c))) caseSwitches++
|
|
if (
|
|
i < t.length - 1 &&
|
|
/[a-z]/.test(c) &&
|
|
c !== 'i' &&
|
|
/[A-Z]/.test(a) &&
|
|
/[A-Z]/.test(t[i + 1])
|
|
)
|
|
lone++
|
|
}
|
|
const innerUpper = /[a-z][A-Z]{2,}[a-z]/.test(t)
|
|
const vowels = (t.match(/[aeiouAEIOU]|y/g) ?? []).length
|
|
const runs = (t.match(/[a-z]+|[A-Z]+|\d+/g) ?? []).length
|
|
const letters = (t.match(/[A-Za-z]/g) ?? []).length
|
|
const maxLower = Math.max(0, ...(t.match(/[a-z]+/g) ?? []).map((r) => r.length))
|
|
return {
|
|
switches,
|
|
caseSwitches,
|
|
lone,
|
|
innerUpper,
|
|
maxConsonants,
|
|
vowels,
|
|
runs,
|
|
letters,
|
|
maxLower,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* What one alphanumeric token of a name is: a word (letters only, in one case or Capitalized, 3+ long, not a packaging
|
|
* word), a scramble, or neither (digits, codes, CamelCase, short tokens). Kept in step with names.rs token_kind.
|
|
*/
|
|
export function tokenKind(t: string): 'word' | 'random' | null {
|
|
if (/^\d+$/.test(t) || NOISE.test(t) || CODE.test(t)) return null
|
|
if (/^[A-Za-z]+$/.test(t)) {
|
|
const s = signals(sceneCase(t))
|
|
if (s.caseSwitches === 0 || (s.caseSwitches === 1 && /^[A-Z][a-z]+$/.test(t))) {
|
|
// A base32 consonant soup ("mrrtmzrzmi4dgmrzmyytqmlbztczbx" without its digit) is no word.
|
|
if (t.length >= 10 && s.vowels * 6 < t.length) return 'random'
|
|
return t.length >= 3 ? 'word' : null
|
|
}
|
|
// Mixed case beyond Capitalization: CamelCase (neutral) or a scramble, which has few vowels and short runs.
|
|
if (
|
|
t.length >= 6 &&
|
|
s.caseSwitches >= 2 &&
|
|
(s.innerUpper || (s.vowels * 4 < t.length && (s.runs >= 4 || s.lone >= 1)))
|
|
)
|
|
return 'random'
|
|
return null
|
|
}
|
|
if (t.length >= 8 && /^[0-9a-f]+$/i.test(t) && (t.match(/\d/g)?.length ?? 0) >= 2) return 'random'
|
|
if (/^[A-Z0-9]+$/.test(t)) {
|
|
// 51294P1CUKR2R35X; COD4MW2REMASTERED (4 switches) and BEATLES1964REMASTER (2) stay.
|
|
return t.length >= 12 && signals(t).switches >= 6 ? 'random' : null
|
|
}
|
|
const s = signals(sceneCase(t))
|
|
if (t.length >= 5 && s.switches >= 3) return 'random'
|
|
if (t.length >= 6 && s.switches >= 2 && (s.lone >= 1 || s.innerUpper || s.maxConsonants >= 4))
|
|
return 'random'
|
|
// Few vowels and no lowercase run of 3+ ("Jk5Pud", "RKi9BCg"; not "HDR10Plus").
|
|
if (t.length >= 6 && s.switches >= 2 && s.vowels * 4 < s.letters && s.maxLower <= 2)
|
|
return 'random'
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* A file or release name no one can read: looksRandom's shapes, or more scrambled tokens than words by letter count
|
|
* ("Zx5AjNaK-mh7CFg6Hf8uu", "Description - Lt5nHjlo3RiuBb3C", "PsNdnYiugfZ"; not "Paul McCartney - Band on the Run",
|
|
* "ShesSoWild - Ester Ana 1080p"). Separators and a random "extension" of any length don't hide a scramble, which
|
|
* looksRandom's single-token shapes miss (15% of the readable catalog on 2026-11-02; LOG.md (129)). The readable
|
|
* flag; the drop rules keep looksRandom, which is narrower.
|
|
*/
|
|
export function isUnreadableName(name: string): boolean {
|
|
const stem = fileStem(name).replace(NZBSPLIT, ' ')
|
|
if (looksRandom(stem)) return true
|
|
let words = 0
|
|
let random = 0
|
|
for (const t of stem.split(/[^A-Za-z0-9]+/)) {
|
|
if (!t) continue
|
|
const kind = tokenKind(t)
|
|
if (kind === 'word') words += t.length
|
|
else if (kind === 'random') random += t.length
|
|
}
|
|
return random > 0 && random > words
|
|
}
|