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