diff --git a/model/helpers.py b/model/helpers.py index 4b16cadf..8313d489 100644 --- a/model/helpers.py +++ b/model/helpers.py @@ -16,7 +16,7 @@ from service.cache import RedisCache USER_AGENT: str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " \ "Chrome/90.0.4430.72 Safari/537.36" -headers: dict = {'User-Agent': USER_AGENT} +REQUEST_HEADERS: dict = {'User-Agent': USER_AGENT} current_dir = os.getcwd() cache_dir = os.path.join(current_dir, "cache") if not os.path.exists(cache_dir): @@ -56,7 +56,7 @@ async def url_to_file(url: str, prefix: str = "file://") -> str: temp_file_name = url_sha1 + extension file_dir = os.path.join(cache_dir, temp_file_name) if not os.path.exists(file_dir): - async with httpx.AsyncClient(headers=headers) as client: + async with httpx.AsyncClient(headers=REQUEST_HEADERS) as client: try: data = await client.get(url) except UnsupportedProtocol as error: diff --git a/plugins/map.py b/plugins/map.py new file mode 100644 index 00000000..adbdbb4c --- /dev/null +++ b/plugins/map.py @@ -0,0 +1,71 @@ +from os import sep + +from PIL import Image +from telegram import Update +from telegram.constants import ChatAction +from telegram.ext import CommandHandler, MessageHandler, filters + +from logger import Log +from manager import listener_plugins_class +from plugins.base import BasePlugins, restricts +from plugins.errorhandler import conversation_error_handler +from service.map import MapHelper +from utils.base import PaimonContext + + +@listener_plugins_class() +class Map(BasePlugins): + + def __init__(self): + self.init_resource_map = False + self.map_helper = MapHelper() + + @classmethod + def create_handlers(cls) -> list: + map_res = cls() + return [ + CommandHandler("map", map_res.command_start, block=False), + MessageHandler(filters.Regex(r"^资源点查询(.*)"), map_res.command_start, block=True) + ] + + async def init_point_list_and_map(self): + Log.info("正在初始化地图资源节点") + if not self.init_resource_map: + await self.map_helper.init_point_list_and_map() + self.init_resource_map = True + + @conversation_error_handler + @restricts(restricts_time=20) + async def command_start(self, update: Update, context: PaimonContext): + message = update.message + args = context.args + user = update.effective_user + if not self.init_resource_map: + await self.init_point_list_and_map() + await message.reply_chat_action(ChatAction.TYPING) + if len(args) >= 1: + resource_name = args[0] + else: + Log.info(f"用户: {user.full_name} [{user.id}] 使用了 map 命令") + await message.reply_text("请输入要查找的资源,或私聊派蒙发送 `/map list` 查看资源列表", parse_mode="Markdown") + return + if resource_name in ("list", "列表"): + if filters.ChatType.GROUPS.filter(message): + reply_message = await message.reply_text("请私聊派蒙使用该命令") + self._add_delete_message_job(context, reply_message.chat_id, reply_message.message_id, 300) + self._add_delete_message_job(context, message.chat_id, message.message_id, 300) + return + Log.info(f"用户: {user.full_name} [{user.id}] 使用 map 命令查询了 资源列表") + text = self.map_helper.get_resource_list_mes() + await message.reply_text(text) + return + Log.info(f"用户: {user.full_name} [{user.id}] 使用 map 命令查询了 {resource_name}") + text = await self.map_helper.get_resource_map_mes(resource_name) + if "不知道" in text or "没有找到" in text: + await message.reply_text(text, parse_mode="Markdown") + return + img = Image.open(f"cache{sep}map.jpg") + if img.size[0] > 2048 or img.size[1] > 2048: + await message.reply_document(open(f"cache{sep}map.jpg", mode='rb+'), caption=text) + else: + await message.reply_photo(open(f"cache{sep}map.jpg", mode='rb+'), caption=text) diff --git a/requirements.txt b/requirements.txt index 5d70cbc2..7c90f692 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,4 +16,5 @@ lxml>=4.9.0 fakeredis>=1.8.1 aiohttp<=3.8.1 python-telegram-bot==20.0a2 -pytz>=2021.3 \ No newline at end of file +pytz>=2021.3 +Pillow \ No newline at end of file diff --git a/resources/icon/0.png b/resources/icon/0.png new file mode 100644 index 00000000..bdf2090b Binary files /dev/null and b/resources/icon/0.png differ diff --git a/resources/icon/box.png b/resources/icon/box.png new file mode 100644 index 00000000..dadec344 Binary files /dev/null and b/resources/icon/box.png differ diff --git a/resources/icon/box_alpha.png b/resources/icon/box_alpha.png new file mode 100644 index 00000000..0c634fa2 Binary files /dev/null and b/resources/icon/box_alpha.png differ diff --git a/service/map.py b/service/map.py new file mode 100644 index 00000000..db99ee70 --- /dev/null +++ b/service/map.py @@ -0,0 +1,313 @@ +import os +import time +from io import BytesIO +from typing import Optional, List + +import httpx +import ujson +from PIL import Image, ImageMath + +from model.helpers import REQUEST_HEADERS + +Image.MAX_IMAGE_PIXELS = None + +ZOOM = 0.5 +RESOURCE_ICON_OFFSET = (-int(150 * 0.5 * ZOOM), -int(150 * ZOOM)) + + +class MapHelper: + LABEL_URL = 'https://api-static.mihoyo.com/common/blackboard/ys_obc/v1/map/label/tree?app_sn=ys_obc' + POINT_LIST_URL = 'https://api-static.mihoyo.com/common/blackboard/ys_obc/v1/map/point/list?map_id=2&app_sn=ys_obc' + MAP_URL = 'https://api-static.mihoyo.com/common/map_user/ys_obc/v1/map/info?map_id=2&app_sn=ys_obc&lang=zh-cn' + + def __init__(self, cache_dir_name: str = "cache"): + self._current_dir = os.getcwd() + self._output_dir = os.path.join(self._current_dir, cache_dir_name) + self._resources_icon_dir = os.path.join(self._current_dir, "resources", "icon") + self._cache_dir = os.path.join(self._current_dir, "cache") + self._map_dir = os.path.join(self._cache_dir, "map_icon.jpg") + self.client = httpx.AsyncClient(headers=REQUEST_HEADERS, timeout=10.0) + self.all_resource_type: dict = {} + """这个字典保存所有资源类型 + + "1": { + "id": 1, + "name": "传送点", + "icon": "", + "parent_id": 0, + "depth": 1, + "node_type": 1, + "jump_type": 0, + "jump_target_id": 0, + "display_priority": 0, + "children": [] + } + """ + self.can_query_type_list: dict = {} + """这个字典保存所有可以查询的资源类型名称和ID,这个字典只有名称和ID + + 上边字典里"depth": 2的类型才可以查询,"depth": 1的是1级目录,不能查询 + + "七天神像":"2" + + "风神瞳":"5" + """ + self.all_resource_point_list: list = [] + """这个列表保存所有资源点的数据 + + { + "id": 2740, + "label_id": 68, + "x_pos": -1789, + "y_pos": 2628, + "author_name": "作者名称", + "ctime": "更新时间", + "display_state": 1 + } + """ + self.date: str = "" + """记录上次更新"all_resource_point_list"的日期 + """ + + self.center: Optional[List[float]] = None + """center + """ + + self.map_icon: Optional[Image] = None + """map_icon + """ + + async def download_icon(self, url): + """下载图片 返回Image对象 + :param url: + :return: + """ + resp = await self.client.get(url=url) + if resp.status_code != 200: + raise ValueError(f"获取图片数据失败,错误代码 {resp.status_code}") + icon = resp.content + return Image.open(BytesIO(icon)) + + async def download_json(self, url): + """ + 获取资源数据,返回 JSON + :param url: + :return: dict + """ + resp = await self.client.get(url=url) + if resp.status_code != 200: + raise RuntimeError(f"获取资源点数据失败,错误代码 {resp.status_code}") + return resp.json() + + async def init_point_list_and_map(self): + await self.up_label_and_point_list() + await self.up_map() + + async def up_map(self): + """更新地图文件 并按照资源点的范围自动裁切掉不需要的地方 + 裁切地图需要最新的资源点位置,所以要先调用 up_label_and_point_list 再更新地图 + :return: None + """ + map_info = await self.download_json(self.MAP_URL) + map_info = map_info["data"]["info"]["detail"] + map_info = ujson.loads(map_info) + + map_url_list = map_info['slices'][0] + origin = map_info["origin"] + + x_start = map_info['total_size'][1] + y_start = map_info['total_size'][1] + x_end = 0 + y_end = 0 + for resource_point in self.all_resource_point_list: + x_pos = resource_point["x_pos"] + origin[0] + y_pos = resource_point["y_pos"] + origin[1] + x_start = min(x_start, x_pos) + y_start = min(y_start, y_pos) + x_end = max(x_end, x_pos) + y_end = max(y_end, y_pos) + + x_start -= 200 + y_start -= 200 + x_end += 200 + y_end += 200 + + self.center = [origin[0] - x_start, origin[1] - y_start] + x = int(x_end - x_start) + y = int(y_end - y_start) + self.map_icon = Image.new("RGB", (x, y)) + x_offset = 0 + for i in map_url_list: + map_url = i["url"] + map_icon = await self.download_icon(map_url) + self.map_icon.paste(map_icon, (int(-x_start) + x_offset, int(-y_start))) + x_offset += map_icon.size[0] + + async def up_label_and_point_list(self): + """更新label列表和资源点列表 + :return: + """ + label_data = await self.download_json(self.LABEL_URL) + for label in label_data["data"]["tree"]: + self.all_resource_type[str(label["id"])] = label + for sublist in label["children"]: + self.all_resource_type[str(sublist["id"])] = sublist + self.can_query_type_list[sublist["name"]] = str(sublist["id"]) + await self.up_icon_image(sublist) + label["children"] = [] + test = await self.download_json(self.POINT_LIST_URL) + self.all_resource_point_list = test["data"]["point_list"] + self.date = time.strftime("%d") + + async def up_icon_image(self, sublist: dict): + """检查是否有图标,没有图标下载保存到本地 + :param sublist: + :return: + """ + icon_id = sublist["id"] + icon_path = os.path.join(self._cache_dir, f"{icon_id}.png") + + if not os.path.exists(icon_path): + icon_url = sublist["icon"] + icon = await self.download_icon(icon_url) + icon = icon.resize((150, 150)) + + box_alpha = Image.open( + os.path.join(os.path.dirname(__file__), os.path.pardir, + "resources", "icon", "box_alpha.png")).getchannel("A") + box = Image.open(os.path.join(os.path.dirname(__file__), os.path.pardir, "resources", "icon", "box.png")) + + try: + icon_alpha = icon.getchannel("A") + icon_alpha = ImageMath.eval("convert(a*b/256, 'L')", a=icon_alpha, b=box_alpha) + except ValueError: + # 米游社的图有时候会没有alpha导致报错,这时候直接使用box_alpha当做alpha就行 + icon_alpha = box_alpha + + icon2 = Image.new("RGBA", (150, 150), "#00000000") + icon2.paste(icon, (0, -10)) + + bg = Image.new("RGBA", (150, 150), "#00000000") + bg.paste(icon2, mask=icon_alpha) + bg.paste(box, mask=box) + + with open(icon_path, "wb") as icon_file: + bg.save(icon_file) + + async def get_resource_map_mes(self, name): + if self.date != time.strftime("%d"): + await self.init_point_list_and_map() + if name not in self.can_query_type_list: + return f"派蒙还不知道 {name} 在哪里呢,可以发送 `/map list` 查看资源列表" + resource_id = self.can_query_type_list[name] + map_res = ResourceMap(self.all_resource_point_list, self.map_icon, self.center, resource_id) + count = map_res.get_resource_count() + if not count: + return f"派蒙没有找到 {name} 的位置,可能米游社wiki还没更新" + map_res.gen_jpg() + return f"派蒙一共找到 {name} 的 {count} 个位置点\n* 数据来源于米游社wiki" + + def get_resource_list_mes(self): + temp = {list_id: [] for list_id in self.all_resource_type if self.all_resource_type[list_id]["depth"] == 1} + + for list_id in self.all_resource_type: + # 再找2级目录 + if self.all_resource_type[list_id]["depth"] == 2: + temp[str(self.all_resource_type[list_id]["parent_id"])].append(list_id) + mes = "当前资源列表如下:\n" + + for resource_type_id, value in temp.items(): + if resource_type_id in ["1", "12", "50", "51", "95", "131"]: + # 在游戏里能查到的数据这里就不列举了,不然消息太长了 + continue + mes += f"{self.all_resource_type[resource_type_id]['name']}:" + for resource_id in value: + mes += f"{self.all_resource_type[resource_id]['name']}," + mes += "\n" + return mes + + +class ResourceMap: + + def __init__(self, all_resource_point_list: List[dict], map_icon: Image, center: List[float], resource_id: int): + self.all_resource_point_list = all_resource_point_list + self.resource_id = resource_id + self.center = center + self.map_image = map_icon.copy() + self.map_size = self.map_image.size + # 地图要要裁切的左上角和右下角坐标 + # 这里初始化为地图的大小 + self.x_start = self.map_size[0] + self.y_start = self.map_size[1] + self.x_end = 0 + self.y_end = 0 + resource_icon = Image.open(self.get_icon_path()) + self.resource_icon = resource_icon.resize((int(150 * ZOOM), int(150 * ZOOM))) + self.resource_xy_list = self.get_resource_point_list() + + def get_icon_path(self): + # 检查有没有图标,有返回正确图标,没有返回默认图标 + icon_path = os.path.join(os.path.dirname(__file__), os.path.pardir, + "resources", "icon", f"{self.resource_id}.png") + if os.path.exists(icon_path): + return icon_path + return os.path.join(os.path.dirname(__file__), os.path.pardir, "resources", "icon", "0.png") + + def get_resource_point_list(self): + temp_list = [] + for resource_point in self.all_resource_point_list: + if str(resource_point["label_id"]) == self.resource_id: + # 获取xy坐标,然后加上中心点的坐标完成坐标转换 + x = resource_point["x_pos"] + self.center[0] + y = resource_point["y_pos"] + self.center[1] + temp_list.append((int(x), int(y))) + return temp_list + + def paste(self): + for x, y in self.resource_xy_list: + # 把资源图片贴到地图上 + # 这时地图已经裁切过了,要以裁切后的地图左上角为中心再转换一次坐标 + x -= self.x_start + y -= self.y_start + self.map_image.paste(self.resource_icon, (x + RESOURCE_ICON_OFFSET[0], y + RESOURCE_ICON_OFFSET[1]), + self.resource_icon) + + def crop(self): + # 把大地图裁切到只保留资源图标位置 + for x, y in self.resource_xy_list: + # 找出4个方向最远的坐标,用于后边裁切 + self.x_start = min(x, self.x_start) + self.y_start = min(y, self.y_start) + self.x_end = max(x, self.x_end) + self.y_end = max(y, self.y_end) + + # 先把4个方向扩展150像素防止把资源图标裁掉 + self.x_start -= 150 + self.y_start -= 150 + self.x_end += 150 + self.y_end += 150 + + # 如果图片裁切得太小会看不出资源的位置在哪,检查图片裁切的长和宽看够不够1000,不到1000的按1000裁切 + if (self.x_end - self.x_start) < 1000: + center = int((self.x_end + self.x_start) / 2) + self.x_start = center - 500 + self.x_end = center + 500 + if (self.y_end - self.y_start) < 1000: + center = int((self.y_end + self.y_start) / 2) + self.y_start = center - 500 + self.y_end = center + 500 + + self.map_image = self.map_image.crop((self.x_start, self.y_start, + self.x_end, self.y_end)) + + def gen_jpg(self): + if not self.resource_xy_list: + return "没有这个资源的信息" + if not os.path.exists("cache"): + os.mkdir("cache") # 查找 cache 目录 (缓存目录) 是否存在,如果不存在则创建 + self.crop() + self.paste() + self.map_image.save(f'cache{os.sep}map.jpg', format='JPEG') + + def get_resource_count(self): + return len(self.resource_xy_list)