From 91a133b694071bf996fb35b7105beb79944e1bde Mon Sep 17 00:00:00 2001 From: Karako <70872201+karakoo@users.noreply.github.com> Date: Sun, 28 Aug 2022 22:37:31 +0800 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=E9=87=8D=E5=86=99=20wiki?= =?UTF-8?q?=20=E6=A8=A1=E5=9D=97=E5=92=8C=E7=9B=B8=E5=85=B3=E6=8F=92?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 使用 `pydantic` 重写了 wiki 模块所使用的 model 2. 添加了 weapon_level.json 用于后续计算武器升级所需的经验 3. 修改了 wiki 插件,以适应新的 model --- core/wiki/cache.py | 27 +- core/wiki/services.py | 128 ++++----- metadata/shortname.py | 269 +++++++++++++----- models/wiki/base.py | 247 +++++++++++++++++ models/wiki/character.py | 177 ++++++++++++ models/wiki/characters.py | 189 ------------- models/wiki/helpers.py | 27 -- models/wiki/material.py | 57 ++++ models/wiki/metadata/weapon_level.json | 362 +++++++++++++++++++++++++ models/wiki/other.py | 132 +++++++++ models/wiki/weapon.py | 143 ++++++++++ models/wiki/weapons.py | 210 -------------- plugins/genshin/weapon.py | 73 +++-- resources/genshin/weapon/weapon.html | 2 +- test/model/wiki/test_wiki.py | 121 ++++++--- 15 files changed, 1521 insertions(+), 643 deletions(-) create mode 100644 models/wiki/base.py create mode 100644 models/wiki/character.py delete mode 100644 models/wiki/characters.py delete mode 100644 models/wiki/helpers.py create mode 100644 models/wiki/material.py create mode 100644 models/wiki/metadata/weapon_level.json create mode 100644 models/wiki/other.py create mode 100644 models/wiki/weapon.py delete mode 100644 models/wiki/weapons.py diff --git a/core/wiki/cache.py b/core/wiki/cache.py index c2aed5f..752ca69 100644 --- a/core/wiki/cache.py +++ b/core/wiki/cache.py @@ -1,5 +1,6 @@ -import ujson +import ujson as json +from models.wiki.base import Model from utils.redisdb import RedisDB @@ -8,14 +9,22 @@ class WikiCache: self.client = redis.client self.qname = "wiki" - async def refresh_info_cache(self, key_name: str, info): - qname = f"{self.qname}:{key_name}" - await self.client.set(qname, ujson.dumps(info)) + async def set(self, key: str, value): + qname = f"{self.qname}:{key}" + if isinstance(value, Model): + value = value.json() + elif isinstance(value, (dict, list)): + value = json.dumps(value) + await self.client.set(qname, value) - async def del_one(self, key_name: str): - qname = f"{self.qname}:{key_name}" + async def delete(self, key: str): + qname = f"{self.qname}:{key}" await self.client.delete(qname) - async def get_one(self, key_name: str) -> str: - qname = f"{self.qname}:{key_name}" - return await self.client.get(qname) + async def get(self, key: str) -> dict: + qname = f"{self.qname}:{key}" + result = json.loads(await self.client.get(qname)) + if isinstance(result, list) and len(result) > 0: + for num, item in enumerate(result): + result[num] = json.loads(item) + return result diff --git a/core/wiki/services.py b/core/wiki/services.py index 911ef68..110a00b 100644 --- a/core/wiki/services.py +++ b/core/wiki/services.py @@ -1,11 +1,9 @@ -import asyncio - -import ujson +from typing import List, NoReturn, Optional +from core.wiki.cache import WikiCache from logger import Log -from models.wiki.characters import Characters -from models.wiki.weapons import Weapons -from .cache import WikiCache +from models.wiki.character import Character +from models.wiki.weapon import Weapon class WikiService: @@ -13,58 +11,47 @@ class WikiService: def __init__(self, cache: WikiCache): self._cache = cache """Redis 在这里的作用是作为持久化""" - self.weapons = Weapons() - self.characters = Characters() - self._characters_list = [] - self._characters_name_list = [] - self._weapons_name_list = [] - self._weapons_list = [] + self._character_list = [] + self._character_name_list = [] + self._weapon_name_list = [] + self._weapon_list = [] self.first_run = True - async def refresh_weapon(self): - weapon_url_list = await self.weapons.get_all_weapon_url() - Log.info(f"一共找到 {len(weapon_url_list)} 把武器信息") - weapons_list = [] - task_list = [] - for index, weapon_url in enumerate(weapon_url_list): - task_list.append(self.weapons.get_weapon_info(weapon_url)) - # weapon_info = await self.weapons.get_weapon_info(weapon_url) - if index % 5 == 0: - result_list = await asyncio.gather(*task_list) - weapons_list.extend(result for result in result_list if isinstance(result, dict)) - task_list.clear() - if index % 10 == 0 and index != 0: - Log.info(f"现在已经获取到 {index} 把武器信息") - result_list = await asyncio.gather(*task_list) - weapons_list.extend(result for result in result_list if isinstance(result, dict)) + async def refresh_weapon(self) -> NoReturn: + weapon_name_list = await Weapon.get_name_list() + Log.info(f"一共找到 {len(weapon_name_list)} 把武器信息") + + weapon_list = [] + num = 0 + async for weapon in Weapon.full_data_generator(): + weapon_list.append(weapon) + num += 1 + if num % 10 == 0: + Log.info(f"现在已经获取到 {num} 把武器信息") Log.info("写入武器信息到Redis") - self._weapons_list = weapons_list - await self._cache.del_one("weapon") - await self._cache.refresh_info_cache("weapon", weapons_list) + self._weapon_list = weapon_list + await self._cache.delete("weapon") + await self._cache.set("weapon", [i.json() for i in weapon_list]) - async def refresh_characters(self): - characters_url_list = await self.characters.get_all_characters_url() - Log.info(f"一共找到 {len(characters_url_list)} 个角色信息") - characters_list = [] - task_list = [] - for index, characters_url in enumerate(characters_url_list): - task_list.append(self.characters.get_characters(characters_url)) - if index % 5 == 0: - result_list = await asyncio.gather(*task_list) - characters_list.extend(result for result in result_list if isinstance(result, dict)) - task_list.clear() - if index % 10 == 0 and index != 0: - Log.info(f"现在已经获取到 {index} 个角色信息") - result_list = await asyncio.gather(*task_list) - characters_list.extend(result for result in result_list if isinstance(result, dict)) + async def refresh_characters(self) -> NoReturn: + character_name_list = await Character.get_name_list() + Log.info(f"一共找到 {len(character_name_list)} 个角色信息") + + character_list = [] + num = 0 + async for character in Character.full_data_generator(): + character_list.append(character) + num += 1 + if num % 10 == 0: + Log.info(f"现在已经获取到 {num} 个角色信息") Log.info("写入角色信息到Redis") - self._characters_list = characters_list - await self._cache.del_one("characters") - await self._cache.refresh_info_cache("characters", characters_list) + self._character_list = character_list + await self._cache.delete("characters") + await self._cache.set("characters", [i.json() for i in character_list]) - async def refresh_wiki(self): + async def refresh_wiki(self) -> NoReturn: """ 用于把Redis的缓存全部加载进Python :return: @@ -76,40 +63,39 @@ class WikiService: await self.refresh_characters() Log.info("刷新成功") - async def init(self): + async def init(self) -> NoReturn: """ 用于把Redis的缓存全部加载进Python :return: """ if self.first_run: - weapon_dict = await self._cache.get_one("weapon") - self._weapons_list = ujson.loads(weapon_dict) - for weapon in self._weapons_list: - self._weapons_name_list.append(weapon["name"]) - characters_dict = await self._cache.get_one("characters") - self._characters_list = ujson.loads(characters_dict) - for characters in self._characters_list: - self._characters_name_list.append(characters["name"]) + weapon_dict = await self._cache.get("weapon") + self._weapon_list = [Weapon.parse_obj(obj) for obj in weapon_dict] + self._weapon_name_list = await Weapon.get_name_list() + characters_dict = await self._cache.get("characters") + self._character_list = [Character.parse_obj(obj) for obj in characters_dict] + self._character_name_list = await Character.get_name_list() + self.first_run = False - async def get_weapons(self, name: str): + async def get_weapons(self, name: str) -> Optional[Weapon]: await self.init() - if len(self._weapons_list) == 0: - return {} - return next((weapon for weapon in self._weapons_list if weapon["name"] == name), {}) + if len(self._weapon_list) == 0: + return None + return next((weapon for weapon in self._weapon_list if weapon.name == name), None) - async def get_weapons_name_list(self) -> list: + async def get_weapons_name_list(self) -> List[str]: await self.init() - return self._weapons_name_list + return self._weapon_name_list - async def get_weapons_list(self) -> list: + async def get_weapons_list(self) -> List[Weapon]: await self.init() - return self._weapons_list + return self._weapon_list - async def get_characters_list(self) -> list: + async def get_characters_list(self) -> List[Character]: await self.init() - return self._characters_list + return self._character_list - async def get_characters_name_list(self) -> list: + async def get_characters_name_list(self) -> List[str]: await self.init() - return self._characters_name_list + return self._character_name_list diff --git a/metadata/shortname.py b/metadata/shortname.py index 8a35832..e5e8c37 100644 --- a/metadata/shortname.py +++ b/metadata/shortname.py @@ -1,67 +1,195 @@ +from typing import Optional + +# noinspection SpellCheckingInspection roles = { - 10000003: ["琴", "Jean", "jean", "团长", "代理团长", "琴团长", "蒲公英骑士"], - 10000006: ["丽莎", "Lisa", "lisa", "图书管理员", "图书馆管理员", "蔷薇魔女"], - 10000005: ["空", "男主", "男主角", "龙哥", "空哥", "旅行者", "卑鄙的外乡人", "荣誉骑士", "爷", "风主", "岩主", "雷主", "履刑者", "抽卡不歪真君"], - 10000007: ["荧", "女主", "女主角", "莹", "萤", "黄毛阿姨", "荧妹"], - 10000014: ["芭芭拉", "Barbara", "barbara", "巴巴拉", "拉粑粑", "拉巴巴", "内鬼", "加湿器", "闪耀偶像", "偶像"], - 10000015: ["凯亚", "Kaeya", "kaeya", "盖亚", "凯子哥", "凯鸭", "矿工", "矿工头子", "骑兵队长", "凯子", "凝冰渡海真君"], - 10000016: ["迪卢克", "diluc", "Diluc", "卢姥爷", "姥爷", "卢老爷", "卢锅巴", "正义人", "正e人", "正E人", "卢本伟", "暗夜英雄", "卢卢伯爵", - "落魄了", "落魄了家人们"], - 10000020: ["雷泽", "razor", "Razor", "狼少年", "狼崽子", "狼崽", "卢皮卡", "小狼", "小狼狗"], - 10000021: ["安柏", "Amber", "amber", "安伯", "兔兔伯爵", "飞行冠军", "侦查骑士", "点火姬", "点火机", "打火机", "打火姬", ], - 10000022: ["温迪", "Venti", "venti", "温蒂", "风神", "卖唱的", "巴巴托斯", "巴巴脱丝", "芭芭托斯", "芭芭脱丝", "干点正事", "不干正事", "吟游诗人", - "诶嘿", "唉嘿", "摸鱼", ], - 10000023: ["香菱", "Xiangling", "xiangling", "香玲", "锅巴", "厨师", "万民堂厨师", "香师傅"], - 10000024: ["北斗", "Beidou", "beidou", "大姐头", "大姐", "无冕的龙王", "龙王"], - 10000025: ["行秋", "Xingqiu", "xingqiu", "秋秋人", "秋妹妹", "书呆子", "水神", "飞云商会二少爷"], - 10000026: ["魈", "Xiao", "xiao", "杏仁豆腐", "打桩机", "插秧", "三眼五显仙人", "三眼五显真人", "降魔大圣", "护法夜叉", "快乐风男", "无聊", "靖妖傩舞", - "矮子仙人", "三点五尺仙人", "跳跳虎"], - 10000027: ["凝光", "Ningguang", "ningguang", "富婆", "天权星"], - 10000029: ["可莉", "Klee", "klee", "嘟嘟可", "火花骑士", "蹦蹦炸弹", "炸鱼", "放火烧山", "放火烧山真君", "蒙德最强战力", "逃跑的太阳", "啦啦啦", "哒哒哒", - "炸弹人", "禁闭室", ], - 10000030: ["钟离", "Zhongli", "zhongli", "摩拉克斯", "岩王爷", "岩神", "钟师傅", "天动万象", "岩王帝君", "未来可期", "帝君", "拒收病婿"], - 10000031: ["菲谢尔", "Fischl", "fischl", "皇女", "小艾米", "小艾咪", "奥兹", "断罪皇女", "中二病", "中二少女", "中二皇女", "奥兹发射器"], - 10000032: ["班尼特", "Bennett", "bennett", "点赞哥", "点赞", "倒霉少年", "倒霉蛋", "霹雳闪雷真君", "班神", "班爷", "倒霉", "火神", "六星真神"], - 10000033: ["达达利亚", "Tartaglia", "tartaglia", "Childe", "childe", "Ajax", "ajax", "达达鸭", "达达利鸭", "公子", "玩具销售员", - "玩具推销员", "钱包", "鸭鸭", "愚人众末席"], - 10000034: ["诺艾尔", "Noelle", "noelle", "女仆", "高达", "岩王帝姬"], - 10000035: ["七七", "Qiqi", "qiqi", "僵尸", "肚饿真君", "度厄真君"], - 10000036: ["重云", "Chongyun", "chongyun", "纯阳之体", "冰棍"], - 10000037: ["甘雨", "Ganyu", "ganyu", "椰羊", "椰奶", "王小美"], - 10000038: ["阿贝多", "Albedo", "albedo", "可莉哥哥", "升降机", "升降台", "电梯", "白垩之子", "贝爷", "白垩", "阿贝少", "花呗多", "阿贝夕", - "abd", "阿师傅"], - 10000039: ["迪奥娜", "Diona", "diona", "迪欧娜", "dio", "dio娜", "冰猫", "猫猫", "猫娘", "喵喵", "调酒师"], - 10000041: ["莫娜", "Mona", "mona", "穷鬼", "穷光蛋", "穷", "莫纳", "占星术士", "占星师", "讨龙真君", "半部讨龙真君", "阿斯托洛吉斯·莫娜·梅姬斯图斯"], - 10000042: ["刻晴", "Keqing", "keqing", "刻情", "氪晴", "刻师傅", "刻师父", "牛杂", "牛杂师傅", "斩尽牛杂", "免疫", "免疫免疫", "屁斜剑法", - "玉衡星", "阿晴", "啊晴"], - 10000043: ["砂糖", "Sucrose", "sucrose", "雷莹术士", "雷萤术士", "雷荧术士"], - 10000044: ["辛焱", "Xinyan", "xinyan", "辛炎", "黑妹", "摇滚"], - 10000045: ["罗莎莉亚", "Rosaria", "rosaria", "罗莎莉娅", "白色史莱姆", "白史莱姆", "修女", "罗莎利亚", "罗莎利娅", "罗沙莉亚", "罗沙莉娅", "罗沙利亚", - "罗沙利娅", "萝莎莉亚", "萝莎莉娅", "萝莎利亚", "萝莎利娅", "萝沙莉亚", "萝沙莉娅", "萝沙利亚", "萝沙利娅"], - 10000046: ["胡桃", "Hu Tao", "hu tao", "HuTao", "hutao", "Hutao", "胡淘", "往生堂堂主", "火化", "抬棺的", "蝴蝶", "核桃", "堂主", - "胡堂主", "雪霁梅香"], - 10000047: ["枫原万叶", "Kaedehara Kazuha", "Kazuha", "kazuha", "万叶", "叶天帝", "天帝", "叶师傅"], - 10000048: ["烟绯", "Yanfei", "yanfei", "烟老师", "律师", "罗翔"], - 10000051: ["优菈", "Eula", "eula", "优拉", "尤拉", "尤菈", "浪花骑士", "记仇", "劳伦斯"], - 10000002: ["神里绫华", "Kamisato Ayaka", "Ayaka", "ayaka", "神里", "绫华", "神里凌华", "凌华", "白鹭公主", "神里大小姐"], - 10000049: ["宵宫", "Yoimiya", "yoimiya", "霄宫", "烟花", "肖宫", "肖工", "绷带女孩"], - 10000052: ["雷电将军", "Raiden Shogun", "Raiden", "raiden", "雷神", "将军", "雷军", "巴尔", "阿影", "影", "巴尔泽布", "煮饭婆", "奶香一刀", - "无想一刀", "宅女"], - 10000053: ["早柚", "Sayu", "sayu", "小狸猫", "狸猫", "忍者"], - 10000054: ["珊瑚宫心海", "Sangonomiya Kokomi", "Kokomi", "kokomi", "心海", "军师", "珊瑚宫", "书记", "观赏鱼", "水母", "鱼", "美人鱼"], - 10000056: ["九条裟罗", "Kujou Sara", "Sara", "sara", "九条", "九条沙罗", "裟罗", "沙罗", "天狗"], - 10000062: ["埃洛伊", "Aloy", "aloy"], - 10000050: ["托马", "Thoma", "thoma", "家政官", "太郎丸", "地头蛇", "男仆", "拖马"], - 10000055: ["五郎", "Gorou", "gorou", "柴犬", "土狗", "希娜", "希娜小姐"], - 10000057: ["荒泷一斗", "Arataki Itto", "Itto", "itto", "荒龙一斗", "荒泷天下第一斗", "一斗", "一抖", "荒泷", "1斗", "牛牛", "斗子哥", "牛子哥", - "牛子", "孩子王", "斗虫", "巧乐兹", "放牛的"], - 10000058: ["八重神子", "Yae Miko", "Miko", "miko", "八重", "神子", "狐狸", "想得美哦", "巫女", "屑狐狸", "骚狐狸", "八重宫司", "婶子", "小八"], - 10000059: ["鹿野院平藏", "shikanoin heizou", "Heizou", "heizou", "heizo", "鹿野苑", "鹿野院", "平藏", "鹿野苑平藏"], - 10000060: ["夜兰", "Yelan", "yelan", "夜阑", "叶澜", "腋兰", "夜天后"], - 10000063: ["申鹤", "Shenhe", "shenhe", "神鹤", "小姨", "小姨子", "审鹤"], - 10000064: ["云堇", "Yun Jin", "yunjin", "yun jin", "云瑾", "云先生", "云锦", "神女劈观"], - 10000065: ["久岐忍", "Kuki Shinobu", "Kuki", "kuki", "Shinobu", "shinobu", "97忍", "小忍", "久歧忍", "97", "茄忍", "阿忍", "忍姐"], - 10000066: ["神里绫人", "Kamisato Ayato", "Ayato", "ayato", "绫人", "神里凌人", "凌人", "0人", "神人", "零人", "大舅哥"], + 10000002: [ + "神里绫华", "Kamisato Ayaka", "Ayaka", "ayaka", "神里", "绫华", "神里凌华", "凌华", "白鹭公主", "神里大小姐" + ], + 10000003: [ + "琴", "Jean", "jean", "团长", "代理团长", "琴团长", "蒲公英骑士" + ], + 10000005: [ + "空", "男主", "男主角", "龙哥", "空哥" + ], + 10000006: [ + "丽莎", "Lisa", "lisa", "图书管理员", "图书馆管理员", "蔷薇魔女" + ], + 10000007: [ + "荧", "女主", "女主角", "莹", "萤", "黄毛阿姨", "荧妹" + ], + 10000014: [ + "芭芭拉", "Barbara", "barbara", "巴巴拉", "拉粑粑", "拉巴巴", "内鬼", "加湿器", "闪耀偶像", "偶像" + ], + 10000015: [ + "凯亚", "Kaeya", "kaeya", "盖亚", "凯子哥", "凯鸭", "矿工", "矿工头子", "骑兵队长", "凯子", "凝冰渡海真君" + ], + 10000016: [ + "迪卢克", "diluc", "Diluc", "卢姥爷", "姥爷", "卢老爷", "卢锅巴", "正义人", "正e人", "正E人", "卢本伟", + "暗夜英雄", "卢卢伯爵", "落魄了", "落魄了家人们" + ], + 10000020: [ + "雷泽", "razor", "Razor", "狼少年", "狼崽子", "狼崽", "卢皮卡", "小狼", "小狼狗" + ], + 10000021: [ + "安柏", "Amber", "amber", "安伯", "兔兔伯爵", "飞行冠军", "侦查骑士", "点火姬", "点火机", "打火机", "打火姬" + ], + 10000022: [ + "温迪", "Venti", "venti", "温蒂", "风神", "卖唱的", "巴巴托斯", "巴巴脱丝", "芭芭托斯", "芭芭脱丝", "干点正事", + "不干正事", "吟游诗人", "诶嘿", "唉嘿", "摸鱼" + ], + 10000023: [ + "香菱", "Xiangling", "xiangling", "香玲", "锅巴", "厨师", "万民堂厨师", "香师傅" + ], + 10000024: [ + "北斗", "Beidou", "beidou", "大姐头", "大姐", "无冕的龙王", "龙王" + ], + 10000025: [ + "行秋", "Xingqiu", "xingqiu", "秋秋人", "秋妹妹", "书呆子", "水神", "飞云商会二少爷" + ], + 10000026: [ + "魈", "Xiao", "xiao", "杏仁豆腐", "打桩机", "插秧", "三眼五显仙人", "三眼五显真人", "降魔大圣", "护法夜叉", + "快乐风男", "无聊", "靖妖傩舞", "矮子仙人", "三点五尺仙人", "跳跳虎" + ], + 10000027: [ + "凝光", "Ningguang", "ningguang", "富婆", "天权星" + ], + 10000029: [ + "可莉", "Klee", "klee", "嘟嘟可", "火花骑士", "蹦蹦炸弹", "炸鱼", "放火烧山", "放火烧山真君", "蒙德最强战力", + "逃跑的太阳", "啦啦啦", "哒哒哒", "炸弹人", "禁闭室" + ], + 10000030: [ + "钟离", "Zhongli", "zhongli", "摩拉克斯", "岩王爷", "岩神", "钟师傅", "天动万象", "岩王帝君", "未来可期", + "帝君", "拒收病婿" + ], + 10000031: [ + "菲谢尔", "Fischl", "fischl", "皇女", "小艾米", "小艾咪", "奥兹", "断罪皇女", "中二病", "中二少女", "中二皇女", + "奥兹发射器" + ], + 10000032: [ + "班尼特", "Bennett", "bennett", "点赞哥", "点赞", "倒霉少年", "倒霉蛋", "霹雳闪雷真君", "班神", "班爷", "倒霉", + "火神", "六星真神" + ], + 10000033: [ + "达达利亚", "Tartaglia", "tartaglia", "Childe", "childe", "Ajax", "ajax", "达达鸭", "达达利鸭", "公子", + "玩具销售员", "玩具推销员", "钱包", "鸭鸭", "愚人众末席" + ], + 10000034: [ + "诺艾尔", "Noelle", "noelle", "女仆", "高达", "岩王帝姬" + ], + 10000035: [ + "七七", "Qiqi", "qiqi", "僵尸", "肚饿真君", "度厄真君", "77" + ], + 10000036: [ + "重云", "Chongyun", "chongyun", "纯阳之体", "冰棍" + ], + 10000037: [ + "甘雨", "Ganyu", "ganyu", "椰羊", "椰奶", "王小美" + ], + 10000038: [ + "阿贝多", "Albedo", "albedo", "可莉哥哥", "升降机", "升降台", "电梯", "白垩之子", "贝爷", "白垩", "阿贝少", + "花呗多", "阿贝夕", "abd", "阿师傅" + ], + 10000039: [ + "迪奥娜", "Diona", "diona", "迪欧娜", "dio", "dio娜", "冰猫", "猫猫", "猫娘", "喵喵", "调酒师" + ], + 10000041: [ + "莫娜", "Mona", "mona", "穷鬼", "穷光蛋", "穷", "莫纳", "占星术士", "占星师", "讨龙真君", "半部讨龙真君", + "阿斯托洛吉斯·莫娜·梅姬斯图斯", "梅姬斯图斯", "梅姬斯图斯卿" + ], + 10000042: [ + "刻晴", "Keqing", "keqing", "刻情", "氪晴", "刻师傅", "刻师父", "牛杂", "牛杂师傅", "斩尽牛杂", "免疫", + "免疫免疫", "屁斜剑法", "玉衡星", "阿晴", "啊晴" + ], + 10000043: [ + "砂糖", "Sucrose", "sucrose", "雷莹术士", "雷萤术士", "雷荧术士" + ], + 10000044: [ + "辛焱", "Xinyan", "xinyan", "辛炎", "黑妹", "摇滚" + ], + 10000045: [ + "罗莎莉亚", "Rosaria", "rosaria", "罗莎莉娅", "白色史莱姆", "白史莱姆", "修女", "罗莎利亚", "罗莎利娅", + "罗沙莉亚", "罗沙莉娅", "罗沙利亚", "罗沙利娅", "萝莎莉亚", "萝莎莉娅", "萝莎利亚", "萝莎利娅", "萝沙莉亚", + "萝沙莉娅", "萝沙利亚", "萝沙利娅" + ], + 10000046: [ + "胡桃", "Hu Tao", "hu tao", "HuTao", "hutao", "Hutao", "胡淘", "往生堂堂主", "火化", "抬棺的", "蝴蝶", "核桃", + "堂主", "胡堂主", "雪霁梅香" + ], + 10000047: [ + "枫原万叶", "Kaedehara Kazuha", "Kazuha", "kazuha", "万叶", "叶天帝", "天帝", "叶师傅" + ], + 10000048: [ + "烟绯", "Yanfei", "yanfei", "烟老师", "律师", "罗翔" + ], + 10000049: [ + "宵宫", "Yoimiya", "yoimiya", "霄宫", "烟花", "肖宫", "肖工", "绷带女孩" + ], + 10000050: [ + "托马", "Thoma", "thoma", "家政官", "太郎丸", "地头蛇", "男仆", "拖马" + ], + 10000051: [ + "优菈", "Eula", "eula", "优拉", "尤拉", "尤菈", "浪花骑士", "记仇", "劳伦斯" + ], + 10000052: [ + "雷电将军", "Raiden Shogun", "Raiden", "raiden", "雷神", "将军", "雷军", "巴尔", "阿影", "影", "巴尔泽布", + "煮饭婆", "奶香一刀", "无想一刀", "宅女" + ], + 10000053: [ + "早柚", "Sayu", "sayu", "小狸猫", "狸猫", "忍者" + ], + 10000054: [ + "珊瑚宫心海", "Sangonomiya Kokomi", "Kokomi", "kokomi", "心海", "军师", "珊瑚宫", "书记", "观赏鱼", "水母", + "鱼", "美人鱼" + ], + 10000055: [ + "五郎", "Gorou", "gorou", "柴犬", "土狗", "希娜", "希娜小姐" + ], + 10000056: [ + "九条裟罗", "Kujou Sara", "Sara", "sara", "九条", "九条沙罗", "裟罗", "沙罗", "天狗" + ], + 10000057: [ + "荒泷一斗", "Arataki Itto", "Itto", "itto", "荒龙一斗", "荒泷天下第一斗", "一斗", "一抖", "荒泷", "1斗", "牛牛", + "斗子哥", "牛子哥", "牛子", "孩子王", "斗虫", "巧乐兹", "放牛的" + ], + 10000058: [ + "八重神子", "Yae Miko", "Miko", "miko", "八重", "神子", "狐狸", "想得美哦", "巫女", "屑狐狸", "骚狐狸", + "八重宫司", "婶子", "小八" + ], + 10000059: [ + "鹿野院平藏", "shikanoin heizou", "Heizou", "heizou", "heizo", "鹿野苑", "鹿野院", "平藏", "鹿野苑平藏", "鹿野", + "小鹿" + ], + 10000060: [ + "夜兰", "Yelan", "yelan", "夜阑", "叶澜", "腋兰", "夜天后" + ], + 10000062: [ + "埃洛伊", "Aloy", "aloy" + ], + 10000063: [ + "申鹤", "Shenhe", "shenhe", "神鹤", "小姨", "小姨子", "审鹤" + ], + 10000064: [ + "云堇", "Yun Jin", "yunjin", "yun jin", "云瑾", "云先生", "云锦", "神女劈观" + ], + 10000065: [ + "久岐忍", "Kuki Shinobu", "Kuki", "kuki", "Shinobu", "shinobu", "97忍", "小忍", "久歧忍", "97", "茄忍", "阿忍", + "忍姐" + ], + 10000066: [ + "神里绫人", "Kamisato Ayato", "Ayato", "ayato", "绫人", "神里凌人", "凌人", "0人", "神人", "零人", "大舅哥" + ], + 10000067: [ + "柯莱", "Collei", "collei", "克莱", "科莱", "须弥飞行冠军" + ], + 10000068: [ + "多莉", "Dori", "dori", "多利", "多丽" + ], + 10000069: [ + "提纳里", "Tighnari", "tighnari", "巡林官", "小提", "缇娜里", "提哪里", "提那里" + ], + 20000000: [ + "主角", "旅行者", "卑鄙的外乡人", "荣誉骑士", "爷", "风主", "岩主", "雷主", "草主", "履刑者", "抽卡不歪真君" + ] } weapons = { "磐岩结绿": ["绿箭", "绿剑"], @@ -145,14 +273,15 @@ weapons = { def roleToName(shortname: str) -> str: - if not shortname: - return shortname - for value in roles.values(): - for i in value: - if i == shortname: - return value[0] - return shortname + """讲角色昵称转为正式名""" + return next((value[0] for value in roles.values() for name in value if name == shortname), shortname) + + +def roleToId(name: str) -> Optional[int]: + """获取角色ID""" + return next((key for key, value in roles.items() for n in value if n == name), None) def weaponToName(shortname: str) -> str: + """讲武器昵称转为正式名""" return next((key for key, value in weapons.items() if shortname == key or shortname in value), shortname) diff --git a/models/wiki/base.py b/models/wiki/base.py new file mode 100644 index 0000000..a8ac0d9 --- /dev/null +++ b/models/wiki/base.py @@ -0,0 +1,247 @@ +import asyncio +import re +from abc import abstractmethod +from asyncio import Queue +from multiprocessing import Value +from typing import AsyncIterator, ClassVar, List, Optional, Tuple, Union + +import anyio +import ujson as json +from bs4 import BeautifulSoup +from httpx import AsyncClient, HTTPError, Response, URL +from pydantic import ( + BaseConfig as PydanticBaseConfig, + BaseModel as PydanticBaseModel, +) +from typing_extensions import Self + +__all__ = ['Model', 'WikiModel', 'SCRAPE_HOST'] + +SCRAPE_HOST = URL("https://genshin.honeyhunterworld.com/") + + +class Model(PydanticBaseModel): + """基类""" + + def __new__(cls, *args, **kwargs): + # 让每次new的时候都解析 + cls.update_forward_refs() + return super(Model, cls).__new__(cls) + + class Config(PydanticBaseConfig): + # 使用 ujson 作为解析库 + json_dumps = json.dumps + json_loads = json.loads + + +class WikiModel(Model): + """wiki所用到的基类 + + Attributes: + id (:obj:`int`): ID + name (:obj:`str`): 名称 + rarity (:obj:`int`): 星级 + + _client (:class:`httpx.AsyncClient`): 发起 http 请求的 client + """ + _client: ClassVar[AsyncClient] = AsyncClient() + + id: str + name: str + rarity: int + + @staticmethod + @abstractmethod + def scrape_urls() -> List[URL]: + """爬取的目标网页集合 + + 例如有关武器的页面有: + [单手剑](https://genshin.honeyhunterworld.com/fam_sword/?lang=CHS) + [双手剑](https://genshin.honeyhunterworld.com/fam_claymore/?lang=CHS) + [长柄武器](https://genshin.honeyhunterworld.com/fam_polearm/?lang=CHS) + 。。。 + 这个函数就是返回这些页面的网址所组成的 List + + """ + + @classmethod + async def _client_get(cls, url: Union[URL, str], retry_times: int = 5, sleep: float = 1) -> Response: + """用自己的 client 发起 get 请求的快捷函数 + + Args: + url: 发起请求的 url + retry_times: 发生错误时的重复次数。不能小于 0 . + sleep: 发生错误后等待重试的时间,单位为秒。 + Returns: + 返回对应的请求 + Raises: + 请求所需要的异常 + """ + for _ in range(retry_times): + try: + return await cls._client.get(url, follow_redirects=True) + except HTTPError: + await anyio.sleep(sleep) + return await cls._client.get(url, follow_redirects=True) # 防止 retry_times 等于 0 的时候无法发生请求 + + @classmethod + @abstractmethod + async def _parse_soup(cls, soup: BeautifulSoup) -> Self: + """解析 soup 生成对应 WikiModel + + Args: + soup: 需要解析的 soup + Returns: + 返回对应的 WikiModel + """ + + @classmethod + async def _scrape(cls, url: Union[URL, str]) -> Self: + """从 url 中爬取数据,并返回对应的 Model + + Args: + url: 目标 url. 可以为字符串 str , 也可以为 httpx.URL + Returns: + 返回对应的 WikiModel + """ + response = await cls._client_get(url) + return await cls._parse_soup(BeautifulSoup(response.text, 'lxml')) + + @classmethod + async def get_by_id(cls, id_: str) -> Self: + """通过ID获取Model + + Args: + id_: 目标 ID + Returns: + 返回对应的 WikiModel + """ + return await cls._scrape(await cls.get_url_by_id(id_)) + + @classmethod + async def get_by_name(cls, name: str) -> Optional[Self]: + """通过名称获取Model + + Args: + name: 目标名 + Returns: + 返回对应的 WikiModel + """ + url = await cls.get_url_by_name(name) + if url is None: + return None + else: + return await cls._scrape(url) + + @classmethod + async def get_full_data(cls) -> List[Self]: + """获取全部数据的 Model + + Returns: + 返回能爬到的所有的 Model 所组成的 List + """ + return [i async for i in cls.full_data_generator()] + + @classmethod + async def full_data_generator(cls) -> AsyncIterator[Self]: + """Model 生成器 + + 这是一个异步生成器,该函数在使用时会爬取所有数据,并将其转为对应的 Model,然后存至一个队列中 + 当有需要时,再一个一个地迭代取出 + + Returns: + 返回能爬到的所有的 WikiModel 所组成的 List + """ + queue: Queue[Self] = Queue() # 存放 Model 的队列 + signal = Value('i', 0) # 一个用于异步任务同步的信号 + + async def task(u): + # 包装的爬虫任务 + await queue.put(await cls._scrape(u)) # 爬取一条数据,并将其放入队列中 + signal.value -= 1 # 信号量减少 1 ,说明该爬虫任务已经完成 + + for _, url in await cls.get_name_list(with_url=True): # 遍历爬取所有需要爬取的页面 + signal.value += 1 # 信号量增加 1 ,说明有一个爬虫任务被添加 + asyncio.create_task(task(url)) # 创建一个爬虫任务 + + while signal.value > 0 or not queue.empty(): # 当还有未完成的爬虫任务或存放数据的队列不为空时 + yield await queue.get() # 取出并返回一个存放的 Model + + def __str__(self) -> str: + return f"<{self.__class__.__name__} {super(WikiModel, self).__str__()}>" + + def __repr__(self) -> str: + return self.__str__() + + @staticmethod + async def get_url_by_id(id_: str) -> URL: + """根据 id 获取对应的 url + + 例如神里绫华的ID为 ayaka_002,对应的数据页url为 https://genshin.honeyhunterworld.com/ayaka_002/?lang=CHS + + Args: + id_ : 实列ID + Returns: + 返回对应的 url + """ + return SCRAPE_HOST.join(f"{id_}/?lang=CHS") + + @classmethod + async def _name_list_generator(cls, *, with_url: bool = False) -> AsyncIterator[Union[str, Tuple[str, URL]]]: + """一个 Model 的名称 和 其对应 url 的异步生成器 + + Args: + with_url: 是否返回相应的 url + Returns: + 返回对应的名称列表 或者 名称与url 的列表 + """ + urls = cls.scrape_urls() + queue: Queue[Union[str, Tuple[str, URL]]] = Queue() # 存放 Model 的队列 + signal = Value('i', len(urls)) # 一个用于异步任务同步的信号,初始值为存放所需要爬取的页面数 + + async def task(page: URL, s: Value): + """包装的爬虫任务""" + response = await cls._client_get(page) + # 从页面中获取对应的 chaos data (未处理的json格式字符串) + chaos_data = re.findall(r'sortable_data\.push\((.*)\);\s*sortable_cur_page', response.text)[0] + json_data = json.loads(chaos_data) # 转为 json + for data in json_data: # 遍历 json + data_name = re.findall(r'>(.*)<', data[1])[0] # 获取 Model 的名称 + if with_url: # 如果需要返回对应的 url + data_url = SCRAPE_HOST.join(re.findall(r'\"(.*?)\"', data[0])[0]) + await queue.put((data_name, data_url)) + else: + await queue.put(data_name) + signal.value = signal.value - 1 # 信号量减少 1 ,说明该爬虫任务已经完成 + + for url in urls: # 遍历需要爬出的页面 + asyncio.create_task(task(url, signal)) # 添加爬虫任务 + while signal.value > 0 or not queue.empty(): # 当还有未完成的爬虫任务或存放数据的队列不为空时 + yield await queue.get() # 取出并返回一个存放的 Model + + @classmethod + async def get_name_list(cls, *, with_url: bool = False) -> List[Union[str, Tuple[str, URL]]]: + """获取全部 Model 的 名称 + + Returns: + 返回能爬到的所有的 Model 的名称所组成的 List + """ + return [i async for i in cls._name_list_generator(with_url=with_url)] + + @classmethod + async def get_url_by_name(cls, name: str) -> Optional[URL]: + """通过 Model 的名称获取对应的 url + + Args: + name: 实列名 + Returns: + 若有对应的实列,则返回对应的 url; 若没有, 则返回 None + """ + async for n, url in cls._name_list_generator(with_url=True): + if name == n: + return url + + @property + @abstractmethod + def icon(self): + """返回此 Model 的图标链接""" diff --git a/models/wiki/character.py b/models/wiki/character.py new file mode 100644 index 0000000..e8979f1 --- /dev/null +++ b/models/wiki/character.py @@ -0,0 +1,177 @@ +import re +from typing import List, Optional + +from bs4 import BeautifulSoup +from httpx import URL + +from models.wiki.base import Model, SCRAPE_HOST +from models.wiki.base import WikiModel +from models.wiki.other import Association, Element, WeaponType + + +class Birth(Model): + """生日 + Attributes: + day: 天 + month: 月 + """ + day: int + month: int + + +class CharacterAscension(Model): + """角色的突破材料 + + Attributes: + level: 等级突破材料 + skill: 技能/天赋培养材料 + """ + level: List[str] = [] + skill: List[str] = [] + + +class CharacterState(Model): + """角色属性值 + + Attributes: + level: 等级 + HP: 生命 + ATK: 攻击力 + DEF: 防御力 + CR: 暴击率 + CD: 暴击伤害 + bonus: 突破属性 + """ + level: str + HP: int + ATK: float + DEF: float + CR: str + CD: str + bonus: str + + +class CharacterIcon(Model): + icon: str + side: str + gacha: str + splash: Optional[str] + + +class Character(WikiModel): + """角色 + Attributes: + title: 称号 + occupation: 所属 + association: 地区 + weapon_type: 武器类型 + element: 元素 + birth: 生日 + constellation: 命之座 + cn_cv: 中配 + jp_cv: 日配 + en_cv: 英配 + kr_cv: 韩配 + description: 描述 + """ + + id: str + title: str + occupation: str + association: Association + weapon_type: WeaponType + element: Element + birth: Optional[Birth] + constellation: str + cn_cv: str + jp_cv: str + en_cv: str + kr_cv: str + description: str + ascension: CharacterAscension + + stats: List[CharacterState] + + @classmethod + def scrape_urls(cls) -> List[URL]: + return [SCRAPE_HOST.join("fam_chars/?lang=CHS")] + + @classmethod + async def _parse_soup(cls, soup: BeautifulSoup) -> 'Character': + """解析角色页""" + soup = soup.select('.wp-block-post-content')[0] + tables = soup.find_all('table') + table_rows = tables[0].find_all('tr') + + def get_table_text(row_num: int) -> str: + """一个快捷函数,用于返回表格对应行的最后一个单元格中的文本""" + return table_rows[row_num].find_all('td')[-1].text.replace('\xa0', '') + + id_ = re.findall(r'img/(.*?_\d+)_.*', table_rows[0].find('img').attrs['src'])[0] + name = get_table_text(0) + if name != '旅行者': # 如果角色名不是 旅行者 + title = get_table_text(1) + occupation = get_table_text(2) + association = Association.convert(get_table_text(3).lower().title()) + rarity = len(table_rows[4].find_all('img')) + weapon_type = WeaponType[get_table_text(5)] + element = Element[get_table_text(6)] + birth = Birth(day=int(get_table_text(7)), month=int(get_table_text(8))) + constellation = get_table_text(10) + cn_cv = get_table_text(11) + jp_cv = get_table_text(12) + en_cv = get_table_text(13) + kr_cv = get_table_text(14) + else: + name = '空' if id_.endswith('5') else '荧' + title = get_table_text(0) + occupation = get_table_text(1) + association = Association.convert(get_table_text(2).lower().title()) + rarity = len(table_rows[3].find_all('img')) + weapon_type = WeaponType[get_table_text(4)] + element = Element[get_table_text(5)] + birth = None + constellation = get_table_text(7) + cn_cv = get_table_text(8) + jp_cv = get_table_text(9) + en_cv = get_table_text(10) + kr_cv = get_table_text(11) + description = get_table_text(-3) + ascension = CharacterAscension( + level=[ + target[0] for i in table_rows[-2].find_all('a') + if (target := re.findall(r'/(.*)/', i.attrs['href'])) # 过滤掉错误的材料(honey网页的bug) + ], + skill=[re.findall(r'/(.*)/', i.attrs['href'])[0] for i in table_rows[-1].find_all('a')] + ) + stats = [] + for row in tables[2].find_all('tr')[1:]: + cells = row.find_all('td') + stats.append( + CharacterState( + level=cells[0].text, HP=cells[1].text, ATK=cells[2].text, DEF=cells[3].text, + CR=cells[4].text, CD=cells[5].text, bonus=cells[6].text + ) + ) + return Character( + id=id_, name=name, title=title, occupation=occupation, association=association, weapon_type=weapon_type, + element=element, birth=birth, constellation=constellation, cn_cv=cn_cv, jp_cv=jp_cv, rarity=rarity, + en_cv=en_cv, kr_cv=kr_cv, description=description, ascension=ascension, stats=stats + ) + + @classmethod + async def get_url_by_name(cls, name: str) -> Optional[URL]: + # 重写此函数的目的是处理主角名字的 ID + _map = {'荧': "playergirl_007", '空': "playerboy_005"} + if (id_ := _map.get(name, None)) is not None: + return await cls.get_url_by_id(id_) + return await super(Character, cls).get_url_by_name(name) + + @property + def icon(self) -> CharacterIcon: + return CharacterIcon( + icon=str(SCRAPE_HOST.join(f'/img/{self.id}_icon.png')), + side=str(SCRAPE_HOST.join(f'/img/{self.id}_side_icon.png')), + gacha=str(SCRAPE_HOST.join(f'/img/{self.id}_gacha_card.png')), + splash=str(SCRAPE_HOST.join(f'/img/{self.id}_gacha_splash.png')) + ) diff --git a/models/wiki/characters.py b/models/wiki/characters.py deleted file mode 100644 index 726b35a..0000000 --- a/models/wiki/characters.py +++ /dev/null @@ -1,189 +0,0 @@ -import re -from typing import Optional - -import httpx -from bs4 import BeautifulSoup - -from .helpers import get_headers - - -class Characters: - CHARACTERS_LIST_URL = "https://genshin.honeyhunterworld.com/db/char/characters/?lang=CHS" - ROOT_URL = "https://genshin.honeyhunterworld.com" - - def __init__(self): - self.client = httpx.AsyncClient(headers=get_headers()) - - async def _get_soup(self, url: str) -> Optional[BeautifulSoup]: - request = await self.client.get(url) - return BeautifulSoup(request.text, "lxml") - - async def get_all_characters_url(self): - url_list = [] - soup = await self._get_soup(self.CHARACTERS_LIST_URL) - character_list = soup.find_all('div', {'class': 'char_sea_cont'}) - for character in character_list: - name = character.find("span", {"class": "sea_charname"}).text - if "旅行者" in name: - continue - character_link = self.ROOT_URL + character.a['href'] - url_list.append(character_link) - return url_list - - def get_characters_info_template(self): - characters_info_dict = { - "name": "", - "title": "", - "rarity": 0, - "element": {"name": "", "icon": ""}, - "description": "", - "constellations": {}, - "skills": { - "normal_attack": self.get_skills_info_template(), - "skill_e": self.get_skills_info_template(), - "skill_q": self.get_skills_info_template(), - "skill_replace": self.get_skills_info_template(), - }, - "gacha_splash": "" - } - return characters_info_dict - - @staticmethod - def get_skills_info_template(): - skills_info_dict = { - "icon": "", - "name": "", - "description": "" - } - return skills_info_dict - - async def get_characters(self, url: str): - characters_info_dict = self.get_characters_info_template() - soup = await self._get_soup(url) - main_content = soup.find("div", {'class': 'wrappercont'}) - char_name = main_content.find('div', {'class': 'custom_title'}).text - characters_info_dict["name"] = char_name - # 基础信息 - char_info_table = main_content.find('table', {'class': 'item_main_table'}).find_all('tr') - for char_info_item in char_info_table: - content = char_info_item.find_all('td') - if content[0].text == "Title": - char_title = content[1].text - characters_info_dict["title"] = char_title - if content[0].text == "Allegiance": - char_allegiance = content[1].text - characters_info_dict["allegiance"] = char_allegiance - if content[0].text == "Rarity": - char_rarity = len(content[1].find_all('div', {'class': 'sea_char_stars_wrap'})) - characters_info_dict["rarity"] = char_rarity - if content[0].text == "Element": - char_element_icon_url = self.ROOT_URL + content[1].find('img')['data-src'].replace("_35", "") - characters_info_dict["element"]["icon"] = char_element_icon_url - if content[0].text == "Astrolabe Name": - char_astrolabe_name = content[1].text - if content[0].text == "In-game Description": - char_description = content[1].text - characters_info_dict["description"] = char_description - - # 角色属性表格 咕咕咕 - skill_dmg_wrapper = main_content.find('div', {'class': 'skilldmgwrapper'}).find_all('tr') - - # 命之座 - constellations_title = main_content.find('span', {'class': 'item_secondary_title'}, string="Constellations") - constellations_table = constellations_title.findNext('table', {'class': 'item_main_table'}).find_all('tr') - constellations_list = [] - constellations_list_index = 0 - for index, value in enumerate(constellations_table): - # 判断第一行 - if index % 2 == 0: - constellations_dict = { - "icon": "", - "name": "", - "description": "" - } - constellations_list.append(constellations_dict) - icon_url = self.ROOT_URL + value.find_all('img', {'class': 'itempic'})[-1]['data-src'] - constellations_name = value.find_all('a', href=re.compile("/db/skill"))[-1].text - constellations_list[constellations_list_index]["icon"] = icon_url - constellations_list[constellations_list_index]["name"] = constellations_name - if index % 2 == 1: - constellations_description = value.find('div', {'class': 'skill_desc_layout'}).text - constellations_list[constellations_list_index]["description"] = constellations_description - constellations_list_index += 1 - - characters_info_dict["constellations"] = constellations_list - - # 技能 - skills_title = main_content.find('span', string='Attack Talents') - - # 普攻 - normal_attack_area = skills_title.find_next_sibling() - normal_attack_info = normal_attack_area.find_all('tr') - normal_attack_icon = self.ROOT_URL + normal_attack_info[0].find('img', {'class': 'itempic'})['data-src'] - normal_attack_name = normal_attack_info[0].find('a', href=re.compile('/db/skill/')).text - normal_attack_desc = normal_attack_info[1].find('div', {'class': 'skill_desc_layout'}).text.replace(" ", "\n") - normal_attack = characters_info_dict["skills"]["normal_attack"] - normal_attack["icon"] = normal_attack_icon - normal_attack["name"] = normal_attack_name - normal_attack["description"] = normal_attack_desc - - normal_attack_table_area = normal_attack_area.find_next_sibling() - # normal_attack_table = normal_attack_table_area.find_all('tr') - - skill_e_area = normal_attack_table_area.find_next_sibling() - skill_e_info = skill_e_area.find_all('tr') - skill_e_icon = self.ROOT_URL + skill_e_info[0].find('img', {'class': 'itempic'})['data-src'] - skill_e_name = skill_e_info[0].find('a', href=re.compile('/db/skill/')).text - skill_e_desc = skill_e_info[1].find('div', {'class': 'skill_desc_layout'}).text.replace(" ", "\n") - skill_e = characters_info_dict["skills"]["skill_e"] - skill_e["icon"] = skill_e_icon - skill_e["name"] = skill_e_name - skill_e["description"] = skill_e_desc - - skill_e_table_area = skill_e_area.find_next_sibling() - # skillE_table = skillE_table_area.find_all('tr') - - load_another_talent_q: bool = False - if char_name in ("神里绫华", "莫娜"): - load_another_talent_q = True - - skill_q_area = skill_e_table_area.find_next_sibling() - skill_q_info = skill_q_area.find_all('tr') - skill_q_icon = self.ROOT_URL + skill_q_info[0].find('img', {'class': 'itempic'})['data-src'] - skill_q_name = skill_q_info[0].find('a', href=re.compile('/db/skill/')).text - skill_q_desc = skill_q_info[1].find('div', {'class': 'skill_desc_layout'}).text.replace(" ", "\n") - skill_q_table_area = skill_q_area.find_next_sibling() - # skill_q_table = skill_q_table_area.find_all('tr') - - if load_another_talent_q: - skill_replace = characters_info_dict["skills"]["skill_replace"] - skill_replace["icon"] = skill_q_icon - skill_replace["name"] = skill_q_name - skill_replace["description"] = skill_q_desc - else: - skill_q = characters_info_dict["skills"]["skill_q"] - skill_q["icon"] = skill_q_icon - skill_q["name"] = skill_q_name - skill_q["description"] = skill_q_desc - - if load_another_talent_q: - skill_q2_area = skill_q_table_area.find_next_sibling() - skill_q2_info = skill_q2_area.find_all('tr') - skill_q2_icon = self.ROOT_URL + skill_q2_info[0].find('img', {'class': 'itempic'})['data-src'] - skill_q2_name = skill_q2_info[0].find('a', href=re.compile('/db/skill/')).text - skill_q2_desc = skill_q2_info[1].find('div', {'class': 'skill_desc_layout'}).text.replace(" ", "\n") - skill_q2 = characters_info_dict["skills"]["skill_q"] - skill_q2["icon"] = skill_q2_icon - skill_q2["name"] = skill_q2_name - skill_q2["description"] = skill_q2_desc - - # 角色图片 - char_pic_area = main_content.find('span', string='Character Gallery').find_next_sibling() - all_char_pic = char_pic_area.find("div", {"class": "gallery_cont"}) - - gacha_splash_text = all_char_pic.find("span", {"class": "gallery_cont_span"}, string="Gacha Splash") - gacha_splash_pic_url = self.ROOT_URL + gacha_splash_text.previous_element.previous_element["data-src"].replace( - "_70", "") - characters_info_dict["gacha_splash"] = gacha_splash_pic_url - - return characters_info_dict diff --git a/models/wiki/helpers.py b/models/wiki/helpers.py deleted file mode 100644 index aa091d7..0000000 --- a/models/wiki/helpers.py +++ /dev/null @@ -1,27 +0,0 @@ -import re - -ID_RGX = re.compile(r"/db/[^.]+_(?P\d+)") - - -def get_headers(): - headers = { - "accept": "text/html,application/xhtml+xml,application/xml;" - "q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.63 Safari/537.36", - "referer": "https://genshin.honeyhunterworld.com/db/char/hutao/?lang=CHS", - } - return headers - - -def get_id_form_url(url: str): - matches = ID_RGX.search(url) - if matches is None: - return -1 - entries = matches.groupdict() - if entries is None: - return -1 - try: - return int(entries.get('id')) - except (IndexError, ValueError, TypeError): - return -1 diff --git a/models/wiki/material.py b/models/wiki/material.py new file mode 100644 index 0000000..2a21da8 --- /dev/null +++ b/models/wiki/material.py @@ -0,0 +1,57 @@ +import re +from typing import List + +from bs4 import BeautifulSoup +from httpx import URL +from typing_extensions import Self + +from models.wiki.base import SCRAPE_HOST, WikiModel + +__all__ = ['Material'] + + +class Material(WikiModel): + """材料 + + Attributes: + type: 类型 + source: 获取方式 + description: 描述 + serise: 材料系列 + """ + + type: str + source: List[str] + description: str + + @staticmethod + def scrape_urls() -> List[URL]: + return [SCRAPE_HOST.join(f'fam_wep_{i}/?lang=CHS') for i in ['primary', 'secondary', 'common']] + + @classmethod + async def _parse_soup(cls, soup: BeautifulSoup) -> Self: + """解析材料页""" + soup = soup.select('.wp-block-post-content')[0] + tables = soup.find_all('table') + table_rows = tables[0].find_all('tr') + + def get_table_text(row_num: int) -> str: + """一个快捷函数,用于返回表格对应行的最后一个单元格中的文本""" + return table_rows[row_num].find_all('td')[-1].text.replace('\xa0', '') + + id_ = re.findall(r'/img/(.*?)\.webp', str(table_rows[0]))[0] + name = get_table_text(0) + rarity = len(table_rows[3].find_all('img')) + type_ = get_table_text(1) + source = list( + filter( + lambda x: x, # filter 在这里的作用是过滤掉为空的数据 + table_rows[-2].find_all('td')[-1].encode_contents().decode().split('
') + ) + ) + description = get_table_text(-1) + return Material(id=id_, name=name, rarity=rarity, type=type_, source=source, description=description) + + @property + def icon(self) -> str: + return str(SCRAPE_HOST.join(f'/img/{self.id}.png')) diff --git a/models/wiki/metadata/weapon_level.json b/models/wiki/metadata/weapon_level.json new file mode 100644 index 0000000..cdb7def --- /dev/null +++ b/models/wiki/metadata/weapon_level.json @@ -0,0 +1,362 @@ +[ + { + "level": 1, + "requiredExps": [125, 175, 275, 400, 600] + }, + { + "level": 2, + "requiredExps": [200, 275, 425, 625, 950] + }, + { + "level": 3, + "requiredExps": [275, 400, 600, 900, 1350] + }, + { + "level": 4, + "requiredExps": [350, 550, 800, 1200, 1800] + }, + { + "level": 5, + "requiredExps": [475, 700, 1025, 1550, 2325] + }, + { + "level": 6, + "requiredExps": [575, 875, 1275, 1950, 2925] + }, + { + "level": 7, + "requiredExps": [700, 1050, 1550, 2350, 3525] + }, + { + "level": 8, + "requiredExps": [850, 1250, 1850, 2800, 4200] + }, + { + "level": 9, + "requiredExps": [1000, 1475, 2175, 3300, 4950] + }, + { + "level": 10, + "requiredExps": [1150, 1700, 2500, 3800, 5700] + }, + { + "level": 11, + "requiredExps": [1300, 1950, 2875, 4350, 6525] + }, + { + "level": 12, + "requiredExps": [1475, 2225, 3250, 4925, 7400] + }, + { + "level": 13, + "requiredExps": [1650, 2475, 3650, 5525, 8300] + }, + { + "level": 14, + "requiredExps": [1850, 2775, 4050, 6150, 9225] + }, + { + "level": 15, + "requiredExps": [2050, 3050, 4500, 6800, 10200] + }, + { + "level": 16, + "requiredExps": [2250, 3375, 4950, 7500, 11250] + }, + { + "level": 17, + "requiredExps": [2450, 3700, 5400, 8200, 12300] + }, + { + "level": 18, + "requiredExps": [2675, 4025, 5900, 8950, 13425] + }, + { + "level": 19, + "requiredExps": [2925, 4375, 6425, 9725, 14600] + }, + { + "level": 20, + "requiredExps": [3150, 4725, 6925, 10500, 15750] + }, + { + "level": 21, + "requiredExps": [3575, 5350, 7850, 11900, 17850] + }, + { + "level": 22, + "requiredExps": [3825, 5750, 8425, 12775, 19175] + }, + { + "level": 23, + "requiredExps": [4100, 6175, 9050, 13700, 20550] + }, + { + "level": 24, + "requiredExps": [4400, 6600, 9675, 14650, 21975] + }, + { + "level": 25, + "requiredExps": [4700, 7025, 10325, 15625, 23450] + }, + { + "level": 26, + "requiredExps": [5000, 7475, 10975, 16625, 24950] + }, + { + "level": 27, + "requiredExps": [5300, 7950, 11650, 17650, 26475] + }, + { + "level": 28, + "requiredExps": [5600, 8425, 12350, 18700, 28050] + }, + { + "level": 29, + "requiredExps": [5925, 8900, 13050, 19775, 29675] + }, + { + "level": 30, + "requiredExps": [6275, 9400, 13800, 20900, 31350] + }, + { + "level": 31, + "requiredExps": [6600, 9900, 14525, 22025, 33050] + }, + { + "level": 32, + "requiredExps": [6950, 10450, 15300, 23200, 34800] + }, + { + "level": 33, + "requiredExps": [7325, 10975, 16100, 24375, 36575] + }, + { + "level": 34, + "requiredExps": [7675, 11525, 16900, 25600, 38400] + }, + { + "level": 35, + "requiredExps": [8050, 12075, 17700, 26825, 40250] + }, + { + "level": 36, + "requiredExps": [8425, 12650, 18550, 28100, 42150] + }, + { + "level": 37, + "requiredExps": [8825, 13225, 19400, 29400, 44100] + }, + { + "level": 38, + "requiredExps": [9225, 13825, 20275, 30725, 46100] + }, + { + "level": 39, + "requiredExps": [9625, 14425, 21175, 32075, 48125] + }, + { + "level": 40, + "requiredExps": [10025, 15050, 22050, 33425, 50150] + }, + { + "level": 41, + "requiredExps": [10975, 16450, 24150, 36575, 54875] + }, + { + "level": 42, + "requiredExps": [11425, 17125, 25125, 38075, 57125] + }, + { + "level": 43, + "requiredExps": [11875, 17825, 26125, 39600, 59400] + }, + { + "level": 44, + "requiredExps": [12350, 18525, 27150, 41150, 61725] + }, + { + "level": 45, + "requiredExps": [12825, 19225, 28200, 42725, 64100] + }, + { + "level": 46, + "requiredExps": [13300, 19950, 29250, 44325, 66500] + }, + { + "level": 47, + "requiredExps": [13775, 20675, 30325, 45950, 68925] + }, + { + "level": 48, + "requiredExps": [14275, 21425, 31425, 47600, 71400] + }, + { + "level": 49, + "requiredExps": [14800, 22175, 32550, 49300, 73950] + }, + { + "level": 50, + "requiredExps": [15300, 22950, 33650, 51000, 76500] + }, + { + "level": 51, + "requiredExps": [16625, 24925, 36550, 55375, 83075] + }, + { + "level": 52, + "requiredExps": [17175, 25750, 37775, 57225, 85850] + }, + { + "level": 53, + "requiredExps": [17725, 26600, 39000, 59100, 88650] + }, + { + "level": 54, + "requiredExps": [18300, 27450, 40275, 61025, 91550] + }, + { + "level": 55, + "requiredExps": [18875, 28325, 41550, 62950, 94425] + }, + { + "level": 56, + "requiredExps": [19475, 29225, 42850, 64925, 97400] + }, + { + "level": 57, + "requiredExps": [20075, 30100, 44150, 66900, 100350] + }, + { + "level": 58, + "requiredExps": [20675, 31025, 45500, 68925, 103400] + }, + { + "level": 59, + "requiredExps": [21300, 31950, 46850, 70975, 106475] + }, + { + "level": 60, + "requiredExps": [21925, 32875, 48225, 73050, 109575] + }, + { + "level": 61, + "requiredExps": [23675, 35500, 52075, 78900, 118350] + }, + { + "level": 62, + "requiredExps": [24350, 36500, 53550, 81125, 121700] + }, + { + "level": 63, + "requiredExps": [25025, 37525, 55050, 83400, 125100] + }, + { + "level": 64, + "requiredExps": [25700, 38575, 56550, 85700, 128550] + }, + { + "level": 65, + "requiredExps": [26400, 39600, 58100, 88025, 132050] + }, + { + "level": 66, + "requiredExps": [27125, 40675, 59650, 90375, 135575] + }, + { + "level": 67, + "requiredExps": [27825, 41750, 61225, 92750, 139125] + }, + { + "level": 68, + "requiredExps": [28550, 42825, 62800, 95150, 142725] + }, + { + "level": 69, + "requiredExps": [29275, 43900, 64400, 97575, 146375] + }, + { + "level": 70, + "requiredExps": [30025, 45025, 66025, 100050, 150075] + }, + { + "level": 71, + "requiredExps": [32300, 48450, 71075, 107675, 161525] + }, + { + "level": 72, + "requiredExps": [33100, 49650, 72825, 110325, 165500] + }, + { + "level": 73, + "requiredExps": [33900, 50850, 74575, 113000, 169500] + }, + { + "level": 74, + "requiredExps": [34700, 52075, 76350, 115700, 173550] + }, + { + "level": 75, + "requiredExps": [35525, 53300, 78150, 118425, 177650] + }, + { + "level": 76, + "requiredExps": [36350, 54550, 80000, 121200, 181800] + }, + { + "level": 77, + "requiredExps": [37200, 55800, 81850, 124000, 186000] + }, + { + "level": 78, + "requiredExps": [38050, 57075, 83700, 126825, 190250] + }, + { + "level": 79, + "requiredExps": [38900, 58350, 85575, 129675, 194525] + }, + { + "level": 80, + "requiredExps": [39775, 59650, 87500, 132575, 198875] + }, + { + "level": 81, + "requiredExps": [46950, 70425, 103275, 156475, 234725] + }, + { + "level": 82, + "requiredExps": [52775, 79150, 116075, 175875, 263825] + }, + { + "level": 83, + "requiredExps": [59275, 88925, 130425, 197600, 296400] + }, + { + "level": 84, + "requiredExps": [66600, 99900, 146500, 221975, 332975] + }, + { + "level": 85, + "requiredExps": [74800, 112175, 164550, 249300, 373950] + }, + { + "level": 86, + "requiredExps": [83975, 125975, 184775, 279950, 419925] + }, + { + "level": 87, + "requiredExps": [94275, 141425, 207400, 314250, 471375] + }, + { + "level": 88, + "requiredExps": [105800, 158725, 232775, 352700, 529050] + }, + { + "level": 89, + "requiredExps": [118725, 178100, 261200, 395775, 593675] + }, + { + "level": 90, + "requiredExps": [133200, 199800, 293050, 444025, 666050] + } +] \ No newline at end of file diff --git a/models/wiki/other.py b/models/wiki/other.py new file mode 100644 index 0000000..4c54035 --- /dev/null +++ b/models/wiki/other.py @@ -0,0 +1,132 @@ +from enum import Enum +from typing import Optional + +from typing_extensions import Self + +from models.wiki.base import SCRAPE_HOST + +__all__ = [ + 'Element', + 'WeaponType', + 'AttributeType', + 'Association', +] + + +class Element(Enum): + """元素""" + Pyro = '火' + Hydro = '水' + Electro = '雷' + Cryo = '冰' + Dendro = '草' + Anemo = '风' + Geo = '岩' + Multi = '无' # 主角 + + +_WEAPON_ICON_MAP = { + 'Sword': SCRAPE_HOST.join('img/s_23101.png'), + 'Claymore': SCRAPE_HOST.join('img/s_163101.png'), + 'Polearm': SCRAPE_HOST.join('img/s_233101.png'), + 'Catalyst': SCRAPE_HOST.join('img/s_43101.png'), + 'Bow': SCRAPE_HOST.join('img/s_213101.png'), +} + + +class WeaponType(Enum): + """武器类型""" + Sword = '单手剑' + Claymore = '双手剑' + Polearm = '长柄武器' + Catalyst = '法器' + Bow = '弓' + + def icon_url(self) -> str: + return str(_WEAPON_ICON_MAP.get(self.name)) + + +_ATTR_TYPE_MAP = { + # 这个字典用于将 Honey 页面中遇到的 属性的缩写的字符 转为 AttributeType 的字符 + # 例如 Honey 页面上写的 HP% 则对应 HP_p + "HP": ['Health'], + "HP_p": ['HP%', 'Health %'], + "ATK": ['Attack'], + "ATK_p": ['Atk%', 'Attack %'], + "DEF": ['Defense'], + "DEF_p": ['Def%', 'Defense %'], + "EM": ['Elemental Mastery'], + "ER": ['ER%', 'Energy Recharge %'], + "CR": ['CrR%', 'Critical Rate %', 'CritRate%'], + "CD": ['Crd%', 'Critical Damage %', 'CritDMG%'], + "PD": ['Phys%', 'Physical Damage %'], + "HB": [], + "Pyro": [], + "Hydro": [], + "Electro": [], + "Cryo": [], + "Dendro": [], + "Anemo": [], + "Geo": [], +} + + +class AttributeType(Enum): + """属性枚举类。包含了武器和圣遗物的属性。""" + HP = "生命" + HP_p = "生命%" + ATK = "攻击力" + ATK_p = "攻击力%" + DEF = "防御力" + DEF_p = "防御力%" + EM = "元素精通" + ER = "元素充能效率" + CR = "暴击率" + CD = "暴击伤害" + PD = "物理伤害加成" + HB = "治疗加成" + Pyro = '火元素伤害加成' + Hydro = '水元素伤害加成' + Electro = '雷元素伤害加成' + Cryo = '冰元素伤害加成' + Dendro = '草元素伤害加成' + Anemo = '风元素伤害加成' + Geo = '岩元素伤害加成' + + @classmethod + def convert(cls, string: str) -> Optional[Self]: + string = string.strip() + for k, v in _ATTR_TYPE_MAP.items(): + if string == k or string in v or string.upper() == k: + return cls[k] + + +_ASSOCIATION_MAP = { + 'Other': ['Mainactor', 'Ranger', 'Fatui'], + 'Snezhnaya': [], + 'Sumeru': [], + 'Inazuma': [], + 'Liyue': [], + 'Mondstadt': [], +} + + +class Association(Enum): + """角色所属地区""" + Other = '其它' + Snezhnaya = '至冬' + Sumeru = '须弥' + Inazuma = '稻妻' + Liyue = '璃月' + Mondstadt = '蒙德' + + @classmethod + def convert(cls, string: str) -> Optional[Self]: + string = string.strip() + for k, v in _ASSOCIATION_MAP.items(): + if string == k or string in v: + return cls[k] + string = string.lower().title() + if string == k or string in v: + return cls[k] + return cls[string] diff --git a/models/wiki/weapon.py b/models/wiki/weapon.py new file mode 100644 index 0000000..e50a770 --- /dev/null +++ b/models/wiki/weapon.py @@ -0,0 +1,143 @@ +import itertools +import re +from typing import List, Optional, Tuple, Union + +from bs4 import BeautifulSoup +from httpx import URL + +from models.wiki.base import Model, SCRAPE_HOST, WikiModel +from models.wiki.other import AttributeType, WeaponType + +__all__ = ['Weapon', 'WeaponAffix', 'WeaponAttribute'] + + +class WeaponAttribute(Model): + """武器词条""" + type: AttributeType + value: str + + +class WeaponAffix(Model): + """武器技能 + + Attributes: + name: 技能名 + description: 技能描述 + + """ + name: str + description: List[str] + + +class WeaponState(Model): + level: str + ATK: float + bonus: Optional[str] + + +class WeaponIcon(Model): + icon: str + awakened: str + gacha: str + + +class Weapon(WikiModel): + """武器 + + Attributes: + type: 武器类型 + attack: 基础攻击力 + attribute: + affix: 武器技能 + description: 描述 + ascension: 突破材料 + story: 武器故事 + """ + + weapon_type: WeaponType + attack: float + attribute: Optional[WeaponAttribute] + affix: Optional[WeaponAffix] + description: str + ascension: List[str] + story: Optional[str] + + stats: List[WeaponState] + + @staticmethod + def scrape_urls() -> List[URL]: + return [SCRAPE_HOST.join(f"fam_{i.lower()}/?lang=CHS") for i in WeaponType.__members__] + + @classmethod + async def _parse_soup(cls, soup: BeautifulSoup) -> 'Weapon': + """解析武器页""" + soup = soup.select('.wp-block-post-content')[0] + tables = soup.find_all('table') + table_rows = tables[0].find_all('tr') + + def get_table_text(row_num: int) -> str: + """一个快捷函数,用于返回表格对应行的最后一个单元格中的文本""" + return table_rows[row_num].find_all('td')[-1].text.replace('\xa0', '') + + def find_table(select: str): + """一个快捷函数,用于寻找对应表格头的表格""" + return list(filter(lambda x: select in ' '.join(x.attrs['class']), tables)) + + id_ = re.findall(r'/img/(.*?)_gacha', str(table_rows[0]))[0] + weapon_type = WeaponType[get_table_text(1).split(',')[-1].strip()] + name = get_table_text(0) + rarity = len(table_rows[2].find_all('img')) + attack = float(get_table_text(4)) + ascension = [re.findall(r'/(.*)/', tag.attrs['href'])[0] for tag in table_rows[-1].find_all('a')] + if rarity > 2: # 如果是 3 星及其以上的武器 + attribute = WeaponAttribute( + type=AttributeType.convert( + tables[2].find('thead').find('tr').find_all('td')[2].text.split(' ')[1] + ), + value=get_table_text(6) + ) + affix = WeaponAffix(name=get_table_text(7), description=[ + i.find_all('td')[1].text for i in tables[3].find_all('tr')[1:] + ]) + if len(tables) < 11: + description = get_table_text(-1) + else: + description = get_table_text(9) + if story_table := find_table('quotes'): + story = story_table[0].text.strip() + else: + story = None + else: # 如果是 2 星及其以下的武器 + attribute = affix = None + description = get_table_text(5) + story = tables[-1].text.strip() + stats = [] + for row in tables[2].find_all('tr')[1:]: + cells = row.find_all('td') + if rarity > 2: + stats.append(WeaponState(level=cells[0].text, ATK=cells[1].text, bonus=cells[2].text)) + else: + stats.append(WeaponState(level=cells[0].text, ATK=cells[1].text)) + return Weapon( + id=id_, name=name, rarity=rarity, attack=attack, attribute=attribute, affix=affix, weapon_type=weapon_type, + story=story, stats=stats, description=description, ascension=ascension + ) + + @classmethod + async def get_name_list(cls, *, with_url: bool = False) -> List[Union[str, Tuple[str, URL]]]: + # 重写此函数的目的是名字去重,例如单手剑页面中有三个 “「一心传」名刀” + name_list = [i async for i in cls._name_list_generator(with_url=with_url)] + if with_url: + return [ + (i[0], list(i[1])[0][1]) for i in itertools.groupby(name_list, lambda x: x[0]) + ] + else: + return [i[0] for i in itertools.groupby(name_list, lambda x: x)] + + @property + def icon(self) -> WeaponIcon: + return WeaponIcon( + icon=str(SCRAPE_HOST.join(f'/img/{self.id}.png')), + awakened=str(SCRAPE_HOST.join(f'/img/{self.id}_awaken_icon.png')), + gacha=str(SCRAPE_HOST.join(f'/img/{self.id}_gacha_icon.png')), + ) diff --git a/models/wiki/weapons.py b/models/wiki/weapons.py deleted file mode 100644 index f83304a..0000000 --- a/models/wiki/weapons.py +++ /dev/null @@ -1,210 +0,0 @@ -import os -from enum import Enum -from typing import Optional - -import httpx -import ujson -from bs4 import BeautifulSoup - -from .helpers import get_headers, get_id_form_url - - -class WeaponType(Enum): - Sword = "sword" # 单手剑 - Claymore = "claymore" # 双手剑 - PoleArm = "polearm" # 长柄武器 - Bow = "bow" # 弓 - Catalyst = "catalyst" # 法器 - - -class Weapons: - IGNORE_WEAPONS_ID = [ - "1001", "1101", "1406", - "2001", "2101", "2204", "2406", "2407", - "3001", "3101", "3204", "3404", - "4001", "4101", "4201", "4403", "4405", "4406", - "5001", "5101", "5201", "5404", "5404", "5405", - ] # 忽略的武器包括一星、二星武器,beta表格内无名武器,未上架到正服的武器 - - # 根地址 - ROOT_URL = "https://genshin.honeyhunterworld.com" - - TEXT_MAPPING = { - "Type": "类型", - "Rarity": "Rarity", - "Base Attack": "基础攻击力" - } - - WEAPON_TYPE_MAPPING = { - "Sword": "https://genshin.honeyhunterworld.com/img/skills/s_33101.png", # 单手剑 - "Claymore": "https://genshin.honeyhunterworld.com/img/skills/s_163101.png", # 双手剑 - "Polearm": "https://genshin.honeyhunterworld.com/img/skills/s_233101.png", # 长枪 - "Bow": "https://genshin.honeyhunterworld.com/img/skills/s_213101.png", # 弓箭 - "Catalyst": "https://genshin.honeyhunterworld.com/img/skills/s_43101.png", # 法器 - } - - def __init__(self): - self.client = httpx.AsyncClient(headers=get_headers()) - project_path = os.path.dirname(__file__) - characters_file = os.path.join(project_path, "metadata", "ascension.json") - monster_file = os.path.join(project_path, "metadata", "monster.json") - elite_file = os.path.join(project_path, "metadata", "elite.json") - with open(characters_file, "r", encoding="utf-8") as f: - self._ascension_json: dict = ujson.load(f) - with open(monster_file, "r", encoding="utf-8") as f: - self._monster_json: dict = ujson.load(f) - with open(elite_file, "r", encoding="utf-8") as f: - self._elite_json: dict = ujson.load(f) - - async def _get_soup(self, url: str) -> Optional[BeautifulSoup]: - request = await self.client.get(url) - return BeautifulSoup(request.text, "lxml") - - async def get_weapon_url_list(self, weapon_type: WeaponType): - weapon_url_list = [] - url = self.ROOT_URL + f"/db/weapon/{weapon_type.value}/?lang=CHS" - soup = await self._get_soup(url) - weapon_table = soup.find("span", {"class": "item_secondary_title"}, - string="Released (Codex) Weapons").find_next_sibling() - weapon_table_rows = weapon_table.find_all("tr") - for weapon_table_row in weapon_table_rows: - content = weapon_table_row.find_all("td")[2] - if content.find("a") is not None: - weapon_url = self.ROOT_URL + content.find("a")["href"] - weapon_id = str(get_id_form_url(weapon_url)) - if weapon_id not in self.IGNORE_WEAPONS_ID: - weapon_url_list.append(weapon_url) - return weapon_url_list - - async def get_all_weapon_url(self): - all_weapon_url = [] - temp_data = await self.get_weapon_url_list(WeaponType.Bow) - all_weapon_url.extend(temp_data) - temp_data = await self.get_weapon_url_list(WeaponType.Sword) - all_weapon_url.extend(temp_data) - temp_data = await self.get_weapon_url_list(WeaponType.PoleArm) - all_weapon_url.extend(temp_data) - temp_data = await self.get_weapon_url_list(WeaponType.Catalyst) - all_weapon_url.extend(temp_data) - temp_data = await self.get_weapon_url_list(WeaponType.Claymore) - all_weapon_url.extend(temp_data) - return all_weapon_url - - @staticmethod - def get_weapon_info_template(): - weapon_info_dict = { - "name": "", - "description": "", - "source_img": "", - "atk": - { - "min": 0, - "max": 999999, - "name": "基础攻击力" - }, - "secondary": - { - "min": 0.1, - "max": 999999.9, - "name": "" - }, - "star": - { - "value": -1, - "icon": "" - }, - "type": - { - "name": "", - "icon": "" - }, - "passive_ability": - { - "name": "", - "description": "" - } - } - materials_dict = { - "name": "", - "star": { - "value": 0, - "icon": "" - }, - "city": "", - "icon": "" - } - weapon_info_dict["materials"] = { - "ascension": materials_dict, - "elite": materials_dict, - "monster": materials_dict, - } - return weapon_info_dict - - async def get_weapon_info(self, url: str): - weapon_info_dict = self.get_weapon_info_template() - soup = await self._get_soup(url) - weapon_content = soup.find("div", {"class": "wrappercont"}) - data = weapon_content.find("div", {"class": "data_cont_wrapper", "style": "display: block"}) - weapon_info = data.find("table", {"class": "item_main_table"}) - weapon_name = weapon_content.find("div", {"class": "custom_title"}).text.replace("-", "").replace(" ", "") - weapon_info_dict["name"] = weapon_name - weapon_info_row = weapon_info.find_all("tr") - for weapon_info_ in weapon_info_row: - content = weapon_info_.find_all("td") - if len(content) == 3: # 第一行会有三个td,其中一个td是武器图片 - weapon_info_dict["source_img"] = self.ROOT_URL + content[0].find("img", - {"class": "itempic lazy"})["data-src"] - weapon_info_dict["type"]["name"] = content[2].text - weapon_info_dict["type"]["icon"] = self.get_weapon_type(content[2].text) - elif len(content) == 2: - if content[0].text == "Rarity": - weapon_info_dict["star"]["value"] = len( - content[1].find_all("div", {"class": "sea_char_stars_wrap"})) - elif content[0].text == "Special (passive) Ability": - weapon_info_dict["passive_ability"]["name"] = content[1].text - elif content[0].text == "Special (passive) Ability Description": - weapon_info_dict["passive_ability"]["description"] = content[1].text - elif content[0].text == "In-game Description": - weapon_info_dict["description"] = content[1].text - elif content[0].text == "Secondary Stat": - weapon_info_dict["secondary"]["name"] = content[1].text - - stat_table = data.find("span", {"class": "item_secondary_title"}, - string=" Stat Progression ").find_next_sibling() - stat_table_row = stat_table.find_all("tr") - for stat_table_ in stat_table_row: - content = stat_table_.find_all("td") - # 通过等级判断 - if content[0].text == "1": - weapon_info_dict["atk"]["min"] = int(content[1].text) - weapon_info_dict["secondary"]["min"] = float(content[2].text) - elif content[0].text == "80+": - item_hrefs = content[3].find_all("a") - for item_href in item_hrefs: - item_id = get_id_form_url(item_href["href"]) - ascension = self.get_ascension(str(item_id)) - if ascension.get("name") is not None: - weapon_info_dict["materials"]["ascension"] = ascension - monster = self.get_monster(str(item_id)) - if monster.get("name") is not None: - weapon_info_dict["materials"]["monster"] = monster - elite = self.get_elite(str(item_id)) - if elite.get("name") is not None: - weapon_info_dict["materials"]["elite"] = elite - elif content[0].text == "90": - weapon_info_dict["atk"]["max"] = int(content[1].text) - weapon_info_dict["secondary"]["max"] = float(content[2].text) - - return weapon_info_dict - - def get_ascension(self, item_id: str): - return self._ascension_json.get(item_id, {}) - - def get_monster(self, item_id: str): - return self._monster_json.get(item_id, {}) - - def get_elite(self, item_id: str): - return self._elite_json.get(item_id, {}) - - def get_weapon_type(self, weapon_type: str): - return self.WEAPON_TYPE_MAPPING.get(weapon_type, "") diff --git a/plugins/genshin/weapon.py b/plugins/genshin/weapon.py index 26e0460..99f2de6 100644 --- a/plugins/genshin/weapon.py +++ b/plugins/genshin/weapon.py @@ -1,11 +1,13 @@ -from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.constants import ChatAction -from telegram.ext import filters, CommandHandler, MessageHandler, CallbackContext +from telegram.ext import CallbackContext, CommandHandler, MessageHandler, filters from core.template.services import TemplateService from core.wiki.services import WikiService from logger import Log from metadata.shortname import weaponToName +from models.wiki.base import SCRAPE_HOST +from models.wiki.weapon import Weapon from plugins.base import BasePlugins from utils.bot import get_all_args from utils.decorators.error import error_callable @@ -16,10 +18,12 @@ from utils.service.inject import inject @listener_plugins_class() -class Weapon(BasePlugins): +class WeaponPlugin(BasePlugins): """武器查询""" - KEYBOARD = [[InlineKeyboardButton(text="查看武器列表并查询", switch_inline_query_current_chat="查看武器列表并查询")]] + KEYBOARD = [[ + InlineKeyboardButton(text="查看武器列表并查询", switch_inline_query_current_chat="查看武器列表并查询") + ]] @inject def __init__(self, template_service: TemplateService, wiki_service: WikiService): @@ -52,7 +56,7 @@ class Weapon(BasePlugins): weapon_name = weaponToName(weapon_name) weapons_list = await self.wiki_service.get_weapons_list() for weapon in weapons_list: - if weapon["name"] == weapon_name: + if weapon.name == weapon_name: weapon_data = weapon break else: @@ -65,27 +69,44 @@ class Weapon(BasePlugins): Log.info(f"用户 {user.full_name}[{user.id}] 查询武器命令请求 || 参数 {weapon_name}") await message.reply_chat_action(ChatAction.TYPING) - async def input_template_data(_weapon_data): - _template_data = { - "weapon_name": _weapon_data["name"], - "weapon_info_type_img": await url_to_file(_weapon_data["type"]["icon"]), - "progression_secondary_stat_value": _weapon_data["secondary"]["max"], - "progression_secondary_stat_name": _weapon_data["secondary"]["name"], - "weapon_info_source_img": await url_to_file(_weapon_data["source_img"]), - "progression_base_atk": _weapon_data["atk"]["max"], - "weapon_info_source_list": [], - "special_ability_name": _weapon_data["passive_ability"]["name"], - "special_ability_info": _weapon_data["passive_ability"]["description"], - } - _template_data["weapon_info_source_list"].append( - await url_to_file(_weapon_data["materials"]["ascension"]["icon"]) - ) - _template_data["weapon_info_source_list"].append( - await url_to_file(_weapon_data["materials"]["elite"]["icon"]) - ) - _template_data["weapon_info_source_list"].append( - await url_to_file(_weapon_data["materials"]["monster"]["icon"]) - ) + async def input_template_data(_weapon_data: Weapon): + if weapon.rarity > 2: + bonus = _weapon_data.stats[-1].bonus + if '%' in bonus: + bonus = str(round(float(bonus.rstrip('%')))) + '%' + else: + bonus = str(round(float(bonus))) + _template_data = { + "weapon_name": _weapon_data.name, + "weapon_info_type_img": await url_to_file(_weapon_data.weapon_type.icon_url()), + "progression_secondary_stat_value": bonus, + "progression_secondary_stat_name": _weapon_data.attribute.type.value, + "weapon_info_source_img": await url_to_file(_weapon_data.icon.icon), + "weapon_info_max_level": _weapon_data.stats[-1].level, + "progression_base_atk": round(_weapon_data.stats[-1].ATK), + "weapon_info_source_list": [ + await url_to_file(str(SCRAPE_HOST.join(f'/img/{mid}.png'))) + for mid in _weapon_data.ascension[-3:] + ], + "special_ability_name": _weapon_data.affix.name, + "special_ability_info": _weapon_data.affix.description[0], + } + else: + _template_data = { + "weapon_name": _weapon_data.name, + "weapon_info_type_img": await url_to_file(_weapon_data.weapon_type.icon_url()), + "progression_secondary_stat_value": ' ', + "progression_secondary_stat_name": '无其它属性加成', + "weapon_info_source_img": await url_to_file(_weapon_data.icon.icon), + "weapon_info_max_level": _weapon_data.stats[-1].level, + "progression_base_atk": round(_weapon_data.stats[-1].ATK), + "weapon_info_source_list": [ + await url_to_file(str(SCRAPE_HOST.join(f'/img/{mid}.png'))) + for mid in _weapon_data.ascension[-3:] + ], + "special_ability_name": '', + "special_ability_info": _weapon_data.description, + } return _template_data template_data = await input_template_data(weapon_data) diff --git a/resources/genshin/weapon/weapon.html b/resources/genshin/weapon/weapon.html index 000769d..e9d5ca3 100644 --- a/resources/genshin/weapon/weapon.html +++ b/resources/genshin/weapon/weapon.html @@ -33,7 +33,7 @@
-

