found a new portrait site

This commit is contained in:
esef committed 2026-07-11 21:40:18 +03:00
1 parent 9a907df72e
commit 04df9e8f62
4 files changed
+69 -97

No files matched your search

+38 -19
View File
@@ -11,8 +11,7 @@ BG_PATH = ASSETS_DIR / "gacha_bg.png"
# ── APIs ──────────────────────────────────────────────────────────────────── # ── APIs ────────────────────────────────────────────────────────────────────
BANNER_API = "https://api.ennead.cc/buruaka/banner" BANNER_API = "https://api.ennead.cc/buruaka/banner"
SCHALE_PORTRAIT = "https://raw.githubusercontent.com/SchaleDB/SchaleDB/main/images/student/portrait" JOEXYZ_CDN = "https://ba.joexyz.online/cdn/v2/images"
WIKI_CDN = "https://static.wikitide.net/bluearchivewiki"
# ── Gacha rates (matching real Blue Archive) ──────────────────────────────── # ── Gacha rates (matching real Blue Archive) ────────────────────────────────
# Regular/Pickup: 3★ = 3.0%, 2★ = 18.5%, 1★ = 78.5% # Regular/Pickup: 3★ = 3.0%, 2★ = 18.5%, 1★ = 78.5%
@@ -69,25 +68,45 @@ PAGE_SIZE = 10
POOL_TOTALS = {3: 106, 2: 22, 1: 11} # standard-pool size for progress bars POOL_TOTALS = {3: 106, 2: 22, 1: 11} # standard-pool size for progress bars
FILTER_LABELS: dict[str, int | None] = {"All": None, "3★": 3, "2★": 2, "1★": 1} FILTER_LABELS: dict[str, int | None] = {"All": None, "3★": 3, "2★": 2, "1★": 1}
# ── Wiki name overrides (SchaleDB name → wiki Portrait_ filename stem) ────── # ── CDN icon slug mapping (SchaleDB variant suffix → joexyz CDN slug suffix) ─
WIKI_NAME_MAP: dict[str, str] = { # Base names without a parenthesised variant use the lowercased name directly.
"Aris": "Arisu", # "Aris" is a special case — the CDN uses "aris", not "arisu".
"Shiroko (Cycling)": "Shiroko_(Riding)", CDN_SUFFIX_MAP: dict[str, str] = {
"Shun (Small)": "Shun_(Kid)", "New Year": "newyear",
"Neru (Bunny)": "Neru_(Bunny_Girl)", "Hot Spring": "onsen",
"Karin (Bunny)": "Karin_(Bunny_Girl)", "Bunny": "bunnygirl",
"Asuna (Bunny)": "Asuna_(Bunny_Girl)", "Cheer Squad": "cheerleader",
"Utaha (Cheer Squad)": "Utaha_(Cheerleader)", "Camp": "camp",
"Hibiki (Cheer Squad)": "Hibiki_(Cheerleader)", "Cycling": "cycling",
"Akane (Bunny)": "Akane_(Bunny_Girl)", "Dress": "dress",
"Aris (Maid)": "Arisu_(Maid)", "Small": "small",
"Toki (Bunny)": "Toki_(Bunny_Girl)", "Maid": "maid",
"Kotori (Cheer Squad)": "Kotori_(Cheerleader)", "Track": "track",
"Kotama (Camp)": "Kotama_(Camping)", "Swimsuit": "swimsuit",
"Hare (Camp)": "Hare_(Camping)", "Band": "band",
"Shiroko Terror": "Shiroko_(Terror)", "Christmas": "christmas",
"Casual": "casual",
"Guide": "guide",
} }
def cdn_icon_slug(name: str) -> str:
"""Convert a SchaleDB student Name to the joexyz CDN icon filename slug."""
# Normalise "Name Terror" into the same parenthesised path
if name.endswith(" Terror"):
name = name[:-7] + " (Terror)"
# "Aris" is "aris" on the CDN, not "arisu"
if name == "Aris":
return "aris"
if "(" in name:
base, rest = name.split("(", 1)
base = base.strip().lower().replace(" ", "_")
variant = rest.rstrip(")").strip()
suffix = CDN_SUFFIX_MAP.get(variant, variant.lower().replace(" ", "_"))
return f"{base}_{suffix}"
return name.lower().replace(" ", "_")
# ── Font paths ────────────────────────────────────────────────────────────── # ── Font paths ──────────────────────────────────────────────────────────────
FONT_PATHS = [ FONT_PATHS = [
"C:/Windows/Fonts/segoeui.ttf", "C:/Windows/Fonts/segoeui.ttf",
+9 -32
View File
@@ -1,11 +1,9 @@
#this module loads student data and fetches live banner info from the api## #this module loads student data and fetches live banner info from the api##
"""Student and banner data layer for the Blue Archive gacha simulator.""" """Student and banner data layer for the Blue Archive gacha simulator."""
###################################################################### ######################################################################
import hashlib
import json import json
import logging import logging
import random import random
import urllib.parse
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -15,9 +13,8 @@ import aiohttp
from .constants import ( from .constants import (
BUNDLED_DB, BUNDLED_DB,
BANNER_API, BANNER_API,
SCHALE_PORTRAIT, JOEXYZ_CDN,
WIKI_CDN, cdn_icon_slug,
WIKI_NAME_MAP,
BANNER_RATES, BANNER_RATES,
DEFAULT_RATES, DEFAULT_RATES,
PULL10_RATES, PULL10_RATES,
@@ -26,27 +23,6 @@ from .constants import (
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def _wiki_filename(student: dict) -> str:
"""Build the wiki Portrait_ filename (no extension) for a student."""
name = student["Name"]
mapped = WIKI_NAME_MAP.get(name)
if mapped is not None:
return mapped
return name.replace(" ", "_")
def wiki_portrait_url(student: dict) -> str:
"""Compute the CDN URL for a student's wiki portrait (252x204 face image)."""
filename = f"Portrait_{_wiki_filename(student)}.png"
h = hashlib.md5(filename.encode()).hexdigest()
return f"{WIKI_CDN}/{h[0]}/{h[:2]}/{urllib.parse.quote(filename)}"
def schale_portrait_url(student_id: int) -> str:
"""Full-body sprite URL (fallback if wiki portrait is unavailable)."""
return f"{SCHALE_PORTRAIT}/{student_id}.webp"
class StudentDB: class StudentDB:
"""In-memory student database loaded from the bundled SchaleDB JSON.""" """In-memory student database loaded from the bundled SchaleDB JSON."""
@@ -177,13 +153,14 @@ class StudentDB:
pool = self.by_rarity.get(rarity, []) pool = self.by_rarity.get(rarity, [])
return random.choice(pool) if pool else random.choice(self.students) return random.choice(pool) if pool else random.choice(self.students)
def wiki_portrait(self, student: dict) -> str: def cdn_icon(self, student: dict) -> str:
"""Wiki face-portrait CDN URL for a student dict (252x204).""" """joexyz CDN icon URL for a student (face/upper-body)."""
return wiki_portrait_url(student) slug = cdn_icon_slug(student["Name"])
return f"{JOEXYZ_CDN}/students/icons/{slug}.png"
def schale_portrait(self, student_id: int) -> str: def cdn_skill_portrait(self, student: dict) -> str:
"""SchaleDB full-body sprite URL (fallback).""" """joexyz CDN skill-portrait URL (full-body fallback)."""
return schale_portrait_url(student_id) return f"{JOEXYZ_CDN}/skill-portraits/Skill_Portrait_{student['DevName']}.png"
# Global singleton # Global singleton
+18 -45
View File
@@ -45,56 +45,42 @@ FOOTER_FONT = _get_font(18, bold=False)
# ── Image fetching ───────────────────────────────────────────────────────── # ── Image fetching ─────────────────────────────────────────────────────────
async def _fetch_portrait(session: aiohttp.ClientSession, student: dict) -> Optional[Image.Image]: async def _fetch_portrait(session: aiohttp.ClientSession, student: dict) -> Optional[Image.Image]:
"""Download a student's face portrait. """Download a student's face icon from the joexyz CDN.
Tries the wiki CDN first (252x204 face image). Falls back to SchaleDB Uses the student icon CDN (face/upper-body crop). Falls back to the
full-body sprite with an upper-body crop on failure. skill-portrait (full-body art) if the icon is unavailable.
""" """
student_id = student["Id"] # 1. Try the face icon
icon_url = db.cdn_icon(student)
# 1. Try wiki face portrait
wiki_url = db.wiki_portrait(student)
try: try:
async with session.get(wiki_url, timeout=aiohttp.ClientTimeout(total=10)) as resp: async with session.get(icon_url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
if resp.status == 200: if resp.status == 200:
data = await resp.read() data = await resp.read()
return Image.open(io.BytesIO(data)).convert("RGBA") return Image.open(io.BytesIO(data)).convert("RGBA")
except Exception: except Exception:
log.debug("Wiki portrait failed for %s, trying SchaleDB fallback", student["Name"]) log.debug("CDN icon failed for %s, trying skill portrait fallback", student["Name"])
# 2. Fallback: SchaleDB full-body sprite with upper-body crop # 2. Fallback: skill portrait (full-body character art)
schale_url = db.schale_portrait(student_id) sp_url = db.cdn_skill_portrait(student)
try: try:
async with session.get(schale_url, timeout=aiohttp.ClientTimeout(total=10)) as resp: async with session.get(sp_url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
if resp.status == 200: if resp.status == 200:
data = await resp.read() data = await resp.read()
img = Image.open(io.BytesIO(data)).convert("RGBA") img = Image.open(io.BytesIO(data)).convert("RGBA")
# Crop top portion for face/upper-body (source is ~614x1024) # Skill portraits are full-body; crop upper portion for card fit
p_w, p_h = img.size p_w, p_h = img.size
crop_h = int(p_w * 1.20) crop_h = int(p_w * 1.20)
if crop_h < p_h: if crop_h < p_h:
img = img.crop((0, 0, p_w, crop_h)) img = img.crop((0, 0, p_w, crop_h))
return img return img
except Exception: except Exception:
log.exception("Failed to fetch portrait for student %d", student_id) log.exception("Failed to fetch any portrait for %s", student["Name"])
return None return None
# ── Drawing helpers ──────────────────────────────────────────────────────── # ── Drawing helpers ────────────────────────────────────────────────────────
def _rounded_rect(
draw: ImageDraw.Draw,
xy: tuple[int, int, int, int],
radius: int,
fill: Optional[tuple] = None,
outline: Optional[tuple] = None,
width: int = 3,
) -> None:
"""Draw a rounded rectangle."""
draw.rounded_rectangle(xy, radius=radius, fill=fill, outline=outline, width=width)
def _star_polygon(cx: int, cy: int, outer_r: int, inner_r: int) -> list[tuple[int, int]]: def _star_polygon(cx: int, cy: int, outer_r: int, inner_r: int) -> list[tuple[int, int]]:
"""Generate vertex list for a 5-pointed star centered at (cx, cy). """Generate vertex list for a 5-pointed star centered at (cx, cy).
@@ -132,17 +118,6 @@ def _draw_star_icons(
draw.polygon(pts, fill=color) draw.polygon(pts, fill=color)
def _draw_rounded_rect(
draw: ImageDraw.Draw,
xy: tuple[int, int, int, int],
radius: int,
fill: Optional[tuple] = None,
outline: Optional[tuple] = None,
width: int = 3,
) -> None:
_rounded_rect(draw, xy, radius, fill, outline, width)
# ── Main render ──────────────────────────────────────────────────────────── # ── Main render ────────────────────────────────────────────────────────────
async def render_pull( async def render_pull(
@@ -257,18 +232,17 @@ def _draw_card(
bg = RARITY_BG.get(rarity, (20, 20, 20)) bg = RARITY_BG.get(rarity, (20, 20, 20))
# Card background with rounded corners # Card background with rounded corners
_draw_rounded_rect(draw, (x, y, x + CARD_W, y + CARD_H), radius=CARD_RADIUS, fill=bg + (255,)) draw.rounded_rectangle((x, y, x + CARD_W, y + CARD_H), radius=CARD_RADIUS, fill=bg + (255,))
# Card border # Card border
_draw_rounded_rect( draw.rounded_rectangle(
draw, (x, y, x + CARD_W, y + CARD_H), radius=CARD_RADIUS, outline=color, width=2 (x, y, x + CARD_W, y + CARD_H), radius=CARD_RADIUS, outline=color, width=2
) )
# ── Star banner strip at top ──────────────────────────────────────── # ── Star banner strip at top ────────────────────────────────────────
banner_top = y + 1 banner_top = y + 1
banner_bottom = y + STAR_BANNER_H banner_bottom = y + STAR_BANNER_H
# Semi-transparent dark strip # Semi-transparent dark strip
_draw_rounded_rect( draw.rounded_rectangle(
draw,
(x + 3, banner_top, x + CARD_W - 3, banner_bottom), (x + 3, banner_top, x + CARD_W - 3, banner_bottom),
radius=CARD_RADIUS - 2, radius=CARD_RADIUS - 2,
fill=(0, 0, 0, 80), fill=(0, 0, 0, 80),
@@ -299,7 +273,7 @@ def _draw_card(
poy = py + (ph - new_h) // 2 poy = py + (ph - new_h) // 2
canvas.paste(portrait, (pox, poy), portrait if portrait.mode == "RGBA" else None) canvas.paste(portrait, (pox, poy), portrait if portrait.mode == "RGBA" else None)
else: else:
_draw_rounded_rect(draw, (px, py, px + pw, py + ph), radius=6, fill=(30, 30, 40, 180)) draw.rounded_rectangle((px, py, px + pw, py + ph), radius=6, fill=(30, 30, 40, 180))
ph_font = _get_font(16) ph_font = _get_font(16)
msg = "No Image" msg = "No Image"
mw = draw.textlength(msg, font=ph_font) mw = draw.textlength(msg, font=ph_font)
@@ -307,8 +281,7 @@ def _draw_card(
# ── Name strip at bottom ──────────────────────────────────────────── # ── Name strip at bottom ────────────────────────────────────────────
name_y = y + CARD_H - NAME_STRIP_H + 2 name_y = y + CARD_H - NAME_STRIP_H + 2
_draw_rounded_rect( draw.rounded_rectangle(
draw,
(x + 3, name_y, x + CARD_W - 3, y + CARD_H - 3), (x + 3, name_y, x + CARD_W - 3, y + CARD_H - 3),
radius=CARD_RADIUS - 3, radius=CARD_RADIUS - 3,
fill=(0, 0, 0, 150), fill=(0, 0, 0, 150),
+4 -1
View File
@@ -18,7 +18,10 @@ class SysInfo(commands.Cog):
await ctx.reply('\n'.join(sys_commands)) await ctx.reply('\n'.join(sys_commands))
###################################################################### ######################################################################
@commands.command() @commands.command()
async def fetch(self, ctx): async def fetch(self, ctx):
result = subprocess.run(['fastfetch', '--pipe', '--logo', 'none'], capture_output=True, text=True) result = subprocess.run(['fastfetch', '--pipe', '--logo', 'none'], capture_output=True, text=True)
clean = self.strip_ansi(result.stdout) clean = self.strip_ansi(result.stdout)