Readable names: a token-by-token classifier for the readable flag
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:
1 parent
2643266559
commit
81a5dce45a
10 files changed
+497
-12
No files matched your search
@@ -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"));
|
||||
|
||||
Reference in new issue
Block a user