Make roster refresh rebuild from SchaleDB base and fail-safe

This commit is contained in:
esef committed 2026-09-15 15:30:16 +03:00
1 parent c8e3ab57cc
commit 45f4dfd653
2 files changed
+55 -22

No files matched your search

+22 -12
View File
@@ -38,7 +38,17 @@ class StudentDB:
if self._loaded: if self._loaded:
return return
with open(BUNDLED_DB, "r", encoding="utf-8") as f: with open(BUNDLED_DB, "r", encoding="utf-8") as f:
self.students = json.load(f) students = json.load(f)
self._index(students)
def load_data(self, students: list[dict]) -> None:
"""Index an already-parsed student list (used by the roster refresher)."""
if self._loaded:
return
self._index(list(students))
def _index(self, students: list[dict]) -> None:
self.students = students
excluded = 0 excluded = 0
for s in self.students: for s in self.students:
@@ -81,15 +91,7 @@ class StudentDB:
) )
async def merge_ennead(self, banner_cache: dict) -> int: async def merge_ennead(self, banner_cache: dict) -> int:
"""Merge characters from the ennead.cc API that aren't in the SchaleDB. """Fetch the ennead.cc character API and merge any new characters."""
Fetches the full character list, maps fields to SchaleDB format, and
derives IsLimited from banner history. Only characters with IDs not
already present are added — existing SchaleDB entries are never touched.
Must be called after load() and after banners have been fetched.
Returns the number of new characters merged.
"""
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get(CHARACTER_API, timeout=aiohttp.ClientTimeout(total=15)) as resp: async with session.get(CHARACTER_API, timeout=aiohttp.ClientTimeout(total=15)) as resp:
@@ -101,9 +103,16 @@ class StudentDB:
log.exception("Failed to fetch ennead character API") log.exception("Failed to fetch ennead character API")
return 0 return 0
# Build IsLimited from all known banners (0=standard, 1=limited) return self.merge_chars(chars, banner_cache)
is_limited = self._build_is_limited(banner_cache)
def merge_chars(self, chars: list[dict], banner_cache: dict) -> int:
"""Merge already-fetched ennead characters that aren't in the roster.
Maps fields to SchaleDB format and derives IsLimited from banner
history. Only characters with IDs not already present are added —
existing entries are never touched. Returns the number merged.
"""
is_limited = self._build_is_limited(banner_cache)
try: try:
added = self._merge_chars(chars, is_limited) added = self._merge_chars(chars, is_limited)
if added: if added:
@@ -116,6 +125,7 @@ class StudentDB:
) )
except Exception: except Exception:
log.exception("Failed to merge ennead characters") log.exception("Failed to merge ennead characters")
return 0
return added return added
def _merge_chars(self, chars: list[dict], is_limited: dict[str, int]) -> int: def _merge_chars(self, chars: list[dict], is_limited: dict[str, int]) -> int:
+33 -10
View File
@@ -1,28 +1,51 @@
"""Refresh the bundled Blue Archive roster from the live ennead.cc API. """Rebuild the bundled Blue Archive roster from upstream sources.
Loads the current ba_students.json, merges any characters that have been Downloads the latest SchaleDB export (full fields including portrait data),
released since the last run, and atomically writes the result back. Run weekly then merges the live ennead.cc character feed for anything SchaleDB doesn't
via the discordbot-refresh systemd timer so fresh clones stay current without cover (variants, welfare, collabs). Writes the result atomically.
depending on the live API at startup.
Fails WITHOUT writing if any required upstream fetch fails, so a transient
network error can never shrink the existing roster.
Run weekly via the discordbot-refresh systemd timer.
""" """
import asyncio import asyncio
import json import json
from pathlib import Path from pathlib import Path
from blue_archive.data import db, fetch_banners import aiohttp
from blue_archive.constants import CHARACTER_API
from blue_archive.data import StudentDB, fetch_banners
SCHALEDB_URL = "https://raw.githubusercontent.com/SchaleDB/SchaleDB/main/data/en/students.min.json"
OUT = Path(__file__).with_name("ba_students.json") OUT = Path(__file__).with_name("ba_students.json")
async def fetch_json(session: aiohttp.ClientSession, url: str, timeout: int = 30) -> list[dict]:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=timeout)) as resp:
resp.raise_for_status()
return json.loads(await resp.text())
async def main() -> None: async def main() -> None:
db.load() async with aiohttp.ClientSession() as session:
banners = await fetch_banners() base = await fetch_json(session, SCHALEDB_URL)
added = await db.merge_ennead(banners) chars = await fetch_json(session, CHARACTER_API)
banners = await fetch_banners(session)
if not chars:
raise RuntimeError("ennead character API returned an empty list")
db = StudentDB()
db.load_data(base)
added = db.merge_chars(chars, banners)
tmp = OUT.with_suffix(".json.tmp") tmp = OUT.with_suffix(".json.tmp")
tmp.write_text(json.dumps(db.students, ensure_ascii=False), encoding="utf-8") tmp.write_text(json.dumps(db.students, ensure_ascii=False), encoding="utf-8")
tmp.replace(OUT) # atomic swap so a reader never sees a partial file tmp.replace(OUT) # atomic swap so a reader never sees a partial file
print(f"refreshed {OUT.name}: {len(db.students)} students (+{added} new)") print(f"refreshed {OUT.name}: {len(db.students)} students "
f"(SchaleDB {len(base)} + {added} ennead)")
if __name__ == "__main__": if __name__ == "__main__":