refactor(bf-bot): 插件重构为 handlers/services/utils 分层,资源归入 res/

- 旧平铺模块(data/data_utils/database_op/image_builder*/get_bf6_data/
  gametools_bf6/bf6_data/img_utils)拆分为 handlers(绑定/查询)、
  services(fetchers/processing/query)、utils(渲染/参数)分层;
  新增 repository.py / config.py / paths.py
- 字体/图片/模板资源迁入 res/,新增 BF2042 卡面素材(bg/object_icon 等)
- browser_ext/:BF2042 扩展捕获桥(WS 直连兜底通道)源码入库
- 新增 hexi/web/src/plugins/bf_bot 前端管理页
- 新增 tests:test_bf_bot_helpers / repository / tracker_api

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
sansenhoshiandClaude Code committed 2026-10-13 17:53:06 +08:00
1 parent 614e1d7e00
commit a31aab00e8
752 files changed
+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())