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>
This commit is contained in:
2026-09-01 13:13:40 +08:00
co-authored by Claude
parent 1783c60afa
commit b61d09f09f
3201 changed files with 160436 additions and 171 deletions
+73 -169
View File
@@ -1,176 +1,80 @@
# ---> Python # ---- Python / tooling ----
# Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.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/ *.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/ .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/ .ruff_cache/
.mypy_cache/
.venv/
dist/
build/
# PyPI configuration file # ---- IDE ----
.pypirc .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
+1 -2
View File
@@ -1,2 +1 @@
# Hexi 个人娱乐bot,自己随便写点小功能
+34
View File
@@ -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() # 直接运行,不需要传参
+433
View File
@@ -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)。
+156
View File
@@ -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`。
View File
+488
View File
@@ -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,
}
],
)
View File
+125
View File
@@ -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
+284
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 938 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1002 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 647 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 648 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 601 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 519 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Some files were not shown because too many files have changed in this diff Show More