Single-archive rules: stub archives and nonsense pieces dropped at the edge
The judge gets each file's articles seen and bytes, and whether the window closed on the release. A settled release of one unsplit archive posted to 2+ groups is dropped as a stub archive when its articles are <= 256 KiB with the release >= 1 MB, or it is in 4+ groups, or by a webmail poster (those two under 100 MB); releases touching music, e-book, comics, tabs or fonts groups are exempt. A one-article archive of 100 kB-1 MB under a release-like, unscrambled name without spaces or an e-book format is dropped as a nonsense piece. Both switched by the rule set (dropStubArchives, dropNonsensePieces), mirrored in lib/rules, sampled like every drop (LOG.md (123)-(127)). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
2f1bbf2538
commit
e470e231d0
8 files changed
+397
-15
No files matched your search
@@ -62,7 +62,7 @@ export interface ProbeCandidate {
|
||||
|
||||
/** What a rule dropped in a batch (REBUILD.md, rules and recovery). */
|
||||
export interface RuleCount {
|
||||
rule: 'crosspost' | 'obfuscated' | 'poster' | 'executable' | 'garbage' | 'per-article' | 'incomplete'
|
||||
rule: 'crosspost' | 'obfuscated' | 'poster' | 'executable' | 'garbage' | 'per-article' | 'stub-archive' | 'nonsense-piece' | 'incomplete'
|
||||
key: string
|
||||
articles: number
|
||||
files: number
|
||||
@@ -246,9 +246,20 @@ export class RangeIndexer {
|
||||
this.completed = new Map()
|
||||
for (const [key, all] of byRelease) {
|
||||
uploadsOf(all).forEach((list, upload) => {
|
||||
// Settled as far as this indexer knows: it judges a release's files as each batch completes them (the Rust
|
||||
// window passes whether the window closed on the release and no block edge cut it).
|
||||
const verdict = this.rules.judge(
|
||||
key,
|
||||
list.map((f) => ({ name: f.segments[0].file.name, totalParts: f.segments[0].totalParts, groups: f.segments[0].file.groups, firstMessageId: f.segments[0].messageId, poster: f.segments[0].file.poster }))
|
||||
list.map((f) => ({
|
||||
name: f.segments[0].file.name,
|
||||
totalParts: f.segments[0].totalParts,
|
||||
groups: f.segments[0].file.groups,
|
||||
firstMessageId: f.segments[0].messageId,
|
||||
poster: f.segments[0].file.poster,
|
||||
segments: f.segments.length,
|
||||
bytes: f.segments.reduce((n, s) => n + s.bytes, 0),
|
||||
})),
|
||||
true
|
||||
)
|
||||
if (verdict.rule === null) {
|
||||
this.batch.releases.push(chunkOf(key, list, upload))
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
export { uploadTool, signatureGroup, signatureKey, explored } from './upload_tools.js'
|
||||
export { RuleSet, DEFAULT_RULES, isRandomFile, isScrambledFile, ownerOf, posterWords, type Rules, type Verdict } from './rule_set.js'
|
||||
export { RuleSet, DEFAULT_RULES, isRandomFile, isScrambledFile, ownerOf, posterWords, stubArchiveLegs, isNonsensePiece, STUB_ARTICLE_BYTES, STUB_MIN_BYTES, STUB_MAX_BYTES, STUB_WIDE_GROUPS, NONSENSE_MIN_BYTES, NONSENSE_MAX_BYTES, type Rules, type Verdict, type JudgedFile } from './rule_set.js'
|
||||
+103
-4
@@ -30,6 +30,18 @@ export interface Rules {
|
||||
* again under other titles (119k releases on 2026-10-16; LOG.md 2026-10-16 (65)).
|
||||
*/
|
||||
droppedPosters: string[]
|
||||
/**
|
||||
* Drop settled releases of one unsplit archive without a par2, posted to 2+ groups, whose articles are at most
|
||||
* STUB_ARTICLE_BYTES with the release at least STUB_MIN_BYTES, or posted to 4+ groups, or by a webmail poster, the
|
||||
* last two under STUB_MAX_BYTES: the exe-stub and recycled-installer floods under film and software names (55%
|
||||
* of the catalog on 2026-10-30; 99.3% of the known fakes, 0 known content; LOG.md (123)-(126)).
|
||||
*/
|
||||
dropStubArchives: boolean
|
||||
/**
|
||||
* Drop one-article archives of NONSENSE_MIN_BYTES to NONSENSE_MAX_BYTES under a release-like, unscrambled name:
|
||||
* the newznzb.* pieces (9% of the catalog; LOG.md (123), (125)).
|
||||
*/
|
||||
dropNonsensePieces: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_RULES: Omit<Rules, 'priorities' | 'droppedSignatures'> = {
|
||||
@@ -39,9 +51,31 @@ export const DEFAULT_RULES: Omit<Rules, 'priorities' | 'droppedSignatures'> = {
|
||||
samplePercent: 1,
|
||||
dropExecutables: true,
|
||||
droppedPosters: ['celebmovie', 'celebwow', 'celebfx', 'iluvcelebs', 'nudecelebs', 'celebsextapes'],
|
||||
dropStubArchives: true,
|
||||
dropNonsensePieces: true,
|
||||
}
|
||||
|
||||
export type Verdict = { rule: null } | { rule: 'poster' | 'executable' | 'garbage' | 'per-article'; key: string; sample: boolean }
|
||||
/** The stub-archive rule's bounds (edge/src/names.rs): Power-Post's 250 kB default article, the fakes' size span. */
|
||||
export const STUB_ARTICLE_BYTES = 262144
|
||||
export const STUB_MIN_BYTES = 1_000_000
|
||||
export const STUB_MAX_BYTES = 100_000_000
|
||||
export const STUB_WIDE_GROUPS = 4
|
||||
/** The nonsense-piece rule's bounds: under 100 kB are cue/log sidecars and adverts, 1 MB and up is rule 1's. */
|
||||
export const NONSENSE_MIN_BYTES = 100_000
|
||||
export const NONSENSE_MAX_BYTES = 1_000_000
|
||||
|
||||
export type Verdict = { rule: null } | { rule: 'poster' | 'executable' | 'garbage' | 'per-article' | 'stub-archive' | 'nonsense-piece'; key: string; sample: boolean }
|
||||
|
||||
/** A file as the judge sees it: its name, parts, groups, poster, and the articles seen with their bytes. */
|
||||
export interface JudgedFile {
|
||||
name: string
|
||||
totalParts: number
|
||||
groups: string[]
|
||||
firstMessageId: string
|
||||
poster: string
|
||||
segments: number
|
||||
bytes: number
|
||||
}
|
||||
|
||||
/** A poster as droppedPosters matches it: lowercased, letters and digits only. */
|
||||
export function posterWords(poster: string): string {
|
||||
@@ -51,6 +85,61 @@ export function posterWords(poster: string): string {
|
||||
/** Files that come along with a release's content: they don't make it one of executables or not. */
|
||||
const SIDECAR = /\.(par2|nfo|sfv|txt|jpe?g|png|nzb)$/i
|
||||
const EXECUTABLE = /\.(exe|scr|com|pif|bat|cmd|vbs|lnk)\s*$/i
|
||||
/** One zip, rar or 7z that is not a volume of a set. */
|
||||
const ARCHIVE = /\.(zip|rar|7z)$/i
|
||||
const VOLUME = /\.(part\d+\.rar|r\d\d|z\d\d|7z\.\d\d\d|\d\d\d)$/i
|
||||
/** A poster at a free mail provider: the stub floods' throwaway accounts. */
|
||||
const WEBMAIL = /@(gmail|googlemail|yahoo|hotmail|outlook|live|msn|aol|gmx|web|t-online|freenet|mail|yandex|icloud|me|hushmail|protonmail|libero|orange|free|arcor|wanadoo|ymail|rocketmail|inbox|mail\.ru|bk|list)\./i
|
||||
/** A name that reads as a release: a year, a scene tag, a group suffix or dotted words. */
|
||||
const RELEASE_LIKE = /(\b(19|20)\d\d\b|[._-](720p|1080p|2160p|x264|x265|hevc|bluray|web-?dl|webrip|hdtv|xvid|s\d\de\d\d|german|multi|crack|repack|proper|flac|mp3|xxx|mp4|retail|keygen|portable|incl|v\d+(\.\d+)+)\b|-[A-Za-z0-9]{2,}$|\.[A-Za-z0-9]+\.[A-Za-z0-9]+\.)/i
|
||||
|
||||
/**
|
||||
* Groups whose communities post real single small archives (karaoke zips, e-books, comics, tabs, fonts) the way the
|
||||
* stub floods do (Power-Post defaults, a crosspost or two): a release touching one is exempt from the stub-archive
|
||||
* rule. Costs 16 of 32,308 known fakes in the 217k sample (LOG.md (127)).
|
||||
*/
|
||||
const STUB_EXEMPT_GROUPS = /(sound|mp3|karaoke|music|audio|flac|lossless|e-?book|epub|comic|guitar|font|magazine)/i
|
||||
|
||||
/** The single unsplit archive a release consists of, or null. */
|
||||
function singleArchive(files: JudgedFile[]): JudgedFile | null {
|
||||
return files.length === 1 && ARCHIVE.test(files[0].name) && !VOLUME.test(files[0].name) ? files[0] : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The legs of the stub-archive rule a settled single archive trips: "A" (articles <= STUB_ARTICLE_BYTES, release >=
|
||||
* STUB_MIN_BYTES: a one-article release always has small articles, so e-books and sidecars are exempt), "X" (4+
|
||||
* groups) and "W" (webmail poster), the last two under STUB_MAX_BYTES (the fakes stop at ~90 MB; hash-named uploads
|
||||
* and real albums above it don't). Empty when none.
|
||||
*/
|
||||
export function stubArchiveLegs(f: JudgedFile): string {
|
||||
if (f.groups.length < 2 || f.groups.some((g) => STUB_EXEMPT_GROUPS.test(g))) return ''
|
||||
let legs = ''
|
||||
if (f.bytes / Math.max(f.segments, 1) <= STUB_ARTICLE_BYTES && f.bytes >= STUB_MIN_BYTES) legs += 'A'
|
||||
if (f.groups.length >= STUB_WIDE_GROUPS && f.bytes < STUB_MAX_BYTES) legs += 'X'
|
||||
if (f.bytes < STUB_MAX_BYTES && WEBMAIL.test(f.poster)) legs += 'W'
|
||||
return legs
|
||||
}
|
||||
|
||||
/** An e-book by its name ("Author - Title (2020) (epub).rar"): one small article is a book, not a nonsense piece. */
|
||||
const EBOOK = /\b(epub|mobi|azw3?|pdf|djvu|cbr|cbz|e-?books?)\b/i
|
||||
|
||||
/**
|
||||
* A nonsense piece: one article, one small archive under a release-like, unscrambled name without spaces (the
|
||||
* newznzb.* pieces are scene-styled or random with a "-XX" suffix; books and albums are written with spaces).
|
||||
*/
|
||||
export function isNonsensePiece(f: JudgedFile): boolean {
|
||||
return (
|
||||
f.totalParts === 1 &&
|
||||
f.segments === 1 &&
|
||||
f.bytes >= NONSENSE_MIN_BYTES &&
|
||||
f.bytes < NONSENSE_MAX_BYTES &&
|
||||
!f.name.includes(' ') &&
|
||||
RELEASE_LIKE.test(fileStem(f.name)) &&
|
||||
!EBOOK.test(f.name) &&
|
||||
!isScrambledFile(f.name) &&
|
||||
!isRandomFile(f.name)
|
||||
)
|
||||
}
|
||||
|
||||
export function isRandomFile(name: string) {
|
||||
return looksRandom(fileStem(name))
|
||||
@@ -98,10 +187,12 @@ export class RuleSet {
|
||||
|
||||
/**
|
||||
* A release's verdict from its files: dropped when its poster is; when its content is bare
|
||||
* executables; else kept as a whole when any file has a readable name; else dropped as garbage when its signature or tool is
|
||||
* dropped, as per-article random subjects when every file has few parts.
|
||||
* executables; when it is a settled single archive that is a stub or a nonsense piece; else kept as a whole when
|
||||
* any file has a readable name; else dropped as garbage when its signature or tool is dropped, as per-article
|
||||
* random subjects when every file has few parts. `settled`: the window closed on the release (a par2 can still
|
||||
* join an open one), which the single-archive rules need.
|
||||
*/
|
||||
judge(releaseKey: string, files: { name: string; totalParts: number; groups: string[]; firstMessageId: string; poster: string }[]): Verdict {
|
||||
judge(releaseKey: string, files: JudgedFile[], settled = true): Verdict {
|
||||
const sample = explored(releaseKey, this.rules.samplePercent)
|
||||
if (this.posters.length > 0 && files.length > 0) {
|
||||
const words = posterWords(files[0].poster)
|
||||
@@ -114,6 +205,14 @@ export class RuleSet {
|
||||
return { rule: 'executable', key: EXECUTABLE.exec(content[0].name)![1].toLowerCase(), sample }
|
||||
}
|
||||
}
|
||||
const archive = settled ? singleArchive(files) : null
|
||||
if (archive) {
|
||||
if (this.rules.dropStubArchives) {
|
||||
const legs = stubArchiveLegs(archive)
|
||||
if (legs !== '') return { rule: 'stub-archive', key: legs, sample }
|
||||
}
|
||||
if (this.rules.dropNonsensePieces && isNonsensePiece(archive)) return { rule: 'nonsense-piece', key: uploadTool(archive.firstMessageId), sample }
|
||||
}
|
||||
if (files.some((f) => !isRandomFile(f.name))) return { rule: null }
|
||||
const first = files[0]
|
||||
const tool = uploadTool(first.firstMessageId)
|
||||
|
||||
Reference in new issue
Block a user