mirror of
https://github.com/PaiGramTeam/PaiGram.git
synced 2024-12-26 19:35:37 +00:00
567 lines
20 KiB
Python
567 lines
20 KiB
Python
"""用于下载和管理角色、武器、材料等的图标"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import re
|
|
from abc import ABC, abstractmethod
|
|
from functools import cached_property, lru_cache, partial
|
|
from multiprocessing import RLock as Lock
|
|
from pathlib import Path
|
|
from ssl import SSLZeroReturnError
|
|
from typing import AsyncIterator, Awaitable, Callable, ClassVar, Dict, Optional, TYPE_CHECKING, TypeVar, Union
|
|
|
|
from aiofiles import open as async_open
|
|
from aiofiles.os import remove as async_remove
|
|
from enkanetwork import Assets as EnkaAssets
|
|
from enkanetwork.model.assets import CharacterAsset as EnkaCharacterAsset
|
|
from httpx import AsyncClient, HTTPError, HTTPStatusError, TransportError, URL
|
|
from typing_extensions import Self
|
|
|
|
from core.base_service import BaseService
|
|
from core.config import config
|
|
from metadata.genshin import AVATAR_DATA, HONEY_DATA, MATERIAL_DATA, NAMECARD_DATA, WEAPON_DATA
|
|
from metadata.scripts.honey import update_honey_metadata
|
|
from metadata.scripts.metadatas import update_metadata_from_ambr, update_metadata_from_github
|
|
from metadata.shortname import roleToId, weaponToId
|
|
from modules.wiki.base import HONEY_HOST
|
|
from utils.const import AMBR_HOST, ENKA_HOST, PROJECT_ROOT
|
|
from utils.log import logger
|
|
from utils.typedefs import StrOrInt, StrOrURL
|
|
|
|
if TYPE_CHECKING:
|
|
from httpx import Response
|
|
from multiprocessing.synchronize import RLock
|
|
|
|
__all__ = ("AssetsServiceType", "AssetsService", "AssetsServiceError", "AssetsCouldNotFound", "DEFAULT_EnkaAssets")
|
|
|
|
ICON_TYPE = Union[Callable[[bool], Awaitable[Optional[Path]]], Callable[..., Awaitable[Optional[Path]]]]
|
|
NAME_MAP_TYPE = Dict[str, StrOrURL]
|
|
|
|
ASSETS_PATH = PROJECT_ROOT.joinpath("resources/assets")
|
|
ASSETS_PATH.mkdir(exist_ok=True, parents=True)
|
|
|
|
DATA_MAP = {"avatar": AVATAR_DATA, "weapon": WEAPON_DATA, "material": MATERIAL_DATA}
|
|
|
|
DEFAULT_EnkaAssets = EnkaAssets(lang="chs")
|
|
|
|
|
|
class AssetsServiceError(Exception):
|
|
pass
|
|
|
|
|
|
class AssetsCouldNotFound(AssetsServiceError):
|
|
def __init__(self, message: str, target: str):
|
|
self.message = message
|
|
self.target = target
|
|
super().__init__(f"{message}: target={target}")
|
|
|
|
|
|
class _AssetsService(ABC):
|
|
_lock: ClassVar["RLock"] = Lock()
|
|
_dir: ClassVar[Path]
|
|
icon_types: ClassVar[list[str]]
|
|
|
|
_client: Optional[AsyncClient] = None
|
|
_links: dict[str, str] = {}
|
|
|
|
id: int
|
|
type: str
|
|
|
|
icon: ICON_TYPE
|
|
"""图标"""
|
|
|
|
@abstractmethod
|
|
@cached_property
|
|
def game_name(self) -> str:
|
|
"""游戏数据中的名称"""
|
|
|
|
@cached_property
|
|
def honey_id(self) -> str:
|
|
"""当前资源在 Honey Impact 所对应的 ID"""
|
|
return HONEY_DATA[self.type].get(str(self.id), [""])[0]
|
|
|
|
@property
|
|
def path(self) -> Path:
|
|
"""当前资源的文件夹"""
|
|
result = self._dir.joinpath(str(self.id)).resolve()
|
|
result.mkdir(exist_ok=True, parents=True)
|
|
return result
|
|
|
|
@property
|
|
def client(self) -> AsyncClient:
|
|
with self._lock:
|
|
if self._client is None or self._client.is_closed:
|
|
self._client = AsyncClient()
|
|
return self._client
|
|
|
|
def __init__(self, client: Optional[AsyncClient] = None) -> None:
|
|
self._client = client
|
|
|
|
def __call__(self, target: int) -> Self:
|
|
"""用于生成与 target 对应的 assets"""
|
|
result = self.__class__(self.client)
|
|
result.id = target
|
|
return result
|
|
|
|
def __init_subclass__(cls, **kwargs) -> None:
|
|
"""初始化一些类变量"""
|
|
from itertools import chain
|
|
|
|
cls.icon_types = [ # 支持的图标类型
|
|
k
|
|
for k, v in chain(cls.__annotations__.items(), *map(lambda x: x.__annotations__.items(), cls.__bases__))
|
|
if v in [ICON_TYPE, "ICON_TYPE"]
|
|
]
|
|
cls.type = cls.__name__.lstrip("_").split("Assets")[0].lower() # 当前 assert 的类型
|
|
cls._dir = ASSETS_PATH.joinpath(cls.type) # 图标保存的文件夹
|
|
cls._dir.mkdir(exist_ok=True, parents=True)
|
|
|
|
async def _request(self, url: str, interval: float = 0.2) -> "Response":
|
|
error = None
|
|
for _ in range(5):
|
|
try:
|
|
response = await self.client.get(url, follow_redirects=False)
|
|
if response.headers.get("content-length", None) == "2358":
|
|
continue
|
|
return response
|
|
except (TransportError, SSLZeroReturnError) as e:
|
|
error = e
|
|
await asyncio.sleep(interval)
|
|
continue
|
|
if error is not None:
|
|
raise error
|
|
|
|
async def _download(self, url: StrOrURL, path: Path, retry: int = 5) -> Path | None:
|
|
"""从 url 下载图标至 path"""
|
|
logger.debug("正在从 %s 下载图标至 %s", url, path)
|
|
headers = None
|
|
if config.enka_network_api_agent is not None and URL(url).host == "enka.network":
|
|
headers = {"user-agent": config.enka_network_api_agent}
|
|
for time in range(retry):
|
|
try:
|
|
response = await self.client.get(url, follow_redirects=False, headers=headers)
|
|
except Exception as error: # pylint: disable=W0703
|
|
if not isinstance(error, (HTTPError, SSLZeroReturnError)):
|
|
logger.error(error) # 打印未知错误
|
|
if time != retry - 1: # 未达到重试次数
|
|
await asyncio.sleep(1)
|
|
else:
|
|
raise error
|
|
continue
|
|
if response.status_code != 200: # 判定页面是否正常
|
|
return None
|
|
async with async_open(path, "wb") as file:
|
|
await file.write(response.content) # 保存图标
|
|
return path.resolve()
|
|
|
|
async def _get_from_ambr(self, item: str) -> AsyncIterator[str | None]: # pylint: disable=W0613,R0201
|
|
"""从 ambr.top 上获取目标链接"""
|
|
yield None
|
|
|
|
async def _get_from_enka(self, item: str) -> AsyncIterator[str | None]: # pylint: disable=W0613,R0201
|
|
"""从 enke.network 上获取目标链接"""
|
|
yield None
|
|
|
|
async def _get_from_honey(self, item: str) -> AsyncIterator[str | None]:
|
|
"""从 honey 上获取目标链接"""
|
|
if (honey_name := self.honey_name_map.get(item, None)) is not None:
|
|
yield HONEY_HOST.join(f"img/{honey_name}.png")
|
|
yield HONEY_HOST.join(f"img/{honey_name}.webp")
|
|
|
|
async def _download_url_generator(self, item: str) -> AsyncIterator[str]:
|
|
# 获取当前 `AssetsService` 的所有爬虫
|
|
for func in map(lambda x: getattr(self, x), sorted(filter(lambda x: x.startswith("_get_from_"), dir(self)))):
|
|
async for url in func(item):
|
|
if url is not None:
|
|
try:
|
|
if (response := await self._request(url := str(url))) is None:
|
|
continue
|
|
response.raise_for_status()
|
|
yield url
|
|
except HTTPStatusError:
|
|
continue
|
|
|
|
async def _get_download_url(self, item: str) -> str | None:
|
|
"""获取图标的下载链接"""
|
|
async for url in self._download_url_generator(item):
|
|
if url is not None:
|
|
return url
|
|
|
|
async def _get_img(self, overwrite: bool = False, *, item: str) -> Path | None:
|
|
"""获取图标"""
|
|
path = next(filter(lambda x: x.stem == item, self.path.iterdir()), None)
|
|
if not overwrite and path: # 如果需要下载的图标存在且不覆盖( overwrite )
|
|
return path.resolve()
|
|
if path is not None and path.exists():
|
|
if overwrite: # 如果覆盖
|
|
await async_remove(path) # 删除已存在的图标
|
|
else:
|
|
return path
|
|
# 依次从使用当前 assets class 中的爬虫下载图标,顺序为爬虫名的字母顺序
|
|
async for url in self._download_url_generator(item):
|
|
if url is not None:
|
|
path = self.path.joinpath(f"{item}{Path(url).suffix}")
|
|
if (result := await self._download(url, path)) is not None:
|
|
return result
|
|
|
|
@lru_cache
|
|
async def get_link(self, item: str) -> str | None:
|
|
"""获取相应图标链接"""
|
|
return await self._get_download_url(item)
|
|
|
|
def __getattr__(self, item: str):
|
|
"""魔法"""
|
|
if item in self.icon_types:
|
|
return partial(self._get_img, item=item)
|
|
object.__getattribute__(self, item)
|
|
return None
|
|
|
|
@abstractmethod
|
|
@cached_property
|
|
def game_name_map(self) -> dict[str, str]:
|
|
"""游戏中的图标名"""
|
|
|
|
@abstractmethod
|
|
@cached_property
|
|
def honey_name_map(self) -> dict[str, str]:
|
|
"""来自honey的图标名"""
|
|
|
|
|
|
class _AvatarAssets(_AssetsService):
|
|
enka: EnkaCharacterAsset | None
|
|
|
|
side: ICON_TYPE
|
|
"""侧视图图标"""
|
|
|
|
card: ICON_TYPE
|
|
"""卡片图标"""
|
|
|
|
gacha: ICON_TYPE
|
|
"""抽卡立绘"""
|
|
|
|
gacha_card: ICON_TYPE
|
|
"""抽卡卡片"""
|
|
|
|
AVATAR_DEFAULT: int = 10000005
|
|
"""默认角色ID"""
|
|
|
|
@cached_property
|
|
def game_name(self) -> str:
|
|
icon = "UI_AvatarIcon_"
|
|
if (avatar := AVATAR_DATA.get(str(self.id), None)) is not None:
|
|
icon = avatar["icon"]
|
|
else:
|
|
for aid, avatar in AVATAR_DATA.items():
|
|
if aid.startswith(str(self.id)):
|
|
icon = avatar["icon"]
|
|
return re.findall(r"UI_AvatarIcon_(.*)", icon)[0]
|
|
|
|
@cached_property
|
|
def honey_id(self) -> str:
|
|
return HONEY_DATA["avatar"].get(str(self.id), "")[0]
|
|
|
|
@cached_property
|
|
def enka(self) -> Optional[EnkaCharacterAsset]:
|
|
api = getattr(self, "_enka_api", None)
|
|
cid = getattr(self, "id", None)
|
|
return None if api is None or cid is None else api.character(cid)
|
|
|
|
def __init__(self, client: Optional[AsyncClient] = None, enka: Optional[EnkaAssets] = None):
|
|
super().__init__(client)
|
|
self._enka_api = enka or DEFAULT_EnkaAssets
|
|
|
|
def __call__(self, target: StrOrInt) -> "_AvatarAssets":
|
|
if target == 0:
|
|
target = self.AVATAR_DEFAULT
|
|
temp = target
|
|
result = _AvatarAssets(self.client)
|
|
if isinstance(target, str):
|
|
try:
|
|
target = int(target)
|
|
except ValueError:
|
|
target = roleToId(target)
|
|
if isinstance(target, str) or target is None:
|
|
raise AssetsCouldNotFound("找不到对应的角色", temp)
|
|
result.id = target
|
|
result._enka_api = self._enka_api
|
|
return result
|
|
|
|
async def _get_from_ambr(self, item: str) -> AsyncIterator[str | None]:
|
|
if item in {"icon", "side", "gacha"}:
|
|
yield str(AMBR_HOST.join(f"assets/UI/{self.game_name_map[item]}.png"))
|
|
|
|
async def _get_from_enka(self, item: str) -> AsyncIterator[str | None]:
|
|
if (item_id := self.game_name_map.get(item)) is not None:
|
|
yield str(ENKA_HOST.join(f"ui/{item_id}.png"))
|
|
|
|
@cached_property
|
|
def honey_name_map(self) -> dict[str, str]:
|
|
return {
|
|
"icon": f"{self.honey_id}_icon",
|
|
"side": f"{self.honey_id}_side_icon",
|
|
"gacha": f"{self.honey_id}_gacha_splash",
|
|
"gacha_card": f"{self.honey_id}_gacha_card",
|
|
}
|
|
|
|
@cached_property
|
|
def game_name_map(self) -> dict[str, str]:
|
|
return {
|
|
"icon": f"UI_AvatarIcon_{self.game_name}",
|
|
"card": f"UI_AvatarIcon_{self.game_name}_Card",
|
|
"side": f"UI_AvatarIcon_Side_{self.game_name}",
|
|
"gacha": f"UI_Gacha_AvatarImg_{self.game_name}",
|
|
}
|
|
|
|
|
|
class _WeaponAssets(_AssetsService):
|
|
awaken: ICON_TYPE
|
|
"""突破后图标"""
|
|
|
|
gacha: ICON_TYPE
|
|
"""抽卡立绘"""
|
|
|
|
@cached_property
|
|
def game_name(self) -> str:
|
|
return re.findall(r"UI_EquipIcon_(.*)", WEAPON_DATA[str(self.id)]["icon"])[0]
|
|
|
|
@cached_property
|
|
def game_name_map(self) -> dict[str, str]:
|
|
return {
|
|
"icon": f"UI_EquipIcon_{self.game_name}",
|
|
"awaken": f"UI_EquipIcon_{self.game_name}_Awaken",
|
|
"gacha": f"UI_Gacha_EquipIcon_{self.game_name}",
|
|
}
|
|
|
|
@cached_property
|
|
def honey_id(self) -> str:
|
|
return f"i_n{self.id}"
|
|
|
|
def __call__(self, target: StrOrInt) -> Self:
|
|
temp = target
|
|
result = _WeaponAssets(self.client)
|
|
if isinstance(target, str):
|
|
target = int(target) if target.isnumeric() else weaponToId(target)
|
|
if isinstance(target, str) or target is None:
|
|
raise AssetsCouldNotFound("找不到对应的武器", temp)
|
|
result.id = target
|
|
return result
|
|
|
|
async def _get_from_ambr(self, item: str) -> AsyncIterator[str | None]:
|
|
if item == "icon":
|
|
yield str(AMBR_HOST.join(f"assets/UI/{self.game_name_map.get(item)}.png"))
|
|
|
|
async def _get_from_enka(self, item: str) -> AsyncIterator[str | None]:
|
|
if item in self.game_name_map:
|
|
yield str(ENKA_HOST.join(f"ui/{self.game_name_map.get(item)}.png"))
|
|
|
|
@cached_property
|
|
def honey_name_map(self) -> dict[str, str]:
|
|
return {
|
|
"icon": f"{self.honey_id}",
|
|
"awaken": f"{self.honey_id}_awaken_icon",
|
|
"gacha": f"{self.honey_id}_gacha_icon",
|
|
}
|
|
|
|
|
|
class _MaterialAssets(_AssetsService):
|
|
@cached_property
|
|
def game_name(self) -> str:
|
|
return str(self.id)
|
|
|
|
@cached_property
|
|
def game_name_map(self) -> dict[str, str]:
|
|
return {"icon": f"UI_ItemIcon_{self.game_name}"}
|
|
|
|
@cached_property
|
|
def honey_name_map(self) -> dict[str, str]:
|
|
return {"icon": self.honey_id}
|
|
|
|
def __call__(self, target: StrOrInt) -> Self:
|
|
temp = target
|
|
result = _MaterialAssets(self.client)
|
|
if isinstance(target, str):
|
|
if target.isnumeric():
|
|
target = int(target)
|
|
else:
|
|
target = {v["name"]: int(k) for k, v in MATERIAL_DATA.items()}.get(target)
|
|
if isinstance(target, str) or target is None:
|
|
raise AssetsCouldNotFound("找不到对应的素材", temp)
|
|
result.id = target
|
|
return result
|
|
|
|
async def _get_from_ambr(self, item: str) -> AsyncIterator[str | None]:
|
|
if item == "icon":
|
|
yield str(AMBR_HOST.join(f"assets/UI/{self.game_name_map.get(item)}.png"))
|
|
|
|
async def _get_from_honey(self, item: str) -> AsyncIterator[str | None]:
|
|
yield HONEY_HOST.join(f"/img/{self.honey_name_map.get(item)}.png")
|
|
yield HONEY_HOST.join(f"/img/{self.honey_name_map.get(item)}.webp")
|
|
|
|
|
|
class _ArtifactAssets(_AssetsService):
|
|
flower: ICON_TYPE
|
|
"""生之花"""
|
|
|
|
plume: ICON_TYPE
|
|
"""死之羽"""
|
|
|
|
sands: ICON_TYPE
|
|
"""时之沙"""
|
|
|
|
goblet: ICON_TYPE
|
|
"""空之杯"""
|
|
|
|
circlet: ICON_TYPE
|
|
"""理之冠"""
|
|
|
|
@cached_property
|
|
def honey_id(self) -> str:
|
|
return HONEY_DATA["artifact"][str(self.id)][0]
|
|
|
|
@cached_property
|
|
def game_name(self) -> str:
|
|
return f"UI_RelicIcon_{self.id}"
|
|
|
|
async def _get_from_enka(self, item: str) -> AsyncIterator[str | None]:
|
|
if item in self.game_name_map:
|
|
yield str(ENKA_HOST.join(f"ui/{self.game_name_map.get(item)}.png"))
|
|
|
|
async def _get_from_ambr(self, item: str) -> AsyncIterator[str | None]:
|
|
if item in self.game_name_map:
|
|
yield str(AMBR_HOST.join(f"assets/UI/reliquary/{self.game_name_map[item]}.png"))
|
|
|
|
@cached_property
|
|
def game_name_map(self) -> dict[str, str]:
|
|
return {
|
|
"icon": f"UI_RelicIcon_{self.id}_4",
|
|
"flower": f"UI_RelicIcon_{self.id}_4",
|
|
"plume": f"UI_RelicIcon_{self.id}_2",
|
|
"sands": f"UI_RelicIcon_{self.id}_5",
|
|
"goblet": f"UI_RelicIcon_{self.id}_1",
|
|
"circlet": f"UI_RelicIcon_{self.id}_3",
|
|
}
|
|
|
|
@cached_property
|
|
def honey_name_map(self) -> dict[str, str]:
|
|
first_id = int(re.findall(r"\d+", HONEY_DATA["artifact"][str(self.id)][-1])[0])
|
|
return {
|
|
"icon": f"i_n{first_id + 30}",
|
|
"flower": f"i_n{first_id + 30}",
|
|
"plume": f"i_n{first_id + 10}",
|
|
"sands": f"i_n{first_id + 40}",
|
|
"goblet": f"i_n{first_id}",
|
|
"circlet": f"i_n{first_id + 20}",
|
|
}
|
|
|
|
|
|
class _NamecardAssets(_AssetsService):
|
|
enka: EnkaCharacterAsset | None
|
|
|
|
navbar: ICON_TYPE
|
|
"""好友名片背景"""
|
|
|
|
profile: ICON_TYPE
|
|
"""个人资料名片背景"""
|
|
|
|
NAME_CARD_DEFAULT: int = 210189
|
|
"""默认名片 ID"""
|
|
|
|
@cached_property
|
|
def honey_id(self) -> str:
|
|
return HONEY_DATA["namecard"][str(self.id)][0]
|
|
|
|
@cached_property
|
|
def game_name(self) -> str:
|
|
return NAMECARD_DATA[str(self.id)]["icon"]
|
|
|
|
@lru_cache
|
|
def _get_id_from_avatar_id(self, avatar_id: Union[int, str]) -> int:
|
|
avatar_icon_name = AVATAR_DATA[str(avatar_id)]["icon"].split("_")[-1]
|
|
fallback = None
|
|
for namecard_id, namecard_data in NAMECARD_DATA.items():
|
|
if namecard_data["icon"].split("_")[-1] == avatar_icon_name:
|
|
return int(namecard_id)
|
|
if avatar_icon_name in namecard_data["icon"].split("_")[-1]:
|
|
fallback = int(namecard_id)
|
|
if fallback:
|
|
return fallback
|
|
raise ValueError(avatar_id)
|
|
|
|
def __call__(self, target: int) -> "_NamecardAssets":
|
|
if target == 0:
|
|
target = self.NAME_CARD_DEFAULT
|
|
result = _NamecardAssets(self.client)
|
|
target = int(target) if not isinstance(target, int) else target
|
|
if target > 10000000:
|
|
target = self._get_id_from_avatar_id(target)
|
|
result.id = target
|
|
result.enka = DEFAULT_EnkaAssets.namecards(target)
|
|
return result
|
|
|
|
async def _get_from_ambr(self, item: str) -> AsyncIterator[str | None]:
|
|
if item == "profile":
|
|
yield AMBR_HOST.join(f"assets/UI/namecard/{self.game_name_map[item]}.png.png")
|
|
|
|
async def _get_from_enka(self, item: str) -> AsyncIterator[str | None]:
|
|
if (url := getattr(self.enka, {"profile": "banner"}.get(item, item), None)) is not None:
|
|
yield url.url
|
|
|
|
@cached_property
|
|
def game_name_map(self) -> dict[str, str]:
|
|
return {
|
|
"icon": self.game_name,
|
|
"navbar": NAMECARD_DATA[str(self.id)]["navbar"],
|
|
"profile": NAMECARD_DATA[str(self.id)]["profile"],
|
|
}
|
|
|
|
@cached_property
|
|
def honey_name_map(self) -> dict[str, str]:
|
|
return {
|
|
"icon": self.honey_id,
|
|
"navbar": f"{self.honey_id}_back",
|
|
"profile": f"{self.honey_id}_profile",
|
|
}
|
|
|
|
|
|
class AssetsService(BaseService.Dependence):
|
|
"""asset服务
|
|
|
|
用于储存和管理 asset :
|
|
当对应的 asset (如某角色图标)不存在时,该服务会先查找本地。
|
|
若本地不存在,则从网络上下载;若存在,则返回其路径
|
|
"""
|
|
|
|
avatar: _AvatarAssets
|
|
"""角色"""
|
|
|
|
weapon: _WeaponAssets
|
|
"""武器"""
|
|
|
|
material: _MaterialAssets
|
|
"""素材"""
|
|
|
|
artifact: _ArtifactAssets
|
|
"""圣遗物"""
|
|
|
|
namecard: _NamecardAssets
|
|
"""名片"""
|
|
|
|
def __init__(self):
|
|
for attr, assets_type_name in filter(
|
|
lambda x: (not x[0].startswith("_")) and x[1].endswith("Assets"), self.__annotations__.items()
|
|
):
|
|
setattr(self, attr, globals()[assets_type_name]())
|
|
|
|
async def initialize(self) -> None: # pylint: disable=R0201
|
|
"""启动 AssetsService 服务,刷新元数据"""
|
|
logger.info("正在刷新元数据")
|
|
# todo 这3个任务同时异步下载
|
|
await update_metadata_from_github(False)
|
|
await update_metadata_from_ambr(False)
|
|
await update_honey_metadata(False)
|
|
logger.info("刷新元数据成功")
|
|
|
|
|
|
AssetsServiceType = TypeVar("AssetsServiceType", bound=_AssetsService)
|