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 @@
|
||||
"""取数层:外部数据源客户端(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
|
||||
Reference in new issue
Block a user