- 旧平铺模块(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>
284 lines
9.0 KiB
JavaScript
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 });
|
|
});
|