Files
Daan MeijerandClaude Fable 5.1 3612bf3e43
Test and deploy / test (push) Successful in 2m49s
Test and deploy / deploy (push) Successful in 3m24s
Readable names: CamelCase product names and words with numbers stay readable
The production dry run of releases:readable flagged 5.37M of 19.8M rows,
and its first sample held real names: "CrossStitcher - Issue 440",
"SchiffsModell", "FastFilms 2.04", "BestCrypt NP v4.0", "SmithsWork",
"Laura15Juli2026". Two CamelCase words with few vowels have four runs and
no lone letter: a scramble now needs five runs, a lone letter or an inner
capital run besides its few vowels; the consonant-run clause is gone
(it counted y); a token whose letter runs are all words or acronyms with
numbers between them is neutral. On a 49k-row production sample the
flagged share went from 27.4% to 26.4% with the letters-only flags all
scrambles; the evaluation sets are unchanged (LOG.md (129)).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-02 13:22:54 +01:00

149 lines
6.1 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-xz]/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. Two CamelCase words with few vowels
// (FastFilms, BestCrypt, SchiffsModell) have four runs and no lone letter; a scramble has more runs, a lone
// letter or an inner capital run.
if (
t.length >= 6 &&
s.caseSwitches >= 2 &&
s.vowels * 4 < t.length &&
(s.runs >= 5 || s.lone >= 1 || s.innerUpper)
)
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
}
// Words with numbers between them (Laura15Juli2026, Back4WinXP): every letter run a word or an acronym.
if (
(t.match(/[A-Za-z]+/g) ?? []).every(
(w) =>
/^[A-Z]{2,}$/.test(w) ||
(/^[A-Z]?[a-z]{2,}$/.test(w) && (w.match(/[aeiouy]/g) ?? []).length * 5 >= w.length)
)
)
return 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
}