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 ────────────────────────────────────────────────────────────────────
BANNER_API = "https://api.ennead.cc/buruaka/banner"
SCHALE_PORTRAIT = "https://raw.githubusercontent.com/SchaleDB/SchaleDB/main/images/student/portrait"
WIKI_CDN = "https://static.wikitide.net/bluearchivewiki"
JOEXYZ_CDN = "https://ba.joexyz.online/cdn/v2/images"
# ── Gacha rates (matching real Blue Archive) ────────────────────────────────
# 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
FILTER_LABELS: dict[str, int | None] = {"All": None, "3★": 3, "2★": 2, "1★": 1}
# ── Wiki name overrides (SchaleDB name → wiki Portrait_ filename stem) ──────
WIKI_NAME_MAP: dict[str, str] = {
"Aris": "Arisu",
"Shiroko (Cycling)": "Shiroko_(Riding)",
"Shun (Small)": "Shun_(Kid)",
"Neru (Bunny)": "Neru_(Bunny_Girl)",
"Karin (Bunny)": "Karin_(Bunny_Girl)",
"Asuna (Bunny)": "Asuna_(Bunny_Girl)",
"Utaha (Cheer Squad)": "Utaha_(Cheerleader)",
"Hibiki (Cheer Squad)": "Hibiki_(Cheerleader)",
"Akane (Bunny)": "Akane_(Bunny_Girl)",
"Aris (Maid)": "Arisu_(Maid)",
"Toki (Bunny)": "Toki_(Bunny_Girl)",
"Kotori (Cheer Squad)": "Kotori_(Cheerleader)",
"Kotama (Camp)": "Kotama_(Camping)",
"Hare (Camp)": "Hare_(Camping)",
"Shiroko Terror": "Shiroko_(Terror)",
# ── CDN icon slug mapping (SchaleDB variant suffix → joexyz CDN slug suffix) ─
# Base names without a parenthesised variant use the lowercased name directly.
# "Aris" is a special case — the CDN uses "aris", not "arisu".
CDN_SUFFIX_MAP: dict[str, str] = {
"New Year": "newyear",
"Hot Spring": "onsen",
"Bunny": "bunnygirl",
"Cheer Squad": "cheerleader",
"Camp": "camp",
"Cycling": "cycling",
"Dress": "dress",
"Small": "small",
"Maid": "maid",
"Track": "track",
"Swimsuit": "swimsuit",
"Band": "band",
"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 = [
"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##
"""Student and banner data layer for the Blue Archive gacha simulator."""
######################################################################
import hashlib
import json
import logging
import random
import urllib.parse
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
@@ -15,9 +13,8 @@ import aiohttp
from .constants import (
BUNDLED_DB,
BANNER_API,
SCHALE_PORTRAIT,
WIKI_CDN,
WIKI_NAME_MAP,
JOEXYZ_CDN,
cdn_icon_slug,
BANNER_RATES,
DEFAULT_RATES,
PULL10_RATES,
@@ -26,27 +23,6 @@ from .constants import (
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:
"""In-memory student database loaded from the bundled SchaleDB JSON."""
@@ -177,13 +153,14 @@ class StudentDB:
pool = self.by_rarity.get(rarity, [])
return random.choice(pool) if pool else random.choice(self.students)
def wiki_portrait(self, student: dict) -> str:
"""Wiki face-portrait CDN URL for a student dict (252x204)."""
return wiki_portrait_url(student)
def cdn_icon(self, student: dict) -> str:
"""joexyz CDN icon URL for a student (face/upper-body)."""
slug = cdn_icon_slug(student["Name"])
return f"{JOEXYZ_CDN}/students/icons/{slug}.png"
def schale_portrait(self, student_id: int) -> str:
"""SchaleDB full-body sprite URL (fallback)."""
return schale_portrait_url(student_id)
def cdn_skill_portrait(self, student: dict) -> str:
"""joexyz CDN skill-portrait URL (full-body fallback)."""
return f"{JOEXYZ_CDN}/skill-portraits/Skill_Portrait_{student['DevName']}.png"
# Global singleton
+18 -45
View File
@@ -45,56 +45,42 @@ FOOTER_FONT = _get_font(18, bold=False)
# ── Image fetching ─────────────────────────────────────────────────────────
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
full-body sprite with an upper-body crop on failure.
Uses the student icon CDN (face/upper-body crop). Falls back to the
skill-portrait (full-body art) if the icon is unavailable.
"""
student_id = student["Id"]
# 1. Try wiki face portrait
wiki_url = db.wiki_portrait(student)
# 1. Try the face icon
icon_url = db.cdn_icon(student)
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:
data = await resp.read()
return Image.open(io.BytesIO(data)).convert("RGBA")
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
schale_url = db.schale_portrait(student_id)
# 2. Fallback: skill portrait (full-body character art)
sp_url = db.cdn_skill_portrait(student)
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:
data = await resp.read()
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
crop_h = int(p_w * 1.20)
if crop_h < p_h:
img = img.crop((0, 0, p_w, crop_h))
return img
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
# ── 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]]:
"""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)
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 ────────────────────────────────────────────────────────────
async def render_pull(
@@ -257,18 +232,17 @@ def _draw_card(
bg = RARITY_BG.get(rarity, (20, 20, 20))
# 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
_draw_rounded_rect(
draw, (x, y, x + CARD_W, y + CARD_H), radius=CARD_RADIUS, outline=color, width=2
draw.rounded_rectangle(
(x, y, x + CARD_W, y + CARD_H), radius=CARD_RADIUS, outline=color, width=2
)
# ── Star banner strip at top ────────────────────────────────────────
banner_top = y + 1
banner_bottom = y + STAR_BANNER_H
# Semi-transparent dark strip
_draw_rounded_rect(
draw,
draw.rounded_rectangle(
(x + 3, banner_top, x + CARD_W - 3, banner_bottom),
radius=CARD_RADIUS - 2,
fill=(0, 0, 0, 80),
@@ -299,7 +273,7 @@ def _draw_card(
poy = py + (ph - new_h) // 2
canvas.paste(portrait, (pox, poy), portrait if portrait.mode == "RGBA" else None)
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)
msg = "No Image"
mw = draw.textlength(msg, font=ph_font)
@@ -307,8 +281,7 @@ def _draw_card(
# ── Name strip at bottom ────────────────────────────────────────────
name_y = y + CARD_H - NAME_STRIP_H + 2
_draw_rounded_rect(
draw,
draw.rounded_rectangle(
(x + 3, name_y, x + CARD_W - 3, y + CARD_H - 3),
radius=CARD_RADIUS - 3,
fill=(0, 0, 0, 150),
+4 -1
View File
@@ -18,7 +18,10 @@ class SysInfo(commands.Cog):
await ctx.reply('\n'.join(sys_commands))
######################################################################
@commands.command()
@commands.command()
async def fetch(self, ctx):
result = subprocess.run(['fastfetch', '--pipe', '--logo', 'none'], capture_output=True, text=True)
clean = self.strip_ansi(result.stdout)