import json import html import re from datetime import datetime, timedelta from typing import Dict, Any, Optional, List, Union from inspect import isawaitable from typing import Any, Awaitable, Callable, Dict, Optional from astrbot.api import logger from astrbot.api import AstrBotConfig import astrbot.api.message_components as Comp from .request import APIClient from .sqlite import AsyncSQLiteDB from .fun_basic import load_template,gold_to_parts,week_to_num,compare_date_str,format_time,format_remaining JX3BOX_API_BASE_URLS = { "node": "https://node.jx3box.com", "next2": "https://next2.jx3box.com", "cms": "https://cms.jx3box.com", } class JX3BOXService: def __init__(self, config: AstrBotConfig, sqlite: AsyncSQLiteDB, cache_sqlite: Optional[AsyncSQLiteDB] = None): # 引用插件配置文件 self._config = config # 仅显式配置为 false 时关闭证书验证,旧配置默认安全开启。 self._api: APIClient = APIClient( ssl_verify=self._config.get("tls_verify", True) is not False ) # 引用sqlite self._sql_db = sqlite self._cache_db = cache_sqlite or sqlite self.token = self._config.get("jx3api_token", "") async def close(self): """释放底层 APIClient 资源""" if self._api: await self._api.close() def _init_return_data(self) -> Dict[str, Any]: """初始化标准的返回数据结构""" return { "code": 0, "msg": "功能函数未执行", "data": {}, "temp": "", "icons": {} } async def _base_request( self, source: str, api_path: str, method: str = "GET", params: Optional[Dict[str, Any]] = None, out: Optional[str] = "data", ) -> Optional[Any]: """统一封装 JX3BOX Node、Next2 和 CMS 接口请求。""" try: if not self._api: logger.error("API client is not initialized") return None base_url = JX3BOX_API_BASE_URLS.get(source) if not base_url: logger.error(f"不支持的 JX3BOX 数据源: {source}") return None normalized_path = api_path if api_path.startswith("/") else f"/{api_path}" api_url = f"{base_url}{normalized_path}" request_method = method.upper() if request_method == "GET": data = await self._api.get(api_url, params=params, out_key=out) elif request_method == "POST": data = await self._api.post(api_url, data=params, out_key=out) else: logger.error(f"不支持的 JX3BOX 请求方法: {request_method}") return None if not data: logger.warning(f"获取接口信息失败或返回空数据: {api_url}") return data except Exception as e: logger.error(f"JX3BOX 基础请求调用出错 ({source}:{api_path}): {e}") return None async def qiyugonglue(self, name: str) -> Dict[str, Any]: """奇遇攻略""" return_data = self._init_return_data() # 1. 调用基础请求 data = await self._base_request( "node", "/serendipities", params={"name": name}, out="list", ) if not data: return_data["msg"] = "未找到该奇遇" return return_data # 提取dwID dwID = data[0]["dwID"] data1 = await self._base_request( "node", f"/serendipity/{dwID}/achievement", out=None, ) # 获取奇遇攻略 data2 = await self._base_request( "cms", f"/api/cms/wiki/post/type/achievement/source/{data1['achievement_id']}", ) if not data2: return_data["msg"] = "获取攻略数据异常" return return_data # 4. 处理数据 try: return_data["data"] = {} content = data2["post"]["content"] return_data["temp"] = content except Exception as e: logger.exception("处理返回数据失败") return_data["msg"] = "处理返回数据失败" return return_data return_data["code"] = 200 return return_data async def peizhuang(self, name: str, tags: str) -> Dict[str, Any]: """配装""" return_data = self._init_return_data() # 数据库查询数据 result = await self._sql_db.select_one( "kungfu", "name=? OR name1=? OR name2=? OR name3=? OR name4=? OR name5=?", (name, name, name, name, name, name) ) logger.debug(result) if result is None: return_data["msg"] = "未找到该心法" return return_data mount = result.get("pzid", None) if not mount: return_data["msg"] = "未找到该心法" return return_data logger.debug(f"查询到数据:{mount}") # 1. 构造请求参数 params = { "per": "10", "page": "1", "tags": tags, "client": "std", "global_level": "130", "mount": mount, "star": "1" } # 2. 调用基础请求 data: Optional[Dict[str, Any]] = await self._base_request( "cms", "/api/cms/app/pz", params=params, ) # 验证数据 if not data: return_data["msg"] = "配装数据获取异常" return return_data # 3. 处理返回数据 try: result_msg = f"{name}--配装\n" for item in data["list"]: result_msg += f"【{item['zlp']}】--{item['title']}\n" result_msg += f"链接:https://www.jx3box.com/pz/view/{item['id']}\n\n" return_data["data"] = result_msg except Exception as e: logger.exception("处理返回数据失败:",e) return_data["msg"] = "处理返回数据失败" return return_data return_data["code"] = 200 return return_data async def _macro_posts(self, name: str) -> Optional[List[Dict[str, Any]]]: """按心法查询宏帖子,并保留接口热度排序。""" result = await self._sql_db.select_one( "kungfu", "name=? OR name1=? OR name2=? OR name3=? OR name4=? OR name5=?", (name, name, name, name, name, name) ) if result is None: return None kungfu = result.get("name", None) if kungfu is None: return None logger.debug(f"查询到数据:{kungfu}") data: Optional[Dict[str, Any]] = await self._base_request( "cms", "/api/cms/posts", params={"subtype": kungfu}, ) if not isinstance(data, dict): return None return [ item for item in data.get("list", []) if isinstance(item, dict) and item.get("ID") ] async def hong1(self, name: str) -> Dict[str, str]: """宏首轮:返回“序号 -> 显示内容”的选择数据。""" posts = await self._macro_posts(name) if not posts: raise ValueError("未找到该心法一键宏") try: options = { str(index): f"{post.get('author', '')}\t{post.get('post_title', '')}" for index, post in enumerate(posts, start=1) } if not options: raise ValueError("未找到该心法一键宏") except Exception as e: logger.exception("处理返回数据失败") if isinstance(e, ValueError): raise raise RuntimeError("处理返回数据失败") from e return options async def hong_select(self,name: str,selected: Dict[str, Any],) -> Dict[str, Any]: """宏次轮:根据单项选择数据重新定位帖子并查询详情。""" if not isinstance(selected, dict) or len(selected) != 1: return_data = self._init_return_data() return_data["msg"] = "宏选项数据格式异常" return return_data choice, label = next(iter(selected.items())) posts = await self._macro_posts(name) if not posts: return_data = self._init_return_data() return_data["msg"] = "未找到该心法一键宏" return return_data selected_post = next( ( post for post in posts if f"{post.get('author', '')}\t{post.get('post_title', '')}" == str(label) ), None, ) if selected_post is None: try: selected_post = posts[int(choice) - 1] except (TypeError, ValueError, IndexError): return_data = self._init_return_data() return_data["msg"] = "无效宏序号" return return_data pid = str(selected_post.get("ID") or "").strip() if not pid: return_data = self._init_return_data() return_data["msg"] = "宏帖子 ID 无效" return return_data return await self.hong2(pid) async def hong2(self, pid: str) -> Dict[str, Any]: """宏 心法""" return_data = self._init_return_data() data = await self._base_request("cms", f"/api/cms/post/{pid}") if not isinstance(data, dict): return_data["msg"] = "获取宏数据异常" return return_data try: # 文章正文 return_data["temp"] = data.get("post_content", "") # 安全获取宏数据,避免 KeyError macro_list = data.get("post_meta", {}).get("data", []) msg = "" for m in macro_list: msg += f"【宏名称】\n{m.get('name', '')}\n" msg += f"【使用说明】\n{m.get('desc', '')}\n" msg += f"【宏脚本】\n{m.get('macro', '')}\n\n" # 没有宏数据时返回文章信息 if not msg: msg = ( f"标题:{data.get('post_title', '')}\n" f"作者:{data.get('author', '')}\n" "该帖子没有宏数据" ) return_data["data"] = msg return_data["code"] = 200 except Exception: logger.exception("处理返回数据失败") return_data["msg"] = "处理返回数据失败" return return_data async def _load_trade_cache(self, key: str) -> tuple[Optional[Any], bool]: """读取交易行基础数据缓存,返回数据和是否已过期。""" try: row = await self._cache_db.select_one("trade_item_cache", "key=?", (key,)) except Exception as e: logger.error(f"读取交易行缓存失败: {e}") return None, True if not row: return None, True try: payload = json.loads(row.get("content", "{}")) updated_at = datetime.strptime(row.get("updated_at", ""), "%Y-%m-%d %H:%M:%S") expired = datetime.now() - updated_at > timedelta(days=30) return payload, expired except Exception as e: logger.error(f"解析交易行缓存失败: {e}") return None, True async def _save_trade_cache(self, key: str, payload: Any): """写入交易行基础数据缓存。""" try: await self._cache_db.execute( """ INSERT INTO trade_item_cache (key, content, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET content=excluded.content, updated_at=excluded.updated_at """, ( key, json.dumps(payload, ensure_ascii=False), datetime.now().strftime("%Y-%m-%d %H:%M:%S"), ), ) except Exception as e: logger.error(f"写入交易行缓存失败: {e}") async def _get_trade_item_groups(self) -> Optional[List[Dict[str, Any]]]: """获取交易行物品库,优先使用未过期缓存""" cache_key = "trade_item_groups" cached, expired = await self._load_trade_cache(cache_key) if isinstance(cached, list) and not expired: return cached data = await self._base_request( "cms", "/api/cms/pvx/item/group", params={"client": "std"}, ) if isinstance(data, list) and data: await self._save_trade_cache(cache_key, data) return data if isinstance(cached, list) and cached: logger.warning("交易行物品库接口失败,使用旧缓存") return cached return None def _flatten_trade_items(self, groups: List[Dict[str, Any]]) -> list[Dict[str, Any]]: """从交易行物品分组中提取可查询物品""" items = [] for group in groups: if not isinstance(group, dict): continue for item in group.get("items", []) or []: if not isinstance(item, dict): continue item_id = item.get("item_id") label = item.get("label") if not item_id or not label: continue items.append( { "item_id": str(item_id), "label": str(label), "icon": str(item.get("icon") or ""), } ) return items def _match_trade_items(self, items: list[Dict[str, Any]], keyword: str, limit: int = 50) -> list[Dict[str, Any]]: """按物品名模糊匹配交易行物品""" keyword = (keyword or "").strip() if not keyword: return [] matched = [] seen = set() for item in items: label = item.get("label", "") item_id = item.get("item_id", "") if keyword not in label or item_id in seen: continue seen.add(item_id) if label == keyword: rank = 0 elif label.startswith(keyword): rank = 1 else: rank = 2 matched.append((rank, len(label), label, item)) matched.sort(key=lambda row: (row[0], row[1], row[2])) return [row[3] for row in matched[:limit]] async def jiaoyihang(self, name: str , server: str) -> Dict[str, Any]: """区服交易行""" return_data = self._init_return_data() item_groups = await self._get_trade_item_groups() if not item_groups: return_data["msg"] = "交易行基础物品数据获取失败" return return_data trade_items = self._flatten_trade_items(item_groups) matched_items = self._match_trade_items(trade_items, name, 50) if not matched_items: return_data["msg"] = "未找到匹配的交易行物品" return return_data item_map = {item["item_id"]: item for item in matched_items} params = { "item_ids": list(item_map.keys()), "server": server, "aggregate_type": "hourly", } price_data: Optional[List[Dict[str, Any]]] = await self._base_request( "next2", "/api/auction/", method="POST", params=params, out="", ) if not price_data or not isinstance(price_data, list): return_data["msg"] = "未查询到交易行价格数据" return return_data try: result = [] for price_item in price_data: if not isinstance(price_item, dict): continue item_id = str(price_item.get("item_id") or "") base_item = item_map.get(item_id) if not base_item: continue timestamp = price_item.get("timestamp") try: created = datetime.fromtimestamp(int(timestamp)).strftime("%Y-%m-%d %H:%M:%S") except (TypeError, ValueError, OSError): created = "" result.append( { "item_id": item_id, "name": base_item.get("label", ""), "icon": f"https://icon.jx3box.com/icon/{base_item.get('icon', '')}.png", "server": price_item.get("server", server), "price": price_item.get("price", 0), "price_parts": gold_to_parts(price_item.get("price", 0)), "sample": price_item.get("sample", 0), "created": created, } ) if not result: return_data["msg"] = "未查询到交易行价格数据" return return_data return_data["data"] = { "search_name": name, "server": server, "matched_count": len(matched_items), "result_count": len(result), "list": result, "update_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), } except Exception as e: logger.error(f"处理交易行数据失败: {e}") return_data["msg"] = "处理交易行数据失败" return return_data # 5. 模板渲染 try: return_data["temp"] = await load_template("jiaoyihang.html") return_data["code"] = 200 except FileNotFoundError as e: logger.error(f"加载模板失败: {e}") return_data["msg"] = "系统错误:模板文件不存在" return return_data return return_data