Files
dwroller/scripts/scrape-rulebooks.py

662 lines
30 KiB
Python
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.

#!/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()