Compare commits

..
9 Commits
Author SHA1 Message Date
sansenhoshiandClaude Code 5573104f68 refactor(picstatus): 定时采集迁移 hexi.core.scheduler
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 17:53:43 +08:00
sansenhoshiandClaude Code dbd7beb520 chore(deps): 补充 gmssl 依赖(video_analysis 抖音 a_bogus 签名 SM3)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 17:53:43 +08:00
sansenhoshiandClaude Code 2754109100 chore: README 与规范化文档更新,忽略 BF2042 开发产物
- README:bf_bot 描述更新(tracker.gg 通道 / 绑定管理页)+ cherry_tomato 行
- .gitignore:移除 bf_bot 旧目录规则;忽略 /dev/bf2042/(500MB+ 本地
  DLL/字体/图开发产物,不入库)
- dev/docs/plugin-type-classification 补充

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 17:53:28 +08:00
sansenhoshiandClaude Code fb15b976aa feat(cherry-tomato): 新增图片混淆/解混淆插件(小番茄)
Gilbert 曲线像素重排,可与网页版互解;`混淆` / `解混淆` 命令
(回复图片或图文同条消息发送),附 tests/test_cherry_tomato.py

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 17:53:27 +08:00
sansenhoshiandClaude Code 968b3d1cfe feat(web): 任务中心全局 Provider / 设置·布局·路由调整 / 视频解析页适配
- tasks.tsx:顶栏任务中心全局 Provider(SSE 任务进度,供各插件页共享)
- 设置页与 AppLayout / client / router 调整,BF2042 插件页路由注册
- web_hub:dashboard / bot_settings 同步更新
- 视频解析页配合后端(缓存 tab / 任务槽进度)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 17:53:27 +08:00
sansenhoshiandClaude Code 6af60a7d55 feat(steam-info): 资料页状态/最近游玩/ban 描述增强,时长图渲染与定时任务迁移
- steam.py / draw.py / handlers 扩充:个人资料页状态构建、最近游玩、
  游戏头像与中文游戏名查询、ban 描述
- 定时播报迁入 hexi.core.scheduler
- 新增 tests/test_steam_profile_parse.py(资料 HTML/XML 解析)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 17:53:22 +08:00
sansenhoshiandClaude Code 0ac1958932 feat(core): 定时任务薄封装 scheduler / 跨插件任务槽 task_slots / 出站媒体显式发送方式
- scheduler.py:apscheduler 薄封装(统一 Asia/Shanghai、misfire_grace_time、起止/异常日志)
- task_slots.py:长任务任务槽(进度上报 / 同槽去重 / SSE 推送 / 取消),
  video_analysis temp 清理等长任务接入
- outbound_media:新增 HEXI_MEDIA_MODE 显式发送方式(http/base64/file,/hub 可改),
  修复 alconna 对 base64 载荷补 https:// 前缀导致的 fetch failed(repair_mangled_url)
- learning_chat 定时任务迁入 hexi.core.scheduler
- 测试:新增 test_scheduler / test_task_slots,扩充 test_outbound_media

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 17:53:21 +08:00
sansenhoshiandClaude Code a789a44f5d feat(video-analysis): 抖音免浏览器解析链路 + 快手支持 + 小红书加固 + 发送防重复
- 抖音(2026-10-13):删除 SSR + playwright 旧链路(douyin_ssr.py 移除,
  douyin_api.py 精简为下载工具),新增 douyin_web.py(x-tt-argus 网关头 +
  web detail 接口 + 最高码率梯/多 CDN 回退下载)与 douyin_sign.py
  (纯 Python a_bogus 签名,vendor 自 f2(Apache-2.0),依赖 gmssl SM3);
  实测 10/10 成功率、单作品 <1s(旧浏览器链路 5-15s 且随机撞验证码)
- 快手(2026-10-08):kuaishou_content.py 分享页 INIT_STATE 静态解析(无登录态),
  清晰度梯最佳档 + 图集 URL 组装,入口/策略/Web 面板同步接入
- 小红书:按链接来源定国际/国内站优先序、视频 backupUrls 逐候选回退、
  图片 urlDefault→infoList WB_DFT→url 三档回退
- 长视频防重复发送(2026-10-13):媒体发送带 per-call _timeout(默认 300s),
  超时=结果未知不再降级重试;auto/active 两条 handler 互斥
- Web 解析封面抽帧 ffmpeg 加 CREATE_NO_WINDOW(pythonw 下不再弹 cmd 黑窗)
- 依赖:+gmssl;测试:新增 test_douyin_web / test_kuaishou_parse / test_video_cleanup,
  扩充 test_video_policy / test_video_sender_link / test_video_group_file / test_video_media_naming

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 17:53:17 +08:00
sansenhoshiandClaude Code a31aab00e8 refactor(bf-bot): 插件重构为 handlers/services/utils 分层,资源归入 res/
- 旧平铺模块(data/data_utils/database_op/image_builder*/get_bf6_data/
  gametools_bf6/bf6_data/img_utils)拆分为 handlers(绑定/查询)、
  services(fetchers/processing/query)、utils(渲染/参数)分层;
  新增 repository.py / config.py / paths.py
- 字体/图片/模板资源迁入 res/,新增 BF2042 卡面素材(bg/object_icon 等)
- browser_ext/:BF2042 扩展捕获桥(WS 直连兜底通道)源码入库
- 新增 hexi/web/src/plugins/bf_bot 前端管理页
- 新增 tests:test_bf_bot_helpers / repository / tracker_api

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 17:53:06 +08:00
816 changed files with 14104 additions and 3553 deletions

No files matched your search

