diff --git a/e2e/binance-orderbook/specs/control-flows.pw.js b/e2e/binance-orderbook/specs/control-flows.pw.js index 46cabc2..8af00fb 100644 --- a/e2e/binance-orderbook/specs/control-flows.pw.js +++ b/e2e/binance-orderbook/specs/control-flows.pw.js @@ -190,7 +190,9 @@ test('starting a ladder preserves action slots and turns only the active action const submissionsAfterStop = state.events .filter((event) => event.type === 'order-submitted').length; await expect(status).toHaveText( - `阶梯开多已停止 · 已挂 ${submissionsAfterStop} 笔 · 已撤 0 笔`, + submissionsAfterStop > 0 + ? `阶梯开多已停止 · 已挂 ${submissionsAfterStop} 笔` + : '阶梯开多已停止', ); expect(submissionsAfterStop).toBeLessThanOrEqual(submissionsBeforeStop + 1); await page.waitForTimeout(700); diff --git a/scripts/binance-coinmarketcap-data.user.js b/scripts/binance-coinmarketcap-data.user.js index af8e28b..57b1786 100644 --- a/scripts/binance-coinmarketcap-data.user.js +++ b/scripts/binance-coinmarketcap-data.user.js @@ -3,7 +3,7 @@ // @namespace binance.coinmarketcap.data // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 0.1.14 +// @version 0.1.15 // @author jackhai9 // @description 在 Binance 合约页面显示当前币种的 CoinMarketCap 中文页关键估值与供应量数据 // @match https://www.binance.com/*/futures/* @@ -30,6 +30,52 @@ return Boolean(parseFuturesTradingSymbolFromPathname(pathname)); } + // src/shared/spa-route-change.js + var ROUTE_CHANGE_EVENT = "jh-userscripts:spa-route-change"; + var ROUTE_PATCH_MARKER = Symbol.for("jh-userscripts.spa-route-change-patched"); + var ROUTE_DISPATCH_STATE = Symbol.for("jh-userscripts.spa-route-change-dispatch"); + function dispatchRouteChange(view) { + const href = view.location.href; + if (view[ROUTE_DISPATCH_STATE]?.href === href) return; + const state = { href }; + view[ROUTE_DISPATCH_STATE] = state; + view.dispatchEvent(new view.Event(ROUTE_CHANGE_EVENT)); + view.queueMicrotask(() => { + if (view[ROUTE_DISPATCH_STATE] === state) delete view[ROUTE_DISPATCH_STATE]; + }); + } + function patchHistoryMethod(view, methodName) { + const current = view.history[methodName]; + if (current[ROUTE_PATCH_MARKER]) return; + function routeAwareHistoryMethod(...args) { + const previousHref = view.location.href; + const result = Reflect.apply(current, this, args); + if (view.location.href !== previousHref) dispatchRouteChange(view); + return result; + } + Object.defineProperty(routeAwareHistoryMethod, ROUTE_PATCH_MARKER, { value: true }); + view.history[methodName] = routeAwareHistoryMethod; + } + function ensureSpaRouteChangePatched(view) { + if (!view?.history) throw new Error("SPA route patch requires a window"); + patchHistoryMethod(view, "pushState"); + patchHistoryMethod(view, "replaceState"); + } + function installSpaRouteChangeListener(view, listener) { + if (!view?.history || typeof listener !== "function") { + throw new Error("SPA route listener requires a window and callback"); + } + ensureSpaRouteChangePatched(view); + view.addEventListener(ROUTE_CHANGE_EVENT, listener); + view.addEventListener("popstate", listener); + view.addEventListener("hashchange", listener); + return () => { + view.removeEventListener(ROUTE_CHANGE_EVENT, listener); + view.removeEventListener("popstate", listener); + view.removeEventListener("hashchange", listener); + }; + } + // src/binance-coinmarketcap-data/index.user.js (function() { "use strict"; @@ -41,7 +87,7 @@ const STORAGE_COLLAPSED_KEY = "jh_binance_cmc_data_collapsed"; const PANEL_WIDTH = 240; const REFRESH_MS = 30 * 1e3; - const SYMBOL_CHECK_MS = 1500; + const ROUTE_WATCHDOG_MS = 5e3; const CMC_BASE = "https://coinmarketcap.com/zh/currencies/"; const CMC_MAP_API = "https://api.coinmarketcap.com/data-api/v1/cryptocurrency/map"; const CMC_DETAIL_API = "https://api.coinmarketcap.com/data-api/v3/cryptocurrency/detail"; @@ -62,8 +108,8 @@ let lastSymbol = null; let lastUpdateTs = 0; let refreshTimer = null; - let symbolTimer = null; let routeTimer = null; + let removeSpaRouteChangeListener = null; let dragCleanup = null; let unloadCleanup = null; let inFlightSymbol = null; @@ -642,31 +688,20 @@ refreshForCurrentSymbol(false, true); }, REFRESH_MS); } - if (!symbolTimer) { - symbolTimer = setInterval(function() { - if (!isFuturesTradingPage()) { - pauseForNonTradingPage(); - return; - } - const symbol = getCurrentSymbol(); - if (symbol && symbol !== lastSymbol && symbol !== inFlightSymbol) { - lastRowsHtml = ""; - refreshForCurrentSymbol(true, false); - } - }, SYMBOL_CHECK_MS); - } } function stopDataLoop() { refreshEpoch++; inFlightSymbol = null; if (refreshTimer) clearInterval(refreshTimer); - if (symbolTimer) clearInterval(symbolTimer); refreshTimer = null; - symbolTimer = null; } function stopRouteWatcher() { if (routeTimer) clearInterval(routeTimer); routeTimer = null; + if (removeSpaRouteChangeListener) { + removeSpaRouteChangeListener(); + removeSpaRouteChangeListener = null; + } } function stopLoop() { stopDataLoop(); @@ -705,9 +740,17 @@ startDataLoop(); } function startRouteWatcher() { - if (routeTimer || document.hidden || panelClosed) return; + if (document.hidden || panelClosed) return; lastPath = location.pathname; - routeTimer = setInterval(handleRouteChange, SYMBOL_CHECK_MS); + if (!removeSpaRouteChangeListener) { + removeSpaRouteChangeListener = installSpaRouteChangeListener(window, handleRouteChange); + } + if (!routeTimer) { + routeTimer = setInterval(function() { + ensureSpaRouteChangePatched(window); + handleRouteChange(); + }, ROUTE_WATCHDOG_MS); + } } function setupDrag(panel) { const header = panel.querySelector("#" + PANEL_ID + "-header"); diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index 0524b2b..32fc174 100644 --- a/scripts/binance-orderbook-trade.user.js +++ b/scripts/binance-orderbook-trade.user.js @@ -3,7 +3,7 @@ // @namespace binance.orderbook.trade // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 2.7.148 +// @version 2.7.149 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -853,7 +853,14 @@ } function formatLadderProgressCounts(progress) { assertLadderProgress(progress); - return `已挂 ${progress.submittedOrders} 笔 · 已撤 ${progress.cancelledOrders} 笔`; + const counts = []; + if (progress.submittedOrders > 0) counts.push(`已挂 ${progress.submittedOrders} 笔`); + if (progress.cancelledOrders > 0) counts.push(`已撤 ${progress.cancelledOrders} 笔`); + return counts; + } + function appendLadderProgressCounts(status, progress) { + const counts = formatLadderProgressCounts(progress); + return counts.length > 0 ? `${status} · ${counts.join(" · ")}` : status; } function createLadderProgress() { return { @@ -871,17 +878,17 @@ } function formatStoppedLadderProgress(label, progress) { assertLadderLabel(label); - return `${label}已停止 · ${formatLadderProgressCounts(progress)}`; + return appendLadderProgressCounts(`${label}已停止`, progress); } function formatInterruptedLadderProgress(label, reason, progress) { assertLadderLabel(label); assertLadderMessage(reason); - return `${label}已中止:${reason} · ${formatLadderProgressCounts(progress)}`; + return appendLadderProgressCounts(`${label}已中止:${reason}`, progress); } function formatFailedLadderProgress(label, message, progress) { assertLadderLabel(label); assertLadderMessage(message); - return `${label}失败:${message} · ${formatLadderProgressCounts(progress)}`; + return appendLadderProgressCounts(`${label}失败:${message}`, progress); } function formatCompletedLadderProgress(label, completedOrders, totalOrders, progress) { assertLadderLabel(label); @@ -1007,6 +1014,52 @@ return Boolean(parseFuturesTradingSymbolFromPathname(pathname)); } + // src/shared/spa-route-change.js + var ROUTE_CHANGE_EVENT = "jh-userscripts:spa-route-change"; + var ROUTE_PATCH_MARKER = Symbol.for("jh-userscripts.spa-route-change-patched"); + var ROUTE_DISPATCH_STATE = Symbol.for("jh-userscripts.spa-route-change-dispatch"); + function dispatchRouteChange(view) { + const href = view.location.href; + if (view[ROUTE_DISPATCH_STATE]?.href === href) return; + const state = { href }; + view[ROUTE_DISPATCH_STATE] = state; + view.dispatchEvent(new view.Event(ROUTE_CHANGE_EVENT)); + view.queueMicrotask(() => { + if (view[ROUTE_DISPATCH_STATE] === state) delete view[ROUTE_DISPATCH_STATE]; + }); + } + function patchHistoryMethod(view, methodName) { + const current = view.history[methodName]; + if (current[ROUTE_PATCH_MARKER]) return; + function routeAwareHistoryMethod(...args) { + const previousHref = view.location.href; + const result = Reflect.apply(current, this, args); + if (view.location.href !== previousHref) dispatchRouteChange(view); + return result; + } + Object.defineProperty(routeAwareHistoryMethod, ROUTE_PATCH_MARKER, { value: true }); + view.history[methodName] = routeAwareHistoryMethod; + } + function ensureSpaRouteChangePatched(view) { + if (!view?.history) throw new Error("SPA route patch requires a window"); + patchHistoryMethod(view, "pushState"); + patchHistoryMethod(view, "replaceState"); + } + function installSpaRouteChangeListener(view, listener) { + if (!view?.history || typeof listener !== "function") { + throw new Error("SPA route listener requires a window and callback"); + } + ensureSpaRouteChangePatched(view); + view.addEventListener(ROUTE_CHANGE_EVENT, listener); + view.addEventListener("popstate", listener); + view.addEventListener("hashchange", listener); + return () => { + view.removeEventListener(ROUTE_CHANGE_EVENT, listener); + view.removeEventListener("popstate", listener); + view.removeEventListener("hashchange", listener); + }; + } + // src/binance-orderbook-trade/dom/account-orders.js function getNormalizedText(el) { return normalizeText(el?.textContent || ""); @@ -2361,7 +2414,6 @@ LOCAL_LADDER_CLOSE_STEP_KEY ]; const LADDER_SUBMIT_ACK_TIMEOUT_MS = 3500; - const LADDER_SUBMIT_POLL_MS = 80; const LADDER_ACTION_FEEDBACK_MIN_MS = 240; const LADDER_REPLACE_OPEN_ORDERS_CLEAR_TIMEOUT_MS = 6500; const LADDER_REPLACE_ROW_SETTLE_MS = 240; @@ -2405,7 +2457,7 @@ "1000" ]; const DEFAULT_OPEN_LEVERAGE = 2; - const AUTO_OPEN_LEVERAGE_DELAY_MS = 120; + const BNC_HEADERS_READY_TIMEOUT_MS = 5e3; const AUTO_OPEN_LEVERAGE_DEDUPE_MS = 1200; const DOM_LOOKUP_CACHE_MS = 250; const INPUT_BORDER_COLOR = "var(--color-InputLine)"; @@ -2430,6 +2482,7 @@ const PANEL_BOTTOM_TOOLTIP_GAP = 12; const TRADE_UI_STATE_TIMEOUT_MS = 1e3; const TRADE_ACTION_BUTTON_READY_TIMEOUT_MS = 2e3; + const ROUTE_WATCHDOG_MS = 5e3; let lastTs = 0; let isEditingMultiplier = false; let multiplierEditContext = null; @@ -2923,6 +2976,10 @@ return findTradeButton(BINANCE_PAGE_TEXT.tradeAction.OPEN_SHORT, "OPEN"); } let cachedBncHeaders = null; + let resolveBncHeadersReady; + const bncHeadersReady = new Promise((resolve) => { + resolveBncHeadersReady = resolve; + }); const HEADER_KEYS_TO_CACHE = [ "csrftoken", "bnc-uuid", @@ -2966,11 +3023,17 @@ } function beginLadderSubmitResponseCapture() { ladderSubmitCaptureSequence += 1; + let resolveRequestStarted; + const requestStarted = new Promise((resolve) => { + resolveRequestStarted = resolve; + }); activeLadderSubmitCapture = { captureId: ladderSubmitCaptureSequence, apiErrors: [], apiSuccesses: [], - responseObservations: [] + responseObservations: [], + requestStarted, + resolveRequestStarted }; return activeLadderSubmitCapture.captureId; } @@ -2993,6 +3056,19 @@ function trackLadderSubmitResponse(request, capture, requestUrl) { const observation = request.then((response) => observeLadderSubmitResponse(response, capture, requestUrl)).catch(() => null); capture.responseObservations.push(observation); + capture.resolveRequestStarted(); + } + function cacheBncHeaders(snapshot) { + const becameReady = !cachedBncHeaders; + cachedBncHeaders = snapshot; + if (!becameReady) return; + resolveBncHeadersReady(); + resolveBncHeadersReady = null; + queueMicrotask(() => { + if (isFuturesTradingPage() && getActiveTradeMode() === "OPEN") { + queueAutoOpenLeveragePositionCheck("headers_ready"); + } + }); } async function waitForLadderSubmitResponseObservations(captureId, timeoutMs) { const capture = activeLadderSubmitCapture?.captureId === captureId ? activeLadderSubmitCapture : null; @@ -3021,7 +3097,7 @@ window.fetch = function(...args) { try { const snapshot = extractHeadersFromFetchArgs(args); - if (snapshot) cachedBncHeaders = snapshot; + if (snapshot) cacheBncHeaders(snapshot); } catch (_e) { } const capture = activeLadderSubmitCapture; @@ -4122,45 +4198,107 @@ } return ""; } + function waitForOrderSubmitStartOrFailureFeedback(submitCaptureId, previousFeedbackSnapshot, timeoutMs) { + const capture = activeLadderSubmitCapture?.captureId === submitCaptureId ? activeLadderSubmitCapture : null; + if (!capture) throw new Error("下单响应捕获上下文丢失"); + return new Promise((resolve) => { + let settled = false; + const finish = (result) => { + if (settled) return; + settled = true; + observer.disconnect(); + window.clearTimeout(timer); + resolve(result); + }; + const readFailure = () => { + const feedback = readNewVisibleOrderFeedbackText(previousFeedbackSnapshot); + if (!feedback) return null; + const acknowledgement = evaluateOrderSubmitAcknowledgement({ + feedback, + isNewFeedback: true + }); + return acknowledgement.status === "failure" ? { status: "failure", message: acknowledgement.message } : null; + }; + const observer = new MutationObserver(() => { + const failure2 = readFailure(); + if (failure2) finish(failure2); + }); + const timer = window.setTimeout(() => finish({ status: "timeout" }), timeoutMs); + observer.observe(document.body, { + subtree: true, + childList: true, + characterData: true + }); + capture.requestStarted.then(() => finish({ status: "request_started" })); + const failure = readFailure(); + if (failure) finish(failure); + }); + } + function waitForOrderFailureFeedback(previousFeedbackSnapshot, timeoutMs) { + return new Promise((resolve) => { + let settled = false; + const finish = (result) => { + if (settled) return; + settled = true; + observer.disconnect(); + window.clearTimeout(timer); + resolve(result); + }; + const readFailure = () => { + const feedback = readNewVisibleOrderFeedbackText(previousFeedbackSnapshot); + if (!feedback) return null; + const acknowledgement = evaluateOrderSubmitAcknowledgement({ + feedback, + isNewFeedback: true + }); + return acknowledgement.status === "failure" ? { status: "failure", message: acknowledgement.message } : null; + }; + const observer = new MutationObserver(() => { + const failure2 = readFailure(); + if (failure2) finish(failure2); + }); + const timer = window.setTimeout(() => finish({ status: "timeout" }), timeoutMs); + observer.observe(document.body, { + subtree: true, + childList: true, + characterData: true + }); + const failure = readFailure(); + if (failure) finish(failure); + }); + } async function waitForOrderSubmitAcknowledgement(button, label, previousFeedbackSnapshot, submitCaptureId, mode) { const startedAt = Date.now(); - let sawBusy = isSubmitButtonBusy(button); - let pendingFailure = null; - while (Date.now() - startedAt < LADDER_SUBMIT_ACK_TIMEOUT_MS) { - const feedback = readNewVisibleOrderFeedbackText(previousFeedbackSnapshot); - const acknowledgement = evaluateOrderSubmitAcknowledgement({ - feedback, - isNewFeedback: Boolean(feedback) - }); - if (acknowledgement.status === "failure" && !pendingFailure) { - pendingFailure = { message: acknowledgement.message }; - } - const capturedApiErrorsNow = readLadderSubmitApiErrors(submitCaptureId); - if (capturedApiErrorsNow.length === 1 && isBinancePostOnlyMakerRejectCode(capturedApiErrorsNow[0].code)) { - throw createLadderSubmitApiError(capturedApiErrorsNow[0].code); - } - if (pendingFailure) { - const remainingAckMs = Math.max(0, LADDER_SUBMIT_ACK_TIMEOUT_MS - (Date.now() - startedAt)); - const capturedApiErrors = await waitForLadderSubmitResponseObservations( - submitCaptureId, - remainingAckMs - ); - if (capturedApiErrors.length === 1 && isBinancePostOnlyMakerRejectCode(capturedApiErrors[0].code)) { - throw createLadderSubmitApiError(capturedApiErrors[0].code); - } - if (capturedApiErrors.length === 0 && mode === "CLOSE" && isPostOnlyMakerRejectionFeedback(pendingFailure.message)) { - throw createLadderMakerPriceConflictError(pendingFailure.message); - } - const capturedCodes = [...new Set(capturedApiErrors.map(({ code }) => code))]; - const diagnostic = capturedCodes.length === 0 ? "未捕获错误码" : `错误码 ${capturedCodes.join(", ")}`; - throw new Error(`${pendingFailure.message}(${diagnostic})`); - } - const capturedApiSuccessesNow = readLadderSubmitApiSuccesses(submitCaptureId); - if (capturedApiSuccessesNow.length === 1) return; - const busy = isSubmitButtonBusy(button); - if (busy) sawBusy = true; - await delay(LADDER_SUBMIT_POLL_MS); + const sawBusy = isSubmitButtonBusy(button); + const activity = await waitForOrderSubmitStartOrFailureFeedback( + submitCaptureId, + previousFeedbackSnapshot, + LADDER_SUBMIT_ACK_TIMEOUT_MS + ); + const remainingAckMs = Math.max(0, LADDER_SUBMIT_ACK_TIMEOUT_MS - (Date.now() - startedAt)); + const capturedApiErrors = activeLadderSubmitCapture.responseObservations.length > 0 ? await waitForLadderSubmitResponseObservations(submitCaptureId, remainingAckMs) : []; + const capturedApiSuccesses = readLadderSubmitApiSuccesses(submitCaptureId); + if (capturedApiErrors.length === 1 && isBinancePostOnlyMakerRejectCode(capturedApiErrors[0].code)) { + throw createLadderSubmitApiError(capturedApiErrors[0].code); + } + let failureActivity = activity.status === "failure" ? activity : null; + if (!failureActivity && capturedApiErrors.length > 0) { + const feedbackWaitMs = Math.max(0, LADDER_SUBMIT_ACK_TIMEOUT_MS - (Date.now() - startedAt)); + failureActivity = await waitForOrderFailureFeedback( + previousFeedbackSnapshot, + feedbackWaitMs + ); + if (failureActivity.status !== "failure") failureActivity = null; + } + if (failureActivity && capturedApiErrors.length === 0 && mode === "CLOSE" && isPostOnlyMakerRejectionFeedback(failureActivity.message)) { + throw createLadderMakerPriceConflictError(failureActivity.message); } + if (failureActivity) { + const capturedCodes = [...new Set(capturedApiErrors.map(({ code }) => code))]; + const diagnostic = capturedCodes.length === 0 ? "未捕获错误码" : `错误码 ${capturedCodes.join(", ")}`; + throw new Error(`${failureActivity.message}(${diagnostic})`); + } + if (capturedApiSuccesses.length === 1) return; const settleHint = sawBusy ? "按钮已恢复但未捕获当前下单 API 成功响应" : "未捕获当前下单 API 成功响应"; throw new Error(`未收到明确${label}成功反馈(${settleHint}),已停止;请核对当前委托/历史成交`); } @@ -6092,10 +6230,10 @@ } async function waitForBncHeaders(symbol) { if (cachedBncHeaders) return true; - for (let i = 0; i < 10; i++) { - await delay(500); - if (cachedBncHeaders || !isCurrentObservedSymbol(symbol)) break; - } + await Promise.race([ + bncHeadersReady, + delay(BNC_HEADERS_READY_TIMEOUT_MS) + ]); return Boolean(cachedBncHeaders && isCurrentObservedSymbol(symbol)); } async function fetchCurrentSymbolPositionState(symbol) { @@ -6124,7 +6262,6 @@ return getActiveTradeMode() === "OPEN" && isCurrentObservedSymbol(symbol); } async function autoResetOpenLeverageToDefault(symbol, triggerSource) { - await delay(AUTO_OPEN_LEVERAGE_DELAY_MS); if (!isStableOpenContext(symbol)) return false; if (!await waitForBncHeaders(symbol)) { log("bapi header 尚未缓存,跳过杠杆重置", symbol); @@ -7488,44 +7625,20 @@ queueAutoOpenLeveragePositionCheck("symbol_change"); } } - let symbolChangeTimer = null; - function startSymbolChangeTimer() { - if (symbolChangeTimer || document.hidden) return; - symbolChangeTimer = window.setInterval(checkSymbolChangeForLeverage, 500); - } - function stopSymbolChangeTimer() { - if (!symbolChangeTimer) return; - window.clearInterval(symbolChangeTimer); - symbolChangeTimer = null; - } - let renderPanelTimer = null; let routeWatcherTimer = null; + let removeSpaRouteChangeListener = null; let routeWasTrading = isFuturesTradingPage(); - function startRenderPanelTimer() { - if (renderPanelTimer || document.hidden || !isFuturesTradingPage()) return; - renderPanelTimer = setInterval(renderPanel, 1e3); - } - function stopRenderPanelTimer() { - if (renderPanelTimer) { - clearInterval(renderPanelTimer); - renderPanelTimer = null; - } - } function startTradingTimers() { if (document.hidden || !isFuturesTradingPage()) return; - startSymbolChangeTimer(); ensureTradeModeTabObserver(); ensureAccountPositionObserver(); ensureOrderbookPrecisionObserver(); - startRenderPanelTimer(); } function stopTradingTimers() { - stopSymbolChangeTimer(); stopTradeModeTabObserver(); stopAccountPositionObserver(); stopOrderbookPrecisionObserver(); clearTradeUiMutationWait(); - stopRenderPanelTimer(); } function syncRouteState() { if (document.hidden) return; @@ -7545,31 +7658,36 @@ } else { checkSymbolChangeForLeverage(); } - const needsRender = !renderPanelTimer || !wasTrading; + const needsRender = !wasTrading || !document.getElementById(PANEL_ID); startTradingTimers(); scheduleChartOrdersRecovery(); - if (needsRender) renderPanel(); + if (needsRender) scheduleRenderPanel(); if (!wasTrading && getActiveTradeMode() === "OPEN") { queueAutoOpenLeveragePositionCheck("route_return"); } } function startRouteWatcher() { - if (routeWatcherTimer || document.hidden) return; - routeWatcherTimer = setInterval(syncRouteState, 1e3); + if (document.hidden) return; + if (!removeSpaRouteChangeListener) { + removeSpaRouteChangeListener = installSpaRouteChangeListener(window, syncRouteState); + } + if (!routeWatcherTimer) { + routeWatcherTimer = setInterval(() => { + ensureSpaRouteChangePatched(window); + syncRouteState(); + if (isFuturesTradingPage()) renderPanel(); + }, ROUTE_WATCHDOG_MS); + } } function stopRouteWatcher() { - if (!routeWatcherTimer) return; - clearInterval(routeWatcherTimer); + if (routeWatcherTimer) clearInterval(routeWatcherTimer); routeWatcherTimer = null; + removeSpaRouteChangeListener?.(); + removeSpaRouteChangeListener = null; } startRouteWatcher(); startTradingTimers(); scheduleChartOrdersRecovery(); - window.setTimeout(() => { - if (isFuturesTradingPage() && getActiveTradeMode() === "OPEN") { - queueAutoOpenLeveragePositionCheck("init"); - } - }, 1500); document.addEventListener("visibilitychange", () => { if (document.hidden) { stopTradingTimers(); diff --git a/scripts/binance-trading-data.user.js b/scripts/binance-trading-data.user.js index fb2d70f..4ec505c 100644 --- a/scripts/binance-trading-data.user.js +++ b/scripts/binance-trading-data.user.js @@ -3,7 +3,7 @@ // @namespace binance.trading.data // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 1.1.12 +// @version 1.1.13 // @author jackhai9 // @description 在合约交易页面叠加浮动面板,定时拉取交易数据(持仓量、多空比、资金费率等)并显示当前值 + 多空信号 // @match https://www.binance.com/*/futures/* @@ -27,6 +27,52 @@ return Boolean(parseFuturesTradingSymbolFromPathname(pathname)); } + // src/shared/spa-route-change.js + var ROUTE_CHANGE_EVENT = "jh-userscripts:spa-route-change"; + var ROUTE_PATCH_MARKER = Symbol.for("jh-userscripts.spa-route-change-patched"); + var ROUTE_DISPATCH_STATE = Symbol.for("jh-userscripts.spa-route-change-dispatch"); + function dispatchRouteChange(view) { + const href = view.location.href; + if (view[ROUTE_DISPATCH_STATE]?.href === href) return; + const state = { href }; + view[ROUTE_DISPATCH_STATE] = state; + view.dispatchEvent(new view.Event(ROUTE_CHANGE_EVENT)); + view.queueMicrotask(() => { + if (view[ROUTE_DISPATCH_STATE] === state) delete view[ROUTE_DISPATCH_STATE]; + }); + } + function patchHistoryMethod(view, methodName) { + const current = view.history[methodName]; + if (current[ROUTE_PATCH_MARKER]) return; + function routeAwareHistoryMethod(...args) { + const previousHref = view.location.href; + const result = Reflect.apply(current, this, args); + if (view.location.href !== previousHref) dispatchRouteChange(view); + return result; + } + Object.defineProperty(routeAwareHistoryMethod, ROUTE_PATCH_MARKER, { value: true }); + view.history[methodName] = routeAwareHistoryMethod; + } + function ensureSpaRouteChangePatched(view) { + if (!view?.history) throw new Error("SPA route patch requires a window"); + patchHistoryMethod(view, "pushState"); + patchHistoryMethod(view, "replaceState"); + } + function installSpaRouteChangeListener(view, listener) { + if (!view?.history || typeof listener !== "function") { + throw new Error("SPA route listener requires a window and callback"); + } + ensureSpaRouteChangePatched(view); + view.addEventListener(ROUTE_CHANGE_EVENT, listener); + view.addEventListener("popstate", listener); + view.addEventListener("hashchange", listener); + return () => { + view.removeEventListener(ROUTE_CHANGE_EVENT, listener); + view.removeEventListener("popstate", listener); + view.removeEventListener("hashchange", listener); + }; + } + // src/binance-trading-data/index.user.js (function() { "use strict"; @@ -43,6 +89,7 @@ const FIRST_DELAY = 5e3; const RETRY_DELAYS = [1e4, 15e3, 2e4]; const RETRY_FALLBACK = 3e4; + const ROUTE_WATCHDOG_MS = 5e3; const DEFAULT_PERIOD = "5m"; const DATA_LIMIT = 30; const OI_TREND_PERIODS = 6; @@ -664,6 +711,7 @@ let cycleTimer = null; let retryTimer = null; let pathTimer = null; + let removeSpaRouteChangeListener = null; let agoTimer = null; let serverTimeTimer = null; let dragCleanup = null; @@ -827,6 +875,10 @@ clearInterval(pathTimer); pathTimer = null; } + if (removeSpaRouteChangeListener) { + removeSpaRouteChangeListener(); + removeSpaRouteChangeListener = null; + } } function stopLoop() { epoch++; @@ -878,9 +930,17 @@ activateTradingPage(); } function startRouteWatcher() { - if (pathTimer || document.hidden || panelClosed) return; + if (document.hidden || panelClosed) return; lastPath = location.pathname; - pathTimer = setInterval(handlePathChange, 1e3); + if (!removeSpaRouteChangeListener) { + removeSpaRouteChangeListener = installSpaRouteChangeListener(window, handlePathChange); + } + if (!pathTimer) { + pathTimer = setInterval(function() { + ensureSpaRouteChangePatched(window); + handlePathChange(); + }, ROUTE_WATCHDOG_MS); + } } function start() { log("脚本启动"); diff --git a/scripts/m3u8-downloader.user.js b/scripts/m3u8-downloader.user.js index 41b737b..b485bde 100644 --- a/scripts/m3u8-downloader.user.js +++ b/scripts/m3u8-downloader.user.js @@ -3,7 +3,7 @@ // @namespace https://github.com/jackhai9/userscripts // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 0.10.36 +// @version 0.10.37 // @description m3u8 下载增强脚本,仅在白名单视频站启用,避免误伤交易页等重前端应用 // @author jackhai9 // @include https://18jav.tv/* @@ -28,7 +28,7 @@ }; // src/m3u8-downloader/constants.js - var M3U8_MESSAGE_TYPE, BROOKS_MEDIA_INDEX_MESSAGE_TYPE, BROOKS_MEDIA_INDEX_STATE_KEY, BROOKS_MEDIA_EXPORT_SCHEMA_VERSION, BROOKS_MEDIA_EXPORT_TIMEOUT_MS, BROOKS_MEDIA_EXPORT_STEP_DELAY_MS, BROOKS_MEDIA_EXPORT_STATUS_INTERVAL_MS, EXTERNAL_DOWNLOADER_BLOCKED_HOST_SUFFIXES; + var M3U8_MESSAGE_TYPE, BROOKS_MEDIA_INDEX_MESSAGE_TYPE, BROOKS_MEDIA_INDEX_STATE_KEY, BROOKS_MEDIA_EXPORT_SCHEMA_VERSION, BROOKS_MEDIA_EXPORT_TIMEOUT_MS, BROOKS_MEDIA_EXPORT_STATUS_INTERVAL_MS, EXTERNAL_DOWNLOADER_BLOCKED_HOST_SUFFIXES; var init_constants = __esm({ "src/m3u8-downloader/constants.js"() { M3U8_MESSAGE_TYPE = "jh-userscripts:m3u8-detected"; @@ -36,7 +36,6 @@ BROOKS_MEDIA_INDEX_STATE_KEY = "jh-userscripts:brooks-media-index-export"; BROOKS_MEDIA_EXPORT_SCHEMA_VERSION = 2; BROOKS_MEDIA_EXPORT_TIMEOUT_MS = 45e3; - BROOKS_MEDIA_EXPORT_STEP_DELAY_MS = 500; BROOKS_MEDIA_EXPORT_STATUS_INTERVAL_MS = 1e3; EXTERNAL_DOWNLOADER_BLOCKED_HOST_SUFFIXES = [ ".b-cdn.net", @@ -413,6 +412,9 @@ var brooksMediaExportState = null; var brooksMediaExportFrame = null; var brooksMediaExportPending = null; + function scheduleNextBrooksMediaExport() { + queueMicrotask(processNextBrooksMediaExport); + } function notifyBrooksMediaIndexDetected(url, referer) { if (!isBrooksHost(location.hostname)) { return; @@ -449,7 +451,7 @@ saveBrooksMediaExportState(); clearBrooksMediaExportFrame(); updateBrooksMediaExportStatus(); - setTimeout(processNextBrooksMediaExport, BROOKS_MEDIA_EXPORT_STEP_DELAY_MS); + scheduleNextBrooksMediaExport(); } function isBrooksMediaExportFrameMessage(event, data) { return !!(brooksMediaExportPending && brooksMediaExportFrame && event.source === brooksMediaExportFrame.contentWindow && data && data.brooksExport && data.brooksExport.pageUrl && isSameBrooksVideoPage(data.brooksExport.pageUrl, brooksMediaExportPending.url)); @@ -764,7 +766,7 @@ saveBrooksMediaExportState(); clearBrooksMediaExportFrame(); updateBrooksMediaExportStatus(); - setTimeout(processNextBrooksMediaExport, BROOKS_MEDIA_EXPORT_STEP_DELAY_MS); + scheduleNextBrooksMediaExport(); } function appendBrooksMediaExporterDom() { if (!isBrooksCourseIndexPage() || document.getElementById("brooks-media-export-dom") || !document.body) { diff --git a/src/binance-coinmarketcap-data/index.user.js b/src/binance-coinmarketcap-data/index.user.js index c32fad7..e5170e1 100644 --- a/src/binance-coinmarketcap-data/index.user.js +++ b/src/binance-coinmarketcap-data/index.user.js @@ -3,7 +3,7 @@ // @namespace binance.coinmarketcap.data // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 0.1.14 +// @version 0.1.15 // @author jackhai9 // @description 在 Binance 合约页面显示当前币种的 CoinMarketCap 中文页关键估值与供应量数据 // @match https://www.binance.com/*/futures/* @@ -23,6 +23,10 @@ import { isFuturesTradingPathname, parseFuturesTradingSymbolFromPathname, } from '../shared/binance-futures-route.js'; +import { + ensureSpaRouteChangePatched, + installSpaRouteChangeListener, +} from '../shared/spa-route-change.js'; (function () { 'use strict'; @@ -36,7 +40,7 @@ import { const STORAGE_COLLAPSED_KEY = 'jh_binance_cmc_data_collapsed'; const PANEL_WIDTH = 240; const REFRESH_MS = 30 * 1000; - const SYMBOL_CHECK_MS = 1_500; + const ROUTE_WATCHDOG_MS = 5_000; const CMC_BASE = 'https://coinmarketcap.com/zh/currencies/'; const CMC_MAP_API = 'https://api.coinmarketcap.com/data-api/v1/cryptocurrency/map'; const CMC_DETAIL_API = 'https://api.coinmarketcap.com/data-api/v3/cryptocurrency/detail'; @@ -60,8 +64,8 @@ import { let lastSymbol = null; let lastUpdateTs = 0; let refreshTimer = null; - let symbolTimer = null; let routeTimer = null; + let removeSpaRouteChangeListener = null; let dragCleanup = null; let unloadCleanup = null; let inFlightSymbol = null; @@ -656,33 +660,22 @@ import { refreshForCurrentSymbol(false, true); }, REFRESH_MS); } - if (!symbolTimer) { - symbolTimer = setInterval(function () { - if (!isFuturesTradingPage()) { - pauseForNonTradingPage(); - return; - } - const symbol = getCurrentSymbol(); - if (symbol && symbol !== lastSymbol && symbol !== inFlightSymbol) { - lastRowsHtml = ''; - refreshForCurrentSymbol(true, false); - } - }, SYMBOL_CHECK_MS); - } } function stopDataLoop() { refreshEpoch++; inFlightSymbol = null; if (refreshTimer) clearInterval(refreshTimer); - if (symbolTimer) clearInterval(symbolTimer); refreshTimer = null; - symbolTimer = null; } function stopRouteWatcher() { if (routeTimer) clearInterval(routeTimer); routeTimer = null; + if (removeSpaRouteChangeListener) { + removeSpaRouteChangeListener(); + removeSpaRouteChangeListener = null; + } } function stopLoop() { @@ -728,9 +721,17 @@ import { } function startRouteWatcher() { - if (routeTimer || document.hidden || panelClosed) return; + if (document.hidden || panelClosed) return; lastPath = location.pathname; - routeTimer = setInterval(handleRouteChange, SYMBOL_CHECK_MS); + if (!removeSpaRouteChangeListener) { + removeSpaRouteChangeListener = installSpaRouteChangeListener(window, handleRouteChange); + } + if (!routeTimer) { + routeTimer = setInterval(function () { + ensureSpaRouteChangePatched(window); + handleRouteChange(); + }, ROUTE_WATCHDOG_MS); + } } function setupDrag(panel) { diff --git a/src/binance-orderbook-trade/core/ladder-progress.js b/src/binance-orderbook-trade/core/ladder-progress.js index de9fa1e..ceb2ef4 100644 --- a/src/binance-orderbook-trade/core/ladder-progress.js +++ b/src/binance-orderbook-trade/core/ladder-progress.js @@ -24,7 +24,15 @@ function assertLadderMessage(message) { function formatLadderProgressCounts(progress) { assertLadderProgress(progress); - return `已挂 ${progress.submittedOrders} 笔 · 已撤 ${progress.cancelledOrders} 笔`; + const counts = []; + if (progress.submittedOrders > 0) counts.push(`已挂 ${progress.submittedOrders} 笔`); + if (progress.cancelledOrders > 0) counts.push(`已撤 ${progress.cancelledOrders} 笔`); + return counts; +} + +function appendLadderProgressCounts(status, progress) { + const counts = formatLadderProgressCounts(progress); + return counts.length > 0 ? `${status} · ${counts.join(' · ')}` : status; } /** @@ -49,19 +57,19 @@ export function recordLadderCancelledOrder(progress) { export function formatStoppedLadderProgress(label, progress) { assertLadderLabel(label); - return `${label}已停止 · ${formatLadderProgressCounts(progress)}`; + return appendLadderProgressCounts(`${label}已停止`, progress); } export function formatInterruptedLadderProgress(label, reason, progress) { assertLadderLabel(label); assertLadderMessage(reason); - return `${label}已中止:${reason} · ${formatLadderProgressCounts(progress)}`; + return appendLadderProgressCounts(`${label}已中止:${reason}`, progress); } export function formatFailedLadderProgress(label, message, progress) { assertLadderLabel(label); assertLadderMessage(message); - return `${label}失败:${message} · ${formatLadderProgressCounts(progress)}`; + return appendLadderProgressCounts(`${label}失败:${message}`, progress); } export function formatCompletedLadderProgress(label, completedOrders, totalOrders, progress) { diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index 8830e48..1c21fbd 100644 --- a/src/binance-orderbook-trade/index.user.js +++ b/src/binance-orderbook-trade/index.user.js @@ -3,7 +3,7 @@ // @namespace binance.orderbook.trade // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 2.7.148 +// @version 2.7.149 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -105,6 +105,10 @@ import { isFuturesTradingPathname, parseFuturesTradingSymbolFromPathname, } from '../shared/binance-futures-route.js'; +import { + ensureSpaRouteChangePatched, + installSpaRouteChangeListener, +} from '../shared/spa-route-change.js'; import { createAccountOrdersMutationSignal, findAccountOrdersTabByIdentity as findAccountOrdersTabByIdentityDom, @@ -265,7 +269,6 @@ import { LOCAL_LADDER_CLOSE_STEP_KEY, ]; const LADDER_SUBMIT_ACK_TIMEOUT_MS = 3500; - const LADDER_SUBMIT_POLL_MS = 80; const LADDER_ACTION_FEEDBACK_MIN_MS = 240; const LADDER_REPLACE_OPEN_ORDERS_CLEAR_TIMEOUT_MS = 6500; const LADDER_REPLACE_ROW_SETTLE_MS = 240; @@ -309,7 +312,7 @@ import { '1000', ]; const DEFAULT_OPEN_LEVERAGE = 2; - const AUTO_OPEN_LEVERAGE_DELAY_MS = 120; + const BNC_HEADERS_READY_TIMEOUT_MS = 5000; const AUTO_OPEN_LEVERAGE_DEDUPE_MS = 1200; const DOM_LOOKUP_CACHE_MS = 250; const INPUT_BORDER_COLOR = 'var(--color-InputLine)'; @@ -334,6 +337,7 @@ import { const PANEL_BOTTOM_TOOLTIP_GAP = 12; const TRADE_UI_STATE_TIMEOUT_MS = 1000; const TRADE_ACTION_BUTTON_READY_TIMEOUT_MS = 2000; + const ROUTE_WATCHDOG_MS = 5000; let lastTs = 0; let isEditingMultiplier = false; @@ -963,6 +967,10 @@ import { // 策略:拦截 window.fetch,从 Binance 自身发出的 bapi 请求里缓存 header, // 后续 adjustLeverageApi 复用这些 header。 let cachedBncHeaders = null; + let resolveBncHeadersReady; + const bncHeadersReady = new Promise((resolve) => { + resolveBncHeadersReady = resolve; + }); const HEADER_KEYS_TO_CACHE = [ 'csrftoken', 'bnc-uuid', 'device-info', 'fvideo-id', 'clienttype', 'x-passthrough-token', @@ -1023,11 +1031,17 @@ import { function beginLadderSubmitResponseCapture() { ladderSubmitCaptureSequence += 1; + let resolveRequestStarted; + const requestStarted = new Promise((resolve) => { + resolveRequestStarted = resolve; + }); activeLadderSubmitCapture = { captureId: ladderSubmitCaptureSequence, apiErrors: [], apiSuccesses: [], responseObservations: [], + requestStarted, + resolveRequestStarted, }; return activeLadderSubmitCapture.captureId; } @@ -1055,6 +1069,20 @@ import { .then((response) => observeLadderSubmitResponse(response, capture, requestUrl)) .catch(() => null); capture.responseObservations.push(observation); + capture.resolveRequestStarted(); + } + + function cacheBncHeaders(snapshot) { + const becameReady = !cachedBncHeaders; + cachedBncHeaders = snapshot; + if (!becameReady) return; + resolveBncHeadersReady(); + resolveBncHeadersReady = null; + queueMicrotask(() => { + if (isFuturesTradingPage() && getActiveTradeMode() === 'OPEN') { + queueAutoOpenLeveragePositionCheck('headers_ready'); + } + }); } async function waitForLadderSubmitResponseObservations(captureId, timeoutMs) { @@ -1093,7 +1121,7 @@ import { window.fetch = function (...args) { try { const snapshot = extractHeadersFromFetchArgs(args); - if (snapshot) cachedBncHeaders = snapshot; + if (snapshot) cacheBncHeaders(snapshot); } catch (_e) { /* 不干扰原始请求 */ } const capture = activeLadderSubmitCapture; const requestUrl = getFetchRequestUrl(args); @@ -2435,60 +2463,133 @@ import { return ''; } - async function waitForOrderSubmitAcknowledgement(button, label, previousFeedbackSnapshot, submitCaptureId, mode) { - const startedAt = Date.now(); - let sawBusy = isSubmitButtonBusy(button); - let pendingFailure = null; - - while (Date.now() - startedAt < LADDER_SUBMIT_ACK_TIMEOUT_MS) { - const feedback = readNewVisibleOrderFeedbackText(previousFeedbackSnapshot); - const acknowledgement = evaluateOrderSubmitAcknowledgement({ - feedback, - isNewFeedback: Boolean(feedback), + function waitForOrderSubmitStartOrFailureFeedback( + submitCaptureId, + previousFeedbackSnapshot, + timeoutMs, + ) { + const capture = activeLadderSubmitCapture?.captureId === submitCaptureId + ? activeLadderSubmitCapture + : null; + if (!capture) throw new Error('下单响应捕获上下文丢失'); + + return new Promise((resolve) => { + let settled = false; + const finish = (result) => { + if (settled) return; + settled = true; + observer.disconnect(); + window.clearTimeout(timer); + resolve(result); + }; + const readFailure = () => { + const feedback = readNewVisibleOrderFeedbackText(previousFeedbackSnapshot); + if (!feedback) return null; + const acknowledgement = evaluateOrderSubmitAcknowledgement({ + feedback, + isNewFeedback: true, + }); + return acknowledgement.status === 'failure' + ? { status: 'failure', message: acknowledgement.message } + : null; + }; + const observer = new MutationObserver(() => { + const failure = readFailure(); + if (failure) finish(failure); }); - if (acknowledgement.status === 'failure' && !pendingFailure) { - pendingFailure = { message: acknowledgement.message }; - } - const capturedApiErrorsNow = readLadderSubmitApiErrors(submitCaptureId); - if ( - capturedApiErrorsNow.length === 1 - && isBinancePostOnlyMakerRejectCode(capturedApiErrorsNow[0].code) - ) { - throw createLadderSubmitApiError(capturedApiErrorsNow[0].code); - } - if (pendingFailure) { - const remainingAckMs = Math.max(0, LADDER_SUBMIT_ACK_TIMEOUT_MS - (Date.now() - startedAt)); - const capturedApiErrors = await waitForLadderSubmitResponseObservations( - submitCaptureId, - remainingAckMs, - ); - if ( - capturedApiErrors.length === 1 - && isBinancePostOnlyMakerRejectCode(capturedApiErrors[0].code) - ) { - throw createLadderSubmitApiError(capturedApiErrors[0].code); - } - if ( - capturedApiErrors.length === 0 - && mode === 'CLOSE' - && isPostOnlyMakerRejectionFeedback(pendingFailure.message) - ) { - throw createLadderMakerPriceConflictError(pendingFailure.message); - } - const capturedCodes = [...new Set(capturedApiErrors.map(({ code }) => code))]; - const diagnostic = capturedCodes.length === 0 - ? '未捕获错误码' - : `错误码 ${capturedCodes.join(', ')}`; - throw new Error(`${pendingFailure.message}(${diagnostic})`); - } - const capturedApiSuccessesNow = readLadderSubmitApiSuccesses(submitCaptureId); - if (capturedApiSuccessesNow.length === 1) return; + const timer = window.setTimeout(() => finish({ status: 'timeout' }), timeoutMs); + observer.observe(document.body, { + subtree: true, + childList: true, + characterData: true, + }); + capture.requestStarted.then(() => finish({ status: 'request_started' })); + const failure = readFailure(); + if (failure) finish(failure); + }); + } + + function waitForOrderFailureFeedback(previousFeedbackSnapshot, timeoutMs) { + return new Promise((resolve) => { + let settled = false; + const finish = (result) => { + if (settled) return; + settled = true; + observer.disconnect(); + window.clearTimeout(timer); + resolve(result); + }; + const readFailure = () => { + const feedback = readNewVisibleOrderFeedbackText(previousFeedbackSnapshot); + if (!feedback) return null; + const acknowledgement = evaluateOrderSubmitAcknowledgement({ + feedback, + isNewFeedback: true, + }); + return acknowledgement.status === 'failure' + ? { status: 'failure', message: acknowledgement.message } + : null; + }; + const observer = new MutationObserver(() => { + const failure = readFailure(); + if (failure) finish(failure); + }); + const timer = window.setTimeout(() => finish({ status: 'timeout' }), timeoutMs); + observer.observe(document.body, { + subtree: true, + childList: true, + characterData: true, + }); + const failure = readFailure(); + if (failure) finish(failure); + }); + } - const busy = isSubmitButtonBusy(button); - if (busy) sawBusy = true; + async function waitForOrderSubmitAcknowledgement(button, label, previousFeedbackSnapshot, submitCaptureId, mode) { + const startedAt = Date.now(); + const sawBusy = isSubmitButtonBusy(button); + const activity = await waitForOrderSubmitStartOrFailureFeedback( + submitCaptureId, + previousFeedbackSnapshot, + LADDER_SUBMIT_ACK_TIMEOUT_MS, + ); + const remainingAckMs = Math.max(0, LADDER_SUBMIT_ACK_TIMEOUT_MS - (Date.now() - startedAt)); + const capturedApiErrors = activeLadderSubmitCapture.responseObservations.length > 0 + ? await waitForLadderSubmitResponseObservations(submitCaptureId, remainingAckMs) + : []; + const capturedApiSuccesses = readLadderSubmitApiSuccesses(submitCaptureId); - await delay(LADDER_SUBMIT_POLL_MS); + if ( + capturedApiErrors.length === 1 + && isBinancePostOnlyMakerRejectCode(capturedApiErrors[0].code) + ) { + throw createLadderSubmitApiError(capturedApiErrors[0].code); + } + let failureActivity = activity.status === 'failure' ? activity : null; + if (!failureActivity && capturedApiErrors.length > 0) { + const feedbackWaitMs = Math.max(0, LADDER_SUBMIT_ACK_TIMEOUT_MS - (Date.now() - startedAt)); + failureActivity = await waitForOrderFailureFeedback( + previousFeedbackSnapshot, + feedbackWaitMs, + ); + if (failureActivity.status !== 'failure') failureActivity = null; + } + if ( + failureActivity + && capturedApiErrors.length === 0 + && mode === 'CLOSE' + && isPostOnlyMakerRejectionFeedback(failureActivity.message) + ) { + throw createLadderMakerPriceConflictError(failureActivity.message); } + if (failureActivity) { + const capturedCodes = [...new Set(capturedApiErrors.map(({ code }) => code))]; + const diagnostic = capturedCodes.length === 0 + ? '未捕获错误码' + : `错误码 ${capturedCodes.join(', ')}`; + throw new Error(`${failureActivity.message}(${diagnostic})`); + } + if (capturedApiSuccesses.length === 1) return; const settleHint = sawBusy ? '按钮已恢复但未捕获当前下单 API 成功响应' : '未捕获当前下单 API 成功响应'; throw new Error(`未收到明确${label}成功反馈(${settleHint}),已停止;请核对当前委托/历史成交`); @@ -4724,10 +4825,10 @@ import { async function waitForBncHeaders(symbol) { if (cachedBncHeaders) return true; - for (let i = 0; i < 10; i++) { - await delay(500); - if (cachedBncHeaders || !isCurrentObservedSymbol(symbol)) break; - } + await Promise.race([ + bncHeadersReady, + delay(BNC_HEADERS_READY_TIMEOUT_MS), + ]); return Boolean(cachedBncHeaders && isCurrentObservedSymbol(symbol)); } @@ -4759,7 +4860,6 @@ import { } async function autoResetOpenLeverageToDefault(symbol, triggerSource) { - await delay(AUTO_OPEN_LEVERAGE_DELAY_MS); if (!isStableOpenContext(symbol)) return false; if (!await waitForBncHeaders(symbol)) { @@ -6335,51 +6435,22 @@ import { } } - let symbolChangeTimer = null; - function startSymbolChangeTimer() { - if (symbolChangeTimer || document.hidden) return; - // Binance SPA 切换币种时 URL 变化但不触发 popstate,用前台轮询检测。 - symbolChangeTimer = window.setInterval(checkSymbolChangeForLeverage, 500); - } - - function stopSymbolChangeTimer() { - if (!symbolChangeTimer) return; - window.clearInterval(symbolChangeTimer); - symbolChangeTimer = null; - } - - let renderPanelTimer = null; let routeWatcherTimer = null; + let removeSpaRouteChangeListener = null; let routeWasTrading = isFuturesTradingPage(); - function startRenderPanelTimer() { - if (renderPanelTimer || document.hidden || !isFuturesTradingPage()) return; - renderPanelTimer = setInterval(renderPanel, 1000); - } - - function stopRenderPanelTimer() { - if (renderPanelTimer) { - clearInterval(renderPanelTimer); - renderPanelTimer = null; - } - } - function startTradingTimers() { if (document.hidden || !isFuturesTradingPage()) return; - startSymbolChangeTimer(); ensureTradeModeTabObserver(); ensureAccountPositionObserver(); ensureOrderbookPrecisionObserver(); - startRenderPanelTimer(); } function stopTradingTimers() { - stopSymbolChangeTimer(); stopTradeModeTabObserver(); stopAccountPositionObserver(); stopOrderbookPrecisionObserver(); clearTradeUiMutationWait(); - stopRenderPanelTimer(); } function syncRouteState() { @@ -6402,36 +6473,40 @@ import { checkSymbolChangeForLeverage(); } - const needsRender = !renderPanelTimer || !wasTrading; + const needsRender = !wasTrading || !document.getElementById(PANEL_ID); startTradingTimers(); scheduleChartOrdersRecovery(); - if (needsRender) renderPanel(); + if (needsRender) scheduleRenderPanel(); if (!wasTrading && getActiveTradeMode() === 'OPEN') { queueAutoOpenLeveragePositionCheck('route_return'); } } function startRouteWatcher() { - if (routeWatcherTimer || document.hidden) return; - routeWatcherTimer = setInterval(syncRouteState, 1000); + if (document.hidden) return; + if (!removeSpaRouteChangeListener) { + removeSpaRouteChangeListener = installSpaRouteChangeListener(window, syncRouteState); + } + if (!routeWatcherTimer) { + routeWatcherTimer = setInterval(() => { + ensureSpaRouteChangePatched(window); + syncRouteState(); + if (isFuturesTradingPage()) renderPanel(); + }, ROUTE_WATCHDOG_MS); + } } function stopRouteWatcher() { - if (!routeWatcherTimer) return; - clearInterval(routeWatcherTimer); + if (routeWatcherTimer) clearInterval(routeWatcherTimer); routeWatcherTimer = null; + removeSpaRouteChangeListener?.(); + removeSpaRouteChangeListener = null; } startRouteWatcher(); startTradingTimers(); scheduleChartOrdersRecovery(); - window.setTimeout(() => { - if (isFuturesTradingPage() && getActiveTradeMode() === 'OPEN') { - queueAutoOpenLeveragePositionCheck('init'); - } - }, 1500); - document.addEventListener('visibilitychange', () => { if (document.hidden) { stopTradingTimers(); diff --git a/src/binance-trading-data/index.user.js b/src/binance-trading-data/index.user.js index 111cd26..ffa0dd6 100644 --- a/src/binance-trading-data/index.user.js +++ b/src/binance-trading-data/index.user.js @@ -3,7 +3,7 @@ // @namespace binance.trading.data // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 1.1.12 +// @version 1.1.13 // @author jackhai9 // @description 在合约交易页面叠加浮动面板,定时拉取交易数据(持仓量、多空比、资金费率等)并显示当前值 + 多空信号 // @match https://www.binance.com/*/futures/* @@ -20,6 +20,10 @@ import { isFuturesTradingPathname, parseFuturesTradingSymbolFromPathname, } from '../shared/binance-futures-route.js'; +import { + ensureSpaRouteChangePatched, + installSpaRouteChangeListener, +} from '../shared/spa-route-change.js'; (function () { 'use strict'; @@ -41,6 +45,7 @@ import { const FIRST_DELAY = 5_000; // 周期边界后首次等待 5s const RETRY_DELAYS = [10_000, 15_000, 20_000]; // 后续重试间隔,用完后按 30s 循环 const RETRY_FALLBACK = 30_000; + const ROUTE_WATCHDOG_MS = 5_000; const DEFAULT_PERIOD = '5m'; const DATA_LIMIT = 30; const OI_TREND_PERIODS = 6; @@ -722,6 +727,7 @@ import { let cycleTimer = null; let retryTimer = null; let pathTimer = null; + let removeSpaRouteChangeListener = null; let agoTimer = null; let serverTimeTimer = null; let dragCleanup = null; @@ -903,6 +909,10 @@ import { function stopRouteWatcher() { if (pathTimer) { clearInterval(pathTimer); pathTimer = null; } + if (removeSpaRouteChangeListener) { + removeSpaRouteChangeListener(); + removeSpaRouteChangeListener = null; + } } function stopLoop() { @@ -962,9 +972,17 @@ import { } function startRouteWatcher() { - if (pathTimer || document.hidden || panelClosed) return; + if (document.hidden || panelClosed) return; lastPath = location.pathname; - pathTimer = setInterval(handlePathChange, 1000); + if (!removeSpaRouteChangeListener) { + removeSpaRouteChangeListener = installSpaRouteChangeListener(window, handlePathChange); + } + if (!pathTimer) { + pathTimer = setInterval(function () { + ensureSpaRouteChangePatched(window); + handlePathChange(); + }, ROUTE_WATCHDOG_MS); + } } function start() { diff --git a/src/m3u8-downloader/brooks-exporter.js b/src/m3u8-downloader/brooks-exporter.js index c128563..b7c3117 100644 --- a/src/m3u8-downloader/brooks-exporter.js +++ b/src/m3u8-downloader/brooks-exporter.js @@ -1,7 +1,6 @@ import { BROOKS_MEDIA_EXPORT_SCHEMA_VERSION, BROOKS_MEDIA_EXPORT_STATUS_INTERVAL_MS, - BROOKS_MEDIA_EXPORT_STEP_DELAY_MS, BROOKS_MEDIA_EXPORT_TIMEOUT_MS, BROOKS_MEDIA_INDEX_MESSAGE_TYPE, BROOKS_MEDIA_INDEX_STATE_KEY, @@ -38,6 +37,10 @@ export function createBrooksMediaExporter({ originXHR, downloadWithA, getTitle } var brooksMediaExportFrame = null var brooksMediaExportPending = null + function scheduleNextBrooksMediaExport() { + queueMicrotask(processNextBrooksMediaExport) + } + function notifyBrooksMediaIndexDetected(url, referer) { if (!isBrooksHost(location.hostname)) { return @@ -75,7 +78,7 @@ export function createBrooksMediaExporter({ originXHR, downloadWithA, getTitle } saveBrooksMediaExportState() clearBrooksMediaExportFrame() updateBrooksMediaExportStatus() - setTimeout(processNextBrooksMediaExport, BROOKS_MEDIA_EXPORT_STEP_DELAY_MS) + scheduleNextBrooksMediaExport() } function isBrooksMediaExportFrameMessage(event, data) { @@ -431,7 +434,7 @@ export function createBrooksMediaExporter({ originXHR, downloadWithA, getTitle } saveBrooksMediaExportState() clearBrooksMediaExportFrame() updateBrooksMediaExportStatus() - setTimeout(processNextBrooksMediaExport, BROOKS_MEDIA_EXPORT_STEP_DELAY_MS) + scheduleNextBrooksMediaExport() } function appendBrooksMediaExporterDom() { diff --git a/src/m3u8-downloader/constants.js b/src/m3u8-downloader/constants.js index 396358a..4b0b470 100644 --- a/src/m3u8-downloader/constants.js +++ b/src/m3u8-downloader/constants.js @@ -3,7 +3,6 @@ export const BROOKS_MEDIA_INDEX_MESSAGE_TYPE = 'jh-userscripts:brooks-media-inde export const BROOKS_MEDIA_INDEX_STATE_KEY = 'jh-userscripts:brooks-media-index-export' export const BROOKS_MEDIA_EXPORT_SCHEMA_VERSION = 2 export const BROOKS_MEDIA_EXPORT_TIMEOUT_MS = 45000 -export const BROOKS_MEDIA_EXPORT_STEP_DELAY_MS = 500 export const BROOKS_MEDIA_EXPORT_STATUS_INTERVAL_MS = 1000 export const EXTERNAL_DOWNLOADER_BLOCKED_HOST_SUFFIXES = [ '.b-cdn.net', diff --git a/src/m3u8-downloader/index.user.js b/src/m3u8-downloader/index.user.js index 0f7a715..cda8cc5 100644 --- a/src/m3u8-downloader/index.user.js +++ b/src/m3u8-downloader/index.user.js @@ -3,7 +3,7 @@ // @namespace https://github.com/jackhai9/userscripts // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 0.10.36 +// @version 0.10.37 // @description m3u8 下载增强脚本,仅在白名单视频站启用,避免误伤交易页等重前端应用 // @author jackhai9 // @include https://18jav.tv/* diff --git a/src/shared/spa-route-change.js b/src/shared/spa-route-change.js new file mode 100644 index 0000000..7899d29 --- /dev/null +++ b/src/shared/spa-route-change.js @@ -0,0 +1,51 @@ +const ROUTE_CHANGE_EVENT = 'jh-userscripts:spa-route-change'; +const ROUTE_PATCH_MARKER = Symbol.for('jh-userscripts.spa-route-change-patched'); +const ROUTE_DISPATCH_STATE = Symbol.for('jh-userscripts.spa-route-change-dispatch'); + +function dispatchRouteChange(view) { + const href = view.location.href; + if (view[ROUTE_DISPATCH_STATE]?.href === href) return; + const state = { href }; + view[ROUTE_DISPATCH_STATE] = state; + view.dispatchEvent(new view.Event(ROUTE_CHANGE_EVENT)); + view.queueMicrotask(() => { + if (view[ROUTE_DISPATCH_STATE] === state) delete view[ROUTE_DISPATCH_STATE]; + }); +} + +function patchHistoryMethod(view, methodName) { + const current = view.history[methodName]; + if (current[ROUTE_PATCH_MARKER]) return; + + function routeAwareHistoryMethod(...args) { + const previousHref = view.location.href; + const result = Reflect.apply(current, this, args); + if (view.location.href !== previousHref) dispatchRouteChange(view); + return result; + } + Object.defineProperty(routeAwareHistoryMethod, ROUTE_PATCH_MARKER, { value: true }); + view.history[methodName] = routeAwareHistoryMethod; +} + +export function ensureSpaRouteChangePatched(view) { + if (!view?.history) throw new Error('SPA route patch requires a window'); + patchHistoryMethod(view, 'pushState'); + patchHistoryMethod(view, 'replaceState'); +} + +export function installSpaRouteChangeListener(view, listener) { + if (!view?.history || typeof listener !== 'function') { + throw new Error('SPA route listener requires a window and callback'); + } + + ensureSpaRouteChangePatched(view); + view.addEventListener(ROUTE_CHANGE_EVENT, listener); + view.addEventListener('popstate', listener); + view.addEventListener('hashchange', listener); + + return () => { + view.removeEventListener(ROUTE_CHANGE_EVENT, listener); + view.removeEventListener('popstate', listener); + view.removeEventListener('hashchange', listener); + }; +} diff --git a/test/unit/binance-data-panel-lifecycle-regressions.test.js b/test/unit/binance-data-panel-lifecycle-regressions.test.js index f56563d..b37a618 100644 --- a/test/unit/binance-data-panel-lifecycle-regressions.test.js +++ b/test/unit/binance-data-panel-lifecycle-regressions.test.js @@ -71,7 +71,10 @@ test('CMC data panel stops business timers on non-trading routes but keeps a rou const startRouteBody = readFunctionBody(sources.cmc, 'startRouteWatcher'); assert.match(pauseBody, /stopDataLoop\(\)/); assert.doesNotMatch(pauseBody, /stopRouteWatcher\(\)/); + assert.match(startRouteBody, /installSpaRouteChangeListener/); assert.match(startRouteBody, /setInterval/); + assert.match(startRouteBody, /ROUTE_WATCHDOG_MS/); + assert.doesNotMatch(sources.cmc, /symbolTimer|SYMBOL_CHECK_MS/); }); test('CMC data panel invalidates superseded and paused refreshes with a monotonic epoch', () => { @@ -90,6 +93,9 @@ for (const [name, source] of Object.entries(sources)) { const startupPrefix = source.slice(0, source.indexOf('const PANEL_ID')); assert.doesNotMatch(startupPrefix, /if \(!isFuturesTradingPage\(\)\) return;/); assert.match(source, /startRouteWatcher\(\)/); + const startRouteBody = readFunctionBody(source, 'startRouteWatcher'); + assert.match(startRouteBody, /installSpaRouteChangeListener/); + assert.match(startRouteBody, /ROUTE_WATCHDOG_MS/); }); } diff --git a/test/unit/binance-orderbook-trade/ladder-progress.test.js b/test/unit/binance-orderbook-trade/ladder-progress.test.js index 2db1ba2..fce8eac 100644 --- a/test/unit/binance-orderbook-trade/ladder-progress.test.js +++ b/test/unit/binance-orderbook-trade/ladder-progress.test.js @@ -28,10 +28,24 @@ test('stopped ladder status reports confirmed submitted and cancelled orders', ( ); }); -test('stopped ladder status keeps explicit zero counts', () => { +test('stopped ladder status omits counters for actions that did not happen', () => { assert.equal( formatStoppedLadderProgress('阶梯平多', createLadderProgress()), - '阶梯平多已停止 · 已挂 0 笔 · 已撤 0 笔', + '阶梯平多已停止', + ); + + const submittedOnly = createLadderProgress(); + recordLadderSubmittedOrder(submittedOnly); + assert.equal( + formatStoppedLadderProgress('阶梯平空', submittedOnly), + '阶梯平空已停止 · 已挂 1 笔', + ); + + const cancelledOnly = createLadderProgress(); + recordLadderCancelledOrder(cancelledOnly); + assert.equal( + formatStoppedLadderProgress('阶梯开多', cancelledOnly), + '阶梯开多已停止 · 已撤 1 笔', ); }); @@ -66,6 +80,15 @@ test('failed and interrupted ladder statuses retain confirmed progress', () => { formatInterruptedLadderProgress('阶梯开多', '交易对已切换', progress), '阶梯开多已中止:交易对已切换 · 已挂 1 笔 · 已撤 1 笔', ); + + assert.equal( + formatFailedLadderProgress('阶梯开空', '数量框状态未稳定', createLadderProgress()), + '阶梯开空失败:数量框状态未稳定', + ); + assert.equal( + formatInterruptedLadderProgress('阶梯平多', '交易对已切换', createLadderProgress()), + '阶梯平多已中止:交易对已切换', + ); }); test('ladder progress rejects invalid counters instead of masking them', () => { diff --git a/test/unit/binance-orderbook-trade/source-regressions.test.js b/test/unit/binance-orderbook-trade/source-regressions.test.js index 06690bd..5937afa 100644 --- a/test/unit/binance-orderbook-trade/source-regressions.test.js +++ b/test/unit/binance-orderbook-trade/source-regressions.test.js @@ -35,12 +35,16 @@ test('source and generated userscript versions stay synchronized', () => { assert.equal(generatedVersion, sourceVersion); }); -test('symbol-change polling is stopped while the tab is hidden', () => { - assert.doesNotMatch(source, /\n setInterval\(checkSymbolChangeForLeverage,\s*500\);/); - assert.match(source, /function startSymbolChangeTimer\(\)/); - assert.match(source, /function stopSymbolChangeTimer\(\)/); +test('route changes are event-driven with one low-frequency watchdog', () => { + assert.doesNotMatch(source, /function startSymbolChangeTimer\(\)/); + assert.doesNotMatch(source, /function startRenderPanelTimer\(\)/); + assert.match(source, /installSpaRouteChangeListener\(window, syncRouteState\)/); + assert.match(source, /const ROUTE_WATCHDOG_MS = 5000;/); + const startRouteBody = readFunctionBody('startRouteWatcher'); + assert.match(startRouteBody, /setInterval/); + assert.match(startRouteBody, /ROUTE_WATCHDOG_MS/); const stopTradingBody = readFunctionBody('stopTradingTimers'); - assert.match(stopTradingBody, /stopSymbolChangeTimer\(\)/); + assert.doesNotMatch(stopTradingBody, /stopSymbolChangeTimer|stopRenderPanelTimer/); const visibilityBody = source.match(/document\.addEventListener\('visibilitychange', \(\) => \{([\s\S]*?)\n \}\);/)?.[1] || ''; assert.match(visibilityBody, /stopTradingTimers\(\)/); assert.match(visibilityBody, /syncRouteState\(\)/); @@ -285,30 +289,25 @@ test('open and close ladders reprice only remaining orders after explicit maker assert.match(observeBody, /capture\.apiSuccesses\.push\(\{ requestUrl \}\)/); assert.match(observeBody, /capture\.apiErrors\.push\(\{ requestUrl, code \}\)/); + const trackBody = readFunctionBody('trackLadderSubmitResponse'); + assert.match(trackBody, /capture\.resolveRequestStarted\(\)/); + const observationBody = readFunctionBody('waitForLadderSubmitResponseObservations'); assert.match(observationBody, /capture\.responseObservations\.slice\(\)/); assert.match(observationBody, /Promise\.race\(\[/); assert.match(observationBody, /delay\(timeoutMs\)/); const acknowledgementBody = readFunctionBody('waitForOrderSubmitAcknowledgement'); - const apiCodeReadIndex = acknowledgementBody.indexOf('readLadderSubmitApiErrors(submitCaptureId)'); - const pendingFailureIndex = acknowledgementBody.indexOf('if (pendingFailure)'); - const apiSuccessReadIndex = acknowledgementBody.indexOf('readLadderSubmitApiSuccesses(submitCaptureId)'); - assert.notEqual(apiCodeReadIndex, -1); - assert.notEqual(apiSuccessReadIndex, -1); - assert.ok(apiCodeReadIndex < pendingFailureIndex); - assert.ok(pendingFailureIndex < apiSuccessReadIndex); + assert.match(acknowledgementBody, /waitForOrderSubmitStartOrFailureFeedback\(/); assert.match(acknowledgementBody, /await waitForLadderSubmitResponseObservations\(/); - assert.match(acknowledgementBody, /capturedApiErrorsNow\.length === 1/); - assert.match(acknowledgementBody, /isBinancePostOnlyMakerRejectCode\(capturedApiErrorsNow\[0\]\.code\)/); - assert.match(acknowledgementBody, /createLadderSubmitApiError\(capturedApiErrorsNow\[0\]\.code\)/); assert.match(acknowledgementBody, /capturedApiErrors\.length === 1/); assert.match(acknowledgementBody, /isBinancePostOnlyMakerRejectCode\(capturedApiErrors\[0\]\.code\)/); assert.match(acknowledgementBody, /capturedApiErrors\.length === 0/); assert.match(acknowledgementBody, /mode === 'CLOSE'/); - assert.match(acknowledgementBody, /isPostOnlyMakerRejectionFeedback\(pendingFailure\.message\)/); - assert.match(acknowledgementBody, /createLadderMakerPriceConflictError\(pendingFailure\.message\)/); - assert.match(acknowledgementBody, /capturedApiSuccessesNow\.length === 1/); + assert.match(acknowledgementBody, /isPostOnlyMakerRejectionFeedback\(failureActivity\.message\)/); + assert.match(acknowledgementBody, /createLadderMakerPriceConflictError\(failureActivity\.message\)/); + assert.match(acknowledgementBody, /capturedApiSuccesses\.length === 1/); + assert.doesNotMatch(acknowledgementBody, /LADDER_SUBMIT_POLL_MS|await delay\(/); assert.doesNotMatch(acknowledgementBody, /acknowledgement\.status === 'success'/); assert.doesNotMatch(source, /LADDER_SUBMIT_API_CODE_GRACE_MS/); @@ -345,6 +344,18 @@ test('open and close ladders reprice only remaining orders after explicit maker assert.match(generatedSource, /isPostOnlyMakerRejectionFeedback/); }); +test('bapi headers wake leverage checks without startup or 500ms polling sleeps', () => { + const waitBody = readFunctionBody('waitForBncHeaders'); + const cacheBody = readFunctionBody('cacheBncHeaders'); + assert.match(waitBody, /bncHeadersReady/); + assert.match(waitBody, /BNC_HEADERS_READY_TIMEOUT_MS/); + assert.doesNotMatch(waitBody, /delay\(500\)|for\s*\(/); + assert.match(cacheBody, /resolveBncHeadersReady\(\)/); + assert.match(cacheBody, /queueAutoOpenLeveragePositionCheck\('headers_ready'\)/); + assert.doesNotMatch(source, /AUTO_OPEN_LEVERAGE_DELAY_MS/); + assert.doesNotMatch(source, /queueAutoOpenLeveragePositionCheck\('init'\)/); +}); + test('stable panel renders avoid repeated orderbook scans and layout writes', () => { const triggerBody = readFunctionBody('findOrderbookPrecisionTrigger'); assert.match(triggerBody, /#futuresOrderbook \.orderbook-tickSize/); diff --git a/test/unit/m3u8-downloader-course-export.test.js b/test/unit/m3u8-downloader-course-export.test.js index 4a7805f..c6d5c6d 100644 --- a/test/unit/m3u8-downloader-course-export.test.js +++ b/test/unit/m3u8-downloader-course-export.test.js @@ -26,6 +26,21 @@ import { const source = await readFile(new URL('../../scripts/m3u8-downloader.user.js', import.meta.url), 'utf8'); +test('Brooks media export advances successful pages without a fixed step delay', async () => { + const exporterSource = await readFile( + new URL('../../src/m3u8-downloader/brooks-exporter.js', import.meta.url), + 'utf8', + ); + const constantsSource = await readFile( + new URL('../../src/m3u8-downloader/constants.js', import.meta.url), + 'utf8', + ); + + assert.match(exporterSource, /queueMicrotask\(processNextBrooksMediaExport\)/); + assert.doesNotMatch(exporterSource, /BROOKS_MEDIA_EXPORT_STEP_DELAY_MS/); + assert.doesNotMatch(constantsSource, /BROOKS_MEDIA_EXPORT_STEP_DELAY_MS/); +}); + test('external downloader preserves the complete tokenized m3u8 source URL', () => { const sourceUrl = 'https://cdn.example/video.m3u8?token=keep&expires=123'; const target = new URL(buildExternalDownloaderUrl(sourceUrl)); diff --git a/test/unit/spa-route-change.test.js b/test/unit/spa-route-change.test.js new file mode 100644 index 0000000..fa3fa6e --- /dev/null +++ b/test/unit/spa-route-change.test.js @@ -0,0 +1,76 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; +import { + ensureSpaRouteChangePatched, + installSpaRouteChangeListener, +} from '../../src/shared/spa-route-change.js'; + +test('SPA route listener observes changed pushState and replaceState URLs', () => { + const dom = new JSDOM('', { url: 'https://www.binance.com/zh-CN/futures/HYPEUSDT' }); + const events = []; + const dispose = installSpaRouteChangeListener(dom.window, () => { + events.push(dom.window.location.pathname); + }); + + dom.window.history.pushState({}, '', '/zh-CN/futures/BTCUSDT'); + dom.window.history.replaceState({}, '', '/zh-CN/futures/ETHUSDT'); + + assert.deepEqual(events, [ + '/zh-CN/futures/BTCUSDT', + '/zh-CN/futures/ETHUSDT', + ]); + dispose(); +}); + +test('SPA route listener ignores same-URL history writes and patches once', () => { + const dom = new JSDOM('', { url: 'https://www.binance.com/zh-CN/futures/HYPEUSDT' }); + let firstCount = 0; + let secondCount = 0; + const disposeFirst = installSpaRouteChangeListener(dom.window, () => { firstCount += 1; }); + const patchedPushState = dom.window.history.pushState; + const disposeSecond = installSpaRouteChangeListener(dom.window, () => { secondCount += 1; }); + + assert.equal(dom.window.history.pushState, patchedPushState); + dom.window.history.pushState({}, '', dom.window.location.href); + assert.equal(firstCount, 0); + assert.equal(secondCount, 0); + + dom.window.history.pushState({}, '', '/zh-CN/futures/BTCUSDT'); + assert.equal(firstCount, 1); + assert.equal(secondCount, 1); + + disposeFirst(); + disposeSecond(); +}); + +test('SPA route patch is restored when an application replaces a history method', () => { + const dom = new JSDOM('', { url: 'https://www.binance.com/zh-CN/futures/HYPEUSDT' }); + let count = 0; + const dispose = installSpaRouteChangeListener(dom.window, () => { count += 1; }); + const applicationPushState = function (...args) { + return Reflect.apply(dom.window.History.prototype.pushState, this, args); + }; + dom.window.history.pushState = applicationPushState; + + ensureSpaRouteChangePatched(dom.window); + assert.notEqual(dom.window.history.pushState, applicationPushState); + dom.window.history.pushState({}, '', '/zh-CN/futures/BTCUSDT'); + assert.equal(count, 1); + dispose(); +}); + +test('nested application wrappers dispatch one route event per URL change', () => { + const dom = new JSDOM('', { url: 'https://www.binance.com/zh-CN/futures/HYPEUSDT' }); + let count = 0; + const dispose = installSpaRouteChangeListener(dom.window, () => { count += 1; }); + const patchedPushState = dom.window.history.pushState; + dom.window.history.pushState = function (...args) { + return Reflect.apply(patchedPushState, this, args); + }; + + ensureSpaRouteChangePatched(dom.window); + dom.window.history.pushState({}, '', '/zh-CN/futures/BTCUSDT'); + assert.equal(count, 1); + dispose(); +});