const fs = require('fs'); const path = require('path'); const { pool } = require('../mariadb'); const APPLY = process.argv.includes('--apply'); const MAX_LINES = 8; const MAX_CHARS = 900; const ROOT = path.join(__dirname, '..'); const BACKUP_DIR = path.join(ROOT, 'backups'); const JSON_RULES = path.join(ROOT, 'rules', 'rules-database.json'); const BAD_TITLE_RE = /^([_\-—–.·•\s\d()+%†]+|NAME|SKILLS|CHARACTERISTICS|SQUAD MODE|COHESION:|SQUAD DESIGNATION:|ISBN:.*)$/i; const STAT_TITLE_RE = /^(\(?\d+[-–]?\d*\)?(\s+\(?[\d–-]+\)?){1,}|.*Pen\s+\d+.*|.*Reload.*|.*Tearing.*|.*Unnatural .*x2.*)$/i; const NOISE_LINE_RE = /^(\d{1,3}|chapter\s+[ivx]+|[ivx]+:\s+|table\s+\d|page\s+\d|www\.|isbn|™|®|©|credits|contents|printed in|fantasy flight|games workshop|character name:|player name:|effects:|type:\s*✔|trained\s*$|basic\s*$|advanced\s+skills|actioN descriptioNs)$/i; const SHEET_RE = /(SPACE MARINE ABILITIES|POWER ARMOUR ABILITIES|PrimarCh’s Curse|Battle Fatigue|CurreNt PoiNts|weapon skill\s+ballistic skill|character name:\s+player name)/i; const STATBLOCK_RE = /(Movement:\s*\d|Wounds:\s*\d|Skills:.*Talents:|Armour:.*Weapons:|wS\s+BS\s+S\s+T\s+ag\s+int\s+Per\s+WP\s+fel)/i; const TABLE_NOISE_RE = /(Rank \d .* Advances|Advance\s+Cost\s+Type|Objective\/Difficulty\s+Requisition|Name\s+Wt\s+Req\s+Renown)/i; const MECHANICS_RE = /(Test|Action|Reaction|Damage|Wounds?|Skill|Talent|Trait|bonus|penalty|Requisition|Renown|Cohesion|Fear|Insanity|Horde|Armou?r|Weapon|Psychic|Initiative|Critical|Fatigue|Pinning|Prone|Dodge|Parry|Opposed|Difficulty|Full Action|Half Action|Free Action|Toughness|Willpower|Agility|Fellowship|Ballistic|Weapon Skill)/i; const LORE_TITLE_RE = /^(GAME|THE LION|SANGUINIUS|LEMAN RUSS|THE DEATHWING|THE RAVENWING|THE LION AND THE WOLF|IMPERIUM|ULTIMA|SEGEMENTUM|THE AGES OF HUMANITY|THE SOUL BINDING|EXTERMINATUS|RADICALS AND PURITANS|BLACK SHIELDS|CATECHISM OF THE XENOS|THE HADEX ANOMALY|OF THE 41ST MILLENNIUM|WE ARE THE IMPERIUM)/i; function cleanWhitespace(text) { return String(text || '') .replace(/\r\n/g, '\n') .replace(/[\x00-\x09\x0B\x0C\x0E-\x1F]/g, '') .replace(/[ \t]+/g, ' ') .replace(/\n\s+/g, '\n') .replace(/\s+\n/g, '\n') .replace(/\n{3,}/g, '\n\n') .trim(); } function cleanTitle(title) { return cleanWhitespace(title) .replace(/[〔〕]/g, '') .replace(/\s+/g, ' ') .trim(); } function isBadTitle(title) { const t = cleanTitle(title); if (!t || t.length < 3) return true; if (BAD_TITLE_RE.test(t)) return true; if (STAT_TITLE_RE.test(t)) return true; if (/^(Prerequisites:|[–-][A-Z]\.|†+|[0-9]{2,}|[0-9]+[–-][0-9]+)$/i.test(t)) return true; return false; } function noiseScore(rule) { const title = cleanTitle(rule.title); const content = cleanWhitespace(rule.content); let score = 0; if (isBadTitle(title)) score += 4; if (SHEET_RE.test(content) || SHEET_RE.test(title)) score += 8; if (STATBLOCK_RE.test(content) || STATBLOCK_RE.test(title)) score += 5; if (TABLE_NOISE_RE.test(content) || TABLE_NOISE_RE.test(title)) score += 4; if (content.length > 1300) score += 2; const lines = content.split('\n').filter(Boolean); const noisyLines = lines.filter(line => NOISE_LINE_RE.test(line) || /^\W+$/.test(line) || line.length > 160).length; if (lines.length && noisyLines / lines.length > 0.45) score += 4; if ((title.match(/\b[A-Z]{2,}\b/g) || []).length > 3) score += 2; return score; } function meaningfulLines(rule) { const title = cleanTitle(rule.title); const content = cleanWhitespace(rule.content); const raw = content.split('\n').map(line => cleanWhitespace(line)).filter(Boolean); const lines = []; for (const line of raw) { if (!line || line === title) continue; if (NOISE_LINE_RE.test(line)) continue; if (/^\W+$/.test(line)) continue; if (/^[A-Z][A-Za-z\s]+:\s*$/.test(line) && line.length < 24) continue; if (line.length > 180 && !/[.;:]$/.test(line)) continue; if (/(\s{8,}| {4,})/.test(line)) continue; if (/^(S|T|ag|int|per|wp|fel|wS|BS)(\s+\S+){4,}$/i.test(line)) continue; lines.push(line); } return lines; } function sanitizeContent(rule) { const summary = cleanWhitespace(rule.summary); const lines = meaningfulLines(rule); const picked = []; if (summary && summary.length > 20 && !SHEET_RE.test(summary)) { picked.push(summary); } for (const line of lines) { if (picked.join('\n').length > MAX_CHARS) break; if (picked.some(existing => existing.toLowerCase() === line.toLowerCase())) continue; picked.push(line); if (picked.length >= MAX_LINES) break; } let content = picked.join('\n').trim(); if (content.length > MAX_CHARS) { content = content.slice(0, MAX_CHARS).replace(/\s+\S*$/, '').trim(); } return content; } function shouldDelete(rule) { const title = cleanTitle(rule.title); const content = cleanWhitespace(rule.content); const score = noiseScore(rule); const useful = sanitizeContent(rule); if (LORE_TITLE_RE.test(title) && !MECHANICS_RE.test(useful)) return true; if (LORE_TITLE_RE.test(title) && String(rule.category || '').toLowerCase() === 'general') return true; if (!useful || useful.length < 35) return true; if (SHEET_RE.test(content)) return true; if (score >= 10) return true; if (isBadTitle(title) && score >= 7) return true; return false; } function sanitizeRule(rule) { return { ...rule, title: cleanTitle(rule.title), content: sanitizeContent(rule), summary: cleanWhitespace(rule.summary), }; } function backupFileName(prefix) { const stamp = new Date().toISOString().replace(/[:.]/g, '-'); return path.join(BACKUP_DIR, `${prefix}-${stamp}.json`); } async function main() { fs.mkdirSync(BACKUP_DIR, { recursive: true }); const [rows] = await pool.execute('SELECT * FROM rules ORDER BY id'); const dbBackupPath = backupFileName('rules-table-before-sanitize'); fs.writeFileSync(dbBackupPath, JSON.stringify(rows, null, 2)); const deletes = []; const updates = []; for (const row of rows) { if (shouldDelete(row)) { deletes.push(row); continue; } const next = sanitizeRule(row); if ( next.title !== row.title || next.content !== row.content || next.summary !== row.summary ) { updates.push(next); } } let jsonStats = null; if (fs.existsSync(JSON_RULES)) { const json = JSON.parse(fs.readFileSync(JSON_RULES, 'utf8')); const rules = Array.isArray(json.rules) ? json.rules : []; const jsonBackupPath = backupFileName('rules-json-before-sanitize'); fs.writeFileSync(jsonBackupPath, JSON.stringify(json, null, 2)); const kept = []; let jsonDeleted = 0; let jsonUpdated = 0; for (const rule of rules) { if (shouldDelete(rule)) { jsonDeleted += 1; continue; } const next = sanitizeRule(rule); if (next.content !== rule.content || next.title !== rule.title || next.summary !== rule.summary) jsonUpdated += 1; kept.push(next); } jsonStats = { before: rules.length, after: kept.length, deleted: jsonDeleted, updated: jsonUpdated, backup: jsonBackupPath }; if (APPLY) fs.writeFileSync(JSON_RULES, JSON.stringify({ ...json, rules: kept }, null, 2)); } console.log(JSON.stringify({ apply: APPLY, db: { before: rows.length, after: rows.length - deletes.length, deleted: deletes.length, updated: updates.length, backup: dbBackupPath, deleteSamples: deletes.slice(0, 12).map(r => ({ id: r.id, title: r.title, score: noiseScore(r) })), updateSamples: updates.slice(0, 8).map(r => ({ id: r.id, title: r.title, chars: r.content.length, preview: r.content.slice(0, 160) })), }, json: jsonStats, }, null, 2)); if (APPLY) { const conn = await pool.getConnection(); try { await conn.beginTransaction(); for (const row of updates) { await conn.execute( 'UPDATE rules SET title = ?, content = ?, summary = ? WHERE id = ?', [row.title, row.content, row.summary || '', row.id] ); } for (const row of deletes) { await conn.execute('DELETE FROM rules WHERE id = ?', [row.id]); } await conn.commit(); } catch (error) { await conn.rollback(); throw error; } finally { conn.release(); } } setTimeout(async () => { await pool.end(); }, 250); } main().catch(async error => { console.error(error); try { await pool.end(); } catch {} process.exit(1); });