+3 -3
View File
@@ -36,9 +36,6 @@ build/
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/
@@ -85,3 +82,6 @@ bg.jpg
# helldivers 图标素材&生成脚本(不入库)
/dev/docs/HD2/
# BF2042 开发本地依赖(dll/字体/图,500MB+,不入库)
/dev/bf2042/
+2 -1
View File
@@ -8,7 +8,7 @@
| 插件 | 功能 | 主要命令 |
| --- | --- | --- |
| `nonebot_plugin_bf_bot` | 战地系列 (BF3/BF4/BF1/BFV/BF2042/BF6) 战绩查询,PIL 生成统计卡片 | `/bft` `/bfbind` `/unbind` `/update_bind` |
| `nonebot_plugin_bf_bot` | 战地系列 (BF3/BF4/BF1/BFV/BF2042/BF6) 战绩查询,PIL 生成统计卡片;BF2042 榜单/模式走 tracker.gg 浏览器通道;附绑定管理 Web 页 | `/bft` `/bfbind` `/unbind` `/update_bind` |
| `nonebot_plugin_deadlock` | Deadlock 战绩、段位、近期比赛查询 | `/nekoscore` `/nekobind` `/nekounbind` `/nekorank` `/nekomatch` |
| `nonebot_plugin_steam_info` | Steam 玩家信息、库存、游戏时长查询 | `/steamhelp` `/steambind` `/steaminfo` `/steamplaytime` 等 |
| `nonebot_plugin_helldivers_tools` | 绝地潜兵 2 前线战况简报、星球详报、随机战备推荐 | `/简报` `/详报` `/随机战备` |
@@ -37,6 +37,7 @@
| `hexi/core`(核心模块) | 核心库:消息解析、权限分级、限流器 (token bucket)、命令冷却、热插件管理(**非插件**) | — |
| `nonebot_plugin_video_analysis` | 视频链接解析 | — |
| `nonebot_plugin_regif` | GIF 注册与处理 | — |
| `nonebot_plugin_cherry_tomato` | 图片混淆/解混淆(小番茄,Gilbert 曲线像素重排,可与网页版互解) | `/混淆` `/解混淆` |
| `nonebot_plugin_picfinder_take` | 识图(Playwright 驱动) | — |
| `nonebot_plugin_picstatus` | 头像状态图生成 | — |
| `nonebot_plugin_group_daily_analysis` | 群聊行为分析报告 | `/群分析` |
+1
View File
@@ -28,6 +28,7 @@
| `hexi.plugins.nonebot_plugin_deer_pipe` | `application` | 每日打卡(鹿)。 |
| `hexi.plugins.nonebot_plugin_dice` | `application` | 掷骰子/结婚判定。 |
| `hexi.plugins.nonebot_plugin_makeaquote` | `application` | 名人/语录图生成。 |
| `hexi.plugins.nonebot_plugin_cherry_tomato` | `application` | 图片混淆/解混淆(小番茄,Gilbert 曲线像素重排)。 |
| `hexi.plugins.nonebot_plugin_random_jm_code` | `application` | 随机 JM 码。 |
| `hexi.plugins.nonebot_plugin_video_analysis` | `application` | 视频链接解析。 |
| `hexi.plugins.nonebot_plugin_regif` | `application` | GIF 倒放/处理。 |
+1
View File
@@ -9,6 +9,7 @@ from . import ( # noqa: E402,F401
plugin_control,
plugin_manager,
rate_limit,
task_slots,
)
+112 -10
View File
@@ -10,6 +10,10 @@
`bot.send` / alconna `finish` / 直接 `send_group_msg` 全从这里过)上,
在真正调用适配器前改写发送类 API 里的本地文件:
发送方式由 `HEXI_MEDIA_MODE` 显式配置(`http` / `base64` / `file`,见 `_mode()`,
/hub 全局设置页可改):默认 `http`,即下面的两级改写;`base64` 跳过第 1 级;
`file` 整体不改写(协议端就在本机时才用)。
1. **默认改写为 `http://<本机可达IP>:<端口>/media/<token>`**,由协议端主动来拉。
地址取自 `driver.config` 的 host/port + 本机网卡,与 /hub 首页「协议端配对」
展示的是同一来源;反向 WS 部署下协议端本就连得到这个地址(它连的就是它),
@@ -29,6 +33,14 @@
且**只做 URL 不做 base64**——文件体积不适合内联。覆盖这条让
`video_analysis` 的群文件「本地直传」兜底和 alconna 的 `$onebot11:file`
一起受益(后者传的是 `Path.as_posix()` 裸路径)。
另有一类「必死值」需要顺手还原:alconna 的 `Media.__post_init__` 会给解析不出
hostname 的 url 补 `https://` 前缀(本意是补全裸域名),而 JPEG 的 base64 载荷
恒以 `/9j/` 开头——`urlparse("base64:///9j/...")` 的 netloc 为空,于是被补成
`https://base64:///9j/...`,协议端拿 `base64` 当域名去 fetch,必报
`ActionFailed ... fetch failed`(2026-10-10 实测:group_tools 的老婆图)。
见 `repair_mangled_url`;它修的是**构造期就已损坏的值**,与发送方式无关,
file 模式下同样执行。
"""
from __future__ import annotations
@@ -104,14 +116,44 @@ def _env_flag(name: str, default: bool) -> bool:
return raw.strip().lower() not in {"", "0", "false", "no", "off"}
#: 资源发送方式(显式配置,仿 HoshinoBot 的 RES_PROTOCOL)
MEDIA_MODES: tuple[str, ...] = ("http", "base64", "file")
def _mode() -> str:
"""当前发送方式:`http`(默认)/ `base64` / `file`。
- `http` 改写为 `http://<可达地址>:<端口>/media/<token>`,协议端来拉;
拿不到可达地址时回退 base64(媒体服务没挂上 / 无网卡)
- `base64` 一律内联,字节由 bot 自己携带,不依赖协议端回拨
- `file` 完全不改写,直接把本地路径交给适配器 —— 只有**协议端就在本机**
时才是对的(跨机必 ENOENT,见模块文档)
配置键 `HEXI_MEDIA_MODE` 优先;没配时按旧的 `HEXI_INLINE_MEDIA` /
`HEXI_INLINE_MEDIA_URL` 两个布尔开关推断,老 .env 行为不变。
"""
raw = (os.environ.get("HEXI_MEDIA_MODE") or "").strip().lower()
if raw in MEDIA_MODES:
return raw
if raw:
logger.warning(
f"HEXI_MEDIA_MODE={raw!r} 不是 {'/'.join(MEDIA_MODES)} 之一,按默认 http 处理"
)
if not _env_flag("HEXI_INLINE_MEDIA", True):
return "file"
if not _env_flag("HEXI_INLINE_MEDIA_URL", True):
return "base64"
return "http"
def _enabled() -> bool:
"""总开关(默认开)。关闭的语义是「协议端就在本机」,此时 file:// 是有效的。"""
return _env_flag("HEXI_INLINE_MEDIA", True)
"""是否启用改写。`file` 模式=「协议端就在本机」,此时 file:// 本来就是对的。"""
return _mode() != "file"
def _url_enabled() -> bool:
"""URL 优先模式(默认开);关掉则一律走 base64。"""
return _env_flag("HEXI_INLINE_MEDIA_URL", True)
"""是否用 URL(`/media/<token>`):只有 http 模式用,其余走 base64 / 原样。"""
return _mode() == "http"
def _ttl() -> float:
@@ -187,12 +229,31 @@ def _is_private(ip: str) -> bool:
return a == 10 or (a == 172 and 16 <= b <= 31) or (a == 192 and b == 168)
def _route_ip() -> str | None:
"""默认路由出口 IP —— 连外网时内核会挑的那张网卡。
多网卡机器只看网卡列表挑不出「协议端该往哪连」:VPN / 虚拟机 / WSL /
EasyTier / Tailscale 的地址**同样是私有段**,排序时纯按字符串比大小,
`10.0.0.129`(EasyTier 虚拟网卡)就会赢过真实局域网的 `192.168.2.186`,
协议端拉不到 → `ActionFailed ... fetch failed`(2026-10-08 实测)。
UDP connect 不发任何包,只是让内核回答"出口在哪";无网络时返回 None。
"""
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
sock.connect(("8.8.8.8", 80))
return str(sock.getsockname()[0])
except OSError:
return None
finally:
sock.close()
def _local_ips() -> list[str]:
"""本机可被协议端访问的 IPv4 列表(私有网段优先,排除环回/链路本地)。
"""本机可被协议端访问的 IPv4 列表(默认路由网卡最前,其次私有网段优先)。
与 hexi/web_hub/dashboard.py 的 `_local_ips` 同源逻辑(那边供 /hub 首页
「协议端配对」展示,即用户已经验证过能用的地址);此处刻意不 import,
避免 hexi.core → hexi.web_hub 的反向依赖。
避免 hexi.core → hexi.web_hub 的反向依赖。改这里记得同步那边。
"""
ips: list[str] = []
try:
@@ -223,6 +284,11 @@ def _local_ips() -> list[str]:
if ip not in out:
out.append(ip)
out.sort(key=lambda ip: (not _is_private(ip), ip))
route = _route_ip()
if route in out:
# 默认路由那张网卡排最前:它才是协议端大概率连得到的地址(见 _route_ip)
out.remove(route)
out.insert(0, route)
return out
@@ -288,7 +354,9 @@ def mount_media_endpoint() -> None:
global _mounted
if not _enabled():
logger.info("出站媒体内联化已禁用 (HEXI_INLINE_MEDIA=false),按原值发送")
logger.info(
f"出站媒体内联化已禁用 (HEXI_MEDIA_MODE={_mode()}),按原值发送"
)
return
if _mounted:
return
@@ -349,6 +417,8 @@ async def _to_base64(path: Path) -> str:
async def rewrite_file(value: Any) -> str | None:
"""改写单个 file 字段值;无需改写(或改写失败)返回 None。"""
if not _enabled():
return None # file 模式:路径原样交给适配器
path = local_file_of(value)
if path is None:
return None
@@ -386,6 +456,32 @@ async def rewrite_upload_file(value: Any) -> str | None:
return None
# alconna 的 Media.__post_init__ 会给解析不出 hostname 的 url 补 `https://`
# 前缀(本意是补全 example.com/a.png 这类裸域名),而 OneBot file 字段认的
# 两个伪协议都可能解析不出 hostname:
# - `base64://` + JPEG 载荷(恒以 "/9j/" 开头)→ netloc 为空 → 变成
# `https://base64:///9j/...`,协议端只会把 "base64" 当域名去 fetch,必死
# - `file:///D:/x.jpg`(netloc 也为空)同理被补头
# 带上 http(s) 外衣后这两个值不再有任何一方能解释,还原回伪协议本身。
_MANGLED_SCHEMES = ("base64://", "file://")
def repair_mangled_url(value: Any) -> str | None:
"""还原被 alconna 补了 http(s):// 头的伪协议值;无需还原返回 None。
只认「http(s):// 前缀 + 伪协议」这种必然非法的组合——裸域名补成
https:// 是 alconna 的正常行为,不受影响。
"""
if not isinstance(value, str):
return None
for prefix in ("https://", "http://"):
if value.startswith(prefix):
rest = value[len(prefix) :]
if rest.startswith(_MANGLED_SCHEMES):
return rest
return None
def _as_segment(node: Any) -> tuple[str, dict] | None:
"""识别消息段对象 → (type, data);不是消息段返回 None。
@@ -404,7 +500,12 @@ def _as_segment(node: Any) -> tuple[str, dict] | None:
async def _walk_segment(seg_type: str, seg_data: dict, depth: int) -> None:
if seg_type in MEDIA_SEG_TYPES:
rewritten = await rewrite_file(seg_data.get("file"))
value = seg_data.get("file")
# 先还原构造期被 alconna 补坏的伪协议,再按需改写本地文件
repaired = repair_mangled_url(value)
if repaired is not None:
value = seg_data["file"] = repaired
rewritten = await rewrite_file(value)
if rewritten is not None:
seg_data["file"] = rewritten
# 合并转发节点:content 可能是 str / list[dict] / Message,里面还可能嵌媒体
@@ -455,9 +556,10 @@ async def _inline_outbound_media(bot: BaseBot, api: str, data: dict[str, Any]) -
钩子拿到的是 `_call_api` 之前**同一个可变 data dict**,且 hook 的 task group
在 `_call_api` 之前 await 完成,所以原地改生效。
这里不按 `_enabled()` 提前退出:两个改写函数各自按发送方式把关(file 模式
原样放行),而 `repair_mangled_url` 修的是构造期就坏掉的值,任何模式都该修。
"""
if not _enabled():
return
is_send = api in _SEND_APIS
is_upload = api in _FILE_UPLOAD_APIS
if not (is_send or is_upload) or not isinstance(data, dict):
+100
View File
@@ -0,0 +1,100 @@
"""定时任务的统一默认值与日志(`nonebot_plugin_apscheduler` 的薄封装)。
直接用 `scheduler.scheduled_job` / `scheduler.add_job` 时,各插件参数各写各的,
而 APScheduler 的原生默认值有两个坑:
1. **不设时区** → 用服务器本地时区。换机器、进容器、系统时区不是
Asia/Shanghai 时,`cron(hour=4)` 这类任务会整体错位 —— 而且错得很安静:
任务照常在跑,只是时间不对(`interval` 类任务不受影响)。
2. **`misfire_grace_time` 默认只有 1 秒** → 事件循环被卡一下(同步 IO、GC、
笔记本休眠唤醒)任务就被判"错过"直接丢弃,一天少跑几次很难发现。
本模块只做两件事:**补默认值** + **包一层日志**(起止 DEBUG、异常 ERROR 后吞掉,
不让坏任务刷爆 apscheduler 自己的日志)。调用方显式给的参数一律优先,
触发条件一个字都不改。
用法(跟原来一模一样,只是换个导入):
from hexi.core.scheduler import add_job, scheduled_job
@scheduled_job("cron", hour=4, id="temp_cleanup")
async def cleanup(): ...
add_job(fn, "interval", minutes=10, id="xxx", replace_existing=True)
动态删任务仍用 `nonebot_plugin_apscheduler.scheduler.remove_job(...)`。
"""
from __future__ import annotations
import functools
from collections.abc import Callable
from typing import Any
from nonebot import require
from nonebot.log import logger
require("nonebot_plugin_apscheduler")
from nonebot_plugin_apscheduler import scheduler # noqa: E402
#: 默认时区:显式钉死,不跟随服务器本地时区(见模块文档第 1 条)
DEFAULT_TIMEZONE = "Asia/Shanghai"
#: 默认宽限:宁可晚做也别不做(见模块文档第 2 条)
DEFAULT_MISFIRE_GRACE_TIME = 60
#: 补进 kwargs 的默认值;`coalesce` 在 APScheduler 里本来就是 True,
#: 写出来是为了表明"积压的任务只补跑一次"是有意为之
_DEFAULTS: dict[str, Any] = {
"timezone": DEFAULT_TIMEZONE,
"misfire_grace_time": DEFAULT_MISFIRE_GRACE_TIME,
"coalesce": True,
}
def _with_defaults(kwargs: dict[str, Any]) -> dict[str, Any]:
for key, value in _DEFAULTS.items():
kwargs.setdefault(key, value)
return kwargs
async def _run_logged(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
"""跑一个任务:起止 DEBUG、异常 ERROR 后吞掉(任务失败不该外抛给调度器)。"""
logger.debug(f"定时任务开始: {func.__name__}")
try:
result = await func(*args, **kwargs)
except Exception:
logger.exception(f"定时任务异常: {func.__name__}")
return None
logger.debug(f"定时任务完成: {func.__name__}")
return result
def scheduled_job(*args: Any, **kwargs: Any) -> Callable[..., Any]:
"""`scheduler.scheduled_job` + 统一默认值与日志(装饰器,用法不变)。"""
_with_defaults(kwargs)
def deco(func: Callable[..., Any]) -> Callable[..., Any]:
# functools.wraps 保住 __name__:APScheduler 不指定 id 时用函数名做任务 id
@functools.wraps(func)
async def wrapper(*fargs: Any, **fkwargs: Any) -> Any:
return await _run_logged(func, *fargs, **fkwargs)
return scheduler.scheduled_job(*args, **kwargs)(wrapper)
return deco
def add_job(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
"""`scheduler.add_job` + 统一默认值(动态增删任务用)。
注意包了一层:注册的是包装函数,`func.__name__` 由 `functools.wraps` 保住,
所以不显式传 `id` 时的默认任务 id 仍是原函数名。删除任务请用原函数的
id(或显式传 `id`)配合 `scheduler.remove_job(...)`。
"""
@functools.wraps(func)
async def wrapper(*fargs: Any, **fkwargs: Any) -> Any:
return await _run_logged(func, *fargs, **fkwargs)
return scheduler.add_job(wrapper, *args, **_with_defaults(kwargs))
+418
View File
@@ -0,0 +1,418 @@
"""跨插件的「任务槽」:长任务的进度上报 + 同槽去重 + SSE 推送。
起因(2026-10-10):video_analysis 的 temp 清理是个几十秒级的阻塞任务,直接放在
HTTP 请求里会把页面交互一起卡住,用户还可能连点几次。这类需求(清缓存、批量下载、
批量生成…)会越来越多,所以在 core 出一份通用机制,插件按需注册。
三个概念:
- **槽(slot)**:字符串标识,约定 `插件id:动作`(如 `video_analysis:temp_cleanup`)。
**同一槽同时只跑一个任务** —— 重复 `submit` 不新建,直接把你交给正在跑的那个并返回
`reused=True`,前端据此禁用按钮 / 提示"已在运行"(服务端是最终防线,不能只靠前端)。
- **执行**:任务体是**同步可调用对象**,由本模块丢线程池跑(`asyncio.to_thread`),
所以清理/下载那类阻塞 IO 不会卡事件循环;函数签名固定为 `func(report) -> 结果`。
- **进度**:`report(done=…, total=…, phase=…, message=…, warning=…)`,在**任务体所在的
线程**里调用,内部用 `loop.call_soon_threadsafe` 切回事件循环改状态。
推送按"百分比桶"节流(同一百分点内不重复发),上万个文件也不会灌爆 SSE。
`report` 的**返回值**是协作式取消信号:`False` 表示有人请求取消,任务体应尽快收尾
(清理这种天然可中断的任务在循环里判断即可,不要硬杀线程)。
推送方式:`subscribe()` 拿一个 `asyncio.Queue`,此后**任何**任务变化都会往队列里塞一份
全量快照;hub 的 `/api/tasks/stream` 直接搭在它上面。任务少、变化不频繁,全量快照比
增量协议省心得多(代价是 `result` 之类的字段要克制,别往里塞大对象)。
"""
from __future__ import annotations
import asyncio
import time
import uuid
from dataclasses import dataclass, field
from typing import Any
from collections.abc import Callable
from nonebot.log import logger
#: 任务体:接收 report 回调,返回任意结果(会放进 task.result)
TaskFunc = Callable[[Callable[..., bool]], Any]
#: 终态任务保留时长(秒)与总量上限(超出先丢最旧的终态任务)
TERMINAL_TTL = 30 * 60
MAX_TASKS = 50
#: 状态取值:running → done / failed / canceled
STATUS_RUNNING = "running"
STATUS_DONE = "done"
STATUS_FAILED = "failed"
STATUS_CANCELED = "canceled"
@dataclass
class Task:
"""一条任务记录;`to_dict()` 就是 SSE / REST 的负载。
字段分四组:**身份**(id / slot / title / plugin_id)、**来源**(trigger / actor /
group_id,出问题时能查到是谁点的)、**进度**(phase / done / total / unit /
message,to_dict 里归成 `progress` 对象)、**结果**(result / warnings / error)
与**时间**(created / started / updated / finished)。
"""
# ── 身份 ──────────────────────────────────────────
id: str
#: 槽名:同槽互斥,约定 `插件id:动作`
slot: str
#: 展示名,如"清理 temp(保留 7 天)"
title: str
plugin_id: str = ""
# ── 来源(审计) ───────────────────────────────────
#: 触发方式:web / command / schedule
trigger: str = ""
#: web 触发是登录用户名,command 触发是 QQ 号
actor: str = ""
#: command 触发时的群号
group_id: str = ""
# ── 状态 ──────────────────────────────────────────
status: str = STATUS_RUNNING
#: 有人请求取消(任务体通过 `report()` 的返回值感知)
cancel_requested: bool = False
# ── 进度 ──────────────────────────────────────────
#: 阶段文案("扫描中" / "清理中" / "回收空目录")
phase: str = ""
done: int = 0
#: 0 = 总数未知(扫描阶段没有进度条)
total: int = 0
#: 进度单位("个文件"),前端拼 "3/7 个文件"
unit: str = ""
#: 当前细节("已删除 2 个")
message: str = ""
# ── 结果 ──────────────────────────────────────────
result: Any = None
#: 部分失败(跳过的被占用文件等)——不算失败,但要让人看见
warnings: list[str] = field(default_factory=list)
error: str = ""
# ── 时间 ──────────────────────────────────────────
created_at: float = field(default_factory=time.time)
started_at: float = 0.0
updated_at: float = field(default_factory=time.time)
finished_at: float = 0.0
#: 结束信号(不出 API):命令层要"等任务跑完再回复"时用 `TASKS.wait(task)`
_done: asyncio.Event = field(
default_factory=asyncio.Event, repr=False, compare=False
)
@property
def active(self) -> bool:
return self.status == STATUS_RUNNING
@property
def percent(self) -> int | None:
"""0-100;总数未知时 None(前端显示不确定进度)。"""
if not self.active:
return 100
if self.total <= 0:
return None
return max(0, min(100, int(self.done * 100 / self.total)))
@property
def duration(self) -> float:
"""耗时(秒):跑着就是"已运行多久",结束就是总用时,没开始是 0。"""
if not self.started_at:
return 0.0
end = self.finished_at or (time.time() if self.active else 0.0)
return max(0.0, end - self.started_at)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"slot": self.slot,
"title": self.title,
"plugin_id": self.plugin_id,
"trigger": self.trigger,
"actor": self.actor,
"group_id": self.group_id,
"status": self.status,
"active": self.active,
"cancel_requested": self.cancel_requested,
"progress": {
"percent": self.percent,
"done": self.done,
"total": self.total,
"unit": self.unit,
"phase": self.phase,
"message": self.message,
},
"result": self.result,
"warnings": list(self.warnings),
"error": self.error,
"created_at": self.created_at,
"started_at": self.started_at,
"updated_at": self.updated_at,
"finished_at": self.finished_at,
"duration": self.duration,
}
class TaskSlots:
"""任务注册表(进程内单例 `TASKS`)。
只在事件循环里用:`submit` 需要 running loop 来建后台任务,`subscribe` 的队列
也绑定在事件循环上。任务体本身跑在线程池里,但改状态一律切回事件循环。
"""
def __init__(self, *, max_tasks: int = MAX_TASKS, ttl: float = TERMINAL_TTL) -> None:
self._tasks: dict[str, Task] = {}
self._subscribers: set[asyncio.Queue[list[dict[str, Any]]]] = set()
self._loop: asyncio.AbstractEventLoop | None = None
self._bg: set[asyncio.Task[None]] = set()
self.max_tasks = max_tasks
self.ttl = ttl
#: 节流用:task.id -> 上次推送的百分比桶(-1 表示"未定进度")
self._last_bucket: dict[str, int] = {}
# ── 查询 ──────────────────────────────────────────────
def list(self) -> list[Task]:
"""全部任务,新的在前(顺带回收超龄/超量的终态任务)。"""
self._prune()
return sorted(self._tasks.values(), key=lambda t: t.created_at, reverse=True)
def get(self, task_id: str) -> Task | None:
return self._tasks.get(task_id)
def active(self, slot: str) -> Task | None:
"""该槽正在跑的任务(没有则 None)—— 去重判定就是它。"""
for task in self._tasks.values():
if task.slot == slot and task.active:
return task
return None
# ── 提交 / 取消 ───────────────────────────────────────
def submit(
self,
*,
slot: str,
title: str,
func: TaskFunc,
plugin_id: str = "",
trigger: str = "",
actor: str = "",
group_id: str = "",
) -> tuple[Task, bool]:
"""提交一个任务;同槽已有在跑的 → 不新建,返回 (已有任务, True)。
立即返回(不等任务跑完),任务体在线程池里执行。
"""
running = self.active(slot)
if running is not None:
logger.info(f"任务槽 {slot} 已有任务在跑({running.title}),本次提交复用")
return running, True
self._loop = asyncio.get_running_loop()
task = Task(
id=uuid.uuid4().hex[:12],
slot=slot,
title=title,
plugin_id=plugin_id,
trigger=trigger,
actor=str(actor or ""),
group_id=str(group_id or ""),
)
self._tasks[task.id] = task
self._prune()
# 审计信息进日志:任务表本身是内存态(重启即清),但"谁在什么时候点了什么"
# 要能在 /hub 日志页翻到
who = trigger or "unknown"
if actor:
who += f" · {actor}"
if group_id:
who += f"@群{group_id}"
logger.info(f"任务槽 {slot} 启动任务:{title}({who})")
bg = asyncio.create_task(self._run(task, func))
self._bg.add(bg)
bg.add_done_callback(self._bg.discard)
self._publish()
return task, False
def cancel(self, task_id: str) -> bool:
"""请求取消:只置标志,**任务体自己决定何时收尾**(协作式,不硬杀线程)。
任务体在 `report(...)` 的返回值里看到 False 就该尽快结束并返回已有结果,
任务随即落到 `canceled` 状态(结果与 warnings 保留,方便看"取消前清了多少")。
"""
task = self._tasks.get(task_id)
if task is None or not task.active:
return False
task.cancel_requested = True
task.updated_at = time.time()
logger.info(f"任务槽 {task.slot} 收到取消请求:{task.title}")
self._publish()
return True
async def wait(self, task: Task) -> Task:
"""等任务跑完并返回它(已结束则立即返回)。
命令层用:"跑完再回复结果";Web 层不用(提交完就返回,进度走 SSE)。
"""
await task._done.wait()
return task
def dismiss(self, task_id: str) -> bool:
"""从列表里删掉一条**已结束**的任务,返回是否删掉了。
在跑的任务删不得(删了就再也没人回传它的状态了),只能先取消、等它收尾;
所以这里对 active 的任务直接返回 False,让调用方去提示。
"""
task = self._tasks.get(task_id)
if task is None or task.active:
return False
self._tasks.pop(task_id, None)
self._last_bucket.pop(task_id, None)
self._publish()
return True
def clear_finished(self) -> int:
"""清掉所有终态任务(在跑的不动),返回清掉的条数。"""
finished = [tid for tid, t in self._tasks.items() if not t.active]
for tid in finished:
self._tasks.pop(tid, None)
self._last_bucket.pop(tid, None)
if finished:
self._publish()
return len(finished)
# ── 订阅(SSE) ───────────────────────────────────────
def subscribe(self) -> asyncio.Queue[list[dict[str, Any]]]:
queue: asyncio.Queue[list[dict[str, Any]]] = asyncio.Queue()
self._subscribers.add(queue)
return queue
def unsubscribe(self, queue: asyncio.Queue[list[dict[str, Any]]]) -> None:
self._subscribers.discard(queue)
def snapshot(self) -> list[dict[str, Any]]:
return [t.to_dict() for t in self.list()]
# ── 内部:执行与推送 ──────────────────────────────────
async def _run(self, task: Task, func: TaskFunc) -> None:
"""线程池里跑任务体;进度回调切回事件循环;异常落进 task.error。"""
task.started_at = time.time()
task.updated_at = task.started_at
def report(
done: int | None = None,
total: int | None = None,
phase: str | None = None,
unit: str | None = None,
message: str | None = None,
warning: str | None = None,
) -> bool:
"""任务体所在线程调用;返回 False 表示"请尽快收尾"(协作式取消)。"""
loop = self._loop
if loop is not None:
loop.call_soon_threadsafe(
self._apply_progress, task, done, total, phase, unit, message, warning
)
# cancel_requested 由事件循环线程写、工作线程读:CPython 下读 bool 是安全的
return not task.cancel_requested
try:
result = await asyncio.to_thread(func, report)
except Exception as e: # noqa: BLE001 - 任务边界:失败即任务状态,不外抛
logger.exception(f"任务失败({task.slot}):{task.title}")
task.status = STATUS_FAILED
task.error = str(e) or type(e).__name__
else:
task.status = STATUS_CANCELED if task.cancel_requested else STATUS_DONE
task.result = result
warn = f",{len(task.warnings)} 条警告" if task.warnings else ""
logger.info(
f"任务{'取消' if task.status == STATUS_CANCELED else '完成'}"
f"({task.slot}):{task.title},用时 {task.duration:.1f}s{warn}"
)
task.finished_at = time.time()
task.updated_at = task.finished_at
task._done.set()
self._publish()
def _apply_progress(
self,
task: Task,
done: int | None,
total: int | None,
phase: str | None,
unit: str | None,
message: str | None,
warning: str | None = None,
) -> None:
"""在事件循环里更新进度;按百分比桶节流推送。"""
if not task.active: # 任务已结束(被 clear / 已失败)→ 丢弃迟到的进度
return
if done is not None:
task.done = max(0, int(done))
if total is not None:
task.total = max(0, int(total))
if phase is not None:
task.phase = phase
if unit is not None:
task.unit = unit
if message is not None:
task.message = message
if warning:
task.warnings.append(str(warning))
task.updated_at = time.time()
bucket = task.percent if task.percent is not None else -1
noteworthy = phase is not None or message is not None or bool(warning)
if bucket != self._last_bucket.get(task.id) or noteworthy:
self._last_bucket[task.id] = bucket
self._publish()
def _publish(self) -> None:
"""给所有订阅者塞一份全量快照(队列满则丢掉最旧的,宁可丢进度也不阻塞)。"""
if not self._subscribers:
return
snapshot = self.snapshot()
for queue in list(self._subscribers):
if queue.full():
try:
queue.get_nowait()
except asyncio.QueueEmpty: # pragma: no cover - full 与 empty 之间被取走
pass
try:
queue.put_nowait(snapshot)
except asyncio.QueueFull: # pragma: no cover - 上面已腾位
continue
def _prune(self) -> None:
"""先清超龄终态任务,再按上限砍最旧的终态任务(在跑的不动)。"""
now = time.time()
for tid, task in list(self._tasks.items()):
if not task.active and now - task.updated_at > self.ttl:
self._tasks.pop(tid, None)
self._last_bucket.pop(tid, None)
overflow = len(self._tasks) - self.max_tasks
if overflow <= 0:
return
finished = sorted(
(t for t in self._tasks.values() if not t.active),
key=lambda t: t.created_at,
)
for task in finished[:overflow]:
self._tasks.pop(task.id, None)
self._last_bucket.pop(task.id, None)
#: 全局单例(hub 路由与各插件共用)
TASKS = TaskSlots()
+40 -252
View File
@@ -1,274 +1,62 @@
from nonebot import on_command, require
from nonebot.adapters import Message, Event, Bot
"""BF查询 —— 入口(MTSS:handlers → services → repository + utils)。
只做五件事:元数据 / require / 导入子模块(命令 + 配置 + Web)/ 建表 / 扩展桥启停钩子。
业务命令在 handlers/,编排在 services/,数据读写走 repository.py。
"""
from nonebot import get_driver, require
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数据)
bf3: /bf3 [EAID] (查询BF3数据)
bf4: /bf4 [EAID] (查询BF4数据)
bf1: /bf1 [EAID] (查询BF1数据)
bfv: /bfv [EAID] (查询BFV数据)
bf2042: /bf2042 [EAID] (查询BF2042生涯数据)
bf2042: /bf2042 武器|载具|装备 [EAID] (BF2042 榜单)
bf2042: /bf2042 模式 [EAID] (BF2042 各模式明细)
bf6: /bf6 [EAID] (查询BF6数据)
绑定: /绑定 EAID (绑定你的QQ与EAID)
解绑: /解绑 (解除你的QQ与当前绑定的EAID)
修改绑定: /修改绑定 EAID (修改你的QQ与当前绑定的EAID)
以上查询命令省略 EAID 时使用已绑定账号;命令需要 @机器人。
""".strip(),
type="application",
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)
# 导入子模块:handlers 注册命令,config 注册配置,web 提供 /api/bf_bot
from . import config as _config # noqa: E402
from . import handlers as _handlers # noqa: E402,F401
from . import web as _web # noqa: E402,F401
from .repository import init_database # noqa: E402
bf_dict = {
"bf3": "战地3",
"bf4": "战地4",
"bf1": "战地1",
"bfv": "战地5",
"bf2042": "战地2042",
"bf6": "战地6",
}
# 建表(幂等):库文件不随仓库分发,新环境/热重载都要在这里补齐,否则查询报 no such table
if not init_database():
logger.error("用户数据库初始化失败,绑定/标签功能将不可用")
_config.register_config()
@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}")
# BF2042 浏览器扩展桥:WS 服务端随 bot 启停(127.0.0.1;见 services/fetchers/tracker_bridge.py)
from .services.fetchers import tracker_bridge as _tracker_bridge # noqa: E402
_driver = get_driver()
_driver.on_startup(_tracker_bridge.start_bridge_server)
_driver.on_shutdown(_tracker_bridge.stop_bridge_server)
@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()
# 注册到统一 Web 管理台(/hub):hub 启动时挂载 /api/bf_bot
# 前端页面 hexi/web/src/plugins/bf_bot/(绑定列表 + 删除)
from hexi.web_hub.web_plugin_registry import register_web_plugin # noqa: E402
# 执行已有数据查询操作
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()
register_web_plugin(
"bf_bot",
"BF查询",
"gamepad",
lambda: _web.build_admin_app(),
module_name=__name__,
)
@@ -1,137 +0,0 @@
import asyncio
import os
import random
from pathlib import Path
from typing import List, Dict, Optional
from curl_cffi import AsyncSession, CurlError
from nonebot import logger
# ---------- 配置 ----------
file_path = os.path.dirname(__file__).replace("\\", "/")
exported_cookie_path = Path(f"{file_path}/cookies/tracker.txt") # 你导出的 cookies.txt
CUSTOM_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"
RAW_BROWSER_HEADERS = """
sec-ch-ua-platform: "Windows"
sec-ch-ua: "Google Chrome";v="141", "Not?A_Brand";v="8", "Chromium";v="141"
sec-ch-ua-mobile: ?0
user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36
"""
# ======================
def parse_raw_headers(raw: str) -> Dict[str, str]:
headers = {}
skip_prefix = (":authority", ":method", ":path", ":scheme")
for line in raw.strip().splitlines():
if not line.strip():
continue
if any(line.lower().startswith(p) for p in skip_prefix):
continue
if ":" not in line:
continue
key, value = line.split(":", 1)
headers[key.strip()] = value.strip()
return headers
def load_cookies_from_txt(path: Path) -> List[Dict[str, str]]:
cookies = []
if not path.exists():
return cookies
with path.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split("\t")
if len(parts) >= 7:
cookies.append({"name": parts[5], "value": parts[6]})
elif "=" in line:
k, v = line.split("=", 1)
cookies.append({"name": k.strip(), "value": v.strip()})
return cookies
def build_cookie_header(cookies: List[Dict[str, str]]) -> str:
return "; ".join(f"{c['name']}={c['value']}" for c in cookies)
def build_headers(
raw_headers: str,
cookies: Optional[List[Dict[str, str]]] = None,
ua_override: Optional[str] = None,
) -> Dict[str, str]:
headers = parse_raw_headers(raw_headers)
# 强制改成 XHR 请求风格
headers.update({
"accept": "application/json, text/plain, */*",
"origin": "https://tracker.gg",
"referer": "https://tracker.gg/",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-site",
})
if cookies:
headers["cookie"] = build_cookie_header(cookies)
if ua_override:
headers["user-agent"] = ua_override
return headers
async def fetch_with_cookies(url: str, headers: dict, impersonate: str = "chrome110", timeout: int = 15):
async with AsyncSession() as session:
try:
await asyncio.sleep(random.uniform(0.5, 1.2)) # 模拟浏览器请求间隔
response = await session.get(url, headers=headers, impersonate=impersonate, timeout=timeout)
# 重试一次,防止偶尔 CF 拦截
if getattr(response, "status_code", 0) in (403, 429):
await asyncio.sleep(2)
response = await session.get(url, headers=headers, impersonate=impersonate, timeout=timeout)
return response
except CurlError as e:
return {"error": f"cURL 错误: {e}"}
except Exception as e:
return {"error": f"未知错误: {e}"}
def is_challenge_response(resp) -> bool:
if isinstance(resp, dict) and "error" in resp:
return True
try:
status = getattr(resp, "status_code", None)
text = getattr(resp, "text", "") or ""
if status in (429, 403):
return True
low = text.lower()
if any(k in low for k in ["cloudflare", "captcha", "cf-challenge", "just a moment"]):
return True
except Exception:
return True
return False
async def search_user_with_fallback(url: str):
# cookies.txt
cookies = load_cookies_from_txt(exported_cookie_path)
headers = build_headers(RAW_BROWSER_HEADERS, cookies=cookies, ua_override=CUSTOM_UA)
logger.info(f"请求 URL: {url}")
logger.info(f"使用 headers 字段数: {len(headers)}")
resp = await fetch_with_cookies(url, headers)
if is_challenge_response(resp):
logger.warning("Cloudflare 拦截或 cookies 失效。")
if isinstance(resp, dict):
return resp
return {"status": getattr(resp, "status_code", None), "preview": getattr(resp, "text", "")[:200]}
else:
logger.info("请求成功。")
return getattr(resp, "json", lambda: resp)() if hasattr(resp, "json") else resp
@@ -0,0 +1,80 @@
# HeXi BF2042 捕获桥(浏览器扩展)
被动捕获 tracker.gg 的 BF2042 API 响应,经 WebSocket 转发给本机 bot。
服务端有两个等价实现:bot 插件内的 `services/fetchers/tracker_bridge.py`(正式,
随 bot 启停,端口/token 是 /hub 插件配置页里的 `bf_ext_ws_port`/`bf_ext_ws_token`),
以及 `debug/bf2042_bridge_server.py`(独立联调用,不启 bot)。两侧默认
`ws://127.0.0.1:39272/?token=hexi-bf2042`。
## 原理
历史通道要走 `--remote-debugging-port` + Playwright `connect_over_cdp`。本扩展把捕获
搬进浏览器内部,参照 FetchV / 猫抓一类捕获扩展的通行模式:
- `inject.js`(主世界,document_start)用 Proxy 包住 `fetch` / `XMLHttpRequest`,
被动观察**页面自己发出**的请求(不改写、不重放,只读 clone 的响应体);
- `content.js`(隔离世界)把捕获经 `chrome.runtime` 转给 SW
(内容脚本连 `ws://127.0.0.1` 会被页面 CSP 的 connect-src 拦掉,所以 WS 必须放 SW);
- `background.js`(SW)持有到 bot 的 WebSocket,转发捕获、执行取数任务:
找/开 tracker.gg 标签页 → 回首页落稳 → 站内跳转进 profile 页 → 等页面发 XHR。
- **失败重试**:单轮 22s 内关键响应没齐(多半撞了 CF 挑战/403,页面 XHR 拿不到
数据),自动重走"首页→站内跳转"刷新,最多 3 轮;每轮结束做页面健康检查
(挑战页/封禁页/登录过期)并打日志。
**不需要任何调试端口**,浏览器就是个普通 Edge 实例,可以常驻不关。
## 安装(一次性)
1. 用专用配置开 Edge(登录态 + CF 信任都在里面):
```
msedge.exe --user-data-dir="<repo>\hexi\data\nonebot_plugin_bf_bot\edge_profile"
```
2. 地址栏开 `edge://extensions` → 打开"开发人员模式" → "加载解压缩的扩展" → 选本目录。
3. 若有"开发者模式扩展"启动提示,保留即可(纯提醒,不影响)。
代码改动后在 `edge://extensions` 点该扩展的"刷新"按钮生效;SW 日志在扩展详情页点
"Service Worker" 检查。
## 联调(不启 bot)
```
.venv\Scripts\python.exe debug\bf2042_bridge_server.py # 手动浏览观察
.venv\Scripts\python.exe debug\bf2042_bridge_server.py --job EAID # 自动取数全流程
```
预期输出:先 "扩展已连接";手动打开任意 BF2042 profile 页会打印被动捕获;
`--job` 时扩展自动完成首页→站内跳转,打印 4 个响应,最后 "任务完成 keys=..."。
注意:bot 在跑时 39272 已被 bot 自己的扩展桥占用——要跑独立联调服务端得先停
bot(或临时改扩展 `background.js` 的端口常量)。bot 在跑时直接看 bot 日志里
"BF2042 扩展桥" 相关行即可,不用另起服务端。
## WebSocket 协议(v0)
服务端:`ws://127.0.0.1:39272/?token=hexi-bf2042`(仅回环 + 共享 token;
常量在 `background.js` 顶部,bot 侧对应配置项为 `bf_ext_ws_port`/`bf_ext_ws_token`,
两边保持一致)。
| 方向 | 消息 |
|---|---|
| ext→bot | `{"type":"hello","name":"hexi-bf2042-bridge","version":"0.1.0"}` |
| bot→ext | `{"type":"fetch","id":"...","player":"X","platform":"origin","profileUrl":"https://tracker.gg/bf2042/profile/origin/X/overview","apiBase":"https://api.tracker.gg/api/v2/bf2042/standard/profile/origin/X"}` |
| ext→bot | `{"type":"capture","id":null 或任务id,"key":"profile\|weapon\|vehicle\|soldier","url":"...","status":200,"body":{...}}` |
| ext→bot | `{"type":"job_done","id":"...","keys":["profile","weapon","vehicle"]}` |
| ext→bot | `{"type":"job_error","id":"...","keys":[...],"detail":"..."}` |
| 双向 | `{"type":"ping"}` |
- 无任务时的捕获以 `id: null` 被动转发。
- 任务的 URL 匹配"两遍 decode 再比",消除 python `quote()` 与 JS
`encodeURIComponent` 的转义差异。
- 任务期间只认自己驱动的标签页(`tabId` 过滤)。
## 注意
- 导航定律保留:首页 bootstrap → 站内跳转(`location.href`);深链直开会被挑战。
- MV3 SW 保活:活跃 WebSocket(Chromium 116+)+ `chrome.alarms`(30s)双保险,
bot 重启后扩展自动重连。
- 若手动浏览时页面有数据、扩展却捕获不到 → 该请求可能不经过页面 `fetch`/`XHR`
(如 Worker 发起)。届时把捕获后端换成 `chrome.debugger`(只开 Network 域),
管道其余部分不用动。
- 本目录不要放 `.py`(它是插件目录子目录的一部分,保持纯前端文件)。
@@ -0,0 +1,283 @@
// HeXi BF2042 捕获桥 - Service Worker
//
// 两个职责:
// 1) 持有到本机 bot 的 WebSocket(自动重连 + 心跳),转发页面捕获的响应;
// 2) 执行 bot 下发的取数任务:找/开 tracker.gg 标签页 → 首页 bootstrap →
// 站内跳转进 profile 页 → 等页面自己发出 XHR → 上报 job_done。
//
// 导航定律(来自插件文档,勿改):地址栏深链会被 Cloudflare 挑战,必须先落首页、
// 再在站内跳转。全程不用任何调试端口,浏览器就是个普通实例。
//
// 重试机制:页面撞 CF 挑战/403 时,页面自己的 XHR 不会产出数据,本轮
// ATTEMPT_WAIT_MS 内关键响应收不齐 → 自动重走"首页→站内跳转"(刷新等价),
// 最多 MAX_ATTEMPTS 轮;提前收齐即收工。
const WS_URL = "ws://127.0.0.1:39272/";
const WS_TOKEN = "hexi-bf2042"; // 仅本机回环 + 共享 token;修改时与 bot 侧配置同步
const HOMEPAGE = "https://tracker.gg/";
const CAPTURE_PREFIX =
"https://api.tracker.gg/api/v2/bf2042/standard/profile/";
const JOB_KEYS = ["profile", "weapon", "vehicle"]; // 齐了即收工(soldier 可选)
const SETTLE_MS = 3000; // 首页落稳再跳,镜像 python 通道的 sleep(3)
const JOB_TIMEOUT_MS = 150000;
// 重试:单轮 X 秒内关键数据没齐 = 多半撞了 CF 挑战/403,重走"首页→站内跳转"刷新
const ATTEMPT_WAIT_MS = 22000;
const MAX_ATTEMPTS = 3;
let ws = null;
let retryMs = 1000;
let job = null; // { id, base, tabId, keys:Set, attempts, timer }
const log = (...a) => console.log("[bf2042-bridge]", ...a);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ------------------------------------------------------------------ WS
function connect() {
if (ws) return; // CONNECTING / OPEN 状态都不重复建
try {
ws = new WebSocket(`${WS_URL}?token=${encodeURIComponent(WS_TOKEN)}`);
} catch (_) {
scheduleReconnect();
return;
}
ws.onopen = () => {
retryMs = 1000;
log("已连接 bot");
send({ type: "hello", name: "hexi-bf2042-bridge", version: "0.1.0" });
};
ws.onmessage = (ev) => {
try {
handleMessage(JSON.parse(ev.data));
} catch (_) {}
};
ws.onclose = () => scheduleReconnect();
ws.onerror = () => {
try {
ws.close();
} catch (_) {}
};
}
function scheduleReconnect() {
ws = null;
setTimeout(connect, retryMs);
retryMs = Math.min(retryMs * 2, 30000); // 1s → 2s → ... → 上限 30s
}
function send(obj) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(obj));
return true;
}
return false;
}
// 心跳:兼作 MV3 SW 保活(Chromium 116+ 活跃 WS 本就会保活,alarms 双保险,
// 覆盖"bot 重启导致断线、SW 又在重连定时器前被回收"的窗口)。
chrome.alarms.create("keepalive", { periodInMinutes: 0.5 });
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name !== "keepalive") return;
if (!ws) connect();
else send({ type: "ping" });
});
connect();
// ------------------------------------------------------------------ 工具
function norm(url) {
return String(url || "").split("?")[0].replace(/\/+$/, "");
}
// 两边都 decode 再比,消除 python quote() 与 JS encodeURIComponent 的转义差异
function canon(url) {
try {
return decodeURIComponent(norm(url));
} catch (_) {
return norm(url);
}
}
function isTracker(url) {
return /^https:\/\/(www\.)?tracker\.gg\//.test(String(url || ""));
}
function keyOf(url) {
const u = norm(url);
for (const k of ["weapon", "vehicle", "soldier", "gadget"]) {
if (u.endsWith(`/segments/${k}`)) return k;
}
return "profile"; // profile 基础响应(gamemode 等段都在它里面)
}
// ------------------------------------------------------------------ 任务
function handleMessage(msg) {
if (!msg || typeof msg !== "object") return;
if (msg.type === "fetch") startJob(msg).catch(() => {});
}
async function startJob(msg) {
if (job) return send({ type: "job_error", id: msg.id, detail: "busy" });
if (!msg.profileUrl || !msg.apiBase) {
return send({ type: "job_error", id: msg.id, detail: "bad_request" });
}
job = {
id: msg.id,
base: canon(msg.apiBase),
tabId: null,
keys: new Set(),
attempts: 0,
timer: setTimeout(() => finishJob("timeout"), JOB_TIMEOUT_MS),
};
try {
const tab = await ensureTab();
job.tabId = tab.id;
await chrome.tabs.update(tab.id, { active: true });
for (let attempt = 1; attempt <= MAX_ATTEMPTS && job; attempt++) {
job.attempts = attempt;
// 严格镜像已验证路径:每轮都先落首页,停顿,再站内跳转
try {
await chrome.tabs.update(tab.id, { url: HOMEPAGE });
await waitComplete(tab.id);
} catch (_) {
break; // 标签页没了
}
await sleep(SETTLE_MS);
log(`第 ${attempt}/${MAX_ATTEMPTS} 轮,站内跳转:`, msg.profileUrl);
// setTimeout 0:让注入脚本先结束再触发跳转,避免 "frame detached" 噪声
chrome.scripting
.executeScript({
target: { tabId: tab.id },
world: "MAIN",
func: (u) => {
setTimeout(() => {
window.location.href = u;
}, 0);
},
args: [msg.profileUrl],
})
.catch(() => {});
const ok = await waitForKeys(tab.id, ATTEMPT_WAIT_MS);
if (!job) return; // 期间已收工/超时
if (ok) return finishJob("done");
const health = await pageHealth(tab.id);
log(`第 ${attempt} 轮未收齐(${health}),${attempt < MAX_ATTEMPTS ? "刷新重试" : "放弃"}`);
}
if (job) finishJob("no_data", "多轮重试后仍未捕获到数据");
} catch (e) {
finishJob("navigate_failed", String(e));
}
}
// 等待关键响应齐(或任务被外部收工);返回是否收齐
function waitForKeys(tabId, timeoutMs) {
return new Promise((resolve) => {
const t0 = Date.now();
const timer = setInterval(() => {
if (!job) {
clearInterval(timer);
resolve(false);
return;
}
if (JOB_KEYS.every((k) => job.keys.has(k))) {
clearInterval(timer);
resolve(true);
return;
}
if (Date.now() - t0 >= timeoutMs) {
clearInterval(timer);
resolve(false);
}
}, 500);
});
}
// 页面健康检查:区分"CF 挑战/封禁"与"页面正常但没数据"(仅供日志)
async function pageHealth(tabId) {
try {
const [res] = await chrome.scripting.executeScript({
target: { tabId },
func: () => {
const t = (document.title || "") + " " + (document.body?.innerText || "").slice(0, 300);
return t.slice(0, 200);
},
});
const t = String(res?.result || "");
if (/just a moment|安全验证|正在进行|verify you are human/i.test(t)) return "CF挑战页";
if (/you'?ve been blocked|been blocked/i.test(t)) return "TRN封禁页";
if (/username sign in|sign in with your tracker/i.test(t)) return "登录过期";
return "页面无挑战";
} catch (_) {
return "健康检查失败(标签页可能已关闭)";
}
}
async function ensureTab() {
const tabs = await chrome.tabs.query({
url: ["https://tracker.gg/*", "https://*.tracker.gg/*"],
});
if (tabs.length) return tabs[0];
const tab = await chrome.tabs.create({ url: HOMEPAGE, active: true });
await waitComplete(tab.id);
return tab;
}
function waitComplete(tabId, timeoutMs = 30000) {
return new Promise((resolve) => {
const done = () => {
clearTimeout(timer);
chrome.tabs.onUpdated.removeListener(listener);
resolve();
};
const timer = setTimeout(done, timeoutMs);
const listener = (id, info) => {
if (id === tabId && info.status === "complete") done();
};
chrome.tabs.onUpdated.addListener(listener);
chrome.tabs
.get(tabId)
.then((t) => {
if (t.status === "complete") done();
})
.catch(done);
});
}
function finishJob(reason, detail) {
if (!job) return;
clearTimeout(job.timer);
const { id, keys } = job;
job = null;
if (reason === "done") {
send({ type: "job_done", id, keys: [...keys] });
} else {
send({ type: "job_error", id, keys: [...keys], detail: detail || reason });
}
}
// ------------------------------------------------------------------ 捕获回传
chrome.runtime.onMessage.addListener((msg, sender) => {
if (!msg || msg.type !== "captured") return;
const url = String(msg.url || "");
if (!url.startsWith(CAPTURE_PREFIX)) return;
const key = keyOf(url);
if (job) {
// 只认我们驱动的那个标签页,避免把用户同时手动浏览的流量掺进来
if (job.tabId != null && sender.tab && sender.tab.id !== job.tabId) return;
if (!canon(url).startsWith(job.base)) return; // 不是这次任务那位玩家的
job.keys.add(key);
send({
type: "capture",
id: job.id,
key,
url,
status: msg.status,
body: msg.body,
});
if (JOB_KEYS.every((k) => job.keys.has(k))) finishJob("done");
return;
}
// 无任务:被动转发(id 为 null),人工浏览也顺手喂数据/便于联调
send({ type: "capture", id: null, key, url, status: msg.status, body: msg.body });
});
@@ -0,0 +1,25 @@
// 隔离世界桥(document_start):把主世界钩子 postMessage 上来的捕获转发给 SW。
//
// WS 由 SW 持有 —— 内容脚本直接连 ws://127.0.0.1 会被页面 CSP 的 connect-src 拦掉。
(() => {
"use strict";
const MARKER = "__hexi_bf2042_capture__";
window.addEventListener("message", (event) => {
if (event.source !== window) return;
const data = event.data;
if (!data || data[MARKER] !== true) return;
try {
chrome.runtime.sendMessage({
type: "captured",
url: String(data.url || ""),
status: data.status,
body: data.body,
});
} catch (_) {
// SW 正在重启等场景,丢弃即可(数据可重查,不值得重试)
}
});
})();
@@ -0,0 +1,102 @@
// 主世界钩子(document_start):被动观察页面自己发出的请求,把命中的响应体副本
// postMessage 给 content.js。参照 FetchV / 猫抓一类捕获扩展的通行模式。
//
// 铁律:只观察,不改写请求与响应;任何异常都必须静默,绝不能影响页面本身。
(() => {
"use strict";
const MARKER = "__hexi_bf2042_capture__";
const API_PREFIX =
"https://api.tracker.gg/api/v2/bf2042/standard/profile/";
function match(url) {
return typeof url === "string" && url.startsWith(API_PREFIX);
}
function report(url, status, body) {
try {
window.postMessage({ [MARKER]: true, url, status, body }, "*");
} catch (_) {
/* 忽略:上报失败不能影响页面 */
}
}
// ---- fetch ----
// 用 Proxy 包一层:Function.prototype.toString.call() 显示为 native
// (Proxy 的 target 是原生函数),比"改写后补 toString"更难被识破。
if (typeof window.fetch === "function") {
const orig = window.fetch;
window.fetch = new Proxy(orig, {
apply(target, thisArg, args) {
const input = args[0];
const url =
typeof input === "string" ? input : (input && input.url) || "";
const result = Reflect.apply(target, thisArg, args);
if (match(url)) {
result
.then((resp) => {
try {
const ctype = resp.headers.get("content-type") || "";
if (!ctype.includes("json")) return;
// 立刻 clone,免得页面随后消费了原响应体
resp
.clone()
.json()
.then((body) => report(url, resp.status, body))
.catch(() => {});
} catch (_) {}
})
.catch(() => {});
}
return result;
},
});
}
// ---- XMLHttpRequest ----
const XHR = window.XMLHttpRequest;
if (XHR && XHR.prototype) {
const origOpen = XHR.prototype.open;
const origSend = XHR.prototype.send;
const urls = new WeakMap(); // 挂在实例上会留可见的自定义属性,用 WeakMap 更干净
XHR.prototype.open = new Proxy(origOpen, {
apply(target, thisArg, args) {
try {
urls.set(thisArg, String(args[1] ?? ""));
} catch (_) {}
return Reflect.apply(target, thisArg, args);
},
});
XHR.prototype.send = new Proxy(origSend, {
apply(target, thisArg, args) {
try {
const url = urls.get(thisArg);
if (match(url)) {
thisArg.addEventListener(
"loadend",
() => {
try {
const rt = thisArg.responseType;
let body = null;
if (rt === "" || rt === "text") {
body = JSON.parse(thisArg.responseText);
} else if (rt === "json") {
body = thisArg.response;
} else {
return; // arraybuffer / blob 等类型不处理
}
if (body != null) report(url, thisArg.status, body);
} catch (_) {}
},
{ once: true }
);
}
} catch (_) {}
return Reflect.apply(target, thisArg, args);
},
});
}
})();
@@ -0,0 +1,31 @@
{
"manifest_version": 3,
"name": "HeXi BF2042 捕获桥",
"version": "0.1.0",
"description": "被动捕获 tracker.gg 的 BF2042 响应并经 WebSocket 转发给本机禾汐 bot。浏览器无需开调试端口。",
"minimum_chrome_version": "116",
"permissions": ["tabs", "scripting", "alarms"],
"host_permissions": [
"https://tracker.gg/*",
"https://*.tracker.gg/*",
"http://127.0.0.1/*"
],
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["https://tracker.gg/*", "https://*.tracker.gg/*"],
"js": ["inject.js"],
"run_at": "document_start",
"world": "MAIN",
"all_frames": true
},
{
"matches": ["https://tracker.gg/*", "https://*.tracker.gg/*"],
"js": ["content.js"],
"run_at": "document_start",
"all_frames": true
}
]
}
@@ -0,0 +1,123 @@
"""配置:按《插件配置文件标准》注册到统一 Web 管理台。
3 个配置项都与 BF2042 取数通道有关:浏览器扩展桥的端口 / token
(``services/fetchers/tracker_bridge.py``),以及取数浏览器专用配置目录
(登录态所在,诊断提示用)。来源是模块运行态,Web 保存后由 setter 热更新、
立即生效;重启后依次按 plugin_config.json 值、`HEXI_BF_*` 环境变量、默认值回退。
读取一律走本模块的 helper(内部 `get_effective_value`),不要在别处缓存值。
"""
from __future__ import annotations
import os
from hexi.web_hub.config_standard import register_config_items
from hexi.web_hub.web_config import get_effective_value
from .paths import DEFAULT_COOKIES_PATH, EDGE_PROFILE_DIR
_PLUGIN_ID = __package__ or __name__.rsplit(".", 1)[0]
_KEY_EDGE_PROFILE = "bf_edge_profile_dir"
_KEY_EXT_WS_PORT = "bf_ext_ws_port"
_KEY_EXT_WS_TOKEN = "bf_ext_ws_token"
# 旧 cookie 通道的配置键:仅保留读取 helper 兼容旧调用,不再注册
_KEY_COOKIES = "bf_tracker_cookies_path"
# 运行态现值:启动时用环境变量兜底初始化(ENV 名与 Web 写回的 .env 名一致)
_state: dict[str, object] = {
_KEY_EDGE_PROFILE: os.environ.get("HEXI_BF_EDGE_PROFILE") or str(EDGE_PROFILE_DIR),
_KEY_EXT_WS_PORT: os.environ.get("HEXI_BF_EXT_WS_PORT") or "39272",
_KEY_EXT_WS_TOKEN: os.environ.get("HEXI_BF_EXT_WS_TOKEN") or "hexi-bf2042",
_KEY_COOKIES: os.environ.get("HEXI_BF_TRACKER_COOKIES")
or str(DEFAULT_COOKIES_PATH),
}
def edge_profile_dir() -> str:
"""BF2042 取数浏览器专用配置目录(内含登录态、CF 信任与捕获桥扩展)。"""
value = get_effective_value(_PLUGIN_ID, _KEY_EDGE_PROFILE, None)
if value:
return str(value)
return str(_state[_KEY_EDGE_PROFILE] or EDGE_PROFILE_DIR)
def ext_ws_port() -> int:
"""扩展桥 WS 服务端端口(仅本机回环)。"""
value = get_effective_value(_PLUGIN_ID, _KEY_EXT_WS_PORT, None)
if value is None:
value = _state[_KEY_EXT_WS_PORT]
try:
return int(str(value))
except (TypeError, ValueError):
return 39272
def ext_ws_token() -> str:
"""扩展桥共享 token(扩展 background.js 顶部常量需与之保持一致)。"""
value = get_effective_value(_PLUGIN_ID, _KEY_EXT_WS_TOKEN, None)
if value:
return str(value)
return str(_state[_KEY_EXT_WS_TOKEN] or "hexi-bf2042")
def tracker_cookies_path() -> str:
"""[旧通道兼容] Netscape 格式 cookie 文件路径。"""
value = get_effective_value(_PLUGIN_ID, _KEY_COOKIES, None)
if value:
return str(value)
return str(_state[_KEY_COOKIES] or DEFAULT_COOKIES_PATH)
def _set(key: str, value: object) -> None:
_state[key] = value
def register_config() -> None:
"""注册 3 个取数通道配置项(Web 可读可改,保存即生效)。"""
register_config_items(
_PLUGIN_ID,
[
{
"key": _KEY_EXT_WS_PORT,
"label": "扩展桥 WS 端口",
"type": "string",
"default": "39272",
"env": "HEXI_BF_EXT_WS_PORT",
"description": (
"扩展桥在本机 127.0.0.1 的监听端口(仅回环)。"
"改后需重启 bot,且扩展 background.js 顶部常量要同步。"
),
"getter": lambda: _state[_KEY_EXT_WS_PORT],
"setter": lambda v: _set(_KEY_EXT_WS_PORT, v),
},
{
"key": _KEY_EXT_WS_TOKEN,
"label": "扩展桥 token",
"type": "password",
"default": "hexi-bf2042",
"env": "HEXI_BF_EXT_WS_TOKEN",
"description": (
"扩展连接时的共享密钥(防本机其它程序误连)。"
"与扩展 background.js 顶部 WS_TOKEN 保持一致;保存即生效。"
),
"getter": lambda: _state[_KEY_EXT_WS_TOKEN],
"setter": lambda v: _set(_KEY_EXT_WS_TOKEN, v),
},
{
"key": _KEY_EDGE_PROFILE,
"label": "取数浏览器配置目录",
"type": "path",
"default": str(EDGE_PROFILE_DIR),
"env": "HEXI_BF_EDGE_PROFILE",
"description": (
"取数浏览器的专用用户数据目录(登录态 + CF 信任 + 捕获桥扩展都在这)。"
"会话过期时用它打开 Edge 手动登录一次即可。"
),
"getter": lambda: _state[_KEY_EDGE_PROFILE],
"setter": lambda v: _set(_KEY_EDGE_PROFILE, v),
},
],
)
-284
View File
@@ -1,284 +0,0 @@
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
@@ -1,151 +0,0 @@
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} 不在任何区间内")
@@ -1,4 +0,0 @@
def bind_user(user_info):
user_info['']
@@ -1,67 +0,0 @@
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)
@@ -1,271 +0,0 @@
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,6 @@
"""Trigger 层:命令注册与消息发送(接触发 → 调 service → 出 View)。"""
from . import binding as _binding # noqa: F401
from . import query as _query # noqa: F401
__all__ = ["binding", "query"]
@@ -0,0 +1,39 @@
"""绑定管理命令:/bfbind(绑定) /unbind(解绑) /update_bind(修改绑定)。"""
from nonebot import on_command
from nonebot.adapters import Event, Message
from nonebot.params import CommandArg
from nonebot.rule import to_me
from nonebot_plugin_alconna import UniMessage
from ..services import binding as binding_service
bind_cmd = on_command("bfbind", rule=to_me(), aliases={"绑定"}, block=True)
unbind_cmd = on_command("unbind", rule=to_me(), aliases={"解绑"}, block=True)
update_bind_cmd = on_command(
"update_bind", rule=to_me(), aliases={"修改绑定"}, block=True
)
async def _reply(event: Event, text: str) -> None:
await UniMessage.at(user_id=event.get_user_id()).text(text).send()
@bind_cmd.handle()
async def handle_bind(event: Event, msg: Message = CommandArg()):
text = await binding_service.bind(event.get_user_id(), msg.extract_plain_text())
await _reply(event, text)
@unbind_cmd.handle()
async def handle_unbind(event: Event):
text = await binding_service.unbind(event.get_user_id())
await _reply(event, text)
@update_bind_cmd.handle()
async def handle_update_bind(event: Event, msg: Message = CommandArg()):
text = await binding_service.update_bind(
event.get_user_id(), msg.extract_plain_text()
)
await _reply(event, text)
@@ -0,0 +1,54 @@
"""`/bft` 及全部别名:命令注册 + 结果发送。"""
from nonebot import on_command
from nonebot.adapters import Event, Message
from nonebot.log import logger
from nonebot.matcher import Matcher
from nonebot.params import CommandArg
from nonebot.rule import to_me
from nonebot_plugin_alconna import UniMessage
from hexi.core.cooldown import cooldown
from ..services.query import run_query
# 群冷却 10s(全群共享,私聊退化为按用户);显式 name 保证热重载后 CD 不重置
query = cooldown(
10,
scope="group",
name="bf_query",
hint="查询太频繁了,{secs} 秒后再试",
)(
on_command(
"bft",
rule=to_me(),
aliases={"bf3", "bf4", "bfv", "bf1", "bf2042", "bf6"},
block=True,
)
)
@query.handle()
async def handle_query(matcher: Matcher, event: Event, msg: Message = CommandArg()):
# 实际输入的游戏别名(去掉 COMMAND_START 后的首段)决定走哪个游戏分支
prefix = matcher.state.get("_prefix") or {}
cmd = (prefix.get("command") or ["bft"])[0]
input_text = msg.extract_plain_text().strip()
user_id = event.get_user_id()
logger.info(f"BF查询:cmd={cmd!r}, input={input_text!r}")
async def notify(text: str) -> None:
await UniMessage.text(text).send()
reply = await run_query(
cmd=cmd, input_text=input_text, qq_id=user_id, notify=notify
)
if reply.image is not None:
await (
UniMessage.image(raw=reply.image)
.at(user_id=user_id)
.text(reply.text)
.send()
)
else:
await UniMessage.at(user_id=user_id).text(reply.text).send()
@@ -1,592 +0,0 @@
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 二进制流
@@ -1,200 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

@@ -0,0 +1,40 @@
"""路径唯一来源:插件根目录 / 静态资源 / 运行数据。
- 静态资源在插件包内 `res/`(字体、模板、通知图、物品图标)。
- 运行数据统一放 `hexi/data/nonebot_plugin_bf_bot/`(绑定库、头像缓存、tracker cookie),
不再写进插件源码目录。
"""
from pathlib import Path
PLUGIN_ROOT = Path(__file__).resolve().parent
RES_DIR = PLUGIN_ROOT / "res"
# hexi/plugins/nonebot_plugin_bf_bot -> hexi
_HEXI_ROOT = PLUGIN_ROOT.parents[1]
DATA_DIR = _HEXI_ROOT / "data" / "nonebot_plugin_bf_bot"
DB_PATH = DATA_DIR / "user_data.db"
AVATAR_CACHE_DIR = DATA_DIR / "avatar_cache"
DEFAULT_COOKIES_PATH = DATA_DIR / "cookies" / "tracker.txt"
# BF2042 直连通道(无浏览器)的 TRN OIDC 令牌文件:刷新链的唯一状态,
# 由 services/fetchers/tracker_api.py 读写(access_token 过期前自动轮换续期)
TRN_TOKENS_PATH = DATA_DIR / "trn_tokens.json"
# BF2042 取数通道(tracker.gg):专用 Edge 配置目录 + 可执行文件
EDGE_PROFILE_DIR = DATA_DIR / "edge_profile"
EDGE_EXECUTABLE_DEFAULT = (
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"
)
# BF2042 卡片素材(旧 project 迁移,2026-10):背景库/物品图标/专家头像/兵种/logo
BF2042_RES = RES_DIR / "bf2042"
BF2042_BG_DIR = BF2042_RES / "bg"
BF2042_OBJECT_ICON_DIR = BF2042_RES / "object_icon"
BF2042_CLASSES_DIR = BF2042_RES / "classes"
BF2042_SPECIALIST_ICON_DIR = BF2042_RES / "specialist_icon"
BF2042_CLASS_ICON_DIR = BF2042_RES / "class_icon"
BF2042_PLATFORM_DIR = BF2042_RES / "platform"
BF2042_LOGO_DIR = BF2042_RES / "bf2042_logo"
BF2042_DEV_LOGO_DIR = BF2042_RES / "dev_logo"
@@ -0,0 +1,313 @@
"""Model 层:绑定库 / 标签库的唯一读写入口(仅标准库)。
- 库文件固定为 `hexi/data/nonebot_plugin_bf_bot/user_data.db`(paths.DB_PATH),
不再写进插件源码目录。
- 本模块顶层不 import nonebot(logger 到函数内延迟导入),保证脱离 NoneBot
运行时也能被单测加载。
- 表结构:users(QQ↔EA 绑定,EA 三字段 UNIQUE)、designation、medals、
query_record(历史遗留,未用)。
"""
from __future__ import annotations
import sqlite3
from typing import Any
from .paths import DB_PATH
def _logger():
from nonebot import logger
return logger
class TableManager:
"""所有表管理器的基类:每次操作一条短连接 + 即时提交。"""
def __init__(self, db_path: str | None = None):
self.db_path = str(db_path or DB_PATH)
def _execute(self, sql: str, params: tuple = ()) -> sqlite3.Cursor:
"""执行 SQL 并返回游标(调用方自行 fetch)。"""
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute(sql, params)
conn.commit()
return cursor
class UserManager(TableManager):
"""users 表:QQ ↔ EAID 绑定。"""
def add_user(
self,
qq_id: str,
ea_player_name: str,
ea_player_id: str,
ea_user_id: str,
designation: str,
medals: str,
) -> int:
"""新增绑定,返回新行 id。撞 EA UNIQUE 约束会抛 sqlite3.IntegrityError,
绑定入口需先用 get_user_by_ea_id 预检(见 services/binding.py)。"""
cursor = self._execute(
"INSERT INTO users (qq_id, ea_player_name, ea_player_id, ea_user_id,"
" designation, medals) VALUES (?, ?, ?, ?, ?, ?)",
(qq_id, ea_player_name, ea_player_id, ea_user_id, designation, medals),
)
return int(cursor.lastrowid or 0)
def update_user(
self,
qq_id: str,
ea_player_name: str,
ea_player_id: str,
ea_user_id: str,
designation: str,
medals: str,
) -> int:
"""换绑:按 qq_id 更新 EA 信息,返回受影响行数。"""
cursor = self._execute(
"UPDATE users SET ea_player_name = ?, ea_player_id = ?, ea_user_id = ?"
" WHERE qq_id = ?",
(ea_player_name, ea_player_id, ea_user_id, qq_id),
)
return cursor.rowcount
def get_user_by_qq(self, qq_id: str) -> dict[str, Any] | None:
cursor = self._execute("SELECT * FROM users WHERE qq_id = ?", (qq_id,))
row = cursor.fetchone()
return dict(row) if row else None
def get_user_by_ea_id(self, ea_user_id: str) -> dict[str, Any] | None:
cursor = self._execute(
"SELECT * FROM users WHERE ea_user_id = ?", (ea_user_id,)
)
row = cursor.fetchone()
return dict(row) if row else None
def delete_user_by_qq(self, qq_id: str) -> int:
cursor = self._execute("DELETE FROM users WHERE qq_id = ?", (qq_id,))
return cursor.rowcount
def list_users(
self, limit: int | None = None, offset: int = 0
) -> list[dict[str, Any]]:
"""Web 绑定管理页用:绑定记录(按绑定时间倒序)。
limit=None 取全量;给 limit/offset 即分页(Web 页每页 10 条)。
"""
sql = "SELECT * FROM users ORDER BY create_time DESC, id DESC"
params: tuple = ()
if limit is not None:
sql += " LIMIT ? OFFSET ?"
params = (int(limit), int(offset))
cursor = self._execute(sql, params)
return [dict(row) for row in cursor.fetchall()]
def count_users(self) -> int:
"""绑定总数(Web 分页用)。"""
cursor = self._execute("SELECT COUNT(*) FROM users")
row = cursor.fetchone()
return int(row[0]) if row else 0
class DesignationManager(TableManager):
"""designation 表:玩家标签(装饰图标)定义。"""
def create_designation(self, name: str, icon_path: str) -> int:
cursor = self._execute(
"INSERT INTO designation (name, icon_path) VALUES (?, ?)", (name, icon_path)
)
return int(cursor.lastrowid or 0)
def get_all_designation(self) -> list[dict[str, Any]]:
cursor = self._execute("SELECT * FROM designation")
return [dict(row) for row in cursor.fetchall()]
def get_designation_by_id_list(self, ids: list[int]) -> list[dict[str, Any]]:
if not ids:
return []
placeholders = ",".join(["?"] * len(ids))
cursor = self._execute(
f"SELECT * FROM designation WHERE id IN ({placeholders})", tuple(ids)
)
return [dict(row) for row in cursor.fetchall()]
class Bf2042SettingsManager(TableManager):
"""bf2042_settings 表:BF2042 卡片个人设置。
- support:旧 project 的"支持者"标记(1=可用个人背景图库 bg/user/{qq}/);
- bg_mode:预留(common/user),当前渲染只认 support + 个人目录是否有图。
"""
def get_settings(self, qq_id: str) -> dict[str, Any] | None:
cursor = self._execute(
"SELECT * FROM bf2042_settings WHERE qq_id = ?", (str(qq_id),)
)
row = cursor.fetchone()
return dict(row) if row else None
def is_support(self, qq_id: str) -> bool:
"""无记录/查询失败一律按普通用户处理(不让装饰性功能拖垮查询)。"""
try:
row = self.get_settings(qq_id)
return bool(row and int(row.get("support") or 0) == 1)
except Exception: # noqa: BLE001
return False
def set_support(self, qq_id: str, support: bool) -> None:
self._execute(
"""
INSERT INTO bf2042_settings (qq_id, support) VALUES (?, ?)
ON CONFLICT(qq_id) DO UPDATE SET support = excluded.support
""",
(str(qq_id), 1 if support else 0),
)
def upsert_settings(self, qq_id: str, **fields: Any) -> None:
"""按列 upsert(bg_mode 等扩展字段留以后用)。"""
allowed = {"support", "bg_mode"}
cols = {k: v for k, v in fields.items() if k in allowed}
if not cols:
return
qq = str(qq_id)
existing = self.get_settings(qq)
if existing is None:
names = ", ".join(cols)
marks = ", ".join(["?"] * len(cols))
self._execute(
f"INSERT INTO bf2042_settings (qq_id, {names}) VALUES (?, {marks})",
(qq, *cols.values()),
)
else:
sets = ", ".join(f"{k} = ?" for k in cols)
self._execute(
f"UPDATE bf2042_settings SET {sets}, update_time = CURRENT_TIMESTAMP "
"WHERE qq_id = ?",
(*cols.values(), qq),
)
class DatabaseManager:
"""建表 / 校验表结构(幂等)。"""
def __init__(self, db_path: str | None = None):
# 默认与 TableManager 指向同一个库文件;
# 绝对路径,避免受启动目录影响
self.db_path = str(db_path or DB_PATH)
self.expected_tables = {
"users": [
("id", "INTEGER PRIMARY KEY AUTOINCREMENT"),
("qq_id", "TEXT NOT NULL"),
("ea_player_name", "TEXT UNIQUE"),
("ea_player_id", "TEXT UNIQUE"),
("ea_user_id", "TEXT UNIQUE"),
("designation", "TEXT NOT NULL"),
("medals", "TEXT NOT NULL"),
("create_time", "TIMESTAMP DEFAULT CURRENT_TIMESTAMP"),
],
"designation": [
("id", "INTEGER PRIMARY KEY"),
("name", "TEXT NOT NULL"),
("icon_path", "TEXT NOT NULL"),
("create_time", "TIMESTAMP DEFAULT CURRENT_TIMESTAMP"),
],
"medals": [
("id", "INTEGER PRIMARY KEY"),
("name", "TEXT NOT NULL"),
("icon_path", "TEXT NOT NULL"),
("create_time", "TIMESTAMP DEFAULT CURRENT_TIMESTAMP"),
],
"query_record": [
("id", "INTEGER PRIMARY KEY"),
("user_id", "TEXT NOT NULL"),
("ea_player_name", "TEXT NOT NULL"),
("ea_player_id", "TEXT NOT NULL"),
("ea_user_id", "TEXT NOT NULL"),
("status", "TEXT NOT NULL"),
("create_time", "TIMESTAMP DEFAULT CURRENT_TIMESTAMP"),
],
# BF2042 卡片个人设置 + 特权标记(旧 project user_bind.support 迁移而来)
"bf2042_settings": [
("qq_id", "TEXT PRIMARY KEY"),
("support", "INTEGER DEFAULT 0"),
("bg_mode", "TEXT DEFAULT 'common'"),
("create_time", "TIMESTAMP DEFAULT CURRENT_TIMESTAMP"),
("update_time", "TIMESTAMP DEFAULT CURRENT_TIMESTAMP"),
],
}
def initialize_database(self) -> bool:
"""初始化数据库并创建表结构(CREATE TABLE IF NOT EXISTS,幂等)。"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
for table_name, columns in self.expected_tables.items():
columns_sql = ", ".join(f"{col} {typ}" for col, typ in columns)
cursor.execute(
f"CREATE TABLE IF NOT EXISTS {table_name} ({columns_sql})"
)
conn.commit()
return True
except Exception as e: # noqa: BLE001
# 不动库文件:建表失败很可能是文件被占用一类的临时问题,
# 直接删库会连带丢掉用户的绑定数据,这里只报告失败
_logger().error(f"Database initialization failed: {str(e)}")
return False
def _verify_database_integrity(self, conn: sqlite3.Connection) -> bool:
"""校验表齐全、列结构一致、SQLite 完整性(供排查用,不参与启动流程)。"""
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
# 忽略 sqlite_sequence 等 SQLite 内部表,它们随 AUTOINCREMENT 自动出现
existing_tables = {
row[0] for row in cursor.fetchall() if not row[0].startswith("sqlite_")
}
missing_tables = set(self.expected_tables.keys()) - existing_tables
if missing_tables:
_logger().warning(f"Missing tables: {missing_tables}")
return False
for table_name, expected_columns in self.expected_tables.items():
cursor.execute(f"PRAGMA table_info({table_name})")
actual_columns = [(row[1], row[2]) for row in cursor.fetchall()]
if not self._compare_columns(actual_columns, expected_columns):
_logger().warning(f"Table {table_name} structure mismatch")
return False
cursor.execute("PRAGMA integrity_check")
integrity_result = cursor.fetchone()
if integrity_result[0] != "ok":
_logger().warning(f"Integrity check failed: {integrity_result}")
return False
return True
@staticmethod
def _type_head(declaration: str) -> str:
"""取类型声明的首个词,用于忽略约束差异。"""
parts = (declaration or "").split()
return parts[0].upper() if parts else ""
def _compare_columns(self, actual: list[tuple], expected: list[tuple]) -> bool:
"""比较实际列与预期列是否匹配。"""
if len(actual) != len(expected):
return False
for (act_col, act_type), (exp_col, exp_type) in zip(actual, expected):
if act_col.lower() != exp_col.lower():
return False
# PRAGMA table_info 只返回类型名,不带 PRIMARY KEY/AUTOINCREMENT 等约束,
# 所以只比对类型首词,否则 INTEGER 永远匹配不上 "INTEGER PRIMARY KEY ..."
if self._type_head(act_type) != self._type_head(exp_type):
return False
return True
def init_database() -> bool:
"""建表(幂等);插件入口导入期调用,兼容新环境与热重载。"""
return DatabaseManager().initialize_database()
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 837 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 815 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 433 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 825 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 818 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1001 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 MiB

Loaded 100 of 816 files, more files were not shown because too many files have changed in this diff. Show more