Add HeXi bot codebase: custom plugins, web frontends, tests
- hexi core: message handling, rate limiting, cooldown, plugin manager - Custom plugins: BF stats, daily check-in, quotes, persona cards, etc. - Community plugins vendored under hexi/plugins with local fixes - Web admin frontends (learning-chat, persona-admin), unified hexi/web - Tests for rate_limit/cooldown/memes/persona; poetry.lock Co-Authored-By: Claude <noreply@anthropic.com>
@@ -1,176 +1,80 @@
|
||||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
# ---- Python / tooling ----
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
.venv/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
# ---- IDE ----
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
|
||||
# ---- 运行环境 ----
|
||||
.env
|
||||
.env.dev
|
||||
*.log
|
||||
|
||||
# ---- hexi/ 数据与缓存(运行时生成,不入库) ----
|
||||
/hexi/data/
|
||||
/hexi/logs/
|
||||
/hexi/data/cache/
|
||||
/hexi/data/temp/
|
||||
/hexi/data/multincm/
|
||||
|
||||
# ---- hexi/ 配置(保留 memes 用户配置;其余运行时数据) ----
|
||||
/hexi/config/_legacy/
|
||||
/hexi/config/nonebot_plugin_memes/
|
||||
|
||||
# ---- 数据库 ----
|
||||
*.db
|
||||
*.sqlite3
|
||||
hexi/plugins/user.db
|
||||
|
||||
# ---- 插件运行时私有数据 ----
|
||||
/hexi/plugins/nonebot_plugin_bf_bot/avatar_cache/
|
||||
/hexi/plugins/nonebot_plugin_bf_bot/cookies/
|
||||
/hexi/plugins/nonebot_plugin_bf_bot/data/
|
||||
/hexi/plugins/nonebot_plugin_dailywife/config/
|
||||
/hexi/plugins/nonebot_plugin_helldivers_tools/temp/
|
||||
/hexi/plugins/nonebot_plugin_video_analysis/data/
|
||||
/hexi/plugins/nonebot_plugin_mc_server_status/data/
|
||||
|
||||
# ---- multincm cwd 调试产物(第三方插件硬编码 Path.cwd()/debug) ----
|
||||
/debug/
|
||||
|
||||
# ---- 前端依赖 ----
|
||||
node_modules/
|
||||
|
||||
# ---- 插件运行时数据(补充) ----
|
||||
# playwright chrome_profile
|
||||
/hexi/plugins/nonebot_plugin_picfinder_take/data/
|
||||
# 群聊数据 json
|
||||
/hexi/plugins/nonebot_plugin_deadlock/data/
|
||||
# QQ 头像缓存
|
||||
/hexi/plugins/nonebot_plugin_group_tools/res/img/memavatar/
|
||||
# 网页存档
|
||||
/hexi/plugins/nonebot_plugin_helldivers_tools/img/assets/wiki assets/
|
||||
# 调用统计/备份
|
||||
/hexi/plugins/nonebot_plugin_steam_info/data/
|
||||
|
||||
# ---- 临时文件 ----
|
||||
*.tmp
|
||||
*.bak
|
||||
bg.jpg
|
||||
# 统一 Web 前端
|
||||
/hexi/web/node_modules/
|
||||
/hexi/web/dist/
|
||||
# HeroUI dev docs
|
||||
/.heroui-docs/
|
||||
|
||||
# 插件配置运行时存储(含敏感值,勿提交)
|
||||
/hexi/config/plugin_config*.json
|
||||
/.ai/
|
||||
/.claude/
|
||||
/hexi/config/
|
||||
/CLAUDE.md
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import nonebot
|
||||
from nonebot.adapters.onebot.v11 import Adapter as ONEBOTV11Adapter
|
||||
from nonebot.log import logger
|
||||
from sqlalchemy import StaticPool
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
|
||||
# 初始化 NoneBot 以及 数据库
|
||||
nonebot.init(datastore_engine_options={"poolclass": StaticPool})
|
||||
|
||||
# 获取应用和驱动实例
|
||||
app = nonebot.get_app()
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"http://localhost:5173",
|
||||
"http://192.168.2.186:5173",
|
||||
"http://127.0.0.1:5173"
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
driver = nonebot.get_driver()
|
||||
|
||||
# 注册适配器
|
||||
driver.register_adapter(ONEBOTV11Adapter)
|
||||
|
||||
# 加载插件
|
||||
nonebot.load_plugins("hexi")
|
||||
nonebot.load_from_toml("pyproject.toml")
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.warning("hexi?启动!")
|
||||
nonebot.run() # 直接运行,不需要传参
|
||||
@@ -0,0 +1,433 @@
|
||||
# 插件配置文件标准(Plugin Config Standard)
|
||||
|
||||
> HeXi 统一插件配置标准 —— 机器可读、带类型的 schema 声明 + 持久化值文件,
|
||||
> 让 Web 管理台(/hub)能对任意插件**读取、展示、修改、热刷新、重启生效**。
|
||||
>
|
||||
> 插件整体骨架/目录/入口/职责划分见《插件结构标准》(docs/plugin-structure-standard.md)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 目标与一句话总结
|
||||
|
||||
- **核心原则**:插件只声明 `schema`,Web 自动生成表单;值统一写入值库;插件通过统一 API 读取生效值。
|
||||
- **一句话**:插件在导入时调用 `register_plugin_config(plugin_id, schema, apply, getter)` 声明配置;值写入 `hexi/config/plugin_config.json`;Web 端读 `{schema, values}` 渲染、保存后写回并热应用。
|
||||
|
||||
- **`plugin_id` 约定**:必须等于 NoneBot 插件模块名(如 `hexi.plugins.nonebot_plugin_helldivers_tools`)。Web 的 `/api/plugins/<id>/config` 才能命中,插件目录合表也用该 id。
|
||||
|
||||
---
|
||||
|
||||
## 1. 相关文件与位置
|
||||
|
||||
```text
|
||||
hexi/
|
||||
├── web_config.py # 配置标准核心:schema 注册 / 值库 / 保存热刷新
|
||||
├── config_standard.py # pydantic Config 一键接入的辅助封装
|
||||
├── config/
|
||||
│ └── plugin_config.json # 统一值库:{ "<plugin_id>": { key: value, ... } }
|
||||
└── web/ # /hub 前端(通用 schema 表单渲染器)
|
||||
```
|
||||
|
||||
> 值库按**插件聚合**,不每个插件一个文件:Web「插件管理」一页聚合所有插件配置、统一热刷新、统一迁移。
|
||||
|
||||
---
|
||||
|
||||
## 2. Schema 字段标准
|
||||
|
||||
每个插件声明一份 schema:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"version": 1, // 插件配置 schema 版本(用于迁移)
|
||||
"fields": [
|
||||
{
|
||||
"key": "hd2_api_base_url", // 必填,插件读取的字段名(snake_case)
|
||||
"label": "API 根地址", // 必填,前端显示名
|
||||
"type": "string", // 必填,见 §3
|
||||
"default": "https://api.helldivers2.dev",
|
||||
"description": "自托管 API 根地址;不填用公共社区 API。",
|
||||
"secret": false, // true → 前端脱敏/掩码
|
||||
"required": false,
|
||||
"placeholder": "http://192.168.2.15:18080",
|
||||
"env": "HD2_API_BASE_URL", // 写回 .env 的变量名;缺省 = key.upper()
|
||||
"options": [ // type=enum 时必填
|
||||
{ "value": "auto", "label": "自动" },
|
||||
{ "value": "manual", "label": "手动" }
|
||||
],
|
||||
"min": 0, "max": 86400, // int/float 范围(可选)
|
||||
"pattern": "^https?://", // 字符串校验(可选)
|
||||
"group": "API 连接", // 表单分组(可选)
|
||||
"depends_on": { "field": "enabled", "value": true }, // 条件显示(可选)
|
||||
"item_type": "int", // type=list 的元素类型 int/float/str/bool(可选)
|
||||
"newline_list": true // type=list 时:多行/逗号分隔(可选)
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `key` | string | 是 | 插件读取字段名,snake_case,唯一 |
|
||||
| `label` | string | 是 | 前端显示名(中文) |
|
||||
| `type` | string | 是 | 见 §3 |
|
||||
| `default` | any | 否 | 用户未改时的回填默认 |
|
||||
| `description` | string | 否 | 前端提示 |
|
||||
| `secret` | bool | 否 | 敏感字段前端掩码,返回时脱敏 |
|
||||
| `required` | bool | 否 | 保存时校验非空 |
|
||||
| `options` | array | enum 必填 | `[{value,label}]` |
|
||||
| `env` | string | 否 | 写 .env 变量名;缺省 `key.upper()` |
|
||||
| `min` / `max` | number | 否 | 数值范围 |
|
||||
| `pattern` | string | 否 | 字符串正则 |
|
||||
| `item_type` | string | list 推荐 | 元素类型 |
|
||||
| `newline_list` / `group` / `depends_on` | - | 否 | 展示/校验增强 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 支持的 type
|
||||
|
||||
| type | 前端控件 | 存储 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `string` | 单行输入 | 字符串 | 通用文本 |
|
||||
| `text` | 多行文本域 | 字符串(支持 \n) | 长文本/提示词/模板 |
|
||||
| `password` | 密码框(掩码) | 字符串 | 自动 `secret:true` |
|
||||
| `int` | 数字输入 | 整数 | |
|
||||
| `float` | 数字输入 | 浮点数 | |
|
||||
| `bool` | 开关 | 布尔 | |
|
||||
| `enum` | 下拉 | `options[].value` | 必须提供 options |
|
||||
| `list` | 多行文本(每行一项) | 数组 | 需 `item_type`;兼容逗号分隔 |
|
||||
| `json`/object | 代码文本域 | JSON 对象(校验后存) | 高级 |
|
||||
| `path` | 单行输入 | 路径字符串 | 运行期转 `Path` |
|
||||
| `object_set` | 通用表格(增删改) | list[dict] | 一组结构化条目;需 `item_schema` + `key_field`,见 §14 |
|
||||
|
||||
**约定**
|
||||
- `key` 一律 snake_case。
|
||||
- `secret` 字段只返回 `****`,绝不返回明文;提交未修改的 `****` 表示保持原值,只有提交 `null` 才清除。
|
||||
- 复杂类型(`list`/`path`/`object`/`object_set`)在 `.env` 里不易安全表达,**只做运行期热更新**,不落 `.env`;重启需插件自行从值库读取。
|
||||
|
||||
---
|
||||
|
||||
## 4. 值文件存储标准
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"hexi.plugins.nonebot_plugin_helldivers_tools": {
|
||||
"hd2_api_base_url": "http://192.168.2.15:18080",
|
||||
"hd2_api_language": "zh-Hans",
|
||||
"hd2_cache_ttl": 60,
|
||||
"hd2_super_client": "hexi-bot.local",
|
||||
"hd2_super_contact": "https://github.com/..."
|
||||
},
|
||||
"hexi.plugins.nonebot_plugin_steam_info": {
|
||||
"steam_api_key": ["key1", "key2"],
|
||||
"steam_request_interval": 300,
|
||||
"steam_broadcast_type": "part",
|
||||
"steam_playtime_theme": "light",
|
||||
"proxy": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 键 = `plugin_id`;值 = 仅**用户显式设置过**的字段(未设置不写死,避免覆盖 .env)。
|
||||
- `null` 表示「清空/恢复默认」。
|
||||
- **原子写入**:先写临时文件再 `os.replace`;启动读取失败时保留原文件并告警,不得静默覆盖为空。
|
||||
- 读取优先级:**用户值库 > getter 生效值 > schema default**。
|
||||
- 保存校验失败返回 HTTP 422,不能静默把非法输入替换成默认值。
|
||||
- 值库按插件保存 `revision`;保存必须携带当前 revision,冲突返回 HTTP 409。
|
||||
|
||||
---
|
||||
|
||||
## 5. 插件读取配置的标准 API
|
||||
|
||||
### 方式 A:pydantic Config 一键接入(推荐)
|
||||
|
||||
```python
|
||||
# 在插件 __init__.py 里
|
||||
from hexi.config_standard import register_model_config
|
||||
from .config import config
|
||||
|
||||
register_model_config(
|
||||
__name__, # = NoneBot 插件模块名
|
||||
config, # 模块级 pydantic Config 实例
|
||||
fields=["hd2_api_base_url", "hd2_cache_ttl", ...],
|
||||
labels={"hd2_api_base_url": "API 根地址", ...},
|
||||
descriptions={...},
|
||||
options={"mode": [{"value": "auto", "label": "自动"}]},
|
||||
types={"mode": "enum"}, # 覆盖自动推断的 type
|
||||
)
|
||||
```
|
||||
|
||||
运行期插件直接读 `config.hd2_api_base_url`。
|
||||
|
||||
`register_model_config` 自动:
|
||||
- 推断字段类型(`bool/int/float/string/enum/text`);
|
||||
- `Literal[...]` → 下拉枚举;`list[str]`/`list[int]` → 多行文本并按元素类型转换;
|
||||
- 内置 getter(回填当前生效值) 与 apply(保存后热更新 pydantic 对象,无需重启);
|
||||
- 密码/key/token 类字段自动 `secret=True`。
|
||||
|
||||
### 方式 B:非 pydantic 插件手动注册
|
||||
|
||||
```python
|
||||
from hexi.web_config import register_plugin_config
|
||||
|
||||
def _get(): # 返回当前生效值 dict
|
||||
return {"field": get_my_cur_value("field")}
|
||||
|
||||
def _apply(values): # 保存后热应用
|
||||
set_my_config(values)
|
||||
|
||||
register_plugin_config(
|
||||
__name__,
|
||||
{"fields": [{"key": "field", "label": "字段", "type": "string", "default": ""}]},
|
||||
apply=_apply,
|
||||
getter=_get,
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> **同一插件可多次注册**:`register_plugin_config` / `register_model_config` / `register_config_items` / `register_object_set` 对同一 `plugin_id` 重复调用时,**字段按 key 自动合并到一个 schema**,getter 合并取值,apply 按注册顺序依次应用(不覆盖)。所以 `config.py` 里分多段注册(如 temp 清理 / S3 / 群分组 / object_set)是合理且推荐的。
|
||||
|
||||
## 6. 值读取优先级
|
||||
|
||||
`get_config(plugin_id)` 对每个字段按序取值:
|
||||
|
||||
1. **用户已保存值**(值库 `plugin_config.json`);
|
||||
2. **getter 返回的当前生效值**(插件运行态);
|
||||
3. **schema.default**;
|
||||
4. `None`。
|
||||
|
||||
这样 Web 表单打开时显示的是**插件当前真正生效的配置**,而不是只看默认值。
|
||||
|
||||
---
|
||||
|
||||
## 7. 保存流程(热刷新)
|
||||
|
||||
`save_config(plugin_id, values)`:
|
||||
|
||||
1. 按 schema 字段的 type/`item_type` 做 **类型校验与转换**(string/int/float/bool/enum/list)。
|
||||
2. **写值库** `plugin_config.json`(原子写)。
|
||||
3. **写 .env**:非 list 字段写入 `os.environ` + `.env`(保证重启仍生效);list/path/object 跳过,避免 `str(list)` 破坏重启解析。
|
||||
4. **调 `apply(values)`**:把值热应用到插件运行态对象(list/path/object 也在此生效)。
|
||||
5. 若插件未提供 apply,则 `hot_reload(plugin_id)` 让插件重载。
|
||||
|
||||
**为什么 apply 与 env 都做**:NoneBot 的 `get_driver().config` 在启动时即固定,重载插件也读不到新 env;所以运行期必须 apply,重启靠 env。
|
||||
|
||||
---
|
||||
|
||||
## 8. Web 端「读 / 改」标准接口(/hub)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/plugins` | 所有已注册 Web 插件 |
|
||||
| GET | `/api/plugins/catalog` | 所有应用插件 + `has_config/has_web/web_path` |
|
||||
| GET | `/api/plugins/{id}/config` | 读 `{schema, values, revision}`(登录);secret 值只返回 `****` |
|
||||
| POST | `/api/plugins/{id}/config` | 保存 `{ "revision": n, "values": { key: value } }`;冲突 409,校验失败 422 |
|
||||
| DELETE | `/api/plugins/{id}/config` | 清空该插件覆盖,恢复默认(可选) |
|
||||
|
||||
响应示例(GET):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"ok": true,
|
||||
"revision": 3,
|
||||
"schema": { "fields": [ { "key": "hd2_cache_ttl", "type": "int", "default": 60, ... } ] },
|
||||
"values": { "hd2_cache_ttl": 60, "hd2_api_base_url": "http://192.168.2.15:18080" }
|
||||
}
|
||||
```
|
||||
|
||||
前端 `hexi/web/src/pages/plugins/index.tsx` 已按 `type` 自动渲染:bool→开关、enum→下拉、text→多行、password→掩码、int/float→数字、secret→脱敏。
|
||||
|
||||
---
|
||||
|
||||
## 9. 权限与安全
|
||||
|
||||
- 全部配置接口走 `hexi.web_auth.require_admin`(OAuth2 + SQLite)。
|
||||
- 配置 POST 必须携带 GET 返回的 `revision`;缺失返回 428,冲突返回 409。
|
||||
- 只允许 `plugin_id` 存在于注册表,未注册返回 `ok:false`(防任意写入)。
|
||||
- 部署时必须显式设置 Web 管理员凭据;禁止生产环境使用默认的 `admin/admin`。
|
||||
- `secret` 字段只返回 `****`;提交 `****` 保持原值,提交 `null` 清除,写回明文仅限服务端受控存储。
|
||||
- 写 .env 用「原行更新/追加」,保留注释,不整文件覆盖。
|
||||
|
||||
---
|
||||
|
||||
## 10. 版本与迁移
|
||||
|
||||
配置保存状态至少区分:`persisted`(值库已保存)、`applied`(运行态已应用)、`restart_required`(需重启)和 `apply_failed`(应用失败)。apply 失败时接口必须返回错误,且保留上一份可恢复配置。
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema_version": 3,
|
||||
"plugins": {
|
||||
"hexi.plugins.xxx": {
|
||||
"schema_version": 1,
|
||||
"values": { "...": "..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 升级 schema 时保留 `values`:新增字段取 `default`,删除字段清理。
|
||||
- 提供 `migrate(plugin_id, old_schema, new_schema)` 钩子,支持字段改名/类型升级/`str` 列表迁 `list[int]`;迁移必须幂等,失败时保留旧值文件并阻止该插件配置加载。
|
||||
- 读到未知插件条目时保留并上报,不强制删除。
|
||||
|
||||
---
|
||||
|
||||
## 11. 落地示例
|
||||
|
||||
```python
|
||||
# hexi/plugins/nonebot_plugin_helldivers_tools/__init__.py
|
||||
from hexi.config_standard import register_model_config
|
||||
from .config import config as _hd2_config
|
||||
|
||||
register_model_config(
|
||||
__name__,
|
||||
_hd2_config,
|
||||
fields=["hd2_api_base_url", "hd2_api_language", "hd2_cache_ttl",
|
||||
"hd2_super_client", "hd2_super_contact"],
|
||||
labels={"hd2_api_base_url": "API 根地址", "hd2_cache_ttl": "数据缓存秒数", ...},
|
||||
descriptions={"hd2_api_base_url": "自托管 API 根地址;不填用公共社区 API", ...},
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 已接入与待接入
|
||||
|
||||
- **已接入**:`helldivers_tools`、`mc_server_status`、`video_analysis`、`steam_info`、`picstatus`、`galgame_card`。
|
||||
- **未接入(建议后续)**:`learning_chat`、`group_daily_analysis`(已有独自 Web 配置,避免冲突)、`picfinder_take`、`bf_bot`、`group_tools`、`hexi_core`(模块级常量/SUPERUSERS,运行期热更复杂)。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 13. 插件内部配置项(来源无关)接入标准
|
||||
|
||||
> **不只是 `.env`**:插件内部自己的配置项(module 常量、YAML/JSON 配置、数据库里的开关等)同样遵循统一 schema,Web 可读可改。核心是让每个配置项自带 **getter/setter**,与来源解耦。
|
||||
|
||||
### 13.1 四种来源与接入方式
|
||||
|
||||
| 配置来源 | 接入方式 | getter/setter | 持久化 | 运行期生效点 |
|
||||
|---|---|---|---|---|
|
||||
| pydantic Config(`.env` 驱动) | `register_model_config` | 自动(读写 Config 对象) | 写值库 + 写 `.env` | apply 热更新对象 |
|
||||
| 模块常量 | `register_config_items(..., store=模块)` | 自动(getattr/setattr) | 写值库(可选写 `.env`) | 需插件用 `get_effective_value` 读取 |
|
||||
| 配置文件(dict/JSON/YAML) | `register_config_items(..., store=dict)` | 自动(读写 dict) | 插件自身写回文件 / 值库 | 插件从 dict 读取时即生效 |
|
||||
| 自定义(DB/运行态) | `register_config_items` 传 `getter`/`setter` | 自定义 | 自定义回写 | 自定义 |
|
||||
|
||||
### 13.2 通用注册 API(任意来源)
|
||||
|
||||
```python
|
||||
from hexi.config_standard import register_config_items
|
||||
|
||||
register_config_items(
|
||||
__name__, # = NoneBot 插件模块名
|
||||
[
|
||||
{"key": "DAILY_LIMIT", "label": "每日限额", "type": "int", "default": 50},
|
||||
{"key": "CHECK", "label": "开启截屏判定", "type": "bool", "default": True},
|
||||
{"key": "SAUCENAO_KEY", "label": "API Key", "type": "password", "secret": True},
|
||||
# 也可为某条目自定义 getter/setter
|
||||
{"key": "custom", "label": "自定义", "type": "string",
|
||||
"getter": lambda: my_get(), "setter": lambda v: my_set(v)},
|
||||
],
|
||||
store=config_module, # dict 或模块;未写 getter/setter 时默认读写这里
|
||||
apply_extra=None, # 可选:保存后做额外持久化/热加载
|
||||
getter_extra=None, # 可选:getter 额外返回非 items 的值
|
||||
)
|
||||
```
|
||||
|
||||
### 13.3 关键规则:运行期要生效,插件必须读生效值
|
||||
|
||||
Web 保存后 apply 会把新值写回来源(模块属性/dict/自定义),但若插件在别处是用 `from .config import X` **值拷贝**进来的量,不受影响。要真正运行期生效,插件在读配置处改用统一 API:
|
||||
|
||||
```python
|
||||
from hexi.web_config import get_effective_value
|
||||
|
||||
limit = get_effective_value("hexi.plugins.nonebot_plugin_picfinder_take", "DAILY_LIMIT", 50)
|
||||
if not check_quota(limit): ...
|
||||
```
|
||||
|
||||
优先级:**用户值库 > getter 生效值 > default**。这样 Web 改完即可生效,重启也不丢。
|
||||
|
||||
### 13.4 已接入的非 env 示例
|
||||
|
||||
- `picfinder_take`:把 `SAUCENAO_KEY / DAILY_LIMIT / SEARCH_TIMEOUT / CHECK / THUMB_ON / CHAIN_REPLY / IGNORE_STAMP / threshold` 这些内部常量注册为 Web 可读可改配置项(store=config 模块)。
|
||||
|
||||
- `video_analysis`:把原本硬编码在 `storage/s3.py` 的 S3 连接配置(PLANA/PLANB/PLANC/公网 endpoint/access/secret/bucket/region/secure/domain)注册为 Web 可读可改项;apply 时清空懒加载 S3 客户端缓存,且 s3 客户端创建时经 `get_effective_value` 读取,因此**运行期与重启均生效**。另用 `type=json` 暴露 `data/list.json` 的**群分组配置**(`groups` + `blacklist`),自定义 getter/setter 同步读写,并标记 `nosave`(不写入 plugin_config.json,权威源是 list.json),避免与 `添加白名单` 等指令互相覆盖为陈旧值。
|
||||
|
||||
- **生效说明**:注册后 Web 能读、能改、会持久化;若希望改完立刻反映到插件行为,需把插件内对应读取点改成 `get_effective_value`(见 §13.3),或让 apply_extra 里 `hot_reload` 并让 config 模块从值库初始化。
|
||||
|
||||
---
|
||||
|
||||
## 14. 通用对象集标准(object_set)
|
||||
|
||||
> 当插件要暴露「一组结构化条目」时(群分组、测速站点、服务器列表、白/黑名单成员、装备表等),用 `object_set` 类型声明 **item_schema + key_field**,Web 端通用渲染成可增删改的表格,后端通用按子字段校验/去重。
|
||||
|
||||
### 14.1 Schema 形态
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"key": "groups",
|
||||
"label": "群分组配置",
|
||||
"type": "object_set",
|
||||
"key_field": "group_id", // 子字段主键(定位/去重/删除行)
|
||||
"item_schema": {
|
||||
"fields": [
|
||||
{"key": "group_id", "label": "群号", "type": "string", "required": true},
|
||||
{"key": "auto", "label": "自动解析", "type": "bool", "default": false},
|
||||
{"key": "plan", "label": "存储方案", "type": "enum",
|
||||
"options": [{"value":"A","label":"A"},{"value":"B","label":"B"}]},
|
||||
{"key": "auto_link", "label": "自动链接关键词", "type": "text", "item_type": "str"}
|
||||
]
|
||||
},
|
||||
"default": [],
|
||||
"nosave": true // 权威源在插件自身文件/DB 时填 true
|
||||
}
|
||||
```
|
||||
|
||||
### 14.2 值形态
|
||||
|
||||
```jsonc
|
||||
[
|
||||
{"group_id":"123", "auto":true, "plan":"A", "auto_link":["xhs"]},
|
||||
{"group_id":"456", "auto":false, "plan":null, "auto_link":[]}
|
||||
]
|
||||
```
|
||||
|
||||
### 14.3 一键注册 API
|
||||
|
||||
```python
|
||||
from hexi.config_standard import register_object_set
|
||||
|
||||
register_object_set(
|
||||
__name__, # NoneBot 插件模块名
|
||||
"groups", # 对象集配置 key
|
||||
[ # item_schema 子字段
|
||||
{"key": "group_id", "label": "群号", "type": "string"},
|
||||
{"key": "auto", "label": "自动解析", "type": "bool", "default": True},
|
||||
{"key": "plan", "label": "方案", "type": "enum",
|
||||
"options": [{"value":"A","label":"A"},{"value":"B","label":"B"}]},
|
||||
{"key": "auto_link", "label": "关键词", "type": "text", "item_type": "str"},
|
||||
],
|
||||
key_field="group_id",
|
||||
getter=get_groups, # () -> list[dict]
|
||||
setter=set_groups, # (list[dict]) -> None(插件自行写回来源)
|
||||
label="群分组配置",
|
||||
nosave=True, # 权威源在 list.json/DB,不写 plugin_config.json
|
||||
)
|
||||
```
|
||||
|
||||
框架自动做:**按 `item_schema` 逐字段类型转换**(bool/int/float/string/enum/text/list/json)、触发 `_coerce_value` 的值转成目标类型、**按 `key_field` 去重并丢弃缺主键行**,再把归一整形的 list 交给你的 setter。
|
||||
|
||||
### 14.4 Web 端通用表格组件(建议)
|
||||
|
||||
- 前端新增 `ObjectSetEditor` 组件,读 `field.item_schema.fields` 渲染表头 + 每行输入(与 §3 type 一一对应);
|
||||
- 支持:**添加行、删除行、编辑单元格、上移/下移**(可选);主键列默认唯一校验;
|
||||
- 保存时把整个 list 提交到既有 `POST /api/plugins/{id}/config`,无需新接口;
|
||||
- 也可按需加细粒度:`POST /api/plugins/{id}/config/{key}` 单条 upsert、`DELETE .../{key}/{key_value}` 删除单行(后端需在 apply 里实现 upsert/delete)。
|
||||
|
||||
### 14.5 可接入示范(候选)
|
||||
|
||||
- `video_analysis`:`list.json` 的 `groups` 群分组(从纯 JSON 升级为结构化 object_set,Key=group_id);
|
||||
- `picstatus`:`ps_test_sites` 测速站点列表(Key=name);
|
||||
- `mc_server_status`:`var.group_list` 每群服务器列表(Key=群号);
|
||||
- `helldivers_tools`:战备数据(可只读展示,来源 SQLite,Key=id);
|
||||
- `learning_chat`:分群配置(Key=group_id)。
|
||||
@@ -0,0 +1,156 @@
|
||||
# 插件结构标准(Plugin Structure Standard)
|
||||
|
||||
> HeXi 所有自定义插件的统一骨架:包命名、目录布局、入口/配置/工具/服务/数据/Web 职责划分。
|
||||
> 配合《插件配置文件标准》(plugin-config-standard.md) 使用:结构标准管「怎么组织代码」,配置标准管「配置如何统一并暴露给 Web」。
|
||||
|
||||
---
|
||||
|
||||
## 0. 目标
|
||||
|
||||
- 让新插件**开箱即用**:照骨架抄一遍即可,天然接入统一配置 + Web 管理台。
|
||||
- 让旧插件**逐步收敛**到同一结构,避免「入口一大坨、配置散落、数据读写各自为政」。
|
||||
- 让 **Web 管理台能统一读/改所有插件的配置**(不是只针对 env)。
|
||||
|
||||
## 1. 包命名与位置
|
||||
|
||||
- 位置:`hexi/plugins/nonebot_plugin_<name>/`(`bot.py` 用 `nonebot.load_plugins("hexi")` 加载)。
|
||||
- 包名 = NoneBot 模块名 = `hexi.plugins.nonebot_plugin_<name>`。
|
||||
- **`plugin_id` 约定**:用于配置 schema / 插件控制 / Web 挂载的值,一律等于模块名(`__name__`)。
|
||||
|
||||
## 2. 架构分层:MTSS(Model–Trigger–Service–View)
|
||||
|
||||
> 本插件结构可用一种「事件驱动的 MVC」来理解:Controller 被换成 **Trigger(触发器)**,并多出一个**双 View**(聊天 + 管理端)。我们称之为 **MTSS**。
|
||||
|
||||
### 2.1 四层职责
|
||||
|
||||
| 层 | 目录 | 职责 |
|
||||
|---|---|---|
|
||||
| **Trigger** | `handlers/*` | `on_command/on_message/on_notice`、定时任务、Web 按钮 → 统一**入口适配器**,接触发后调 service、出 View |
|
||||
| **Service** | `services/*` | 业务编排:一个业务动作一个方法;不关心是哪个触发器进来的,可被多触发器/定时/Web 复用 |
|
||||
| **Model** | `models.py` + `repository.py`/`storage/*` + `data/*` | 数据(ORM/JSON/SQLite/S3)与数据访问;唯一读写入口 |
|
||||
| **View** | 聊天出图/`UniMessage`;Web 端 `web/` + schema 表单 | **双 View**:a) 发给聊天用户(文本/图片/转发);b) 管理端(Web REST + 前端) |
|
||||
|
||||
### 2.2 依赖方向(单向、向下)
|
||||
|
||||
```text
|
||||
Trigger(handlers) → Service(services) → Model(repository/storage)
|
||||
↘ 外部 API / LLM
|
||||
Trigger/View → 依赖 nonebot / OneBot / 平台
|
||||
Service/Model → 尽量不 import nonebot(可单测、未来可换框架)
|
||||
↑ 配置标准 config.py 是横切模块,各层都能读
|
||||
```
|
||||
|
||||
### 2.3 关键规则
|
||||
|
||||
- **Trigger 只做**:接触发 → 鉴权/校验 → 调 service → 出 View;**不要放大逻辑**。
|
||||
- **View 有两个出口**:`chat`(发给用户) 与 `web`(管理端),可共用同一个 Service 的结果(类似 MVVM 的「一个 ViewModel 两个 View」)。
|
||||
- **service/repository 不 import nonebot**,便于单测与切换框架;只依赖 Model 与配置标准。
|
||||
- **触发器来源不限**:群消息 / 私聊 / at / 定时 / Web 按钮。想加「Web 手动触发」,只需在 Trigger 层加一个入口复用同一个 service。
|
||||
|
||||
### 2.4 示例(video_analysis)
|
||||
|
||||
```text
|
||||
Trigger → handlers/entry.py 的 auto/active matcher(card/文本链接)
|
||||
Service → dispatch_url 平台分派(可下沉到 services/)
|
||||
Model → list_proc.py(list.json)、storage/s3.py、models.py(异常)
|
||||
View → chat: UniMessage 文本/图片; web: config schema + 未来的 ObjectSetEditor
|
||||
Config → config.py 统一注册(cleanup/S3/group_config)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 目录骨架模板
|
||||
|
||||
```text
|
||||
nonebot_plugin_xxx/
|
||||
├── __init__.py # 入口:元数据 + 命令注册 + 配置注册 + 启动钩子
|
||||
├── config.py # 配置标准:Config(BaseModel) / register_object_set / register_config_items
|
||||
├── models.py # ORM / dataclass 模型(可选)
|
||||
├── repository.py # 数据访问层:唯一读写入口(可选)
|
||||
├── handlers/ # 命令/消息处理(按域拆分)
|
||||
│ ├── __init__.py
|
||||
│ └── chat.py management.py notices.py ...
|
||||
├── services/ # 业务编排层(被多个 handler/定时任务复用)
|
||||
│ └── __init__.py
|
||||
├── utils/ # 纯工具:无副作用、尽量无 nonebot 依赖、可单测
|
||||
│ └── __init__.py
|
||||
├── data/ # 运行时数据(或统一放 hexi/data/)
|
||||
├── res/ # 静态资源:图片/字体/模板
|
||||
├── web/ # 可选:插件自带 Web 前端(build 产物挂载)
|
||||
├── README.md # 可选
|
||||
└── CLAUDE.md # 可选:插件内开发文档(在此插件内开发时优先读)
|
||||
```
|
||||
|
||||
## 4. `__init__.py` 标准职责(固定顺序)
|
||||
|
||||
1. `__plugin_meta__ = PluginMetadata(name=..., description=..., usage=..., type="application")`。
|
||||
2. 声明依赖 `require("nonebot_plugin_alconna")` 等。
|
||||
3. 显式导入子模块(`from . import handlers, services, utils`),注册 matcher/handler。
|
||||
4. **配置注册**(在 config 就绪后):`register_model_config`(pydantic Config)或 `register_config_items` / `register_object_set`(来源无关)。
|
||||
5. 可选 `register_web_plugin("id", "名称", "icon", lambda: build_app(), module_name=__name__)`。
|
||||
6. 启动钩子 `@driver.on_startup`:初始化 DB / 加载 data 文件 / 注册定时任务。
|
||||
7. 业务命令尽量放 `handlers/`,不要把大逻辑堆在 `__init__.py`。
|
||||
|
||||
## 5. config.py 标准
|
||||
|
||||
### 4.1 环境驱动型(读 `.env` / NoneBot config)
|
||||
```python
|
||||
from nonebot import get_plugin_config
|
||||
from pydantic import BaseModel
|
||||
|
||||
class Config(BaseModel):
|
||||
enable: bool = True
|
||||
api_key: str = ""
|
||||
|
||||
config = get_plugin_config(Config)
|
||||
```
|
||||
然后 `register_model_config(__name__, config, fields=[...], labels={...})` 接入 Web。
|
||||
|
||||
### 4.2 来源无关型(模块常量 / 配置文件 / DB)
|
||||
```python
|
||||
from hexi.config_standard import register_config_items, register_object_set
|
||||
register_object_set(__name__, "groups", [ {item_schema...} ], key_field="group_id",
|
||||
getter=get_groups, setter=set_groups, nosave=True)
|
||||
```
|
||||
详见《plugin-config-standard.md》§5/§13/§14。
|
||||
|
||||
### 4.3 约定
|
||||
- 敏感字段 `type="password"` 且 `secret=True`;
|
||||
- 默认值写进 schema,供 Web 回填;
|
||||
- `nosave=True` 表示权威源在插件自身(文件/DB),Web 修改只 apply 到来源,不写 plugin_config.json。
|
||||
|
||||
## 6. 数据层标准
|
||||
|
||||
- 读写统一走 `repository.py`(或 services 内封装的仓储方法),避免 SQL/文件读写散落各处。
|
||||
- ORM:用 `nonebot_plugin_orm` 的 `get_session()`;模型在 `models.py`。
|
||||
- 文件:**原子写**(tmp + `os.replace`),避免并发/崩溃写坏 JSON。
|
||||
- SQLite:开 WAL、用事务;不要在循环里逐条写。
|
||||
- 路径:统一用插件 `get_data_dir()`/`get_plugin_cache_dir()` 或 `hexi` 的路径工具,**不硬编码绝对路径**。
|
||||
|
||||
## 7. 工具/服务标准
|
||||
|
||||
- `utils/`:纯函数、无状态、尽量不 import nonebot/driver,便于单测。
|
||||
- `services/`:业务编排,可依赖 repository / 外部 API;把「一个业务动作」收敛到一个方法。
|
||||
- **禁止** `from x import *`、`except: pass`、`print()`;用 `logger`。
|
||||
- async handler 内避免同步阻塞(`requests` / `time.sleep` / 同步爬虫);需要就 `asyncio.to_thread`。
|
||||
- 命令触发:需要 @ 用 `rule=to_me()`;全局命令注意与其它插件冲突;高开销命令加冷却/限频(`hexi_core` 的 `cooldown/rate_limit`)。
|
||||
- 外部 API:统一超时 + 重试 + 失败降级/用户提示。
|
||||
|
||||
## 8. Web 接入标准
|
||||
|
||||
- 需要独立 Web 页:`register_web_plugin(id, name, icon, lambda: build_app(), module_name=__name__)`,hub 启动自动挂载 `/api/<id>`。
|
||||
- 需要 Web 配置:`register_plugin_config` / `register_model_config` / `register_config_items` / `register_object_set`(schema 驱动表单)。
|
||||
- 鉴权统一用 `hexi.web_auth.require_admin`(OAuth2 + SQLite),不要自造一套。
|
||||
- 敏感字段 `secret=True`,前端掩码;写回允许明文。
|
||||
|
||||
## 9. 插件迁移检查清单
|
||||
|
||||
- [ ] 包名改为 `nonebot_plugin_*`,位置在 `hexi/plugins/`。
|
||||
- [ ] 有 `__plugin_meta__`,`type="application"`。
|
||||
- [ ] 配置已接入统一标准(Web 能读能改,运行期/重启生效)。
|
||||
- [ ] 命令在 `handlers/`,业务在 `services/`,工具在 `utils/`。
|
||||
- [ ] 数据访问集中(repository / services),原子写 / 事务。
|
||||
- [ ] 无 `*` 导入、裸 `except`、`print`;无同步阻塞混入 async。
|
||||
- [ ] 运行数据写入统一数据目录,不写入插件源码目录;配置读取点支持标准热更新语义。
|
||||
- [ ] 外部请求有超时/重试/失败提示;命令有权限/限流。
|
||||
- [ ] 需要 Web 的已 `register_web_plugin`;鉴权走统一 `web_auth`。
|
||||
@@ -0,0 +1,488 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""统一配置标准辅助:把插件 pydantic Config 模型一键接入 hexi.web_config。
|
||||
|
||||
这个模块是对 hexi.web_config 的薄封装,目的是把「插件配置标准」统一起来:
|
||||
- 插件只需声明一份字段列表(或交给字段推断),就能在 Web 管理台可读可设置。
|
||||
- 提供 getter(回填当前生效值)与 apply(保存后热更新 pydantic 对象)。
|
||||
|
||||
约定:
|
||||
- plugin_id 必须是 NoneBot 插件模块名(与 plugin_control.list_plugins 一致),
|
||||
这样 Web 的 /api/plugins/<id>/config 才能命中。
|
||||
- 字段 key 就是 Config 模型里的属性名;env 名默认等于 key.upper()。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
from types import UnionType
|
||||
from typing import Any, Callable, Iterable, Literal, Optional, Union, get_args, get_origin
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from hexi.web_config import register_plugin_config
|
||||
|
||||
# 常见「密码/密钥」类字段名,前端会自动脱敏
|
||||
_SECRET_HINTS = ("password", "secret", "token", "key", "cookie", "apikey", "api_key")
|
||||
|
||||
|
||||
def _is_secret(key: str) -> bool:
|
||||
kl = key.lower()
|
||||
return any(h in kl for h in _SECRET_HINTS)
|
||||
|
||||
|
||||
def _unwrap_optional(annotation: Any) -> tuple[Any, bool]:
|
||||
"""剥掉 Optional/Union[..., None] 包装,返回 (真实类型, 是否可选)。"""
|
||||
is_optional = False
|
||||
origin = get_origin(annotation)
|
||||
if origin is Union or origin is UnionType:
|
||||
args = get_args(annotation)
|
||||
non_none = [a for a in args if a is not type(None)]
|
||||
if len(non_none) != len(args):
|
||||
is_optional = True
|
||||
if non_none:
|
||||
return non_none[0], is_optional
|
||||
return annotation, is_optional
|
||||
|
||||
|
||||
def _type_label(annotation: Any) -> str:
|
||||
"""把 pydantic 字段类型映射到 web_config 支持的 schema type。"""
|
||||
annotation, _ = _unwrap_optional(annotation)
|
||||
if annotation is bool:
|
||||
return "bool"
|
||||
if annotation is int:
|
||||
return "int"
|
||||
if annotation is float:
|
||||
return "float"
|
||||
if annotation is str:
|
||||
return "string"
|
||||
try:
|
||||
origin = get_origin(annotation)
|
||||
except Exception: # noqa: BLE001
|
||||
origin = None
|
||||
if origin is Literal:
|
||||
return "enum"
|
||||
if origin in (list, set, tuple):
|
||||
args = get_args(annotation)
|
||||
if args and args[0] is str:
|
||||
return "text"
|
||||
return "text"
|
||||
return "string"
|
||||
|
||||
|
||||
def _enum_options(annotation: Any) -> list[dict[str, str]]:
|
||||
annotation, _ = _unwrap_optional(annotation)
|
||||
try:
|
||||
origin = get_origin(annotation)
|
||||
args = get_args(annotation)
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
if origin is Literal:
|
||||
return [{"value": str(o), "label": str(o)} for o in args]
|
||||
return []
|
||||
|
||||
|
||||
def _text_item_type(annotation: Any) -> str:
|
||||
"""List[int]/List[float]/List[str] 的元素类型,用于 text 字段的取值转换。"""
|
||||
annotation, _ = _unwrap_optional(annotation)
|
||||
try:
|
||||
origin = get_origin(annotation)
|
||||
args = get_args(annotation)
|
||||
except Exception: # noqa: BLE001
|
||||
return "str"
|
||||
if origin in (list, set, tuple) and args:
|
||||
el, _ = _unwrap_optional(args[0])
|
||||
if el is int:
|
||||
return "int"
|
||||
if el is float:
|
||||
return "float"
|
||||
if el is bool:
|
||||
return "bool"
|
||||
return "str"
|
||||
|
||||
|
||||
def _coerce_value(value: Any, typ: str, item_type: str = "str") -> Any:
|
||||
"""按 schema type 把前端字符串/值转成目标 python 值。"""
|
||||
if value is None:
|
||||
return None
|
||||
if typ == "bool":
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
||||
if typ == "int":
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if typ == "float":
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if typ == "text":
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
items = list(value)
|
||||
else:
|
||||
# TextArea 多行/逗号分隔都接受
|
||||
import re as _re
|
||||
parts = _re.split(r"[,\n]+", str(value))
|
||||
items = [p.strip() for p in parts if p.strip()]
|
||||
if item_type == "int":
|
||||
out = []
|
||||
for it in items:
|
||||
try:
|
||||
out.append(int(it))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return out
|
||||
if item_type == "float":
|
||||
out = []
|
||||
for it in items:
|
||||
try:
|
||||
out.append(float(it))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return out
|
||||
return items
|
||||
if typ == "json":
|
||||
# 前端提交的是 JSON 字符串,解析成 dict/list;已解析则原样返回
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return json.loads(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return value
|
||||
if typ == "object_set":
|
||||
# 前端提交的要么是字符串 JSON,要么已是 list[dict]
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
return parsed if isinstance(parsed, list) else []
|
||||
except ValueError:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
return []
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
def _stringify(value: Any, typ: str) -> Any:
|
||||
"""把 python 值转成 Web 表单能显示的值。"""
|
||||
if typ == "text":
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return "\n".join(str(v) for v in value)
|
||||
return str(value)
|
||||
if typ == "json":
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, (dict, list, tuple)):
|
||||
return json.dumps(value, ensure_ascii=False, indent=2)
|
||||
return str(value)
|
||||
# object_set 保持结构化(list[dict])原样返回,前端用专用表格组件渲染
|
||||
return value
|
||||
|
||||
|
||||
def register_model_config(
|
||||
plugin_id: str,
|
||||
config_instance: BaseModel,
|
||||
fields: Optional[Iterable[str]] = None,
|
||||
labels: Optional[dict[str, str]] = None,
|
||||
descriptions: Optional[dict[str, str]] = None,
|
||||
options: Optional[dict[str, list[dict[str, str]]]] = None,
|
||||
types: Optional[dict[str, str]] = None,
|
||||
apply_extra: Optional[Callable[[dict[str, Any], BaseModel], None]] = None,
|
||||
getter_extra: Optional[Callable[[BaseModel], dict[str, Any]]] = None,
|
||||
) -> None:
|
||||
"""把 pydantic Config 模型注册进统一 Web 配置标准。
|
||||
|
||||
Args:
|
||||
plugin_id: NoneBot 插件模块名。
|
||||
config_instance: 插件模块级 pydantic Config 实例(如 `.config.config`)。
|
||||
fields: 需要暴露的字段名。缺省=全部非私有字段(自动推断)。
|
||||
labels/descriptions/options: 可选的中文透出/提示/枚举覆盖。
|
||||
apply_extra: 可选的额外热应用回调(如需要重注册定时任务)。
|
||||
getter_extra: 可选的额外取值(如会额外返回非模型字段)。
|
||||
"""
|
||||
try:
|
||||
hints = inspect.get_annotations(type(config_instance), eval_str=True)
|
||||
except Exception: # noqa: BLE001
|
||||
try:
|
||||
hints = {
|
||||
k: f.annotation
|
||||
for k, f in config_instance.__class__.model_fields.items()
|
||||
}
|
||||
except Exception: # noqa: BLE001
|
||||
hints = {}
|
||||
|
||||
try:
|
||||
defaults = {
|
||||
k: f.default
|
||||
for k, f in config_instance.__class__.model_fields.items()
|
||||
}
|
||||
except Exception: # noqa: BLE001
|
||||
defaults = {}
|
||||
|
||||
field_names = list(fields) if fields else [k for k in hints if not k.startswith("_")]
|
||||
schema_fields: list[dict[str, Any]] = []
|
||||
for key in field_names:
|
||||
annotation = hints.get(key, str)
|
||||
typ = (types or {}).get(key) or _type_label(annotation)
|
||||
default = defaults.get(key)
|
||||
field: dict[str, Any] = {
|
||||
"key": key,
|
||||
"label": (labels or {}).get(key, key),
|
||||
"type": typ,
|
||||
"default": _stringify(default, typ),
|
||||
"description": (descriptions or {}).get(key, ""),
|
||||
"secret": _is_secret(key),
|
||||
}
|
||||
if typ == "enum":
|
||||
field["options"] = (options or {}).get(key) or _enum_options(annotation)
|
||||
if typ == "text":
|
||||
field["item_type"] = _text_item_type(annotation)
|
||||
schema_fields.append(field)
|
||||
|
||||
schema = {"fields": schema_fields}
|
||||
|
||||
def _getter() -> dict[str, Any]:
|
||||
values: dict[str, Any] = {}
|
||||
for field in schema_fields:
|
||||
key = field["key"]
|
||||
try:
|
||||
raw = getattr(config_instance, key)
|
||||
except Exception: # noqa: BLE001
|
||||
raw = defaults.get(key)
|
||||
values[key] = _stringify(raw, field["type"])
|
||||
if getter_extra:
|
||||
values.update(getter_extra(config_instance))
|
||||
return values
|
||||
|
||||
def _apply(values: dict[str, Any]) -> None:
|
||||
for field in schema_fields:
|
||||
key = field["key"]
|
||||
if key not in values:
|
||||
continue
|
||||
coerced = _coerce_value(values[key], field["type"], field.get("item_type", "str"))
|
||||
try:
|
||||
setattr(config_instance, key, coerced)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if apply_extra:
|
||||
apply_extra(values, config_instance)
|
||||
|
||||
register_plugin_config(
|
||||
plugin_id,
|
||||
schema,
|
||||
apply=_apply,
|
||||
getter=_getter,
|
||||
)
|
||||
|
||||
def register_config_items(
|
||||
plugin_id: str,
|
||||
items: list[dict[str, Any]],
|
||||
store: Optional[Any] = None,
|
||||
apply_extra: Optional[Callable[[dict[str, Any], Any], None]] = None,
|
||||
getter_extra: Optional[Callable[[Any], dict[str, Any]]] = None,
|
||||
) -> None:
|
||||
"""注册「来源无关」的任意配置项(schema + getter/setter),供 Web 读/改。
|
||||
|
||||
与 register_model_config 的区别:这里每个配置项可以来自任意来源:
|
||||
- 模块常量(module attr)
|
||||
- pydantic Config(字段)
|
||||
- 配置文件(dict / JSON / YAML)
|
||||
- 数据库/运行态状态
|
||||
|
||||
Args:
|
||||
plugin_id: NoneBot 插件模块名。
|
||||
items: 配置项列表,每个 dict 需含 key/label/type,可选:
|
||||
default/description/secret/options/item_type/env,以及 getter/setter。
|
||||
若某 item 未提供 getter/setter,则读写 store:
|
||||
- store 是 dict -> store[key]
|
||||
- store 是 ModuleType -> getattr/setattr(store, key)
|
||||
store: 缺省 getter/setter 时的回退存储(dict 或模块)。
|
||||
apply_extra: 保存后用额外回调做持久化/热加载等。
|
||||
getter_extra: getter 额外返回的非 items 值。
|
||||
"""
|
||||
from types import ModuleType
|
||||
|
||||
def _item_getter(item: dict[str, Any]) -> Any:
|
||||
getter = item.get("getter")
|
||||
if getter is not None:
|
||||
try:
|
||||
return getter()
|
||||
except Exception: # noqa: BLE001
|
||||
return item.get("default")
|
||||
if store is not None:
|
||||
if isinstance(store, dict):
|
||||
return store.get(item["key"], item.get("default"))
|
||||
if isinstance(store, ModuleType):
|
||||
try:
|
||||
return getattr(store, item["key"])
|
||||
except AttributeError:
|
||||
return item.get("default")
|
||||
return item.get("default")
|
||||
|
||||
def _item_setter(item: dict[str, Any], value: Any) -> None:
|
||||
setter = item.get("setter")
|
||||
if setter is not None:
|
||||
try:
|
||||
setter(value)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return
|
||||
if store is not None:
|
||||
if isinstance(store, dict):
|
||||
store[item["key"]] = value
|
||||
elif isinstance(store, ModuleType):
|
||||
try:
|
||||
setattr(store, item["key"], value)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
schema_fields: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
field: dict[str, Any] = {
|
||||
"key": item["key"],
|
||||
"label": item.get("label", item["key"]),
|
||||
"type": item.get("type", "string"),
|
||||
"default": _stringify(item.get("default"), item.get("type", "string")),
|
||||
"description": item.get("description", ""),
|
||||
"secret": item.get("secret", _is_secret(item["key"])),
|
||||
}
|
||||
if item.get("options"):
|
||||
field["options"] = item["options"]
|
||||
if item.get("type") == "text":
|
||||
field["item_type"] = item.get("item_type", "str")
|
||||
if item.get("type") == "object_set" and item.get("item_schema"):
|
||||
field["item_schema"] = item["item_schema"]
|
||||
if item.get("key_field"):
|
||||
field["key_field"] = item["key_field"]
|
||||
if item.get("nosave"):
|
||||
# 文件/外部源配置:值不写进 plugin_config.json(避免与命令修改互相覆盖为陈旧值),
|
||||
# 但仍调用 apply(写入外部源)。
|
||||
field["nosave"] = True
|
||||
schema_fields.append(field)
|
||||
|
||||
schema = {"fields": schema_fields}
|
||||
|
||||
def _getter() -> dict[str, Any]:
|
||||
values: dict[str, Any] = {}
|
||||
for item in items:
|
||||
raw = _item_getter(item)
|
||||
values[item["key"]] = _stringify(raw, item.get("type", "string"))
|
||||
if getter_extra:
|
||||
values.update(getter_extra(store) if store is not None else getter_extra())
|
||||
return values
|
||||
|
||||
def _apply(values: dict[str, Any]) -> None:
|
||||
for item in items:
|
||||
key = item["key"]
|
||||
if key not in values:
|
||||
continue
|
||||
typ = item.get("type", "string")
|
||||
coerced = _coerce_value(values[key], typ, item.get("item_type", "str"))
|
||||
_item_setter(item, coerced)
|
||||
if apply_extra:
|
||||
apply_extra(values, store)
|
||||
|
||||
register_plugin_config(
|
||||
plugin_id,
|
||||
schema,
|
||||
apply=_apply,
|
||||
getter=_getter,
|
||||
)
|
||||
|
||||
|
||||
def register_object_set(
|
||||
plugin_id: str,
|
||||
set_key: str,
|
||||
item_schema_fields: list[dict[str, Any]],
|
||||
key_field: str,
|
||||
getter: Callable[[], list[dict[str, Any]]],
|
||||
setter: Callable[[list[dict[str, Any]]], None],
|
||||
*,
|
||||
label: Optional[str] = None,
|
||||
description: str = "",
|
||||
nosave: bool = False,
|
||||
options_by_key: Optional[dict[str, list[dict[str, str]]]] = None,
|
||||
) -> None:
|
||||
"""注册一个「通用对象集」配置项(type=object_set)。
|
||||
|
||||
适用场景:插件要暴露一组结构化条目(如群分组、测速站点、服务器列表、
|
||||
白/黑名单成员等),Web 端用通用表格渲染,支持增删改。
|
||||
|
||||
Args:
|
||||
plugin_id: NoneBot 插件模块名。
|
||||
set_key: 该对象集的配置 key。
|
||||
item_schema_fields: 子字段 schema(同标准字段;不必含 key),每条含
|
||||
key/label/type/default/options/item_type/secret 等。
|
||||
key_field: 子字段主键名(用于定位唯一行/去重)。
|
||||
getter: () -> list[dict] 取当前对象集。
|
||||
setter: (list[dict]) -> None 持久化整个对象集(应自行校验/写回来源)。
|
||||
label/description: Web 显示。
|
||||
nosave: True 时不写入 plugin_config.json(权威源在插件自身,如文件/DB)。
|
||||
options_by_key: 按子字段 key 覆盖 enum options。
|
||||
"""
|
||||
sub_fields: list[dict[str, Any]] = []
|
||||
for sf in item_schema_fields:
|
||||
field: dict[str, Any] = {
|
||||
"key": sf["key"],
|
||||
"label": sf.get("label", sf["key"]),
|
||||
"type": sf.get("type", "string"),
|
||||
"default": sf.get("default"),
|
||||
"description": sf.get("description", ""),
|
||||
"secret": sf.get("secret", _is_secret(sf["key"])),
|
||||
}
|
||||
if sf.get("type") == "text":
|
||||
field["item_type"] = sf.get("item_type", "str")
|
||||
opts = (options_by_key or {}).get(sf["key"]) or sf.get("options")
|
||||
if opts:
|
||||
field["options"] = opts
|
||||
sub_fields.append(field)
|
||||
|
||||
def _coerce_setter(rows: list[dict[str, Any]]) -> None:
|
||||
"""按 item_schema 把每行子字段转成目标类型,并按 key_field 去重后交给 setter。"""
|
||||
if not isinstance(rows, list):
|
||||
rows = []
|
||||
coerced: list[dict[str, Any]] = []
|
||||
seen: set[Any] = set()
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
nrow: dict[str, Any] = {}
|
||||
for sf in item_schema_fields:
|
||||
key = sf["key"]
|
||||
if key not in row:
|
||||
if "default" in sf and key not in nrow:
|
||||
nrow[key] = _coerce_value(sf["default"], sf.get("type", "string"), sf.get("item_type", "str"))
|
||||
continue
|
||||
nrow[key] = _coerce_value(
|
||||
row[key], sf.get("type", "string"), sf.get("item_type", "str")
|
||||
)
|
||||
key_val = nrow.get(key_field)
|
||||
if key_val is None or key_val == "":
|
||||
continue # 缺主键的行丢弃
|
||||
if key_val in seen:
|
||||
continue # 主键重复只保留首个
|
||||
seen.add(key_val)
|
||||
coerced.append(nrow)
|
||||
setter(coerced)
|
||||
|
||||
register_config_items(
|
||||
plugin_id,
|
||||
[
|
||||
{
|
||||
"key": set_key,
|
||||
"label": label or set_key,
|
||||
"type": "object_set",
|
||||
"item_schema": {"fields": sub_fields},
|
||||
"key_field": key_field,
|
||||
"description": description,
|
||||
"getter": getter,
|
||||
"setter": _coerce_setter,
|
||||
"nosave": nosave,
|
||||
}
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
表情裸词操作
|
||||
|
||||
nonebot-plugin-memes 0.5+ 改用 Alconna 后,表情选项只能用 `-右`/`--right` 形式
|
||||
(0.4.x 旧版是 `对称 /右`)。本插件为表情作者声明的短别名(左/右/上/下/前/后/圆/爷)
|
||||
追加裸词形式,恢复 `表情 操作词` 的用法,如:
|
||||
|
||||
对称 右 / 鬼畜 下 / 循环 上 / 摸 圆 / 小丑 爷 / 小丑面具 前 / ba说 左
|
||||
|
||||
实现方式:patch `nonebot_plugin_memes.matchers.command` 的 `build_option`
|
||||
(追加裸词别名)和 `on_alconna`(重建的 matcher 用 priority=11 + block 抢占原
|
||||
priority=12 的 matcher),然后为受影响的表情重建 matcher。
|
||||
|
||||
注意:本补丁针对 nonebot-plugin-memes 0.8.1 的内部结构编写;插件大版本更新后
|
||||
若内部改名导致补丁失败,会记日志并自动跳过,原插件按默认方式工作(退回 `-右` 语法)。
|
||||
"""
|
||||
|
||||
from nonebot import require
|
||||
|
||||
require("nonebot_plugin_memes")
|
||||
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
|
||||
from nonebot import logger
|
||||
from nonebot.plugin import PluginMetadata
|
||||
|
||||
from meme_generator import BooleanOption, FloatOption, IntegerOption
|
||||
from nonebot_plugin_alconna import Args, Option, store_false, store_true
|
||||
from nonebot_plugin_memes.manager import meme_manager
|
||||
from nonebot_plugin_memes.matchers import command as memes_command
|
||||
|
||||
__plugin_meta__ = PluginMetadata(
|
||||
name="表情裸词操作",
|
||||
description="恢复“表情 操作词”的裸词用法(对称 右、摸 圆、小丑 爷 等)",
|
||||
usage="对称 右 / 鬼畜 下 / 摸 圆 / 小丑 爷 / 小丑面具 前",
|
||||
)
|
||||
|
||||
try:
|
||||
_memes_version = version("nonebot-plugin-memes")
|
||||
except PackageNotFoundError:
|
||||
_memes_version = "?"
|
||||
if _memes_version != "0.8.1":
|
||||
logger.warning(
|
||||
f"表情裸词操作:当前 nonebot-plugin-memes 版本为 {_memes_version},"
|
||||
"本补丁按 0.8.1 编写,若下方出现补丁失败日志请检查兼容性"
|
||||
)
|
||||
|
||||
# 幂等:热重载本插件时不重复打补丁
|
||||
if getattr(memes_command.build_option, "_memes_ops_patched", False):
|
||||
logger.info("表情裸词操作:补丁已应用过,跳过")
|
||||
else:
|
||||
try:
|
||||
_original_build_option = memes_command.build_option
|
||||
_original_on_alconna = memes_command.on_alconna
|
||||
|
||||
def _build_option(option):
|
||||
"""原版 build_option,并在选项名中追加作者声明的裸词短别名(如 `右`)。"""
|
||||
declared_short_aliases = list(option.parser_flags.short_aliases)
|
||||
names: list[str] = []
|
||||
parser_flags = option.parser_flags
|
||||
short_aliases = parser_flags.short_aliases
|
||||
if parser_flags.short:
|
||||
short_aliases.insert(0, option.name[0])
|
||||
long_aliases = parser_flags.long_aliases
|
||||
if parser_flags.long:
|
||||
long_aliases.insert(0, option.name)
|
||||
names.extend([f"-{flag}" for flag in short_aliases])
|
||||
names.extend([f"--{flag}" for flag in long_aliases])
|
||||
names.extend(declared_short_aliases)
|
||||
if isinstance(option, BooleanOption):
|
||||
action = None
|
||||
if option.default is not None:
|
||||
action = store_false if option.default else store_true
|
||||
return Option(
|
||||
name="|".join(names),
|
||||
dest=option.name,
|
||||
default=option.default,
|
||||
action=action,
|
||||
help_text=option.description,
|
||||
)
|
||||
args = Args()
|
||||
if isinstance(option, IntegerOption):
|
||||
arg_type = int
|
||||
elif isinstance(option, FloatOption):
|
||||
arg_type = float
|
||||
else:
|
||||
arg_type = str
|
||||
args.add(name=option.name, value=arg_type)
|
||||
return Option(
|
||||
name="|".join(names),
|
||||
args=args,
|
||||
dest=option.name,
|
||||
help_text=option.description,
|
||||
)
|
||||
|
||||
def _on_alconna(command, *args, **kwargs):
|
||||
"""重建的 matcher 提高优先级并 block,抢占原 priority=12 的 matcher。"""
|
||||
kwargs["priority"] = 11
|
||||
kwargs["block"] = True
|
||||
return _original_on_alconna(command, *args, **kwargs)
|
||||
|
||||
memes_command.build_option = _build_option
|
||||
memes_command.on_alconna = _on_alconna
|
||||
_build_option._memes_ops_patched = True # noqa: B010
|
||||
|
||||
patched: list[str] = []
|
||||
for meme in meme_manager.get_memes():
|
||||
aliases = [
|
||||
flag
|
||||
for option in meme.info.params.options
|
||||
for flag in option.parser_flags.short_aliases
|
||||
]
|
||||
if aliases:
|
||||
memes_command.create_matcher(meme)
|
||||
patched.append(f"{meme.key}({','.join(aliases)})")
|
||||
logger.info(f"表情裸词操作:已为 {len(patched)} 个表情启用裸词操作: {patched}")
|
||||
|
||||
except Exception:
|
||||
# 版本不兼容时优雅降级,恢复原函数,不影响原插件
|
||||
memes_command.build_option = _original_build_option # type: ignore[possibly-undefined]
|
||||
memes_command.on_alconna = _original_on_alconna # type: ignore[possibly-undefined]
|
||||
logger.exception(
|
||||
"表情裸词操作:补丁应用失败(可能与 nonebot-plugin-memes 版本不兼容),"
|
||||
"已恢复原状,表情仍按 `-右` 形式使用"
|
||||
)
|
||||
@@ -0,0 +1,273 @@
|
||||
from nonebot import on_command, require
|
||||
from nonebot.adapters import Message, Event, Bot
|
||||
from nonebot.log import logger
|
||||
from nonebot.matcher import Matcher
|
||||
from nonebot.params import CommandArg
|
||||
from nonebot.plugin import PluginMetadata
|
||||
from nonebot.rule import to_me
|
||||
import time
|
||||
from .user_data.data_utils import UserManager
|
||||
|
||||
require("nonebot_plugin_alconna")
|
||||
from nonebot_plugin_alconna import UniMessage
|
||||
from .data import *
|
||||
from .data_utils import *
|
||||
from .image_builder import *
|
||||
from .text_utils import *
|
||||
|
||||
__plugin_meta__ = PluginMetadata(
|
||||
name="BF查询",
|
||||
description="战地3,4,1,5,2042,6",
|
||||
usage="""
|
||||
bf3: /bf3 EAID (查询BF3数据)
|
||||
bf4: /bf4 EAID (查询BF4数据)
|
||||
bf1: /bf1 EAID (查询BF5数据)
|
||||
bfv: /bfv EAID (查询BF6数据)
|
||||
bf2042: /bf2042 EAID (查询BF2042数据)
|
||||
bf6: /bf6 EAID (查询BF6数据)
|
||||
绑定: /绑定 EAID (绑定你的QQ与EAID)
|
||||
解绑: /解绑 (解除你的QQ与当前绑定的EAID)
|
||||
修改绑定: /修改绑定 EAID (修改你的QQ与当前绑定的EAID)
|
||||
""".strip(),
|
||||
extra={
|
||||
|
||||
},
|
||||
)
|
||||
|
||||
query = on_command("bft", rule=to_me(), aliases={"bf3", "bf4", "bfv", "bf1", "bf2042", "bf6"}, block=True)
|
||||
bind = on_command("bfbind", rule=to_me(), aliases={"绑定"}, block=True)
|
||||
unbind = on_command("unbind", rule=to_me(), aliases={"解绑"}, block=True)
|
||||
update_bind = on_command("update_bind", rule=to_me(), aliases={"修改绑定"}, block=True)
|
||||
|
||||
bf_dict = {
|
||||
"bf3": "战地3",
|
||||
"bf4": "战地4",
|
||||
"bf1": "战地1",
|
||||
"bfv": "战地5",
|
||||
"bf2042": "战地2042",
|
||||
"bf6": "战地6",
|
||||
}
|
||||
|
||||
|
||||
@query.handle()
|
||||
async def handle_function(event: Event, matcher: Matcher, msg: Message = CommandArg()):
|
||||
start_time = time.time()
|
||||
usermanager = UserManager()
|
||||
cmd = matcher.state["_prefix"]["command"][0]
|
||||
game = cmd
|
||||
input_text = msg.extract_plain_text().strip()
|
||||
logger.info(f"{type(input_text)}, {repr(input_text)}")
|
||||
if input_text is None or input_text == "":
|
||||
content = usermanager.get_user_by_qq(event.get_user_id())
|
||||
if not content:
|
||||
await UniMessage.at(user_id=event.get_user_id()).text(
|
||||
"未检测到id,也未检测到绑定记录,请使用 绑定 EAID 进行绑定操作").send()
|
||||
return
|
||||
player_id = content['ea_player_id']
|
||||
user_id = content['ea_user_id']
|
||||
player = content['ea_player_name']
|
||||
platform = "pc"
|
||||
else:
|
||||
content = await get_player_info_by_name(input_text, "pc")
|
||||
if 'errors' in content:
|
||||
await UniMessage.at(user_id=event.get_user_id()).text("未找到玩家,请检查是否拼写正确").send()
|
||||
return
|
||||
player_id = content['personaId']
|
||||
user_id = content['userId']
|
||||
player = content.get('personaName') or content.get('displayName') or content.get('username', 'error')
|
||||
if 'errors' in player:
|
||||
await UniMessage.text(f"请检查id是否正确!").send()
|
||||
return
|
||||
platform = "pc"
|
||||
await UniMessage.text(f"正在查询 {player} 的 {bf_dict[cmd]} 数据,请耐心等待").send()
|
||||
play_stat = ""
|
||||
try:
|
||||
if cmd == "bf3":
|
||||
play_stat = await get_data_bf3(player, player_id, user_id, platform)
|
||||
elif cmd == "bf4":
|
||||
play_stat = await get_data_bf4(player, player_id, user_id, platform)
|
||||
elif cmd == "bf1":
|
||||
play_stat = await get_data_bf1(player, player_id, user_id, platform)
|
||||
elif cmd == "bfv":
|
||||
play_stat = await get_data_bfv(player, player_id, user_id, platform)
|
||||
elif cmd == "bf2042":
|
||||
await UniMessage.at(event.get_user_id()).text("正在开发中,敬请期待!").send()
|
||||
elif cmd == "bf6":
|
||||
play_stat, rank = await asyncio.gather(
|
||||
get_data_bf6(player, player_id, user_id, platform),
|
||||
get_bf6_rank(player)
|
||||
)
|
||||
logger.info(f"等级:{rank}")
|
||||
else:
|
||||
await UniMessage.at(event.get_user_id()).text("指令异常").send()
|
||||
return
|
||||
if "errors" in play_stat:
|
||||
logger.warning(play_stat['errors'][0])
|
||||
msg = play_stat['errors'][0]
|
||||
await UniMessage.text(f"{msg}").send()
|
||||
return
|
||||
else:
|
||||
# logger.info(f"结果{play_stat}")
|
||||
# player = play_stat['userName']
|
||||
weapon, vehicle = await get_best_weapon_and_best_vehicle(play_stat)
|
||||
pid = play_stat['userId']
|
||||
kd = play_stat['killDeath']
|
||||
kpm = play_stat['killsPerMinute']
|
||||
acc = play_stat['accuracy']
|
||||
# 战地3 特化
|
||||
if cmd == 'bf3':
|
||||
head_shots = play_stat['headShots']
|
||||
else:
|
||||
head_shots = play_stat['headshots']
|
||||
time_play = convert_to_hours(play_stat['timePlayed'])
|
||||
logger.info(f"游玩时长监测:{time_play}")
|
||||
if time_play == 0:
|
||||
await UniMessage.at(event.get_user_id()).text("查询接口异常,请等待一段时间后查询").send()
|
||||
return
|
||||
kills = int(play_stat['kills'])
|
||||
kill_assists = int(play_stat['killAssists'])
|
||||
revives = int(play_stat['revives'])
|
||||
wins = int(play_stat['wins'])
|
||||
loses = int(play_stat['loses'])
|
||||
best_weapon = weapon
|
||||
best_vehicle = vehicle
|
||||
best_class = play_stat['bestClass']
|
||||
destroyed = await get_vehicle_destroyed(play_stat["vehicles"])
|
||||
if cmd == 'bf6':
|
||||
best_class = await get_best_classes(play_stat)
|
||||
captured = play_stat['objective']['captured']
|
||||
score = sum(item.get("score", 0) for item in play_stat['classes'])
|
||||
# 计算4兵种游玩时长
|
||||
seconds = sum(item.get("secondsPlayed", 0) for item in play_stat['classes'])
|
||||
kpm = round(kills / (int(seconds) / 60), 2)
|
||||
logger.info(f"击杀数:{kills},游玩分钟数:{seconds / 60},计算kpm值:{kpm}")
|
||||
spm = int(score / (int(seconds) / 60))
|
||||
logger.info(f"计算spm值:{spm}")
|
||||
repairs = play_stat['repairs']
|
||||
img = await build_bf6_stats_card(game='bf6',
|
||||
qq_id=event.get_user_id(),
|
||||
player=player,
|
||||
pid=pid,
|
||||
rank=rank,
|
||||
kd=kd,
|
||||
kpm=kpm,
|
||||
spm=spm,
|
||||
acc=acc,
|
||||
head_shots=head_shots,
|
||||
time_play=time_play,
|
||||
kills=kills,
|
||||
kill_assists=kill_assists,
|
||||
revives=revives,
|
||||
repairs=repairs,
|
||||
captured=captured,
|
||||
score=score,
|
||||
wins=wins,
|
||||
loses=loses,
|
||||
destroyed=destroyed,
|
||||
best_weapon=best_weapon,
|
||||
best_vehicle=best_vehicle,
|
||||
best_class=best_class)
|
||||
else:
|
||||
spm = play_stat['scorePerMinute']
|
||||
rank = play_stat['rank']
|
||||
longest_head_shot = play_stat['longestHeadShot']
|
||||
highest_ill_streak = play_stat['highestKillStreak']
|
||||
stat_data, level_designation = await stats_calculator(play_stat, cmd)
|
||||
img = await build_stats_card(game, player, pid, kd, kpm, spm, acc, head_shots, rank, time_play, kills,
|
||||
kill_assists, revives, wins, loses, destroyed, best_weapon, best_vehicle,
|
||||
best_class,
|
||||
longest_head_shot, highest_ill_streak, stat_data, level_designation)
|
||||
end_time = time.time()
|
||||
duration = round(end_time - start_time, 3)
|
||||
await UniMessage.image(raw=img.getvalue()).at(user_id=event.get_user_id()).text(
|
||||
f"本次查询耗时:{duration}s").send()
|
||||
except Exception as e:
|
||||
logger.exception(f"异常:{e}")
|
||||
|
||||
|
||||
@bind.handle()
|
||||
async def bind_user(event: Event, matcher: Matcher, msg: Message = CommandArg()):
|
||||
user_manager = UserManager()
|
||||
player_name = msg.extract_plain_text()
|
||||
qq_id = event.get_user_id()
|
||||
|
||||
# 执行已有数据查询操作
|
||||
user_record = user_manager.get_user_by_qq(qq_id)
|
||||
if user_record:
|
||||
user_info = await get_player_info_by_ea_id(user_record['ea_player_id'], user_record['ea_user_id'], 'pc')
|
||||
await (UniMessage.at(user_id=event.get_user_id())
|
||||
.text(f"您已经将账号绑定到: {user_info['personaName']},如果你需要更改绑定请发送 修改绑定 EAID ").send())
|
||||
return
|
||||
else:
|
||||
user_info_json = await get_player_info_by_name(player_name, "pc")
|
||||
if 'errors' in user_info_json:
|
||||
await UniMessage.at(user_id=event.get_user_id()).text("未找到玩家,请检查是否拼写正确").send()
|
||||
player_id = user_info_json['personaId']
|
||||
user_id = user_info_json['userId']
|
||||
player_name = user_info_json.get('personaName') or user_info_json.get('displayName') or user_info_json.get(
|
||||
'username', 'error')
|
||||
avatar_url = user_info_json['avatar']
|
||||
logger.info(f"QQ号:{qq_id},EA双ID:{player_id}-{user_id}")
|
||||
|
||||
# 检测ea账号 是否被绑定至其他qq
|
||||
bind_record = user_manager.get_user_by_ea_id(user_id)
|
||||
if bind_record:
|
||||
await (UniMessage.at(user_id=event.get_user_id())
|
||||
.text(
|
||||
f"您的EA账号 {bind_record['ea_player_name']} 已经绑定到: {bind_record['qq_id']},请您确认绑定的EAID").send())
|
||||
return
|
||||
else:
|
||||
# 执行绑定操作
|
||||
record = user_manager.add_user(qq_id, player_name, player_id, user_id, "[]", "[]")
|
||||
logger.info(f"插入数据返回结果:{record}")
|
||||
if record > 0:
|
||||
await UniMessage.at(user_id=event.get_user_id()).text(f"您已经成功绑定至: {player_name}").send()
|
||||
|
||||
|
||||
@unbind.handle()
|
||||
async def bind_user(event: Event, matcher: Matcher, msg: Message = CommandArg()):
|
||||
user_manager = UserManager()
|
||||
qq_id = event.get_user_id()
|
||||
|
||||
# 执行已有数据查询操作
|
||||
user_record = user_manager.get_user_by_qq(qq_id)
|
||||
if user_record:
|
||||
user_info = await get_player_info_by_ea_id(user_record['ea_player_id'], user_record['ea_user_id'], 'pc')
|
||||
rows = user_manager.delete_user_by_qq(qq_id)
|
||||
if rows > 0:
|
||||
await (UniMessage.at(user_id=event.get_user_id())
|
||||
.text(
|
||||
f"您已经与: {user_info['personaName']} 成功解除绑定,如果你需要重新绑定请发送 修改绑定 EAID ").send())
|
||||
return
|
||||
else:
|
||||
await UniMessage.at(user_id=event.get_user_id()).text(f"未查询到绑定记录").send()
|
||||
|
||||
|
||||
@update_bind.handle()
|
||||
async def bind_user(event: Event, matcher: Matcher, msg: Message = CommandArg()):
|
||||
user_manager = UserManager()
|
||||
player_name = msg.extract_plain_text()
|
||||
qq_id = event.get_user_id()
|
||||
|
||||
# 执行已有数据查询操作
|
||||
user_record = user_manager.get_user_by_qq(qq_id)
|
||||
if user_record:
|
||||
user_info_json = await get_player_info_by_name(player_name, "pc")
|
||||
if 'errors' in user_info_json:
|
||||
await UniMessage.at(user_id=event.get_user_id()).text("未找到玩家,请检查是否拼写正确").send()
|
||||
player_id = user_info_json['personaId']
|
||||
user_id = user_info_json['userId']
|
||||
player_name = user_info_json.get('personaName') or user_info_json.get('displayName') or user_info_json.get(
|
||||
'username', 'error')
|
||||
avatar_url = user_info_json['avatar']
|
||||
logger.info(f"QQ号:{qq_id},EA双ID:{player_id}-{user_id}")
|
||||
|
||||
# 执行修改绑定操作
|
||||
record = user_manager.update_user(qq_id, player_name, player_id, user_id, "[]", "[]")
|
||||
logger.info(f"修改数据返回结果:{record}")
|
||||
if record > 0 and record == 1:
|
||||
await UniMessage.at(user_id=event.get_user_id()).text(
|
||||
f"您已经成功从:{user_record['ea_player_name']}修改绑定至: {player_name}").send()
|
||||
else:
|
||||
await UniMessage.at(user_id=event.get_user_id()).text(
|
||||
f"未查询到绑定记录: {player_name},如果需要绑定,请@机器人并发送 绑定 {player_name} ").send()
|
||||
@@ -0,0 +1,137 @@
|
||||
import asyncio
|
||||
import os
|
||||
import random
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
from curl_cffi import AsyncSession, CurlError
|
||||
from nonebot import logger
|
||||
|
||||
# ---------- 配置 ----------
|
||||
file_path = os.path.dirname(__file__).replace("\\", "/")
|
||||
exported_cookie_path = Path(f"{file_path}/cookies/tracker.txt") # 你导出的 cookies.txt
|
||||
CUSTOM_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"
|
||||
|
||||
RAW_BROWSER_HEADERS = """
|
||||
sec-ch-ua-platform: "Windows"
|
||||
sec-ch-ua: "Google Chrome";v="141", "Not?A_Brand";v="8", "Chromium";v="141"
|
||||
sec-ch-ua-mobile: ?0
|
||||
user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36
|
||||
"""
|
||||
|
||||
# ======================
|
||||
|
||||
|
||||
def parse_raw_headers(raw: str) -> Dict[str, str]:
|
||||
headers = {}
|
||||
skip_prefix = (":authority", ":method", ":path", ":scheme")
|
||||
for line in raw.strip().splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
if any(line.lower().startswith(p) for p in skip_prefix):
|
||||
continue
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
headers[key.strip()] = value.strip()
|
||||
return headers
|
||||
|
||||
|
||||
def load_cookies_from_txt(path: Path) -> List[Dict[str, str]]:
|
||||
cookies = []
|
||||
if not path.exists():
|
||||
return cookies
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 7:
|
||||
cookies.append({"name": parts[5], "value": parts[6]})
|
||||
elif "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
cookies.append({"name": k.strip(), "value": v.strip()})
|
||||
return cookies
|
||||
|
||||
|
||||
def build_cookie_header(cookies: List[Dict[str, str]]) -> str:
|
||||
return "; ".join(f"{c['name']}={c['value']}" for c in cookies)
|
||||
|
||||
|
||||
def build_headers(
|
||||
raw_headers: str,
|
||||
cookies: Optional[List[Dict[str, str]]] = None,
|
||||
ua_override: Optional[str] = None,
|
||||
) -> Dict[str, str]:
|
||||
headers = parse_raw_headers(raw_headers)
|
||||
|
||||
# 强制改成 XHR 请求风格
|
||||
headers.update({
|
||||
"accept": "application/json, text/plain, */*",
|
||||
"origin": "https://tracker.gg",
|
||||
"referer": "https://tracker.gg/",
|
||||
"sec-fetch-mode": "cors",
|
||||
"sec-fetch-site": "same-site",
|
||||
})
|
||||
|
||||
if cookies:
|
||||
headers["cookie"] = build_cookie_header(cookies)
|
||||
if ua_override:
|
||||
headers["user-agent"] = ua_override
|
||||
return headers
|
||||
|
||||
|
||||
async def fetch_with_cookies(url: str, headers: dict, impersonate: str = "chrome110", timeout: int = 15):
|
||||
async with AsyncSession() as session:
|
||||
try:
|
||||
await asyncio.sleep(random.uniform(0.5, 1.2)) # 模拟浏览器请求间隔
|
||||
response = await session.get(url, headers=headers, impersonate=impersonate, timeout=timeout)
|
||||
|
||||
# 重试一次,防止偶尔 CF 拦截
|
||||
if getattr(response, "status_code", 0) in (403, 429):
|
||||
await asyncio.sleep(2)
|
||||
response = await session.get(url, headers=headers, impersonate=impersonate, timeout=timeout)
|
||||
|
||||
return response
|
||||
except CurlError as e:
|
||||
return {"error": f"cURL 错误: {e}"}
|
||||
except Exception as e:
|
||||
return {"error": f"未知错误: {e}"}
|
||||
|
||||
|
||||
def is_challenge_response(resp) -> bool:
|
||||
if isinstance(resp, dict) and "error" in resp:
|
||||
return True
|
||||
try:
|
||||
status = getattr(resp, "status_code", None)
|
||||
text = getattr(resp, "text", "") or ""
|
||||
if status in (429, 403):
|
||||
return True
|
||||
low = text.lower()
|
||||
if any(k in low for k in ["cloudflare", "captcha", "cf-challenge", "just a moment"]):
|
||||
return True
|
||||
except Exception:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def search_user_with_fallback(url: str):
|
||||
# cookies.txt
|
||||
cookies = load_cookies_from_txt(exported_cookie_path)
|
||||
|
||||
headers = build_headers(RAW_BROWSER_HEADERS, cookies=cookies, ua_override=CUSTOM_UA)
|
||||
logger.info(f"请求 URL: {url}")
|
||||
logger.info(f"使用 headers 字段数: {len(headers)}")
|
||||
|
||||
resp = await fetch_with_cookies(url, headers)
|
||||
if is_challenge_response(resp):
|
||||
logger.warning("Cloudflare 拦截或 cookies 失效。")
|
||||
if isinstance(resp, dict):
|
||||
return resp
|
||||
return {"status": getattr(resp, "status_code", None), "preview": getattr(resp, "text", "")[:200]}
|
||||
else:
|
||||
logger.info("请求成功。")
|
||||
return getattr(resp, "json", lambda: resp)() if hasattr(resp, "json") else resp
|
||||
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import httpx
|
||||
import re
|
||||
from typing import Any, Dict, Optional
|
||||
from .get_bf6_data import *
|
||||
from .tracker_data import *
|
||||
|
||||
|
||||
async def get_data_bf3(player, player_id, user_id, platform):
|
||||
url = f"https://api.gametools.network/bf3/all/?format_values=true&playerid={player_id}&oid={user_id}&platform={platform}&skip_battlelog=false&lang=en-us"
|
||||
url = f"https://api.gametools.network/bf3/all/?format_values=true&name={player}&platform={platform}&skip_battlelog=false&lang=en-us"
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
# 创建两个并发任务
|
||||
task_id = asyncio.create_task(client.get(url_by_id))
|
||||
task_name = asyncio.create_task(client.get(url_by_name))
|
||||
|
||||
# 等待第一个完成的任务
|
||||
done, pending = await asyncio.wait(
|
||||
[task_id, task_name],
|
||||
return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
# 取消未完成的任务
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
# 检查已完成的任务是否成功且无 errors
|
||||
for task in done:
|
||||
try:
|
||||
response = await task
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if 'errors' not in data:
|
||||
logger.info(f"成功获取数据({'ID' if task is task_id else 'Name'}方式)")
|
||||
return data
|
||||
except (httpx.RequestError, ValueError) as e:
|
||||
logger.warning(f"请求或解析失败: {e}")
|
||||
if pending:
|
||||
pass
|
||||
# 如果所有尝试都失败,返回错误
|
||||
return {"errors": ["未找到玩家"]}
|
||||
|
||||
|
||||
async def get_data_bf4(player, player_id, user_id, platform):
|
||||
url_by_id = f"https://api.gametools.network/bf4/all/?format_values=true&playerid={player_id}&oid={user_id}&platform={platform}&skip_battlelog=false&lang=en-us"
|
||||
url_by_name = f"https://api.gametools.network/bf4/all/?format_values=true&name={player}&platform={platform}&skip_battlelog=false&lang=en-us"
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
# 创建两个并发任务
|
||||
task_id = asyncio.create_task(client.get(url_by_id))
|
||||
task_name = asyncio.create_task(client.get(url_by_name))
|
||||
|
||||
# 等待第一个完成的任务
|
||||
done, pending = await asyncio.wait(
|
||||
[task_id, task_name],
|
||||
return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
# 取消未完成的任务
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
# 检查已完成的任务是否成功且无 errors
|
||||
for task in done:
|
||||
try:
|
||||
response = await task
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if 'errors' not in data:
|
||||
logger.info(f"成功获取数据({'ID' if task is task_id else 'Name'}方式)")
|
||||
return data
|
||||
except (httpx.RequestError, ValueError) as e:
|
||||
logger.warning(f"请求或解析失败: {e}")
|
||||
if pending:
|
||||
pass
|
||||
# 如果所有尝试都失败,返回错误
|
||||
return {"errors": ["未找到玩家"]}
|
||||
|
||||
|
||||
async def get_data_bf1(player, player_id, user_id, platform):
|
||||
url_by_id = f"https://api.gametools.network/bf1/all/?format_values=true&playerid={player_id}&oid={user_id}&platform={platform}&skip_battlelog=false&lang=en-us"
|
||||
url_by_name = f"https://api.gametools.network/bf1/all/?format_values=true&name={player}&platform={platform}&skip_battlelog=false&lang=en-us"
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
# 创建两个并发任务
|
||||
task_id = asyncio.create_task(client.get(url_by_id))
|
||||
task_name = asyncio.create_task(client.get(url_by_name))
|
||||
|
||||
# 等待第一个完成的任务
|
||||
done, pending = await asyncio.wait(
|
||||
[task_id, task_name],
|
||||
return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
# 取消未完成的任务
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
# 检查已完成的任务是否成功且无 errors
|
||||
for task in done:
|
||||
try:
|
||||
response = await task
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if 'errors' not in data:
|
||||
logger.info(f"成功获取数据({'ID' if task is task_id else 'Name'}方式)")
|
||||
return data
|
||||
except (httpx.RequestError, ValueError) as e:
|
||||
logger.warning(f"请求或解析失败: {e}")
|
||||
if pending:
|
||||
pass
|
||||
# 如果所有尝试都失败,返回错误
|
||||
return {"errors": ["未找到玩家"]}
|
||||
|
||||
|
||||
async def get_data_bfv(player, player_id, user_id, platform):
|
||||
url_by_id = f"https://api.gametools.network/bfv/all/?format_values=true&playerid={player_id}&oid={user_id}&platform={platform}&skip_battlelog=false&lang=en-us"
|
||||
url_by_name = f"https://api.gametools.network/bfv/all/?format_values=true&name={player}&platform={platform}&skip_battlelog=false&lang=en-us"
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
# 创建两个并发任务
|
||||
task_id = asyncio.create_task(client.get(url_by_id))
|
||||
task_name = asyncio.create_task(client.get(url_by_name))
|
||||
|
||||
# 等待第一个完成的任务
|
||||
done, pending = await asyncio.wait(
|
||||
[task_id, task_name],
|
||||
return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
# 取消未完成的任务
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
# 检查已完成的任务是否成功且无 errors
|
||||
for task in done:
|
||||
try:
|
||||
response = await task
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if 'errors' not in data:
|
||||
logger.info(f"成功获取数据({'ID' if task is task_id else 'Name'}方式)")
|
||||
return data
|
||||
except (httpx.RequestError, ValueError) as e:
|
||||
logger.warning(f"请求或解析失败: {e}")
|
||||
if pending:
|
||||
pass
|
||||
# 如果所有尝试都失败,返回错误
|
||||
return {"errors": ["未找到玩家"]}
|
||||
|
||||
|
||||
async def get_data_bf6(player_name, player_id, user_id, platform):
|
||||
# 构造两个 URL
|
||||
url_by_id = f"https://api.gametools.network/bf6/all/?format_values=true&playerid={player_id}&oid={user_id}&platform={platform}&skip_battlelog=false&lang=en-us"
|
||||
url_by_name = f"https://api.gametools.network/bf6/all/?format_values=true&name={player_name}&platform={platform}&skip_battlelog=false&lang=en-us"
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
# 创建两个并发任务
|
||||
task_id = asyncio.create_task(client.get(url_by_id))
|
||||
task_name = asyncio.create_task(client.get(url_by_name))
|
||||
|
||||
# 等待第一个完成的任务
|
||||
done, pending = await asyncio.wait(
|
||||
[task_id, task_name],
|
||||
return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
# 取消未完成的任务
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
# 检查已完成的任务是否成功且无 errors
|
||||
for task in done:
|
||||
try:
|
||||
response = await task
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if 'errors' not in data:
|
||||
logger.info(f"成功获取数据({'ID' if task is task_id else 'Name'}方式)")
|
||||
return data
|
||||
except (httpx.RequestError, ValueError) as e:
|
||||
logger.warning(f"请求或解析失败: {e}")
|
||||
if pending:
|
||||
pass
|
||||
# 如果所有尝试都失败,返回错误
|
||||
return {"errors": ["未找到玩家"]}
|
||||
|
||||
|
||||
async def get_player_info_by_name(user_name, platform):
|
||||
user_info_url = f"https://api.gametools.network/bfglobal/player/?name={user_name}&platform={platform}&skip_battlelog=false"
|
||||
|
||||
payload = {}
|
||||
headers = {
|
||||
'accept': 'application/json'
|
||||
}
|
||||
logger.info(f"查询用户请求url:{user_info_url}")
|
||||
response = requests.request("GET", user_info_url, headers=headers, data=payload)
|
||||
|
||||
user_info_json = json.loads(response.text)
|
||||
return user_info_json
|
||||
|
||||
|
||||
async def get_player_info_by_ea_id(player_id, user_id, platform):
|
||||
url = f"https://api.gametools.network/bfglobal/player/?playerid={player_id}&oid={user_id}&platform={platform}&skip_battlelog=false"
|
||||
|
||||
payload = {}
|
||||
headers = {
|
||||
'accept': 'application/json'
|
||||
}
|
||||
|
||||
response = requests.request("GET", url, headers=headers, data=payload)
|
||||
|
||||
user_info_json = json.loads(response.text)
|
||||
return user_info_json
|
||||
|
||||
|
||||
def normalize_name(name: str) -> str:
|
||||
"""统一用户名格式(大小写 + 空格)"""
|
||||
return (name or "").strip().lower()
|
||||
|
||||
|
||||
def extract_rank(status: str) -> int:
|
||||
"""从 status 中提取 Rank 数值"""
|
||||
if not status:
|
||||
return 0
|
||||
match = re.search(r'Rank\s+(\d+)', status)
|
||||
return int(match.group(1)) if match else 0
|
||||
|
||||
async def get_bf6_rank(player,player_id, user_id, platform):
|
||||
url_by_name = f"https://api.gametools.network/bf6/profile/?name={player}&playerid=1234&nucleus_id=1234&platform=ea&skip_battlelog=true&lang=en-us"
|
||||
url_by_id = f"https://api.gametools.network/bf6/profile/?name={player}&playerid=1234&nucleus_id=1234&platform=ea&skip_battlelog=true&lang=en-us"
|
||||
payload = {}
|
||||
headers = {
|
||||
'accept': 'application/json'
|
||||
}
|
||||
|
||||
response = requests.request("GET", url, headers=headers, data=payload)
|
||||
result: Dict[str, Any] = response.json().get("playerProfiles")
|
||||
logger.info(f"Tracker用户信息:{result}")
|
||||
|
||||
if not result or "errors" in result:
|
||||
logger.warning(f"查询失败或返回错误: {player}")
|
||||
return 0
|
||||
|
||||
data = result.get("data") or []
|
||||
if not data:
|
||||
logger.info(f"未搜索到用户: {player}")
|
||||
return 0
|
||||
|
||||
norm_player = normalize_name(player)
|
||||
|
||||
# 评分函数(核心策略)
|
||||
def score(r: Dict[str, Any]) -> int:
|
||||
if not r or not r.get("status"):
|
||||
return -1
|
||||
|
||||
name = normalize_name(r.get("platformUserHandle"))
|
||||
platform = r.get("platformSlug")
|
||||
|
||||
s = 0
|
||||
|
||||
# 用户名匹配权重(最高优先级)
|
||||
if name == norm_player:
|
||||
s += 100
|
||||
elif norm_player in name:
|
||||
s += 50
|
||||
|
||||
# 平台权重(次优先级)
|
||||
if platform == "origin":
|
||||
s += 20
|
||||
|
||||
return s
|
||||
|
||||
# 选出最优 candidate
|
||||
target: Optional[Dict[str, Any]] = max(data, key=score, default=None)
|
||||
|
||||
if not target or score(target) <= 0:
|
||||
logger.info(f"未匹配到有效用户: {player}")
|
||||
return 0
|
||||
|
||||
# 提取信息
|
||||
name = target.get("platformUserHandle")
|
||||
uid = target.get("titleUserId")
|
||||
status = target.get("status", "")
|
||||
|
||||
rank = extract_rank(status)
|
||||
|
||||
user_info = {
|
||||
"name": name,
|
||||
"uid": uid,
|
||||
"rank": rank,
|
||||
"platform": target.get("platformSlug"),
|
||||
}
|
||||
|
||||
logger.info(f"最终匹配用户: {user_info}")
|
||||
|
||||
return rank
|
||||
@@ -0,0 +1,151 @@
|
||||
import os
|
||||
from functools import reduce
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from nonebot import logger
|
||||
|
||||
# from img_utils import png_resize
|
||||
# from param import round_data, interval_table
|
||||
from .param import round_data, interval_table
|
||||
|
||||
filepath = os.path.dirname(__file__).replace("\\", "/")
|
||||
|
||||
|
||||
# async def avatar_cache(avatar_url, player_id):
|
||||
# if avatar_url:
|
||||
# try:
|
||||
# # 添加10s超时判断,如果超时直接使用默认头像
|
||||
# res = BytesIO(requests.get(avatar_url, timeout=10).content)
|
||||
# avatar = Image.open(res).convert('RGBA')
|
||||
# avatar.save(f'{filepath}/avatar_cache/{player_id}.png', 'PNG')
|
||||
# return avatar
|
||||
# except requests.exceptions.RequestException as e:
|
||||
# logger.warning(f"请求异常:{e}")
|
||||
# else:
|
||||
# return None
|
||||
#
|
||||
#
|
||||
# async def get_avatar(avatar_url, player_id):
|
||||
# img = Image.open(filepath + "/avatar_cache/0.png").convert('RGBA')
|
||||
# path = filepath + "/avatar_cache/"
|
||||
# try:
|
||||
# avatar_list = os.listdir(path)
|
||||
# if player_id in str(avatar_list):
|
||||
# logger.info(f"本地已存在{player_id}头像")
|
||||
# img = Image.open(f"{path}{player_id}.png").convert('RGBA')
|
||||
# else:
|
||||
# logger.info(f"未检测到{player_id}头像,缓存至本地")
|
||||
# out_put = filepath + f"/avatar_cache/{player_id}.png"
|
||||
# img = await avatar_cache(avatar_url, out_put)
|
||||
# except Exception as err:
|
||||
# logger.error(f"获取头像失败:{str(err)}")
|
||||
# return img
|
||||
|
||||
|
||||
async def get_best_weapon_and_best_vehicle(player_stat):
|
||||
weapon = sorted(player_stat["weapons"], key=lambda k: k['kills'], reverse=True)[0]
|
||||
vehicle = sorted(player_stat["vehicles"], key=lambda k: k['kills'], reverse=True)[0]
|
||||
return weapon, vehicle
|
||||
|
||||
|
||||
async def get_best_classes(player_stat):
|
||||
classes_data = player_stat['classes']
|
||||
data = [obj for obj in classes_data if obj.get("className") != "All"]
|
||||
best_classes = max(data, key=lambda x: x["kills"])
|
||||
best_classes_name = best_classes["className"]
|
||||
return best_classes_name
|
||||
|
||||
|
||||
async def get_vehicle_destroyed(player_stat):
|
||||
vehicle = reduce(lambda acc, x: acc + x['destroyed'], player_stat, 0)
|
||||
return vehicle
|
||||
|
||||
|
||||
async def stats_calculator(player_stat, game):
|
||||
per500_data = round_data[game]
|
||||
# 场次(无直接数值反馈,采用胜利数+失败数)
|
||||
match_played = player_stat["wins"] + player_stat["loses"]
|
||||
|
||||
kd = player_stat["killDeath"]
|
||||
kpm = player_stat["killsPerMinute"]
|
||||
kill_assists = player_stat["killAssists"]
|
||||
acc = float(player_stat["accuracy"].replace("%", ""))
|
||||
if game == "bf3":
|
||||
headshots = float(0)
|
||||
else:
|
||||
headshots = float(player_stat["headshots"].replace("%", ""))
|
||||
spm = player_stat["scorePerMinute"]
|
||||
revives = player_stat["revives"]
|
||||
|
||||
# second_play = player_stat["secondsPlayed"]
|
||||
|
||||
apr = kill_assists / match_played
|
||||
rpr = revives / match_played
|
||||
|
||||
# SPM*秒数/60/场数=场均得分
|
||||
# dpr = score_perMinute * (second_play / 60) / match_played
|
||||
|
||||
# 计算比值
|
||||
# kd
|
||||
ratio_kd = round(kd / per500_data["kd"], 2)
|
||||
# kpm
|
||||
ratio_kpm = round(kpm / per500_data["kpm"], 2)
|
||||
# 爆头率
|
||||
ratio_hs = round(headshots / 100 / per500_data["hs"], 2)
|
||||
# 场均救援
|
||||
ratio_rpr = round(rpr / per500_data["rpr"], 2)
|
||||
# 场均助攻
|
||||
ratio_apr = round(apr / per500_data["apr"], 2)
|
||||
# 分钟得分
|
||||
ratio_spm = round(spm / per500_data["spm"], 2)
|
||||
logger.info(f"雷达图数据")
|
||||
logger.info(f"生涯KD比值:{ratio_kd}")
|
||||
logger.info(f"生涯Kpm比值:{ratio_kpm}")
|
||||
logger.info(f"生涯准度比值:{ratio_hs}")
|
||||
logger.info(f"场均救援比值:{ratio_rpr}")
|
||||
logger.info(f"场均助攻比值:{ratio_apr}")
|
||||
logger.info(f"分钟得分比值:{ratio_spm}")
|
||||
|
||||
stat_data = [ratio_kd, ratio_apr, ratio_hs, ratio_spm, ratio_kpm, ratio_rpr]
|
||||
|
||||
# 血腥度 kd*0.5 + kpm *0.4 +hs*0.1
|
||||
player_blood = kd * 0.5 + kpm * 0.4 + headshots * 0.1
|
||||
ratio_blood = player_blood / per500_data["blood"]
|
||||
|
||||
# 贡献度 场均助攻*0.4 + 场均救援*0.4 + spm*0.2
|
||||
player_dedication = rpr * 0.4 + apr * 0.4 + spm * 0.2
|
||||
ratio_dedication = player_dedication / per500_data["dedication"]
|
||||
|
||||
# 全能度 血腥度*0.5 + 贡献度*0.5
|
||||
player_all_round = player_blood * 0.5 + player_dedication * 0.5
|
||||
ratio_all_round = player_all_round / per500_data["all_round"]
|
||||
|
||||
logger.info(f"称号评级数据")
|
||||
logger.info(f"血腥度:{player_blood} 比值:{ratio_blood}")
|
||||
logger.info(f"贡献度:{player_dedication} 比值:{ratio_dedication}")
|
||||
logger.info(f"全能度:{player_all_round} 比值:{ratio_all_round}")
|
||||
level_designation = get_level_designation(ratio_blood, ratio_dedication, ratio_all_round)
|
||||
return stat_data, level_designation
|
||||
|
||||
|
||||
def get_level_designation(blood, dedication, all_round):
|
||||
feature_type = ""
|
||||
final_max = max([blood, dedication, all_round])
|
||||
if final_max == blood:
|
||||
feature_type = "blood"
|
||||
elif final_max == dedication:
|
||||
feature_type = "dedication"
|
||||
elif final_max == all_round:
|
||||
feature_type = "all_round"
|
||||
logger.info(f"最高能力:{feature_type}")
|
||||
logger.info(f"最高能力值:{final_max}")
|
||||
final_info = get_feature(final_max, feature_type)
|
||||
return final_info
|
||||
|
||||
|
||||
def get_feature(value, feature_type):
|
||||
"""根据输入值和数值类别,返回对应特征值"""
|
||||
for (low, high), features in interval_table.items():
|
||||
if low <= value < high:
|
||||
return features[feature_type]
|
||||
raise ValueError(f"数值 {value} 不在任何区间内")
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
|
||||
def bind_user(user_info):
|
||||
user_info['']
|
||||
@@ -0,0 +1,67 @@
|
||||
import json
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
async def get_data_bf6(player_id, user_id, platform):
|
||||
url = "https://api.gametools.network/bf6/multiple/?raw=false&format_values=true"
|
||||
payload = json.dumps([
|
||||
{
|
||||
"player_id": player_id,
|
||||
"user_id": user_id,
|
||||
"platform": platform
|
||||
}
|
||||
])
|
||||
|
||||
headers = {
|
||||
'accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
response = requests.request("POST", url, headers=headers, data=payload)
|
||||
info = json.loads(response.text)
|
||||
return info
|
||||
|
||||
|
||||
async def get_data_user(user_name, platform):
|
||||
url = f"https://api.gametools.network/bfglobal/player/?name={user_name}&platform={platform}&skip_battlelog=false"
|
||||
|
||||
payload = {}
|
||||
headers = {
|
||||
'accept': 'application/json'
|
||||
}
|
||||
|
||||
response = requests.request("GET", url, headers=headers, data=payload)
|
||||
data_json = json.loads(response.text)
|
||||
|
||||
player_id = data_json["personaId"]
|
||||
user_id = data_json["userId"]
|
||||
platform = data_json["platform"]
|
||||
if 'personaName' in data_json:
|
||||
user_name = data_json["personaName"]
|
||||
elif 'displayName' in data_json:
|
||||
user_name = data_json["displayName"]
|
||||
elif 'username' in data_json:
|
||||
user_name = data_json["username"]
|
||||
else:
|
||||
data_json = 'errors'
|
||||
|
||||
if "errors" in data_json:
|
||||
return False, data_json
|
||||
|
||||
user_info = {
|
||||
"player_id": player_id,
|
||||
"user_id": user_id,
|
||||
"platform": platform,
|
||||
"user_name": user_name
|
||||
}
|
||||
|
||||
return True, user_info
|
||||
|
||||
|
||||
async def get_player_game_info(user_name, platform):
|
||||
flag, user_info = await get_data_user(user_name, platform)
|
||||
info = await get_data_bf6(user_info['player_id'], user_info['user_id'], user_info['platform'])
|
||||
info['userName'] = user_name
|
||||
info_format = json.dumps(info, ensure_ascii=False, indent=2)
|
||||
print(info_format)
|
||||
@@ -0,0 +1,271 @@
|
||||
from nonebot import logger
|
||||
import json
|
||||
|
||||
# from bf6_data import *
|
||||
import requests
|
||||
from curl_cffi import AsyncSession, CurlError
|
||||
from nonebot import logger
|
||||
|
||||
from .bf6_data import *
|
||||
|
||||
url_search = "https://api.tracker.gg/api/v2/bf6/standard/search?platform={platform}&query={name}&autocomplete=true"
|
||||
url_overview = "https://api.tracker.gg/api/v2/bf6/standard/profile/ign/{param}"
|
||||
|
||||
|
||||
async def format_data(response, search_type):
|
||||
status = ""
|
||||
|
||||
platform_info = response['data']['platformInfo']
|
||||
segments = response['data']['segments']
|
||||
|
||||
overview = ""
|
||||
weapons = []
|
||||
vehicles = []
|
||||
gamemodes = []
|
||||
gadgets = []
|
||||
kits = []
|
||||
maps = []
|
||||
|
||||
for segment in segments:
|
||||
if segment['type'] == 'overview':
|
||||
overview = segment
|
||||
elif segment['type'] == 'weapon':
|
||||
weapons.append(segment)
|
||||
elif segment['type'] == 'vehicle':
|
||||
vehicles.append(segment)
|
||||
elif segment['type'] == 'gamemode':
|
||||
gamemodes.append(segment)
|
||||
elif segment['type'] == 'gadget':
|
||||
gadgets.append(segment)
|
||||
elif segment['type'] == 'kit':
|
||||
kits.append(segment)
|
||||
elif segment['type'] == 'level':
|
||||
maps.append(segment)
|
||||
if search_type == 0:
|
||||
status = await get_overview(platform_info, overview, weapons, vehicles, gamemodes, gadgets, kits, maps)
|
||||
if search_type == 1:
|
||||
status = await get_weapons(platform_info, overview, weapons, vehicles, gamemodes, gadgets, kits, maps)
|
||||
|
||||
return status
|
||||
|
||||
|
||||
async def get_user_id_2_data(title_id, search_type):
|
||||
url = url_overview.format(param=title_id)
|
||||
info = await search_user_with_fallback(url)
|
||||
info = await format_data(info, search_type)
|
||||
return info
|
||||
|
||||
|
||||
# 调用此方法获取格式化后的数据(调用此方法即可)
|
||||
async def get_info(player, search_type):
|
||||
# 先使用橘子搜索
|
||||
url = url_search.format(platform="orign", name=player)
|
||||
result = await search_user_with_fallback(url)
|
||||
# 无结果再使用steam搜索
|
||||
if "errors" in result:
|
||||
url = url_search.format(platform="steam", name=player)
|
||||
result = await search_user_with_fallback(url)
|
||||
title_id_list = []
|
||||
logger.info(f"查询结果: \n{json.dumps(result, ensure_ascii=False, indent=2)}")
|
||||
if "status" in result or "errors" in result:
|
||||
return 3, '查询异常'
|
||||
for res in result['data']:
|
||||
if res['status'] is not None:
|
||||
name = res['platformUserHandle']
|
||||
uid = res['titleUserId']
|
||||
status = res['status'].strip().split('•', 1)[0].replace("Rank", "")
|
||||
user = {
|
||||
'name': name,
|
||||
'rank': status,
|
||||
'uid': uid,
|
||||
}
|
||||
title_id_list.append(user)
|
||||
if len(title_id_list) > 1:
|
||||
user_list = sorted(title_id_list, key=lambda k: int(k['rank']), reverse=True)
|
||||
msg = '\n'.join(f'用户名:{info["name"]}-等级:{info["rank"]}-UID:{info["uid"]}' for info in user_list)
|
||||
logger.info(f"检测到多个同名用户\n{msg}")
|
||||
return 0, title_id_list
|
||||
elif len(title_id_list) == 1:
|
||||
logger.info(f"单用户{title_id_list}")
|
||||
info = await get_user_id_2_data(title_id_list[0]['uid'], search_type)
|
||||
logger.info(f"查询结果: {json.dumps(info, ensure_ascii=False, indent=2)}")
|
||||
return 1, info
|
||||
else:
|
||||
logger.info(f"未查询到用户")
|
||||
return 2, '未查询到用户'
|
||||
|
||||
|
||||
async def get_overview(platform_info, overview, weapons, vehicles, gamemodes, gadgets, kits, maps):
|
||||
top_kill_weapon = sorted(weapons, key=lambda k: k['stats']['kills']['value'], reverse=True)[0]
|
||||
best_weapon = {
|
||||
"名称": top_kill_weapon['metadata']['name'],
|
||||
"击杀": top_kill_weapon['stats']['kills']['value'],
|
||||
"KPM": top_kill_weapon['stats']['killsPerMinute']['value'],
|
||||
"时长": top_kill_weapon['stats']['timePlayed']['displayValue'],
|
||||
"爆头率": top_kill_weapon['stats']['headshotPercentage']['displayValue'],
|
||||
"命中率": top_kill_weapon['stats']['shotsAccuracy']['displayValue']
|
||||
}
|
||||
top_kill_vehicle = sorted(vehicles, key=lambda k: k['stats']['kills']['value'], reverse=True)[0]
|
||||
best_vehicle = {
|
||||
"名称": top_kill_vehicle['metadata']['name'],
|
||||
"击杀": top_kill_vehicle['stats']['kills']['value'],
|
||||
"KPM": top_kill_vehicle['stats']['killsPerMinute']['value'],
|
||||
"时长": top_kill_vehicle['stats']['timePlayed']['displayValue'],
|
||||
"碾压": top_kill_vehicle['stats']['roadKills']['value'],
|
||||
"摧毁": top_kill_vehicle['stats']['destroyedWith']['value'],
|
||||
|
||||
}
|
||||
shot_count = overview['stats']['shotsFired']['value']
|
||||
hit_count = overview['stats']['shotsHit']['value']
|
||||
logger.info(f"开火数:{shot_count}")
|
||||
logger.info(f"命中数:{hit_count}")
|
||||
acc = round((hit_count / shot_count) * 100, 2)
|
||||
logger.info(f"命中率:{acc}%")
|
||||
player_info = {
|
||||
'玩家名称': platform_info['platformUserHandle'],
|
||||
'游玩平台': platform_info['platformSlug'],
|
||||
'游戏等级': overview['stats']['careerPlayerRank']['displayValue'],
|
||||
'游玩时长': overview['stats']['timePlayed']['displayValue'],
|
||||
'游玩场次': overview['stats']['matchesPlayed']['displayValue'],
|
||||
'对局胜率': overview['stats']['wlPercentage']['displayValue'],
|
||||
'对局得分': overview['stats']['score']['displayValue'],
|
||||
'玩家K/D': overview['stats']['kdRatio']['value'],
|
||||
'玩家KPM': overview['stats']['killsPerMinute']['value'],
|
||||
'玩家SPM': overview['stats']['scorePerMinute']['value'],
|
||||
'真人击杀': overview['stats']['playerKills']['displayValue'],
|
||||
'击杀总计': overview['stats']['kills']['displayValue'],
|
||||
'目标占领': overview['stats']['objectivesCaptured']['value'],
|
||||
'游戏助攻': overview['stats']['assists']['displayValue'],
|
||||
'救援数量': overview['stats']['revives']['displayValue'],
|
||||
'最高连杀': overview['stats']['multiKills']['value'],
|
||||
'命中率': f"{acc}%",
|
||||
'爆头率': overview['stats']['headshotPercentage']['displayValue'],
|
||||
'最佳武器': best_weapon,
|
||||
'最佳载具': best_vehicle,
|
||||
}
|
||||
return player_info
|
||||
|
||||
|
||||
async def get_weapons(platform_info, overview, weapons, vehicles, gamemodes, gadgets, kits, maps):
|
||||
top_kill_weapon = sorted(weapons, key=lambda k: k['stats']['kills']['value'], reverse=True)[0]
|
||||
best_weapon = {
|
||||
"名称": top_kill_weapon['metadata']['name'],
|
||||
"击杀": top_kill_weapon['stats']['kills']['value'],
|
||||
"KPM": top_kill_weapon['stats']['killsPerMinute']['value'],
|
||||
"时长": top_kill_weapon['stats']['timePlayed']['displayValue'],
|
||||
"爆头率": top_kill_weapon['stats']['headshotPercentage']['displayValue'],
|
||||
"命中率": top_kill_weapon['stats']['shotsAccuracy']['displayValue']
|
||||
}
|
||||
top_kill_vehicle = sorted(vehicles, key=lambda k: k['stats']['kills']['value'], reverse=True)[0]
|
||||
best_vehicle = {
|
||||
"名称": top_kill_vehicle['metadata']['name'],
|
||||
"击杀": top_kill_vehicle['stats']['kills']['value'],
|
||||
"KPM": top_kill_vehicle['stats']['killsPerMinute']['value'],
|
||||
"时长": top_kill_vehicle['stats']['timePlayed']['displayValue'],
|
||||
"碾压": top_kill_vehicle['stats']['roadKills']['value'],
|
||||
"摧毁": top_kill_vehicle['stats']['destroyedWith']['value'],
|
||||
|
||||
}
|
||||
shot_count = overview['stats']['shotsFired']['value']
|
||||
hit_count = overview['stats']['shotsHit']['value']
|
||||
logger.info(f"开火数:{shot_count}")
|
||||
logger.info(f"命中数:{hit_count}")
|
||||
acc = round((hit_count / shot_count) * 100, 2)
|
||||
logger.info(f"命中率:{acc}%")
|
||||
player_info = {
|
||||
'玩家名称': platform_info['platformUserHandle'],
|
||||
'游玩平台': platform_info['platformSlug'],
|
||||
'游戏等级': overview['stats']['careerPlayerRank']['displayValue'],
|
||||
'游玩时长': overview['stats']['timePlayed']['displayValue'],
|
||||
'游玩场次': overview['stats']['matchesPlayed']['displayValue'],
|
||||
'对局胜率': overview['stats']['wlPercentage']['displayValue'],
|
||||
'对局得分': overview['stats']['score']['displayValue'],
|
||||
'玩家K/D': overview['stats']['kdRatio']['value'],
|
||||
'玩家KPM': overview['stats']['killsPerMinute']['value'],
|
||||
'玩家SPM': overview['stats']['scorePerMinute']['value'],
|
||||
'真人击杀': overview['stats']['playerKills']['displayValue'],
|
||||
'击杀总计': overview['stats']['kills']['displayValue'],
|
||||
'目标占领': overview['stats']['objectivesCaptured']['value'],
|
||||
'游戏助攻': overview['stats']['assists']['displayValue'],
|
||||
'救援数量': overview['stats']['revives']['displayValue'],
|
||||
'最高连杀': overview['stats']['multiKills']['value'],
|
||||
'命中率': f"{acc}%",
|
||||
'爆头率': overview['stats']['headshotPercentage']['displayValue'],
|
||||
'最佳武器': best_weapon,
|
||||
'最佳载具': best_vehicle,
|
||||
}
|
||||
return player_info
|
||||
|
||||
|
||||
async def get_vehicles(platform_info, overview, weapons, vehicles, gamemodes, gadgets, kits, maps):
|
||||
top_kill_weapon = sorted(weapons, key=lambda k: k['stats']['kills']['value'], reverse=True)[0]
|
||||
best_weapon = {
|
||||
"名称": top_kill_weapon['metadata']['name'],
|
||||
"击杀": top_kill_weapon['stats']['kills']['value'],
|
||||
"KPM": top_kill_weapon['stats']['killsPerMinute']['value'],
|
||||
"时长": top_kill_weapon['stats']['timePlayed']['displayValue'],
|
||||
"爆头率": top_kill_weapon['stats']['headshotPercentage']['displayValue'],
|
||||
"命中率": top_kill_weapon['stats']['shotsAccuracy']['displayValue']
|
||||
}
|
||||
top_kill_vehicle = sorted(vehicles, key=lambda k: k['stats']['kills']['value'], reverse=True)[0]
|
||||
best_vehicle = {
|
||||
"名称": top_kill_vehicle['metadata']['name'],
|
||||
"击杀": top_kill_vehicle['stats']['kills']['value'],
|
||||
"KPM": top_kill_vehicle['stats']['killsPerMinute']['value'],
|
||||
"时长": top_kill_vehicle['stats']['timePlayed']['displayValue'],
|
||||
"碾压": top_kill_vehicle['stats']['roadKills']['value'],
|
||||
"摧毁": top_kill_vehicle['stats']['destroyedWith']['value'],
|
||||
|
||||
}
|
||||
shot_count = overview['stats']['shotsFired']['value']
|
||||
hit_count = overview['stats']['shotsHit']['value']
|
||||
logger.info(f"开火数:{shot_count}")
|
||||
logger.info(f"命中数:{hit_count}")
|
||||
acc = round((hit_count / shot_count) * 100, 2)
|
||||
logger.info(f"命中率:{acc}%")
|
||||
player_info = {
|
||||
'玩家名称': platform_info['platformUserHandle'],
|
||||
'游玩平台': platform_info['platformSlug'],
|
||||
'游戏等级': overview['stats']['careerPlayerRank']['displayValue'],
|
||||
'游玩时长': overview['stats']['timePlayed']['displayValue'],
|
||||
'游玩场次': overview['stats']['matchesPlayed']['displayValue'],
|
||||
'对局胜率': overview['stats']['wlPercentage']['displayValue'],
|
||||
'对局得分': overview['stats']['score']['displayValue'],
|
||||
'玩家K/D': overview['stats']['kdRatio']['value'],
|
||||
'玩家KPM': overview['stats']['killsPerMinute']['value'],
|
||||
'玩家SPM': overview['stats']['scorePerMinute']['value'],
|
||||
'真人击杀': overview['stats']['playerKills']['displayValue'],
|
||||
'击杀总计': overview['stats']['kills']['displayValue'],
|
||||
'目标占领': overview['stats']['objectivesCaptured']['value'],
|
||||
'游戏助攻': overview['stats']['assists']['displayValue'],
|
||||
'救援数量': overview['stats']['revives']['displayValue'],
|
||||
'最高连杀': overview['stats']['multiKills']['value'],
|
||||
'命中率': f"{acc}%",
|
||||
'爆头率': overview['stats']['headshotPercentage']['displayValue'],
|
||||
'最佳武器': best_weapon,
|
||||
'最佳载具': best_vehicle,
|
||||
}
|
||||
return player_info
|
||||
|
||||
|
||||
async def get_bf6_data(player_id, user_id, platform):
|
||||
|
||||
url = "https://api.gametools.network/bf6/multiple/?raw=false&format_values=true"
|
||||
|
||||
payload = json.dumps([
|
||||
{
|
||||
"player_id": player_id,
|
||||
"user_id": user_id,
|
||||
"platform": platform
|
||||
}
|
||||
])
|
||||
headers = {
|
||||
'accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
response = requests.request("POST", url, headers=headers, data=payload)
|
||||
|
||||
player_stat_json = json.loads(response.text)
|
||||
|
||||
return player_stat_json
|
||||
@@ -0,0 +1,592 @@
|
||||
import datetime
|
||||
import io
|
||||
import re
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from .data_utils import *
|
||||
from .img_utils import *
|
||||
from .param import *
|
||||
|
||||
# from img_utils import *
|
||||
# from data_utils import *
|
||||
# from param import *
|
||||
|
||||
filepath = os.path.dirname(__file__).replace("\\", "/")
|
||||
|
||||
# 字体
|
||||
font_XXL = ImageFont.truetype(f"{filepath}/font/bf-sub-headline-bold 等宽数字.ttf", 72)
|
||||
font_XL = ImageFont.truetype(f"{filepath}/font/bf-sub-headline-bold 等宽数字.ttf", 64)
|
||||
font_L = ImageFont.truetype(f"{filepath}/font/bf-sub-headline-bold 等宽数字.ttf", 48)
|
||||
font_ML = ImageFont.truetype(f"{filepath}/font/bf-sub-headline-bold 等宽数字.ttf", 42)
|
||||
font_M = ImageFont.truetype(f"{filepath}/font/bf-sub-headline-bold 等宽数字.ttf", 36)
|
||||
font_MS = ImageFont.truetype(f"{filepath}/font/bf-sub-headline-bold 等宽数字.ttf", 32)
|
||||
font_S = ImageFont.truetype(f"{filepath}/font/bf-sub-headline-bold 等宽数字.ttf", 28)
|
||||
font_XS = ImageFont.truetype(f"{filepath}/font/bf-sub-headline-bold 等宽数字.ttf", 24)
|
||||
font_XXS = ImageFont.truetype(f"{filepath}/font/bf-sub-headline-bold 等宽数字.ttf", 18)
|
||||
font_XXXS = ImageFont.truetype(f"{filepath}/font/bf-sub-headline-bold 等宽数字.ttf", 16)
|
||||
|
||||
font_XXL_CH = ImageFont.truetype(f"{filepath}/font/演示创黑FLY.ttf", 72)
|
||||
font_XL_CH = ImageFont.truetype(f"{filepath}/font/演示创黑FLY.ttf", 64)
|
||||
font_L_CH = ImageFont.truetype(f"{filepath}/font/演示创黑FLY.ttf", 48)
|
||||
font_ML_CH = ImageFont.truetype(f"{filepath}/font/演示创黑FLY.ttf", 42)
|
||||
font_M_CH = ImageFont.truetype(f"{filepath}/font/演示创黑FLY.ttf", 36)
|
||||
font_MS_CH = ImageFont.truetype(f"{filepath}/font/演示创黑FLY.ttf", 32)
|
||||
font_S_CH = ImageFont.truetype(f"{filepath}/font/演示创黑FLY.ttf", 28)
|
||||
font_XS_CH = ImageFont.truetype(f"{filepath}/font/演示创黑FLY.ttf", 24)
|
||||
font_XXS_CH = ImageFont.truetype(f"{filepath}/font/演示创黑FLY.ttf", 18)
|
||||
font_XXXS_CH = ImageFont.truetype(f"{filepath}/font/演示创黑FLY.ttf", 16)
|
||||
font_S_CH = ImageFont.truetype(f"{filepath}/font/演示创黑FLY.ttf", 28)
|
||||
font_XS_CH = ImageFont.truetype(f"{filepath}/font/演示创黑FLY.ttf", 20)
|
||||
font_XXS_CH = ImageFont.truetype(f"{filepath}/font/演示创黑FLY.ttf", 18)
|
||||
font_XXXS_CH = ImageFont.truetype(f"{filepath}/font/演示创黑FLY.ttf", 16)
|
||||
|
||||
|
||||
async def build_stats_card(game="bfv", player="Player001", pid=1145141919, kd=1.25, kpm=2.3, spm=500, acc=25.6,
|
||||
head_shots=10, rank=100, time_play=100, kills=100, kill_assists=150, revives=114, wins=10,
|
||||
loses=10, destroyed=514, best_weapon="M1907 SF", best_vehicle="坦克", best_class="Support",
|
||||
longest_head_shot=1200, highest_ill_streak=100, stat_data=None, level_designation=None):
|
||||
if stat_data is None:
|
||||
stat_data = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
|
||||
|
||||
# 获取当前事件并且格式化
|
||||
now = datetime.datetime.now()
|
||||
formatted_time = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# ---------------- OpenCV 部分 ----------------
|
||||
# 载入模板
|
||||
img = cv2.imread(f"{filepath}/template/{game}.png", cv2.IMREAD_COLOR)
|
||||
|
||||
# 载入头像并缩放
|
||||
avatar = cv2.imread(f"{filepath}/avatar_cache/0.png", cv2.IMREAD_UNCHANGED)
|
||||
avatar = cv2.resize(avatar, (200, 200), interpolation=cv2.INTER_AREA)
|
||||
|
||||
# 创建圆形蒙版
|
||||
mask = np.zeros((200, 200), dtype=np.uint8)
|
||||
cv2.circle(mask, (100, 100), 100, 255, -1) # 绘制半径100的实心圆
|
||||
|
||||
# 处理透明通道
|
||||
if avatar.shape[2] == 4:
|
||||
# 合并圆形蒙版与原有alpha通道
|
||||
alpha = cv2.bitwise_and(avatar[:, :, 3], mask)
|
||||
avatar[:, :, 3] = alpha
|
||||
else:
|
||||
# 无透明通道时创建BGRA图像
|
||||
avatar = cv2.cvtColor(avatar, cv2.COLOR_BGR2BGRA)
|
||||
avatar[:, :, 3] = mask
|
||||
|
||||
# 粘贴头像(保持原有坐标)
|
||||
y, x = 55, 426
|
||||
h, w = avatar.shape[:2]
|
||||
alpha = avatar[:, :, 3] / 255.0
|
||||
for c in range(3):
|
||||
img[y:y + h, x:x + w, c] = (alpha * avatar[:, :, c] +
|
||||
(1 - alpha) * img[y:y + h, x:x + w, c])
|
||||
|
||||
# 公告栏
|
||||
img = notice_paste(img)
|
||||
|
||||
# ---------------- PIL 部分 ----------------
|
||||
pil_img = Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
|
||||
draw = ImageDraw.Draw(pil_img)
|
||||
|
||||
# 玩家信息
|
||||
draw.text((665, 65), f"{player}", fill="white", font=font_L_CH)
|
||||
draw.text((745, 133), f"{pid}", fill="white", font=font_M_CH)
|
||||
|
||||
# 等级游玩时长
|
||||
draw.text((740, 212), f"{rank}", fill="white", font=font_M_CH)
|
||||
draw.text((985, 212), f"{time_play}H", fill="white", font=font_M_CH)
|
||||
# draw_centered_text(draw=draw, text=rank, x_left=730, x_right=905, y=210, font=font_M, fill="white", )
|
||||
# draw_centered_text(draw=draw, text=f"{time_play}H", x_left=965, x_right=1120, y=210, font=font_M, fill="white", )
|
||||
# draw.text((730, 195), f"{rank}", fill="white", font=font_M)
|
||||
# draw.text((1000, 195), f"{time_play}", fill="white", font=font_M)
|
||||
|
||||
# 生涯总览
|
||||
|
||||
# 场次
|
||||
draw_centered_text(draw=draw, text=(wins + loses), x_left=45, x_right=265, y=350,
|
||||
font=font_L_CH,
|
||||
fill="white")
|
||||
|
||||
# 命中率
|
||||
draw_centered_text(draw=draw, text=round(float(acc.replace("%", "")), 1), x_left=415, x_right=495, y=343,
|
||||
font=font_M_CH,
|
||||
fill="white")
|
||||
# 爆头率
|
||||
if head_shots:
|
||||
head_shots = head_shots.replace("%", "")
|
||||
else:
|
||||
head_shots = 0
|
||||
draw_centered_text(draw=draw, text=round(float(head_shots), 1), x_left=395, x_right=470, y=420, font=font_M_CH,
|
||||
fill="white")
|
||||
# kd
|
||||
draw_centered_text(draw=draw, text=kd, x_left=585, x_right=785, y=340, font=font_XL_CH, fill="white")
|
||||
# kpm
|
||||
draw_centered_text(draw=draw, text=round(kpm, 1), x_left=825, x_right=1025, y=340, font=font_XL_CH, fill="white")
|
||||
# spm
|
||||
draw_centered_text(draw=draw, text=round(spm, 1), x_left=1065, x_right=1265, y=340, font=font_XL_CH, fill="white")
|
||||
|
||||
# 击杀
|
||||
draw_centered_text(draw=draw, text=kills, x_left=870, x_right=1190, y=595, font=font_L_CH, fill="white")
|
||||
|
||||
# 助攻
|
||||
draw_centered_text(draw=draw, text=kill_assists, x_left=820, x_right=1145, y=770, font=font_L_CH, fill="white")
|
||||
|
||||
# 急救
|
||||
draw_centered_text(draw=draw, text=revives, x_left=770, x_right=1090, y=960, font=font_L_CH, fill="white")
|
||||
|
||||
# 摧毁
|
||||
draw_centered_text(draw=draw, text=destroyed, x_left=720, x_right=1040, y=1140, font=font_L_CH, fill="white")
|
||||
|
||||
# 底部栏
|
||||
|
||||
# 评级
|
||||
if game == 'bf6':
|
||||
logger.info("bf6不计算称号")
|
||||
else:
|
||||
level = level_designation['level']
|
||||
des = level_designation['designation']
|
||||
color = level_designation['color']
|
||||
draw_centered_text(draw=draw, text=level, x_left=70, x_right=200, y=1120, font=font_XL_CH, fill=color)
|
||||
draw_centered_text(draw=draw, text=des, x_left=200, x_right=550, y=1120, font=font_XL_CH, fill="white")
|
||||
|
||||
# 最佳兵种
|
||||
|
||||
draw_centered_text(draw=draw, text=classes[best_class], x_left=140, x_right=310, y=1290, font=font_L_CH,
|
||||
fill="white")
|
||||
|
||||
# 最远击杀
|
||||
draw_centered_text(draw=draw, text=f"{longest_head_shot}m", x_left=455, x_right=625, y=1290, font=font_L_CH,
|
||||
fill="white")
|
||||
|
||||
# 最高连杀
|
||||
draw_centered_text(draw=draw, text=highest_ill_streak, x_left=765, x_right=935, y=1290, font=font_L_CH,
|
||||
fill="white")
|
||||
|
||||
# 最佳⭐
|
||||
pil_img = build_best(draw, pil_img, best_weapon, best_vehicle, game)
|
||||
|
||||
# 生成时间
|
||||
draw.text((1870, 1420), f"{formatted_time}", fill=(154, 132, 149), font=font_XXXS_CH)
|
||||
|
||||
# ---------------- 转回 OpenCV ----------------
|
||||
img = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
|
||||
|
||||
# ---------------- 雷达图 ----------------
|
||||
if game != "bf6":
|
||||
center = (311, 796)
|
||||
r = 185
|
||||
scale_min = 0.25 if min(stat_data) < 0.25 else min(stat_data)
|
||||
img = draw_radar(img, stat_data, center=center, r=r,
|
||||
buffer=0.1, scale_min=scale_min, scale_max=1.10)
|
||||
# 保存
|
||||
success, buffer = cv2.imencode(".png", img)
|
||||
if success:
|
||||
b_io = io.BytesIO(buffer)
|
||||
return b_io # 就可以直接 return 二进制流
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
async def build_bf6_stats_card(game="bfv",
|
||||
qq_id=2931,
|
||||
player="Player001",
|
||||
pid=1145141919,
|
||||
rank=114,
|
||||
kd=1.25,
|
||||
kpm=2.3,
|
||||
spm=500,
|
||||
acc=25.6,
|
||||
head_shots=10,
|
||||
time_play=100,
|
||||
kills=100,
|
||||
kill_assists=150,
|
||||
revives=114,
|
||||
repairs=1000,
|
||||
captured=300,
|
||||
score=145674,
|
||||
wins=10,
|
||||
loses=10,
|
||||
destroyed=514,
|
||||
best_weapon="M1907 SF",
|
||||
best_vehicle="坦克",
|
||||
best_class="Support", ):
|
||||
# 获取当前事件并且格式化
|
||||
now = datetime.datetime.now()
|
||||
formatted_time = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# ---------------- OpenCV 部分 ----------------
|
||||
# 载入模板
|
||||
img = cv2.imread(f"{filepath}/template/{game}.png", cv2.IMREAD_COLOR)
|
||||
|
||||
# 载入头像并缩放
|
||||
avatar = cv2.imread(f"{filepath}/avatar_cache/0.png", cv2.IMREAD_UNCHANGED)
|
||||
avatar = cv2.resize(avatar, (134, 134), interpolation=cv2.INTER_AREA)
|
||||
|
||||
# 粘贴头像(保持原有坐标)
|
||||
y, x = 65, 105
|
||||
h, w = avatar.shape[:2]
|
||||
img[y:y + h, x:x + w] = avatar
|
||||
|
||||
# 公告栏
|
||||
img = notice_paste_bf6(img)
|
||||
img = designation_paste(player, img,
|
||||
user_id=qq_id,
|
||||
start_x=104,
|
||||
start_y=215,
|
||||
target_height=30,
|
||||
spacing=10)
|
||||
|
||||
# ---------------- PIL 部分 ----------------
|
||||
pil_img = Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
|
||||
draw = ImageDraw.Draw(pil_img)
|
||||
|
||||
# 玩家信息 105+160
|
||||
draw.text((265, 80), f"{player}", fill="white", font=font_XL)
|
||||
draw.text((265, 140), f"UID: {pid}", fill="#CCCCCC", font=font_L)
|
||||
|
||||
# 等级
|
||||
pil_img = paste_rank_icon(int(rank), pil_img)
|
||||
draw_centered_text(draw=draw, text=f"{rank}", x_left=950, x_right=1075, y=205,
|
||||
font=font_M,
|
||||
fill="white")
|
||||
# draw.text((740, 212), f"{rank}", fill="white", font=font_M)
|
||||
|
||||
# 游玩时长
|
||||
draw.text((2300, 350), f"{time_play:.1f} H", fill="white", font=font_L)
|
||||
# 场次
|
||||
draw.text((2300, 450), f"{wins + loses}", fill="white", font=font_L)
|
||||
# draw_centered_text(draw=draw, text=(wins + loses), x_left=45, x_right=265, y=350,
|
||||
# font=font_L,
|
||||
# fill="white")
|
||||
|
||||
# 生涯总览
|
||||
# 胜率
|
||||
|
||||
win_rate = round((wins / (wins + loses)) * 100, 1)
|
||||
logger.info(f"胜场{wins},负场{loses},胜率{win_rate}")
|
||||
|
||||
draw_centered_text(draw=draw, text=f"{win_rate}%", x_left=2090, x_right=2200, y=375,
|
||||
font=font_XL,
|
||||
fill="white")
|
||||
|
||||
# 命中率
|
||||
acc_format = round(float(acc.replace("%", "")), 1)
|
||||
draw_centered_text(draw=draw, text=f"{acc_format}%", x_left=1210, x_right=1303, y=363,
|
||||
font=font_M,
|
||||
fill="white")
|
||||
# 爆头率
|
||||
if head_shots:
|
||||
head_shots = head_shots.replace("%", "")
|
||||
else:
|
||||
head_shots = 0
|
||||
draw_centered_text(draw=draw, text=f"{round(float(head_shots), 1)}%", x_left=1210, x_right=1303, y=458, font=font_M,
|
||||
fill="white")
|
||||
# kd
|
||||
draw.text((1350, 355), text=str(kd), fill="white", font=font_XXL)
|
||||
# kpm
|
||||
draw.text((1610, 355), text=f"{kpm:.2f}", fill="white", font=font_XXL)
|
||||
# spm
|
||||
draw.text((1870, 355), text=f"{int(spm)}", fill="white", font=font_XXL)
|
||||
|
||||
# 击杀
|
||||
draw_right_aligned_text(draw=draw, text=add_commas_with_padding("0", 10), x_left=1165, x_right=1455, y=805,
|
||||
font=font_L,
|
||||
fill="#444445")
|
||||
draw_right_aligned_text(draw=draw, text=add_commas(str(kills)), x_left=1165, x_right=1455, y=805,
|
||||
font=font_L, fill="white")
|
||||
|
||||
# 急救
|
||||
draw_right_aligned_text(draw=draw, text=add_commas_with_padding("0", 10), x_left=1165, x_right=1455, y=1080,
|
||||
font=font_L,
|
||||
fill="#444445")
|
||||
draw_right_aligned_text(draw=draw, text=add_commas(str(revives)), x_left=1165, x_right=1455, y=1080,
|
||||
font=font_L, fill="white")
|
||||
|
||||
# 摧毁
|
||||
draw_right_aligned_text(draw=draw, text=add_commas_with_padding("0", 10), x_left=1165, x_right=1455, y=1350,
|
||||
font=font_L,
|
||||
fill="#444445")
|
||||
draw_right_aligned_text(draw=draw, text=add_commas(str(destroyed)), x_left=1165, x_right=1455, y=1350,
|
||||
font=font_L, fill="white")
|
||||
|
||||
# 右侧
|
||||
# 占领
|
||||
draw_right_aligned_text(draw=draw, text=add_commas_with_padding("0", 10), x_left=1485, x_right=1780, y=745,
|
||||
font=font_L,
|
||||
fill="#444445")
|
||||
draw_right_aligned_text(draw=draw, text=add_commas(str(captured)), x_left=1485, x_right=1780, y=745,
|
||||
font=font_L, fill="white")
|
||||
|
||||
# 得分
|
||||
draw_right_aligned_text(draw=draw, text=add_commas_with_padding("0", 10), x_left=1485, x_right=1780, y=955,
|
||||
font=font_L,
|
||||
fill="#444445")
|
||||
draw_right_aligned_text(draw=draw, text=add_commas(str(score)), x_left=1485, x_right=1780, y=955,
|
||||
font=font_L, fill="white")
|
||||
|
||||
# 助攻
|
||||
draw_right_aligned_text(draw=draw, text=add_commas_with_padding("0", 10), x_left=1485, x_right=1780, y=1160,
|
||||
font=font_L,
|
||||
fill="#444445")
|
||||
draw_right_aligned_text(draw=draw, text=add_commas(str(kill_assists)), x_left=1485, x_right=1780, y=1160,
|
||||
font=font_L, fill="white")
|
||||
|
||||
# 修理
|
||||
draw_right_aligned_text(draw=draw, text=add_commas_with_padding("0", 10), x_left=1485, x_right=1780, y=1370,
|
||||
font=font_L,
|
||||
fill="#444445")
|
||||
draw_right_aligned_text(draw=draw, text=add_commas(str(repairs)), x_left=1485, x_right=1780, y=1370,
|
||||
font=font_L, fill="white")
|
||||
|
||||
# 底部栏
|
||||
|
||||
# # 最佳兵种
|
||||
# logger.info(f"最佳兵种:{best_class}")
|
||||
# draw_centered_text(draw=draw, text=classes[best_class], x_left=140, x_right=310, y=1290, font=font_L, fill="white")
|
||||
|
||||
# 最佳⭐
|
||||
pil_img = build_best_bf6(draw, pil_img, best_weapon, best_vehicle, best_class, game)
|
||||
|
||||
# 生成时间
|
||||
draw.text((1750, 10), f"{formatted_time}/战地中文社区/维护 SANSENHOSHI", fill=(154, 132, 149), font=font_XS_CH)
|
||||
|
||||
# ---------------- 转回 OpenCV ----------------
|
||||
img = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
|
||||
|
||||
# ---------------- 雷达图 ----------------
|
||||
|
||||
# 保存
|
||||
success, buffer = cv2.imencode(".png", img)
|
||||
if success:
|
||||
b_io = io.BytesIO(buffer)
|
||||
return b_io # 就可以直接 return 二进制流
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def build_best(draw: ImageDraw, img: Image, weapons, vehicle, game: str):
|
||||
# 最佳武器
|
||||
weapon_name = weapons["weaponName"]
|
||||
w_kills = weapons["kills"]
|
||||
w_kpm = weapons["killsPerMinute"]
|
||||
acc = weapons["accuracy"].replace("%", "")
|
||||
headshots = weapons["headshots"].replace("%", "")
|
||||
|
||||
# 最佳载具
|
||||
vehicle_name = vehicle["vehicleName"]
|
||||
v_kills = vehicle["kills"]
|
||||
v_kpm = vehicle["killsPerMinute"]
|
||||
destroyed = vehicle["destroyed"]
|
||||
|
||||
# 绘制最佳⭐
|
||||
# 武器
|
||||
draw_centered_text(draw=draw, text=bf_item[game][weapon_name].upper(), x_left=1240, x_right=1750, y=1355,
|
||||
font=font_MS_CH, fill="white")
|
||||
draw_centered_text(draw=draw, text=w_kills, x_left=1190, x_right=1390, y=1260, font=font_L_CH, fill="white")
|
||||
draw_centered_text(draw=draw, text=w_kpm, x_left=1510, x_right=1635, y=1260, font=font_L_CH, fill="white")
|
||||
draw_centered_text(draw=draw, text=acc, x_left=1735, x_right=1803, y=1030, font=font_M_CH, fill="white")
|
||||
draw_centered_text(draw=draw, text=headshots, x_left=1695, x_right=1760, y=1200, font=font_M_CH, fill="white")
|
||||
|
||||
# 载具
|
||||
draw_centered_text(draw=draw, text=bf_item[game][vehicle_name].upper(), x_left=1990, x_right=2440, y=1355,
|
||||
font=font_MS_CH, fill="white")
|
||||
draw_centered_text(draw=draw, text=v_kills, x_left=1940, x_right=2150, y=1260, font=font_L_CH, fill="white")
|
||||
draw_centered_text(draw=draw, text=v_kpm, x_left=2295, x_right=2465, y=1055, font=font_XL_CH, fill="white")
|
||||
draw_centered_text(draw=draw, text=destroyed, x_left=2265, x_right=2440, y=1260, font=font_L_CH, fill="white")
|
||||
|
||||
# 图片
|
||||
weapon_url = weapons["image"]
|
||||
vehicle_url = vehicle["image"]
|
||||
wp_icon = get_save_icon(game, weapon_name, "weapon", weapon_url)
|
||||
vc_icon = get_save_icon(game, vehicle_name, "vehicles", vehicle_url)
|
||||
|
||||
img = image_paste_center(wp_icon, img, (1165, 1110))
|
||||
img = image_paste_center(vc_icon, img, (1920, 1110))
|
||||
return img
|
||||
|
||||
|
||||
def build_best_bf6(draw: ImageDraw, img: Image, weapons, vehicle, classes, game: str):
|
||||
# 最佳武器
|
||||
weapon_name = weapons["weaponName"]
|
||||
w_kills = weapons["kills"]
|
||||
w_kpm = weapons["killsPerMinute"]
|
||||
acc = round(float(weapons["accuracy"].replace("%", "")), 1)
|
||||
headshots = round(float(weapons["headshots"].replace("%", "")), 1)
|
||||
|
||||
# 最佳载具
|
||||
vehicle_name = vehicle["vehicleName"]
|
||||
v_kills = vehicle["kills"]
|
||||
v_kpm = vehicle["killsPerMinute"]
|
||||
destroyed = vehicle["destroyed"]
|
||||
|
||||
# 绘制最佳⭐
|
||||
# 武器
|
||||
# draw_centered_text(draw=draw, text=bf_item[game][weapon_name].upper(), x_left=1240, x_right=1750, y=1355,
|
||||
# font=font_MS, fill="white")
|
||||
draw_right_aligned_text(draw=draw, text=add_commas_with_padding("0", 7), x_left=95, x_right=313, y=665, font=font_L,
|
||||
fill="#313131")
|
||||
draw_right_aligned_text(draw=draw, text=add_commas(str(w_kills)), x_left=95, x_right=313, y=665,
|
||||
font=font_L, fill="white")
|
||||
draw_centered_text(draw=draw, text=w_kpm, x_left=335, x_right=447, y=665, font=font_L, fill="white")
|
||||
draw_centered_text(draw=draw, text=f"{acc}%", x_left=470, x_right=580, y=670, font=font_M, fill="white")
|
||||
draw_centered_text(draw=draw, text=f"{headshots}%", x_left=610, x_right=717, y=670, font=font_M, fill="white")
|
||||
|
||||
# 载具
|
||||
# draw_centered_text(draw=draw, text=bf_item[game][vehicle_name].upper(), x_left=1990, x_right=2440, y=1355,
|
||||
# font=font_MS, fill="white")
|
||||
draw_right_aligned_text(draw=draw, text=add_commas_with_padding("0", 7), x_left=125, x_right=325, y=1087,
|
||||
font=font_L,
|
||||
fill="#313131")
|
||||
draw_right_aligned_text(draw=draw, text=add_commas(str(v_kills)), x_left=125, x_right=325, y=1087,
|
||||
font=font_L, fill="white")
|
||||
draw_centered_text(draw=draw, text=v_kpm, x_left=351, x_right=463, y=1087, font=font_L, fill="white")
|
||||
draw_right_aligned_text(draw=draw, text=add_commas_with_padding("0", 7), x_left=477, x_right=688, y=1087,
|
||||
font=font_L,
|
||||
fill="#313131")
|
||||
draw_right_aligned_text(draw=draw, text=destroyed, x_left=477, x_right=688, y=1087, font=font_L, fill="white")
|
||||
|
||||
# 图片
|
||||
weapon_url = weapons["image"]
|
||||
vehicle_url = vehicle["image"]
|
||||
|
||||
wp_icon = get_save_icon(game, weapon_name, "weapon", weapon_url)
|
||||
vc_icon = get_save_icon(game, vehicle_name, "vehicles", vehicle_url)
|
||||
classes_icon = get_icon_from_cache(classes, 'bf6', 'classes')
|
||||
wp_icon_new = png_resize(wp_icon, new_width=729 - 88)
|
||||
vc_icon_new = png_resize(vc_icon, new_width=729 - 88)
|
||||
classes_icon_new = png_resize(classes_icon, new_height=1165 - 326, new_width=1100 - 748)
|
||||
|
||||
img = image_paste(wp_icon_new, img, (88, 325))
|
||||
img = image_paste(vc_icon_new, img, (88, 746))
|
||||
img = image_paste(classes_icon_new, img, (748, 326))
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def normalize_for_radar(data, buffer=0.1, scale_min=0.0, scale_max=1.0):
|
||||
"""
|
||||
将数据动态归一化到指定区间 [scale_min, scale_max],
|
||||
并给最大值预留 buffer,让图形更饱满。
|
||||
|
||||
Args:
|
||||
data (list/ndarray): 原始数据
|
||||
buffer (float): 缓冲比例,例如 0.1 表示最大值按 1.1 倍处理
|
||||
scale_min (float): 映射区间下限
|
||||
scale_max (float): 映射区间上限
|
||||
|
||||
Returns:
|
||||
list: 归一化后的数据
|
||||
"""
|
||||
data = np.array(data, dtype=float)
|
||||
d_min, d_max = data.min(), data.max()
|
||||
|
||||
# 给最大值留 buffer
|
||||
d_max = d_max * (1 + buffer)
|
||||
|
||||
if d_max == d_min: # 避免除零
|
||||
norm = np.ones_like(data) * (scale_min + scale_max) / 2
|
||||
else:
|
||||
norm = (data - d_min) / (d_max - d_min)
|
||||
norm = scale_min + norm * (scale_max - scale_min)
|
||||
|
||||
return norm.tolist()
|
||||
|
||||
|
||||
def add_commas(text):
|
||||
return re.sub(r'(?<!^)(?=(\d{3})+$)', ',', text)
|
||||
|
||||
|
||||
def add_commas_with_padding(value, total_digits):
|
||||
"""
|
||||
固定总位数补零后,再添加千分位分隔符
|
||||
:param value: 输入数字(int / str)
|
||||
:param total_digits: 固定总位数(如 7)
|
||||
:return: 格式化后的字符串,如 '1,234,567'
|
||||
"""
|
||||
# 转字符串并补零
|
||||
text = str(value).zfill(total_digits)
|
||||
|
||||
# 添加千分位
|
||||
return re.sub(r'(?<!^)(?=(\d{3})+$)', ',', text)
|
||||
|
||||
|
||||
# ---------------- 雷达图示例 ----------------
|
||||
def draw_radar(img, stat_data, center=(311, 796), r=185,
|
||||
buffer=0.1, scale_min=0.1, scale_max=1.05):
|
||||
# 动态归一化
|
||||
scaled_data = normalize_for_radar(stat_data, buffer, scale_min, scale_max)
|
||||
|
||||
points = []
|
||||
for i, v in enumerate(scaled_data):
|
||||
angle = 2 * np.pi / len(scaled_data) * i - np.pi / 2
|
||||
px = int(center[0] + r * v * np.cos(angle))
|
||||
py = int(center[1] + r * v * np.sin(angle))
|
||||
points.append([px, py])
|
||||
points = np.array(points, np.int32)
|
||||
|
||||
# 绘制填充
|
||||
overlay = img.copy()
|
||||
cv2.fillPoly(overlay, [points], color=(255, 255, 255, 76))
|
||||
img = cv2.addWeighted(overlay, 0.3, img, 0.7, 0)
|
||||
# 绘制边框
|
||||
cv2.polylines(img, [points], isClosed=True, color=(255, 255, 255), thickness=2)
|
||||
return img
|
||||
|
||||
|
||||
def build_bf6_simple_card(player_info):
|
||||
# 1.创建黑色板块 1920*1080
|
||||
new_img = Image.new('RGBA', (1080, 1920), (255, 255, 255, 1000))
|
||||
|
||||
draw = ImageDraw.Draw(new_img)
|
||||
|
||||
draw.text((50, 60), f"玩家名称:{player_info['玩家名称']}", fill='black', font=font_M)
|
||||
draw.text((550, 60), f"游玩平台:{player_info['游玩平台']}", fill='black', font=font_M)
|
||||
|
||||
draw.text((50, 160), f"游戏等级:{player_info['游戏等级']}", fill='black', font=font_M)
|
||||
draw.text((550, 160), f"游玩时长:{player_info['游玩时长']}", fill='black', font=font_M)
|
||||
|
||||
draw.text((50, 260), f"游玩场次:{player_info['游玩场次']}", fill='black', font=font_M)
|
||||
draw.text((550, 260), f"对局胜率:{player_info['对局胜率']}", fill='black', font=font_M)
|
||||
|
||||
draw.text((50, 360), f"对局得分:{player_info['对局得分']}", fill='black', font=font_M)
|
||||
draw.text((550, 360), f"玩家SPM:{player_info['玩家SPM']}", fill='black', font=font_M)
|
||||
|
||||
draw.text((50, 460), f"玩家K/D:{player_info['玩家K/D']}", fill='black', font=font_M)
|
||||
draw.text((550, 460), f"玩家KPM:{player_info['玩家KPM']}", fill='black', font=font_M)
|
||||
|
||||
draw.text((50, 560), f"击杀总计:{player_info['击杀总计']}", fill='black', font=font_M)
|
||||
draw.text((550, 560), f"真人击杀:{player_info['真人击杀']}", fill='black', font=font_M)
|
||||
|
||||
draw.text((50, 660), f"游戏助攻:{player_info['游戏助攻']}", fill='black', font=font_M)
|
||||
draw.text((550, 660), f"目标占领:{player_info['目标占领']}", fill='black', font=font_M)
|
||||
|
||||
draw.text((50, 760), f"救援数量:{player_info['救援数量']}", fill='black', font=font_M)
|
||||
draw.text((550, 760), f"最高连杀:{player_info['最高连杀']}", fill='black', font=font_M)
|
||||
|
||||
draw.text((50, 860), f"命中率:{player_info['命中率']}", fill='black', font=font_M)
|
||||
draw.text((550, 860), f"爆头率:{player_info['爆头率']}", fill='black', font=font_M)
|
||||
|
||||
draw.text((150, 1060), f"最佳武器", fill='black', font=font_M)
|
||||
draw.text((650, 1060), f"最佳载具", fill='black', font=font_M)
|
||||
|
||||
draw.text((50, 1160), f"名称:{player_info['最佳武器']['名称']}", fill='black', font=font_M)
|
||||
draw.text((50, 1260), f"击杀:{player_info['最佳武器']['击杀']}", fill='black', font=font_M)
|
||||
draw.text((50, 1360), f"KPM:{player_info['最佳武器']['KPM']}", fill='black', font=font_M)
|
||||
draw.text((50, 1460), f"时长:{player_info['最佳武器']['时长']}", fill='black', font=font_M)
|
||||
draw.text((50, 1560), f"命中率:{player_info['最佳武器']['命中率']}", fill='black', font=font_M)
|
||||
draw.text((50, 1660), f"爆头率:{player_info['最佳武器']['爆头率']}", fill='black', font=font_M)
|
||||
|
||||
draw.text((550, 1160), f"名称:{player_info['最佳载具']['名称']}", fill='black', font=font_M)
|
||||
draw.text((550, 1260), f"击杀:{player_info['最佳载具']['击杀']}", fill='black', font=font_M)
|
||||
draw.text((550, 1360), f"KPM:{player_info['最佳载具']['KPM']}", fill='black', font=font_M)
|
||||
draw.text((550, 1460), f"时长:{player_info['最佳载具']['时长']}", fill='black', font=font_M)
|
||||
draw.text((550, 1560), f"碾压:{player_info['最佳载具']['碾压']}", fill='black', font=font_M)
|
||||
draw.text((550, 1660), f"摧毁:{player_info['最佳载具']['摧毁']}", fill='black', font=font_M)
|
||||
|
||||
b_io = BytesIO()
|
||||
new_img.save(b_io, format="PNG")
|
||||
return b_io # 就可以直接 return 二进制流
|
||||
@@ -0,0 +1,200 @@
|
||||
import datetime
|
||||
import io
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from . import add_commas_with_padding, add_commas
|
||||
from .data_utils import *
|
||||
from .img_utils import *
|
||||
from .param import *
|
||||
|
||||
# from img_utils import *
|
||||
# from data_utils import *
|
||||
# from param import *
|
||||
|
||||
filepath = os.path.dirname(__file__).replace("\\", "/")
|
||||
|
||||
# 字体
|
||||
font_XXL = ImageFont.truetype(f"{filepath}/font/bf-sub-headline-bold 等宽数字.ttf", 72)
|
||||
font_XL = ImageFont.truetype(f"{filepath}/font/bf-sub-headline-bold 等宽数字.ttf", 64)
|
||||
font_L = ImageFont.truetype(f"{filepath}/font/bf-sub-headline-bold 等宽数字.ttf", 48)
|
||||
font_ML = ImageFont.truetype(f"{filepath}/font/bf-sub-headline-bold 等宽数字.ttf", 42)
|
||||
font_M = ImageFont.truetype(f"{filepath}/font/bf-sub-headline-bold 等宽数字.ttf", 36)
|
||||
font_MS = ImageFont.truetype(f"{filepath}/font/bf-sub-headline-bold 等宽数字.ttf", 32)
|
||||
font_S = ImageFont.truetype(f"{filepath}/font/bf-sub-headline-bold 等宽数字.ttf", 28)
|
||||
font_XS = ImageFont.truetype(f"{filepath}/font/bf-sub-headline-bold 等宽数字.ttf", 24)
|
||||
font_XXS = ImageFont.truetype(f"{filepath}/font/bf-sub-headline-bold 等宽数字.ttf", 18)
|
||||
font_XXXS = ImageFont.truetype(f"{filepath}/font/bf-sub-headline-bold 等宽数字.ttf", 16)
|
||||
|
||||
font_XXL_CH = ImageFont.truetype(f"{filepath}/font/演示创黑FLY.ttf", 72)
|
||||
font_XL_CH = ImageFont.truetype(f"{filepath}/font/演示创黑FLY.ttf", 64)
|
||||
font_L_CH = ImageFont.truetype(f"{filepath}/font/演示创黑FLY.ttf", 48)
|
||||
font_ML_CH = ImageFont.truetype(f"{filepath}/font/演示创黑FLY.ttf", 42)
|
||||
font_M_CH = ImageFont.truetype(f"{filepath}/font/演示创黑FLY.ttf", 36)
|
||||
font_MS_CH = ImageFont.truetype(f"{filepath}/font/演示创黑FLY.ttf", 32)
|
||||
font_S_CH = ImageFont.truetype(f"{filepath}/font/演示创黑FLY.ttf", 28)
|
||||
font_XS_CH = ImageFont.truetype(f"{filepath}/font/演示创黑FLY.ttf", 24)
|
||||
font_XXS_CH = ImageFont.truetype(f"{filepath}/font/演示创黑FLY.ttf", 18)
|
||||
font_XXXS_CH = ImageFont.truetype(f"{filepath}/font/演示创黑FLY.ttf", 16)
|
||||
|
||||
|
||||
async def build_bf6_horizon_stat(game="bfv",
|
||||
qq_id=2931,
|
||||
player="Player001",
|
||||
pid=1145141919,
|
||||
rank=114,
|
||||
kd=1.25,
|
||||
kpm=2.3,
|
||||
spm=500,
|
||||
acc=25.6,
|
||||
head_shots=10,
|
||||
time_play=100,
|
||||
kills=100,
|
||||
kill_assists=150,
|
||||
revives=114,
|
||||
repairs=1000,
|
||||
captured=300,
|
||||
score=145674,
|
||||
wins=10,
|
||||
loses=10,
|
||||
destroyed=514,
|
||||
best_weapon="M1907 SF",
|
||||
best_vehicle="坦克",
|
||||
best_class="Support", ):
|
||||
# 获取当前事件并且格式化
|
||||
now = datetime.datetime.now()
|
||||
formatted_time = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# ---------------- OpenCV 部分 ----------------
|
||||
# 载入模板
|
||||
img = cv2.imread(f"{filepath}/template/horizon/{game}.png", cv2.IMREAD_COLOR)
|
||||
|
||||
# ---------------- 头像 ----------------
|
||||
avatar = cv2.imread(f"{filepath}/avatar_cache/0.png", cv2.IMREAD_UNCHANGED)
|
||||
avatar = cv2.resize(avatar, (134, 134), interpolation=cv2.INTER_AREA)
|
||||
|
||||
# 粘贴头像(保持原有坐标)
|
||||
y, x = 65, 105
|
||||
h, w = avatar.shape[:2]
|
||||
img[y:y + h, x:x + w] = avatar
|
||||
|
||||
# ---------------- 勋章 ----------------
|
||||
|
||||
# ---------------- PIL 部分 ----------------
|
||||
pil_img = Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
|
||||
draw = ImageDraw.Draw(pil_img)
|
||||
|
||||
# 玩家信息 105+160
|
||||
draw.text((265, 80), f"{player}", fill="white", font=font_XL)
|
||||
draw.text((265, 140), f"UID: {pid}", fill="#CCCCCC", font=font_L)
|
||||
|
||||
# 等级
|
||||
pil_img = paste_rank_icon(int(rank), pil_img)
|
||||
draw_centered_text(draw=draw, text=f"{rank}", x_left=950, x_right=1075, y=205,
|
||||
font=font_M,
|
||||
fill="white")
|
||||
# draw.text((740, 212), f"{rank}", fill="white", font=font_M)
|
||||
|
||||
# 游玩时长
|
||||
draw.text((2300, 350), f"{time_play:.1f} H", fill="white", font=font_L)
|
||||
# 场次
|
||||
draw.text((2300, 450), f"{wins + loses}", fill="white", font=font_L)
|
||||
|
||||
# 生涯总览
|
||||
# 命中率
|
||||
acc_format = round(float(acc.replace("%", "")), 1)
|
||||
draw_centered_text(draw=draw, text=f"{acc_format}%", x_left=1210, x_right=1303, y=363,
|
||||
font=font_M,
|
||||
fill="white")
|
||||
# 爆头率
|
||||
if head_shots:
|
||||
head_shots = head_shots.replace("%", "")
|
||||
else:
|
||||
head_shots = 0
|
||||
draw_centered_text(draw=draw, text=f"{round(float(head_shots), 1)}%", x_left=1210, x_right=1303, y=458,
|
||||
font=font_M,
|
||||
fill="white")
|
||||
# kd
|
||||
draw.text((1350, 355), text=str(kd), fill="white", font=font_XXL)
|
||||
# kpm
|
||||
draw.text((1610, 355), text=f"{kpm:.2f}", fill="white", font=font_XXL)
|
||||
# spm
|
||||
draw.text((1870, 355), text=f"{int(spm)}", fill="white", font=font_XXL)
|
||||
|
||||
# ---------------- 数据 ----------------
|
||||
# 击杀
|
||||
draw_right_aligned_text(draw=draw, text=add_commas_with_padding("0", 10), x_left=1165, x_right=1455, y=805,
|
||||
font=font_L,
|
||||
fill="#444445")
|
||||
draw_right_aligned_text(draw=draw, text=add_commas(str(kills)), x_left=1165, x_right=1455, y=805,
|
||||
font=font_L, fill="white")
|
||||
|
||||
# 急救
|
||||
draw_right_aligned_text(draw=draw, text=add_commas_with_padding("0", 10), x_left=1165, x_right=1455, y=1080,
|
||||
font=font_L,
|
||||
fill="#444445")
|
||||
draw_right_aligned_text(draw=draw, text=add_commas(str(revives)), x_left=1165, x_right=1455, y=1080,
|
||||
font=font_L, fill="white")
|
||||
|
||||
# 摧毁
|
||||
draw_right_aligned_text(draw=draw, text=add_commas_with_padding("0", 10), x_left=1165, x_right=1455, y=1350,
|
||||
font=font_L,
|
||||
fill="#444445")
|
||||
draw_right_aligned_text(draw=draw, text=add_commas(str(destroyed)), x_left=1165, x_right=1455, y=1350,
|
||||
font=font_L, fill="white")
|
||||
|
||||
# 右侧
|
||||
# 占领
|
||||
draw_right_aligned_text(draw=draw, text=add_commas_with_padding("0", 10), x_left=1485, x_right=1780, y=745,
|
||||
font=font_L,
|
||||
fill="#444445")
|
||||
draw_right_aligned_text(draw=draw, text=add_commas(str(captured)), x_left=1485, x_right=1780, y=745,
|
||||
font=font_L, fill="white")
|
||||
|
||||
# 得分
|
||||
draw_right_aligned_text(draw=draw, text=add_commas_with_padding("0", 10), x_left=1485, x_right=1780, y=955,
|
||||
font=font_L,
|
||||
fill="#444445")
|
||||
draw_right_aligned_text(draw=draw, text=add_commas(str(score)), x_left=1485, x_right=1780, y=955,
|
||||
font=font_L, fill="white")
|
||||
|
||||
# 助攻
|
||||
draw_right_aligned_text(draw=draw, text=add_commas_with_padding("0", 10), x_left=1485, x_right=1780, y=1160,
|
||||
font=font_L,
|
||||
fill="#444445")
|
||||
draw_right_aligned_text(draw=draw, text=add_commas(str(kill_assists)), x_left=1485, x_right=1780, y=1160,
|
||||
font=font_L, fill="white")
|
||||
|
||||
# 修理
|
||||
draw_right_aligned_text(draw=draw, text=add_commas_with_padding("0", 10), x_left=1485, x_right=1780, y=1370,
|
||||
font=font_L,
|
||||
fill="#444445")
|
||||
draw_right_aligned_text(draw=draw, text=add_commas(str(repairs)), x_left=1485, x_right=1780, y=1370,
|
||||
font=font_L, fill="white")
|
||||
|
||||
# 底部栏
|
||||
|
||||
# 最佳⭐
|
||||
pil_img = build_best_bf6_horizon(draw, pil_img, best_weapon, best_vehicle, best_class, game)
|
||||
|
||||
# 生成时间
|
||||
draw.text((1750, 10), f"{formatted_time}/战地中文社区/维护 SANSENHOSHI", fill=(154, 132, 149), font=font_XS_CH)
|
||||
|
||||
# ---------------- 转回 OpenCV ----------------
|
||||
img = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
|
||||
|
||||
# ---------------- 雷达图 ----------------
|
||||
|
||||
# 保存
|
||||
success, buffer = cv2.imencode(".png", img)
|
||||
if success:
|
||||
b_io = io.BytesIO(buffer)
|
||||
return b_io # 就可以直接 return 二进制流
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def build_best_bf6_horizon(draw, pil_img, best_weapon, best_vehicle, best_class, game):
|
||||
return 1
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 980 KiB |
|
After Width: | Height: | Size: 938 KiB |
|
After Width: | Height: | Size: 1002 KiB |
|
After Width: | Height: | Size: 410 KiB |
|
After Width: | Height: | Size: 647 KiB |
|
After Width: | Height: | Size: 431 KiB |
|
After Width: | Height: | Size: 466 KiB |
|
After Width: | Height: | Size: 345 KiB |
|
After Width: | Height: | Size: 478 KiB |
|
After Width: | Height: | Size: 514 KiB |
|
After Width: | Height: | Size: 440 KiB |
|
After Width: | Height: | Size: 587 KiB |
|
After Width: | Height: | Size: 615 KiB |
|
After Width: | Height: | Size: 469 KiB |
|
After Width: | Height: | Size: 417 KiB |
|
After Width: | Height: | Size: 452 KiB |
|
After Width: | Height: | Size: 502 KiB |
|
After Width: | Height: | Size: 360 KiB |
|
After Width: | Height: | Size: 580 KiB |
|
After Width: | Height: | Size: 467 KiB |
|
After Width: | Height: | Size: 474 KiB |
|
After Width: | Height: | Size: 440 KiB |
|
After Width: | Height: | Size: 354 KiB |
|
After Width: | Height: | Size: 397 KiB |
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 401 KiB |
|
After Width: | Height: | Size: 533 KiB |
|
After Width: | Height: | Size: 453 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 396 KiB |
|
After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 356 KiB |
|
After Width: | Height: | Size: 431 KiB |
|
After Width: | Height: | Size: 383 KiB |
|
After Width: | Height: | Size: 616 KiB |
|
After Width: | Height: | Size: 478 KiB |
|
After Width: | Height: | Size: 454 KiB |
|
After Width: | Height: | Size: 327 KiB |
|
After Width: | Height: | Size: 598 KiB |
|
After Width: | Height: | Size: 419 KiB |
|
After Width: | Height: | Size: 484 KiB |
|
After Width: | Height: | Size: 648 KiB |
|
After Width: | Height: | Size: 601 KiB |
|
After Width: | Height: | Size: 361 KiB |
|
After Width: | Height: | Size: 497 KiB |
|
After Width: | Height: | Size: 519 KiB |
|
After Width: | Height: | Size: 404 KiB |
|
After Width: | Height: | Size: 334 KiB |