MAl and Kavita update

This commit is contained in:
2026-05-23 10:21:09 +02:00
parent d5817e908a
commit 852f6b84ef
4 changed files with 675 additions and 57 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# General
.DS_Store
.idea
.idea/
.vscode/
# Node modules (all subprojects)
+3 -4
View File
@@ -145,10 +145,9 @@ class ComicInfoBuilder:
api_base_url=api_base_url,
request_timeout=request_timeout,
session=self._session))
self._mal_resolver = (mal_resolver
or MALResolver(
request_timeout=request_timeout,
session=self._session))
# MALResolver is a Singleton — it manages its own session and caches.
self._mal_resolver = mal_resolver or MALResolver(
request_timeout=request_timeout)
self._metadata: "dict | None" = None
self._pages: list[dict] = []
+431
View File
@@ -0,0 +1,431 @@
"""
kavita_person_updater.py
========================
Synchronises Kavita person / character records with MyAnimeList data.
For every character and staff member that MAL knows about for a given manga
the updater:
1. Searches Kavita for a matching Person record (by name similarity /
alias match, configurable threshold).
2. Sets the MAL ID on the Kavita person if it is not yet linked.
3. Uploads the MAL profile image when the cover is not locked and has
not been set in a previous sync run.
4. Populates the description field when Kavita has none and MAL provides
an 'about' text (requires an extra Jikan request per character; only
performed when update_descriptions=True).
Kavita API version
------------------
Tested against Kavita 0.9.0.2.
Authentication
--------------
Uses the `x-api-key` header (API key from Kavita user settings).
No JWT login is required.
Relevant endpoints (Kavita 0.9.0.2)
-------------------------------------
GET /api/Person/search find persons by name / alias
POST /api/Person/update write metadata (malId, description, …)
POST /api/Upload/person set cover image (base64 data URI)
POST /api/Upload/upload-by-url download an external URL to temp storage
(used as an alternative upload path)
Cover upload flow
-----------------
The image is downloaded locally, base64-encoded, and sent as a data URI
to POST /api/Upload/person. This is more reliable than the
upload-by-url → upload/person two-step because it avoids Kavita's temp
file handling (which had known issues in 0.8.x 0.9.x, GitHub #3900).
Dependencies
------------
requests -> pip install requests
"""
from __future__ import annotations
import base64
import difflib
import requests
from MALResolver import MALResolver
class KavitaPersonUpdater:
"""
Syncs Kavita Person records with MyAnimeList data.
Parameters
----------
kavita_base_url : Base URL of the Kavita server, e.g. "http://192.168.2.2:5000"
api_key : Kavita API key (Settings → User → API key)
mal_resolver : Shared MALResolver singleton (created automatically if omitted)
request_timeout : HTTP timeout in seconds for both Kavita and image requests
min_name_score : Minimum difflib similarity ratio (01) required to accept a
Kavita person as a match for a MAL name. Default 0.80.
"""
def __init__(self, kavita_base_url: str, api_key: str, *,
mal_resolver: "MALResolver | None" = None,
request_timeout: int = 30,
min_name_score: float = 0.80):
self._base = kavita_base_url.rstrip("/")
self._timeout = request_timeout
self._min_score = min_name_score
self._mal = mal_resolver or MALResolver()
# Session used for Kavita API calls.
self._session = requests.Session()
self._session.headers.update({
"x-api-key": api_key,
"Content-Type": "application/json",
"Accept": "application/json",
})
# Plain session used to download external images (MAL CDN etc.).
# Must NOT carry the Kavita API headers — Accept: application/json
# would prevent MAL CDN from returning the image bytes.
self._image_session = requests.Session()
self._image_session.headers.update({
"User-Agent": "KavitaPersonUpdater/1.0",
})
# Cache: normalised name -> list of PersonDto dicts (best matches first)
self._person_search_cache: dict[str, list[dict]] = {}
# ------------------------------------------------------------------
# Public: combined update
# ------------------------------------------------------------------
def update_for_manga(self, mal_manga_id: int, *,
update_covers: bool = True,
update_descriptions: bool = True) -> dict:
"""
Runs a full update pass for both characters and staff of the manga.
Returns
-------
{
"characters": {"updated": n, "skipped": n, "not_found": n},
"staff": {"updated": n, "skipped": n, "not_found": n},
}
"""
return {
"characters": self.update_characters(
mal_manga_id,
update_covers=update_covers,
update_descriptions=update_descriptions),
"staff": self.update_staff(
mal_manga_id,
update_covers=update_covers,
update_descriptions=update_descriptions),
}
# ------------------------------------------------------------------
# Public: character update
# ------------------------------------------------------------------
def update_characters(self, mal_manga_id: int, *,
update_covers: bool = True,
update_descriptions: bool = True) -> dict:
"""
Updates Kavita persons that match MAL characters for the manga.
Returns {"updated": n, "skipped": n, "not_found": n}.
"""
entries = self._mal.get_characters_detailed(mal_manga_id)
return self._sync_entries(entries, "character",
update_covers=update_covers,
update_descriptions=update_descriptions)
# ------------------------------------------------------------------
# Public: staff update
# ------------------------------------------------------------------
def update_staff(self, mal_manga_id: int, *,
update_covers: bool = True,
update_descriptions: bool = True) -> dict:
"""
Updates Kavita persons that match MAL staff (authors / artists).
Returns {"updated": n, "skipped": n, "not_found": n}.
"""
entries = self._mal.get_staff_detailed(mal_manga_id)
return self._sync_entries(entries, "staff",
update_covers=update_covers,
update_descriptions=update_descriptions)
# ------------------------------------------------------------------
# Public: cache management
# ------------------------------------------------------------------
def clear_cache(self) -> None:
"""Clears the Kavita person search cache."""
self._person_search_cache.clear()
# ------------------------------------------------------------------
# Internal: main sync loop
# ------------------------------------------------------------------
def _sync_entries(self, entries: list[dict], kind: str, *,
update_covers: bool,
update_descriptions: bool) -> dict:
result: dict = {"updated": 0, "skipped": 0, "not_found": 0,
"errors": []}
for entry in entries:
name = (entry.get("name") or "").strip()
raw_name = (entry.get("raw_name") or "").strip()
if not name and not raw_name:
continue
# Search by the cleaned (XML-safe) name first; if Kavita stores
# the legacy comma form, retry with the raw MAL name.
matches = self._find_kavita_person(name) if name else []
if not matches and raw_name and raw_name != name:
matches = self._find_kavita_person(raw_name)
if not matches:
result["not_found"] += 1
continue
changed = self._apply_mal_data(
matches[0], entry, kind,
update_cover=update_covers,
update_desc=update_descriptions,
errors=result["errors"])
result["updated" if changed else "skipped"] += 1
return result
# ------------------------------------------------------------------
# Internal: Kavita person search
# ------------------------------------------------------------------
def _find_kavita_person(self, name: str) -> list[dict]:
"""
Searches Kavita for persons matching `name`.
Checks both the main name and any stored aliases.
Returns persons sorted by similarity, filtered by min_name_score.
Results are cached per (normalised) query name.
"""
key = name.lower().strip()
if key in self._person_search_cache:
return self._person_search_cache[key]
try:
resp = self._session.get(
f"{self._base}/api/Person/search",
params={"queryString": name},
timeout=self._timeout,
)
resp.raise_for_status()
persons: list[dict] = resp.json() or []
except requests.RequestException:
self._person_search_cache[key] = []
return []
def score(p: dict) -> float:
candidates = [p.get("name") or ""]
candidates += [a for a in (p.get("aliases") or []) if a]
best = 0.0
q = key
for c in candidates:
r = difflib.SequenceMatcher(None, q, c.lower()).ratio()
best = max(best, r)
return best
ranked = sorted(persons, key=score, reverse=True)
filtered = [p for p in ranked if score(p) >= self._min_score]
self._person_search_cache[key] = filtered
return filtered
# ------------------------------------------------------------------
# Internal: apply MAL data to a single Kavita person
# ------------------------------------------------------------------
def _apply_mal_data(self, person: dict, mal_entry: dict, kind: str, *,
update_cover: bool, update_desc: bool,
errors: "list | None" = None) -> bool:
"""
Applies MAL data to one Kavita person record.
Fields updated
--------------
- malId : set when not already pointing to this MAL entity
- description : set when empty and MAL provides 'about' text
- cover image : uploaded when not locked and no prior sync cover exists
Returns True if any change was made. Failures are appended to the
`errors` list (if provided) instead of being silently swallowed.
"""
person_id: "int | None" = person.get("id")
if not person_id:
return False
person_name = person.get("name") or ""
mal_id: "int | None" = mal_entry.get("mal_id")
current_mal_id: int = person.get("malId") or 0
needs_mal_id = bool(mal_id and current_mal_id != mal_id)
# ------ Lazy description fetch -----------------------------------
description: "str | None" = None
if update_desc and not (person.get("description") or "").strip():
if mal_id:
if kind == "character":
details = self._mal.get_character_details(mal_id)
else:
details = self._mal.get_person_details(mal_id)
if details:
description = (details.get("about") or "").strip() or None
needs_desc = bool(description)
# ------ Metadata update ------------------------------------------
changed = False
if needs_mal_id or needs_desc:
payload: dict = {
"id": person_id,
"name": person_name,
# MUST stay a boolean — the cover image itself is uploaded
# separately via POST /api/Upload/person (below). Putting a
# URL here makes Kavita reject the whole payload with HTTP 400.
"coverImageLocked": bool(person.get("coverImageLocked", False)),
"aliases": person.get("aliases") or [],
"description": description or person.get("description"),
"malId": mal_id if needs_mal_id else (current_mal_id or None),
"aniListId": person.get("aniListId") or None,
}
try:
resp = self._session.post(
f"{self._base}/api/Person/update",
json=payload,
timeout=self._timeout,
)
resp.raise_for_status()
changed = True
except requests.RequestException as e:
if errors is not None:
errors.append(
f"Person/update failed for #{person_id} "
f"'{person_name}': {e}")
# ------ Cover image upload ----------------------------------------
# Upload whenever:
# - caller requested cover updates
# - cover is NOT locked (user did not manually pin it)
# - we have not already uploaded this exact MAL entity's image
# (i.e. malId differs OR there is no cover yet).
#
# Persons are auto-created by Kavita on ComicInfo.xml import without
# a cover, so on the first sync we ALWAYS need to upload — regardless
# of whether the metadata payload above also needed updating.
if update_cover and not person.get("coverImageLocked"):
image_url = mal_entry.get("image_url")
already_uploaded = (
mal_id is not None
and current_mal_id == mal_id
and bool(person.get("coverImage"))
)
if image_url and not already_uploaded:
if self._upload_cover(person_id, image_url,
person_name=person_name,
errors=errors):
changed = True
return changed
# ------------------------------------------------------------------
# Internal: cover upload
# ------------------------------------------------------------------
def _upload_cover(self, person_id: int, image_url: str,
lock: bool = False, *,
person_name: str = "",
errors: "list | None" = None) -> bool:
"""
Uploads a cover image to a Kavita person.
The image is downloaded with the plain (header-less) image session
and posted to `POST /api/Upload/person` as a raw base64 string in
the `url` field.
Notes on protocol quirks discovered against Kavita 0.9.0.2:
- The two-step `upload-by-url` -> `Upload/person` flow returns
"Unable to save cover image to Person" (HTTP 400).
- A `data:image/jpeg;base64,...` data URI is rejected with the
same error.
- Only the raw base64 blob (no prefix) is accepted.
"""
label = (f"#{person_id} '{person_name}'"
if person_name else f"#{person_id}")
# 1) Download the image with a clean session — the Kavita session's
# `Accept: application/json` header makes some CDNs refuse to
# return image bytes.
try:
img_resp = self._image_session.get(image_url,
timeout=self._timeout)
img_resp.raise_for_status()
except requests.RequestException as e:
if errors is not None:
errors.append(
f"image download failed for {label} ({image_url}): {e}")
return False
b64 = base64.b64encode(img_resp.content).decode()
# 2) POST the raw base64 blob.
try:
resp = self._session.post(
f"{self._base}/api/Upload/person",
json={"id": person_id, "url": b64, "lockCover": lock},
timeout=self._timeout,
)
if resp.status_code >= 400:
if errors is not None:
errors.append(
f"Upload/person HTTP {resp.status_code} for {label}: "
f"{_short_body(resp)}")
return False
return True
except requests.RequestException as e:
if errors is not None:
errors.append(
f"Upload/person failed for {label}: {e}")
return False
# --------------------------------------------------------------------------
# Module helper
# --------------------------------------------------------------------------
def _short_body(resp: requests.Response, limit: int = 400) -> str:
"""Returns the response body trimmed to `limit` chars for error logging."""
try:
text = resp.text or ""
except Exception:
return "<unreadable response body>"
text = text.strip().replace("\n", " ").replace("\r", " ")
if len(text) > limit:
text = text[:limit] + ""
return text or "<empty body>"
# --------------------------------------------------------------------------
# Usage example
# --------------------------------------------------------------------------
if __name__ == "__main__":
KAVITA_URL = "http://192.168.2.2:5000"
KAVITA_KEY = "Sq4a3hcV171dn3gzCl0K4eN7hZNk4sOA"
updater = KavitaPersonUpdater(KAVITA_URL, KAVITA_KEY)
mal = MALResolver()
mal_id = mal.find_mal_id("One Punch-Man")
print("MAL ID:", mal_id)
if mal_id:
result = updater.update_for_manga(mal_id)
print("Characters:", {k: v for k, v in result["characters"].items()
if k != "errors"})
print("Staff :", {k: v for k, v in result["staff"].items()
if k != "errors"})
# Surface any non-fatal upload / API errors for debugging
for section in ("characters", "staff"):
for err in result[section].get("errors", []):
print(f"[{section}] {err}")
+240 -52
View File
@@ -2,18 +2,25 @@
mal_resolver.py
===============
Fetches and caches MyAnimeList manga metadata (statistics and characters)
Fetches and caches MyAnimeList manga metadata (statistics, characters, staff)
using the public Jikan REST API v4.
Jikan API: https://api.jikan.moe/v4 (no authentication required)
Rate limit: 3 req/s, 60 req/min -> a 400 ms delay between calls is applied.
Rate limit: 3 req/s, 60 req/min -> a 400 ms guard between calls is applied.
Singleton
---------
Only one instance of this class exists per process. Subsequent calls to
MALResolver() return the same object with its warm caches intact.
Provided features
-----------------
- Title-based MAL ID lookup with best-match scoring (cached)
- Title-based MAL ID lookup with best-match scoring
- MAL statistics: score, rank, scored_by, popularity, members, favorites
- Character list for a manga (names only, cached)
- Convenience: get_characters_for_manga(title) -> list[str]
- Character list for a manga (names only — for <Characters> XML tag)
- Detailed character list: name, MAL character ID, image URL, role
- Detailed staff list: name, MAL person ID, image URL, positions
- Lazy full-detail fetches per character / person (for descriptions)
Dependencies
------------
@@ -31,23 +38,50 @@ import requests
class MALResolver:
"""
Fetches and caches MyAnimeList manga data via the Jikan API v4.
Singleton: fetches and caches MAL manga data via Jikan API v4.
The first call to MALResolver() creates and initialises the instance;
all subsequent calls return the same object.
"""
JIKAN_BASE = "https://api.jikan.moe/v4"
_instance: "MALResolver | None" = None
def __init__(self, *,
request_timeout: int = 30,
session: "requests.Session | None" = None):
# ------------------------------------------------------------------
# Singleton machinery
# ------------------------------------------------------------------
def __new__(cls, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self, *, request_timeout: int = 30):
if self._initialized:
return
self.JIKAN_BASE = "https://api.jikan.moe/v4"
self.request_timeout = request_timeout
self._session = session or requests.Session()
self._session = requests.Session()
self._session.headers.setdefault("User-Agent", "MALResolver/1.0")
self._id_cache: dict[str, "int | None"] = {} # title_lower -> mal_id
self._stats_cache: dict[int, dict] = {} # mal_id -> stats dict
self._char_cache: dict[int, list[str]] = {} # mal_id -> [name, ...]
# title_lower -> mal_id
self._id_cache: dict[str, "int | None"] = {}
# mal_id -> stats dict
self._stats_cache: dict[int, dict] = {}
# manga_mal_id -> [name_str, ...] (for ComicInfo <Characters>)
self._char_names_cache: dict[int, list[str]] = {}
# manga_mal_id -> [{mal_id, name, image_url, role}]
self._char_detailed_cache: dict[int, list[dict]] = {}
# manga_mal_id -> [{mal_id, name, image_url, positions}]
self._staff_detailed_cache: dict[int, list[dict]] = {}
# char_mal_id -> {mal_id, name, image_url, about}
self._char_info_cache: dict[int, dict] = {}
# person_mal_id -> {mal_id, name, image_url, about, website_url}
self._person_info_cache: dict[int, dict] = {}
self._last_request_at: float = 0.0
self._initialized = True
# ------------------------------------------------------------------
# Public: ID lookup
@@ -87,23 +121,13 @@ class MALResolver:
"""
Returns a statistics dict for the given MAL manga ID:
{
"score": float | None,
"rank": int | None,
"scored_by": int | None,
"popularity": int | None,
"members": int | None,
"favorites": int | None,
"url": str,
"title": str,
"as_of": str (DD-MM-YYYY),
}
{score, rank, scored_by, popularity, members, favorites,
url, title, as_of (DD-MM-YYYY)}
Returns None if mal_id is None or on network failure.
"""
if mal_id is None:
return None
if mal_id in self._stats_cache:
return self._stats_cache[mal_id]
@@ -133,18 +157,42 @@ class MALResolver:
return self.get_stats(self.find_mal_id(title))
# ------------------------------------------------------------------
# Public: characters
# Public: character names (for ComicInfo <Characters> tag)
# ------------------------------------------------------------------
def get_characters(self, mal_id: "int | None") -> list[str]:
"""
Returns a list of character names (strings) for the manga.
Returns an empty list on failure.
Returns a flat list of character names for the manga.
Used by ComicInfoBuilder to populate the <Characters> XML element.
"""
if mal_id is None:
return []
if mal_id in self._char_names_cache:
return self._char_names_cache[mal_id]
if mal_id in self._char_cache:
return self._char_cache[mal_id]
detailed = self.get_characters_detailed(mal_id)
names = [e["name"] for e in detailed if e.get("name")]
self._char_names_cache[mal_id] = names
return names
def get_characters_for_manga(self, title: str) -> list[str]:
"""Convenience: search by title, then return character names."""
return self.get_characters(self.find_mal_id(title))
# ------------------------------------------------------------------
# Public: detailed character data (for KavitaPersonUpdater)
# ------------------------------------------------------------------
def get_characters_detailed(self, mal_id: "int | None") -> list[dict]:
"""
Returns detailed character entries for a manga:
[{mal_id, name, image_url, role, about=None}, ...]
`about` is not populated here; call get_character_details(char_mal_id)
to fetch it lazily when needed.
"""
if mal_id is None:
return []
if mal_id in self._char_detailed_cache:
return self._char_detailed_cache[mal_id]
try:
data = self._get(f"{self.JIKAN_BASE}/manga/{mal_id}/characters")
@@ -152,36 +200,143 @@ class MALResolver:
except requests.RequestException:
return []
names = []
results = []
for entry in entries:
char = entry.get("character") or {}
name = char.get("name")
if name:
names.append(name)
raw_name = char.get("name") or ""
if not raw_name:
continue
jpg = (char.get("images") or {}).get("jpg") or {}
results.append({
"mal_id": char.get("mal_id"),
# Cleaned name: "Hibino, Susuki" -> "Susuki Hibino". ComicInfo
# <Characters> is comma-separated, so commas in names would
# cause Kavita to split a single character into two persons.
"name": _clean_mal_name(raw_name),
"raw_name": raw_name,
"image_url": jpg.get("image_url") or jpg.get("small_image_url"),
"role": entry.get("role") or "Supporting",
"about": None,
})
self._char_cache[mal_id] = names
return names
self._char_detailed_cache[mal_id] = results
return results
def get_characters_for_manga(self, title: str) -> list[str]:
# ------------------------------------------------------------------
# Public: detailed staff data (for KavitaPersonUpdater)
# ------------------------------------------------------------------
def get_staff_detailed(self, mal_id: "int | None") -> list[dict]:
"""
Convenience: search for manga by title, then return its characters.
Returns detailed staff entries for a manga:
[{mal_id, name, image_url, positions, about=None}, ...]
`about` is not populated here; call get_person_details(person_mal_id)
to fetch it lazily when needed.
"""
return self.get_characters(self.find_mal_id(title))
if mal_id is None:
return []
if mal_id in self._staff_detailed_cache:
return self._staff_detailed_cache[mal_id]
try:
data = self._get(f"{self.JIKAN_BASE}/manga/{mal_id}/staff")
entries = data.get("data") or []
except requests.RequestException:
return []
results = []
for entry in entries:
person = entry.get("person") or {}
raw_name = person.get("name") or ""
if not raw_name:
continue
jpg = (person.get("images") or {}).get("jpg") or {}
results.append({
"mal_id": person.get("mal_id"),
"name": _clean_mal_name(raw_name),
"raw_name": raw_name,
"image_url": jpg.get("image_url") or jpg.get("small_image_url"),
"positions": entry.get("positions") or [],
"about": None,
})
self._staff_detailed_cache[mal_id] = results
return results
# ------------------------------------------------------------------
# Public: individual character / person details (lazy, with description)
# ------------------------------------------------------------------
def get_character_details(self, char_mal_id: "int | None") -> "dict | None":
"""
Returns full details for a single MAL character, including `about`.
Result is cached.
"""
if char_mal_id is None:
return None
if char_mal_id in self._char_info_cache:
return self._char_info_cache[char_mal_id]
try:
data = self._get(f"{self.JIKAN_BASE}/characters/{char_mal_id}")
entry = data.get("data") or {}
except requests.RequestException:
return None
jpg = (entry.get("images") or {}).get("jpg") or {}
result = {
"mal_id": entry.get("mal_id"),
"name": entry.get("name") or "",
"image_url": jpg.get("image_url") or jpg.get("small_image_url"),
"about": entry.get("about"),
}
self._char_info_cache[char_mal_id] = result
return result
def get_person_details(self, person_mal_id: "int | None") -> "dict | None":
"""
Returns full details for a single MAL person (staff), including `about`.
Result is cached.
"""
if person_mal_id is None:
return None
if person_mal_id in self._person_info_cache:
return self._person_info_cache[person_mal_id]
try:
data = self._get(f"{self.JIKAN_BASE}/people/{person_mal_id}")
entry = data.get("data") or {}
except requests.RequestException:
return None
jpg = (entry.get("images") or {}).get("jpg") or {}
result = {
"mal_id": entry.get("mal_id"),
"name": entry.get("name") or "",
"image_url": jpg.get("image_url") or jpg.get("small_image_url"),
"about": entry.get("about"),
"website_url": entry.get("website_url"),
}
self._person_info_cache[person_mal_id] = result
return result
# ------------------------------------------------------------------
# Public: cache management
# ------------------------------------------------------------------
def clear_cache(self) -> None:
"""Clears all internal caches."""
"""Clears all internal caches (the Singleton instance is retained)."""
self._id_cache.clear()
self._stats_cache.clear()
self._char_cache.clear()
self._char_names_cache.clear()
self._char_detailed_cache.clear()
self._staff_detailed_cache.clear()
self._char_info_cache.clear()
self._person_info_cache.clear()
# ------------------------------------------------------------------
# Internal: rate-limited HTTP
# ------------------------------------------------------------------
def _get(self, url: str, params: "dict | None" = None) -> dict:
"""Rate-limited GET request (respects Jikan's 3 req/s limit)."""
"""Rate-limited GET request (respects Jikan's ~3 req/s limit)."""
elapsed = time.monotonic() - self._last_request_at
if elapsed < 0.4:
time.sleep(0.4 - elapsed)
@@ -194,6 +349,35 @@ class MALResolver:
# --------------------------------------------------------------------------
# Module helper
# --------------------------------------------------------------------------
def _clean_mal_name(name: str) -> str:
"""
Converts an MAL name into a comma-free, ComicInfo-safe form.
The ComicInfo <Characters> tag is comma-separated, so a single MAL
character "Hibino, Susuki" written into the XML would be parsed by
Kavita as two persons ("Hibino" and "Susuki").
Conversion:
"Hibino, Susuki" -> "Susuki Hibino" (Western: First Last)
"Yamori, Kou" -> "Kou Yamori"
"Kotoyama" -> "Kotoyama" (unchanged)
Trailing/leading commas and stray whitespace are stripped defensively.
"""
if not name:
return ""
name = name.strip()
if "," in name:
last, _, first = name.partition(",")
first = first.strip()
last = last.strip()
if first and last:
return f"{first} {last}"
# Fallback: strip any remaining commas
return name.replace(",", " ").strip()
return name
def _score_title(query: str, entry: dict) -> float:
"""Returns the best title-similarity score for a Jikan manga entry."""
candidates = [
@@ -203,7 +387,6 @@ def _score_title(query: str, entry: dict) -> float:
]
for alt in (entry.get("titles") or []):
candidates.append(alt.get("title") or "")
best = 0.0
q = query.lower()
for t in candidates:
@@ -217,15 +400,20 @@ def _score_title(query: str, entry: dict) -> float:
# Usage example
# --------------------------------------------------------------------------
if __name__ == "__main__":
resolver = MALResolver()
r1 = MALResolver()
r2 = MALResolver()
assert r1 is r2, "MALResolver must be a Singleton"
mal_id = resolver.find_mal_id("Yofukashi no Uta")
print("MAL ID :", mal_id)
mal_id = r1.find_mal_id("Yofukashi no Uta")
print("MAL ID :", mal_id)
stats = resolver.get_stats(mal_id)
stats = r1.get_stats(mal_id)
if stats:
print("Score :", stats["score"])
print("Rank :", stats["rank"])
print("Score :", stats["score"])
print("Rank :", stats["rank"])
chars = resolver.get_characters(mal_id)
print("Characters (first 5):", chars[:5])
chars = r1.get_characters_detailed(mal_id)
print("Characters (first 3):", [c["name"] for c in chars[:3]])
staff = r1.get_staff_detailed(mal_id)
print("Staff :", [s["name"] for s in staff])