Readable names: a token-by-token classifier for the readable flag
Test and deploy / test (push) Successful in 3m36s
Test and deploy / deploy (push) Skipped

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>
This commit is contained in:
Daan MeijerandClaude Fable 5.1 committed 2026-11-02 10:01:31 +01:00
1 parent 2643266559
commit 81a5dce45a
10 files changed
+497 -12

No files matched your search

+188
View File
@@ -1065,6 +1065,169 @@ pub fn is_random_file(name: &str) -> bool {
looks_random(&file_stem(name))
}
/// Readable or not (lib/release/readable.ts): whether a name says what a release is, read token by token, so
/// separators, spaces and random "extensions" don't hide a scramble that looks_random's single-token shapes miss
/// (LOG.md (129)). The readable flag; the drop rules keep looks_random, which is narrower.
/// Tokens that don't make a name readable on their own: packaging, volume numbers and the posting tools' fixed words.
static NOISE_TOKEN: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)^(addon|samples?|proof|subs?|nfo|part[0-9]+|vol[0-9]+|description|repost|rep|extras?|bonus|cd[0-9]*|dvd[0-9]*|dis[ck][0-9]*|par2|rar|zip|7z|mkv|mp4|avi|vol)$"));
/// Mixed tokens that are codes, not scrambles: S02E10, 1080p, 10bit, x264, Win10x64, BDRip720p, 3DMark03, v2, 5th.
static CODE_TOKEN: LazyLock<Regex> = LazyLock::new(|| re(r"^([sS][0-9]{1,2}[eE][0-9]{1,3}|[xh]26[45]|[A-Za-z]+[0-9]+([a-z]{1,3}|x(64|86))?|[0-9]+[A-Za-z]{1,3}|[0-9]+[A-Za-z]+[0-9]+)$"));
/// The NZBSPLIT tools' hash between their markers is no part of the name.
static NZBSPLIT_HASH: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)__NZBS[P]LIT__[0-9a-f]{32}__NZBS[P]LIT__")); // "SP" is re()'s space class
/// An inner run of 2+ capitals with lowercase on both sides ("tBWTuz", "YDoaROv"; not "3DMark03").
static INNER_UPPER: LazyLock<Regex> = LazyLock::new(|| re(r"[a-z][A-Z]{2,}[a-z]"));
/// Scene tags write "i" for "I" between capitals (XCiTE, PRESCRiPTiON): read it as a capital there.
fn scene_case(t: &[u8]) -> Vec<u8> {
let mut out = t.to_vec();
for i in 1..t.len().saturating_sub(1) {
if t[i] == b'i' && t[i - 1].is_ascii_uppercase() && t[i + 1].is_ascii_uppercase() {
out[i] = b'I';
}
}
out
}
/// The signals of a scrambled token (readable.ts signals()).
struct TokenSignals {
/// Letter/digit and lower-to-upper switches.
switches: usize,
/// Case switches in both directions.
case_switches: usize,
/// Lone lowercase letters (not "i") between capitals ("NtpUgCmS").
lone: usize,
inner_upper: bool,
max_consonants: usize,
/// Vowels, a lowercase y too ("McCartney").
vowels: usize,
/// Runs of one character class.
runs: usize,
letters: usize,
/// The longest lowercase run.
max_lower: usize,
}
fn token_signals(t: &[u8]) -> TokenSignals {
let is_vowel = |c: u8| matches!(c.to_ascii_lowercase(), b'a' | b'e' | b'i' | b'o' | b'u') || c == b'y';
let class = |c: u8| if c.is_ascii_digit() { 0 } else if c.is_ascii_lowercase() { 1 } else { 2 };
let mut s = TokenSignals { switches: 0, case_switches: 0, lone: 0, inner_upper: false, max_consonants: 0, vowels: 0, runs: 0, letters: 0, max_lower: 0 };
let mut consonants = 0;
let mut lower = 0;
for i in 0..t.len() {
let c = t[i];
if c.is_ascii_alphabetic() && !is_vowel(c) {
consonants += 1;
s.max_consonants = s.max_consonants.max(consonants);
} else {
consonants = 0;
}
if is_vowel(c) {
s.vowels += 1;
}
if c.is_ascii_alphabetic() {
s.letters += 1;
}
if c.is_ascii_lowercase() {
lower += 1;
s.max_lower = s.max_lower.max(lower);
} else {
lower = 0;
}
if i == 0 || class(t[i - 1]) != class(c) {
s.runs += 1;
}
if i == 0 {
continue;
}
let a = t[i - 1];
if a.is_ascii_digit() != c.is_ascii_digit() {
s.switches += 1;
} else if a.is_ascii_lowercase() && c.is_ascii_uppercase() {
s.switches += 1;
}
if (a.is_ascii_lowercase() && c.is_ascii_uppercase()) || (a.is_ascii_uppercase() && c.is_ascii_lowercase()) {
s.case_switches += 1;
}
if i < t.len() - 1 && c.is_ascii_lowercase() && c != b'i' && a.is_ascii_uppercase() && t[i + 1].is_ascii_uppercase() {
s.lone += 1;
}
}
s.inner_upper = INNER_UPPER.is_match(t);
s
}
#[derive(PartialEq, Eq, Debug, Clone, Copy)]
pub enum TokenKind {
Word,
Random,
Neutral,
}
/// 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 readable.ts.
pub fn token_kind(t: &[u8]) -> TokenKind {
let n = t.len();
if t.iter().all(u8::is_ascii_digit) || NOISE_TOKEN.is_match(t) || CODE_TOKEN.is_match(t) {
return TokenKind::Neutral;
}
if t.iter().all(u8::is_ascii_alphabetic) {
let s = token_signals(&scene_case(t));
let capitalized = t[0].is_ascii_uppercase() && t[1..].iter().all(u8::is_ascii_lowercase);
if s.case_switches == 0 || (s.case_switches == 1 && capitalized) {
// A base32 consonant soup ("mrrtmzrzmi4dgmrzmyytqmlbztczbx" without its digit) is no word.
if n >= 10 && s.vowels * 6 < n {
return TokenKind::Random;
}
return if n >= 3 { TokenKind::Word } else { TokenKind::Neutral };
}
// Mixed case beyond Capitalization: CamelCase (neutral) or a scramble, which has few vowels and short runs.
if n >= 6 && s.case_switches >= 2 && (s.inner_upper || (s.vowels * 4 < n && (s.runs >= 4 || s.lone >= 1))) {
return TokenKind::Random;
}
return TokenKind::Neutral;
}
if n >= 8 && t.iter().all(u8::is_ascii_hexdigit) && t.iter().filter(|c| c.is_ascii_digit()).count() >= 2 {
return TokenKind::Random;
}
if t.iter().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) {
// 51294P1CUKR2R35X; COD4MW2REMASTERED (4 switches) and BEATLES1964REMASTER (2) stay.
return if n >= 12 && token_signals(t).switches >= 6 { TokenKind::Random } else { TokenKind::Neutral };
}
let s = token_signals(&scene_case(t));
if n >= 5 && s.switches >= 3 {
return TokenKind::Random;
}
if n >= 6 && s.switches >= 2 && (s.lone >= 1 || s.inner_upper || s.max_consonants >= 4) {
return TokenKind::Random;
}
// Few vowels and no lowercase run of 3+ ("Jk5Pud", "RKi9BCg"; not "HDR10Plus").
if n >= 6 && s.switches >= 2 && s.vowels * 4 < s.letters && s.max_lower <= 2 {
return TokenKind::Random;
}
TokenKind::Neutral
}
/// A file or release name no one can read (readable.ts isUnreadableName): looks_random'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").
pub fn is_unreadable_name(name: &str) -> bool {
let stem = file_stem(name);
let stem = NZBSPLIT_HASH.replace(stem.as_bytes(), &b" "[..]);
if looks_random(&String::from_utf8_lossy(&stem)) {
return true;
}
let mut words = 0;
let mut random = 0;
for t in stem.split(|c| !c.is_ascii_alphanumeric()).filter(|t| !t.is_empty()) {
match token_kind(t) {
TokenKind::Word => words += t.len(),
TokenKind::Random => random += t.len(),
TokenKind::Neutral => {}
}
}
random > 0 && random > words
}
/// looksScrambled: at least half the letters and digits of a file's stem in tokens of 5 or more that switch case
/// (lower to upper) or between letters and digits 3 times or more ("nYDeXeF49dieZ1J7flK-JFzZ3j.ZEDsr", "bslXlwgPiqZadhRa").
/// The names a random-subject-per-article upload gives each article that looksRandom misses for their separators.
@@ -1622,6 +1785,31 @@ mod tests {
assert_eq!(id(r#"[2/6] - "x.jpg" yEnc"#, "x.jpg").key, id(r#"[1/6] - "x.part01.rar" yEnc"#, "x.part01.rar").key);
}
#[test]
fn unreadable_names() {
use super::is_unreadable_name;
// Scrambles with separators, spaces, a random "extension", a packaging word, a hex with an addon (LOG.md (129)).
for n in [
"Zx5AjNaK-mh7CFg6Hf8uu.part01.rar", "yWjfnT2ujG4wJj3fG46o0FLzsgkvhmrTsAfW2.E0WsOU", "PsNdnYiugfZ.rar", "phfplC9y07U2yrj", "n5d1ueIf34X",
"tJqr1tq7xK", "Description - Lt5nHjlo3RiuBb3C.par2", "bd5b4a59f3aa87114dcf75c0-addon.rar", "mrrtmzrzmi4dgmrzmyytqmlbztczbx",
"AA-NaR16302830434zxP32108309f3Ab", "_QBs01_MqZYnYwM_iAyRquhc-", "sgkra vU7u2yuw9LDLCjDu04pOez", "Jk5Pud.zQdNj", "7uKsD_NRgT-G_Uc_p8",
"NtpUgCmS_Xa1B2Pk", "{E11BF8E9-E2C9-4DAE-A1B2-8027BC465F3F}", "IkYovjrtZrItjQ.mkv", "ff75ce3f16c746c18b2d78cf.6c52f",
] {
assert!(is_unreadable_name(n), "{n}");
}
// Real names: scene tags, CamelCase, codes, leet, an acronym, a hash beside words.
for n in [
"Dark.Matter.2024.S02E10.No.Quiet.Life.1080p.ATVP.WEB-DL.DDP5.1.Atmos.H.264-Kitsune", "Paul McCartney - Band on the Run",
"ShesSoWild - Ester Ana 1080p", "MyDadsHotGirlfriend.13.02.01.Luna.Star.XXX.1080p.x264-SEXORS", "cFosSpeed 11.0 Build 2420 x86 x64 incl Patch.rar",
"Rocketship-Hurt_Feelings-WEB-2026-PreMaxXxing", "Drum-and-BassDJ_Samurai-Sound_With_Fire_Artemis-(FQY026)-WEB-2006-PRESCRiPTiON",
"Kleverig-H4rd-N12uk3b18-luc16037-720p-smpwr", "Eclipse_IDE_2023-12_R_win32_x86_64.zip", "PartsHPGL 1.06c", "Go4Customer Fax Machine v1.2b",
"AdBeGone 1.2", "3DMark03 320", "COD4MW2REMASTERED", "Movie.2020.1080p-GRP [a3f9e2b1]", "EPL__NZBSPLIT__f1fdb433772a1a7771d6b73c875009ff__NZBSPLIT__EPL.2026.Liverpool.vs.Brighton.1080pEN50fps",
"iPhone", "Some.Show.S01E01.1080p.mkv",
] {
assert!(!is_unreadable_name(n), "{n}");
}
}
#[test]
fn random_names() {
assert!(looks_random("prvjr7hapd0i1ny14pz4"));