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>
No files matched your search
@@ -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),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -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
|
|
||||||
|
Before Width: | Height: | Size: 25 KiB |
|
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()
|
||||||
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 482 KiB |
|
After Width: | Height: | Size: 420 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 505 KiB |
|
After Width: | Height: | Size: 837 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 363 KiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 376 KiB |
|
After Width: | Height: | Size: 372 KiB |
|
After Width: | Height: | Size: 339 KiB |
|
After Width: | Height: | Size: 350 KiB |
|
After Width: | Height: | Size: 277 KiB |
|
After Width: | Height: | Size: 376 KiB |
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 375 KiB |
|
After Width: | Height: | Size: 578 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 815 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 438 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 400 KiB |
|
After Width: | Height: | Size: 455 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 433 KiB |
|
After Width: | Height: | Size: 482 KiB |
|
After Width: | Height: | Size: 10 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 453 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 8.4 MiB |
|
After Width: | Height: | Size: 22 MiB |
|
After Width: | Height: | Size: 4.1 MiB |
|
After Width: | Height: | Size: 7.8 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 453 KiB |
|
After Width: | Height: | Size: 8.5 MiB |
|
After Width: | Height: | Size: 558 KiB |
|
After Width: | Height: | Size: 3.4 MiB |
|
After Width: | Height: | Size: 353 KiB |
|
After Width: | Height: | Size: 4.7 MiB |
|
After Width: | Height: | Size: 431 KiB |
|
After Width: | Height: | Size: 4.0 MiB |
|
After Width: | Height: | Size: 468 KiB |
|
After Width: | Height: | Size: 5.5 MiB |
|
After Width: | Height: | Size: 825 KiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 818 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 5.2 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 1001 KiB |
|
After Width: | Height: | Size: 7.5 MiB |
|
After Width: | Height: | Size: 7.9 MiB |
|
After Width: | Height: | Size: 3.8 MiB |
|
After Width: | Height: | Size: 6.7 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 8.4 MiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 6.6 MiB |