0c269a97ee
Der SAVEPOINT v3.16 vermutete, das Aehnlichkeits-Gate 0,55 sei "grosszuegig" und Jikan stehe in der Kette zu frueh (vor OMDb). Nachgerechnet - titel_aehnlichkeit ist eine reine Funktion - ergibt sich ein anderes Bild als vermutet: "Alien" vs. "Alien 9" -> 0,83 TRIFFT "Inception" vs. "Deception" -> 0,78 TRIFFT "Hero" vs. "Heroman" -> 0,73 TRIFFT "The Dark Knight" vs. "Dark Knight Rises" -> 0,69 TRIFFT Ein Kinofilm, den TMDB verpasst, bekam damit Anime-Metadaten mit Confidence 0,85 - und OMDb, das ihn kennt, wurde nie gefragt. Bemerkenswert dabei, und das widerlegt die Vermutung "einfach zu niedrig": Fuer den Fall, fuer den Jikan ueberhaupt eingebaut wurde, ist das Gate sogar zu HOCH. "Evangelion 2.22" gegen den MAL-Titel "Evangelion: 2.0 You Can (Not) Advance" ergibt 0,51 und faellt durch. Eine einzelne Zahl kann beides nicht leisten. Deshalb zwei Schwellen statt Umsortieren (Reihenfolge bleibt, AGENTS Regel B): Nur ein praktisch exakter Titel (>= 0,9) beendet die Kette. Ein unscharfer Treffer wird GEMERKT, dann wird OMDb gefragt - und erst wenn OMDb nichts hat, kommt er als VORSCHLAG mit Confidence 0,6 zum Zug. Damit gewinnt "exakt" immer gegen "unscharf", egal aus welcher Quelle. Antwort auf die Commander-Frage "JIKAN ist drin - wird das genutzt?": ja, und ab jetzt an der richtigen Stelle. Ehrlicher Vorbehalt: Live gegengeprueft ist das nicht - MyAnimeList war waehrend dieser Sitzung durchweg weg (Jikan antwortete HTTP 504 auf jede Anfrage). Die Arithmetik des Gates ist davon unberuehrt und in test_jikan_helpers.py festgehalten, die Kettenlogik in test_prescan_helpers.py. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
130 lines
5.1 KiB
Python
130 lines
5.1 KiB
Python
"""Jikan API Client (MyAnimeList) — kostenlose Anime-Datenbank OHNE API-Key.
|
|
|
|
Commander-Fund 23.07.: Für Anime-Discs (häufiger Fall im Heimlab) ist MAL die
|
|
präziseste Quelle — „Evangelion: 2.22" existiert dort exakt, mit Poster und
|
|
Beschreibung. Kein Key, Rate-Limit 3 req/s (für Disc-Erkennung irrelevant).
|
|
Doku: https://docs.api.jikan.moe/
|
|
"""
|
|
|
|
from difflib import SequenceMatcher
|
|
from typing import Dict, Optional
|
|
|
|
import requests
|
|
|
|
from cache import get, set as cache_set
|
|
|
|
JIKAN_BASE_URL = "https://api.jikan.moe/v4"
|
|
|
|
|
|
def titel_aehnlichkeit(a: str, b: str) -> float:
|
|
"""Normalisierte Titel-Ähnlichkeit 0..1 (pure Funktion, testbar)."""
|
|
def norm(t: str) -> str:
|
|
return "".join(c for c in t.lower() if c.isalnum() or c == " ").strip()
|
|
|
|
a_n, b_n = norm(a), norm(b)
|
|
if not a_n or not b_n:
|
|
return 0.0
|
|
return SequenceMatcher(None, a_n, b_n).ratio()
|
|
|
|
|
|
class JikanClient:
|
|
# Unter dieser Ähnlichkeit gibt lookup() gar nichts zurück.
|
|
MINDEST_AEHNLICHKEIT = 0.55
|
|
# Ab dieser Ähnlichkeit gilt der Treffer als SICHER und beendet die
|
|
# Metadaten-Kette. Dazwischen ist er nur ein Vorschlag: erst wird OMDb
|
|
# gefragt, und nur wenn das nichts hat, kommt er zum Zug.
|
|
#
|
|
# Warum zwei Schwellen (26.07.2026, ausgerechnet mit titel_aehnlichkeit):
|
|
# Eine einzelne Zahl kann beide Aufgaben nicht leisten. 0,55 lässt kurze
|
|
# Kinofilm-Titel durch, die hier nichts zu suchen haben („Alien" gegen
|
|
# „Alien 9" = 0,83), ist aber gleichzeitig zu streng für den Fall, für den
|
|
# diese Quelle gebaut wurde („Evangelion 2.22" gegen „Evangelion: 2.0 You
|
|
# Can (Not) Advance" = 0,51). Messwerte in test_jikan_helpers.py.
|
|
SICHER_AEHNLICHKEIT = 0.9
|
|
|
|
def __init__(self):
|
|
self.session = requests.Session()
|
|
|
|
def suche(self, title: str) -> list:
|
|
"""Roh-Kandidaten für die manuelle Korrektur (ohne Ähnlichkeits-Gate)."""
|
|
try:
|
|
response = self.session.get(
|
|
f"{JIKAN_BASE_URL}/anime",
|
|
params={"q": title, "limit": 5, "sfw": "true"},
|
|
timeout=10,
|
|
)
|
|
response.raise_for_status()
|
|
eintraege = response.json().get("data") or []
|
|
except Exception:
|
|
return []
|
|
ergebnisse = []
|
|
for e in eintraege:
|
|
bilder = (e.get("images") or {}).get("jpg") or {}
|
|
ergebnisse.append({
|
|
"title": e.get("title_english") or e.get("title") or "",
|
|
"year": e.get("year"),
|
|
"poster": bilder.get("large_image_url") or bilder.get("image_url") or "",
|
|
"overview": (e.get("synopsis") or "")[:200],
|
|
"type": "movie" if e.get("type") == "Movie" else "tv",
|
|
"source": "jikan",
|
|
"id": f"mal-{e.get('mal_id', '')}",
|
|
})
|
|
return ergebnisse
|
|
|
|
def lookup(self, title: str) -> Optional[Dict]:
|
|
"""Sucht per Titel; nimmt den ÄHNLICHSTEN Treffer, nicht blind den ersten."""
|
|
cache_key = f"jikan:{title.lower()}"
|
|
cached = get(cache_key)
|
|
if cached:
|
|
return cached
|
|
|
|
# Jikan wirft sporadisch 504 (Gratis-Dienst) — ein Retry rettet die
|
|
# meisten Fälle, mehr wäre Belästigung.
|
|
eintraege = None
|
|
for versuch in range(2):
|
|
try:
|
|
response = self.session.get(
|
|
f"{JIKAN_BASE_URL}/anime",
|
|
params={"q": title, "limit": 5, "sfw": "true"},
|
|
timeout=10,
|
|
)
|
|
response.raise_for_status()
|
|
eintraege = response.json().get("data") or []
|
|
break
|
|
except Exception as e:
|
|
print(f"Jikan API Error (Versuch {versuch + 1}): {e}")
|
|
if versuch == 0:
|
|
import time
|
|
time.sleep(2)
|
|
if eintraege is None:
|
|
return None
|
|
|
|
bester, bester_score = None, 0.0
|
|
for eintrag in eintraege:
|
|
varianten = [eintrag.get("title") or ""]
|
|
if eintrag.get("title_english"):
|
|
varianten.append(eintrag["title_english"])
|
|
score = max(titel_aehnlichkeit(title, v) for v in varianten)
|
|
if score > bester_score:
|
|
bester, bester_score = eintrag, score
|
|
|
|
if not bester or bester_score < self.MINDEST_AEHNLICHKEIT:
|
|
return None
|
|
|
|
bilder = (bester.get("images") or {}).get("jpg") or {}
|
|
ergebnis = {
|
|
"type": "movie" if bester.get("type") == "Movie" else "tv",
|
|
"id": f"mal-{bester.get('mal_id', '')}",
|
|
"title": bester.get("title_english") or bester.get("title") or title,
|
|
"year": bester.get("year"),
|
|
"overview": (bester.get("synopsis") or "")[:600],
|
|
"poster_path": bilder.get("large_image_url") or bilder.get("image_url") or "",
|
|
"backdrop_path": "",
|
|
"runtime": 0,
|
|
"genres": [g.get("name", "") for g in (bester.get("genres") or []) if g.get("name")],
|
|
"source": "jikan",
|
|
"match_score": round(bester_score, 2),
|
|
}
|
|
cache_set(cache_key, ergebnis)
|
|
return ergebnis
|