Support zzz avatar list

This commit is contained in:
xtaodada 2024-07-06 18:10:50 +08:00
parent 037f5ef3d2
commit a9caa544d0
Signed by: xtaodada
GPG Key ID: 4CBB3F4FA8C85659
6 changed files with 558 additions and 11 deletions

View File

@ -8,8 +8,8 @@ from typing import TYPE_CHECKING, Union
from pydantic import ValidationError
from simnet import ZZZClient, Region
from simnet.errors import BadRequest as SimnetBadRequest, InvalidCookies, NetworkError, CookieException, NeedChallenge
from simnet.models.genshin.calculator import CalculatorCharacterDetails
from simnet.models.genshin.chronicle.characters import Character
from simnet.models.zzz.calculator import ZZZCalculatorCharacter
from simnet.models.zzz.character import ZZZPartialCharacter
from simnet.utils.player import recognize_game_biz
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm.exc import StaleDataError
@ -87,13 +87,13 @@ class CharacterDetails(Plugin):
self,
uid: int,
character_id: int,
) -> Optional["CalculatorCharacterDetails"]:
) -> Optional["ZZZCalculatorCharacter"]:
name = self.get_qname(uid, character_id)
data = await self.redis.get(name)
if data is None:
return None
json_data = str(data, encoding="utf-8")
return CalculatorCharacterDetails.parse_raw(json_data)
return ZZZCalculatorCharacter.parse_raw(json_data)
async def set_character_details(self, player_id: int, character_id: int, data: str):
randint = random.randint(1, 30) # nosec
@ -135,7 +135,7 @@ class CharacterDetails(Plugin):
self,
uid: int,
character_id: int,
) -> Optional["CalculatorCharacterDetails"]:
) -> Optional["ZZZCalculatorCharacter"]:
async with AsyncSession(self.database.engine) as session:
statement = (
select(CharacterDetailsSQLModel)
@ -146,7 +146,7 @@ class CharacterDetails(Plugin):
data = results.first()
if data is not None:
try:
return CalculatorCharacterDetails.parse_raw(data.data)
return ZZZCalculatorCharacter.parse_raw(data.data)
except ValidationError as exc:
logger.error("解析数据出现异常 ValidationError", exc_info=exc)
await session.delete(data)
@ -158,11 +158,11 @@ class CharacterDetails(Plugin):
return None
async def get_character_details(
self, client: "ZZZClient", character: "Union[int,Character]"
) -> Optional["CalculatorCharacterDetails"]:
self, client: "ZZZClient", character: "Union[int,ZZZPartialCharacter]"
) -> Optional["ZZZCalculatorCharacter"]:
"""缓存 character_details 并定时对其进行数据存储 当遇到 Too Many Requests 可以获取以前的数据"""
uid = client.player_id
if isinstance(character, Character):
if isinstance(character, ZZZPartialCharacter):
character_id = character.id
else:
character_id = character
@ -171,7 +171,7 @@ class CharacterDetails(Plugin):
if detail is not None:
return detail
try:
detail = await client.get_character_details(character_id)
detail = (await client.get_zzz_character_info([character_id])).characters[0]
except SimnetBadRequest as exc:
if "Too Many Requests" in exc.message:
return await self.get_character_details_for_mysql(uid, character_id)
@ -179,7 +179,7 @@ class CharacterDetails(Plugin):
asyncio.create_task(self.set_character_details(uid, character_id, detail.json(by_alias=True)))
return detail
try:
return await client.get_character_details(character_id)
return (await client.get_zzz_character_info([character_id])).characters[0]
except SimnetBadRequest as exc:
if "Too Many Requests" in exc.message:
logger.warning("Too Many Requests")

249
plugins/zzz/avatar_list.py Normal file
View File

