#!/usr/bin/env python3 """ Comprehensive Deathwatch rulebook scraper. Extracts weapons, armour, gear, talents, traits, psychic powers, and bestiary from PDFs and imports them into MariaDB. """ import os, re, json, sys import pdfplumber import fitz import mysql.connector from pathlib import Path REPO = Path(__file__).resolve().parent.parent RULES = REPO / 'database' / 'rules' def load_env_file(path): if not path.exists(): return for line in path.read_text().splitlines(): line = line.strip() if not line or line.startswith('#') or '=' not in line: continue key, value = line.split('=', 1) os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) load_env_file(REPO / 'database' / '.env') load_env_file(REPO / '.env') if not os.environ.get('DB_PASSWORD'): raise RuntimeError('DB_PASSWORD must be set in database/.env or .env') DB = dict( host=os.environ.get('DB_HOST', '192.168.1.113'), port=int(os.environ.get('DB_PORT', '3307')), user=os.environ.get('DB_USER', 'deathwatch'), password=os.environ['DB_PASSWORD'], database=os.environ.get('DB_NAME', 'deathwatch'), ) def clean(s): if not s: return '' return re.sub(r'\s+', ' ', str(s).replace('\n', ' ')).strip() def req_clean(s): if not s: return 0 s = re.sub(r'[†‡\*]','', str(s)).strip() try: return int(s) except: return 0 # ── DB helpers ───────────────────────────────────────────────────────────────── def get_db(): return mysql.connector.connect(**DB) def upsert_weapon(cur, name, category, stats_dict, source): cur.execute(""" INSERT INTO weapons (name, category, stats, source) VALUES (%s, %s, %s, %s) ON DUPLICATE KEY UPDATE category=VALUES(category), stats=VALUES(stats), source=VALUES(source) """, (name, category, json.dumps(stats_dict), source)) def upsert_rule(cur, rule_id, title, content, page, source, source_abbr, category): cur.execute(""" INSERT INTO rules (rule_id, title, content, page, source, source_abbr, category) VALUES (%s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE title=VALUES(title), content=VALUES(content), page=VALUES(page), source=VALUES(source), source_abbr=VALUES(source_abbr), category=VALUES(category) """, (rule_id, title, content, page, source, source_abbr, category)) def upsert_bestiary(cur, name, stats, profile_dict, book, page_num): snippet = profile_dict.get('description', '')[:500] cur.execute(""" INSERT INTO bestiary (name, book, page, pdf, stats, profile, snippet) VALUES (%s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE stats=VALUES(stats), profile=VALUES(profile), snippet=VALUES(snippet), page=VALUES(page) """, (name, book, str(page_num), book[:3].lower(), json.dumps(stats), json.dumps(profile_dict), snippet)) # ── Weapon extraction from CR-style tables (pdfplumber) ─────────────────────── def parse_weapon_row(row): """Parse a weapon table row (8-col melee or 12-col ranged).""" if not row or len(row) < 7: return None name = clean(row[0]) if not name or len(name) < 3: return None if re.match(r'^[\d†‡\*\s]+$', name): return None if re.match(r'^(Name|Table|Bolt|Plasma|Melta|Flamer|Las|Solid|Roll|Result)', name): return None # 12-col ranged: Name, Class, Range, RoF, Dmg, Pen, Clip, Rld, Special, Wt, Req, Renown if len(row) >= 12: return { 'name': name, 'class': clean(row[1]), 'range': clean(row[2]), 'rof': clean(row[3]), 'damage': clean(row[4]), 'pen': clean(row[5]), 'clip': clean(row[6]), 'rld': clean(row[7]), 'special': clean(row[8]), 'wt': clean(row[9]), 'req': req_clean(row[10]), 'renown': clean(row[11]).replace('–','-').replace('—','-'), } # 8-col melee: Name, Class, Dmg, Pen, Special, Wt, Req, Renown if len(row) >= 8: return { 'name': name, 'class': clean(row[1]), 'range': '-', 'rof': '-', 'damage': clean(row[2]), 'pen': clean(row[3]), 'clip': '-', 'rld': '-', 'special': clean(row[4]), 'wt': clean(row[5]), 'req': req_clean(row[6]), 'renown': clean(row[7]).replace('–','-').replace('—','-'), } return None def detect_category_from_text(text, current): """Detect weapon category from surrounding text.""" t = text.lower() if re.search(r'melee weapons?|close combat', t): return 'Melee Weapon' if re.search(r'ranged weapons?|bolt weapon|plasma|melta|flamer|las weapon|solid projectile|launcher', t): return 'Ranged Weapon' if re.search(r'grenade|explosive', t): return 'Grenade' if re.search(r'armour|carapace|power armour', t): return 'Armour' if re.search(r'wargear|equipment|gear', t): return 'Gear' return current def extract_weapons_pdfplumber(pdf_path, page_start, page_end, source, source_abbr): """Extract weapon tables using pdfplumber (works well for CR).""" weapons = [] category = 'Ranged Weapon' with pdfplumber.open(pdf_path) as pdf: for pg_idx in range(page_start-1, min(page_end, len(pdf.pages))): page = pdf.pages[pg_idx] text = page.extract_text() or '' category = detect_category_from_text(text, category) for table in page.extract_tables(): for row in table: w = parse_weapon_row(row) if w: w['category'] = category w['source'] = source w['source_abbr'] = source_abbr weapons.append(w) seen = {} for w in weapons: seen[w['name'].lower()] = w return list(seen.values()) # ── Weapon extraction from text (for FF/HtC where tables are text-based) ────── def dedupe_doubled(s): """Fix doubled OCR text like 'MMeelleeee' -> 'Melee'.""" if not s: return s # If every character is doubled, halve it if len(s) % 2 == 0: half = len(s) // 2 if s[:half] == s[half:]: return s[:half] # Try to fix character-by-character doubling result = re.sub(r'(.)\1', r'\1', s) return result if len(result) < len(s) * 0.7 else s def extract_weapons_from_text(pdf_path, page_start, page_end, source, source_abbr): """Extract weapons from text-based tables (FF, HtC).""" doc = fitz.open(str(pdf_path)) weapons = [] category = 'Ranged Weapon' # Pattern: weapon entry in a table row # "Name Class Dmg Pen Special Wt Req Renown" # or "Name Class Range RoF Dmg Pen Clip Rld Special Wt Req Renown" weapon_line_re = re.compile( r'^(.+?)\s{2,}(Melee|Pistol|Basic|Heavy|Mounted|Thrown)\s{2,}(.+?)\s{2,}(\d+)\s{2,}(.+?)\s{2,}(\d+)\s{2,}(.+?)\s{2,}([\w\s]+)$' ) # Simpler pattern for melee: Name Melee Dmg Pen Special Wt Req Renown melee_re = re.compile( r'^(.{3,40}?)\s{2,}(?:Melee|melee)\s{2,}([\dd+\s\w]+)\s{2,}(\d+)\s{2,}(.+?)\s{2,}(\d+\.?\d*)\s{2,}(\d+)\s{2,}(.+)$' ) for pg_idx in range(page_start-1, min(page_end, doc.page_count)): page = doc[pg_idx] text = page.get_text() category = detect_category_from_text(text, category) # Parse line by line for line in text.split('\n'): line = clean(line) if not line or len(line) < 10: continue # Detect category headers if re.match(r'Table \d+[-–]\d+.*Ranged', line, re.I): category = 'Ranged Weapon' elif re.match(r'Table \d+[-–]\d+.*Melee', line, re.I): category = 'Melee Weapon' elif re.match(r'Table \d+[-–]\d+.*Armour', line, re.I): category = 'Armour' elif re.match(r'Table \d+[-–]\d+.*Wargear', line, re.I): category = 'Gear' # Try to match weapon rows with 2+ spaces between columns parts = re.split(r'\s{2,}', line) if len(parts) >= 7: name = dedupe_doubled(parts[0]) if not name or len(name) < 3: continue if re.match(r'^(Name|Table|Chapter|\d+)', name): continue cls = dedupe_doubled(parts[1]) if len(parts) > 1 else '' if cls.lower() not in ('melee','pistol','basic','heavy','mounted','thrown','–','-'): # For armour/gear tables if category in ('Armour', 'Gear') and req_clean(parts[-2]) > 0: weapons.append({ 'name': name, 'class': cls, 'range': '-', 'rof': '-', 'damage': '-', 'pen': '0', 'clip': '-', 'rld': '-', 'special': ' '.join(parts[2:-2]) if len(parts)>4 else '', 'wt': '0', 'req': req_clean(parts[-2]), 'renown': parts[-1] if parts else '-', 'category': category, 'source': source, 'source_abbr': source_abbr }) continue dmg = dedupe_doubled(parts[2]) if len(parts) > 2 else '' pen = dedupe_doubled(parts[3]) if len(parts) > 3 else '0' special = dedupe_doubled(parts[4]) if len(parts) > 4 else '' wt = parts[5] if len(parts) > 5 else '0' req = req_clean(parts[6]) if len(parts) > 6 else 0 renown = parts[7] if len(parts) > 7 else '-' if not re.search(r'\d', dmg): continue weapons.append({ 'name': name, 'class': cls, 'range': '-', 'rof': '-', 'damage': dmg, 'pen': pen, 'clip': '-', 'rld': '-', 'special': special, 'wt': wt, 'req': req, 'renown': renown, 'category': category, 'source': source, 'source_abbr': source_abbr }) doc.close() seen = {} for w in weapons: if w['name'] and len(w['name']) > 2: seen[w['name'].lower()] = w return list(seen.values()) # ── Rule/Talent/Trait extraction ─────────────────────────────────────────────── def extract_rules_section(pdf_path, page_start, page_end, category, source, source_abbr): """Extract rules entries as text blocks.""" doc = fitz.open(str(pdf_path)) entries = [] current_title = None current_content = [] current_page = page_start for pg_idx in range(page_start-1, min(page_end, doc.page_count)): page = doc[pg_idx] blocks = page.get_text("blocks") for block in blocks: text = clean(block[4]) if not text or len(text) < 3: continue if re.match(r'^\d{1,3}$', text): continue if re.match(r'^[IVX]+\s*:', text): continue if re.match(r'^(Table|Contents|Credits|Introduction)', text, re.I): continue is_title = ( len(text) < 80 and len(text.split()) <= 8 and not text.endswith('.') and not text.startswith('Prerequisites') and not re.match(r'^[a-z]', text) and not re.search(r'[0-9]{3,}', text) and not re.match(r'^(The |A |An |In |At |For |With |This |These |When )', text) ) if is_title and current_title and len(' '.join(current_content)) > 30: entries.append({ 'title': current_title, 'content': ' '.join(current_content).strip(), 'page': current_page, 'source': source, 'source_abbr': source_abbr, 'category': category }) current_content = [] if is_title: current_title = text current_page = pg_idx + 1 elif current_title: current_content.append(text) if current_title and len(' '.join(current_content)) > 30: entries.append({ 'title': current_title, 'content': ' '.join(current_content).strip(), 'page': current_page, 'source': source, 'source_abbr': source_abbr, 'category': category }) doc.close() return entries # ── Bestiary extraction (MoX format) ────────────────────────────────────────── STAT_HEADER = re.compile(r'WS\s+[bB]S\s+S\s+T\s+Ag\s+Int\s+Per\s+WP\s+Fel') STAT_VALUES = re.compile(r'^[\d\s\(\)\-–]+$') CREATURE_NAME = re.compile(r'^([A-Z][A-Z\s\'\-]{2,50})$|^([a-z][A-Z]{2,}[a-z\s]+)$') def extract_bestiary_mox(pdf_path): """Extract bestiary entries from Mark of the Xenos format.""" doc = fitz.open(str(pdf_path)) entries = [] current = None for pg_idx in range(8, doc.page_count): # skip intro pages page = doc[pg_idx] text = page.get_text() lines = [l.strip() for l in text.split('\n') if l.strip()] i = 0 while i < len(lines): line = lines[i] # Detect creature name: ALL CAPS or mixed OCR caps, short, no numbers is_creature_name = ( len(line) > 3 and len(line) < 60 and len(line.split()) <= 6 and not re.search(r'\d', line) and not re.search(r'[.,:;]', line) and (line.upper() == line or re.match(r'^[A-Z][a-zA-Z\s]+[A-Z]$', line)) and not re.match(r'^(WS|BS|Skills|Talents|Traits|Armour|Weapons|Gear|Movement|Wounds|Special|The |A |An |In )', line) ) # Detect stat block header if STAT_HEADER.search(line): # Next non-empty line should be stat values j = i + 1 stat_values = [] while j < len(lines) and j < i+5: vals = re.findall(r'[\d\(\)]+', lines[j]) if vals: stat_values.extend(vals) j += 1 if len(stat_values) >= 9: break elif not lines[j].strip(): j += 1 else: break if current and len(stat_values) >= 9: keys = ['ws','bs','s','t','ag','int','per','wp','fel'] for k, v in zip(keys, stat_values[:9]): try: current['stats'][k] = int(re.sub(r'[()]','',v)) except: pass i = j continue # Parse profile fields if current: if line.startswith('Movement:'): m = re.search(r'(\d+)/(\d+)/(\d+)/(\d+)', line) if m: current['stats']['movement'] = m.group(1) m2 = re.search(r'Wounds:\s*(\d+)', line) if m2: current['stats']['wounds'] = int(m2.group(1)) elif line.startswith('Wounds:'): m = re.search(r'(\d+)', line) if m: current['stats']['wounds'] = int(m.group(1)) elif line.startswith('Skills:'): current['profile']['skills'] = line elif line.startswith('Talents:'): current['profile']['talents'] = line elif line.startswith('Traits:'): current['profile']['traits'] = line elif line.startswith('Armour:'): current['profile']['armour'] = line elif line.startswith('Weapons:'): current['profile']['weapons'] = line elif line.startswith('Gear:'): current['profile']['gear'] = line elif line.startswith('Special Rules'): current['profile']['special_rules'] = '' elif 'special_rules' in current['profile'] and not line.startswith(('WS','BS','Movement','Wounds','Skills','Talents','Traits','Armour','Weapons','Gear')): current['profile']['special_rules'] = (current['profile'].get('special_rules','') + ' ' + line).strip() elif not any(line.startswith(x) for x in ('WS','BS','Movement','Wounds','Skills','Talents','Traits','Armour','Weapons','Gear')): current['profile']['description'] = (current['profile'].get('description','') + ' ' + line).strip() if is_creature_name: # Normalise name: fix bROADSIDE bATTLESUIT -> Broadside Battlesuit name = ' '.join(w.capitalize() for w in line.split()) if current and (current['stats'] or current['profile'].get('description','')): entries.append(current) current = { 'name': name, 'page': pg_idx + 1, 'stats': {}, 'profile': {} } i += 1 if current and (current['stats'] or current['profile'].get('description','')): entries.append(current) doc.close() return entries # ── Wargear table extraction (Req + Renown only) ─────────────────────────────── def extract_wargear_table(pdf_path, page_start, page_end, source, source_abbr): """Extract wargear entries with just Req and Renown.""" weapons = [] with pdfplumber.open(pdf_path) as pdf: for pg_idx in range(page_start-1, min(page_end, len(pdf.pages))): page = pdf.pages[pg_idx] text = page.extract_text() or '' if not re.search(r'Req|Renown', text): continue for line in text.split('\n'): parts = re.split(r'\s{2,}', line.strip()) if len(parts) >= 2: name = clean(parts[0]) req = req_clean(parts[-2]) if len(parts) >= 2 else 0 renown = clean(parts[-1]) if parts else '-' if name and len(name) > 3 and req > 0 and not re.match(r'^(Name|Table|\d)', name): weapons.append({ 'name': name, 'class': 'Gear', 'range': '-', 'rof': '-', 'damage': '-', 'pen': '0', 'clip': '-', 'rld': '-', 'special': '-', 'wt': '0', 'req': req, 'renown': renown, 'category': 'Gear', 'source': source, 'source_abbr': source_abbr }) seen = {} for w in weapons: seen[w['name'].lower()] = w return list(seen.values()) # ── Main ──────────────────────────────────────────────────────────────────────── def make_rid(prefix, title): rid = prefix + re.sub(r'[^a-z0-9]', '_', title.lower()) return re.sub(r'_+', '_', rid).strip('_') def run(): print("=== Deathwatch Rulebook Scraper ===\n") db = get_db() cur = db.cursor() total_weapons = 0 total_rules = 0 total_bestiary = 0 # ── 1. CR Ranged Weapons (p145-155) ─────────────────────────────────────── print("CR: Ranged weapons (p145-155)...") cr_ranged = extract_weapons_pdfplumber(RULES/'CR.pdf', 145, 156, 'Core Rulebook', 'CR') for w in cr_ranged: upsert_weapon(cur, w['name'], 'Ranged Weapon', { 'damage':w['damage'], 'pen':w['pen'], 'range':w['range'], 'rof':w['rof'], 'clip':w['clip'], 'rld':w['rld'], 'special':w['special'], 'wt':w['wt'], 'req':w['req'], 'renown':w['renown'], 'source':'CR' }, 'Core Rulebook') db.commit() print(f" {len(cr_ranged)} ranged weapons") total_weapons += len(cr_ranged) # ── 2. CR Melee Weapons (p154-158) ──────────────────────────────────────── print("CR: Melee weapons (p154-158)...") cr_melee = extract_weapons_pdfplumber(RULES/'CR.pdf', 154, 159, 'Core Rulebook', 'CR') for w in cr_melee: upsert_weapon(cur, w['name'], 'Melee Weapon', { 'damage':w['damage'], 'pen':w['pen'], 'range':'-', 'rof':'-', 'clip':'-', 'rld':'-', 'special':w['special'], 'wt':w['wt'], 'req':w['req'], 'renown':w['renown'], 'source':'CR' }, 'Core Rulebook') db.commit() print(f" {len(cr_melee)} melee weapons") total_weapons += len(cr_melee) # ── 3. CR Armour (p163-175) ─────────────────────────────────────────────── print("CR: Armour & Gear (p163-185)...") cr_armour = extract_weapons_pdfplumber(RULES/'CR.pdf', 163, 186, 'Core Rulebook', 'CR') for w in cr_armour: cat = 'Armour' if 'armour' in w['category'].lower() else 'Gear' upsert_weapon(cur, w['name'], cat, { 'damage':w['damage'], 'pen':w['pen'], 'range':w['range'], 'rof':w['rof'], 'clip':w['clip'], 'rld':w['rld'], 'special':w['special'], 'wt':w['wt'], 'req':w['req'], 'renown':w['renown'], 'source':'CR' }, 'Core Rulebook') db.commit() print(f" {len(cr_armour)} armour/gear entries") total_weapons += len(cr_armour) # ── 4. FF Weapons & Wargear (p97-115) ───────────────────────────────────── print("FF: Weapons & wargear (p97-115)...") ff_weapons = extract_weapons_from_text(RULES/'FF.pdf', 97, 116, 'First Founding', 'FF') for w in ff_weapons: upsert_weapon(cur, w['name'], w['category'], { 'damage':w['damage'], 'pen':w['pen'], 'range':w['range'], 'rof':w['rof'], 'clip':w['clip'], 'rld':w['rld'], 'special':w['special'], 'wt':w['wt'], 'req':w['req'], 'renown':w['renown'], 'source':'FF' }, 'First Founding') db.commit() print(f" {len(ff_weapons)} items from FF") total_weapons += len(ff_weapons) # ── 5. CR Talents (p114-131) ────────────────────────────────────────────── print("CR: Talents (p114-131)...") cr_talents = extract_rules_section(RULES/'CR.pdf', 114, 131, 'talents', 'Core Rulebook', 'CR') cnt = 0 for e in cr_talents: if len(e['content']) < 30: continue upsert_rule(cur, make_rid('talent_', e['title']), e['title'], e['content'], e['page'], e['source'], e['source_abbr'], 'talents') cnt += 1 db.commit() print(f" {cnt} talents") total_rules += cnt # ── 6. CR Traits (p131-138) ─────────────────────────────────────────────── print("CR: Traits (p131-138)...") cr_traits = extract_rules_section(RULES/'CR.pdf', 131, 139, 'traits', 'Core Rulebook', 'CR') cnt = 0 for e in cr_traits: if len(e['content']) < 30: continue upsert_rule(cur, make_rid('trait_', e['title']), e['title'], e['content'], e['page'], e['source'], e['source_abbr'], 'traits') cnt += 1 db.commit() print(f" {cnt} traits") total_rules += cnt # ── 7. CR Weapon Special Qualities (p143-146) ───────────────────────────── print("CR: Weapon special qualities (p143-146)...") cr_sq = extract_rules_section(RULES/'CR.pdf', 143, 146, 'weapon_qualities', 'Core Rulebook', 'CR') cnt = 0 for e in cr_sq: if len(e['content']) < 20: continue upsert_rule(cur, make_rid('wq_', e['title']), e['title'], e['content'], e['page'], e['source'], e['source_abbr'], 'weapon_qualities') cnt += 1 db.commit() print(f" {cnt} weapon qualities") total_rules += cnt # ── 8. CR Psychic Powers (p195-240) ─────────────────────────────────────── print("CR: Psychic powers (p195-240)...") cr_psychic = extract_rules_section(RULES/'CR.pdf', 195, 241, 'psychic', 'Core Rulebook', 'CR') cnt = 0 for e in cr_psychic: if len(e['content']) < 30: continue upsert_rule(cur, make_rid('psychic_', e['title']), e['title'], e['content'], e['page'], e['source'], e['source_abbr'], 'psychic') cnt += 1 db.commit() print(f" {cnt} psychic powers") total_rules += cnt # ── 9. CR Chapter lore (p23-56) ─────────────────────────────────────────── print("CR: Chapter lore (p23-56)...") cr_chap = extract_rules_section(RULES/'CR.pdf', 23, 57, 'lore', 'Core Rulebook', 'CR') cnt = 0 for e in cr_chap: if len(e['content']) < 50: continue upsert_rule(cur, make_rid('lore_cr_', e['title']), e['title'], e['content'], e['page'], e['source'], e['source_abbr'], 'lore') cnt += 1 db.commit() print(f" {cnt} chapter lore entries from CR") total_rules += cnt # ── 10. CR Specialities (p57-92) ────────────────────────────────────────── print("CR: Specialities (p57-92)...") cr_spec = extract_rules_section(RULES/'CR.pdf', 57, 93, 'speciality', 'Core Rulebook', 'CR') cnt = 0 for e in cr_spec: if len(e['content']) < 50: continue upsert_rule(cur, make_rid('spec_', e['title']), e['title'], e['content'], e['page'], e['source'], e['source_abbr'], 'speciality') cnt += 1 db.commit() print(f" {cnt} speciality entries from CR") total_rules += cnt # ── 11. MoX Bestiary ────────────────────────────────────────────────────── print("MoX: Bestiary...") mox_entries = extract_bestiary_mox(RULES/'MoX.pdf') cnt = 0 for e in mox_entries: if not e['stats'] and not e['profile'].get('description'): continue upsert_bestiary(cur, e['name'], e['stats'], e['profile'], 'Mark of the Xenos', e['page']) cnt += 1 db.commit() print(f" {cnt} bestiary entries from MoX") total_bestiary += cnt # ── 12. FF Chapter lore (p7-96) ─────────────────────────────────────────── print("FF: Chapter lore (p7-96)...") ff_lore = extract_rules_section(RULES/'FF.pdf', 7, 97, 'lore', 'First Founding', 'FF') cnt = 0 for e in ff_lore: if len(e['content']) < 50: continue upsert_rule(cur, make_rid('lore_ff_', e['title']), e['title'], e['content'], e['page'], e['source'], e['source_abbr'], 'lore') cnt += 1 db.commit() print(f" {cnt} lore entries from FF") total_rules += cnt # ── 13. HtC Chapter lore & talents (p9-120) ─────────────────────────────── print("HtC: Chapter content (p9-120)...") htc_content = extract_rules_section(RULES/'HtC.pdf', 9, 121, 'lore', 'Honour the Chapter', 'HtC') cnt = 0 for e in htc_content: if len(e['content']) < 50: continue upsert_rule(cur, make_rid('lore_htc_', e['title']), e['title'], e['content'], e['page'], e['source'], e['source_abbr'], 'lore') cnt += 1 db.commit() print(f" {cnt} entries from HtC") total_rules += cnt # ── 14. RoB lore & rules ────────────────────────────────────────────────── print("RoB: Rules & lore...") rob_content = extract_rules_section(RULES/'RoB.pdf', 1, 256, 'lore', 'Rites of Battle', 'RoB') cnt = 0 for e in rob_content: if len(e['content']) < 50: continue upsert_rule(cur, make_rid('rob_', e['title']), e['title'], e['content'], e['page'], e['source'], e['source_abbr'], 'lore') cnt += 1 db.commit() print(f" {cnt} entries from RoB") total_rules += cnt # ── Final stats ─────────────────────────────────────────────────────────── cur.execute("SELECT COUNT(*) FROM weapons") wcount = cur.fetchone()[0] cur.execute("SELECT COUNT(*) FROM rules") rcount = cur.fetchone()[0] cur.execute("SELECT COUNT(*) FROM bestiary") bcount = cur.fetchone()[0] cur.execute("SELECT category, COUNT(*) FROM rules GROUP BY category ORDER BY COUNT(*) DESC LIMIT 15") cats = cur.fetchall() cur.execute("SELECT source, COUNT(*) FROM weapons GROUP BY source ORDER BY COUNT(*) DESC") wsrc = cur.fetchall() print(f"\n=== Done ===") print(f" Weapons in DB: {wcount}") print(f" Rules in DB: {rcount}") print(f" Bestiary in DB: {bcount}") print("\nWeapons by source:") for src, cnt in wsrc: print(f" {src}: {cnt}") print("\nRules by category:") for cat, cnt2 in cats: print(f" {cat}: {cnt2}") cur.close() db.close() if __name__ == '__main__': run()