Files
HeXi/hexi/plugins/nonebot_plugin_bf_bot/browser_ext/background.js
T
sansenhoshiandClaude Code a31aab00e8 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>
2026-10-13 17:53:06 +08:00

284 lines
9.0 KiB
JavaScript

// HeXi BF2042 捕获桥 - Service Worker
//
// 两个职责:
// 1) 持有到本机 bot 的 WebSocket(自动重连 + 心跳),转发页面捕获的响应;
// 2) 执行 bot 下发的取数任务:找/开 tracker.gg 标签页 → 首页 bootstrap →
// 站内跳转进 profile 页 → 等页面自己发出 XHR → 上报 job_done。
//
// 导航定律(来自插件文档,勿改):地址栏深链会被 Cloudflare 挑战,必须先落首页、
// 再在站内跳转。全程不用任何调试端口,浏览器就是个普通实例。
//
// 重试机制:页面撞 CF 挑战/403 时,页面自己的 XHR 不会产出数据,本轮
// ATTEMPT_WAIT_MS 内关键响应收不齐 → 自动重走"首页→站内跳转"(刷新等价),
// 最多 MAX_ATTEMPTS 轮;提前收齐即收工。
const WS_URL = "ws://127.0.0.1:39272/";
const WS_TOKEN = "hexi-bf2042"; // 仅本机回环 + 共享 token;修改时与 bot 侧配置同步
const HOMEPAGE = "https://tracker.gg/";
const CAPTURE_PREFIX =
"https://api.tracker.gg/api/v2/bf2042/standard/profile/";
const JOB_KEYS = ["profile", "weapon", "vehicle"]; // 齐了即收工(soldier 可选)
const SETTLE_MS = 3000; // 首页落稳再跳,镜像 python 通道的 sleep(3)
const JOB_TIMEOUT_MS = 150000;
// 重试:单轮 X 秒内关键数据没齐 = 多半撞了 CF 挑战/403,重走"首页→站内跳转"刷新
const ATTEMPT_WAIT_MS = 22000;
const MAX_ATTEMPTS = 3;
let ws = null;
let retryMs = 1000;
let job = null; // { id, base, tabId, keys:Set, attempts, timer }
const log = (...a) => console.log("[bf2042-bridge]", ...a);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ------------------------------------------------------------------ WS
function connect() {
if (ws) return; // CONNECTING / OPEN 状态都不重复建
try {
ws = new WebSocket(`${WS_URL}?token=${encodeURIComponent(WS_TOKEN)}`);
} catch (_) {
scheduleReconnect();
return;
}
ws.onopen = () => {
retryMs = 1000;
log("已连接 bot");
send({ type: "hello", name: "hexi-bf2042-bridge", version: "0.1.0" });
};
ws.onmessage = (ev) => {
try {
handleMessage(JSON.parse(ev.data));
} catch (_) {}
};
ws.onclose = () => scheduleReconnect();
ws.onerror = () => {
try {
ws.close();
} catch (_) {}
};
}
function scheduleReconnect() {
ws = null;
setTimeout(connect, retryMs);
retryMs = Math.min(retryMs * 2, 30000); // 1s → 2s → ... → 上限 30s
}
function send(obj) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(obj));
return true;
}
return false;
}
// 心跳:兼作 MV3 SW 保活(Chromium 116+ 活跃 WS 本就会保活,alarms 双保险,
// 覆盖"bot 重启导致断线、SW 又在重连定时器前被回收"的窗口)。
chrome.alarms.create("keepalive", { periodInMinutes: 0.5 });
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name !== "keepalive") return;
if (!ws) connect();
else send({ type: "ping" });
});
connect();
// ------------------------------------------------------------------ 工具
function norm(url) {
return String(url || "").split("?")[0].replace(/\/+$/, "");
}
// 两边都 decode 再比,消除 python quote() 与 JS encodeURIComponent 的转义差异
function canon(url) {
try {
return decodeURIComponent(norm(url));
} catch (_) {
return norm(url);
}
}
function isTracker(url) {
return /^https:\/\/(www\.)?tracker\.gg\//.test(String(url || ""));
}
function keyOf(url) {
const u = norm(url);
for (const k of ["weapon", "vehicle", "soldier", "gadget"]) {
if (u.endsWith(`/segments/${k}`)) return k;
}
return "profile"; // profile 基础响应(gamemode 等段都在它里面)
}
// ------------------------------------------------------------------ 任务
function handleMessage(msg) {
if (!msg || typeof msg !== "object") return;
if (msg.type === "fetch") startJob(msg).catch(() => {});
}
async function startJob(msg) {
if (job) return send({ type: "job_error", id: msg.id, detail: "busy" });
if (!msg.profileUrl || !msg.apiBase) {
return send({ type: "job_error", id: msg.id, detail: "bad_request" });
}
job = {
id: msg.id,
base: canon(msg.apiBase),
tabId: null,
keys: new Set(),
attempts: 0,
timer: setTimeout(() => finishJob("timeout"), JOB_TIMEOUT_MS),
};
try {
const tab = await ensureTab();
job.tabId = tab.id;
await chrome.tabs.update(tab.id, { active: true });
for (let attempt = 1; attempt <= MAX_ATTEMPTS && job; attempt++) {
job.attempts = attempt;
// 严格镜像已验证路径:每轮都先落首页,停顿,再站内跳转
try {
await chrome.tabs.update(tab.id, { url: HOMEPAGE });
await waitComplete(tab.id);
} catch (_) {
break; // 标签页没了
}
await sleep(SETTLE_MS);
log(`第 ${attempt}/${MAX_ATTEMPTS} 轮,站内跳转:`, msg.profileUrl);
// setTimeout 0:让注入脚本先结束再触发跳转,避免 "frame detached" 噪声
chrome.scripting
.executeScript({
target: { tabId: tab.id },
world: "MAIN",
func: (u) => {
setTimeout(() => {
window.location.href = u;
}, 0);
},
args: [msg.profileUrl],
})
.catch(() => {});
const ok = await waitForKeys(tab.id, ATTEMPT_WAIT_MS);
if (!job) return; // 期间已收工/超时
if (ok) return finishJob("done");
const health = await pageHealth(tab.id);
log(`第 ${attempt} 轮未收齐(${health}),${attempt < MAX_ATTEMPTS ? "刷新重试" : "放弃"}`);
}
if (job) finishJob("no_data", "多轮重试后仍未捕获到数据");
} catch (e) {
finishJob("navigate_failed", String(e));
}
}
// 等待关键响应齐(或任务被外部收工);返回是否收齐
function waitForKeys(tabId, timeoutMs) {
return new Promise((resolve) => {
const t0 = Date.now();
const timer = setInterval(() => {
if (!job) {
clearInterval(timer);
resolve(false);
return;
}
if (JOB_KEYS.every((k) => job.keys.has(k))) {
clearInterval(timer);
resolve(true);
return;
}
if (Date.now() - t0 >= timeoutMs) {
clearInterval(timer);
resolve(false);
}
}, 500);
});
}
// 页面健康检查:区分"CF 挑战/封禁"与"页面正常但没数据"(仅供日志)
async function pageHealth(tabId) {
try {
const [res] = await chrome.scripting.executeScript({
target: { tabId },
func: () => {
const t = (document.title || "") + " " + (document.body?.innerText || "").slice(0, 300);
return t.slice(0, 200);
},
});
const t = String(res?.result || "");
if (/just a moment|安全验证|正在进行|verify you are human/i.test(t)) return "CF挑战页";
if (/you'?ve been blocked|been blocked/i.test(t)) return "TRN封禁页";
if (/username sign in|sign in with your tracker/i.test(t)) return "登录过期";
return "页面无挑战";
} catch (_) {
return "健康检查失败(标签页可能已关闭)";
}
}
async function ensureTab() {
const tabs = await chrome.tabs.query({
url: ["https://tracker.gg/*", "https://*.tracker.gg/*"],
});
if (tabs.length) return tabs[0];
const tab = await chrome.tabs.create({ url: HOMEPAGE, active: true });
await waitComplete(tab.id);
return tab;
}
function waitComplete(tabId, timeoutMs = 30000) {
return new Promise((resolve) => {
const done = () => {
clearTimeout(timer);
chrome.tabs.onUpdated.removeListener(listener);
resolve();
};
const timer = setTimeout(done, timeoutMs);
const listener = (id, info) => {
if (id === tabId && info.status === "complete") done();
};
chrome.tabs.onUpdated.addListener(listener);
chrome.tabs
.get(tabId)
.then((t) => {
if (t.status === "complete") done();
})
.catch(done);
});
}
function finishJob(reason, detail) {
if (!job) return;
clearTimeout(job.timer);
const { id, keys } = job;
job = null;
if (reason === "done") {
send({ type: "job_done", id, keys: [...keys] });
} else {
send({ type: "job_error", id, keys: [...keys], detail: detail || reason });
}
}
// ------------------------------------------------------------------ 捕获回传
chrome.runtime.onMessage.addListener((msg, sender) => {
if (!msg || msg.type !== "captured") return;
const url = String(msg.url || "");
if (!url.startsWith(CAPTURE_PREFIX)) return;
const key = keyOf(url);
if (job) {
// 只认我们驱动的那个标签页,避免把用户同时手动浏览的流量掺进来
if (job.tabId != null && sender.tab && sender.tab.id !== job.tabId) return;
if (!canon(url).startsWith(job.base)) return; // 不是这次任务那位玩家的
job.keys.add(key);
send({
type: "capture",
id: job.id,
key,
url,
status: msg.status,
body: msg.body,
});
if (JOB_KEYS.every((k) => job.keys.has(k))) finishJob("done");
return;
}
// 无任务:被动转发(id 为 null),人工浏览也顺手喂数据/便于联调
send({ type: "capture", id: null, key, url, status: msg.status, body: msg.body });
});