Files
dwroller/scripts/tag-and-dedup-rules.js
Alex e511bff882 Add weapon images, full rule scraping, and expanded quick reference
- scripts/generate-weapon-images.js: AI image generation for weapon cards
  supporting HuggingFace FLUX.1-schnell, OpenAI DALL-E 3, and Gemini Imagen;
  images served from public/weapon-images/ via new static route in server.js
- public/cards.html: load manifest.json and show AI art on weapon cards
- scripts/scrape-rules-from-pdfs.py: PyMuPDF scraper extracting full rule
  text from all five rulebook PDFs with bold-span heading detection
- scripts/tag-and-dedup-rules.js: standalone dedup helper (JS-side grouping)
- database/routes/rulesRoutes.js: remove content truncation in search/random,
  add admin dedup endpoint (JS-side, fast), fix RulesTab to fetch full content
- src/components/RulesTab.jsx: fetch full rule content on modal open
- public/print.html: expanded quick reference with pre-gen character stat
  blocks (Sepheran + Lucian), Astartes traits sheet with correct derived SBs
  and TBs, full Critical Hit Tables for all 4 locations × 4 damage types

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 22:50:50 +01:00

155 lines
5.3 KiB
JavaScript

#!/usr/bin/env node
/**
* tag-and-dedup-rules.js
*
* 1. Re-tags "sanitized" rules with their real source (Core Rulebook).
* 2. Deduplicates rules with the same normalised title, keeping the
* version from the most authoritative source.
*
* Source priority (highest first):
* Core Rulebook > First Founding > Rites of Battle >
* Mark of the Xenos > Honour the Chapter > fandom > sanitized
*
* Usage:
* node scripts/tag-and-dedup-rules.js
* node scripts/tag-and-dedup-rules.js --dry-run (print, don't save)
*/
const { rulesHelpers, logToFile } = require('../database/mariadb');
const mysql = require('mysql2/promise');
const DRY_RUN = process.argv.includes('--dry-run');
const SOURCE_PRIORITY = [
'Core Rulebook',
'First Founding',
'Rites of Battle',
'Mark of the Xenos',
'Honour the Chapter',
'https://40k-rpg-ffg.fandom.com',
'fandom',
'sanitized',
];
function priority(source) {
const i = SOURCE_PRIORITY.indexOf(source);
return i === -1 ? SOURCE_PRIORITY.length : i;
}
function normalizeTitle(t) {
return (t || '')
.toLowerCase()
.replace(/[^a-z0-9 ]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
async function getPool() {
return mysql.createPool({
host: '192.168.1.113',
port: 3307,
user: 'deathwatch',
password: process.env.DB_PASSWORD || 'DwRoller@2025!',
database: 'deathwatch',
waitForConnections: true,
connectionLimit: 5,
});
}
async function main() {
const pool = await getPool();
// ── 1. Load all rules ────────────────────────────────────────────────────
console.log('Loading all rules…');
const [rows] = await pool.execute('SELECT id, rule_id, title, source, source_abbr, category, LENGTH(content) as clen FROM rules');
console.log(` ${rows.length} total rules`);
// ── 2. Re-tag sanitized → Core Rulebook ──────────────────────────────────
const sanitized = rows.filter(r => r.source === 'sanitized');
console.log(`\n[1] Re-tagging ${sanitized.length} "sanitized" rules → Core Rulebook`);
if (!DRY_RUN && sanitized.length > 0) {
await pool.execute(
`UPDATE rules SET source='Core Rulebook', source_abbr='CR' WHERE source='sanitized'`
);
console.log(` ✓ Updated ${sanitized.length} rows`);
// Refresh
sanitized.forEach(r => { r.source = 'Core Rulebook'; r.source_abbr = 'CR'; });
}
// ── 3. Find duplicates by normalised title ───────────────────────────────
console.log('\n[2] Detecting duplicate titles…');
const byNormTitle = {};
for (const r of rows) {
const key = normalizeTitle(r.title);
if (!key) continue;
if (!byNormTitle[key]) byNormTitle[key] = [];
// Update source after re-tag
if (r.source === 'sanitized') r.source = 'Core Rulebook';
byNormTitle[key].push(r);
}
const dupGroups = Object.values(byNormTitle).filter(g => g.length > 1);
console.log(` Found ${dupGroups.length} duplicate groups`);
let deleted = 0;
const toDelete = [];
for (const group of dupGroups) {
// Sort by priority (best source first), then by content length (longer = better)
group.sort((a, b) => {
const pd = priority(a.source) - priority(b.source);
return pd !== 0 ? pd : b.clen - a.clen;
});
const keep = group[0];
const remove = group.slice(1);
if (DRY_RUN && deleted < 20) {
console.log(` KEEP [${keep.source}] "${keep.title}" (${keep.clen} chars)`);
remove.forEach(r => console.log(` DEL [${r.source}] "${r.title}" (${r.clen} chars)`));
}
remove.forEach(r => toDelete.push(r.id));
deleted += remove.length;
}
console.log(` Will delete ${toDelete.length} duplicates`);
if (!DRY_RUN && toDelete.length > 0) {
// Delete in batches of 500
for (let i = 0; i < toDelete.length; i += 500) {
const batch = toDelete.slice(i, i + 500);
const placeholders = batch.map(() => '?').join(',');
await pool.execute(`DELETE FROM rules WHERE id IN (${placeholders})`, batch);
}
console.log(` ✓ Deleted ${toDelete.length} duplicate rules`);
}
// ── 4. Fix bad categories on fandom/url sources ──────────────────────────
console.log('\n[3] Fixing source labels for fandom/url entries…');
if (!DRY_RUN) {
const [r1] = await pool.execute(
`UPDATE rules SET source='Core Rulebook', source_abbr='CR' WHERE source LIKE '%fandom%' OR source LIKE '%40k-rpg%'`
);
console.log(` ✓ Fixed ${r1.affectedRows} fandom entries`);
}
// ── 5. Final stats ───────────────────────────────────────────────────────
const [stats] = await pool.execute(`
SELECT source, COUNT(*) as cnt FROM rules GROUP BY source ORDER BY cnt DESC
`);
console.log('\n── Final source distribution:');
for (const s of stats) {
console.log(` ${String(s.cnt).padStart(5)} ${s.source}`);
}
const [total] = await pool.execute('SELECT COUNT(*) as cnt FROM rules');
console.log(`\nTotal: ${total[0].cnt} rules`);
await pool.end();
}
main().catch(e => { console.error(e); process.exit(1); });