Lv.90

+

Lv.{{ weapon_info_max_level }}

攻击力 {{progression_base_atk}}

diff --git a/test/model/wiki/test_wiki.py b/test/model/wiki/test_wiki.py index 6535f07..cf72371 100644 --- a/test/model/wiki/test_wiki.py +++ b/test/model/wiki/test_wiki.py @@ -1,53 +1,94 @@ import unittest from unittest import IsolatedAsyncioTestCase -from models.wiki.characters import Characters -from models.wiki.weapons import Weapons - -weapons = Weapons() +from models.wiki.character import Character +from models.wiki.material import Material +from models.wiki.weapon import Weapon -class TestWiki(IsolatedAsyncioTestCase): - TEST_WEAPONS_URL = "https://genshin.honeyhunterworld.com/db/weapon/w_3405/?lang=CHS" - TEST_CHARACTERS_URL = "https://genshin.honeyhunterworld.com/db/char/hutao/?lang=CHS" +class TestWeapon(IsolatedAsyncioTestCase): + async def test_get_by_id(self): + weapon = await Weapon.get_by_id('11417') + self.assertEqual(weapon.name, '原木刀') + self.assertEqual(weapon.rarity, 4) + self.assertEqual(weapon.attack, 43.73) + self.assertEqual(weapon.attribute.type.value, '元素充能效率') + self.assertEqual(weapon.affix.name, '森林的瑞佑') - def setUp(self): - self.weapons = Weapons() - self.characters = Characters() + async def test_get_by_name(self): + weapon = await Weapon.get_by_name('风鹰剑') + self.assertEqual(weapon.id, 11501) + self.assertEqual(weapon.rarity, 5) + self.assertEqual(weapon.attack, 47.54) + self.assertEqual(weapon.attribute.type.value, '物理伤害加成') + self.assertEqual(weapon.affix.name, '西风之鹰的抗争') + self.assertTrue('听凭风引,便是正义与自由之风' in weapon.story) - async def test_get_weapon(self): - weapon_info = await self.weapons.get_weapon_info(self.TEST_WEAPONS_URL) - self.assertEqual(weapon_info["name"], "护摩之杖") - self.assertEqual(weapon_info["description"], "在早已失落的古老祭仪中,使用的朱赤「柴火杖」。") - self.assertEqual(weapon_info["atk"]["name"], "基础攻击力") - self.assertEqual(weapon_info["atk"]["min"], 46) - self.assertEqual(weapon_info["atk"]["max"], 608) - self.assertEqual(weapon_info["secondary"]["name"], "暴击伤害") - self.assertEqual(weapon_info["secondary"]["min"], 14.4) - self.assertEqual(weapon_info["secondary"]["max"], 66.2) - self.assertEqual(weapon_info["star"]["value"], 5) - self.assertEqual(weapon_info["type"]["name"], "Polearm") - self.assertEqual(weapon_info["passive_ability"]["name"], "无羁的朱赤之蝶") - self.assertEqual(weapon_info["passive_ability"]["description"], "生命值提升20%。" - "此外,基于装备该武器的角色生命值上限的0.8%," - "获得攻击力加成。当装备该武器的角色生命值低于50%时," - "进一步获得1%基于生命值上限的攻击力提升。") + async def test_get_full_gen(self): + async for weapon in Weapon.full_data_generator(): + self.assertIsInstance(weapon, Weapon) - async def test_get_all_weapon_url(self): - url_list = await self.weapons.get_all_weapon_url() - self.assertEqual(True, len(url_list) >= 123) + async def test_get_full(self): + full_data = await Weapon.get_full_data() + for weapon in full_data: + self.assertIsInstance(weapon, Weapon) - async def test_get_characters(self): - characters_info = await self.characters.get_characters(self.TEST_CHARACTERS_URL) - self.assertEqual(characters_info["name"], "胡桃") - self.assertEqual(characters_info["title"], "雪霁梅香") - self.assertEqual(characters_info["rarity"], 5) - self.assertEqual(characters_info["description"], "「往生堂」七十七代堂主,年纪轻轻就已主掌璃月的葬仪事务。") - self.assertEqual(characters_info["allegiance"], "往生堂") + async def test_name_list(self): + from httpx import URL + async for name in Weapon._name_list_generator(with_url=True): + self.assertIsInstance(name[0], str) + self.assertIsInstance(name[1], URL) - async def test_get_all_characters_url(self): - url_list = await self.characters.get_all_characters_url() - self.assertEqual(True, len(url_list) >= 49) + +class TestCharacter(IsolatedAsyncioTestCase): + async def test_get_by_id(self): + character = await Character.get_by_id('ayaka_002') + self.assertEqual(character.name, '神里绫华') + self.assertEqual(character.title, '白鹭霜华') + self.assertEqual(character.occupation, '社奉行') + self.assertEqual(character.association.value, '稻妻') + self.assertEqual(character.cn_cv, '小N') + + async def test_get_by_name(self): + character = await Character.get_by_name('神里绫华') + self.assertEqual(character.id, 'ayaka_002') + self.assertEqual(character.title, '白鹭霜华') + self.assertEqual(character.occupation, '社奉行') + self.assertEqual(character.association.value, '稻妻') + self.assertEqual(character.cn_cv, '小N') + + main_character = await Character.get_by_name('荧') + self.assertEqual(main_character.constellation, '旅人座') + self.assertEqual(main_character.cn_cv, '宴宁&多多poi') + + async def test_get_full(self): + async for character in Character.full_data_generator(): + self.assertIsInstance(character, Character) + + +class TestMaterial(IsolatedAsyncioTestCase): + async def test_get_full_gen(self): + async for material in Material.full_data_generator(): + self.assertIsInstance(material, Material) + + async def test_get_full(self): + material_list = await Material.get_full_data() + for material in material_list: + self.assertIsInstance(material, Material) + + +class TestAll(IsolatedAsyncioTestCase): + async def test_all_get_full(self): + import asyncio + materials, weapons, characters = tuple(await asyncio.gather( + Material.get_full_data(), + Weapon.get_full_data(), + Character.get_full_data(), + return_exceptions=True + )) + self.assertEqual(len(materials), 120) + self.assertEqual(len(weapons), 151) + self.assertEqual(len(characters), 58) if __name__ == "__main__":