From 77f6af8817fdfd63055d5f4a19bc4c0bb312c97d Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 25 Apr 2026 08:36:27 +0200 Subject: [PATCH] =?UTF-8?q?Breed-Enricher:=20verbesserte=20Wikipedia-Suche?= =?UTF-8?q?=20+=20retry=5Fmissed=20f=C3=BCr=20ki=5Fenriched=3D2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _NAME_ALIASES-Dict mit 20 Rassen-Mappings (z.B. Zwergpinscher → Miniature Pinscher) - exsentences=5 statt exintro=1 + Threshold 80 statt 150 Zeichen (fixiert Zwergpinscher, Spinone, Thai Bangkaew) - Neue Fallback-Kette: direkt DE/EN → name_de → Aliasse → normalisierte Namen → opensearch DE/EN - Neue Funktion retry_missed(limit) für ki_enriched=2 ohne DB-Reset - CLI-Flags --retry-missed und --dry-run --- backend/scraper/breed_enricher.py | 426 +++++++++++++++++++++--------- 1 file changed, 304 insertions(+), 122 deletions(-) diff --git a/backend/scraper/breed_enricher.py b/backend/scraper/breed_enricher.py index 0a44e98..97a706e 100644 --- a/backend/scraper/breed_enricher.py +++ b/backend/scraper/breed_enricher.py @@ -2,7 +2,7 @@ BAN YARO — Rassen-Anreicherung (Wikipedia-grounded) Strategie: - 1. Wikipedia-Einleitungstext abrufen (de → en Fallback) + 1. Wikipedia-Einleitungstext abrufen (de → en Fallback + opensearch + Aliasse) 2. Claude Haiku extrahiert Fakten NUR aus dem Quelltext 3. Kein Wikipedia-Artikel → ki_enriched=2, ki_source='none' (nicht veröffentlichen) @@ -31,6 +31,30 @@ _HAIKU_MODEL = "claude-haiku-4-5-20251001" _WP_HEADERS = {"User-Agent": "Banyaro/1.0 (https://banyaro.de; mail@banyaro.de) httpx"} +# Bekannte Name-Mappings: DB-Name → Liste von Wikipedia-Titeln die zu versuchen sind +_NAME_ALIASES: dict[str, list[str]] = { + "Staffordshire Bullterrier": ["Staffordshire Bull Terrier"], + "Tibet-Spaniel": ["Tibetan Spaniel"], + "Tschechischer Terrier": ["Český teriér", "Czech Terrier", "Böhmischer Terrier"], + "Weißer Schweizer Schäferhund": ["White Swiss Shepherd Dog", "Berger Blanc Suisse"], + "Zwergpinscher": ["Miniature Pinscher"], + "wire-haired dachshund": ["Wire-haired Dachshund", "Dachshund"], + "Spinone Italiano": ["Spinone"], + "Thai Bangkaew Dog": ["Thai Bangkaew"], + "Terceira-Dogge": ["Fila da Terceira"], + "Ungarische Bracke - Transylvanischer Laufhund": ["Transylvanian Hound"], + "anglo-français de moyenne vénerie": ["Anglo-Français de Petite Vénerie", "Anglo-Français de moyenne vénerie"], + "artésien-normand": ["Basset Artésien Normand"], + "kanadischer Eskinohund": ["Canadian Eskimo Dog"], + "Wäller": ["Wäller"], + "Serbischer Laufhund": ["Srpski gonič", "Serbian Hound"], + "Serbian sheep dog": ["Sharplaninac", "Šarplaninac"], + "Slovenský hrubosrstý stavač": ["Slovak Rough-haired Pointer"], + "Spino siciliano": ["Cirneco dell'Etna"], + "Svensk vit älghund": ["Jamthund", "Jämthund", "Swedish Elkhound"], + "Walker Foxhound": ["Treeing Walker Coonhound"], +} + # Übersetzungstabelle für englische TheDogAPI-Temperamentwörter _TEMPER_DE: dict[str, str] = { "adaptable": "anpassungsfähig", @@ -175,60 +199,164 @@ def _parse_json(raw: str) -> dict: raise ValueError(f"Kein gültiges JSON in Antwort gefunden: {raw[:200]}") -async def _fetch_wikipedia_text(name: str) -> tuple[str | None, str | None]: - """Holt den Einleitungstext eines Wikipedia-Artikels (de → en Fallback). +def _normalize_name(name: str) -> list[str]: + """Erzeugt normalisierte Suchvarianten für einen Rassennamen.""" + variants = [] + # Bindestriche durch Leerzeichen ersetzen + no_dash = name.replace("-", " ") + if no_dash != name: + variants.append(no_dash) + # Gängige Suffixe abschneiden und Rest behalten + suffixes = [ + " Sheepdog", " Dog", " Hound", " Terrier", " Spaniel", + " Shepherd", " Pointer", " Retriever", " Bulldog", + ] + for suffix in suffixes: + if name.lower().endswith(suffix.lower()) and len(name) > len(suffix) + 3: + variants.append(name[: -len(suffix)].strip()) + break + return variants + + +async def _wp_direct(client: httpx.AsyncClient, lang: str, title: str) -> tuple[str | None, str | None]: + """Holt Artikeltext via titles-Parameter (direkte Suche). + + Nutzt exsentences=5 statt exintro=1, damit Artikel mit sehr kurzer Einleitung + (z.B. Zwergpinscher DE: 143 Zeichen) trotzdem genug Text liefern. + """ + try: + resp = await client.get( + f"https://{lang}.wikipedia.org/w/api.php", + params={ + "action": "query", + "titles": title, + "prop": "extracts", + "exsentences": 5, + "explaintext": 1, + "format": "json", + "redirects": 1, + }, + ) + pages = resp.json().get("query", {}).get("pages", {}) + for page in pages.values(): + if page.get("pageid", -1) == -1: + continue + text = page.get("extract", "").strip() + if len(text) > 80: + return text[:3000], lang + except Exception as e: + logger.debug("WP direkt (%s/%s) fehlgeschlagen: %s", lang, title, e) + return None, None + + +async def _wp_opensearch(client: httpx.AsyncClient, lang: str, query: str) -> tuple[str | None, str | None]: + """Sucht via opensearch und holt dann den ersten Treffer-Artikel.""" + try: + resp = await client.get( + f"https://{lang}.wikipedia.org/w/api.php", + params={ + "action": "opensearch", + "search": query, + "limit": 1, + "format": "json", + }, + ) + results = resp.json() + # opensearch gibt [query, [titles], [descriptions], [urls]] zurück + titles = results[1] if len(results) > 1 else [] + if not titles: + return None, None + found_title = titles[0] + logger.debug("Opensearch (%s) '%s' → '%s'", lang, query, found_title) + return await _wp_direct(client, lang, found_title) + except Exception as e: + logger.debug("Opensearch (%s/%s) fehlgeschlagen: %s", lang, query, e) + return None, None + + +async def _fetch_wikipedia_text( + name: str, name_de: str | None = None +) -> tuple[str | None, str | None]: + """Holt den Einleitungstext eines Wikipedia-Artikels. + + Fallback-Kette: + 1. Direkte Suche DE (Originalname) + 2. Direkte Suche EN (Originalname) + 3. Direkte Suche DE (name_de, falls vorhanden und verschieden) + 4. Aliasse aus _NAME_ALIASES (DE + EN je Alias) + 5. Normalisierte Namensvarianten direkt DE + EN + 6. Opensearch DE (Originalname) + 7. Opensearch EN (Originalname) Returns: (text, lang) oder (None, None) wenn kein Artikel gefunden. """ - for lang in ("de", "en"): - try: - async with httpx.AsyncClient(timeout=10, headers=_WP_HEADERS) as client: - resp = await client.get( - f"https://{lang}.wikipedia.org/w/api.php", - params={ - "action": "query", - "titles": name, - "prop": "extracts", - "exintro": 1, - "explaintext": 1, - "format": "json", - "redirects": 1, - }, - ) - pages = resp.json().get("query", {}).get("pages", {}) - for page in pages.values(): - if page.get("pageid", -1) == -1: - continue - text = page.get("extract", "").strip() - if len(text) > 150: - return text[:3000], lang - except Exception as e: - logger.debug("Wikipedia-Text (%s) fehlgeschlagen für %s: %s", lang, name, e) + async with httpx.AsyncClient(timeout=10, headers=_WP_HEADERS) as client: + + # 1+2: Direkte Suche mit Originalnamen + for lang in ("de", "en"): + text, lang_found = await _wp_direct(client, lang, name) + if text: + return text, lang_found + + # 3: Direkte Suche mit name_de (falls vorhanden und nicht identisch) + if name_de and name_de.strip() and name_de.strip() != name: + for lang in ("de", "en"): + text, lang_found = await _wp_direct(client, lang, name_de.strip()) + if text: + logger.debug("name_de-Treffer (%s): %s → %s", lang, name, name_de) + return text, lang_found + + # 4: Bekannte Aliasse + for alias in _NAME_ALIASES.get(name, []): + for lang in ("de", "en"): + text, lang_found = await _wp_direct(client, lang, alias) + if text: + logger.debug("Alias-Treffer (%s): %s → %s", lang, name, alias) + return text, lang_found + + # 5: Normalisierte Namensvarianten + for variant in _normalize_name(name): + for lang in ("de", "en"): + text, lang_found = await _wp_direct(client, lang, variant) + if text: + logger.debug("Normalisierungs-Treffer (%s): %s → %s", lang, name, variant) + return text, lang_found + + # 6+7: Opensearch als letzter Ausweg + for lang in ("de", "en"): + text, lang_found = await _wp_opensearch(client, lang, name) + if text: + logger.debug("Opensearch-Treffer (%s): %s", lang, name) + return text, lang_found + return None, None async def _fetch_wikimedia_photo(name: str) -> str | None: """Sucht ein lizenzfreies Foto via Wikipedia pageimages API (de → en Fallback).""" + # Auch Aliasse probieren + names_to_try = [name] + _NAME_ALIASES.get(name, []) for lang in ("de", "en"): - try: - async with httpx.AsyncClient(timeout=8, headers=_WP_HEADERS) as client: - resp = await client.get( - f"https://{lang}.wikipedia.org/w/api.php", - params={ - "action": "query", - "titles": name, - "prop": "pageimages", - "format": "json", - "pithumbsize": 800, - "redirects": 1, - }, - ) - pages = resp.json().get("query", {}).get("pages", {}) - for page in pages.values(): - if "thumbnail" in page: - return page["thumbnail"]["source"] - except Exception as e: - logger.debug("Wikimedia-Foto (%s) fehlgeschlagen für %s: %s", lang, name, e) + for title in names_to_try: + try: + async with httpx.AsyncClient(timeout=8, headers=_WP_HEADERS) as client: + resp = await client.get( + f"https://{lang}.wikipedia.org/w/api.php", + params={ + "action": "query", + "titles": title, + "prop": "pageimages", + "format": "json", + "pithumbsize": 800, + "redirects": 1, + }, + ) + pages = resp.json().get("query", {}).get("pages", {}) + for page in pages.values(): + if "thumbnail" in page: + return page["thumbnail"]["source"] + except Exception as e: + logger.debug("Wikimedia-Foto (%s/%s) fehlgeschlagen: %s", lang, title, e) return None @@ -258,7 +386,89 @@ async def _haiku_complete(prompt: str) -> str: return resp.content[0].text.strip() -async def enrich_breeds(limit: int = 10) -> int: +async def _enrich_one(rasse: dict, dry_run: bool = False) -> bool: + """Reichert eine einzelne Rasse an. Gibt True zurück wenn erfolgreich.""" + name = rasse["name"] + name_de = rasse.get("name_de") + rasse_id = rasse["id"] + + # 1. Wikipedia-Text holen + wiki_text, wiki_lang = await _fetch_wikipedia_text(name, name_de) + + if not wiki_text: + if not dry_run: + with db() as conn: + conn.execute( + "UPDATE wiki_rassen SET ki_enriched=2, ki_source='none' WHERE id=?", + (rasse_id,), + ) + logger.info("Kein Wikipedia-Artikel: %s → übersprungen", name) + await asyncio.sleep(0.5) + return False + + if dry_run: + logger.info("[DRY-RUN] Gefunden: %s (WP-%s, %d Zeichen)", name, wiki_lang.upper(), len(wiki_text)) + return True + + # 2. Haiku extrahiert Fakten aus dem Quelltext + prompt = _PROMPT.format(name=name, lang=wiki_lang.upper(), wiki_text=wiki_text) + try: + raw = await _haiku_complete(prompt) + except Exception as e: + logger.error("Haiku-Anfrage fehlgeschlagen für %s: %s", name, e) + await asyncio.sleep(3) + return False + + try: + data = _parse_json(raw) + except ValueError as e: + logger.warning("JSON-Parsing fehlgeschlagen für %s: %s", name, e) + await asyncio.sleep(2) + return False + + # 3. DB-Update + updates = { + k: v for k, v in data.items() + if k in _DIRECT_FIELDS and v is not None + } + if "temperament" in updates: + updates["temperament"] = translate_temperament(updates["temperament"]) + updates["ki_enriched"] = 1 + updates["ki_model"] = _HAIKU_MODEL + updates["ki_source"] = f"wikipedia_{wiki_lang}" + + cols = ", ".join(f"{k}=?" for k in updates) + values = list(updates.values()) + [rasse_id] + + try: + with db() as conn: + conn.execute(f"UPDATE wiki_rassen SET {cols} WHERE id=?", values) + logger.info("Rasse angereichert: %s (%d Felder, WP-%s)", + name, len(updates) - 2, wiki_lang.upper()) + except Exception as e: + logger.error("DB-Update fehlgeschlagen für %s: %s", name, e) + await asyncio.sleep(2) + return False + + # 4. Foto holen wenn noch keins vorhanden + if not rasse.get("foto_url"): + foto_url = await _fetch_wikimedia_photo(name) + if foto_url: + try: + with db() as conn: + conn.execute( + "UPDATE wiki_rassen SET foto_url=? WHERE id=?", + (foto_url, rasse_id), + ) + logger.info("Wikimedia-Foto gesetzt: %s", name) + except Exception as e: + logger.error("Foto-Update fehlgeschlagen für %s: %s", name, e) + + await asyncio.sleep(1) + return True + + +async def enrich_breeds(limit: int = 10, dry_run: bool = False) -> int: """ Reichert bis zu `limit` Rassen an (ki_enriched = 0). @@ -269,7 +479,7 @@ async def enrich_breeds(limit: int = 10) -> int: """ with db() as conn: rassen = conn.execute( - """SELECT id, name, slug, herkunft, foto_url FROM wiki_rassen + """SELECT id, name, slug, herkunft, foto_url, name_de FROM wiki_rassen WHERE ki_enriched = 0 ORDER BY name ASC LIMIT ?""", @@ -281,81 +491,42 @@ async def enrich_breeds(limit: int = 10) -> int: return 0 enriched_count = 0 - for rasse in rassen: - name = rasse["name"] - rasse_id = rasse["id"] - - # 1. Wikipedia-Text holen - wiki_text, wiki_lang = await _fetch_wikipedia_text(name) - - if not wiki_text: - # Kein Artikel → markieren und überspringen - with db() as conn: - conn.execute( - "UPDATE wiki_rassen SET ki_enriched=2, ki_source='none' WHERE id=?", - (rasse_id,), - ) - logger.info("Kein Wikipedia-Artikel: %s → übersprungen", name) - await asyncio.sleep(0.5) - continue - - # 2. Haiku extrahiert Fakten aus dem Quelltext - prompt = _PROMPT.format(name=name, lang=wiki_lang.upper(), wiki_text=wiki_text) - try: - raw = await _haiku_complete(prompt) - except Exception as e: - logger.error("Haiku-Anfrage fehlgeschlagen für %s: %s", name, e) - await asyncio.sleep(3) - continue - - try: - data = _parse_json(raw) - except ValueError as e: - logger.warning("JSON-Parsing fehlgeschlagen für %s: %s", name, e) - await asyncio.sleep(2) - continue - - # 3. DB-Update - updates = { - k: v for k, v in data.items() - if k in _DIRECT_FIELDS and v is not None - } - if "temperament" in updates: - updates["temperament"] = translate_temperament(updates["temperament"]) - updates["ki_enriched"] = 1 - updates["ki_model"] = _HAIKU_MODEL - updates["ki_source"] = f"wikipedia_{wiki_lang}" - - cols = ", ".join(f"{k}=?" for k in updates) - values = list(updates.values()) + [rasse_id] - - try: - with db() as conn: - conn.execute(f"UPDATE wiki_rassen SET {cols} WHERE id=?", values) - logger.info("Rasse angereichert: %s (%d Felder, WP-%s)", - name, len(updates) - 2, wiki_lang.upper()) + if await _enrich_one(rasse, dry_run=dry_run): enriched_count += 1 - except Exception as e: - logger.error("DB-Update fehlgeschlagen für %s: %s", name, e) - await asyncio.sleep(2) - continue - # 4. Foto holen wenn noch keins vorhanden - if not rasse["foto_url"]: - foto_url = await _fetch_wikimedia_photo(name) - if foto_url: - try: - with db() as conn: - conn.execute( - "UPDATE wiki_rassen SET foto_url=? WHERE id=?", - (foto_url, rasse_id), - ) - logger.info("Wikimedia-Foto gesetzt: %s", name) - except Exception as e: - logger.error("Foto-Update fehlgeschlagen für %s: %s", name, e) + return enriched_count - await asyncio.sleep(1) + +async def retry_missed(limit: int = 70, dry_run: bool = False) -> int: + """ + Versucht erneut, Rassen anzureichern die beim ersten Durchlauf + keinen Wikipedia-Artikel hatten (ki_enriched = 2). + + Setzt ki_enriched NICHT zurück — verarbeitet direkt. + Bei Erfolg wird ki_enriched=1 gesetzt (überschreibt 2). + Bei erneutem Misserfolg bleibt ki_enriched=2. + + Returns: Anzahl erfolgreich angereicherter Rassen. + """ + with db() as conn: + rassen = conn.execute( + """SELECT id, name, slug, herkunft, foto_url, name_de FROM wiki_rassen + WHERE ki_enriched = 2 + ORDER BY name ASC + LIMIT ?""", + (limit,), + ).fetchall() + + if not rassen: + logger.info("Keine übersprungenen Rassen zum Retry gefunden.") + return 0 + + logger.info("Retry für %d Rassen mit ki_enriched=2 ...", len(rassen)) + enriched_count = 0 + for rasse in rassen: + if await _enrich_one(rasse, dry_run=dry_run): + enriched_count += 1 return enriched_count @@ -405,14 +576,25 @@ if __name__ == "__main__": logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") parser = argparse.ArgumentParser(description="Rassen-Anreicherung (Wikipedia-grounded)") - parser.add_argument("--limit", type=int, default=10) + parser.add_argument("--limit", type=int, default=10, + help="Maximale Anzahl zu verarbeitender Rassen") parser.add_argument("--reset-gemma", action="store_true", help="Gemma-Einträge zurücksetzen bevor angereichert wird") + parser.add_argument("--retry-missed", action="store_true", + help="Rassen mit ki_enriched=2 erneut versuchen") + parser.add_argument("--dry-run", action="store_true", + help="Nur Wikipedia-Suche testen, keine DB-Änderungen") args = parser.parse_args() if args.reset_gemma: n = reset_gemma_entries() print(f"Reset: {n} Gemma-Einträge zurückgesetzt") - count = asyncio.run(enrich_breeds(args.limit)) - print(f"Angereichert: {count} Rassen") + if args.retry_missed: + count = asyncio.run(retry_missed(args.limit, dry_run=args.dry_run)) + prefix = "[DRY-RUN] " if args.dry_run else "" + print(f"{prefix}Retry: {count} von {args.limit} Rassen angereichert") + else: + count = asyncio.run(enrich_breeds(args.limit, dry_run=args.dry_run)) + prefix = "[DRY-RUN] " if args.dry_run else "" + print(f"{prefix}Angereichert: {count} Rassen")