Compare commits
2 Commits
73dbcc683d
...
2758008e7b
| Author | SHA1 | Date | |
|---|---|---|---|
| 2758008e7b | |||
| abedad8877 |
+35
@@ -0,0 +1,35 @@
|
||||
# Python 缓存与编译产物
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# 测试与覆盖率产物
|
||||
tests/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
|
||||
# 类型检查与代码质量工具缓存
|
||||
.mypy_cache/
|
||||
.pyright/
|
||||
.ruff_cache/
|
||||
|
||||
# Python 虚拟环境
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# 编辑器与操作系统文件
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 本地日志和临时文件
|
||||
*.log
|
||||
*.tmp
|
||||
*.temp
|
||||
*.bak
|
||||
*.swp
|
||||
*~
|
||||
@@ -1,5 +1,19 @@
|
||||
## 更新记录
|
||||
|
||||
### version: 3.2.1:
|
||||
|
||||
统一封装 JX3BOX Node、Next2 和 CMS 三类接口请求,集中处理基础地址、GET/POST、请求参数、返回字段提取与异常日志。
|
||||
|
||||
修复资历菜单和点数缓存的刷新链路,缓存过期后分别请求 `/api/node/achievement/menus` 与 `/api/node/achievement/points`,上游失败时继续使用旧缓存。
|
||||
|
||||
刷马与赤兔后台推送改用 JX3BOX Next2 马场消息接口,并向后台任务管理器注入 `JX3BOXService`。
|
||||
|
||||
配装查询开始实际传递类型筛选参数。
|
||||
|
||||
新增项目级 `.gitignore`,忽略 Python 缓存、虚拟环境、日志、临时文件及整个 `tests/` 目录。
|
||||
|
||||
同步更新 README、插件注册信息和元数据版本号。
|
||||
|
||||
### version: 3.1:
|
||||
|
||||
增加副本记录查询功能。
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
|
||||
`astrbot_plugin_jx3` 通过 JX3API、剑侠茶馆、JX3BOX 等数据源查询《剑网3》游戏数据,并根据功能将结果发送为纯文本、图片、图文消息或两轮交互消息。插件同时提供本地避雷记录和开服、新闻、刷马、赤兔后台推送能力。
|
||||
|
||||
当前仓库版本为 **v3.1**,要求 **AstrBot >= 4.11.0**,元数据声明支持 `aiocqhttp` 与 `weixin_oc` 平台。
|
||||
当前仓库版本为 **v3.2.1**,要求 **AstrBot >= 4.11.0**,元数据声明支持 `aiocqhttp` 与 `weixin_oc` 平台。
|
||||
|
||||
> 本 README 以 v3.1 的 `main.py` 指令映射、`core/message.py` 参数签名和三个数据服务的实际实现为准。当前代码与内置帮助图之间存在少量差异,详见[当前版本状态](#当前版本状态)。
|
||||
> 本 README 以 v3.2.1 的 `main.py` 指令映射、`core/message.py` 参数签名和三个数据服务的实际实现为准。当前代码与内置帮助图之间存在少量差异,详见[当前版本状态](#当前版本状态)。
|
||||
|
||||
## 功能特点
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
- 支持本地 SQLite 避雷记录的增删改查。
|
||||
- 支持开服、新闻、刷马、赤兔四类定时轮询与会话推送。
|
||||
- 复用 `aiohttp.ClientSession`,统一处理 GET、POST、JSON、图片和分页请求。
|
||||
- JX3BOX 的 Node、Next2、CMS 请求统一封装,资历与交易行基础数据支持本地快照缓存和过期兜底。
|
||||
- 内置 46 个页面片段,通过公共布局与样式在本地组装为完整 HTML,并附带通用、门派/心法和奇遇图标资源。
|
||||
|
||||
## 数据来源
|
||||
@@ -30,7 +31,7 @@
|
||||
| --- | --- | --- |
|
||||
| [JX3API](https://www.jx3api.com/) | `core/jx3api_data.py` | 绝大多数游戏查询、官方资讯、排行、角色和推送数据 |
|
||||
| [剑侠茶馆](https://www.jianxiachaguan.cn/) | `core/aijx3_data.py` | 阵营沙盘图片 |
|
||||
| [JX3BOX](https://www.jx3box.com/) | `core/jx3box_data.py` | 奇遇攻略、配装、宏、资历和交易行 |
|
||||
| [JX3BOX](https://www.jx3box.com/) | `core/jx3box_data.py` | 奇遇攻略、配装、宏、资历、交易行及刷马/赤兔推送消息 |
|
||||
| 本地 SQLite | `core/sqlite.py`、`core/bilei_data.py` | 心法别名、避雷记录、推送状态和基础数据缓存 |
|
||||
|
||||
外部数据源的可用性、数据时效和字段结构均不由本插件控制。接口变更、网络异常、凭据权限不足或上游限流都可能导致查询失败。
|
||||
@@ -74,7 +75,7 @@ pip install -r data/plugins/astrbot_plugin_jx3/requirements.txt
|
||||
| `aiofiles` | 异步读取 HTML 模板 |
|
||||
| `aiosqlite` | 异步访问本地 SQLite 数据库 |
|
||||
| `apscheduler` | 后台轮询与消息推送调度 |
|
||||
| `matplotlib` | 当前依赖清单保留的绘图依赖;v3.1 业务代码未直接导入 |
|
||||
| `matplotlib` | 当前依赖清单保留的绘图依赖;v3.2.1 业务代码未直接导入 |
|
||||
|
||||
## 插件配置
|
||||
|
||||
@@ -371,7 +372,7 @@ flowchart LR
|
||||
|
||||
- `JX3APIService`:以 `https://www.jx3api.com` 为根地址,通过 GET 请求实现主体功能。
|
||||
- `AIJX3Service`:向剑侠茶馆发送 POST 请求,目前只负责沙盘图片。
|
||||
- `JX3BOXService`:直接访问 JX3BOX 的 Node、CMS、Next2 和图标地址,负责攻略、配装、宏、资历和交易行。
|
||||
- `JX3BOXService`:通过统一请求入口访问 JX3BOX 的 Node、CMS 和 Next2 服务,负责攻略、配装、宏、资历、交易行及刷马/赤兔推送消息。
|
||||
|
||||
服务方法通常返回统一结构:
|
||||
|
||||
@@ -399,6 +400,8 @@ flowchart LR
|
||||
- 可通过 `out_key` 提取响应中的指定字段。
|
||||
- 网络、HTTP、JSON 和业务码异常统一记录日志并返回 `None`。
|
||||
|
||||
业务服务在 `APIClient` 之上维护各自的基础请求方法。`JX3APIService` 以 `https://www.jx3api.com` 为固定根地址;`JX3BOXService._base_request()` 根据 `node`、`next2`、`cms` 数据源选择基础地址,并统一转发 GET/POST 参数和 `out` 返回字段。JX3BOX 业务代码只传接口路径,不再重复拼接完整域名。
|
||||
|
||||
### 5. 消息与图片渲染
|
||||
|
||||
`core/message.py` 根据业务结果选择输出方式:
|
||||
@@ -449,7 +452,7 @@ AstrBot 的渲染接口接收完整 HTML 字符串,因此插件不会依赖渲
|
||||
| `data/plugin_data.db` | 随插件分发,只读基础数据为主 | `kungfu` 心法名称、别名和 JX3BOX 配装 ID |
|
||||
| AstrBot 插件数据目录下的 `local_data.db` | 运行时创建和维护 | 避雷记录、推送状态、资历与交易行基础数据缓存 |
|
||||
|
||||
`achievement_cache` 同时被资历基础数据和交易行物品分组复用。缓存有效期为 30 天;缓存过期后优先刷新,上游请求失败时会尝试使用旧缓存兜底。
|
||||
`achievement_cache` 同时被资历基础数据和交易行物品分组复用。每个接口快照以一条 JSON 记录保存,当前使用 `achievement_menus`、`achievement_points` 和 `trade_item_groups` 三个键。缓存有效期为 30 天;缓存过期后优先全量刷新,上游请求失败时继续使用可解析的旧缓存兜底。资历菜单与点数的刷新接口分别为 JX3BOX Node 的 `/api/node/achievement/menus` 和 `/api/node/achievement/points`。
|
||||
|
||||
### 7. 后台推送
|
||||
|
||||
@@ -463,10 +466,13 @@ AstrBot 的渲染接口接收完整 HTML 字符串,因此插件不会依赖渲
|
||||
|
||||
调度任务取得业务数据后读取其中的 `status`。状态发生变化时,向所有 `umos` 发送 `data` 文本,并将新状态写回 `tuishong` 表。插件卸载时会移除全部任务并以非等待方式关闭调度器。
|
||||
|
||||
开服与新闻任务使用 `JX3APIService`;刷马与赤兔任务使用 `JX3BOXService.machangxiaoxi()` 请求 Next2 马场消息接口,分别传入 `horse/foreshow` 和 `chitu-horse/share_msg`,并把最新消息 ID 作为状态值避免重复推送。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
astrbot_plugin_jx3/
|
||||
├── .gitignore # Python、工具缓存、临时文件和 tests 忽略规则
|
||||
├── main.py # 插件入口、生命周期、数据库建表和指令分发
|
||||
├── metadata.yaml # 插件名称、版本、平台和 AstrBot 版本要求
|
||||
├── _conf_schema.json # AstrBot 管理面板配置结构
|
||||
@@ -486,21 +492,19 @@ astrbot_plugin_jx3/
|
||||
│ ├── sqlite.py # aiosqlite 通用封装
|
||||
│ ├── fun_basic.py # 图标、时间和货币格式化工具
|
||||
│ └── template.py # 模板组合、异步读取与内存缓存
|
||||
├── templates/
|
||||
│ ├── layouts/
|
||||
│ │ └── base.html # 唯一的完整 HTML 文档骨架
|
||||
│ ├── pages/
|
||||
│ │ └── *.html # 46 个页面内容与 Jinja2 数据绑定
|
||||
│ ├── styles/
|
||||
│ │ ├── tokens.css # 设计变量与页面宽度
|
||||
│ │ ├── base.css # 全局背景、外框和排版
|
||||
│ │ ├── components.css # 表格、网格、卡片、状态等公共组件
|
||||
│ │ └── pages/*.css # 可选的复杂页面独有布局(当前 13 个)
|
||||
│ ├── img/ # 通用图片资源
|
||||
│ ├── sect/ # 门派与心法图标
|
||||
│ └── serendipity/ # 奇遇图标
|
||||
└── tests/
|
||||
└── test_template_system.py # 46 个模板的组合、渲染与安全检查
|
||||
└── templates/
|
||||
├── layouts/
|
||||
│ └── base.html # 唯一的完整 HTML 文档骨架
|
||||
├── pages/
|
||||
│ └── *.html # 46 个页面内容与 Jinja2 数据绑定
|
||||
├── styles/
|
||||
│ ├── tokens.css # 设计变量与页面宽度
|
||||
│ ├── base.css # 全局背景、外框和排版
|
||||
│ ├── components.css # 表格、网格、卡片、状态等公共组件
|
||||
│ └── pages/*.css # 可选的复杂页面独有布局(当前 13 个)
|
||||
├── img/ # 通用图片资源
|
||||
├── sect/ # 门派与心法图标
|
||||
└── serendipity/ # 奇遇图标
|
||||
```
|
||||
|
||||
## 新增功能开发
|
||||
@@ -520,19 +524,19 @@ astrbot_plugin_jx3/
|
||||
|
||||
```bash
|
||||
python -m compileall -q .
|
||||
python -m unittest tests.test_template_system -v
|
||||
git diff --check
|
||||
```
|
||||
|
||||
模板测试会检查 46 个页面片段、13 个可选页面样式、公共组件按声明选择性注入、完整文档组合、Jinja2 基础渲染,以及模板路径能否阻止目录穿越。它和 `compileall` 仍不能替代带真实数据的 AstrBot 消息、HTML 渲染和外部接口联调。
|
||||
项目级 `.gitignore` 会忽略整个 `tests/`,本地自行维护的测试不会随插件分发。语法检查和差异检查仍不能替代带真实数据的 AstrBot 消息、HTML 渲染、后台推送和外部接口联调;发布前应在具备有效凭据的实际环境中覆盖成功、空数据、超时及上游异常路径。
|
||||
|
||||
## 当前版本状态
|
||||
|
||||
以下内容是对 v3.1 当前源码的静态核对结果,部署和二次开发前应注意:
|
||||
以下内容是对 v3.2.1 当前源码的静态核对结果,部署和二次开发前应注意:
|
||||
|
||||
1. `command_map` 实际注册 108 个触发词;`templates/pages/helps.html` 标注 105 条,并遗漏 `功能`、`小药`、`骗子`、`开服推送`。帮助图中的 `开服监控` 不是当前有效触发词。
|
||||
2. 默认服务器配置会用于四类后台任务,并只在 `烟花` 查询中通过 `serverdefault()` 显式补齐;其他可选服务器参数会原样传为空字符串。
|
||||
3. `JX3BOXService._get_achievement_base_data()` 当前调用了该类中未定义的 `_base_request()`,因此 `资历` 在用户选择分类后无法完成基础数据加载。
|
||||
4. 刷马和赤兔后台任务当前调用了 `JX3APIService` 中不存在的 `shuamamsg()`,启用 `smts` 或 `ctts` 后,定时回调会记录执行异常。
|
||||
2. 默认服务器配置用于刷马和赤兔后台任务;开服监控仍固定查询 `梦江南`,新闻任务不使用服务器参数。普通查询中只有 `烟花` 通过 `serverdefault()` 显式补齐默认服务器,其他可选服务器参数会原样传为空字符串。
|
||||
3. `JX3BOXService` 通过统一的 `_base_request()` 分发 Node、Next2 和 CMS 请求;资历菜单与点数缓存过期后分别从 `/api/node/achievement/menus` 和 `/api/node/achievement/points` 刷新。
|
||||
4. 刷马和赤兔后台任务已改用 JX3BOX Next2 马场消息接口;`machangxiaoxi()` 当前直接读取结果列表第一项,上游返回空列表时会由后台任务记录数据结构异常而不发送消息。
|
||||
5. `main.py` 仍计算 `data/jx3api_config.json` 路径,但仓库没有该文件,当前三个服务也不从该路径读取接口配置;接口地址直接维护在服务代码中。
|
||||
6. `APIClient` 当前默认 `ssl_verify=False`,即外部 HTTPS 请求不校验证书。对传输安全有要求的部署应先评估并调整该设置。
|
||||
7. JX3API 服务初始化时会把 Token 和 Ticket 写入 debug 日志。不要公开调试日志,建议二次开发时移除敏感值输出。
|
||||
|
||||
+8
-6
@@ -11,6 +11,7 @@ from astrbot.api import AstrBotConfig
|
||||
|
||||
|
||||
from .jx3api_data import JX3APIService
|
||||
from .jx3box_data import JX3BOXService
|
||||
from .sqlite import AsyncSQLiteDB
|
||||
|
||||
class AsyncTask:
|
||||
@@ -18,10 +19,11 @@ class AsyncTask:
|
||||
基于 APScheduler 的后台异步监控任务管理类
|
||||
"""
|
||||
|
||||
def __init__(self, context: Context, config: AstrBotConfig, jx3api: JX3APIService, sqlite: AsyncSQLiteDB):
|
||||
def __init__(self, context: Context, config: AstrBotConfig, jx3api: JX3APIService, jx3box: JX3BOXService, sqlite: AsyncSQLiteDB):
|
||||
self.context = context
|
||||
self.conf = config
|
||||
self.jx3fun = jx3api
|
||||
self.jx3api = jx3api
|
||||
self.jx3box = jx3box
|
||||
self.sql = sqlite
|
||||
|
||||
self.server = self.conf.get("server", "梦江南")
|
||||
@@ -107,10 +109,10 @@ class AsyncTask:
|
||||
|
||||
async def init_tasks(self):
|
||||
settings = [
|
||||
("kfts", "开服监控", lambda: self.jx3fun.kaifu("梦江南")),
|
||||
("xwts", "新闻资讯", lambda: self.jx3fun.xinwen(1)),
|
||||
("smts", "刷马消息", lambda: self.jx3fun.shuamamsg(self.server,type="horse",subtype="foreshow")),
|
||||
("ctts", "赤兔消息", lambda: self.jx3fun.shuamamsg(self.server,type="chitu-horse",subtype="share_msg")),
|
||||
("kfts", "开服监控", lambda: self.jx3api.kaifu("梦江南")),
|
||||
("xwts", "新闻资讯", lambda: self.jx3api.xinwen(1)),
|
||||
("smts", "刷马消息", lambda: self.jx3box.machangxiaoxi(self.server,"horse","foreshow")),
|
||||
("ctts", "赤兔消息", lambda: self.jx3box.machangxiaoxi(self.server,"chitu-horse","share_msg")),
|
||||
]
|
||||
|
||||
for key, name, fetch in settings:
|
||||
|
||||
+129
-29
@@ -38,6 +38,13 @@ ACHIEVEMENT_CHOICES = [
|
||||
|
||||
ACHIEVEMENT_CHOICE_MAP = {index: (menu_id, title) for index, menu_id, title in ACHIEVEMENT_CHOICES}
|
||||
|
||||
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):
|
||||
# 实例化 API Client
|
||||
@@ -67,28 +74,110 @@ class JX3BOXService:
|
||||
}
|
||||
|
||||
|
||||
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 machangxiaoxi(self, srever: str, type: str, subtype: str) -> Dict[str, Any]:
|
||||
"""马场消息 """
|
||||
return_data = self._init_return_data()
|
||||
|
||||
data = await self._base_request(
|
||||
"next2",
|
||||
"/api/game/reporter/horse",
|
||||
"GET",
|
||||
params={
|
||||
"pageIndex":1,
|
||||
"pageSize":1,
|
||||
"server":srever,
|
||||
"type":type,
|
||||
"subtype":subtype,
|
||||
},
|
||||
)
|
||||
|
||||
if data == None:
|
||||
return None
|
||||
else:
|
||||
data_list = data["list"][0]
|
||||
return_data["status"] = data_list["id"]
|
||||
return_data["data"] = (
|
||||
f"区服:{srever}\n"
|
||||
f"{data_list.get('content')}\n"
|
||||
f"时间:{data_list.get('created_at')}\n"
|
||||
)
|
||||
return_data["code"] = 200
|
||||
|
||||
return return_data
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
async def qiyugonglue(self, name: str) -> Dict[str, Any]:
|
||||
"""奇遇攻略"""
|
||||
return_data = self._init_return_data()
|
||||
|
||||
# 1. 调用基础请求
|
||||
api_url = "https://node.jx3box.com/serendipities"
|
||||
data = await self._api.get(api_url, params={"name": name}, out_key="list")
|
||||
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"]
|
||||
url = f"https://node.jx3box.com/serendipity/{dwID}/achievement"
|
||||
logger.debug(f"获取ID接口地址:{url}")
|
||||
|
||||
data1 = await self._api.get(url)
|
||||
url1 = f"https://cms.jx3box.com/api/cms/wiki/post/type/achievement/source/{data1['achievement_id']}"
|
||||
logger.debug(f"获取攻略接口地址:{url1}")
|
||||
data1 = await self._base_request(
|
||||
"node",
|
||||
f"/serendipity/{dwID}/achievement",
|
||||
out=None,
|
||||
)
|
||||
|
||||
# 获取奇遇攻略
|
||||
data2 = await self._api.get(url1, out_key="data")
|
||||
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
|
||||
@@ -134,7 +223,7 @@ class JX3BOXService:
|
||||
params = {
|
||||
"per": "10",
|
||||
"page": "1",
|
||||
"tags": "",
|
||||
"tags": tags,
|
||||
"client": "std",
|
||||
"global_level": "130",
|
||||
"mount": mount,
|
||||
@@ -142,8 +231,11 @@ class JX3BOXService:
|
||||
}
|
||||
|
||||
# 2. 调用基础请求
|
||||
api_url = "https://cms.jx3box.com/api/cms/app/pz"
|
||||
data: Optional[Dict[str, Any]] = await self._api.get(api_url, params=params, out_key="data")
|
||||
data: Optional[Dict[str, Any]] = await self._base_request(
|
||||
"cms",
|
||||
"/api/cms/app/pz",
|
||||
params=params,
|
||||
)
|
||||
|
||||
# 验证数据
|
||||
if not data:
|
||||
@@ -195,8 +287,11 @@ class JX3BOXService:
|
||||
params = {"subtype": kungfu}
|
||||
|
||||
# 2. 调用基础请求
|
||||
api_url = "https://cms.jx3box.com/api/cms/posts"
|
||||
data: Optional[Dict[str, Any]] = await self._api.get(api_url, params=params, out_key="data")
|
||||
data: Optional[Dict[str, Any]] = await self._base_request(
|
||||
"cms",
|
||||
"/api/cms/posts",
|
||||
params=params,
|
||||
)
|
||||
|
||||
if not data:
|
||||
return_data["msg"] = "未找到该心法一键宏"
|
||||
@@ -230,10 +325,7 @@ class JX3BOXService:
|
||||
"""宏 心法"""
|
||||
return_data = self._init_return_data()
|
||||
|
||||
url = f"https://cms.jx3box.com/api/cms/post/{pid}"
|
||||
logger.debug(f"获取宏接口地址:{url}")
|
||||
|
||||
data = await self._api.get(url, out_key="data")
|
||||
data = await self._base_request("cms", f"/api/cms/post/{pid}")
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return_data["msg"] = "获取宏数据异常"
|
||||
@@ -313,13 +405,13 @@ class JX3BOXService:
|
||||
logger.error(f"写入资历缓存失败: {e}")
|
||||
|
||||
|
||||
async def _get_achievement_base_data(self, cache_key: str, config_key: str) -> Optional[Dict[str, Any]]:
|
||||
async def _get_achievement_base_data(self, cache_key: str, api_path: str) -> Optional[Dict[str, Any]]:
|
||||
"""获取资历菜单或点数数据,优先使用未过期缓存"""
|
||||
cached, expired = await self._load_achievement_cache(cache_key)
|
||||
if cached and not expired:
|
||||
return cached
|
||||
|
||||
data: Optional[Dict[str, Any]] = await self._base_request(config_key, "GET")
|
||||
data: Optional[Dict[str, Any]] = await self._base_request("node", api_path)
|
||||
if data and isinstance(data, dict):
|
||||
await self._save_achievement_cache(cache_key, data)
|
||||
return data
|
||||
@@ -339,8 +431,11 @@ class JX3BOXService:
|
||||
return cached
|
||||
|
||||
|
||||
api_url = "https://cms.jx3box.com/api/cms/pvx/item/group"
|
||||
data = await self._api.get(api_url, params={"client":"std"}, out_key="data")
|
||||
data = await self._base_request(
|
||||
"cms",
|
||||
"/api/cms/pvx/item/group",
|
||||
params={"client": "std"},
|
||||
)
|
||||
if isinstance(data, list) and data:
|
||||
await self._save_achievement_cache(cache_key, data)
|
||||
return data
|
||||
@@ -485,10 +580,10 @@ class JX3BOXService:
|
||||
return_data["msg"] = "无法获取角色全区 ID"
|
||||
return return_data
|
||||
|
||||
achievement_data = await self._api.get(
|
||||
"https://next2.jx3box.com/api/next2/user-achievements",
|
||||
achievement_data = await self._base_request(
|
||||
"next2",
|
||||
"/api/next2/user-achievements",
|
||||
params={"jx3id": global_id},
|
||||
out_key="data",
|
||||
)
|
||||
if not achievement_data or not isinstance(achievement_data, dict):
|
||||
return_data["msg"] = "未查询到资历数据"
|
||||
@@ -507,11 +602,11 @@ class JX3BOXService:
|
||||
|
||||
menu_payload = await self._get_achievement_base_data(
|
||||
"achievement_menus",
|
||||
"jx3box_achievement_menus",
|
||||
"/api/node/achievement/menus",
|
||||
)
|
||||
point_payload = await self._get_achievement_base_data(
|
||||
"achievement_points",
|
||||
"jx3box_achievement_points",
|
||||
"/api/node/achievement/points",
|
||||
)
|
||||
if not menu_payload or not point_payload:
|
||||
return_data["msg"] = "基础资历数据获取失败"
|
||||
@@ -600,8 +695,13 @@ class JX3BOXService:
|
||||
"server": server,
|
||||
"aggregate_type": "hourly",
|
||||
}
|
||||
api_url = "https://next2.jx3box.com/api/auction/"
|
||||
price_data: Optional[List[Dict[str, Any]]] = await self._api.post(api_url, data=params, out_key="")
|
||||
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):
|
||||
|
||||
@@ -18,8 +18,8 @@ from .core.fun_basic import load_as_base64
|
||||
|
||||
@register("astrbot_plugin_jx3",
|
||||
"fxdyz",
|
||||
"通过调用剑网三API接口获取游戏数据,处理发送。",
|
||||
"3.2.0",
|
||||
"聚合剑网三游戏数据,提供查询、图片渲染、本地避雷和后台推送。",
|
||||
"3.2.1",
|
||||
"https://github.com/qsc20001102/astrbot_plugin_jx3"
|
||||
)
|
||||
class Jx3ApiPlugin(Star):
|
||||
@@ -154,7 +154,13 @@ class Jx3ApiPlugin(Star):
|
||||
self.jx3api = JX3APIService(self.conf, self.plugin_sql_db, self.local_sql_db)
|
||||
self.aijx3 = AIJX3Service(self.conf, self.plugin_sql_db, self.local_sql_db)
|
||||
self.jx3box = JX3BOXService(self.conf, self.plugin_sql_db, self.local_sql_db)
|
||||
self.jx3at = AsyncTask(cast(Context, self.context), self.conf, self.jx3api, self.local_sql_db)
|
||||
self.jx3at = AsyncTask(
|
||||
cast(Context, self.context),
|
||||
self.conf,
|
||||
self.jx3api,
|
||||
self.jx3box,
|
||||
self.local_sql_db,
|
||||
)
|
||||
self.jx3cmd = MessageBuilder(self.server, self.jx3api, self.aijx3, self.jx3box, self.bilei, self.jx3at, self.icons)
|
||||
|
||||
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
name: astrbot_plugin_jx3
|
||||
display_name: 剑网三游戏数据查询工具
|
||||
desc: 通过接口调用剑网三API接口获取游戏数据,处理发送。
|
||||
version: 3.2.0
|
||||
desc: 聚合剑网三游戏数据,提供查询、图片渲染、本地避雷和后台推送。
|
||||
version: 3.2.1
|
||||
author: 飞翔大野猪
|
||||
repo: https://github.com/qsc20001102/astrbot_plugin_jx3
|
||||
astrbot_version: ">=4.11.0"
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
import asyncio
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from jinja2 import ChainableUndefined, Environment
|
||||
|
||||
from core.template import TemplateRepository
|
||||
|
||||
|
||||
ROOT = Path(__file__).parent.parent
|
||||
TEMPLATE_ROOT = ROOT / "templates"
|
||||
SPECIAL_PAGE_STYLES = {
|
||||
"baizhan",
|
||||
"chengbeng",
|
||||
"chengjiu",
|
||||
"fubenjilu",
|
||||
"helps",
|
||||
"jiaoyihang",
|
||||
"juesheliaotian",
|
||||
"richangyuche",
|
||||
"wujia",
|
||||
"xingxiashijian",
|
||||
"zhanji",
|
||||
"zili",
|
||||
"zilipaixing",
|
||||
}
|
||||
|
||||
|
||||
class TemplateRepositoryTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.repository = TemplateRepository(TEMPLATE_ROOT)
|
||||
|
||||
def test_all_pages_compose_to_valid_jinja_documents(self):
|
||||
pages = sorted((TEMPLATE_ROOT / "pages").glob("*.html"))
|
||||
page_styles = sorted((TEMPLATE_ROOT / "styles" / "pages").glob("*.css"))
|
||||
self.assertEqual(46, len(pages))
|
||||
self.assertEqual(
|
||||
SPECIAL_PAGE_STYLES,
|
||||
{style.stem for style in page_styles},
|
||||
)
|
||||
|
||||
environment = Environment(undefined=ChainableUndefined)
|
||||
empty_context = {
|
||||
"data": {},
|
||||
"icons": {"img": {}, "sect": {}, "serendipity": {}},
|
||||
"items": [],
|
||||
"summary": {},
|
||||
}
|
||||
for page in pages:
|
||||
with self.subTest(page=page.name):
|
||||
fragment = page.read_text(encoding="utf-8")
|
||||
for document_tag in ("!DOCTYPE", "html", "head", "style", "body"):
|
||||
self.assertIsNone(
|
||||
re.search(rf"<{document_tag}(?:\s|>)", fragment, re.IGNORECASE)
|
||||
)
|
||||
|
||||
template = asyncio.run(self.repository.get(page.name))
|
||||
self.assertIn("<!DOCTYPE html>", template)
|
||||
self.assertIn("<body", template)
|
||||
self.assertIn("</body>", template)
|
||||
self.assertNotIn("<!--__PAGE_CONTENT__-->", template)
|
||||
self.assertNotIn("/*__PAGE_CSS__*/", template)
|
||||
self.assertNotIn("/* @component", template)
|
||||
self.assertNotIn("/* @endcomponent */", template)
|
||||
rendered = environment.from_string(template).render(empty_context)
|
||||
self.assertIn("</html>", rendered)
|
||||
|
||||
def test_repeated_page_patterns_use_shared_components(self):
|
||||
components = (TEMPLATE_ROOT / "styles" / "components.css").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
for selector in (
|
||||
".data-table",
|
||||
".data-cell--rank",
|
||||
".card-grid",
|
||||
".ability-card",
|
||||
".collection-card",
|
||||
".object-card",
|
||||
):
|
||||
self.assertIn(selector, components)
|
||||
|
||||
expected_components = {
|
||||
"bilei.html": "data-table",
|
||||
"rank_role.html": "data-cell--rank",
|
||||
"jineng.html": "ability-card",
|
||||
"juesheqiyu.html": "collection-card",
|
||||
"qiwu.html": "object-card",
|
||||
}
|
||||
for name, component in expected_components.items():
|
||||
with self.subTest(page=name):
|
||||
fragment = (TEMPLATE_ROOT / "pages" / name).read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertIn(component, fragment)
|
||||
page_style = TEMPLATE_ROOT / "styles" / "pages" / (
|
||||
f"{Path(name).stem}.css"
|
||||
)
|
||||
self.assertFalse(page_style.exists())
|
||||
|
||||
def test_image_templates_do_not_define_responsive_breakpoints(self):
|
||||
for style in sorted((TEMPLATE_ROOT / "styles").rglob("*.css")):
|
||||
with self.subTest(style=style.relative_to(TEMPLATE_ROOT)):
|
||||
content = style.read_text(encoding="utf-8")
|
||||
self.assertIsNone(
|
||||
re.search(r"@media\s*(?:screen\s+and\s+)?\([^)]*width", content)
|
||||
)
|
||||
|
||||
base = (TEMPLATE_ROOT / "styles" / "base.css").read_text(encoding="utf-8")
|
||||
self.assertIn("min-width: calc(var(--jx3-page-width) + 94px)", base)
|
||||
self.assertIn("width: var(--jx3-page-width)", base)
|
||||
self.assertNotIn("100vw", base)
|
||||
|
||||
def test_only_declared_component_blocks_are_injected(self):
|
||||
table_page = asyncio.run(self.repository.get("bilei.html"))
|
||||
self.assertIn(".data-table--spaced", table_page)
|
||||
self.assertNotIn(".ability-card__head", table_page)
|
||||
self.assertNotIn(".object-card__footer", table_page)
|
||||
|
||||
ability_page = asyncio.run(self.repository.get("jineng.html"))
|
||||
self.assertIn(".card-grid", ability_page)
|
||||
self.assertIn(".ability-card__head", ability_page)
|
||||
self.assertNotIn(".data-table--spaced", ability_page)
|
||||
self.assertNotIn(".object-card__footer", ability_page)
|
||||
|
||||
object_page = asyncio.run(self.repository.get("qiwu.html"))
|
||||
self.assertIn(".object-card__footer", object_page)
|
||||
self.assertNotIn(".ability-card__head", object_page)
|
||||
|
||||
def test_wujia_groups_follow_the_api_names_instead_of_list_positions(self):
|
||||
"""万宝楼分组在真实返回中位于列表末尾,且在售组名为“在售期”。"""
|
||||
template = asyncio.run(self.repository.get("wujia.html"))
|
||||
rendered = Environment(undefined=ChainableUndefined).from_string(template).render(
|
||||
{
|
||||
"icons": {"img": {}, "sect": {}, "serendipity": {}},
|
||||
"name": "浮屠明音礼盒",
|
||||
"alias": "",
|
||||
"view": "",
|
||||
"desc": "",
|
||||
"category": "外观礼盒",
|
||||
"date": "2018-08-24",
|
||||
"retail": 888,
|
||||
"list": [
|
||||
{"name": "电信区", "list": []},
|
||||
{"name": "双线区", "list": []},
|
||||
{"name": "无界区", "list": []},
|
||||
{
|
||||
"name": "梦江南",
|
||||
"list": [
|
||||
{
|
||||
"date": "QUERY-MARKER",
|
||||
"server": "梦江南",
|
||||
"value": 4700,
|
||||
"sale": 4,
|
||||
}
|
||||
],
|
||||
},
|
||||
{"name": "公示期", "list": []},
|
||||
{
|
||||
"name": "在售期",
|
||||
"list": [
|
||||
{
|
||||
"date": "SALE-MARKER",
|
||||
"zone": "电信区",
|
||||
"server": "梦江南",
|
||||
"value": 5188,
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
sale_start = rendered.index('<div class="table-card sale">')
|
||||
query_start = rendered.index('<div class="table-card query">')
|
||||
next_card = rendered.index('<div class="table-card">', query_start)
|
||||
sale_section = rendered[sale_start:query_start]
|
||||
query_section = rendered[query_start:next_card]
|
||||
|
||||
self.assertIn("SALE-MARKER", sale_section)
|
||||
self.assertNotIn("QUERY-MARKER", sale_section)
|
||||
self.assertIn("查询区服:梦江南", re.sub(r"\s+", "", query_section))
|
||||
self.assertIn("QUERY-MARKER", query_section)
|
||||
self.assertNotIn("SALE-MARKER", query_section)
|
||||
|
||||
def test_known_dark_theme_text_uses_shared_color_tokens(self):
|
||||
expected = {
|
||||
"jiaoyihang.css": (".item-name", ".price", ".sample"),
|
||||
"juesheliaotian.css": (".role-col", ".message-col"),
|
||||
"zilipaixing.css": (".force", ".role-name-col", ".server-col"),
|
||||
}
|
||||
for name, selectors in expected.items():
|
||||
with self.subTest(style=name):
|
||||
content = (TEMPLATE_ROOT / "styles" / "pages" / name).read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
for selector in selectors:
|
||||
block = re.search(
|
||||
rf"{re.escape(selector)}\s*\{{(?P<body>.*?)\}}",
|
||||
content,
|
||||
re.DOTALL,
|
||||
)
|
||||
self.assertIsNotNone(block)
|
||||
self.assertIn("var(--jx3-", block.group("body"))
|
||||
|
||||
def test_rejects_unknown_component_declaration(self):
|
||||
components = (TEMPLATE_ROOT / "styles" / "components.css").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
self.repository._select_component_styles(components, {"missing"})
|
||||
|
||||
def test_cache_returns_same_template_instance(self):
|
||||
first = asyncio.run(self.repository.get("helps.html"))
|
||||
shared_assets = self.repository._shared_assets
|
||||
second = asyncio.run(self.repository.get("helps.html"))
|
||||
self.assertIs(first, second)
|
||||
asyncio.run(self.repository.get("bilei.html"))
|
||||
self.assertIs(shared_assets, self.repository._shared_assets)
|
||||
|
||||
self.repository.clear()
|
||||
self.assertIsNone(self.repository._shared_assets)
|
||||
|
||||
def test_rejects_path_traversal(self):
|
||||
with self.assertRaises(ValueError):
|
||||
asyncio.run(self.repository.get("../helps.html"))
|
||||
|
||||
def test_help_page_catalogue_structure(self):
|
||||
fragment = (TEMPLATE_ROOT / "pages" / "helps.html").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
categories = re.findall(
|
||||
r'<div class="section-title">([^<]+)</div>', fragment
|
||||
)
|
||||
features = re.findall(r'<div class="feature(?:\s|\")', fragment)
|
||||
commands = re.findall(r'<span class="command">(.*?)</span>', fragment)
|
||||
|
||||
self.assertEqual(
|
||||
[
|
||||
"活动情报",
|
||||
"名剑相关",
|
||||
"排行榜单",
|
||||
"物价交易",
|
||||
"阵营战场",
|
||||
"角色名片",
|
||||
"奇遇相关",
|
||||
"百战记录",
|
||||
"角色信息",
|
||||
"资历相关",
|
||||
"心法配装",
|
||||
"游戏社区",
|
||||
"百度贴吧",
|
||||
"系统工具",
|
||||
"杂项功能",
|
||||
"避雷功能",
|
||||
"推送功能",
|
||||
],
|
||||
categories,
|
||||
)
|
||||
self.assertEqual(69, len(features))
|
||||
self.assertEqual(105, len(commands))
|
||||
self.assertEqual(105, len(set(commands)))
|
||||
self.assertIn("共 17 个功能分类 · 69 项功能 · 105 条指令", fragment)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user