Files
2026-09-07 15:48:05 +08:00

551 lines
18 KiB
Python

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