- Remove OCR noise, credits, and duplicates from rules-database.json (288→255 rules) - Add clean_rules.py script for rule cleanup - Add CLAUDE.md, docs/, and update README with documentation links Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
166 lines
5.4 KiB
Python
166 lines
5.4 KiB
Python
#!/usr/bin/env python3
|
||
"""Clean up the rules database: remove duplicates, OCR artifacts, and noise."""
|
||
|
||
import json
|
||
import re
|
||
import sys
|
||
from collections import Counter
|
||
|
||
INPUT = "database/rules/rules-database.json"
|
||
OUTPUT = "database/rules/rules-database-cleaned.json"
|
||
|
||
def is_noise(content):
|
||
"""Return True if content is likely OCR noise."""
|
||
text = content.strip()
|
||
if len(text) < 10:
|
||
return True
|
||
# Only symbols/whitespace
|
||
if re.sub(r'[\s™®©—–‘’“”•‣…]', '', text).strip() == "":
|
||
return True
|
||
# Only page numbers
|
||
if re.match(r'^\s*\d+\s*$', text):
|
||
return True
|
||
# Only index entries
|
||
if text.lower() == "index":
|
||
return True
|
||
return False
|
||
|
||
def is_credits(content):
|
||
"""Return True if content is credits/copyright."""
|
||
text = content.lower()
|
||
return any(kw in text for kw in [
|
||
"credits", "designed by", "lead developer", "art direction",
|
||
"production manager", "game designer", "game producer",
|
||
"publisher", "licensing", "graphic design",
|
||
"cover art", "copyright", "game workshop",
|
||
"ffg", "fantasy flight games",
|
||
])
|
||
|
||
def is_page_header(content):
|
||
"""Return True if content looks like a page header/number."""
|
||
text = content.strip()
|
||
# Page number lines like "88 —" or "165 115"
|
||
if re.match(r'^\d+\s*(—|—\s*\d+)?$', text):
|
||
return True
|
||
# "GAME?" or "GAME? (p.X"
|
||
if re.match(r'^GAME\?\s*\(p\.', text):
|
||
return True
|
||
return False
|
||
|
||
def fix_title(title):
|
||
"""Fix common OCR title issues."""
|
||
# Remove leading/trailing whitespace and control chars
|
||
title = title.strip()
|
||
# Remove control characters
|
||
title = re.sub(r'[\x00-\x1f\x7f]', '', title).strip()
|
||
# Fix "GAME?" -> "GAME"
|
||
title = re.sub(r'^GAME\?', 'GAME', title)
|
||
# Fix "fellowShip" -> "Fellowship"
|
||
title = re.sub(r'^(fel)\s*\(', r'\1 (', title, flags=re.IGNORECASE)
|
||
# Fix "balliStiC" -> "Ballistic"
|
||
title = re.sub(r'^(balliStiC)\s*Skill', r'Ballistic Skill', title, flags=re.IGNORECASE)
|
||
# Fix "perCeption" -> "Perception"
|
||
title = re.sub(r'^(perCeption)', r'Perception', title, flags=re.IGNORECASE)
|
||
# Fix "ExaMple" -> "Example"
|
||
title = re.sub(r'^(exaMple)', r'Example', title, flags=re.IGNORECASE)
|
||
# Fix "s" on its own line
|
||
if title.strip() == "s":
|
||
return None
|
||
# Fix "88 —" or "165 115" style page numbers
|
||
if re.match(r'^\d+\s*(—|—\s*\d+)?$', title):
|
||
return None
|
||
# Fix "therefore, and you will know no fear." (p.34, Core Rulebook)
|
||
if re.match(r'^.*\(p\.\d+,\s*(Core Rulebook|Game Master\'s Kit)\)\s*$', title):
|
||
return None
|
||
# Fix "— (p.XX, Source)"
|
||
if re.match(r'^—\s*\(p\.\d+,\s*(Core Rulebook|Game Master\'s Kit)\)\s*$', title):
|
||
return None
|
||
return title
|
||
|
||
def dedup_rules(rules):
|
||
"""Remove duplicate rules, keeping the one with the most content."""
|
||
seen = {}
|
||
kept = []
|
||
removed = []
|
||
|
||
for rule in rules:
|
||
title = fix_title(rule.get('title', ''))
|
||
if title is None:
|
||
removed.append(('title_fix_none', rule))
|
||
continue
|
||
|
||
key = title.strip().lower()
|
||
content = rule.get('content', '')
|
||
|
||
if key in seen:
|
||
existing = seen[key]
|
||
if len(content) > len(existing.get('content', '')):
|
||
removed.append(('duplicate', existing))
|
||
seen[key] = rule
|
||
else:
|
||
removed.append(('duplicate', rule))
|
||
else:
|
||
seen[key] = rule
|
||
|
||
# Update titles in the kept rules
|
||
for rule in seen.values():
|
||
rule['title'] = fix_title(rule.get('title', ''))
|
||
|
||
return list(seen.values()), removed
|
||
|
||
def main():
|
||
with open(INPUT) as f:
|
||
data = json.load(f)
|
||
|
||
rules = data['rules']
|
||
print(f"Loaded {len(rules)} rules")
|
||
|
||
# Phase 1: Remove noise
|
||
noise_rules = [r for r in rules if is_noise(r.get('content', ''))]
|
||
credits_rules = [r for r in rules if is_credits(r.get('content', ''))]
|
||
header_rules = [r for r in rules if is_page_header(r.get('content', ''))]
|
||
|
||
print(f"\nNoise (short/symbols): {len(noise_rules)}")
|
||
print(f"Credits/copyright: {len(credits_rules)}")
|
||
print(f"Page headers: {len(header_rules)}")
|
||
|
||
# Phase 2: Dedup
|
||
deduped, dups = dedup_rules(rules)
|
||
print(f"\nDuplicates removed: {len(dups)}")
|
||
|
||
# Phase 3: Combine noise removal
|
||
cleaned = []
|
||
for r in deduped:
|
||
content = r.get('content', '')
|
||
if is_noise(content) or is_credits(content) or is_page_header(content):
|
||
continue
|
||
cleaned.append(r)
|
||
|
||
print(f"\nFinal count: {len(cleaned)} (was {len(rules)})")
|
||
print(f"Removed: {len(rules) - len(cleaned)}")
|
||
|
||
# Show what was removed
|
||
print("\n--- Removed rules ---")
|
||
for reason, r in dups:
|
||
print(f" [{reason}] {r.get('title', '?')[:60]}")
|
||
for r in noise_rules + credits_rules + header_rules:
|
||
if r not in cleaned:
|
||
print(f" [noise] {r.get('title', '?')[:60]}")
|
||
|
||
# Write output
|
||
out = {'rules': cleaned}
|
||
with open(OUTPUT, 'w') as f:
|
||
json.dump(out, f, indent=2, ensure_ascii=False)
|
||
|
||
print(f"\nWrote cleaned rules to {OUTPUT}")
|
||
|
||
# Show category breakdown
|
||
from collections import Counter
|
||
cats = Counter(r.get('category', 'unknown') for r in cleaned)
|
||
print("\nCategories:")
|
||
for c, n in cats.most_common():
|
||
print(f" {c}: {n}")
|
||
|
||
if __name__ == '__main__':
|
||
main()
|