Files
dwroller/database/scripts/sanitize_rules_midgame.js
2026-06-29 13:37:02 +02:00

224 lines
8.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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|PrimarChs 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);
});