refactor(bf-bot): 插件重构为 handlers/services/utils 分层,资源归入 res/
- 旧平铺模块(data/data_utils/database_op/image_builder*/get_bf6_data/ gametools_bf6/bf6_data/img_utils)拆分为 handlers(绑定/查询)、 services(fetchers/processing/query)、utils(渲染/参数)分层; 新增 repository.py / config.py / paths.py - 字体/图片/模板资源迁入 res/,新增 BF2042 卡面素材(bg/object_icon 等) - browser_ext/:BF2042 扩展捕获桥(WS 直连兜底通道)源码入库 - 新增 hexi/web/src/plugins/bf_bot 前端管理页 - 新增 tests:test_bf_bot_helpers / repository / tracker_api Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
1 parent
614e1d7e00
commit
a31aab00e8
752 files changed
+6241
-2506
No files matched your search
@@ -0,0 +1 @@
|
||||
"""业务编排层:查询/绑定等业务动作;不依赖 nonebot(View 由 handlers 负责)。"""
|
||||
@@ -0,0 +1,116 @@
|
||||
"""绑定编排:/bfbind、/unbind、/update_bind 的业务流程。
|
||||
|
||||
只做数据操作与文案产出(返回给用户的字符串),不直接发消息。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from nonebot import logger
|
||||
|
||||
from ..repository import UserManager
|
||||
from .fetchers.gametools import (
|
||||
get_player_info_by_ea_id,
|
||||
get_player_info_by_name,
|
||||
player_display_name,
|
||||
)
|
||||
|
||||
_PLATFORM = "pc"
|
||||
|
||||
|
||||
async def bind(qq_id: str, player_name: str) -> str:
|
||||
"""绑定 QQ ↔ EAID;已绑/被他人占用/查无此人都给出对应提示。"""
|
||||
manager = UserManager()
|
||||
user_record = manager.get_user_by_qq(qq_id)
|
||||
if user_record:
|
||||
info = await get_player_info_by_ea_id(
|
||||
user_record["ea_player_id"], user_record["ea_user_id"], _PLATFORM
|
||||
)
|
||||
name = player_display_name(info, user_record["ea_player_name"])
|
||||
return f"您已经将账号绑定到: {name},如果你需要更改绑定请发送 修改绑定 EAID "
|
||||
|
||||
player_name = (player_name or "").strip()
|
||||
if not player_name:
|
||||
return "请提供要绑定的 EAID,例如:绑定 YourEAID"
|
||||
|
||||
user_info = await get_player_info_by_name(player_name, _PLATFORM)
|
||||
if "errors" in user_info:
|
||||
return "未找到玩家,请检查是否拼写正确"
|
||||
player_id = user_info["personaId"]
|
||||
user_id = user_info["userId"]
|
||||
player_name = player_display_name(user_info, player_name)
|
||||
logger.info(f"QQ号:{qq_id},EA双ID:{player_id}-{user_id}")
|
||||
|
||||
# 检测ea账号是否被绑定至其他qq(EA 三字段 UNIQUE,靠这里预检兜住)
|
||||
bind_record = manager.get_user_by_ea_id(user_id)
|
||||
if bind_record:
|
||||
return (
|
||||
f"您的EA账号 {bind_record['ea_player_name']} 已经绑定到: "
|
||||
f"{bind_record['qq_id']},请您确认绑定的EAID"
|
||||
)
|
||||
|
||||
try:
|
||||
record = manager.add_user(qq_id, player_name, player_id, user_id, "[]", "[]")
|
||||
except sqlite3.IntegrityError as err:
|
||||
logger.warning(f"绑定撞 UNIQUE 约束:{err}")
|
||||
return "该 EA 账号已被绑定,请确认绑定的 EAID"
|
||||
logger.info(f"插入数据返回结果:{record}")
|
||||
if record > 0:
|
||||
return f"您已经成功绑定至: {player_name}"
|
||||
return "绑定失败,请稍后再试"
|
||||
|
||||
|
||||
async def unbind(qq_id: str) -> str:
|
||||
manager = UserManager()
|
||||
user_record = manager.get_user_by_qq(qq_id)
|
||||
if not user_record:
|
||||
return "未查询到绑定记录"
|
||||
info = await get_player_info_by_ea_id(
|
||||
user_record["ea_player_id"], user_record["ea_user_id"], _PLATFORM
|
||||
)
|
||||
name = player_display_name(info, user_record["ea_player_name"])
|
||||
rows = manager.delete_user_by_qq(qq_id)
|
||||
if rows > 0:
|
||||
return f"您已经与: {name} 成功解除绑定,如果你需要重新绑定请发送 修改绑定 EAID "
|
||||
return "解绑失败,请稍后再试"
|
||||
|
||||
|
||||
async def update_bind(qq_id: str, player_name: str) -> str:
|
||||
manager = UserManager()
|
||||
user_record = manager.get_user_by_qq(qq_id)
|
||||
if not user_record:
|
||||
return (
|
||||
f"未查询到绑定记录: {player_name},如果需要绑定,"
|
||||
f"请@机器人并发送 绑定 {player_name} "
|
||||
)
|
||||
|
||||
player_name = (player_name or "").strip()
|
||||
if not player_name:
|
||||
return "请提供要换绑的 EAID,例如:修改绑定 YourEAID"
|
||||
|
||||
user_info = await get_player_info_by_name(player_name, _PLATFORM)
|
||||
if "errors" in user_info:
|
||||
return "未找到玩家,请检查是否拼写正确"
|
||||
player_id = user_info["personaId"]
|
||||
user_id = user_info["userId"]
|
||||
player_name = player_display_name(user_info, player_name)
|
||||
logger.info(f"QQ号:{qq_id},EA双ID:{player_id}-{user_id}")
|
||||
|
||||
# 与 bind 相同的占用预检;绑给同一个 QQ 时视为无操作继续
|
||||
bind_record = manager.get_user_by_ea_id(user_id)
|
||||
if bind_record and str(bind_record["qq_id"]) != str(qq_id):
|
||||
return (
|
||||
f"您的EA账号 {bind_record['ea_player_name']} 已经绑定到: "
|
||||
f"{bind_record['qq_id']},请您确认绑定的EAID"
|
||||
)
|
||||
|
||||
try:
|
||||
record = manager.update_user(qq_id, player_name, player_id, user_id, "[]", "[]")
|
||||
except sqlite3.IntegrityError as err:
|
||||
logger.warning(f"换绑撞 UNIQUE 约束:{err}")
|
||||
return "该 EA 账号已被绑定,请确认绑定的 EAID"
|
||||
logger.info(f"修改数据返回结果:{record}")
|
||||
if record > 0:
|
||||
return f"您已经成功从:{user_record['ea_player_name']}修改绑定至: {player_name}"
|
||||
return "修改绑定失败,请稍后再试"
|
||||
@@ -0,0 +1 @@
|
||||
"""取数层:外部数据源客户端(gametools / tracker.gg 浏览器通道 / BF2042)。"""
|
||||
@@ -0,0 +1,391 @@
|
||||
"""BF2042 战绩取数(tracker.gg,直连主通道 / 扩展桥兜底)。
|
||||
|
||||
取数调度全部走 ``tracker_edge.fetch_bf2042_segments``:主通道是 ``tracker_api``
|
||||
的无浏览器直连(curl_cffi + TRN Bearer 令牌),浏览器扩展桥仅作兜底;本模块
|
||||
只做"拿数据 → 防御式解析 → 卡片字段",对通道无感知。
|
||||
|
||||
响应结构实测:
|
||||
|
||||
- 一次取数会带来 5 个关键响应:
|
||||
- ``profile/{platform}/{name}`` → ``{"data": {platformInfo, segments:[overview, gamemode...]}}``
|
||||
- ``.../segments/weapon`` → ``{"data": [段数组]}``
|
||||
- ``.../segments/vehicle`` → ``{"data": [段数组]}``
|
||||
- ``.../segments/gadget`` → 装备(榜单视图用)
|
||||
- ``.../segments/soldier`` → 兵种(卡片用)
|
||||
- overview 里没有 SPM / 命中率 / 分数,所以卡片改展示真实存在的
|
||||
「每分钟伤害」「场均击杀」。
|
||||
|
||||
解析全部防御式:字段缺失退化成占位符,不抛异常。
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from nonebot import logger
|
||||
|
||||
from .tracker_edge import TrackerResult, fetch_bf2042_segments
|
||||
|
||||
__all__ = [
|
||||
"get_bf2042_info",
|
||||
"get_bf2042_list",
|
||||
"get_bf2042_gamemodes",
|
||||
"bf2042_list_title",
|
||||
]
|
||||
|
||||
_MISSING = "—"
|
||||
|
||||
# 用户层用 "pc",tracker.gg 用平台 slug
|
||||
_PLATFORM_ALIASES = {
|
||||
"pc": "origin",
|
||||
"origin": "origin",
|
||||
"steam": "steam",
|
||||
"xbl": "xbl",
|
||||
"psn": "psn",
|
||||
}
|
||||
|
||||
|
||||
def _platform_slug(platform: str) -> str:
|
||||
return _PLATFORM_ALIASES.get((platform or "").lower(), "origin")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 取值工具:全部容忍缺失
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _stat(stats: dict, *keys) -> dict | None:
|
||||
"""返回第一个命中的 stat。tracker 的 stat 形如 {"value":..,"displayValue":..}。"""
|
||||
for key in keys:
|
||||
entry = stats.get(key)
|
||||
if isinstance(entry, dict):
|
||||
return entry
|
||||
if entry is not None:
|
||||
return {"value": entry, "displayValue": entry}
|
||||
return None
|
||||
|
||||
|
||||
def _value(entry: dict | None):
|
||||
return entry.get("value") if entry else None
|
||||
|
||||
|
||||
def _display(entry: dict | None, default=_MISSING):
|
||||
"""优先用 displayValue(带单位/百分比,免去单位猜测)。"""
|
||||
if not entry:
|
||||
return default
|
||||
display = entry.get("displayValue")
|
||||
if display is not None:
|
||||
return display
|
||||
value = entry.get("value")
|
||||
return default if value is None else value
|
||||
|
||||
|
||||
def _number(entry: dict | None, default=_MISSING):
|
||||
"""数值型字段,取不到时给 0(计数类字段缺省即 0)。"""
|
||||
value = _value(entry)
|
||||
return default if value is None else value
|
||||
|
||||
|
||||
def _segment(segments: list[dict], seg_type: str) -> dict:
|
||||
for seg in segments or []:
|
||||
if seg.get("type") == seg_type:
|
||||
return seg
|
||||
return {}
|
||||
|
||||
|
||||
def _best_of(segments: list[dict], seg_type: str) -> dict:
|
||||
"""从同类 segment 里挑击杀最高的那个(武器/载具)。"""
|
||||
candidates = [s for s in (segments or []) if s.get("type") == seg_type]
|
||||
if not candidates:
|
||||
return {}
|
||||
|
||||
def _kills(seg: dict) -> float:
|
||||
return _value(_stat(seg.get("stats") or {}, "kills")) or 0
|
||||
|
||||
return max(candidates, key=_kills)
|
||||
|
||||
|
||||
def _name_of(seg: dict) -> str:
|
||||
meta = seg.get("metadata") or {}
|
||||
# metadata.name 常带尾随空格(如 "PKP-BP ")
|
||||
name = meta.get("displayName") or meta.get("name") or ""
|
||||
return name.strip() or _MISSING
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 解析
|
||||
# --------------------------------------------------------------------------- #
|
||||
def normalize_bf2042(profile: dict, extra_segments: list[dict] | None = None) -> dict:
|
||||
"""把 tracker.gg profile 响应整理成文字卡需要的字段。
|
||||
|
||||
extra_segments: 由 /segments/weapon、/segments/vehicle 另取的段,拼进来一起挑最佳。
|
||||
"""
|
||||
data = (profile or {}).get("data") or {}
|
||||
segments = list(data.get("segments") or []) + list(extra_segments or [])
|
||||
platform_info = data.get("platformInfo") or {}
|
||||
|
||||
overview = (_segment(segments, "overview").get("stats")) or {}
|
||||
|
||||
wins = _value(_stat(overview, "wins")) or 0
|
||||
losses = _value(_stat(overview, "losses")) or 0
|
||||
matches = _value(_stat(overview, "matches")) or (wins + losses)
|
||||
|
||||
best_weapon_seg = _best_of(segments, "weapon")
|
||||
best_vehicle_seg = _best_of(segments, "vehicle")
|
||||
|
||||
def _sub(seg: dict) -> dict:
|
||||
stats = seg.get("stats") or {}
|
||||
return {
|
||||
"名称": _name_of(seg),
|
||||
"类别": (seg.get("metadata") or {}).get("category") or _MISSING,
|
||||
"击杀": _number(_stat(stats, "kills")),
|
||||
"KPM": _value(_stat(stats, "killsPerMinute")) or _MISSING,
|
||||
"时长": _display(_stat(stats, "timePlayed")),
|
||||
"命中率": _display(_stat(stats, "shotsAccuracy")),
|
||||
"爆头率": _display(_stat(stats, "headshotPercentage")),
|
||||
"摧毁": _number(_stat(stats, "destroyed")), # 仅载具有
|
||||
}
|
||||
|
||||
return {
|
||||
"玩家名称": platform_info.get("platformUserHandle") or _MISSING,
|
||||
"游玩平台": platform_info.get("platformSlug") or _MISSING,
|
||||
"游戏等级": _display(_stat(overview, "level")),
|
||||
"游玩时长": _display(_stat(overview, "timePlayed")),
|
||||
"游玩场次": matches or _MISSING,
|
||||
"对局胜率": _display(_stat(overview, "wlPercentage")),
|
||||
"玩家K/D": _value(_stat(overview, "kdRatio")) or _MISSING,
|
||||
"玩家KPM": _value(_stat(overview, "killsPerMinute")) or _MISSING,
|
||||
# tracker 的 BF2042 overview 没有 SPM / 得分,用真实存在的这两项替代
|
||||
"每分钟伤害": _display(_stat(overview, "dmgPerMin")),
|
||||
"场均击杀": _value(_stat(overview, "killsPerMatch")) or _MISSING,
|
||||
"击杀总计": _number(_stat(overview, "kills")),
|
||||
"游戏助攻": _number(_stat(overview, "assists")),
|
||||
"救援数量": _number(_stat(overview, "revives")),
|
||||
"爆头率": _display(_stat(overview, "headshotPercentage")),
|
||||
"最佳武器": _sub(best_weapon_seg),
|
||||
"最佳载具": _sub(best_vehicle_seg),
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 榜单 / 模式视图
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _raw(entry: dict | None):
|
||||
"""原样取 value(比率类字段用,displayValue 偶尔缺失)。"""
|
||||
value = _value(entry)
|
||||
return _MISSING if value is None else value
|
||||
|
||||
|
||||
# tracker.gg 偶尔漏译,metadata.name 会是 gad_open_equip_rpgv2 这种原始键
|
||||
_RAW_NAME_PREFIXES = ("gad_open_equip_", "gad_", "wep_", "veh_")
|
||||
|
||||
|
||||
def _pretty_name(name: str) -> str:
|
||||
if not name or name == _MISSING or not re.fullmatch(r"[a-z0-9_]+", name):
|
||||
return name
|
||||
for prefix in _RAW_NAME_PREFIXES:
|
||||
if name.startswith(prefix):
|
||||
name = name[len(prefix) :]
|
||||
break
|
||||
return name.replace("_", " ").upper()
|
||||
|
||||
|
||||
# kind -> 标题 / 段名 / 排序字段 / (列名, stat 键, 取法)
|
||||
_LIST_VIEWS: dict[str, dict] = {
|
||||
"weapon": {
|
||||
"标题": "武器",
|
||||
"段": "weapon",
|
||||
"排序": "kills",
|
||||
"列": (
|
||||
("击杀", "kills", _number),
|
||||
("KPM", "killsPerMinute", _raw),
|
||||
("命中率", "shotsAccuracy", _display),
|
||||
("爆头率", "headshotPercentage", _display),
|
||||
),
|
||||
},
|
||||
"vehicle": {
|
||||
"标题": "载具",
|
||||
"段": "vehicle",
|
||||
"排序": "kills",
|
||||
"列": (
|
||||
("击杀", "kills", _number),
|
||||
("KPM", "killsPerMinute", _raw),
|
||||
("摧毁", "destroyed", _number),
|
||||
("时长", "timePlayed", _display),
|
||||
),
|
||||
},
|
||||
"gadget": {
|
||||
"标题": "装备",
|
||||
"段": "gadget",
|
||||
"排序": "uses", # 装备大多 0 杀,使用次数才是主指标
|
||||
"列": (
|
||||
("使用", "uses", _number),
|
||||
("击杀", "kills", _number),
|
||||
("部署", "deployments", _number),
|
||||
("时长", "timePlayed", _display),
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
_GAMEMODE_COLUMNS = (
|
||||
("场次", "matches", _number),
|
||||
("胜率", "wlPercentage", _display),
|
||||
("击杀", "kills", _number),
|
||||
("KPM", "killsPerMinute", _raw),
|
||||
)
|
||||
|
||||
|
||||
def _rows_of(segments: list[dict], columns, sort_key: str, top: int) -> list[dict]:
|
||||
"""按 sort_key 降序取前 top,摊平成「名称/标签/值列表」的渲染行。"""
|
||||
|
||||
def rank(seg: dict) -> float:
|
||||
return _value(_stat(seg.get("stats") or {}, sort_key)) or 0
|
||||
|
||||
rows = []
|
||||
for item in sorted(segments or [], key=rank, reverse=True)[:top]:
|
||||
stats = item.get("stats") or {}
|
||||
rows.append(
|
||||
{
|
||||
"名称": _pretty_name(_name_of(item)),
|
||||
"标签": (item.get("metadata") or {}).get("category") or "",
|
||||
"值": [str(fn(_stat(stats, key))) for _, key, fn in columns],
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def bf2042_list_title(kind: str) -> str:
|
||||
"""榜单中文标题(武器/载具/装备),指令层拼提示语用。"""
|
||||
view = _LIST_VIEWS.get(kind)
|
||||
return view["标题"] if view else ""
|
||||
|
||||
|
||||
def normalize_bf2042_list(kind: str, segments: list[dict], top: int = 15) -> dict:
|
||||
"""把 weapon/vehicle/gadget 段整理成榜单卡字段(按主指标排序,取前 top)。"""
|
||||
view = _LIST_VIEWS[kind]
|
||||
return {
|
||||
"标题": view["标题"],
|
||||
"列名": [name for name, _, _ in view["列"]],
|
||||
"行": _rows_of(segments, view["列"], view["排序"], top),
|
||||
}
|
||||
|
||||
|
||||
def normalize_bf2042_gamemodes(profile: dict, top: int = 15) -> dict:
|
||||
"""把 profile 里的 gamemode 段整理成榜单卡字段(按游玩时长排序)。"""
|
||||
segments = ((profile or {}).get("data") or {}).get("segments") or []
|
||||
modes = [s for s in segments if s.get("type") == "gamemode"]
|
||||
return {
|
||||
"标题": "模式",
|
||||
"列名": [name for name, _, _ in _GAMEMODE_COLUMNS],
|
||||
"行": _rows_of(modes, _GAMEMODE_COLUMNS, "timePlayed", top),
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 抓取(调度在 tracker_edge:直连 API 主通道 / 扩展桥兜底)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _handle(profile: dict | None, fallback: str) -> str:
|
||||
info = ((profile or {}).get("data") or {}).get("platformInfo") or {}
|
||||
return info.get("platformUserHandle") or fallback
|
||||
|
||||
|
||||
def _failure(reason: str, detail: str) -> TrackerResult:
|
||||
return TrackerResult(False, data=None, status=None, reason=reason, detail=detail)
|
||||
|
||||
|
||||
async def _load_profile(slug: str, name: str, segments: tuple[str, ...] = ()):
|
||||
"""经调度层取数,返回 (profile 数据, 段字典, None)。
|
||||
|
||||
段字典键:weapon / vehicle / gadget / soldier。一次取数全部拉齐,
|
||||
不做任何"按需补段"的额外请求。失败返回 (None, {}, 带 reason 结果)。
|
||||
"""
|
||||
result = await fetch_bf2042_segments(name, slug)
|
||||
if result.get("error"):
|
||||
return (
|
||||
None,
|
||||
{},
|
||||
_failure(
|
||||
str(result["error"]), str(result.get("detail") or "tracker.gg 取数失败")
|
||||
),
|
||||
)
|
||||
profile = result.get("profile")
|
||||
if not isinstance(profile, dict) or not profile.get("data"):
|
||||
logger.warning(f"BF2042 取到的 profile 为空: {name}")
|
||||
return None, {}, _failure("not_found", "tracker.gg 未返回该玩家的数据")
|
||||
seg_lists = {
|
||||
"weapon": result.get("weapon") or [],
|
||||
"vehicle": result.get("vehicle") or [],
|
||||
"gadget": result.get("gadget") or [],
|
||||
"soldier": result.get("soldier") or [],
|
||||
}
|
||||
return profile, seg_lists, None
|
||||
|
||||
|
||||
async def get_bf2042_info(player: str, platform: str = "pc") -> TrackerResult:
|
||||
"""查询 BF2042 生涯数据,返回 TrackerResult(data 为生涯卡字段 dict)。
|
||||
|
||||
data 里同时带上原始段(``_profile``/``_weapons``/``_vehicles``/``_soldiers``),
|
||||
供新版卡片(``services.processing.build_bf2042_career_card``)构建完整槽位。
|
||||
"""
|
||||
slug = _platform_slug(platform)
|
||||
name = (player or "").strip()
|
||||
if not name:
|
||||
return TrackerResult(False, reason="error", detail="玩家名为空")
|
||||
|
||||
profile, seg_lists, err = await _load_profile(slug, name)
|
||||
if err is not None:
|
||||
return err
|
||||
|
||||
extra = seg_lists.get("weapon", []) + seg_lists.get("vehicle", [])
|
||||
logger.info(f"BF2042 生涯取数成功: {name}(武器/载具段 {len(extra)} 条)")
|
||||
data = normalize_bf2042(profile or {}, extra)
|
||||
data["_profile"] = profile or {}
|
||||
data["_weapons"] = seg_lists.get("weapon") or []
|
||||
data["_vehicles"] = seg_lists.get("vehicle") or []
|
||||
data["_soldiers"] = seg_lists.get("soldier") or []
|
||||
return TrackerResult(True, data=data, status=200, reason="ok")
|
||||
|
||||
|
||||
async def get_bf2042_list(player: str, platform: str, kind: str) -> TrackerResult:
|
||||
"""查询武器 / 载具 / 装备榜,返回 TrackerResult(data 为榜单卡字段 dict)。
|
||||
|
||||
kind ∈ weapon / vehicle / gadget;数据全在页面一次加载的截获结果里。
|
||||
"""
|
||||
slug = _platform_slug(platform)
|
||||
name = (player or "").strip()
|
||||
if not name:
|
||||
return TrackerResult(False, reason="error", detail="玩家名为空")
|
||||
view = _LIST_VIEWS.get(kind)
|
||||
if view is None:
|
||||
return TrackerResult(False, reason="error", detail=f"未知榜单类型 {kind}")
|
||||
|
||||
segment = view["段"]
|
||||
profile, seg_lists, err = await _load_profile(slug, name)
|
||||
if err is not None:
|
||||
return err
|
||||
|
||||
rows_source = seg_lists.get(segment, [])
|
||||
if not rows_source:
|
||||
# 段拉取失败或该玩家确实没有这类数据(直连通道四段全取,桥兜底仅三段)
|
||||
return TrackerResult(
|
||||
False,
|
||||
reason="error",
|
||||
detail=f"当前通道暂无「{view['标题']}」数据",
|
||||
)
|
||||
data = normalize_bf2042_list(kind, rows_source)
|
||||
data["玩家名称"] = _handle(profile, name)
|
||||
logger.info(f"BF2042 {data['标题']}榜取数成功: {name}({len(data['行'])} 条)")
|
||||
return TrackerResult(True, data=data, status=200, reason="ok")
|
||||
|
||||
|
||||
async def get_bf2042_gamemodes(player: str, platform: str = "pc") -> TrackerResult:
|
||||
"""查询各游戏模式明细(在 profile 响应的 gamemode 段里,不用额外请求)。"""
|
||||
slug = _platform_slug(platform)
|
||||
name = (player or "").strip()
|
||||
if not name:
|
||||
return TrackerResult(False, reason="error", detail="玩家名为空")
|
||||
|
||||
profile, _, err = await _load_profile(slug, name)
|
||||
if err is not None:
|
||||
return err
|
||||
|
||||
data = normalize_bf2042_gamemodes(profile or {})
|
||||
data["玩家名称"] = _handle(profile, name)
|
||||
logger.info(f"BF2042 模式取数成功: {name}({len(data['行'])} 条)")
|
||||
return TrackerResult(True, data=data, status=200, reason="ok")
|
||||
@@ -0,0 +1,132 @@
|
||||
"""EA 系取数(api.gametools.network)。
|
||||
|
||||
- `fetch_all_stats`:bf3/bf4/bf1/bfv/bf6 的 `/all` 战绩接口(by-id 与 by-name
|
||||
并发请求,谁先拿到有效数据用谁)。
|
||||
- `get_player_info_by_name` / `get_player_info_by_ea_id`:`bfglobal/player` 查人,
|
||||
绑定流程用。
|
||||
- `get_bf6_rank`:`bf6/profile` 取等级(playerCard.rank)。
|
||||
|
||||
gametools 是同步 requests 的部分一律 `asyncio.to_thread`,避免卡事件循环。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import requests
|
||||
from nonebot import logger
|
||||
|
||||
_API = "https://api.gametools.network"
|
||||
_HEADERS = {"accept": "application/json"}
|
||||
|
||||
|
||||
async def fetch_all_stats(game, player, player_id, user_id, platform):
|
||||
"""查 `{game}/all/`:by-id 与 by-name 并发,先成功(且无 errors)的胜出。"""
|
||||
base = f"{_API}/{game}/all/?format_values=true"
|
||||
url_by_id = (
|
||||
f"{base}&playerid={player_id}&oid={user_id}"
|
||||
f"&platform={platform}&skip_battlelog=false&lang=en-us"
|
||||
)
|
||||
url_by_name = (
|
||||
f"{base}&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}")
|
||||
# 如果所有尝试都失败,返回错误
|
||||
return {"errors": ["未找到玩家"]}
|
||||
|
||||
|
||||
async def _get_player_json(url: str) -> dict:
|
||||
"""requests 放线程池 + 超时;网络/解析失败统一降级成 {"errors": [...]}。"""
|
||||
try:
|
||||
response = await asyncio.to_thread(
|
||||
requests.get, url, headers=_HEADERS, timeout=10
|
||||
)
|
||||
return json.loads(response.text)
|
||||
except Exception as err: # noqa: BLE001 - 调用方按 errors 分支处理
|
||||
logger.warning(f"查询玩家信息失败: {err}")
|
||||
return {"errors": [str(err)]}
|
||||
|
||||
|
||||
def player_display_name(info: dict, fallback: str = "error") -> str:
|
||||
"""从 bfglobal/player 响应里取展示名。
|
||||
|
||||
该接口实测有两种形态:一种给 personaName,另一种(同一玩家也会出现)只给
|
||||
name + nucleusId;漏掉 name 时卡片会把玩家名显示成 "error"(老代码的问题)。
|
||||
"""
|
||||
return (
|
||||
info.get("personaName")
|
||||
or info.get("name")
|
||||
or info.get("displayName")
|
||||
or info.get("username")
|
||||
or fallback
|
||||
)
|
||||
|
||||
|
||||
async def get_player_info_by_name(user_name, platform) -> dict:
|
||||
url = (
|
||||
f"{_API}/bfglobal/player/?name={user_name}"
|
||||
f"&platform={platform}&skip_battlelog=false"
|
||||
)
|
||||
logger.info(f"查询用户请求url:{url}")
|
||||
return await _get_player_json(url)
|
||||
|
||||
|
||||
async def get_player_info_by_ea_id(player_id, user_id, platform) -> dict:
|
||||
url = (
|
||||
f"{_API}/bfglobal/player/?playerid={player_id}&oid={user_id}"
|
||||
f"&platform={platform}&skip_battlelog=false"
|
||||
)
|
||||
return await _get_player_json(url)
|
||||
|
||||
|
||||
async def get_bf6_rank(player, player_id, user_id, platform) -> int:
|
||||
"""取BF6等级:gametools profile 接口返回 playerProfiles[0].playerCard.rank"""
|
||||
url = (
|
||||
f"{_API}/bf6/profile/?name={player}"
|
||||
f"&playerid={player_id}&nucleus_id={user_id}&platform={platform}"
|
||||
f"&skip_battlelog=true&lang=en-us"
|
||||
)
|
||||
try:
|
||||
response = await asyncio.to_thread(
|
||||
requests.get, url, headers=_HEADERS, timeout=10
|
||||
)
|
||||
profiles = response.json().get("playerProfiles") or []
|
||||
except Exception as err: # noqa: BLE001 - 等级取不到就当 0,不打断整卡
|
||||
logger.warning(f"查询BF6等级失败: {err}")
|
||||
return 0
|
||||
logger.info(f"BF6 profile 返回:{str(profiles)[:300]}")
|
||||
|
||||
if not isinstance(profiles, list) or not profiles:
|
||||
logger.info(f"未搜索到用户: {player}")
|
||||
return 0
|
||||
|
||||
profile = profiles[0] if isinstance(profiles[0], dict) else {}
|
||||
# 该接口的真实字段:playerCard.rank 是等级数字,rankName 是称号名
|
||||
rank = (profile.get("playerCard") or {}).get("rank") or 0
|
||||
logger.info(f"BF6等级: {rank}(称号:{profile.get('rankName')})")
|
||||
return rank
|
||||
@@ -0,0 +1,364 @@
|
||||
"""tracker.gg 直连取数通道(BF2042 主通道,无浏览器)。
|
||||
|
||||
2026-10-13 实测确立(与旧结论不同,以本文件为准):
|
||||
|
||||
- api.tracker.gg 的 WAF 拦的是**没有登录凭据的匿名请求**(裸 curl/requests →
|
||||
403 "You've Been Blocked");带上 TRN 账号的 OIDC ``Bearer`` 令牌 + curl_cffi
|
||||
的 Chrome TLS/HTTP2 指纹后,profile 与各 segments 全部 200 放行 —— 无需浏览器、
|
||||
无需 cf_clearance、无需 TRN-Api-Key。
|
||||
- 403 的另一种形态是 CF 挑战页("Just a moment…",access_token 过期时出现);
|
||||
与封禁页一样,处理方式都是强制刷新令牌后重试一次。
|
||||
- 404 = 玩家不存在(``CollectorResultStatus::NotFound``),确定性结果,不重试。
|
||||
|
||||
令牌链路(offline_access 刷新链,一次引导、持续自持):
|
||||
|
||||
- 引导:从取数浏览器专用配置的 localStorage(leveldb WAL,明文)里读出
|
||||
``auth.tokens`` 的 refresh_token;仅在令牌文件缺失/刷新失败时兜底执行,
|
||||
日常运行完全不碰浏览器。
|
||||
- 运行时:access_token 将过期或遇 401/403 时,POST
|
||||
``account.tracker.gg/connect/token``(refresh_token 授权,公共客户端,
|
||||
无需 secret)换新;TRN 每次刷新都**轮换** refresh_token,新值原子落盘 ——
|
||||
每次刷新都在续命,持续使用即永不过期。
|
||||
- 唯一状态文件:``hexi/data/nonebot_plugin_bf_bot/trn_tokens.json``。
|
||||
|
||||
顶层不 import 插件包相对路径(tests 与 debug 探针按文件裸加载本模块),
|
||||
paths / config 的读取一律放函数内(与 tracker_bridge 同约定)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from curl_cffi import requests as cr
|
||||
from curl_cffi.requests.exceptions import RequestException, Timeout
|
||||
from nonebot import logger
|
||||
|
||||
__all__ = [
|
||||
"TrnAuthError",
|
||||
"fetch_bf2042_direct",
|
||||
]
|
||||
|
||||
_TOKEN_URL = "https://account.tracker.gg/connect/token"
|
||||
_CLIENT_ID = "0af2521e-33e9-4d40-836e-6f865cb98870"
|
||||
_SCOPE = "openid profile account api events offline_access"
|
||||
|
||||
_API_BASE = "https://api.tracker.gg/api/v2/bf2042/standard/profile"
|
||||
_SEGMENTS = ("weapon", "vehicle", "gadget", "soldier")
|
||||
|
||||
_EXPIRY_MARGIN_S = 120.0 # access_token 剩余寿命低于该值即提前刷新
|
||||
_HTTP_TIMEOUT = 30 # 单请求超时(秒)
|
||||
_ATTEMPT_TIMEOUT_S = 75.0 # 一次完整取数(profile + 4 段)的整体上限
|
||||
|
||||
_token_lock = threading.Lock()
|
||||
|
||||
|
||||
class TrnAuthError(RuntimeError):
|
||||
"""TRN 登录态(令牌链)不可用:没有 refresh_token 或刷新被拒。"""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 令牌存取
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _token_path() -> Path:
|
||||
from ...paths import TRN_TOKENS_PATH
|
||||
|
||||
return Path(TRN_TOKENS_PATH)
|
||||
|
||||
|
||||
def _read_token_file() -> dict:
|
||||
try:
|
||||
data = json.loads(_token_path().read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except Exception as exc: # noqa: BLE001 - 坏文件不致命,当作没有
|
||||
logger.warning(f"BF2042 令牌文件不可读({exc}),按缺失处理")
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _write_token_file(tokens: dict) -> None:
|
||||
path = _token_path()
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(".json.tmp")
|
||||
tmp.write_text(
|
||||
json.dumps(tokens, ensure_ascii=False, indent=1), encoding="utf-8"
|
||||
)
|
||||
os.replace(tmp, path)
|
||||
except Exception as exc: # noqa: BLE001 - 落盘失败只影响下次冷启动
|
||||
logger.warning(f"BF2042 令牌文件写入失败:{exc}")
|
||||
|
||||
|
||||
def _token_fresh(tokens: dict, now: float | None = None) -> bool:
|
||||
access = tokens.get("access_token")
|
||||
expires_at = tokens.get("expires_at")
|
||||
if not access or not isinstance(expires_at, (int, float)):
|
||||
return False
|
||||
return float(expires_at) > (now if now is not None else time.time())
|
||||
|
||||
|
||||
def _normalize_token_payload(payload: dict, prev_refresh: str) -> dict:
|
||||
"""令牌端点响应 → 落盘格式;未轮换 refresh_token 时沿用旧值。"""
|
||||
return {
|
||||
"access_token": str(payload.get("access_token") or ""),
|
||||
"refresh_token": str(payload.get("refresh_token") or prev_refresh or ""),
|
||||
"expires_at": time.time()
|
||||
+ float(payload.get("expires_in") or 3600)
|
||||
- _EXPIRY_MARGIN_S,
|
||||
"updated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
|
||||
|
||||
def _do_refresh(refresh_token: str) -> dict:
|
||||
"""用 refresh_token 换新令牌(公共客户端,无 secret);失败抛 TrnAuthError。"""
|
||||
try:
|
||||
with cr.Session(impersonate="chrome") as session:
|
||||
resp = session.post(
|
||||
_TOKEN_URL,
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": _CLIENT_ID,
|
||||
"refresh_token": refresh_token,
|
||||
"scope": _SCOPE,
|
||||
},
|
||||
timeout=_HTTP_TIMEOUT,
|
||||
)
|
||||
except RequestException as exc:
|
||||
raise TrnAuthError(f"令牌端点请求失败:{exc}") from exc
|
||||
if resp.status_code != 200:
|
||||
raise TrnAuthError(f"令牌刷新被拒:HTTP {resp.status_code} {resp.text[:120]}")
|
||||
payload = resp.json()
|
||||
if not payload.get("access_token"):
|
||||
raise TrnAuthError("令牌刷新响应缺少 access_token")
|
||||
return _normalize_token_payload(payload, refresh_token)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 浏览器档案引导(localStorage leveldb,明文 WAL)
|
||||
# --------------------------------------------------------------------------- #
|
||||
_STORAGE_KEY = "auth.tokens"
|
||||
|
||||
|
||||
def _decode_tokens_blob(raw: bytes, key: str = _STORAGE_KEY) -> dict | None:
|
||||
"""从 leveldb 原始字节里找 localStorage 的 auth.tokens 并解出 JSON。
|
||||
|
||||
key 与值各有两种编码可能(ASCII/UTF-16LE、Latin-1/UTF-16LE);.log(WAL)
|
||||
里是明文,块压缩后的 .ldb 解不出(返回 None,属预期)。
|
||||
"""
|
||||
decoder = json.JSONDecoder()
|
||||
markers = {key.encode(), key.encode("utf-16-le")}
|
||||
for marker in markers:
|
||||
for match in re.finditer(re.escape(marker), raw):
|
||||
window = raw[match.end() : match.end() + 8000]
|
||||
for enc, head in (("utf-16-le", b'{\x00"\x00'), ("latin-1", b'{"')):
|
||||
idx = window.find(head)
|
||||
if idx == -1:
|
||||
continue
|
||||
try:
|
||||
obj, _ = decoder.raw_decode(
|
||||
window[idx:].decode(enc, errors="replace")
|
||||
)
|
||||
except Exception: # noqa: BLE001 - 相邻记录干扰,继续找下一处
|
||||
continue
|
||||
if isinstance(obj, dict):
|
||||
return obj
|
||||
return None
|
||||
|
||||
|
||||
def _extract_tokens_from_dir(leveldb_dir: Path) -> dict | None:
|
||||
"""扫 leveldb 目录里的日志文件,返回带 refresh_token 的令牌对象。"""
|
||||
if not leveldb_dir.is_dir():
|
||||
return None
|
||||
candidates = sorted(leveldb_dir.glob("*.log")) + sorted(leveldb_dir.glob("*.ldb"))
|
||||
for path in candidates:
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except Exception: # noqa: BLE001 - 浏览器正在写,读不到就跳过
|
||||
continue
|
||||
obj = _decode_tokens_blob(raw)
|
||||
if obj and obj.get("refresh_token"):
|
||||
return obj
|
||||
return None
|
||||
|
||||
|
||||
def _bootstrap_tokens() -> dict | None:
|
||||
"""兜底引导:从取数浏览器专用配置的 localStorage 里找登录态。"""
|
||||
try:
|
||||
from ...config import edge_profile_dir
|
||||
|
||||
profile_dir = Path(edge_profile_dir())
|
||||
except Exception as exc: # noqa: BLE001 - 配置不可用时不阻塞
|
||||
logger.warning(f"BF2042 令牌引导:读配置失败({exc})")
|
||||
return None
|
||||
found = _extract_tokens_from_dir(
|
||||
profile_dir / "Default" / "Local Storage" / "leveldb"
|
||||
)
|
||||
if found:
|
||||
logger.info("BF2042 令牌引导:已从取数浏览器配置提取 refresh_token")
|
||||
return found
|
||||
|
||||
|
||||
def ensure_access_token(force: bool = False) -> str:
|
||||
"""取一个可用的 access_token;需要时走刷新链并落盘轮换后的令牌。"""
|
||||
with _token_lock:
|
||||
tokens = _read_token_file()
|
||||
if not force and _token_fresh(tokens):
|
||||
return str(tokens["access_token"])
|
||||
|
||||
candidates: list[str] = []
|
||||
if tokens.get("refresh_token"):
|
||||
candidates.append(str(tokens["refresh_token"]))
|
||||
boot = _bootstrap_tokens() or {}
|
||||
if boot.get("refresh_token") and boot["refresh_token"] not in candidates:
|
||||
candidates.append(str(boot["refresh_token"]))
|
||||
|
||||
last_error: Exception | None = None
|
||||
for refresh_token in candidates:
|
||||
try:
|
||||
fresh = _do_refresh(refresh_token)
|
||||
except TrnAuthError as exc:
|
||||
last_error = exc
|
||||
logger.warning(f"BF2042 令牌刷新失败({exc}),换下一个来源")
|
||||
continue
|
||||
_write_token_file(fresh)
|
||||
logger.info("BF2042 直连:TRN 令牌已刷新(轮换续期)")
|
||||
return str(fresh["access_token"])
|
||||
raise TrnAuthError(
|
||||
f"无可用 TRN 登录态:{last_error or '令牌文件与取数浏览器配置里都没有 refresh_token'}"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 取数
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _empty_result() -> dict:
|
||||
return {
|
||||
"profile": None,
|
||||
"weapon": [],
|
||||
"vehicle": [],
|
||||
"gadget": [],
|
||||
"soldier": [],
|
||||
"error": None,
|
||||
"detail": "",
|
||||
}
|
||||
|
||||
|
||||
def _api_headers(access_token: str) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
"Origin": "https://tracker.gg",
|
||||
"Referer": "https://tracker.gg/",
|
||||
}
|
||||
|
||||
|
||||
def _errors_message(payload) -> str:
|
||||
"""tracker.gg 错误响应 → 可读文案(形如 {"errors":[{"message":...}]})。"""
|
||||
if isinstance(payload, dict):
|
||||
errors = payload.get("errors")
|
||||
if isinstance(errors, list) and errors and isinstance(errors[0], dict):
|
||||
return str(errors[0].get("message") or "")
|
||||
return ""
|
||||
|
||||
|
||||
def _get_json(session, url: str, headers: dict):
|
||||
"""GET 并尽量解 JSON;非 JSON(封禁页/挑战页)返回 None。"""
|
||||
resp = session.get(url, headers=headers, timeout=_HTTP_TIMEOUT)
|
||||
try:
|
||||
payload = resp.json()
|
||||
except Exception: # noqa: BLE001 - 错误页是 HTML,属预期
|
||||
payload = None
|
||||
return resp.status_code, payload
|
||||
|
||||
|
||||
def _fetch_sync(name: str, platform: str, *, force_refresh: bool = False) -> dict:
|
||||
"""同步完成一次取数:profile + 各 segments;返回调用方契约字典。"""
|
||||
result = _empty_result()
|
||||
try:
|
||||
token = ensure_access_token(force=force_refresh)
|
||||
except TrnAuthError as exc:
|
||||
result["error"], result["detail"] = "auth", str(exc)
|
||||
return result
|
||||
|
||||
base = f"{_API_BASE}/{platform}/{quote(str(name))}"
|
||||
headers = _api_headers(token)
|
||||
try:
|
||||
with cr.Session(impersonate="chrome") as session:
|
||||
status, payload = _get_json(session, base, headers)
|
||||
if status in (401, 403):
|
||||
result["error"] = "auth"
|
||||
result["detail"] = f"HTTP {status}(令牌被拒或触发风控)"
|
||||
return result
|
||||
if status == 404:
|
||||
result["error"] = "not_found"
|
||||
result["detail"] = _errors_message(payload) or "tracker.gg 未找到该玩家"
|
||||
return result
|
||||
if (
|
||||
status != 200
|
||||
or not isinstance(payload, dict)
|
||||
or not payload.get("data")
|
||||
):
|
||||
result["error"] = "error"
|
||||
result["detail"] = f"profile 响应异常:HTTP {status}"
|
||||
return result
|
||||
result["profile"] = payload
|
||||
|
||||
for seg in _SEGMENTS:
|
||||
s_status, s_payload = _get_json(
|
||||
session, f"{base}/segments/{seg}", headers
|
||||
)
|
||||
if (
|
||||
s_status == 200
|
||||
and isinstance(s_payload, dict)
|
||||
and isinstance(s_payload.get("data"), list)
|
||||
):
|
||||
result[seg] = s_payload["data"]
|
||||
else:
|
||||
logger.warning(
|
||||
f"BF2042 直连:{name} 的 {seg} 段取数失败(HTTP {s_status})"
|
||||
)
|
||||
except Timeout as exc:
|
||||
result["error"], result["detail"] = "timeout", f"直连 tracker.gg 超时:{exc}"
|
||||
except RequestException as exc:
|
||||
result["error"], result["detail"] = "error", f"直连 tracker.gg 请求失败:{exc}"
|
||||
return result
|
||||
|
||||
|
||||
async def _run_attempt(name: str, platform: str, *, force_refresh: bool) -> dict:
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
asyncio.to_thread(_fetch_sync, name, platform, force_refresh=force_refresh),
|
||||
timeout=_ATTEMPT_TIMEOUT_S,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
result = _empty_result()
|
||||
result["error"], result["detail"] = "timeout", "直连 tracker.gg 超时(整体)"
|
||||
return result
|
||||
|
||||
|
||||
async def fetch_bf2042_direct(name: str, platform: str = "origin") -> dict:
|
||||
"""无浏览器直连取数(主通道)。
|
||||
|
||||
返回契约(与扩展桥捕获结果的归一化一致)::
|
||||
|
||||
{"profile": dict | None, "weapon"/"vehicle"/"gadget"/"soldier": list,
|
||||
"error": None | "auth" | "not_found" | "timeout" | "error",
|
||||
"detail": str}
|
||||
|
||||
遇鉴权失败(401/403,含令牌过期触发的 CF 挑战)强制刷新令牌重试一次。
|
||||
"""
|
||||
result = await _run_attempt(name, platform, force_refresh=False)
|
||||
if result.get("error") == "auth":
|
||||
logger.warning(
|
||||
f"BF2042 直连鉴权失败({result.get('detail')}),强制刷新令牌后重试"
|
||||
)
|
||||
result = await _run_attempt(name, platform, force_refresh=True)
|
||||
return result
|
||||
@@ -0,0 +1,289 @@
|
||||
"""浏览器扩展捕获桥:WS 服务端 + 取数任务客户端(BF2042 扩展通道)。
|
||||
|
||||
配套 ``browser_ext/``(Edge/Chromium MV3 扩展,见其 README):扩展在浏览器里被动
|
||||
捕获 tracker.gg 页面自己发出的 API 响应,主动连本服务端回传;bot 侧发"取数任务",
|
||||
扩展负责真实访问(首页 → 站内跳转)并把响应流式送回。
|
||||
|
||||
设计约束(照插件测试/探针惯例):
|
||||
|
||||
- 顶层不 import 插件包相对路径(``tests/test_bf_bot_helpers.py`` 与
|
||||
``debug/bf2042_bridge_probe.py`` 都按文件裸加载本模块);配置读取放在函数内。
|
||||
- 不 import ``tracker_edge``(避免成环):期待 URL 表(want)与页面地址由调用方传入,
|
||||
响应归一化由调用方 ``tracker_edge._rollup`` 负责。
|
||||
|
||||
协议(与扩展 ``background.js`` 对齐):
|
||||
bot→ext {"type":"fetch","id","player","platform","profileUrl","apiBase"}
|
||||
ext→bot {"type":"capture","id": 任务id或null(被动),"key","url","status","body"}
|
||||
ext→bot {"type":"job_done"/"job_error",...};{"type":"hello",...};{"type":"ping"}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
|
||||
from aiohttp import WSMsgType, web
|
||||
from nonebot import logger
|
||||
|
||||
__all__ = [
|
||||
"BridgeServer",
|
||||
"ext_connected",
|
||||
"fetch_via_extension",
|
||||
"start_bridge_server",
|
||||
"stop_bridge_server",
|
||||
]
|
||||
|
||||
_REQUIRED_KEYS = ("profile", "weapon", "vehicle") # 齐了收工;soldier 有宽限窗口
|
||||
_GRACE_S = 2.5 # 迟到响应(soldier 常在任务收工后 0~1s 才到)的收集宽限
|
||||
_WS_MAX_MSG = 16 * 1024 * 1024 # 捕获响应可达几百 KB,放大消息上限
|
||||
|
||||
|
||||
def _norm(url: str) -> str:
|
||||
"""归一化 URL:去掉 query 和末尾斜杠(与 tracker_edge._norm 同规则)。"""
|
||||
return str(url).split("?", 1)[0].rstrip("/")
|
||||
|
||||
|
||||
class _Collector:
|
||||
"""单次任务的捕获收集器(纯逻辑,可单测;计时用 time.monotonic)。"""
|
||||
|
||||
def __init__(self, name: str, platform: str, want: dict[str, str]):
|
||||
self.name = name
|
||||
self.platform = platform
|
||||
# URL(归一化) → key;feed 按 URL 直接命中
|
||||
self.want = {_norm(url): key for key, url in want.items()}
|
||||
self.captured: dict[str, object] = {}
|
||||
self.error: str | None = None
|
||||
self.detail = ""
|
||||
self.done_at: float | None = None # 必收键齐(或收到 job_done)的时刻
|
||||
|
||||
def feed(self, url: str, body: object) -> str | None:
|
||||
"""喂一条捕获;命中想要的 URL 且未收过则记录,返回命中的 key。"""
|
||||
key = self.want.get(_norm(url))
|
||||
if key is None or key in self.captured:
|
||||
return None
|
||||
self.captured[key] = body
|
||||
if self.done_at is None and all(k in self.captured for k in _REQUIRED_KEYS):
|
||||
self.done_at = time.monotonic()
|
||||
return key
|
||||
|
||||
def mark_done(self) -> None:
|
||||
"""扩展报告 job_done:开始宽限计时(通常 feed 已先触发)。"""
|
||||
if self.done_at is None:
|
||||
self.done_at = time.monotonic()
|
||||
|
||||
@property
|
||||
def ready(self) -> bool:
|
||||
"""必收键齐且宽限窗口已过,可以收工。"""
|
||||
if self.done_at is None:
|
||||
return False
|
||||
if not all(k in self.captured for k in _REQUIRED_KEYS):
|
||||
return False
|
||||
return time.monotonic() - self.done_at >= _GRACE_S
|
||||
|
||||
|
||||
class BridgeServer:
|
||||
"""扩展桥 WS 服务端(仅回环;token 校验;配合 bot 侧单飞锁,任务在途至多一个)。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
port: int,
|
||||
token_getter: Callable[[], str],
|
||||
*,
|
||||
host: str = "127.0.0.1",
|
||||
):
|
||||
self.port = port
|
||||
self.host = host
|
||||
self._token_getter = token_getter
|
||||
self._runner: web.AppRunner | None = None
|
||||
self._ws: web.WebSocketResponse | None = None
|
||||
self._jobs: dict[str, _Collector] = {}
|
||||
|
||||
# ------------------------------------------------------------------ 生命周期
|
||||
async def start(self) -> None:
|
||||
app = web.Application()
|
||||
app.router.add_get("/", self._handler)
|
||||
self._runner = web.AppRunner(app, access_log=None)
|
||||
await self._runner.setup()
|
||||
await web.TCPSite(self._runner, self.host, self.port).start()
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._ws is not None and not self._ws.closed:
|
||||
await self._ws.close()
|
||||
if self._runner is not None:
|
||||
await self._runner.cleanup()
|
||||
self._runner = None
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._ws is not None and not self._ws.closed
|
||||
|
||||
# ------------------------------------------------------------------ 连接
|
||||
async def _handler(self, request: web.Request) -> web.StreamResponse:
|
||||
if request.query.get("token") != self._token_getter():
|
||||
return web.Response(status=403, text="bad token")
|
||||
ws = web.WebSocketResponse(max_msg_size=_WS_MAX_MSG, heartbeat=25.0)
|
||||
await ws.prepare(request)
|
||||
old = self._ws
|
||||
self._ws = ws # 新连接顶掉旧连接(扩展 SW 重启/重载时常见)
|
||||
if old is not None and not old.closed:
|
||||
await old.close()
|
||||
logger.info("BF2042 扩展桥:扩展已连接")
|
||||
try:
|
||||
async for msg in ws:
|
||||
if msg.type == WSMsgType.TEXT:
|
||||
try:
|
||||
data = json.loads(msg.data)
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(data, dict):
|
||||
self._on_message(data)
|
||||
elif msg.type == WSMsgType.ERROR:
|
||||
logger.warning(f"BF2042 扩展桥 WS 异常:{ws.exception()}")
|
||||
finally:
|
||||
if self._ws is ws: # 被新连接顶掉的旧连接不动任务状态
|
||||
self._ws = None
|
||||
for collector in self._jobs.values():
|
||||
if collector.error is None:
|
||||
collector.error, collector.detail = (
|
||||
"ext_offline",
|
||||
"扩展连接断开",
|
||||
)
|
||||
logger.info("BF2042 扩展桥:扩展断开")
|
||||
return ws
|
||||
|
||||
def _on_message(self, msg: dict) -> None:
|
||||
mtype = msg.get("type")
|
||||
if mtype == "capture":
|
||||
body = msg.get("body")
|
||||
if body is None:
|
||||
return
|
||||
url = str(msg.get("url") or "")
|
||||
job_id = msg.get("id")
|
||||
if job_id:
|
||||
collector = self._jobs.get(str(job_id))
|
||||
if collector is not None:
|
||||
collector.feed(url, body)
|
||||
else:
|
||||
# 被动捕获(人工浏览 / 任务收工后迟到的 soldier):喂给所有在途任务
|
||||
for collector in self._jobs.values():
|
||||
collector.feed(url, body)
|
||||
elif mtype == "job_done":
|
||||
collector = self._jobs.get(str(msg.get("id")))
|
||||
if collector is not None:
|
||||
collector.mark_done()
|
||||
elif mtype == "job_error":
|
||||
collector = self._jobs.get(str(msg.get("id")))
|
||||
if collector is not None and collector.error is None:
|
||||
collector.error = "error"
|
||||
collector.detail = str(msg.get("detail") or "扩展取数失败")
|
||||
elif mtype == "hello":
|
||||
logger.info(f"BF2042 扩展桥:{msg.get('name')} v{msg.get('version')} 报到")
|
||||
# ping 等其余消息忽略(aiohttp heartbeat 负责链路保活)
|
||||
|
||||
# ------------------------------------------------------------------ 任务
|
||||
async def fetch(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
platform: str,
|
||||
want: dict[str, str],
|
||||
page_url: str,
|
||||
api_base: str,
|
||||
wait_s: float = 120.0,
|
||||
) -> tuple[dict, str | None, str]:
|
||||
"""下发一次取数任务并等待捕获,返回 (captured, error, detail)。"""
|
||||
ws = self._ws
|
||||
if ws is None or ws.closed:
|
||||
return {}, "ext_offline", "浏览器扩展未连接"
|
||||
job_id = uuid.uuid4().hex[:12]
|
||||
collector = _Collector(name, platform, want)
|
||||
self._jobs[job_id] = collector
|
||||
try:
|
||||
await ws.send_json(
|
||||
{
|
||||
"type": "fetch",
|
||||
"id": job_id,
|
||||
"player": name,
|
||||
"platform": platform,
|
||||
"profileUrl": page_url,
|
||||
"apiBase": api_base,
|
||||
}
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 - 发送失败按离线处理
|
||||
self._jobs.pop(job_id, None)
|
||||
return {}, "ext_offline", f"发送任务失败:{e}"
|
||||
|
||||
deadline = time.monotonic() + max(wait_s, 1.0)
|
||||
while True:
|
||||
if collector.error is not None or collector.ready:
|
||||
break
|
||||
if time.monotonic() >= deadline:
|
||||
break
|
||||
if self._ws is not ws or ws.closed:
|
||||
if collector.error is None:
|
||||
collector.error, collector.detail = "ext_offline", "扩展连接断开"
|
||||
break
|
||||
await asyncio.sleep(0.2)
|
||||
self._jobs.pop(job_id, None)
|
||||
return collector.captured, collector.error, collector.detail
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 模块级单例 + nonebot 启停钩子(由插件 __init__.py 注册)
|
||||
# --------------------------------------------------------------------------- #
|
||||
_bridge: BridgeServer | None = None
|
||||
|
||||
|
||||
async def start_bridge_server() -> None:
|
||||
"""按配置启动扩展桥;端口占用等失败只记日志,不阻断 bot 启动。"""
|
||||
global _bridge
|
||||
if _bridge is not None:
|
||||
return
|
||||
from ...config import ext_ws_port, ext_ws_token
|
||||
|
||||
bridge = BridgeServer(port=ext_ws_port(), token_getter=ext_ws_token)
|
||||
try:
|
||||
await bridge.start()
|
||||
except OSError as e: # noqa: BLE001 - 端口被占/无权限时降级
|
||||
logger.error(f"BF2042 扩展桥启动失败({e});扩展通道不可用")
|
||||
return
|
||||
_bridge = bridge
|
||||
logger.info(f"BF2042 扩展桥已监听 ws://127.0.0.1:{bridge.port}")
|
||||
|
||||
|
||||
async def stop_bridge_server() -> None:
|
||||
global _bridge
|
||||
if _bridge is not None:
|
||||
await _bridge.stop()
|
||||
_bridge = None
|
||||
logger.info("BF2042 扩展桥已停止")
|
||||
|
||||
|
||||
def ext_connected() -> bool:
|
||||
"""扩展当前是否已连上(排障/状态查询用)。"""
|
||||
return _bridge is not None and _bridge.connected
|
||||
|
||||
|
||||
async def fetch_via_extension(
|
||||
*,
|
||||
name: str,
|
||||
platform: str,
|
||||
want: dict[str, str],
|
||||
page_url: str,
|
||||
api_base: str,
|
||||
wait_s: float = 120.0,
|
||||
) -> tuple[dict, str | None, str]:
|
||||
"""经模块单例下发取数任务;服务端未启动时直接返回 ext_offline。"""
|
||||
if _bridge is None:
|
||||
return {}, "ext_offline", "扩展桥服务端未启动"
|
||||
return await _bridge.fetch(
|
||||
name=name,
|
||||
platform=platform,
|
||||
want=want,
|
||||
page_url=page_url,
|
||||
api_base=api_base,
|
||||
wait_s=wait_s,
|
||||
)
|
||||
@@ -0,0 +1,180 @@
|
||||
"""tracker.gg 取数总调度(BF2042):直连 API 主通道 → 扩展桥兜底。
|
||||
|
||||
**主通道 = 直连(无浏览器)**:``tracker_api.fetch_bf2042_direct`` —— curl_cffi
|
||||
Chrome 指纹 + TRN OIDC Bearer 令牌直连 api.tracker.gg。实测(2026-10-13)原先
|
||||
"You've Been Blocked" 的根因是**请求里没有登录凭据**,不是"直连"本身;旧结论
|
||||
"绝不主动 fetch API"就此修正(详见 ``tracker_api`` 模块文档)。
|
||||
|
||||
**兜底 = 浏览器扩展桥**(``tracker_bridge`` + ``browser_ext/``):仅在直连失败
|
||||
(auth / timeout / error)时启用,保持原行为不变 —— 扩展常驻浏览器主动连 WS,
|
||||
bot 只发任务、只收回传,零调试端口。``not_found`` 是确定性结果,不重试也不兜底。
|
||||
|
||||
节流(风控保护,都别撤):直连通道自身是正常 API 调用,3s 轻节流;回退扩展桥
|
||||
会触发真实页面加载,保留 20s 节流。并发查询由单飞锁串行化。
|
||||
|
||||
配置项(Web 管理台可改):``bf_ext_ws_port``、``bf_ext_ws_token``、
|
||||
``bf_edge_profile_dir``(兜底桥与令牌引导诊断用)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import quote
|
||||
|
||||
from nonebot import logger
|
||||
|
||||
__all__ = [
|
||||
"TrackerResult",
|
||||
"fetch_bf2042_segments",
|
||||
"user_action_hint",
|
||||
]
|
||||
|
||||
_API = "https://api.tracker.gg/api/v2/bf2042/standard"
|
||||
|
||||
# 单飞 + 节流:并发查询串行化;直连通道轻节流,扩展桥通道保持 20s
|
||||
# (实测:短时间连续加载页面会触发 Cloudflare 风控,连首页都开始挑战)
|
||||
_fetch_lock = asyncio.Lock()
|
||||
_MIN_INTERVAL_S = 20.0
|
||||
_DIRECT_MIN_INTERVAL_S = 3.0
|
||||
_last_fetch_monotonic: float = 0.0
|
||||
_last_direct_monotonic: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackerResult:
|
||||
"""抓取结果,携带失败原因便于上层给用户正确提示。
|
||||
|
||||
reason ∈ ok / ext_offline / timeout / not_found / error
|
||||
"""
|
||||
|
||||
ok: bool
|
||||
data: object = None
|
||||
status: int | None = None
|
||||
reason: str = "ok"
|
||||
detail: str = ""
|
||||
|
||||
|
||||
def user_action_hint() -> str:
|
||||
"""给用户的自救指引(控制台里贴一次,CF 挑战/登录过期时人肉过一下即可)。"""
|
||||
from ...config import edge_profile_dir
|
||||
|
||||
return (
|
||||
"请打开取数浏览器(专用配置)访问 tracker.gg 手动完成验证/登录一次:"
|
||||
f' msedge.exe --user-data-dir="{edge_profile_dir()}" https://tracker.gg/'
|
||||
)
|
||||
|
||||
|
||||
def _norm(url: str) -> str:
|
||||
"""归一化 URL:去掉 query 和末尾斜杠,用于响应匹配。"""
|
||||
return url.split("?", 1)[0].rstrip("/")
|
||||
|
||||
|
||||
def _segment_urls(name: str) -> dict[str, str]:
|
||||
"""页面加载会发出的关键请求(URL 与页面自身发的一致,带尾部 ?)。"""
|
||||
quoted = quote(name)
|
||||
base = f"{_API}/profile/origin/{quoted}"
|
||||
return {
|
||||
"profile": f"{base}?",
|
||||
"weapon": f"{base}/segments/weapon?",
|
||||
"vehicle": f"{base}/segments/vehicle?",
|
||||
"soldier": f"{base}/segments/soldier?",
|
||||
}
|
||||
|
||||
|
||||
def _page_url(name: str, platform: str) -> str:
|
||||
"""tracker.gg 的 profile 页面地址(站内跳转的目标)。"""
|
||||
return f"https://tracker.gg/bf2042/profile/{platform}/{quote(name)}/overview"
|
||||
|
||||
|
||||
def _rollup(captured: dict, err: str | None = None, detail: str = "") -> dict:
|
||||
"""把截获的响应归一化成调用方契约。
|
||||
|
||||
返回 {"profile": {...} | None, "weapon": [...], "vehicle": [...],
|
||||
"soldier": [...], "error": str | None, "detail": str}
|
||||
"""
|
||||
|
||||
def _list_of_segments(seg) -> list:
|
||||
if isinstance(seg, list):
|
||||
return seg
|
||||
if isinstance(seg, dict):
|
||||
data = seg.get("data")
|
||||
return data if isinstance(data, list) else []
|
||||
return []
|
||||
|
||||
profile = captured.get("profile")
|
||||
if err is None and (not isinstance(profile, dict) or not profile.get("data")):
|
||||
err, detail = "error", "未截获到 profile 数据"
|
||||
|
||||
return {
|
||||
"profile": profile if err is None else None,
|
||||
"weapon": _list_of_segments(captured.get("weapon")),
|
||||
"vehicle": _list_of_segments(captured.get("vehicle")),
|
||||
"gadget": _list_of_segments(captured.get("gadget")),
|
||||
"soldier": _list_of_segments(captured.get("soldier")),
|
||||
"error": err,
|
||||
"detail": detail,
|
||||
}
|
||||
|
||||
|
||||
async def fetch_bf2042_segments(
|
||||
name: str,
|
||||
platform: str = "origin",
|
||||
*,
|
||||
wait_s: float = 120.0,
|
||||
) -> dict:
|
||||
"""BF2042 取数总调度:直连 API(主)→ 扩展桥(兜底)。
|
||||
|
||||
返回 {"profile": {...} | None, "weapon"/"vehicle"/"gadget"/"soldier": [...],
|
||||
"error": str | None, "detail": str}
|
||||
|
||||
直连通道见 ``tracker_api``(无浏览器,3s 轻节流);失败(auth / timeout /
|
||||
error)时回退扩展桥(20s 节流,需取数浏览器在线,``wait_s`` 为桥的等待窗口),
|
||||
两者原因都会写进 ``detail`` 便于排查。``not_found`` 是确定性结果,不重试。
|
||||
"""
|
||||
global _last_fetch_monotonic, _last_direct_monotonic
|
||||
|
||||
async with _fetch_lock:
|
||||
# ---- 主通道:直连 API(轻节流) ----
|
||||
delta = time.monotonic() - _last_direct_monotonic
|
||||
if _last_direct_monotonic and delta < _DIRECT_MIN_INTERVAL_S:
|
||||
await asyncio.sleep(_DIRECT_MIN_INTERVAL_S - delta)
|
||||
_last_direct_monotonic = time.monotonic()
|
||||
|
||||
from . import tracker_api
|
||||
|
||||
direct = await tracker_api.fetch_bf2042_direct(name, platform)
|
||||
if direct.get("error") is None:
|
||||
return _rollup(direct)
|
||||
if direct.get("error") == "not_found":
|
||||
return _rollup(direct, "not_found", str(direct.get("detail") or ""))
|
||||
|
||||
logger.warning(
|
||||
f"BF2042 直连不可用({direct.get('error')}:{direct.get('detail')}),"
|
||||
"回退扩展桥"
|
||||
)
|
||||
|
||||
# ---- 兜底:扩展桥(需要取数浏览器在线) ----
|
||||
delta = time.monotonic() - _last_fetch_monotonic
|
||||
if _last_fetch_monotonic and delta < _MIN_INTERVAL_S:
|
||||
await asyncio.sleep(_MIN_INTERVAL_S - delta)
|
||||
_last_fetch_monotonic = time.monotonic()
|
||||
|
||||
from . import tracker_bridge
|
||||
|
||||
want = _segment_urls(name)
|
||||
captured, err, detail = await tracker_bridge.fetch_via_extension(
|
||||
name=name,
|
||||
platform=platform,
|
||||
want=want,
|
||||
page_url=_page_url(name, platform),
|
||||
api_base=want["profile"][:-1], # 去掉尾部 "?"
|
||||
wait_s=wait_s,
|
||||
)
|
||||
if err is None and not captured:
|
||||
err, detail = "timeout", "等待页面数据超时"
|
||||
rolled = _rollup(captured, err, detail)
|
||||
if rolled["error"] is not None:
|
||||
rolled["detail"] = f"{rolled['detail']};直连失败:{direct.get('detail')}"
|
||||
return rolled
|
||||
@@ -0,0 +1,301 @@
|
||||
"""战绩加工:最佳武器/载具、载具摧毁数、六维雷达数据与称号评级。"""
|
||||
|
||||
from functools import reduce
|
||||
|
||||
from nonebot import logger
|
||||
|
||||
from ..utils.params import SPECIALIST_NAMES, interval_table, round_data
|
||||
|
||||
|
||||
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"]
|
||||
if game == "bf3":
|
||||
headshots = float(0)
|
||||
else:
|
||||
headshots = float(player_stat["headshots"].replace("%", ""))
|
||||
spm = player_stat["scorePerMinute"]
|
||||
revives = player_stat["revives"]
|
||||
|
||||
apr = kill_assists / match_played
|
||||
rpr = revives / 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"\n生涯KD比值:{ratio_kd}\n生涯Kpm比值:{ratio_kpm}\n生涯准度比值:{ratio_hs}"
|
||||
f"\n场均救援比值:{ratio_rpr}\n场均助攻比值:{ratio_apr}\n分钟得分比值:{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"\n血腥度:{player_blood} 比值:{ratio_blood}"
|
||||
f"\n贡献度:{player_dedication} 比值:{ratio_dedication}"
|
||||
f"\n全能度:{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} 不在任何区间内")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# BF2042 生涯卡数据(旧 project 卡片槽位 → tracker.gg 数据映射)
|
||||
# --------------------------------------------------------------------------- #
|
||||
_PLACEHOLDER = "–"
|
||||
|
||||
|
||||
def _seg_stat(seg: dict, *keys):
|
||||
"""从段 stats 取第一个命中的显示值(displayValue 优先)。"""
|
||||
stats = (seg or {}).get("stats") or {}
|
||||
for key in keys:
|
||||
entry = stats.get(key)
|
||||
if isinstance(entry, dict):
|
||||
dv = entry.get("displayValue")
|
||||
if dv is not None:
|
||||
return dv
|
||||
v = entry.get("value")
|
||||
if v is not None:
|
||||
return v
|
||||
elif entry is not None:
|
||||
return entry
|
||||
return _PLACEHOLDER
|
||||
|
||||
|
||||
def _seg_value(seg: dict, *keys) -> float:
|
||||
stats = (seg or {}).get("stats") or {}
|
||||
for key in keys:
|
||||
entry = stats.get(key)
|
||||
if isinstance(entry, dict) and entry.get("value") is not None:
|
||||
return float(entry["value"])
|
||||
return 0.0
|
||||
|
||||
|
||||
def _fmt_minutes_hours(minutes: float) -> str:
|
||||
"""tracker timePlayed(分钟) → "X.XX" 小时字符串(旧卡习惯)。"""
|
||||
try:
|
||||
return f"{float(minutes) / 60:.2f}"
|
||||
except (TypeError, ValueError):
|
||||
return _PLACEHOLDER
|
||||
|
||||
|
||||
def _s_level(level) -> str:
|
||||
"""等级 S 前缀:tracker 的 298 → 'S199'(与旧 gametools 一致)。"""
|
||||
try:
|
||||
lv = int(level)
|
||||
except (TypeError, ValueError):
|
||||
return str(level)
|
||||
return f"S{lv - 99}" if lv - 99 > 0 else str(lv)
|
||||
|
||||
|
||||
def _top_rows(segments: list[dict], top: int) -> list[dict]:
|
||||
"""武器/载具段 → 卡片 TOP 行(按击杀降序)。"""
|
||||
ranked = sorted(segments or [], key=lambda s: _seg_value(s, "kills"), reverse=True)
|
||||
rows = []
|
||||
for seg in ranked[:top]:
|
||||
meta = seg.get("metadata") or {}
|
||||
rows.append(
|
||||
{
|
||||
"名称": (meta.get("name") or "").strip() or _PLACEHOLDER,
|
||||
"类别": meta.get("category") or "",
|
||||
"击杀": _seg_stat(seg, "kills"),
|
||||
"爆头率": _seg_stat(seg, "headshotPercentage"),
|
||||
"命中率": _seg_stat(seg, "shotsAccuracy"),
|
||||
"KPM": _seg_stat(seg, "killsPerMinute"),
|
||||
"时长": _fmt_minutes_hours(_seg_value(seg, "timePlayed")),
|
||||
"摧毁": _seg_stat(seg, "destroyedWith"),
|
||||
# 原始值给排序/调试用
|
||||
"_kills": _seg_value(seg, "kills"),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def build_bf2042_career_card(
|
||||
profile: dict,
|
||||
weapon_segs: list[dict] | None,
|
||||
vehicle_segs: list[dict] | None,
|
||||
soldier_segs: list[dict] | None = None,
|
||||
) -> dict:
|
||||
"""tracker.gg 数据 → 旧卡片字段契约(dev/bf2042 的 data 结构)。
|
||||
|
||||
字段名沿用旧版(userName/killDeath/infantryKillDeath/secondsPlayed/
|
||||
dividedKills/humanPrecentage/weapons[].headshots 等),渲染层照旧坐标绘制;
|
||||
tracker 没有的数据(命中率/mvp/标记敌人数/处决等)填占位符 "–"。
|
||||
"""
|
||||
data = (profile or {}).get("data") or {}
|
||||
segments = data.get("segments") or []
|
||||
overview = next((s for s in segments if s.get("type") == "overview"), {})
|
||||
info = data.get("platformInfo") or {}
|
||||
|
||||
kills = _seg_value(overview, "kills")
|
||||
kd = _seg_value(overview, "kdRatio")
|
||||
kpm = _seg_value(overview, "killsPerMinute")
|
||||
human_kills = _seg_value(overview, "humanKills")
|
||||
human_per = round(100.0 * human_kills / kills, 1) if kills else 0.0
|
||||
ratio = (human_kills / kills) if kills else 0.0
|
||||
|
||||
level = _seg_stat(overview, "level")
|
||||
try:
|
||||
level_s = int(level) - 99
|
||||
level = f"S {level_s:03d}" if level_s > 0 else f"{int(level):03d}"
|
||||
except (TypeError, ValueError):
|
||||
level = str(level)
|
||||
|
||||
# 真实 K/D、真实 KPM(旧卡算法:按"对人击杀占比"折算)
|
||||
real_kd = round(kd * ratio, 2)
|
||||
real_kpm = round(kpm * ratio, 2)
|
||||
|
||||
# 最佳专家(soldier 段→旧 classes/characterName 结构)
|
||||
best = None
|
||||
ranked = sorted(
|
||||
soldier_segs or [], key=lambda s: _seg_value(s, "kills"), reverse=True
|
||||
)
|
||||
if ranked:
|
||||
seg = ranked[0]
|
||||
raw_name = ((seg.get("metadata") or {}).get("name") or "").strip()
|
||||
best = {
|
||||
"characterName": raw_name,
|
||||
"displayName": SPECIALIST_NAMES.get(raw_name, raw_name),
|
||||
"className": (seg.get("metadata") or {}).get("category") or "",
|
||||
"kills": _seg_stat(seg, "kills"),
|
||||
"kpm": _seg_stat(seg, "killsPerMinute"),
|
||||
"killDeath": _seg_stat(seg, "kdRatio"),
|
||||
"secondsPlayed": round(_seg_value(seg, "timePlayed")),
|
||||
}
|
||||
|
||||
# 鉴定文案(旧阈值)
|
||||
if kpm > 1.00 and kd > 2 and real_kd > 1.5:
|
||||
verdict, verdict_color = "Pro哥\n你带我走吧T_T", "gold"
|
||||
else:
|
||||
verdict, verdict_color = "薯薯\n别拷打我了哥>_<", "skyblue"
|
||||
|
||||
def _objects(segs: list[dict], name_key: str, is_vehicle: bool) -> list[dict]:
|
||||
rows = []
|
||||
for seg in sorted(
|
||||
segs or [], key=lambda x: _seg_value(x, "kills"), reverse=True
|
||||
)[:5]:
|
||||
meta = seg.get("metadata") or {}
|
||||
rows.append(
|
||||
{
|
||||
name_key: (meta.get("name") or "").strip(),
|
||||
"type": meta.get("category") or "",
|
||||
"kills": _seg_stat(seg, "kills"),
|
||||
"headshots": _seg_stat(seg, "headshotPercentage"),
|
||||
"accuracy": _seg_stat(seg, "shotsAccuracy"),
|
||||
"killsPerMinute": _seg_stat(seg, "killsPerMinute"),
|
||||
"timeEquipped": round(_seg_value(seg, "timePlayed")),
|
||||
"destroyed": _seg_stat(seg, "destroyedWith") if is_vehicle else "–",
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
return {
|
||||
"userName": info.get("platformUserHandle") or _PLACEHOLDER,
|
||||
"platform": info.get("platformSlug") or "origin",
|
||||
"level": level,
|
||||
"secondsPlayed": int(_seg_value(overview, "timePlayed") // 3600),
|
||||
"matchesPlayed": _seg_stat(overview, "matches"),
|
||||
"mvp": "–", # tracker 无
|
||||
"bestSquad": "–", # tracker 无
|
||||
"playerTakeDowns": _seg_stat(overview, "meleeKills"),
|
||||
"kills": _seg_stat(overview, "kills"),
|
||||
"deaths": _seg_stat(overview, "deaths"),
|
||||
"killDeath": _seg_stat(overview, "kdRatio"),
|
||||
"infantryKillDeath": round(kd * ratio, 2),
|
||||
"killsPerMinute": _seg_stat(overview, "killsPerMinute"),
|
||||
"killsPerMatch": _seg_stat(overview, "killsPerMatch"),
|
||||
"headShots": _seg_stat(overview, "headshotPercentage"),
|
||||
"accuracy": "–", # tracker overview 无整体命中率
|
||||
"winPercent": _seg_stat(overview, "wlPercentage"),
|
||||
"humanPrecentage": f"{human_per}%",
|
||||
"dividedKills": {
|
||||
"ai": _seg_stat(overview, "aiKills"),
|
||||
"vehicle": _seg_stat(overview, "vehicleKills"),
|
||||
"longDistance": "–", # tracker 无
|
||||
"inRound": {"longDistance": "–"},
|
||||
},
|
||||
"revives": _seg_stat(overview, "revives"),
|
||||
"enemiesSpotted": "–", # tracker 无
|
||||
"vehiclesDestroyed": _seg_stat(overview, "vehiclesDestroyed"),
|
||||
"bestClass": best,
|
||||
"realKd": real_kd,
|
||||
"realKpm": real_kpm,
|
||||
"verdict": verdict,
|
||||
"verdictColor": verdict_color,
|
||||
"weapons": _objects(weapon_segs or [], "weaponName", False),
|
||||
"vehicles": _objects(vehicle_segs or [], "vehicleName", True),
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
"""查询编排:目标解析 → 取数 → 加工 → 渲染,返回给 Trigger 层发送的结果。
|
||||
|
||||
只产出结果对象(文本/图片字节),不直接发消息;"正在查询…"等进度提示通过
|
||||
`notify` 回调交给 Trigger 层发送,本层保持与 nonebot 消息 API 解耦。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from nonebot import logger
|
||||
|
||||
from ..repository import Bf2042SettingsManager, UserManager
|
||||
from ..utils.args import (
|
||||
BF2042_ERROR_HINT,
|
||||
BF_GAME_NAMES,
|
||||
parse_bf2042_args,
|
||||
)
|
||||
from ..utils.render_bf2042 import build_bf2042_career_card, build_bf2042_list_card
|
||||
from ..utils.render_ea import build_bf6_stats_card, build_stats_card
|
||||
from ..utils.text import convert_to_hours
|
||||
from .fetchers.bf2042 import (
|
||||
bf2042_list_title,
|
||||
get_bf2042_gamemodes,
|
||||
get_bf2042_info,
|
||||
get_bf2042_list,
|
||||
)
|
||||
from .fetchers.gametools import (
|
||||
fetch_all_stats,
|
||||
get_bf6_rank,
|
||||
get_player_info_by_name,
|
||||
player_display_name,
|
||||
)
|
||||
from .processing import (
|
||||
get_best_classes,
|
||||
get_best_weapon_and_best_vehicle,
|
||||
get_vehicle_destroyed,
|
||||
stats_calculator,
|
||||
)
|
||||
|
||||
Notify = Callable[[str], Awaitable[None]]
|
||||
|
||||
_PLATFORM = "pc"
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueryReply:
|
||||
"""一次查询的最终回复:文本 +(可选)PNG 图片字节。"""
|
||||
|
||||
text: str
|
||||
image: bytes | None = None
|
||||
|
||||
|
||||
async def run_query(
|
||||
*, cmd: str, input_text: str, qq_id: str, notify: Notify
|
||||
) -> QueryReply:
|
||||
"""`/bft` 及全部别名共用的查询入口。
|
||||
|
||||
cmd: 实际输入的游戏别名(bf3/bf4/bf1/bfv/bf2042/bf6)。
|
||||
input_text: 命令参数(EAID 或 BF2042 子命令),可为空。
|
||||
qq_id: 触发者 QQ,用于读绑定与取头像。
|
||||
notify: 进度提示回调("正在查询…")。
|
||||
"""
|
||||
started = time.time()
|
||||
if cmd not in BF_GAME_NAMES:
|
||||
return QueryReply(
|
||||
"指令异常:请使用 bf3 / bf4 / bf1 / bfv / bf2042 / bf6(可带 EAID)"
|
||||
)
|
||||
if cmd == "bf2042":
|
||||
reply = await _run_bf2042(input_text, qq_id, notify)
|
||||
else:
|
||||
reply = await _run_ea(cmd, input_text, qq_id, notify)
|
||||
if reply.image is not None:
|
||||
reply.text = f"本次查询耗时:{round(time.time() - started, 3)}s"
|
||||
return reply
|
||||
|
||||
|
||||
def _resolve_bound_record(qq_id: str) -> QueryReply | dict:
|
||||
"""无参数查询时取绑定记录;未绑定返回给用户的提示。"""
|
||||
content = UserManager().get_user_by_qq(qq_id)
|
||||
if not content:
|
||||
return QueryReply(
|
||||
"未检测到id,也未检测到绑定记录,请使用 绑定 EAID 进行绑定操作"
|
||||
)
|
||||
return content
|
||||
|
||||
|
||||
async def _run_ea(cmd: str, input_text: str, qq_id: str, notify: Notify) -> QueryReply:
|
||||
"""EA 系(BF3/4/1/5/6):gametools 取数 + 卡片渲染。"""
|
||||
if not input_text:
|
||||
content = _resolve_bound_record(qq_id)
|
||||
if isinstance(content, QueryReply):
|
||||
return content
|
||||
player = content["ea_player_name"]
|
||||
player_id = content["ea_player_id"]
|
||||
user_id = content["ea_user_id"]
|
||||
else:
|
||||
content = await get_player_info_by_name(input_text, _PLATFORM)
|
||||
if "errors" in content:
|
||||
return QueryReply("未找到玩家,请检查是否拼写正确")
|
||||
player_id = content["personaId"]
|
||||
user_id = content["userId"]
|
||||
player = player_display_name(content)
|
||||
await notify(f"正在查询 {player} 的 {BF_GAME_NAMES[cmd]} 数据,请耐心等待")
|
||||
|
||||
try:
|
||||
rank = 0
|
||||
if cmd == "bf6":
|
||||
play_stat, rank = await asyncio.gather(
|
||||
fetch_all_stats(cmd, player, player_id, user_id, _PLATFORM),
|
||||
get_bf6_rank(player, player_id, user_id, _PLATFORM),
|
||||
)
|
||||
logger.info(f"等级:{rank}")
|
||||
else:
|
||||
play_stat = await fetch_all_stats(
|
||||
cmd, player, player_id, user_id, _PLATFORM
|
||||
)
|
||||
if "errors" in play_stat:
|
||||
logger.warning(play_stat["errors"][0])
|
||||
return QueryReply(str(play_stat["errors"][0]))
|
||||
|
||||
img = await _render_ea_card(cmd, play_stat, rank, player, qq_id)
|
||||
if isinstance(img, QueryReply):
|
||||
return img
|
||||
return QueryReply("", image=img)
|
||||
except Exception as e: # noqa: BLE001 - 兜底给用户可见提示,明细写日志
|
||||
logger.exception(f"异常:{e}")
|
||||
return QueryReply("查询异常,请稍后再试")
|
||||
|
||||
|
||||
async def _render_ea_card(cmd: str, play_stat, rank, player, qq_id):
|
||||
"""渲染卡片;返回 PNG 字节,或对用户的错误提示(QueryReply)。"""
|
||||
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:
|
||||
return QueryReply("查询接口异常,请等待一段时间后查询")
|
||||
|
||||
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}")
|
||||
img = await build_bf6_stats_card(
|
||||
game="bf6",
|
||||
qq_id=qq_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=play_stat["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"]
|
||||
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(
|
||||
cmd,
|
||||
player,
|
||||
pid,
|
||||
kd,
|
||||
kpm,
|
||||
spm,
|
||||
acc,
|
||||
head_shots,
|
||||
play_stat["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,
|
||||
qq_id=qq_id,
|
||||
)
|
||||
if img is None:
|
||||
return QueryReply("图片渲染失败,请稍后再试")
|
||||
return img.getvalue()
|
||||
|
||||
|
||||
async def _run_bf2042(input_text: str, qq_id: str, notify: Notify) -> QueryReply:
|
||||
"""BF2042 独立流程:tracker.gg 浏览器通道。
|
||||
|
||||
与其它战地不同,BF2042 不经过 EA 接口,所以玩家名要么来自指令参数,
|
||||
要么来自绑定记录,不需要 personaId/userId。
|
||||
|
||||
指令形式:
|
||||
/bf2042 [EAID] 生涯卡
|
||||
/bf2042 武器|载具|装备 [EAID] 榜单卡
|
||||
/bf2042 模式 [EAID] 各模式明细
|
||||
"""
|
||||
target = parse_bf2042_args(input_text)
|
||||
player = target.player
|
||||
if not player:
|
||||
content = _resolve_bound_record(qq_id)
|
||||
if isinstance(content, QueryReply):
|
||||
return content
|
||||
player = content["ea_player_name"]
|
||||
|
||||
if target.view == "list":
|
||||
what = f"{bf2042_list_title(target.kind)}榜"
|
||||
elif target.view == "gamemode":
|
||||
what = "各模式数据"
|
||||
else:
|
||||
what = "数据"
|
||||
await notify(f"正在查询 {player} 的 {BF_GAME_NAMES['bf2042']}{what},请耐心等待")
|
||||
|
||||
try:
|
||||
if target.view == "list":
|
||||
result = await get_bf2042_list(player, _PLATFORM, target.kind)
|
||||
elif target.view == "gamemode":
|
||||
result = await get_bf2042_gamemodes(player, _PLATFORM)
|
||||
else:
|
||||
result = await get_bf2042_info(player, _PLATFORM)
|
||||
except Exception as e: # noqa: BLE001 - 兜底,别让异常静默
|
||||
logger.exception(f"BF2042 查询异常:{e}")
|
||||
return QueryReply("BF2042 查询异常,请稍后再试")
|
||||
|
||||
if not result.ok:
|
||||
logger.warning(f"BF2042 查询失败({result.reason}):{result.detail}")
|
||||
hint = BF2042_ERROR_HINT.get(result.reason, "查询失败,请稍后再试")
|
||||
return QueryReply(f"BF2042 {hint}")
|
||||
|
||||
info = result.data if isinstance(result.data, dict) else {}
|
||||
if target.view == "career":
|
||||
# 新版生涯卡:旧版布局 + tracker.gg 数据映射;背景按支持者特权选
|
||||
from .processing import build_bf2042_career_card as build_card_slots
|
||||
|
||||
try:
|
||||
support = Bf2042SettingsManager().is_support(qq_id)
|
||||
except Exception: # noqa: BLE001 - 装饰性功能,失败退回公共背景
|
||||
support = False
|
||||
card = build_card_slots(
|
||||
info.get("_profile") or {},
|
||||
info.get("_weapons") or [],
|
||||
info.get("_vehicles") or [],
|
||||
info.get("_soldiers") or [],
|
||||
)
|
||||
card["_qq_id"] = qq_id
|
||||
card["_support"] = support
|
||||
img = build_bf2042_career_card(card)
|
||||
else:
|
||||
img = build_bf2042_list_card(info)
|
||||
return QueryReply("", image=img.getvalue())
|
||||
Reference in new issue
Block a user