@ -0,0 +1,249 @@
import asyncio
import math
from typing import List, Optional, TYPE_CHECKING, Dict, Union, Tuple, Any
from arkowrapper import ArkoWrapper
from pydantic import BaseModel
from simnet.models.zzz.calculator import ZZZCalculatorCharacter
from simnet.models.zzz.character import ZZZPartialCharacter
from telegram.constants import ChatAction
from telegram.ext import filters
from core.dependence.assets import AssetsService, AssetsCouldNotFound
from core.plugin import Plugin, handler
from core.services.cookies import CookiesService
from core.services.template.models import FileType
from core.services.template.services import TemplateService
from core.services.wiki.services import WikiService
from gram_core.plugin.methods.inline_use_data import IInlineUseData
from gram_core.services.template.models import RenderGroupResult
from plugins.tools.genshin import GenshinHelper, CharacterDetails
from utils.const import RESOURCE_DIR
from utils.log import logger
from utils.uid import mask_number
if TYPE_CHECKING:
from simnet import ZZZClient
from telegram.ext import ContextTypes
from telegram import Update
from gram_core.services.template.models import RenderResult
MAX_AVATAR_COUNT = 40
class EquipmentData(BaseModel):
id: int
name: str
level: int
eidolon: int
rarity: int
icon: str
class SkillData(BaseModel):
id: int
level: int
max_level: int
class AvatarData(BaseModel):
id: int
name: str
level: int
eidolon: int
rarity: int
icon: str = ""
skills: List[SkillData]
equipment: Optional[EquipmentData] = None
class AvatarListPlugin(Plugin):
"""练度统计"""
def __init__(
self,
cookies_service: CookiesService = None,
assets_service: AssetsService = None,
template_service: TemplateService = None,
wiki_service: WikiService = None,
helper: GenshinHelper = None,
character_details: CharacterDetails = None,
) -> None:
self.cookies_service = cookies_service
self.assets_service = assets_service
self.template_service = template_service
self.wiki_service = wiki_service
self.helper = helper
self.character_details = character_details
async def get_avatar_data(self, character_id: int, client: "ZZZClient") -> Optional["ZZZCalculatorCharacter"]:
return await self.character_details.get_character_details(client, character_id)
@staticmethod
async def get_avatars_data(client: "ZZZClient") -> List["ZZZPartialCharacter"]:
task_info_results = (await client.get_zzz_characters()).characters
return sorted(
list(filter(lambda x: x, task_info_results)),
key=lambda x: (
x.level,
x.rarity,
x.rank,
),
reverse=True,
)
async def get_avatars_details(
self, characters: List["ZZZPartialCharacter"], client: "ZZZClient"
) -> Dict[int, "ZZZCalculatorCharacter"]:
async def _task(cid):
return await self.get_avatar_data(cid, client)
task_detail_results = await asyncio.gather(*[_task(character.id) for character in characters])
return {character.id: detail for character, detail in zip(characters, task_detail_results)}
@staticmethod
def get_skill_data(character: Optional["ZZZCalculatorCharacter"]) -> List[SkillData]:
if not character:
return [SkillData(id=i, level=1, max_level=10) for i in range(1, 5)]
return [SkillData(id=skill.skill_type, level=skill.level, max_level=10) for skill in character.skills]
@staticmethod
def fix_rarity(rarity: str) -> int:
return {"S": 5, "A": 4, "B": 3}.get(rarity, 4)
async def get_final_data(self, characters: List["ZZZPartialCharacter"], client: "ZZZClient") -> List[AvatarData]:
details = await self.get_avatars_details(characters, client)
data = []
for character in characters:
try:
detail = details.get(character.id)
equip = (
EquipmentData(
id=detail.weapon.id,
name=detail.weapon.name,
level=detail.weapon.level,
eidolon=detail.weapon.star,
rarity=self.fix_rarity(detail.weapon.rarity),
icon=self.assets_service.weapon.icon(detail.weapon.id, detail.weapon.name).as_uri(),
)
if detail.weapon
else None
)
avatar = AvatarData(
id=character.id,
name=character.name,
level=character.level,
eidolon=character.rank,
rarity=self.fix_rarity(character.rarity),
icon=self.assets_service.avatar.icon(character.id, character.name).as_uri(),
skills=self.get_skill_data(detail),
equipment=equip,
)
data.append(avatar)
except AssetsCouldNotFound as e:
logger.warning("未找到角色 %s[%s] 的资源: %s", character.name, character.id, e)
return data
async def avatar_list_render(
self,
base_render_data: Dict,
avatar_datas: List[AvatarData],
only_one_page: bool,
) -> Union[Tuple[Any], List["RenderResult"], None]:
def render_task(start_id: int, c: List[AvatarData]):
_render_data = {
"avatar_datas": c, # 角色数据
"start_id": start_id, # 开始序号
}
_render_data.update(base_render_data)
return self.template_service.render(
"zzz/avatar_list/main.html",
_render_data,
viewport={"width": 1040, "height": 500},
full_page=True,
query_selector=".container",
file_type=FileType.PHOTO,
ttl=30 * 24 * 60 * 60,
)
if only_one_page:
return [await render_task(0, avatar_datas)]
image_count = len(avatar_datas)
while image_count > MAX_AVATAR_COUNT:
image_count /= 2
image_count = math.ceil(image_count)
avatar_datas_group = [avatar_datas[i : i + image_count] for i in range(0, len(avatar_datas), image_count)]
tasks = [render_task(i * image_count, c) for i, c in enumerate(avatar_datas_group)]
return await asyncio.gather(*tasks)
async def add_theme_data(self, data: Dict, player_id: int):
res = RESOURCE_DIR / "img"
data["avatar"] = (res / "avatar.png").as_uri()
data["background"] = (res / "home.png").as_uri()
return data
async def render(self, client: "ZZZClient", all_avatars: bool = False) -> List["RenderResult"]:
characters: List["ZZZPartialCharacter"] = await self.get_avatars_data(client)
has_more = (not all_avatars) and len(characters) > MAX_AVATAR_COUNT
if has_more:
characters = characters[:MAX_AVATAR_COUNT]
avatar_datas = await self.get_final_data(characters, client)
base_render_data = {
"uid": mask_number(client.player_id), # 玩家uid
"has_more": has_more, # 是否显示了全部角色
}
await self.add_theme_data(base_render_data, client.player_id)
return await self.avatar_list_render(base_render_data, avatar_datas, has_more)
@handler.command("avatars", cookie=True, block=False)
@handler.message(filters.Regex(r"^(全部)?练度统计$"), cookie=True, block=False)
async def avatar_list(self, update: "Update", _: "ContextTypes.DEFAULT_TYPE"):
user_id = await self.get_real_user_id(update)
message = update.effective_message
uid, offset = self.get_real_uid_or_offset(update)
all_avatars = "全部" in message.text or "all" in message.text # 是否发送全部角色
self.log_user(update, logger.info, "[bold]练度统计[/bold]: all=%s", all_avatars, extra={"markup": True})
await message.reply_chat_action(ChatAction.TYPING)
async with self.helper.genshin(user_id, player_id=uid, offset=offset) as client:
notice = await message.reply_text("彦卿需要收集整理数据,还请耐心等待哦~")
self.add_delete_message_job(notice, delay=60)
images = await self.render(client, all_avatars)
for group in ArkoWrapper(images).group(10): # 每 10 张图片分一个组
await RenderGroupResult(results=group).reply_media_group(message, write_timeout=60)
self.log_user(
update,
logger.info,
"[bold]练度统计[/bold]发送图片成功",
extra={"markup": True},
)
async def avatar_list_use_by_inline(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE") -> None:
callback_query = update.callback_query
user = update.effective_user
user_id = user.id
uid = IInlineUseData.get_uid_from_context(context)
self.log_user(update, logger.info, "查询练度统计")
async with self.helper.genshin(user_id, player_id=uid) as client:
client: "ZZZClient"
images = await self.render(client)
render = images[0]
await render.edit_inline_media(callback_query)
async def get_inline_use_data(self) -> List[Optional[IInlineUseData]]:
return [
IInlineUseData(
text="练度统计",
hash="avatar_list",
callback=self.avatar_list_use_by_inline,
cookie=True,
player=True,
)
]

View File

@ -0,0 +1,131 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Avatar List</title>
<link type="text/css" href="./style.css" rel="stylesheet"/>
<link type="text/css" href="../../styles/public.css" rel="stylesheet"/>
<script src="../../js/tailwindcss-3.1.8.js"></script>
</head>
<body>
<div class="container flex flex-col justify-center p-6">
<div class="header rounded-xl p-2 mb-6" style='background-image: url("{{ background }}") '>
<div class="frame rounded-lg border-solid border-2 bg-contain p-4 flex items-center">
<img class="w-28 h-28 rounded-full mr-4" src="{{ avatar }}" alt="Avatar">
<div>
<div class="uid text-xl italic px-2">UID: {{ uid }}</div>
</div>
</div>
</div>
<div class="content rounded-xl overflow-hidden flex flex-col justify-center">
<table class="table text-center border-collapse">
<thead>
<tr>
<th class="text-right">#</th>
<th colspan="2">角色</th>
<th>等级</th>
<th>意象</th>
<th>普攻</th>
<th>特殊</th>
<th>闪避</th>
<th>连携</th>
<th>核心</th>
<th>支援</th>
<th colspan="4">音擎</th>
</tr>
</thead>
<tbody>
{% for avatar_data in avatar_datas %}
{% set equipment = avatar_data.equipment %}
{% if avatar_data.rarity == 5 %}
{% set row_bg = 'gold' %}
{% else %}
{% set row_bg = 'nongold' %}
{% endif %}
{% if equipment != none %}
{% set equip_star = equipment.rarity %}
{% else %}
{% set equip_star = '' %}
{% endif %}
<tr class="h-8 border border-b">
<td class="text-right {{row_bg}}">{{ start_id + loop.index }}</td>
<td class="role {{row_bg}}">
<img class="h-8 pl-2 img-drop-shadow" src="{{ avatar_data.icon }}"/>
</td>
<td class="role text-left {{row_bg}}">{{ avatar_data.name }}</td>
<td>{{ avatar_data.level }}</td>
<td
{% set constellation = avatar_data.eidolon %}
{% if constellation != 0 %}
class="color {{ ['green', 'cyan', 'blue', 'purple', 'pink', 'red'][constellation - 1] }}"
{% endif %}
>
<span class="number role inline-block">{{ constellation }}</span>
<div class="role bg"></div>
</td>
{% for skill in avatar_data.skills %}
{% set talent_style = 'talent' %}
{% set skill_level = skill.level %}
{% if skill_level < 4 %}
{% set talent_style = talent_style + ' talent-level-first' %}
{% endif %}
{% if skill.max_level == skill.level %}
{% if loop.index == 1 %}
{% set talent_max_style = " talent-level-max" %}
{% else %}
{% set talent_max_style = " talent-level-max-img" %}
{% endif %}
{% set talent_style = talent_style + talent_max_style %}
{% endif %}
{% if skill.max_level != skill.level %}
{% if skill_level < 4 %}
{% set talent_style = talent_style + ' talent-level-1' %}
{% elif skill_level < 6 %}
{% set talent_style = talent_style + ' talent-level-2' %}
{% elif skill_level < 9 %}
{% set talent_style = talent_style + ' talent-level-3' %}
{% else %}
{% set talent_style = talent_style + ' talent-level-4' %}
{% endif %}
{% endif %}
<td class="{{ talent_style }}">{{ skill.level }}</td>
{% endfor %}
{% if equipment != none %}
<td class="weapon-{{ equip_star }}-star text-left pl-3">
{% if equipment.level < 10 %}
Lv.{{ equipment.level }}
{% else %}
Lv.{{ equipment.level }}
{% endif %}
</td>
<td class="weapon-{{ equip_star }}-star color border-none {{ ['green', 'cyan', 'blue', 'purple', 'red'][equipment.eidolon - 1] }}">
<span class="weapon number">{{ equipment.eidolon }}</span>
<div class="weapon bg p-1">
<div class="frame-border border-solid border"></div>
</div>
</td>
<td class="weapon weapon-{{ equip_star }}-star">
<img class="h-8 pl-1" src="{{ equipment.icon }}" alt="weapon">
</td>
<td class="weapon-{{ equip_star }}-star text-left">{{ equipment.name }}</td>
{% else %}
<td colspan="4"></td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
<div class="notice">
{% if has_more %}
<div>
*想查看完整数据请在指令中加上<code>all</code>或者<code>全部</code>: <code>/avatars all</code><code>全部练度统计</code>
</div>
{% endif %}
</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,167 @@
:root {
--white: rgb(246 248 249);
--bg-color: rgb(233 229 220);
--h-color: rgb(203 189 162);
--red: rgb(255 86 33/ 80%);
--pink: rgb(215 57 203/80%);
--purple: rgb(159 68 211/80%);
--blue: rgb(98 168 233/ 80%);
--cyan: rgb(4 150 255/80%);
--green: rgb(67 185 124/ 80%);
--grey: rgb(189 191 190);
}
/* 上半 */
.container {
width: 1000px !important;
max-width: 1000px !important;
}
.header {
/*background: #e0dad3 url(../gacha_log/img/) no-repeat right;*/
box-shadow: 0 0 8px #72a2ae79;
background-size: cover;
background-position: top;
}
.frame {
border-color: #cdbea8;
color: white;
}
.text-shadow {
text-shadow: 0 0.08em 0.1em #00000023;
}
/* 下半 */
.content {
box-shadow: 0 0 8px #72a2ae79;
font-size: 21px;
}
.table thead {
background-color: #e0dad3;
}
.table tr {
border-color: #00000021;
}
.table tr:nth-child(even) {
background-color: #f2f2f2;
}
.role img {
transform: scale(1.05);
}
.weapon img {
transform: scale(1.1);
}
/* 计数+菱形图案 */
.weapon img,
.color {
position: relative;
z-index: 0;
}
.role.bg,
.weapon.bg {
position: absolute;
z-index: -1;
}
.role.bg {
top: 5px;
left: 17px;
width: 22px;
height: 22px;
transform: rotateZ(45deg);
}
.weapon.bg {
top: 0;
left: -8px;
transform: skewX(-11deg);
}
.frame-border {
border-color: #f7e2ad2b;
width: 62px;
height: 27px;
}
.color .number {
color: white;
}
.green .bg {
background-color: #3c7d7f;
}
.cyan .bg {
background-color: #4898c7;
}
.blue .bg {
background-color: #4576b4;
}
.purple .bg {
background-color: #955ec9;
}
.pink .bg {
background-color: #c24e9a;
}
.red .bg {
background-color: #c24e4e;
}
/* 武器、角色背景 */
.weapon-1-star {
background-color: #cfcfcf;
}
.weapon-2-star {
background-color: #c0edee;
}
.weapon-3-star {
background-color: #bedcff;
}
.weapon-4-star, .nongold {
background-color: #d3c8fa;
}
.weapon-5-star, .gold {
background-color: #f9e7bb;
}
.notice {
background-image: linear-gradient(135deg, #9452a5 10%, #fff5c3 100%);
}
.talent {
background-size: contain, 1.6em;
background-repeat: no-repeat;
background-position: center center;
text-shadow: 1px 1px 2px rgb(0 0 0 /20%);
z-index: -1 !important;
border-right-width: 0 !important;
border-left-width: 0 !important;
}
.talent-level-first {
background-color: rgb(189, 191, 190) !important;
}
.talent-level-1 {
background-color: rgb(189, 191, 190);
}
.talent-level-2 {
background-color: var(--green);
}
.talent-level-3 {
background-color: var(--blue);
}
.talent-level-4 {
background-color: rgb(190, 160, 250);
}
.talent-level-max {
background-image: linear-gradient(90deg, rgba(251, 129, 124, 0.8) 0%, rgba(255, 93, 85, 0.65) 50%, rgba(251, 129, 124, 0.8) 100%) !important;
}
.talent-level-max-img {
background-image: linear-gradient(90deg, rgba(251, 129, 124, 0.8) 0%, rgba(255, 93, 85, 0.65) 50%, rgba(251, 129, 124, 0.8) 100%) !important;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB