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>
This commit is contained in:
sansenhoshiandClaude Code committed 2026-10-13 17:53:06 +08:00
1 parent 614e1d7e00
commit a31aab00e8
752 files changed
+6228 -2493

No files matched your search

+40 -252
View File
@@ -1,274 +1,62 @@
from nonebot import on_command, require """BF查询 —— 入口(MTSS:handlers → services → repository + utils)。
from nonebot.adapters import Message, Event, Bot
只做五件事:元数据 / require / 导入子模块(命令 + 配置 + Web)/ 建表 / 扩展桥启停钩子。
业务命令在 handlers/,编排在 services/,数据读写走 repository.py。
"""
from nonebot import get_driver, require
from nonebot.log import logger from nonebot.log import logger
from nonebot.matcher import Matcher
from nonebot.params import CommandArg
from nonebot.plugin import PluginMetadata from nonebot.plugin import PluginMetadata
from nonebot.rule import to_me
import time
from .user_data.data_utils import UserManager
require("nonebot_plugin_alconna") 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( __plugin_meta__ = PluginMetadata(
name="BF查询", name="BF查询",
description="战地3,4,1,5,2042,6", description="战地3,4,1,5,2042,6",
usage=""" usage="""
bf3: /bf3 EAID (查询BF3数据) bf3: /bf3 [EAID] (查询BF3数据)
bf4: /bf4 EAID (查询BF4数据) bf4: /bf4 [EAID] (查询BF4数据)
bf1: /bf1 EAID (查询BF5数据) bf1: /bf1 [EAID] (查询BF1数据)
bfv: /bfv EAID (查询BF6数据) bfv: /bfv [EAID] (查询BFV数据)
bf2042: /bf2042 EAID (查询BF2042数据) bf2042: /bf2042 [EAID] (查询BF2042生涯数据)
bf6: /bf6 EAID (查询BF6数据) bf2042: /bf2042 武器|载具|装备 [EAID] (BF2042 榜单)
bf2042: /bf2042 模式 [EAID] (BF2042 各模式明细)
bf6: /bf6 [EAID] (查询BF6数据)
绑定: /绑定 EAID (绑定你的QQ与EAID) 绑定: /绑定 EAID (绑定你的QQ与EAID)
解绑: /解绑 (解除你的QQ与当前绑定的EAID) 解绑: /解绑 (解除你的QQ与当前绑定的EAID)
修改绑定: /修改绑定 EAID (修改你的QQ与当前绑定的EAID) 修改绑定: /修改绑定 EAID (修改你的QQ与当前绑定的EAID)
以上查询命令省略 EAID 时使用已绑定账号;命令需要 @机器人。
""".strip(), """.strip(),
type="application", type="application",
extra={
},
) )
query = on_command("bft", rule=to_me(), aliases={"bf3", "bf4", "bfv", "bf1", "bf2042", "bf6"}, block=True) # 导入子模块:handlers 注册命令,config 注册配置,web 提供 /api/bf_bot
bind = on_command("bfbind", rule=to_me(), aliases={"绑定"}, block=True) from . import config as _config # noqa: E402
unbind = on_command("unbind", rule=to_me(), aliases={"解绑"}, block=True) from . import handlers as _handlers # noqa: E402,F401
update_bind = on_command("update_bind", rule=to_me(), aliases={"修改绑定"}, block=True) from . import web as _web # noqa: E402,F401
from .repository import init_database # noqa: E402
bf_dict = { # 建表(幂等):库文件不随仓库分发,新环境/热重载都要在这里补齐,否则查询报 no such table
"bf3": "战地3", if not init_database():
"bf4": "战地4", logger.error("用户数据库初始化失败,绑定/标签功能将不可用")
"bf1": "战地1",
"bfv": "战地5",
"bf2042": "战地2042",
"bf6": "战地6",
}
_config.register_config()
@query.handle() # BF2042 浏览器扩展桥:WS 服务端随 bot 启停(127.0.0.1;见 services/fetchers/tracker_bridge.py)
async def handle_function(event: Event, matcher: Matcher, msg: Message = CommandArg()): from .services.fetchers import tracker_bridge as _tracker_bridge # noqa: E402
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}")
_driver = get_driver()
_driver.on_startup(_tracker_bridge.start_bridge_server)
_driver.on_shutdown(_tracker_bridge.stop_bridge_server)
@bind.handle() # 注册到统一 Web 管理台(/hub):hub 启动时挂载 /api/bf_bot
async def bind_user(event: Event, matcher: Matcher, msg: Message = CommandArg()): # 前端页面 hexi/web/src/plugins/bf_bot/(绑定列表 + 删除)
user_manager = UserManager() from hexi.web_hub.web_plugin_registry import register_web_plugin # noqa: E402
player_name = msg.extract_plain_text()
qq_id = event.get_user_id()
# 执行已有数据查询操作 register_web_plugin(
user_record = user_manager.get_user_by_qq(qq_id) "bf_bot",
if user_record: "BF查询",
user_info = await get_player_info_by_ea_id(user_record['ea_player_id'], user_record['ea_user_id'], 'pc') "gamepad",
await (UniMessage.at(user_id=event.get_user_id()) lambda: _web.build_admin_app(),
.text(f"您已经将账号绑定到: {user_info['personaName']},如果你需要更改绑定请发送 修改绑定 EAID ").send()) module_name=__name__,
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()
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 MiB

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: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 MiB

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