231 lines
8.4 KiB
Python
Executable File
231 lines
8.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""BGG Author Fact-Check — prüft Autoren-Zuordnungen gegen BoardGameGeek.
|
||
|
||
Extrahiert Spiel-Autor-Paare aus Artikel-HTML und verifiziert über die
|
||
BGG XML API v2, ob der genannte Autor tatsächlich Designer des Spiels ist.
|
||
|
||
Usage: python3 check_authors.py <artikel.html>
|
||
Exit 0 = alle Autoren korrekt, 1 = Fehler gefunden, 2 = API/Warning
|
||
"""
|
||
import sys, re, urllib.request, urllib.parse, xml.etree.ElementTree as ET, time
|
||
|
||
BGG_KEY_FILE = "/tmp/.bggkey"
|
||
|
||
def get_bgg_key():
|
||
try:
|
||
with open(BGG_KEY_FILE) as f:
|
||
return f.read().strip()
|
||
except FileNotFoundError:
|
||
return None
|
||
|
||
def bgg_search(game_name):
|
||
"""Search BGG for a game, return results with exact matches first."""
|
||
key = get_bgg_key()
|
||
url = f"https://boardgamegeek.com/xmlapi2/search?query={urllib.parse.quote(game_name)}&type=boardgame"
|
||
headers = {}
|
||
if key:
|
||
headers["Authorization"] = f"Bearer {key}"
|
||
|
||
req = urllib.request.Request(url, headers=headers)
|
||
for attempt in range(3):
|
||
try:
|
||
resp = urllib.request.urlopen(req, timeout=15)
|
||
if resp.getcode() == 202:
|
||
time.sleep(3)
|
||
continue
|
||
root = ET.fromstring(resp.read())
|
||
results = []
|
||
for item in root.findall("item"):
|
||
results.append({
|
||
"id": item.attrib.get("id"),
|
||
"name": item.find("name").attrib.get("value", ""),
|
||
"year": item.find("yearpublished").attrib.get("value", "") if item.find("yearpublished") is not None else ""
|
||
})
|
||
# Sort: exact name matches first
|
||
exact = [r for r in results if r["name"].lower() == game_name.lower()]
|
||
others = [r for r in results if r["name"].lower() != game_name.lower()]
|
||
return exact + others
|
||
except Exception:
|
||
time.sleep(2)
|
||
return []
|
||
|
||
def bgg_thing(game_id):
|
||
"""Get BGG thing data including designers."""
|
||
key = get_bgg_key()
|
||
url = f"https://boardgamegeek.com/xmlapi2/thing?id={game_id}&stats=1"
|
||
headers = {}
|
||
if key:
|
||
headers["Authorization"] = f"Bearer {key}"
|
||
req = urllib.request.Request(url, headers=headers)
|
||
for attempt in range(3):
|
||
try:
|
||
resp = urllib.request.urlopen(req, timeout=15)
|
||
if resp.getcode() == 202:
|
||
time.sleep(3)
|
||
continue
|
||
root = ET.fromstring(resp.read())
|
||
item = root.find("item")
|
||
if item is None:
|
||
return None
|
||
designers = []
|
||
for link in item.findall("link"):
|
||
if link.attrib.get("type") == "boardgamedesigner":
|
||
designers.append(link.attrib.get("value", ""))
|
||
return {
|
||
"name": item.find("name").attrib.get("value", "") if item.find("name") is not None else "",
|
||
"designers": designers,
|
||
"year": item.find("yearpublished").attrib.get("value", "") if item.find("yearpublished") is not None else ""
|
||
}
|
||
except Exception:
|
||
time.sleep(2)
|
||
return None
|
||
|
||
def extract_game_author_pairs(html):
|
||
"""Extrahiert Spiel-Autor-Paare über BGG-Links + Infoboxen im Artikel."""
|
||
pairs = []
|
||
|
||
# Finde alle Infoboxen mit BGG-Link
|
||
# Pattern: <p><strong>GAME</strong> · AUTHOR · ...
|
||
infobox_pattern = re.compile(
|
||
r'<div class="infobox">\s*<p[^>]*>'
|
||
r'<strong>([^<]+)</strong>\s*·\s*' # Game name
|
||
r'([^·<]+)' # Author (stop at next · or <)
|
||
r'.*?boardgamegeek\.com/boardgame/(\d+)/', # BGG ID
|
||
re.DOTALL
|
||
)
|
||
|
||
for m in infobox_pattern.finditer(html):
|
||
game = m.group(1).strip()
|
||
author_raw = m.group(2).strip()
|
||
bgg_id = m.group(3)
|
||
# Clean author: remove nested tags
|
||
author = re.sub(r'<[^>]+>', '', author_raw).strip()
|
||
pairs.append({
|
||
"game": game,
|
||
"claimed_author": author,
|
||
"bgg_id": bgg_id,
|
||
"context": m.group(0)[:200]
|
||
})
|
||
|
||
# Pattern 2: Prose claims — find "für ... <strong>GAME</strong>" and backtrack for author
|
||
prose_pattern = re.compile(
|
||
r'(?:gewann|erhielt|bekam|holte).*?f\xfcr\s+.*?<strong>([^<]+)</strong>',
|
||
re.DOTALL | re.IGNORECASE
|
||
)
|
||
|
||
for m in prose_pattern.finditer(html):
|
||
game = m.group(1).strip()
|
||
# Skip if already found in infobox check
|
||
if any(p["game"] == game for p in pairs):
|
||
continue
|
||
# Backtrack up to 500 chars to find preceding <strong>AUTHOR</strong>
|
||
start = max(0, m.start() - 500)
|
||
before = html[start:m.start()]
|
||
# Find the LAST <strong> before this match (likely the author)
|
||
author_matches = re.findall(r'<strong>([^<]+)</strong>', before)
|
||
if author_matches:
|
||
author = author_matches[-1].strip()
|
||
# Search BGG for this game
|
||
results = bgg_search(game)
|
||
if results:
|
||
pairs.append({
|
||
"game": game,
|
||
"claimed_author": author,
|
||
"bgg_id": results[0]["id"],
|
||
"context": f"...{author}...gewann...f\xfcr {game}..."
|
||
})
|
||
|
||
return pairs
|
||
|
||
def normalize_name(name):
|
||
"""Normalize author name for comparison."""
|
||
return re.sub(r'\s+', ' ', name.strip().lower())
|
||
|
||
def main():
|
||
if len(sys.argv) < 2:
|
||
print("Usage: check_authors.py <artikel.html>")
|
||
sys.exit(1)
|
||
|
||
with open(sys.argv[1]) as f:
|
||
html = f.read()
|
||
|
||
# Extract body
|
||
body_match = re.search(r'<article>(.*?)</article>', html, re.DOTALL)
|
||
body = body_match.group(1) if body_match else html
|
||
|
||
pairs = extract_game_author_pairs(body)
|
||
|
||
if not pairs:
|
||
print("ℹ️ Keine Spiel-Autor-Paare zur Prüfung gefunden.")
|
||
sys.exit(0)
|
||
|
||
errors = 0
|
||
warnings = 0
|
||
|
||
for pair in pairs:
|
||
game = pair["game"]
|
||
print(f"\n🔍 Prüfe: »{game}«")
|
||
|
||
# Use BGG ID directly if available, otherwise search
|
||
bgg_id = pair.get("bgg_id")
|
||
if bgg_id:
|
||
thing = bgg_thing(bgg_id)
|
||
else:
|
||
results = bgg_search(game)
|
||
if not results:
|
||
print(f" ⚠️ BGG nicht gefunden für »{game}«")
|
||
warnings += 1
|
||
continue
|
||
thing = bgg_thing(results[0]["id"])
|
||
|
||
if not thing:
|
||
print(f" ⚠️ BGG-Details nicht abrufbar (ID {bgg_id or '?'})")
|
||
warnings += 1
|
||
continue
|
||
|
||
# Verify game name matches (avoid false positives from bad search results)
|
||
bgg_name = thing["name"].lower()
|
||
claimed_lower = game.lower()
|
||
if bgg_name != claimed_lower and claimed_lower not in bgg_name and bgg_name not in claimed_lower:
|
||
print(f" ⚠️ BGG fand »{thing['name']}« statt »{game}« — Spiel vermutlich zu neu für BGG")
|
||
warnings += 1
|
||
continue
|
||
|
||
designers = [normalize_name(d) for d in thing["designers"]]
|
||
claimed = normalize_name(pair.get("claimed_author", ""))
|
||
|
||
print(f" BGG: {thing['name']} ({thing['year']})")
|
||
print(f" Designer: {', '.join(thing['designers']) if thing['designers'] else 'KEINE'}")
|
||
|
||
if claimed and claimed not in " ".join(designers):
|
||
# Try partial match (last name)
|
||
claimed_parts = claimed.split()
|
||
found = False
|
||
for d in designers:
|
||
if any(part in d for part in claimed_parts if len(part) > 2):
|
||
found = True
|
||
break
|
||
if not found:
|
||
print(f" ❌ FAKTENFEHLER: »{pair.get('claimed_author', '?')}« ist NICHT Designer von »{thing['name']}«!")
|
||
errors += 1
|
||
else:
|
||
print(f" ✅ Teil-Match für »{pair.get('claimed_author', '?')}«")
|
||
elif claimed:
|
||
print(f" ✅ Autor bestätigt")
|
||
else:
|
||
print(f" ℹ️ Kein expliziter Autor-Anspruch zum Abgleichen")
|
||
|
||
print(f"\n{'='*50}")
|
||
if errors == 0 and warnings == 0:
|
||
print("✅ ALLE AUTOREN-ZUORDNUNGEN KORREKT")
|
||
sys.exit(0)
|
||
elif errors == 0:
|
||
print(f"⚠️ {warnings} Warnung(en) — KEINE Faktenfehler")
|
||
sys.exit(0)
|
||
else:
|
||
print(f"❌ {errors} FAKTENFEHLER in Autoren-Zuordnungen!")
|
||
sys.exit(1)
|
||
|
||
if __name__ == "__main__":
|
||
main()
|