import asyncio import typing from asyncio import Lock from ctypes import c_double from datetime import datetime from functools import partial from multiprocessing import Value from os import path from ssl import SSLZeroReturnError from time import time as time_ from typing import TYPE_CHECKING, Dict, Iterable, Iterator, List, Optional, Tuple import aiofiles import aiofiles.os import bs4 import pydantic from arkowrapper import ArkoWrapper from httpx import AsyncClient, HTTPError, TimeoutException from pydantic import BaseModel, RootModel from simnet.errors import BadRequest as SimnetBadRequest from simnet.errors import InvalidCookies from simnet.models.genshin.chronicle.characters import Character from telegram.constants import ChatAction, ParseMode from telegram.error import RetryAfter, TimedOut from core.config import config from core.dependence.assets import AssetsCouldNotFound, AssetsService, AssetsServiceType from core.plugin import Plugin, handler from core.services.template.models import FileType, RenderGroupResult from core.services.template.services import TemplateService from metadata.genshin import AVATAR_DATA, HONEY_DATA from plugins.tools.genshin import CharacterDetails, CookiesNotFoundError, GenshinHelper, PlayerNotFoundError from utils.const import DATA_DIR from utils.log import logger from utils.uid import mask_number if TYPE_CHECKING: from simnet import GenshinClient from telegram import Message, Update from telegram.ext import ContextTypes INTERVAL = 1 DATA_FILE_PATH = DATA_DIR.joinpath("daily_material.json").resolve() # fmt: off # 章节顺序、国家(区域)名是从《足迹》 PV 中取的 DOMAINS = [ "忘却之峡", # 蒙德精通秘境 "太山府", # 璃月精通秘境 "菫色之庭", # 稻妻精通秘境 "昏识塔", # 须弥精通秘境 "苍白的遗荣", # 枫丹精通秘境 "蕴火的幽墟", # 纳塔精通秘境 "", # 至东精通秘境 "", # 坎瑞亚精通秘境 "塞西莉亚苗圃", # 蒙德炼武秘境 "震雷连山密宫", # 璃月炼武秘境 "砂流之庭", # 稻妻炼武秘境 "有顶塔", # 须弥炼武秘境 "深潮的余响", # 枫丹炼武秘境 "深古瞭望所", # 纳塔炼武秘境 "", # 至东炼武秘境 "", # 坎瑞亚炼武秘境 ] # fmt: on DOMAIN_AREA_MAP = dict(zip(DOMAINS, ["蒙德", "璃月", "稻妻", "须弥", "枫丹", "纳塔", "至冬", "坎瑞亚"] * 2)) # 此处 avatar 和 weapon 需要分别对应 AreaDailyMaterialsData 中的两个 *_materials 字段,具体逻辑见 _parse_honey_impact_source DOMAIN_TYPE_MAP = dict(zip(DOMAINS, len(DOMAINS) // 2 * ["avatar"] + len(DOMAINS) // 2 * ["weapon"])) WEEK_MAP = ["一", "二", "三", "四", "五", "六", "日"] def sort_item(items: List["ItemData"]) -> Iterable["ItemData"]: """对武器和角色进行排序 排序规则:持有(星级 > 等级 > 命座/精炼) > 未持有(星级 > 等级 > 命座/精炼) """ def key(item: "ItemData"): # 有个小小的意外逻辑,不影响排序输出,如果要修改可能需要注意下: # constellation 可以为 None 或 0,此时都会被判断为 False # 此时 refinment or constellation or -1 都会返回 -1 # 但不影响排序结果 return ( item.level is not None, # 根据持有与未持有进行分组并排序 item.rarity, # 根据星级分组并排序 item.refinement or item.constellation or -1, # 根据命座/精炼进行分组并排序 item.id, # 默认按照物品 ID 进行排序 ) return sorted(items, key=key, reverse=True) def get_material_serial_name(names: Iterable[str]) -> str: """ 获取材料的系列名,本质上是求字符串列表的最长子串 如:「自由」的教导、「自由」的指引、「自由」的哲学,三者系列名为「『自由』」 如:高塔孤王的破瓦、高塔孤王的残垣、高塔孤王的断片、高塔孤王的碎梦,四者系列名为「高塔孤王」 TODO(xr1s): 感觉可以优化 """ def all_substrings(string: str) -> Iterator[str]: """获取字符串的所有连续字串""" length = len(string) for i in range(length): for j in range(i + 1, length + 1): yield string[i:j] result = [] for name_a, name_b in ArkoWrapper(names).repeat(1).group(2).unique(list): for sub_string in all_substrings(name_a): if sub_string in ArkoWrapper(all_substrings(name_b)): result.append(sub_string) result = ArkoWrapper(result).sort(len, reverse=True)[0] chars = {"的": 0, "之": 0} for char, k in chars.items(): result = result.split(char)[k] return result class AreaDailyMaterialsData(BaseModel): """ AreaDailyMaterialsData 储存某一天某个国家所有可以刷的突破素材以及可以突破的角色和武器 对应 /daily_material 命令返回的图中一个国家横向这一整条的信息 """ avatar_materials: List[str] = [] """ avatar_materials 是当日该国所有可以刷的精通和炼武素材的 ID 列表 举个例子:稻妻周三可以刷「天光」系列材料 (不用蒙德璃月举例是因为它们每天的角色武器太多了,等稻妻多了再换) 那么 avatar_materials 将会包括 - 104326 「天光」的教导 - 104327 「天光」的指引 - 104328 「天光」的哲学 """ avatar: List[str] = [] """ avatar 是排除旅行者后该国当日可以突破天赋的角色 ID 列表 举个例子:稻妻周三可以刷「天光」系列精通素材 需要用到「天光」系列的角色有 - 10000052 雷电将军 - 10000053 早柚 - 10000055 五郎 - 10000058 八重神子 """ weapon_materials: List[str] = [] """ weapon_materials 是当日该国所有可以刷的炼武素材的 ID 列表 举个例子:稻妻周三可以刷今昔剧画系列材料 那么 weapon_materials 将会包括 - 114033 今昔剧画之恶尉 - 114034 今昔剧画之虎啮 - 114035 今昔剧画之一角 - 114036 今昔剧画之鬼人 """ weapon: List[str] = [] """ weapon 是该国当日可以突破天赋的武器 ID 列表 举个例子:稻妻周三可以刷今昔剧画系列炼武素材 需要用到今昔剧画系列的武器有 - 11416 笼钓瓶一心 - 13414 喜多院十文字 - 13415 「渔获」 - 13416 断浪长鳍 - 13509 薙草之稻光 - 14509 神乐之真意 """ class MaterialsData(RootModel): root: Optional[List[Dict[str, "AreaDailyMaterialsData"]]] = None def weekday(self, weekday: int) -> Dict[str, "AreaDailyMaterialsData"]: if self.root is None: return {} return self.root[weekday] def is_empty(self) -> bool: return self.root is None class DailyMaterial(Plugin): """每日素材表""" everyday_materials: "MaterialsData" = MaterialsData() """ everyday_materials 储存的是一周中每天能刷的素材 ID 按照如下形式组织 ```python everyday_materials[周几][国家] = AreaDailyMaterialsData( avatar=[角色, 角色, ...], avatar_materials=[精通素材, 精通素材, 精通素材], weapon=[武器, 武器, ...] weapon_materials=[炼武素材, 炼武素材, 炼武素材, 炼武素材], ) ``` """ locks: Tuple[Lock, Lock] = (Lock(), Lock()) """ Tuple[每日素材缓存锁, 角色武器材料图标锁] """ def __init__( self, assets: AssetsService, template_service: TemplateService, helper: GenshinHelper, character_details: CharacterDetails, ): self.assets_service = assets self.template_service = template_service self.helper = helper self.character_details = character_details self.client = AsyncClient() async def initialize(self): """插件在初始化时,会检查一下本地是否缓存了每日素材的数据""" async def task_daily(): async with self.locks[0]: logger.info("正在开始获取每日素材缓存") await self._refresh_everyday_materials() # 当缓存不存在或已过期(大于 3 天)则重新下载 # TODO(xr1s): 是不是可以改成 21 天? if not await aiofiles.os.path.exists(DATA_FILE_PATH): asyncio.create_task(task_daily()) else: mtime = await aiofiles.os.path.getmtime(DATA_FILE_PATH) mtime = datetime.fromtimestamp(mtime) elapsed = datetime.now() - mtime if elapsed.days > 3: asyncio.create_task(task_daily()) # 若存在则直接使用缓存 if await aiofiles.os.path.exists(DATA_FILE_PATH): async with aiofiles.open(DATA_FILE_PATH, "rb") as cache: try: self.everyday_materials = self.everyday_materials.parse_raw(await cache.read()) except pydantic.ValidationError: await aiofiles.os.remove(DATA_FILE_PATH) asyncio.create_task(task_daily()) async def _get_skills_data(self, client: "FragileGenshinClient", character: Character) -> Optional[List[int]]: if client.damaged: return None detail = None try: real_client = typing.cast("GenshinClient", client.client) detail = await self.character_details.get_character_details(real_client, character) except InvalidCookies: client.damaged = True except SimnetBadRequest as e: if e.ret_code == -502002: client.damaged = True raise if detail is None: return None talents = [t for t in detail.talents if t.type in ["attack", "skill", "burst"]] return [t.level for t in talents] async def _get_items_from_user( self, user_id: int, uid: int, offset: int ) -> Tuple[Optional["GenshinClient"], "UserOwned"]: """获取已经绑定的账号的角色、武器信息""" user_data = UserOwned() try: logger.debug("尝试获取已绑定的原神账号") client = await self.helper.get_genshin_client(user_id, player_id=uid, offset=offset) logger.debug("获取账号数据成功: UID=%s", client.player_id) characters = await client.get_genshin_characters(client.player_id) for character in characters: if character.name == "旅行者": continue character_id = str(AVATAR_DATA[str(character.id)]["id"]) character_assets = self.assets_service.avatar(character_id) character_icon = await character_assets.icon(False) character_side = await character_assets.side(False) user_data.avatar[character_id] = ItemData( id=character_id, name=typing.cast(str, character.name), rarity=int(typing.cast(str, character.rarity)), level=character.level, constellation=character.constellation, gid=character.id, icon=character_icon.as_uri(), origin=character, ) # 判定武器的突破次数是否大于 2, 若是, 则将图标替换为 awakened (觉醒) 的图标 weapon = character.weapon weapon_id = str(weapon.id) weapon_awaken = "icon" if weapon.ascension < 2 else "awaken" weapon_icon = await getattr(self.assets_service.weapon(weapon_id), weapon_awaken)() if weapon_id not in user_data.weapon: # 由于用户可能持有多把同一种武器 # 这里需要使用 List 来储存所有不同角色持有的同名武器 user_data.weapon[weapon_id] = [] user_data.weapon[weapon_id].append( ItemData( id=weapon_id, name=weapon.name, level=weapon.level, rarity=weapon.rarity, refinement=weapon.refinement, icon=weapon_icon.as_uri(), c_path=character_side.as_uri(), ) ) except (PlayerNotFoundError, CookiesNotFoundError): self.log_user(user_id, logger.info, "未查询到绑定的账号信息") except InvalidCookies: self.log_user(user_id, logger.info, "所绑定的账号信息已失效") else: # 没有异常返回数据 return client, user_data # 有上述异常的, client 会返回 None return None, user_data async def area_user_weapon( self, area_name: str, user_owned: "UserOwned", area_daily: "AreaDailyMaterialsData", loading_prompt: "Message", ) -> Optional["AreaData"]: """ area_user_weapon 通过从选定区域当日可突破的武器中查找用户持有的武器 计算 /daily_material 返回的页面中该国下会出现的武器列表 """ weapon_items: List["ItemData"] = [] for weapon_id in area_daily.weapon: weapons = user_owned.weapon.get(weapon_id) if weapons is None or len(weapons) == 0: weapon = await self._assemble_item_from_honey_data("weapon", weapon_id) if weapon is None: continue weapons = [weapon] if weapons[0].rarity < 4: continue weapon_items.extend(weapons) if len(weapon_items) == 0: return None weapon_materials = await self.user_materials(area_daily.weapon_materials, loading_prompt) return AreaData( name=area_name, materials=weapon_materials, items=list(sort_item(weapon_items)), material_name=get_material_serial_name(map(lambda x: x.name, weapon_materials)), ) async def area_user_avatar( self, area_name: str, user_owned: "UserOwned", area_daily: "AreaDailyMaterialsData", client: "FragileGenshinClient", loading_prompt: "Message", ) -> Optional["AreaData"]: """ area_user_avatar 通过从选定区域当日可升级的角色技能中查找用户拥有的角色 计算 /daily_material 返回的页面中该国下会出现的角色列表 """ avatar_items: List[ItemData] = [] for avatar_id in area_daily.avatar: avatar = user_owned.avatar.get(avatar_id) avatar = avatar or await self._assemble_item_from_honey_data("avatar", avatar_id) if avatar is None: continue if avatar.origin is None: avatar_items.append(avatar) continue # 最大努力获取用户角色等级 try: avatar.skills = await self._get_skills_data(client, avatar.origin) except SimnetBadRequest as e: if e.ret_code != -502002: raise e self.add_delete_message_job(loading_prompt, delay=5) await loading_prompt.edit_text( "获取角色天赋信息失败,如果想要显示角色天赋信息,请先在米游社/HoYoLab中使用一次养成计算器后再使用此功能~", parse_mode=ParseMode.HTML, ) avatar_items.append(avatar) if len(avatar_items) == 0: return None avatar_materials = await self.user_materials(area_daily.avatar_materials, loading_prompt) return AreaData( name=area_name, materials=avatar_materials, items=list(sort_item(avatar_items)), material_name=get_material_serial_name(map(lambda x: x.name, avatar_materials)), ) async def user_materials(self, material_ids: List[str], loading_prompt: "Message") -> List["ItemData"]: """ user_materials 返回 /daily_material 每个国家角色或武器列表右上角标的素材列表 """ area_materials: List[ItemData] = [] for material_id in material_ids: # 添加这个区域当天(weekday)的培养素材 material = None try: material = self.assets_service.material(material_id) except AssetsCouldNotFound as exc: logger.warning("AssetsCouldNotFound message[%s] target[%s]", exc.message, exc.target) await loading_prompt.edit_text(f"出错了呜呜呜 ~ {config.notice.bot_name}找不到一些素材") raise [_, material_name, material_rarity] = HONEY_DATA["material"][material_id] material_icon = await material.icon(False) material_uri = material_icon.as_uri() area_materials.append( ItemData( id=material_id, icon=material_uri, name=typing.cast(str, material_name), rarity=typing.cast(int, material_rarity), ) ) return area_materials @handler.command("daily_material", block=False) async def daily_material(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE"): user_id = await self.get_real_user_id(update) uid, offset = self.get_real_uid_or_offset(update) message = typing.cast("Message", update.effective_message) args = self.get_args(context) now = datetime.now() try: weekday = (_ := int(args[0])) - (_ > 0) weekday = (weekday % 7 + 7) % 7 time = title = f"星期{WEEK_MAP[weekday]}" except (ValueError, IndexError): title = "今日" weekday = now.weekday() - (1 if now.hour < 4 else 0) weekday = 6 if weekday < 0 else weekday time = f"星期{WEEK_MAP[weekday]}" full = bool(args and args[-1] == "full") # 判定最后一个参数是不是 full self.log_user(update, logger.info, "每日素材命令请求 || 参数 weekday=%s full=%s", WEEK_MAP[weekday], full) if weekday == 6: the_day = "今天" if title == "今日" else "这天" await message.reply_text(f"{the_day}是星期天, 全部素材都可以刷哦~", parse_mode=ParseMode.HTML) return if self.locks[0].locked(): # 若检测到了第一个锁:正在下载每日素材表的数据 loading_prompt = await message.reply_text(f"{config.notice.bot_name}正在摘抄每日素材表,以后再来探索吧~") self.add_delete_message_job(loading_prompt, delay=5) return if self.locks[1].locked(): # 若检测到了第二个锁:正在下载角色、武器、材料的图标 await message.reply_text(f"{config.notice.bot_name}正在搬运每日素材的图标,以后再来探索吧~") return loading_prompt = await message.reply_text(f"{config.notice.bot_name}可能需要找找图标素材,还请耐心等待哦~") await message.reply_chat_action(ChatAction.TYPING) # 获取已经缓存的秘境素材信息 if self.everyday_materials.is_empty(): # 若没有缓存每日素材表的数据 logger.info("正在获取每日素材缓存") await self._refresh_everyday_materials() # 尝试获取用户已绑定的原神账号信息 client, user_owned = await self._get_items_from_user(user_id, uid, offset) today_materials = self.everyday_materials.weekday(weekday) fragile_client = FragileGenshinClient(client) area_avatars: List["AreaData"] = [] area_weapons: List["AreaData"] = [] for country_name, area_daily in today_materials.items(): area_avatar = await self.area_user_avatar( country_name, user_owned, area_daily, fragile_client, loading_prompt ) if area_avatar is not None: area_avatars.append(area_avatar) area_weapon = await self.area_user_weapon(country_name, user_owned, area_daily, loading_prompt) if area_weapon is not None: area_weapons.append(area_weapon) render_data = RenderData( title=title, time=time, uid=mask_number(client.player_id) if client else client, character=area_avatars, weapon=area_weapons, ) await message.reply_chat_action(ChatAction.TYPING) # 是否发送原图 file_type = FileType.DOCUMENT if full else FileType.PHOTO character_img_data, weapon_img_data = await asyncio.gather( self.template_service.render( # 渲染角色素材页 "genshin/daily_material/character.jinja2", {"data": render_data}, {"width": 2060, "height": 500}, file_type=file_type, ttl=30 * 24 * 60 * 60, ), self.template_service.render( # 渲染武器素材页 "genshin/daily_material/weapon.jinja2", {"data": render_data}, {"width": 2060, "height": 500}, file_type=file_type, ttl=30 * 24 * 60 * 60, ), ) self.add_delete_message_job(loading_prompt, delay=5) await message.reply_chat_action(ChatAction.UPLOAD_PHOTO) character_img_data.filename = f"{title}可培养角色.png" weapon_img_data.filename = f"{title}可培养武器.png" await RenderGroupResult([character_img_data, weapon_img_data]).reply_media_group(message) logger.debug("角色、武器培养素材图发送成功") @handler.command("refresh_daily_material", admin=True, block=False) async def refresh(self, update: "Update", context: "ContextTypes.DEFAULT_TYPE"): user = update.effective_user message = update.effective_message logger.info("用户 {%s}[%s] 刷新[bold]每日素材[/]缓存命令", user.full_name, user.id, extra={"markup": True}) if self.locks[0].locked(): notice = await message.reply_text(f"{config.notice.bot_name}还在抄每日素材表呢,我有在好好工作哦~") self.add_delete_message_job(notice, delay=10) return if self.locks[1].locked(): notice = await message.reply_text(f"{config.notice.bot_name}正在搬运每日素材图标,在努力工作呢!") self.add_delete_message_job(notice, delay=10) return async with self.locks[1]: # 锁住第二把锁 notice = await message.reply_text( f"{config.notice.bot_name}正在重新摘抄每日素材表,请稍等~", parse_mode=ParseMode.HTML ) async with self.locks[0]: # 锁住第一把锁 await self._refresh_everyday_materials() notice = await notice.edit_text( "每日素材表" + ("摘抄完成!" if self.everyday_materials else "坏掉了!等会它再长好了之后我再抄。。。") + "\n正搬运每日素材的图标中。。。", parse_mode=ParseMode.HTML, ) time = await self._download_icon(notice) async def job(_, n): await n.edit_text(n.text_html.split("\n")[0] + "\n每日素材图标搬运完成!", parse_mode=ParseMode.HTML) await asyncio.sleep(INTERVAL) await notice.delete() context.application.job_queue.run_once( partial(job, n=notice), when=time + INTERVAL, name="notice_msg_final_job" ) async def _refresh_everyday_materials(self, retry: int = 5): """刷新来自 honey impact 的每日素材表""" for attempts in range(1, retry + 1): try: response = await self.client.get("https://gensh.honeyhunterworld.com/?lang=CHS") response.raise_for_status() except (HTTPError, SSLZeroReturnError): await asyncio.sleep(1) if attempts == retry: logger.error("每日素材刷新失败, 请稍后重试") return else: logger.warning("每日素材刷新失败, 正在重试第 %d 次", attempts) continue self.everyday_materials = _parse_honey_impact_source(response.content) # 当场缓存到文件 content = self.everyday_materials.model_dump_json() async with aiofiles.open(DATA_FILE_PATH, "w", encoding="utf-8") as file: await file.write(content) logger.success("每日素材刷新成功") return async def _assemble_item_from_honey_data(self, item_type: str, item_id: str) -> Optional["ItemData"]: """用户拥有的角色和武器中找不到数据时,使用 HoneyImpact 的数据组装出基本信息置灰展示""" honey_item = HONEY_DATA[item_type].get(item_id) if honey_item is None: return None try: icon = await getattr(self.assets_service, item_type)(item_id).icon() except KeyError: return None return ItemData( id=item_id, name=typing.cast(str, honey_item[1]), rarity=typing.cast(int, honey_item[2]), icon=icon.as_uri(), ) async def _download_icon(self, message: "Message") -> float: """下载素材图标""" asset_list = [] lock = asyncio.Lock() the_time = Value(c_double, time_() - INTERVAL) async def edit_message(text): """修改提示消息""" async with lock: if message is not None and time_() >= (the_time.value + INTERVAL): try: await message.edit_text( "\n".join(message.text_html.split("\n")[:2] + [text]), parse_mode=ParseMode.HTML ) the_time.value = time_() except (TimedOut, RetryAfter): pass async def task(item_id, name, item_type): try: logger.debug("正在开始下载 %s 的图标素材", name) await edit_message(f"正在搬运 {name} 的图标素材。。。") asset: AssetsServiceType = getattr(self.assets_service, item_type)(item_id) asset_list.append(asset.honey_id) # 找到该素材对象的所有图标类型 # 并根据图标类型找到下载对应图标的函数 for icon_type in asset.icon_types: await getattr(asset, icon_type)(True) # 执行下载函数 logger.debug("%s 的图标素材下载成功", name) await edit_message(f"正在搬运 {name} 的图标素材。。。成功!") except TimeoutException as exc: logger.warning("Httpx [%s]\n%s[%s]", exc.__class__.__name__, exc.request.method, exc.request.url) return exc except Exception as exc: logger.error("图标素材下载出现异常!", exc_info=exc) return exc notice_text = "图标素材下载完成" for TYPE, ITEMS in HONEY_DATA.items(): # 遍历每个对象 task_list = [] new_items = [] for ID, DATA in ITEMS.items(): if (ITEM := [ID, DATA[1], TYPE]) not in new_items: new_items.append(ITEM) task_list.append(task(*ITEM)) results = await asyncio.gather(*task_list, return_exceptions=True) # 等待所有任务执行完成 for result in results: if isinstance(result, TimeoutException): notice_text = "图标素材下载过程中请求超时\n有关详细信息,请查看日志" elif isinstance(result, Exception): notice_text = "图标素材下载过程中发生异常\n有关详细信息,请查看日志" break try: await message.edit_text(notice_text) except RetryAfter as e: await asyncio.sleep(e.retry_after + 0.1) await message.edit_text(notice_text) except Exception as e: logger.debug(e) logger.info("图标素材下载完成") return the_time.value def _parse_honey_impact_source(source: bytes) -> MaterialsData: """ ## honeyimpact 的源码格式: ```html
``` """ honey_item_url_map: Dict[str, str] = { # 这个变量可以静态化,不过考虑到这个函数三天调用一次,懒得改了 typing.cast(str, honey_url): typing.cast(str, honey_id) for honey_id, [honey_url, _, _] in HONEY_DATA["material"].items() } calendar = bs4.BeautifulSoup(source, "lxml").select_one(".calendar_day_wrap") if calendar is None: return MaterialsData() # 多半是格式错误或者网页数据有误 everyday_materials: List[Dict[str, "AreaDailyMaterialsData"]] = [{} for _ in range(7)] current_country: str = "" for element in calendar.find_all(recursive=False): element: bs4.Tag if element.name == "span": # 找到代表秘境的 span domain_name = next(iter(element)).text # 第一个孩子节点的 text current_country = DOMAIN_AREA_MAP[domain_name] # 后续处理 a 列表也会用到这个 current_country materials_type = f"{DOMAIN_TYPE_MAP[domain_name]}_materials" for div in element.find_all("div", recursive=False): # 7 个 div 对应的是一周中的每一天 div: bs4.Tag weekday = int(div.attrs["data-days"]) # data-days 是一周中的第几天(周一 0,周日 6) if current_country not in everyday_materials[weekday]: everyday_materials[weekday][current_country] = AreaDailyMaterialsData() materials: List[str] = getattr(everyday_materials[weekday][current_country], materials_type) for a in div.find_all("a", recursive=False): # 当天能刷的所有素材在 a 列表中 a: bs4.Tag href = a.attrs["href"] # 素材 ID 在 href 中 honey_url = path.dirname(href).removeprefix("/") materials.append(honey_item_url_map[honey_url]) if element.name == "a": # country_name 是从上面的 span 继承下来的,下面的 item 对应的是角色或者武器 # element 的第一个 child,也就是 div.calendar_pic_wrap calendar_pic_wrap = typing.cast(bs4.Tag, next(iter(element))) # element 的第一个孩子 item_name_span = calendar_pic_wrap.select_one("span") if item_name_span is None or item_name_span.text.strip() == "旅行者": continue # 因为旅行者的天赋计算比较复杂,不做旅行者的天赋计算 # data-assign 的数字就是 Item ID data_assign = calendar_pic_wrap.attrs["data-assign"] item_is_weapon = data_assign.startswith("weapon_") item_id = "".join(filter(str.isdigit, data_assign)) for weekday in map(int, calendar_pic_wrap.attrs["data-days"]): # data-days 中存的是星期几可以刷素材 ascendable_items = everyday_materials[weekday][current_country] ascendable_items = ascendable_items.weapon if item_is_weapon else ascendable_items.avatar ascendable_items.append(item_id) return MaterialsData.model_validate(everyday_materials) class FragileGenshinClient: def __init__(self, client: Optional["GenshinClient"]): self.client = client self._damaged = False @property def damaged(self): return self._damaged or self.client is None @damaged.setter def damaged(self, damaged: bool): self._damaged = damaged class ItemData(BaseModel): id: str # ID name: str # 名称 rarity: int # 星级 icon: str # 图标 level: Optional[int] = None # 等级 constellation: Optional[int] = None # 命座 skills: Optional[List[int]] = None # 天赋等级 gid: Optional[int] = None # 角色在 genshin.py 里的 ID refinement: Optional[int] = None # 精炼度 c_path: Optional[str] = None # 武器使用者图标 origin: Optional[Character] = None # 原始数据 class AreaData(BaseModel): name: str # 区域名 material_name: str # 区域的材料系列名 materials: List[ItemData] = [] # 区域材料 items: List[ItemData] = [] # 可培养的角色或武器 class RenderData(BaseModel): title: str # 页面标题,主要用于显示星期几 time: str # 页面时间 uid: Optional[str] = None # 用户UID character: List[AreaData] = [] # 角色数据 weapon: List[AreaData] = [] # 武器数据 def __getitem__(self, item): return self.__getattribute__(item) class UserOwned(BaseModel): avatar: Dict[str, ItemData] = {} """角色 ID 到角色对象的映射""" weapon: Dict[str, List[ItemData]] = {} """用户同时可以拥有多把同名武器,因此是 ID 到 List 的映射"""