diff --git a/docs/binance-orderbook-trade-development.md b/docs/binance-orderbook-trade-development.md
index 9c8e092..e523156 100644
--- a/docs/binance-orderbook-trade-development.md
+++ b/docs/binance-orderbook-trade-development.md
@@ -126,6 +126,10 @@ The geometry adapter checks the native `hasModel()` contract before reading pane
Every synchronous depth render creates a fresh geometry snapshot. Its 13-step inverse coordinate search memoizes native coordinate-to-price samples by the exact numeric coordinate, shared across that render's levels and initial validation samples. Search branches, precision, pixel-row ownership and cumulative quantities are unchanged. No sample survives into the next geometry, so zoom, logarithmic/inverted mode, and native scale changes are resampled. This removes repeated native calls at shared search-tree nodes without adding timers or caching depth prices/results. Invalid initial samples still reject the geometry; invalid intermediate search samples still throw.
+Host synchronization passes its already-validated geometry directly into the
+synchronous paint. A separately scheduled render still obtains a fresh geometry;
+no snapshot is retained across frames or scale changes.
+
The userscript installs `core/binance-native-depth-source.js` at `document-start` and passively observes the native `/fapi/v1/rpiDepth?limit=1000` response and `{symbol}@rpiDepth@500ms` messages. It preserves Binance's original `fetch` result and WebSocket instances and never opens a second depth connection. `core/depth-profile-book.js` applies the observed `lastUpdateId`, `U`, `u`, and `pu` sequence contract and treats quantities as absolute values; zero removes a price level. The profile keeps every active price accumulated from the native stream and defers pixel-row aggregation to rendering, so distant levels are not discarded by the snapshot limit. Rendering remains sparse: a row is painted only when at least one real price level maps to that chart pixel. `core/depth-profile-session.js` only subscribes the active symbol to that page-owned source. A sequence gap waits for Binance's native resynchronization instead of issuing a userscript-owned retry request, while a changed private RPI contract fails the profile explicitly without blocking Binance's own request.
The session must stop and invalidate old work on symbol change, non-trading routes, hidden documents, and `pagehide`. The overlay canvas uses `pointer-events: none`; only its compact collapse control may receive pointer input. Do not connect this visualization book to ladder pricing or any trading decision.
diff --git a/docs/binance-strategy27-events-development.md b/docs/binance-strategy27-events-development.md
index cf0209e..b47401c 100644
--- a/docs/binance-strategy27-events-development.md
+++ b/docs/binance-strategy27-events-development.md
@@ -11,7 +11,7 @@ V10 live projection. The VPS remains the only market-data and event-analysis
authority. The userscript opens no Binance market-data WebSocket, uses no
Binance API key, and does not recalculate the four force groups.
-Version 0.6.3 retains this installation's private gateway configuration and
+Version 0.6.4 retains this installation's private gateway configuration and
provides a shared read-only transport. Strategy29 owns its own summary panel,
lifecycle and panel position. The existing `strategy27GatewayOrigin` and
`strategy27GatewayAuthSecret` storage keys remain the single credential source;
@@ -165,7 +165,7 @@ ADR 032 in CorsairQuant owns the server-side rule and transport contract. The
browser does not reconstruct candidates from ordinary events or recalculate
market evidence. The client, lifecycle, panel, native chart layer and optional-job
controller are wired into the entrypoint and tested together. The source and
-generated install artifact are version 0.6.3 with identical metadata headers.
+generated install artifact are version 0.6.4 with identical metadata headers.
The generated artifact passes syntax, release-contract and isolated execution
checks, including candidate delivery, paired entities, clear and context stop.
Binance operator-page validation remains outstanding. Server/gateway rollout
diff --git a/docs/binance-strategy29-bollinger-development.md b/docs/binance-strategy29-bollinger-development.md
index cd73084..eb8d7ee 100644
--- a/docs/binance-strategy29-bollinger-development.md
+++ b/docs/binance-strategy29-bollinger-development.md
@@ -10,7 +10,7 @@ already-loaded native chart candles. The summary reads the authenticated
unified loopback gateway; it does not call Binance market-data or account APIs,
submit orders, rotate hidden charts, or add remote events as chart drawings.
-Install Strategy29 0.5.3 with orderbook 2.7.199 or later, or use it alone.
+Install Strategy29 0.5.4 with orderbook 2.7.199 or later, or use it alone.
Install CorsairQuant signal client 0.6.1 for the remote summary.
Do not combine it with the embedded observer in orderbook 2.7.198.
After updating/disabling the old script, reload the page. An embedded observer
@@ -166,6 +166,9 @@ latest snapshot. Rows are displayed and bounded by descending signal close time,
with descending durable sequence breaking ties. The snapshot response remains
sequence-ascending so the global increment contract is unchanged. Historical
backfills cannot evict newer signal times merely by being inserted later.
+An empty increment advances the event-check timestamp without sorting retained
+records or replacing their DOM rows. Nonempty increments sort once for retention
+and rendering; locale changes still rebuild the translated rows.
This requires the server's explicit latest
query contract; a server rejecting it stops the remote context visibly.
Publish the V3 observer API contract before the client, then verify installed
diff --git a/docs/userscript-performance-review.md b/docs/userscript-performance-review.md
new file mode 100644
index 0000000..20bb774
--- /dev/null
+++ b/docs/userscript-performance-review.md
@@ -0,0 +1,132 @@
+# Userscript Performance Review
+
+Date: 2026-09-12. Baseline: `ee78e438f22e1c7d0f4317f8d5d563a8b2936292`.
+
+## Scope and evidence
+
+The review covers all eight repository-owned userscripts, their source entrypoints,
+hot DOM paths, recurring work, and lifecycle cleanup. Generated artifacts were
+checked against their sources, not edited independently. Third-party Tampermonkey
+scripts and native Binance/TradingView/CMC application code are outside this scope.
+
+The measurements below count actual calls and DOM operations in deterministic
+fixtures. They are not estimates of live page CPU, memory savings, or latency.
+The same synthetic inputs run against the baseline and current implementations.
+Real Chromium fixtures also exercise the CMC layout and media-scanning changes.
+
+## Audit coverage
+
+| Script | Finding and outcome | Version |
+| --- | --- | --- |
+| Binance orderbook trade | Host synchronization measured depth geometry twice before one paint. The synchronous paint now consumes the first validated snapshot; independent frames still measure fresh geometry. | 2.7.206 |
+| Binance trading data | The one-second age display reparsed and replaced three footer elements every tick. The panel creates those elements once and updates changed text only. | 1.1.16 |
+| Binance CoinMarketCap data | Reviewed the 30-second refresh, five-second route watchdog, refresh epochs, drag listeners, and hidden/closed/non-trading cleanup. No comparable redundant hot path was established; behavior is unchanged. | 0.1.17, unchanged |
+| Binance Strategy27 events | Each context tick discovered the same chart root twice. The target resolver now receives the root validated within that synchronous tick. | 0.6.4 |
+| Binance Strategy29 Bollinger | Empty event increments sorted retained records twice and rebuilt up to 20 rows. Empty increments now update freshness without replacing rows; nonempty increments reuse one sorted list. | 0.5.4 |
+| m3u8 downloader | One changed video/source caused a document-wide video scan. Mutation work now queues only affected videos and deduplicates them before the existing animation frame. | 0.10.38 |
+| Auto refresh | Reviewed exact URL matching, the scheduled target and the 30-second missed-target check. No DOM scanning or expensive recurring work was found; scheduling is unchanged. | 1.0.10, unchanged |
+| CoinMarketCap valuation helper | Every mutation batch read layout and ancestor text for every `span,p,div` before checking its label. Matching now precedes expensive checks, and batches share one scan per animation frame. | 0.2.9 |
+
+Shared route patches remain idempotent, and route dispatch retains its existing
+deduplication. TradingView marker ownership, full-history detection, native shape
+audits, cooperative batch yields, and chart-save coordination remain unchanged.
+Those checks protect current chart/session identity and require live profiling
+before any further attempt to reduce their work.
+
+## Operation counts
+
+| Deterministic scenario | Baseline | Optimized |
+| --- | ---: | ---: |
+| CMC initial scan with two metric cards and 1,000 unrelated quote rows: layout reads | 6,020 | 8 |
+| Same CMC document, ten mutation batches before the next paint: full text scans | 10 | 1 |
+| Same ten CMC batches: layout reads / computed-style reads | 60,200 / 30,100 | 8 / 4 |
+| One changed source among 100 videos: video scans | 100 | 1 |
+| Same media mutation: document-wide video queries | 1 | 0 |
+| Ten empty Strategy29 increments with 20 retained signals: list replacements | 10 | 0 |
+| Same empty increments: newly created elements | 800 | 0 |
+| Ten Strategy27 context ticks: chart-root queries | 20 | 10 |
+| One orderbook host synchronization: native geometry snapshots | 2 | 1 |
+| Sixty trading-data age updates: footer subtree replacements | 60 | 0 |
+
+The first seven rows are reproduced by the standalone benchmark. The final three
+are covered by source-entrypoint regression tests that were observed failing with
+the baseline behavior and passing after the changes. Their assertions also check
+displayed values, current client ownership, and fresh geometry on the next render.
+
+## Preserved contracts
+
+- CMC keeps the full text fallback scan so delayed nearby values, replaced labels,
+ and newly eligible cards remain discoverable. Only matching text reaches layout
+ checks; explainer-based matching and the top-left visibility boundary are intact.
+ Queued scans validate the current route when they execute.
+- Media scanning still performs initial, DOM-ready, and load discovery. Added
+ nested sources resolve to their owning video. Removed videos, videos adopted
+ into another document, and picture-only sources do not enter the scan. Pending
+ videos are pruned during mutation delivery and checked again at frame execution.
+ XHR interception, URL deduplication, export persistence and queue semantics are
+ unchanged.
+- Strategy29 still advances freshness and the client cursor on empty responses.
+ Locale changes rebuild translated rows; ordering, retention, cursor reset, and
+ nonempty event replacement retain their existing semantics.
+- Each Strategy27 tick still validates visible-root uniqueness before resolving
+ the frame, native chart, exact symbol, and one-second interval. The historical
+ migration fixture remains frozen at its original API and version.
+- Depth price-scale validation, logarithmic/inverted mapping, and coordinate
+ precision are unchanged. Geometry is reused only synchronously, never cached
+ across paints. Chinese and numeric symbols, including `龙虾USDT` and `4USDT`,
+ remain covered by real Chromium depth fixtures.
+- Trading-data fetch frequency, server-time alignment, retry boundaries, and
+ visibility/close lifecycle are unchanged. Repeated footer updates in the same
+ second produce no DOM writes.
+
+## Reproduction
+
+Use the repository-supported Node runtime. The completed local verification used
+Node 24.16.0.
+
+```sh
+node test/manual/userscript-performance-benchmark.mjs ee78e438f22e1c7d0f4317f8d5d563a8b2936292
+node --test test/dom/coinmarketcap-valuation-helper.test.js test/dom/m3u8-media-scan.test.js test/dom/binance-trading-data-footer.test.js
+node --test test/unit/binance-orderbook-trade/depth-profile-render-cycle.test.js
+node --test test/dom/binance-strategy27-events/strategy27-entrypoint.test.js test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js
+npm test
+npm run test:ui -- --reporter=line
+```
+
+The benchmark writes only its JSON report to stdout. Supplying a baseline revision
+reads historical source through `git show`; it does not check out another branch.
+The Strategy29 panel comparison uses unchanged current dependencies. The benchmark
+stubs network requests and uses synthetic data only.
+
+The new CMC browser fixture initially lacked a UTF-8 declaration and rendered
+Chinese text as mojibake. The fixture now declares UTF-8 in both its response and
+markup. This was a deterministic fixture defect, not a retry or suppressed failure.
+
+Final validation completed:
+
+- 1,017 unit/DOM tests passed; zero failures, cancellations, or skips.
+- 80 Chromium tests passed with Playwright retries set to zero.
+- All five affected generated bundles exactly match an in-memory rebuild from
+ current source, including metadata headers. All eight install scripts passed
+ syntax checks, and `git diff --check` passed.
+- The CMC cards and Chinese/numeric-symbol depth screenshots were inspected in
+ isolated fixtures. Operation counts were rechecked after the media document
+ ownership regression was fixed.
+
+Local evidence is under `test-results/userscript-performance/`: the before/after
+operation counts, artifact hashes, final unit/browser output, verification totals,
+and inspected screenshots. Release, public-artifact, and installation evidence is
+recorded separately from these performance measurements.
+
+## Verification limits
+
+The performance measurements were collected in isolated fixtures. The live browser
+attempt during implementation validation was blocked by the caller-identity policy.
+Chromium fixture screenshots establish local rendering only, not live Binance or
+CMC performance, and no logged-in Brooks export was exercised. Tampermonkey source
+readback and post-reload loaded-source evidence are separate release checks; neither
+can be inferred from the fixture results.
+
+Independent read-only reviewers were requested but did not return a completed
+verdict because model services returned rate-limit/unavailable errors. This review
+gap remains explicit; passing automated checks is not an independent-review claim.
diff --git a/docs/userscript-validation.md b/docs/userscript-validation.md
index fb723ec..910d94c 100644
--- a/docs/userscript-validation.md
+++ b/docs/userscript-validation.md
@@ -8,6 +8,9 @@ by `docs/binance-strategy27-events-development.md`; Brooks/m3u8 behavior is owne
by `docs/brooks-media-sync-workflow.md`. Release and remote-publish mutations
are owned by `skills/userscript-release/SKILL.md`.
+The eight-script performance audit, operation-count baselines, and reproduction
+commands are recorded in `docs/userscript-performance-review.md`.
+
## Script Matrix
| Script | Editable source | Artifact | Focused checks | Detailed guide |
@@ -63,6 +66,9 @@ panel is closed, or the route is not a trading page. A route watcher may remain
alive while the panel is paused so a later SPA transition can restart the
business loop.
+The one-second age display retains its footer elements and updates only changed
+text. It does not recreate the timestamp row or alter the data-fetch schedule.
+
Each period fetch records which endpoint produced fresh data and which endpoint
used a cached value or has no value. Fresh and cached indicators remain distinct
when the panel computes directional votes. A stale request must not render into
diff --git a/e2e/binance-orderbook/specs/userscript-performance.pw.js b/e2e/binance-orderbook/specs/userscript-performance.pw.js
new file mode 100644
index 0000000..c240d9c
--- /dev/null
+++ b/e2e/binance-orderbook/specs/userscript-performance.pw.js
@@ -0,0 +1,102 @@
+import { fileURLToPath } from 'node:url';
+import { test, expect } from '@playwright/test';
+
+const artifact = name => fileURLToPath(new URL(`../../../scripts/${name}.user.js`, import.meta.url));
+
+test('CMC valuation updates coalesce while real layout reads stay on the metric cards', async ({ page }, testInfo) => {
+ const errors = [];
+ page.on('pageerror', error => errors.push(error.message));
+ await page.route('**/*', route => route.abort('blockedbyclient'));
+ await page.route('https://coinmarketcap.com/zh/currencies/bitcoin/', route => route.fulfill({
+ contentType: 'text/html; charset=utf-8',
+ body: `
${Array.from({ length: 1000 }, (_, index) => ``).join('')} `,
+ }));
+ await page.goto('https://coinmarketcap.com/zh/currencies/bitcoin/');
+ await page.evaluate(() => {
+ const stats = { layoutReads: 0, quoteLayoutReads: 0, scans: 0 };
+ window.performanceFixture = stats;
+ const rect = Element.prototype.getBoundingClientRect;
+ Element.prototype.getBoundingClientRect = function (...args) {
+ stats.layoutReads += 1;
+ if (this.closest('#prices')) stats.quoteLayoutReads += 1;
+ return Reflect.apply(rect, this, args);
+ };
+ const query = document.querySelectorAll.bind(document);
+ document.querySelectorAll = selector => {
+ if (selector === 'span,p,div') stats.scans += 1;
+ return query(selector);
+ };
+ });
+ await page.addScriptTag({ path: artifact('coinmarketcap-valuation-helper') });
+ await expect(page.locator('#cap')).toHaveText('流通市值');
+ await expect(page.locator('#fdv')).toHaveText('FDV/总估值');
+ await expect(page.locator('.jh-cmc-valuation-highlight')).toHaveCount(2);
+ const result = await page.evaluate(async () => {
+ const stats = window.performanceFixture;
+ const before = { ...stats };
+ for (let index = 0; index < 10; index += 1) {
+ document.querySelector('.quote span').firstChild.data = String(index + 1000);
+ await Promise.resolve();
+ }
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
+ return {
+ initialQuoteLayoutReads: before.quoteLayoutReads,
+ quoteLayoutReads: stats.quoteLayoutReads - before.quoteLayoutReads,
+ scans: stats.scans - before.scans,
+ layoutReads: stats.layoutReads - before.layoutReads,
+ };
+ });
+ expect(result).toEqual({ initialQuoteLayoutReads: 0, quoteLayoutReads: 0, scans: 1, layoutReads: 8 });
+ expect(errors).toEqual([]);
+ await testInfo.attach('operation-counts', { body: JSON.stringify(result, null, 2), contentType: 'application/json' });
+ await page.locator('#stats').screenshot({ path: testInfo.outputPath('valuation-cards.png') });
+});
+
+test('m3u8 discovery inspects one changed video in a real 100-video document', async ({ page }, testInfo) => {
+ const errors = [];
+ page.on('pageerror', error => errors.push(error.message));
+ await page.route('**/*', route => route.abort('blockedbyclient'));
+ await page.route('https://njav.com/watch/fixture', route => route.fulfill({
+ contentType: 'text/html; charset=utf-8',
+ body: `${Array.from({ length: 100 }, (_, index) => ` `).join('')}`,
+ }));
+ await page.goto('https://njav.com/watch/fixture');
+ await page.evaluate(() => {
+ const stats = { documentScans: 0, videoIds: [], requests: [] };
+ window.performanceFixture = stats;
+ window.XMLHttpRequest = class {
+ open(_method, url) { stats.requests.push(url); }
+ send() {}
+ };
+ const query = document.querySelectorAll.bind(document);
+ document.querySelectorAll = selector => {
+ if (selector === 'video') stats.documentScans += 1;
+ return query(selector);
+ };
+ const elementQuery = Element.prototype.querySelectorAll;
+ Element.prototype.querySelectorAll = function (selector) {
+ if (this.tagName === 'VIDEO' && selector === 'source') stats.videoIds.push(this.id);
+ return Reflect.apply(elementQuery, this, [selector]);
+ };
+ });
+ await page.addScriptTag({ path: artifact('m3u8-downloader') });
+ const result = await page.evaluate(async () => {
+ const stats = window.performanceFixture;
+ stats.documentScans = 0;
+ stats.videoIds.length = 0;
+ document.querySelector('#v42 source').src = 'https://media.example/changed.m3u8';
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
+ return stats;
+ });
+ expect(result).toEqual({ documentScans: 0, videoIds: ['v42'], requests: ['https://media.example/changed.m3u8'] });
+ expect(errors).toEqual([]);
+ await testInfo.attach('operation-counts', { body: JSON.stringify(result, null, 2), contentType: 'application/json' });
+});
diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js
index bc31ac6..ce33da5 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.205
+// @version 2.7.206
// @author jackhai9
// @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板
// @match https://www.binance.com/*/futures/*
@@ -11731,6 +11731,9 @@
scheduleDepthProfileSync();
return;
}
+ paintDepthProfileUi(geometry);
+ }
+ function paintDepthProfileUi(geometry) {
setDepthProfileGeometry(depthProfileView, geometry);
const status = depthProfileStatusPresentation();
setDepthProfileViewState(depthProfileView, {
@@ -11858,8 +11861,7 @@
syncDepthProfile();
}
});
- setDepthProfileGeometry(depthProfileView, geometry);
- renderDepthProfileUi();
+ paintDepthProfileUi(geometry);
if (!depthProfileEnabled) return;
const symbol = getCurrentSymbol();
if (!symbol) return;
diff --git a/scripts/binance-strategy27-events.user.js b/scripts/binance-strategy27-events.user.js
index db028ab..99bb50c 100644
--- a/scripts/binance-strategy27-events.user.js
+++ b/scripts/binance-strategy27-events.user.js
@@ -3,7 +3,7 @@
// @namespace binance.strategy27.events
// @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.6.3
+// @version 0.6.4
// @author jackhai9
// @description Display Strategy 27 events and provide the shared private CorsairQuant gateway connection
// @match https://www.binance.com/*/futures/*
@@ -1184,8 +1184,7 @@
const resolution = chart.resolution();
return () => chart.symbol() === symbol && chart.resolution() === resolution;
}
- function findStrategy27ChartTarget(document, expectedRouteSymbol) {
- const chartRoot = findStrategy27ChartRoot(document);
+ function findStrategy27ChartTarget(chartRoot, expectedRouteSymbol) {
if (!chartRoot) return null;
const frames = Array.from(chartRoot.querySelectorAll("iframe")).filter(hasVisibleBox);
if (!frames.length) return null;
@@ -3421,7 +3420,7 @@ ${t("候选", "Candidate")} ${annotation.candidateId}`,
}
let target;
try {
- target = findStrategy27ChartTarget(pageDocument, routeSymbol);
+ target = findStrategy27ChartTarget(chartRoot, routeSymbol);
} catch (error) {
stopActive("interval_changed");
const inactive = error.message.includes("one-second chart");
diff --git a/scripts/binance-strategy29-bollinger.user.js b/scripts/binance-strategy29-bollinger.user.js
index 86fc80d..3dff227 100644
--- a/scripts/binance-strategy29-bollinger.user.js
+++ b/scripts/binance-strategy29-bollinger.user.js
@@ -3,7 +3,7 @@
// @namespace binance.strategy29.bollinger
// @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.5.3
+// @version 0.5.4
// @author jackhai9
// @description Native Bollinger/SMA60 markers and the default read-only cross-timeframe summary
// @match https://www.binance.com/*/futures/*
@@ -2257,9 +2257,8 @@
function assertLive() {
if (destroyed) throw new Error("Strategy 29 summary panel is destroyed");
}
- function renderEvents() {
+ function renderEvents(ordered) {
events.replaceChildren();
- const ordered = [...eventRecords.values()].sort(newestSignalFirst);
for (const event of ordered) {
const row = element(document, "div", {
role: "remote-event",
@@ -2282,7 +2281,7 @@
collapse.textContent = text(collapsed ? SUMMARY_COPY.expand : SUMMARY_COPY.collapse);
position.clamp();
});
- renderEvents();
+ renderEvents([]);
const api = Object.freeze({
setLocale(nextLocale) {
assertLive();
@@ -2300,7 +2299,7 @@
eventsFreshness.textContent = text(lastEventsAt === null ? SUMMARY_COPY.noEventsCheck : SUMMARY_COPY.eventsAt(formatClock(lastEventsAt)));
if (lastStatus !== null) api.renderStatus(lastStatus);
else clearCurrentStatus();
- renderEvents();
+ renderEvents([...eventRecords.values()].sort(newestSignalFirst));
position.clamp();
},
setConnection(state, message) {
@@ -2360,20 +2359,22 @@
},
addEvents(incoming, observedAtMs = null) {
assertLive();
- for (const event of incoming) eventRecords.set(event.event_id, event);
- const ordered = [...eventRecords.values()].sort(newestSignalFirst);
- while (ordered.length > maxEvents) eventRecords.delete(ordered.pop().event_id);
+ if (incoming.length > 0) {
+ for (const event of incoming) eventRecords.set(event.event_id, event);
+ const ordered = [...eventRecords.values()].sort(newestSignalFirst);
+ while (ordered.length > maxEvents) eventRecords.delete(ordered.pop().event_id);
+ renderEvents(ordered);
+ }
if (observedAtMs !== null) {
lastEventsAt = observedAtMs;
eventsFreshness.textContent = text(SUMMARY_COPY.eventsAt(formatClock(observedAtMs)));
}
- renderEvents();
position.clamp();
},
clearEvents() {
assertLive();
eventRecords.clear();
- renderEvents();
+ renderEvents([]);
position.clamp();
},
destroy() {
diff --git a/scripts/binance-trading-data.user.js b/scripts/binance-trading-data.user.js
index 90c372b..ad423bf 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.15
+// @version 1.1.16
// @author jackhai9
// @description 在合约交易页面叠加浮动面板,定时拉取交易数据(持仓量、多空比、资金费率等)并显示当前值 + 多空信号
// @match https://www.binance.com/*/futures/*
@@ -486,7 +486,11 @@
C.sub,
";font-size:12px;border-top:1px solid ",
C.border,
- ';">',
+ ';">',
+ '',
+ ' ',
+ "
",
+ "",
""
].join("");
document.body.appendChild(panel);
@@ -606,7 +610,12 @@
const mm = String(d.getMinutes()).padStart(2, "0");
const ss = String(d.getSeconds()).padStart(2, "0");
const ago = Math.floor((Date.now() - lastUpdateTs) / 1e3);
- el.innerHTML = '更新于 ' + hh + ":" + mm + ":" + ss + " " + ago + "秒前
";
+ const updatedText = "更新于 " + hh + ":" + mm + ":" + ss;
+ const elapsedText = ago + "秒前";
+ const updated = el.querySelector('[data-role="updated-at"]');
+ const elapsed = el.querySelector('[data-role="elapsed"]');
+ if (updated.textContent !== updatedText) updated.textContent = updatedText;
+ if (elapsed.textContent !== elapsedText) elapsed.textContent = elapsedText;
}
function setupDrag(panel) {
const header = panel.querySelector("#" + PANEL_ID + "-header");
diff --git a/scripts/coinmarketcap-valuation-helper.user.js b/scripts/coinmarketcap-valuation-helper.user.js
index 6b88a24..d80fb42 100644
--- a/scripts/coinmarketcap-valuation-helper.user.js
+++ b/scripts/coinmarketcap-valuation-helper.user.js
@@ -3,7 +3,7 @@
// @namespace coinmarketcap.valuation.helper
// @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.2.8
+// @version 0.2.9
// @author jackhai9
// @description 在 CoinMarketCap 中文币种页面左上角统计区标注并高亮流通市值和FDV/总估值
// @match https://coinmarketcap.com/zh/currencies/*
@@ -78,10 +78,11 @@
}
function getDirectText(element) {
- return Array.from(element.childNodes)
- .filter((node) => node.nodeType === Node.TEXT_NODE)
- .map((node) => node.textContent || '')
- .join('');
+ let text = '';
+ for (const node of element.childNodes) {
+ if (node.nodeType === Node.TEXT_NODE) text += node.textContent || '';
+ }
+ return text;
}
function replaceDirectText(element, replacement) {
@@ -133,11 +134,12 @@
const candidates = [scope, ...scope.querySelectorAll(TEXT_SELECTOR)];
for (const element of candidates) {
if (element === explainer || explainer.contains(element)) continue;
- if (normalizeText(getDirectText(element)) === metric.replacement) {
+ const directText = normalizeText(getDirectText(element));
+ if (directText === metric.replacement) {
highlightMetricCard(element);
break;
}
- if (!metric.labels.includes(normalizeText(getDirectText(element)))) continue;
+ if (!metric.labels.includes(directText)) continue;
replaceDirectText(element, metric.replacement);
highlightMetricCard(element);
@@ -149,13 +151,13 @@
function renameLabelsByText() {
for (const element of document.querySelectorAll(TEXT_SELECTOR)) {
+ // Quote updates affect the whole page; only matching labels need layout or ancestor text.
+ const replacement = findReplacement(normalizeText(getDirectText(element)));
+ if (!replacement) continue;
if (!isVisible(element)) continue;
if (!isInTopLeftStatsArea(element)) continue;
if (!hasMetricValueNearby(element)) continue;
- const replacement = findReplacement(normalizeText(getDirectText(element)));
- if (!replacement) continue;
-
replaceDirectText(element, replacement);
highlightMetricCard(element);
}
@@ -180,7 +182,16 @@
safeRenameLabels();
if (document.body) {
- const observer = new MutationObserver(safeRenameLabels);
+ let renameQueued = false;
+ const observer = new MutationObserver(() => {
+ if (renameQueued) return;
+ renameQueued = true;
+ // Several independent quote updates can arrive before the next paint.
+ window.requestAnimationFrame(() => {
+ renameQueued = false;
+ safeRenameLabels();
+ });
+ });
observer.observe(document.body, {
childList: true,
characterData: true,
diff --git a/scripts/m3u8-downloader.user.js b/scripts/m3u8-downloader.user.js
index b485bde..16b10e6 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.37
+// @version 0.10.38
// @description m3u8 下载增强脚本,仅在白名单视频站启用,避免误伤交易页等重前端应用
// @author jackhai9
// @include https://18jav.tv/*
@@ -1053,47 +1053,54 @@
}
root.querySelectorAll("video").forEach(scanVideo);
}
- function nodeMayContainMedia(node) {
- if (!node || node.nodeType !== 1) {
- return false;
- }
- if (node.matches && node.matches("video, source")) {
- return true;
- }
- return !!(node.querySelector && node.querySelector("video, source"));
- }
function startMediaScan() {
let pendingScan = false;
- const scheduleScan = () => {
+ let fullScan = false;
+ const pendingVideos = /* @__PURE__ */ new Set();
+ const queueMediaElement = (element) => {
+ const video = element.closest("video");
+ if (video && document.contains(video)) pendingVideos.add(video);
+ };
+ const queueAddedMedia = (node) => {
+ if (node.nodeType !== 1) return;
+ if (node.matches("video, source")) queueMediaElement(node);
+ node.querySelectorAll("video, source").forEach(queueMediaElement);
+ };
+ const scheduleScan = (scanAll = false) => {
+ if (scanAll) fullScan = true;
if (pendingScan) {
return;
}
pendingScan = true;
requestAnimationFrame(() => {
pendingScan = false;
- scanMedia(document);
+ const videos = [...pendingVideos];
+ pendingVideos.clear();
+ if (fullScan) {
+ fullScan = false;
+ scanMedia(document);
+ } else {
+ videos.filter((video) => document.contains(video)).forEach(scanVideo);
+ }
});
};
scanMedia(document);
- document.addEventListener("DOMContentLoaded", scheduleScan, { once: true });
- window.addEventListener("load", scheduleScan, { once: true });
+ document.addEventListener("DOMContentLoaded", () => scheduleScan(true), { once: true });
+ window.addEventListener("load", () => scheduleScan(true), { once: true });
const observeTarget = document.documentElement || document;
new MutationObserver((mutations) => {
for (const mutation of mutations) {
- if (mutation.type === "attributes" && nodeMayContainMedia(mutation.target)) {
- scheduleScan();
- return;
+ if (mutation.type === "attributes" && mutation.target.matches("video, source")) {
+ queueMediaElement(mutation.target);
}
- if (mutation.type !== "childList") {
- continue;
- }
- for (const node of mutation.addedNodes) {
- if (nodeMayContainMedia(node)) {
- scheduleScan();
- return;
- }
+ if (mutation.type === "childList") {
+ mutation.addedNodes.forEach(queueAddedMedia);
}
}
+ for (const video of pendingVideos) {
+ if (!document.contains(video)) pendingVideos.delete(video);
+ }
+ if (pendingVideos.size > 0) scheduleScan();
}).observe(observeTarget, {
childList: true,
subtree: true,
diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js
index 36d3508..1e8863a 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.205
+// @version 2.7.206
// @author jackhai9
// @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板
// @match https://www.binance.com/*/futures/*
@@ -8111,6 +8111,11 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js';
scheduleDepthProfileSync();
return;
}
+ paintDepthProfileUi(geometry);
+ }
+
+ /** Reuse geometry only within this synchronous paint; the next frame samples the native scale again. */
+ function paintDepthProfileUi(geometry) {
setDepthProfileGeometry(depthProfileView, geometry);
const status = depthProfileStatusPresentation();
setDepthProfileViewState(depthProfileView, {
@@ -8248,8 +8253,7 @@ import { showUsdtRebalanceDialog } from './dom/usdt-rebalance-dialog.js';
syncDepthProfile();
},
});
- setDepthProfileGeometry(depthProfileView, geometry);
- renderDepthProfileUi();
+ paintDepthProfileUi(geometry);
if (!depthProfileEnabled) return;
const symbol = getCurrentSymbol();
diff --git a/src/binance-strategy27-events/dom/tradingview-event-layer.js b/src/binance-strategy27-events/dom/tradingview-event-layer.js
index 7879a00..dd2bf2b 100644
--- a/src/binance-strategy27-events/dom/tradingview-event-layer.js
+++ b/src/binance-strategy27-events/dom/tradingview-event-layer.js
@@ -38,8 +38,8 @@ export function pinMarkerChartContext(chart) {
return () => chart.symbol() === symbol && chart.resolution() === resolution;
}
-export function findStrategy27ChartTarget(document, expectedRouteSymbol) {
- const chartRoot = findStrategy27ChartRoot(document);
+/** Resolve the frame from the root validated by this synchronous context sample. */
+export function findStrategy27ChartTarget(chartRoot, expectedRouteSymbol) {
if (!chartRoot) return null;
const frames = Array.from(chartRoot.querySelectorAll('iframe')).filter(hasVisibleBox);
if (!frames.length) return null;
diff --git a/src/binance-strategy27-events/index.user.js b/src/binance-strategy27-events/index.user.js
index ef87b69..17bf7cb 100644
--- a/src/binance-strategy27-events/index.user.js
+++ b/src/binance-strategy27-events/index.user.js
@@ -3,7 +3,7 @@
// @namespace binance.strategy27.events
// @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.6.3
+// @version 0.6.4
// @author jackhai9
// @description Display Strategy 27 events and provide the shared private CorsairQuant gateway connection
// @match https://www.binance.com/*/futures/*
@@ -316,7 +316,7 @@ const promptUser = globalThis.prompt.bind(globalThis);
let target;
try {
- target = findStrategy27ChartTarget(pageDocument, routeSymbol);
+ target = findStrategy27ChartTarget(chartRoot, routeSymbol);
} catch (error) {
stopActive('interval_changed');
const inactive = error.message.includes('one-second chart');
diff --git a/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js b/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js
index 5510fa5..2aba222 100644
--- a/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js
+++ b/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js
@@ -133,9 +133,8 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve
function assertLive() {
if (destroyed) throw new Error('Strategy 29 summary panel is destroyed');
}
- function renderEvents() {
+ function renderEvents(ordered) {
events.replaceChildren();
- const ordered = [...eventRecords.values()].sort(newestSignalFirst);
for (const event of ordered) {
const row = element(document, 'div', {
role: 'remote-event',
@@ -159,7 +158,7 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve
collapse.textContent = text(collapsed ? COPY.expand : COPY.collapse);
position.clamp();
});
- renderEvents();
+ renderEvents([]);
const api = Object.freeze({
setLocale(nextLocale) {
@@ -178,7 +177,7 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve
eventsFreshness.textContent = text(lastEventsAt === null ? COPY.noEventsCheck : COPY.eventsAt(formatClock(lastEventsAt)));
if (lastStatus !== null) api.renderStatus(lastStatus);
else clearCurrentStatus();
- renderEvents();
+ renderEvents([...eventRecords.values()].sort(newestSignalFirst));
position.clamp();
},
setConnection(state, message) {
@@ -245,20 +244,23 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve
},
addEvents(incoming, observedAtMs = null) {
assertLive();
- for (const event of incoming) eventRecords.set(event.event_id, event);
- const ordered = [...eventRecords.values()].sort(newestSignalFirst);
- while (ordered.length > maxEvents) eventRecords.delete(ordered.pop().event_id);
+ if (incoming.length > 0) {
+ for (const event of incoming) eventRecords.set(event.event_id, event);
+ const ordered = [...eventRecords.values()].sort(newestSignalFirst);
+ while (ordered.length > maxEvents) eventRecords.delete(ordered.pop().event_id);
+ renderEvents(ordered);
+ }
+ // Empty increments still advance freshness, but contain no changes to retained rows.
if (observedAtMs !== null) {
lastEventsAt = observedAtMs;
eventsFreshness.textContent = text(COPY.eventsAt(formatClock(observedAtMs)));
}
- renderEvents();
position.clamp();
},
clearEvents() {
assertLive();
eventRecords.clear();
- renderEvents();
+ renderEvents([]);
position.clamp();
},
destroy() {
diff --git a/src/binance-strategy29-bollinger/index.user.js b/src/binance-strategy29-bollinger/index.user.js
index e6c1bf7..b02af12 100644
--- a/src/binance-strategy29-bollinger/index.user.js
+++ b/src/binance-strategy29-bollinger/index.user.js
@@ -3,7 +3,7 @@
// @namespace binance.strategy29.bollinger
// @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.5.3
+// @version 0.5.4
// @author jackhai9
// @description Native Bollinger/SMA60 markers and the default read-only cross-timeframe summary
// @match https://www.binance.com/*/futures/*
diff --git a/src/binance-trading-data/index.user.js b/src/binance-trading-data/index.user.js
index 0d0a87f..c516b7c 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.15
+// @version 1.1.16
// @author jackhai9
// @description 在合约交易页面叠加浮动面板,定时拉取交易数据(持仓量、多空比、资金费率等)并显示当前值 + 多空信号
// @match https://www.binance.com/*/futures/*
@@ -472,7 +472,11 @@ import {
'',
].join('');
@@ -580,9 +584,13 @@ import {
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
const ago = Math.floor((Date.now() - lastUpdateTs) / 1000);
- el.innerHTML = '' +
- '更新于 ' + hh + ':' + mm + ':' + ss + ' ' +
- '' + ago + '秒前
';
+ const updatedText = '更新于 ' + hh + ':' + mm + ':' + ss;
+ const elapsedText = ago + '秒前';
+ const updated = el.querySelector('[data-role="updated-at"]');
+ const elapsed = el.querySelector('[data-role="elapsed"]');
+ // The second timer changes text only; retained elements avoid reparsing the footer on every tick.
+ if (updated.textContent !== updatedText) updated.textContent = updatedText;
+ if (elapsed.textContent !== elapsedText) elapsed.textContent = elapsedText;
}
/* ========== 拖拽 ========== */
diff --git a/src/m3u8-downloader/index.user.js b/src/m3u8-downloader/index.user.js
index cda8cc5..3a85a21 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.37
+// @version 0.10.38
// @description m3u8 下载增强脚本,仅在白名单视频站启用,避免误伤交易页等重前端应用
// @author jackhai9
// @include https://18jav.tv/*
@@ -281,50 +281,57 @@ import { createBrooksMediaExporter } from './brooks-exporter.js'
root.querySelectorAll('video').forEach(scanVideo)
}
- function nodeMayContainMedia(node) {
- if (!node || node.nodeType !== 1) {
- return false
- }
- if (node.matches && node.matches('video, source')) {
- return true
- }
- return !!(node.querySelector && node.querySelector('video, source'))
- }
-
function startMediaScan() {
let pendingScan = false
- const scheduleScan = () => {
+ let fullScan = false
+ const pendingVideos = new Set()
+ const queueMediaElement = (element) => {
+ const video = element.closest('video')
+ if (video && document.contains(video)) pendingVideos.add(video)
+ }
+ const queueAddedMedia = (node) => {
+ if (node.nodeType !== 1) return
+ if (node.matches('video, source')) queueMediaElement(node)
+ node.querySelectorAll('video, source').forEach(queueMediaElement)
+ }
+ const scheduleScan = (scanAll = false) => {
+ if (scanAll) fullScan = true
if (pendingScan) {
return
}
pendingScan = true
requestAnimationFrame(() => {
pendingScan = false
- scanMedia(document)
+ const videos = [...pendingVideos]
+ pendingVideos.clear()
+ if (fullScan) {
+ fullScan = false
+ scanMedia(document)
+ } else {
+ videos.filter(video => document.contains(video)).forEach(scanVideo)
+ }
})
}
scanMedia(document)
- document.addEventListener('DOMContentLoaded', scheduleScan, { once: true })
- window.addEventListener('load', scheduleScan, { once: true })
+ document.addEventListener('DOMContentLoaded', () => scheduleScan(true), { once: true })
+ window.addEventListener('load', () => scheduleScan(true), { once: true })
const observeTarget = document.documentElement || document
new MutationObserver((mutations) => {
for (const mutation of mutations) {
- if (mutation.type === 'attributes' && nodeMayContainMedia(mutation.target)) {
- scheduleScan()
- return
+ if (mutation.type === 'attributes' && mutation.target.matches('video, source')) {
+ queueMediaElement(mutation.target)
}
- if (mutation.type !== 'childList') {
- continue
- }
- for (const node of mutation.addedNodes) {
- if (nodeMayContainMedia(node)) {
- scheduleScan()
- return
- }
+ if (mutation.type === 'childList') {
+ mutation.addedNodes.forEach(queueAddedMedia)
}
}
+ // Frames pause in background tabs; discard removed or adopted videos before they accumulate.
+ for (const video of pendingVideos) {
+ if (!document.contains(video)) pendingVideos.delete(video)
+ }
+ if (pendingVideos.size > 0) scheduleScan()
}).observe(observeTarget, {
childList: true,
subtree: true,
diff --git a/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js b/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js
index ab18631..0a2d5b8 100644
--- a/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js
+++ b/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js
@@ -135,6 +135,20 @@ test('real entrypoint starts independent clients and manual clear preserves comp
assert.equal(h.pending('ordinary').length, 1);
});
+test('each context tick discovers the chart root once without restarting healthy clients', async (t) => {
+ const h = await harness(t);
+ let chartRootQueries = 0;
+ const query = h.page.document.querySelectorAll.bind(h.page.document);
+ t.mock.method(h.page.document, 'querySelectorAll', selector => {
+ if (selector === '.chart-widget-root') chartRootQueries += 1;
+ return query(selector);
+ });
+ for (let index = 0; index < 10; index += 1) h.tick();
+ assert.equal(chartRootQueries, 10);
+ assert.equal(h.pending('ordinary').length, 1);
+ assert.equal(h.pending('compound').length, 1);
+});
+
test('ordinary failure does not stop compound; the existing timer expires candidates and interval changes abort both', async (t) => {
const h = await harness(t);
await h.respond('ordinary', 'invalid JSON');
diff --git a/test/dom/binance-strategy27-events/tradingview-event-layer.test.js b/test/dom/binance-strategy27-events/tradingview-event-layer.test.js
index 2880164..7695f1a 100644
--- a/test/dom/binance-strategy27-events/tradingview-event-layer.test.js
+++ b/test/dom/binance-strategy27-events/tradingview-event-layer.test.js
@@ -4,9 +4,14 @@ import test from 'node:test';
import { loadFixtureDom } from '../../helpers/dom.js';
import {
createTradingViewEventLayer,
- findStrategy27ChartTarget,
+ findStrategy27ChartRoot,
+ findStrategy27ChartTarget as resolveStrategy27ChartTarget,
} from '../../../src/binance-strategy27-events/dom/tradingview-event-layer.js';
+function findStrategy27ChartTarget(document, symbol) {
+ return resolveStrategy27ChartTarget(findStrategy27ChartRoot(document), symbol);
+}
+
function createChartDom({
resolution = '1S',
symbol = 'BTRUSDT@PRICETYPE=LAST',
diff --git a/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js b/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js
index 5327554..5a19c31 100644
--- a/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js
+++ b/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js
@@ -13,6 +13,34 @@ function createStrategy29SummaryPanel(document, symbol, options = {}) {
const status = JSON.parse(await readFile(new URL('../../fixtures/strategy29-gateway-status.json', import.meta.url)));
const events = JSON.parse(await readFile(new URL('../../fixtures/strategy29-gateway-events.json', import.meta.url)));
+test('empty increments retain event DOM identities while advancing freshness', () => {
+ const dom = new JSDOM('');
+ const panel = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT');
+ const incoming = Array.from({ length: 20 }, (_, index) => ({
+ ...events.events[0], event_id: `performance-${index}`, sequence: index + 1,
+ bar_close_ms: events.events[0].bar_close_ms + index * 60_000,
+ }));
+ panel.addEvents(incoming, events.observed_at_ms);
+ const container = dom.window.document.querySelector('[data-role=events]');
+ const rows = [...container.children];
+ const freshness = dom.window.document.querySelector('[data-role=events-freshness]');
+ const before = freshness.textContent;
+ const observer = new dom.window.MutationObserver(() => {});
+ observer.observe(container, { childList: true, subtree: true, characterData: true });
+ for (let index = 1; index <= 10; index += 1) panel.addEvents([], events.observed_at_ms + index * 5_000);
+ assert.equal(container.children.length, rows.length);
+ rows.forEach((row, index) => assert.equal(container.children[index], row));
+ assert.equal(observer.takeRecords().length, 0);
+ assert.notEqual(freshness.textContent, before);
+ assert.equal(panel.size, 20);
+ panel.setLocale('zh-CN');
+ assert.notEqual(container.firstChild, rows[0]);
+ assert.match(freshness.textContent, /UTC\+08/);
+ observer.disconnect();
+ panel.destroy();
+ dom.window.close();
+});
+
for (const state of ['module_disabled', 'gateway_unavailable', 'unavailable']) {
test(`${state} removes current readiness while preserving historical signals across locale changes`, () => {
const dom = new JSDOM('');
diff --git a/test/dom/binance-trading-data-footer.test.js b/test/dom/binance-trading-data-footer.test.js
new file mode 100644
index 0000000..f21aca2
--- /dev/null
+++ b/test/dom/binance-trading-data-footer.test.js
@@ -0,0 +1,77 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import test from 'node:test';
+import vm from 'node:vm';
+import { JSDOM } from 'jsdom';
+
+const source = readFileSync(new URL('../../src/binance-trading-data/index.user.js', import.meta.url), 'utf8');
+
+function declaration(name) {
+ const start = source.indexOf(`function ${name}(`);
+ assert.notEqual(start, -1);
+ const brace = source.indexOf('{', start);
+ let depth = 0;
+ for (let index = brace; index < source.length; index += 1) {
+ if (source[index] === '{') depth += 1;
+ if (source[index] === '}') depth -= 1;
+ if (depth === 0) return source.slice(start, index + 1);
+ }
+ assert.fail('function declaration must be complete');
+}
+
+function harness(t) {
+ const dom = new JSDOM('');
+ t.after(() => dom.window.close());
+ const updatedAt = new Date(2026, 8, 12, 12, 34, 56).getTime();
+ let now = updatedAt;
+ t.mock.method(Date, 'now', () => now);
+ const context = vm.createContext({
+ document: dom.window.document, window: dom.window, Date,
+ PANEL_ID: 'jh-binance-trading-data-panel', PANEL_WIDTH: 240,
+ C: { bg: '#fff', border: '#ddd', sub: '#666', text: '#111' },
+ lastUpdateTs: updatedAt,
+ injectFlashStyle() {}, normalizeSavedPosition: () => null,
+ loadPosition: () => null, loadCollapsed: () => false,
+ keepPanelInViewport() {}, savePanelPosition() {}, cleanupPanelDrag() {},
+ setupDrag() {}, setupCollapseAndClose() {}, cleanupPanelUnload() {},
+ });
+ vm.runInContext(declaration('ensurePanel'), context);
+ vm.runInContext(declaration('updateFooter'), context);
+ const panel = context.ensurePanel();
+ const footer = panel.querySelector('#jh-binance-trading-data-panel-footer');
+ context.updateFooter(footer);
+ return { context, footer, window: dom.window, updatedAt, setNow: value => { now = value; } };
+}
+
+test('sixty elapsed-time updates preserve the footer elements and update the displayed seconds', t => {
+ const h = harness(t);
+ const row = h.footer.firstElementChild;
+ const labels = [...row.children];
+ for (let second = 1; second <= 60; second += 1) {
+ h.setNow(h.updatedAt + second * 1000);
+ h.context.updateFooter(h.footer);
+ }
+ assert.equal(h.footer.firstElementChild, row);
+ assert.equal(row.children[0], labels[0]);
+ assert.equal(row.children[1], labels[1]);
+ assert.equal(labels[0].textContent, '更新于 12:34:56');
+ assert.equal(labels[1].textContent, '60秒前');
+});
+
+test('a repeated render in the same second produces no footer DOM mutation', t => {
+ const h = harness(t);
+ const observer = new h.window.MutationObserver(() => {});
+ observer.observe(h.footer, { childList: true, subtree: true, characterData: true });
+ h.context.updateFooter(h.footer);
+ assert.equal(observer.takeRecords().length, 0);
+ observer.disconnect();
+});
+
+test('new data updates the retained timestamp label and resets elapsed seconds', t => {
+ const h = harness(t);
+ h.context.lastUpdateTs = h.updatedAt + 300_000;
+ h.setNow(h.context.lastUpdateTs);
+ h.context.updateFooter(h.footer);
+ assert.equal(h.footer.children[0].children[0].textContent, '更新于 12:39:56');
+ assert.equal(h.footer.children[0].children[1].textContent, '0秒前');
+});
diff --git a/test/dom/coinmarketcap-valuation-helper.test.js b/test/dom/coinmarketcap-valuation-helper.test.js
new file mode 100644
index 0000000..502ca70
--- /dev/null
+++ b/test/dom/coinmarketcap-valuation-helper.test.js
@@ -0,0 +1,104 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import test from 'node:test';
+import { JSDOM } from 'jsdom';
+
+const source = readFileSync(new URL('../../scripts/coinmarketcap-valuation-helper.user.js', import.meta.url), 'utf8');
+
+function harness(t, markup, { pathname = '/zh/currencies/bitcoin/' } = {}) {
+ const dom = new JSDOM(markup, {
+ url: `https://coinmarketcap.com${pathname}`, runScripts: 'outside-only', pretendToBeVisual: true,
+ });
+ t.after(() => dom.window.close());
+ const { window } = dom;
+ const document = window.document;
+ const frames = [];
+ const measured = [];
+ let scans = 0;
+ window.requestAnimationFrame = callback => { frames.push(callback); return frames.length; };
+ window.Element.prototype.getBoundingClientRect = function () {
+ measured.push(this);
+ return {
+ width: this.closest('[data-hidden]') ? 0 : 120, height: 24,
+ top: Number(this.closest('[data-top]')?.dataset.top ?? 200), left: 100,
+ };
+ };
+ window.getComputedStyle = () => ({ display: 'block', visibility: 'visible' });
+ const query = document.querySelectorAll.bind(document);
+ document.querySelectorAll = selector => {
+ if (selector === 'span,p,div') scans += 1;
+ return query(selector);
+ };
+ window.eval(source);
+ async function flush() {
+ for (let round = 0; round < 5; round += 1) {
+ await Promise.resolve();
+ if (!frames.length) return;
+ for (const callback of frames.splice(0)) callback();
+ }
+ assert.equal(frames.length, 0, 'renaming must settle without an observer feedback loop');
+ }
+ return { window, document, measured, frames, flush, get scans() { return scans; } };
+}
+
+const cards = `
+ 市值 $123M
+ FDV $456M
+`;
+
+test('valuation matching never reads layout for unrelated quote rows', t => {
+ const background = Array.from({ length: 200 }, (_, index) => ``).join('');
+ const h = harness(t, cards + background);
+ assert.equal(h.document.querySelector('#cap').textContent, '流通市值');
+ assert.equal(h.document.querySelector('#fdv').textContent, 'FDV/总估值');
+ assert.equal(h.document.querySelectorAll('.jh-cmc-valuation-highlight').length, 2);
+ assert.equal(h.measured.filter(element => element.closest('.quote')).length, 0);
+ assert.ok(h.measured.length <= 12, `only the two metric scopes/cards need layout, received ${h.measured.length}`);
+});
+
+test('independent price mutation batches share one rename scan per animation frame', async t => {
+ const h = harness(t, cards + '123
');
+ const initialScans = h.scans;
+ for (let index = 0; index < 10; index += 1) {
+ h.document.querySelector('#price').firstChild.data = String(index);
+ await Promise.resolve();
+ }
+ assert.equal(h.scans, initialScans);
+ assert.equal(h.frames.length, 1);
+ await h.flush();
+ assert.equal(h.scans, initialScans + 1);
+ assert.equal(h.document.querySelector('#cap').textContent, '流通市值');
+});
+
+test('text-only labels still react to a delayed nearby value and replacement labels', async t => {
+ const h = harness(t, '');
+ assert.equal(h.document.querySelector('#label').textContent, '市值');
+ h.document.querySelector('#value').textContent = '$123';
+ await h.flush();
+ assert.equal(h.document.querySelector('#label').textContent, '流通市值');
+ h.document.querySelector('#label').replaceWith(Object.assign(h.document.createElement('p'), { id: 'label', textContent: '完全稀释估值 (FDV)' }));
+ await h.flush();
+ assert.equal(h.document.querySelector('#label').textContent, 'FDV/总估值');
+});
+
+test('hidden and out-of-area labels retain the original text', t => {
+ const h = harness(t, `
+ 市值 $123M
+ FDV $456M
+ `);
+ assert.equal(h.document.querySelector('#hidden').textContent, '市值');
+ assert.equal(h.document.querySelector('#lower').textContent, 'FDV');
+});
+
+test('a queued scan validates the current route before changing labels', async t => {
+ const h = harness(t, cards);
+ h.document.querySelector('#cap').textContent = '市值';
+ await Promise.resolve();
+ h.window.history.pushState({}, '', '/zh/watchlist/');
+ await h.flush();
+ assert.equal(h.document.querySelector('#cap').textContent, '市值');
+ h.window.history.pushState({}, '', '/zh/currencies/ethereum/');
+ h.document.querySelector('#cap').textContent = '市值';
+ await h.flush();
+ assert.equal(h.document.querySelector('#cap').textContent, '流通市值');
+});
diff --git a/test/dom/m3u8-media-scan.test.js b/test/dom/m3u8-media-scan.test.js
new file mode 100644
index 0000000..ed507d5
--- /dev/null
+++ b/test/dom/m3u8-media-scan.test.js
@@ -0,0 +1,131 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import test from 'node:test';
+import { JSDOM } from 'jsdom';
+
+const source = readFileSync(new URL('../../scripts/m3u8-downloader.user.js', import.meta.url), 'utf8');
+
+async function harness(t, markup = '') {
+ const dom = new JSDOM(markup, { url: 'https://njav.com/watch/fixture', runScripts: 'outside-only', pretendToBeVisual: true });
+ t.after(() => dom.window.close());
+ const { window } = dom;
+ const frames = [];
+ const requests = [];
+ const scannedVideos = [];
+ let documentScans = 0;
+ window.requestAnimationFrame = callback => { frames.push(callback); return frames.length; };
+ window.XMLHttpRequest = class {
+ open(method, url) { assert.equal(method, 'GET'); requests.push(url); }
+ send() {}
+ };
+ const query = window.document.querySelectorAll.bind(window.document);
+ window.document.querySelectorAll = selector => {
+ if (selector === 'video') documentScans += 1;
+ return query(selector);
+ };
+ const elementQuery = window.Element.prototype.querySelectorAll;
+ window.Element.prototype.querySelectorAll = function (selector) {
+ if (this.tagName === 'VIDEO' && selector === 'source') scannedVideos.push(this);
+ return elementQuery.call(this, selector);
+ };
+ async function flush() {
+ await Promise.resolve();
+ for (const callback of frames.splice(0)) callback();
+ await Promise.resolve();
+ }
+ window.eval(source);
+ await new Promise(setImmediate);
+ await flush();
+ scannedVideos.length = 0;
+ documentScans = 0;
+ return { window, document: window.document, frames, requests, scannedVideos, flush, get documentScans() { return documentScans; } };
+}
+
+test('a source change scans only its owning video among 100 existing videos', async t => {
+ const h = await harness(t, Array.from({ length: 100 }, (_, index) => ` `).join(''));
+ const video = h.document.querySelector('video');
+ video.firstChild.src = 'https://media.example/new.m3u8';
+ await h.flush();
+ assert.deepEqual(h.requests, ['https://media.example/new.m3u8']);
+ assert.deepEqual(h.scannedVideos, [video]);
+ assert.equal(h.documentScans, 0);
+});
+
+test('separate mutations deduplicate videos and preserve all sources discovered in the frame', async t => {
+ const h = await harness(t, ' ');
+ const a = h.document.querySelector('#a');
+ const b = h.document.querySelector('#b');
+ a.src = 'https://media.example/a.m3u8';
+ await Promise.resolve();
+ const sourceNode = Object.assign(h.document.createElement('source'), { src: 'https://media.example/alt.m3u8' });
+ a.appendChild(sourceNode);
+ b.src = 'https://media.example/b.m3u8';
+ await Promise.resolve();
+ assert.equal(h.frames.length, 1);
+ await h.flush();
+ assert.deepEqual(h.scannedVideos, [a, b]);
+ assert.deepEqual(h.requests.sort(), ['https://media.example/a.m3u8', 'https://media.example/alt.m3u8', 'https://media.example/b.m3u8']);
+});
+
+test('added media subtrees are scanned without revisiting unrelated existing videos', async t => {
+ const h = await harness(t, ' ');
+ const wrapper = h.document.createElement('div');
+ wrapper.innerHTML = '';
+ h.document.body.appendChild(wrapper);
+ await h.flush();
+ assert.deepEqual(h.scannedVideos.map(video => video.id), ['new']);
+ assert.deepEqual(h.requests, ['https://media.example/added.m3u8']);
+ assert.equal(h.documentScans, 0);
+});
+
+test('a video removed before the frame is not scanned or requested', async t => {
+ const h = await harness(t, ' ');
+ const video = h.document.querySelector('video');
+ video.src = 'https://media.example/removed.m3u8';
+ await Promise.resolve();
+ video.remove();
+ await h.flush();
+ assert.deepEqual(h.scannedVideos, []);
+ assert.deepEqual(h.requests, []);
+});
+
+test('a queued video adopted into another document is no longer owned by this scanner', async t => {
+ const h = await harness(t, ' ');
+ const video = h.document.querySelector('video');
+ video.src = 'https://media.example/moved.m3u8';
+ await Promise.resolve();
+ const otherDocument = h.document.implementation.createHTMLDocument('Other media document');
+ otherDocument.body.appendChild(video);
+ assert.equal(video.isConnected, true);
+ await h.flush();
+ assert.deepEqual(h.requests, []);
+});
+
+test('sources inside a newly inserted wrapper still queue their existing owning video', async t => {
+ const h = await harness(t, ' ');
+ const wrapper = h.document.createElement('div');
+ wrapper.innerHTML = '';
+ h.document.querySelector('#owner').appendChild(wrapper);
+ await h.flush();
+ assert.deepEqual(h.scannedVideos.map(video => video.id), ['owner']);
+ assert.deepEqual(h.requests, ['https://media.example/nested.m3u8']);
+});
+
+test('non-media src mutations and text updates queue no media work', async t => {
+ const h = await harness(t, 'Old
');
+ h.document.querySelector('img').src = 'https://media.example/image.jpg';
+ h.document.querySelector('picture source').src = 'https://media.example/preview.webp';
+ h.document.querySelector('p').textContent = 'New';
+ await Promise.resolve();
+ assert.equal(h.frames.length, 0);
+ assert.deepEqual(h.scannedVideos, []);
+});
+
+test('initial media discovery and later load discovery keep request deduplication', async t => {
+ const h = await harness(t, ' ');
+ assert.deepEqual(h.requests, ['https://media.example/initial.m3u8']);
+ h.document.querySelector('video').setAttribute('src', 'https://media.example/initial.m3u8');
+ await h.flush();
+ assert.deepEqual(h.requests, ['https://media.example/initial.m3u8']);
+ assert.equal(h.scannedVideos.length, 1);
+});
diff --git a/test/manual/userscript-performance-benchmark.mjs b/test/manual/userscript-performance-benchmark.mjs
new file mode 100644
index 0000000..7eb63d2
--- /dev/null
+++ b/test/manual/userscript-performance-benchmark.mjs
@@ -0,0 +1,142 @@
+import assert from 'node:assert/strict';
+import { execFileSync } from 'node:child_process';
+import { readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { build } from 'esbuild';
+import { JSDOM } from 'jsdom';
+
+const root = fileURLToPath(new URL('../../', import.meta.url));
+const baseline = process.argv[2];
+if (process.argv.length > 3) throw new Error('Usage: node test/manual/userscript-performance-benchmark.mjs [baseline-revision]');
+
+function readSource(path, revision) {
+ return revision === undefined
+ ? readFileSync(join(root, path), 'utf8')
+ : execFileSync('git', ['show', `${revision}:${path}`], { cwd: root, encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 });
+}
+
+async function flushFrames(frames) {
+ for (let round = 0; round < 5; round += 1) {
+ await Promise.resolve();
+ if (!frames.length) return;
+ for (const callback of frames.splice(0)) callback();
+ }
+ assert.equal(frames.length, 0, 'fixture did not settle');
+}
+
+async function valuation(revision) {
+ const markup = `市值 $123M
+ FDV $456M
`
+ + Array.from({ length: 1000 }, (_, index) => ``).join('');
+ const dom = new JSDOM(markup, { url: 'https://coinmarketcap.com/zh/currencies/bitcoin/', runScripts: 'outside-only' });
+ const { window } = dom;
+ const counts = { layoutReads: 0, styleReads: 0, fullTextScans: 0 };
+ const frames = [];
+ window.requestAnimationFrame = callback => { frames.push(callback); return frames.length; };
+ window.Element.prototype.getBoundingClientRect = () => {
+ counts.layoutReads += 1;
+ return { width: 100, height: 24, top: 200, left: 100 };
+ };
+ window.getComputedStyle = () => { counts.styleReads += 1; return { display: 'block', visibility: 'visible' }; };
+ const query = window.document.querySelectorAll.bind(window.document);
+ window.document.querySelectorAll = selector => {
+ if (selector === 'span,p,div') counts.fullTextScans += 1;
+ return query(selector);
+ };
+ try {
+ window.eval(readSource('scripts/coinmarketcap-valuation-helper.user.js', revision));
+ await flushFrames(frames);
+ const initial = { ...counts };
+ counts.layoutReads = counts.styleReads = counts.fullTextScans = 0;
+ for (let index = 0; index < 10; index += 1) {
+ window.document.querySelector('.quote span').firstChild.data = String(index + 1000);
+ await Promise.resolve();
+ }
+ await flushFrames(frames);
+ assert.equal(window.document.querySelector('#cap').textContent, '流通市值');
+ assert.equal(window.document.querySelector('#fdv').textContent, 'FDV/总估值');
+ return { irrelevantQuoteRows: 1000, initial, tenMutationBatchesBeforePaint: { ...counts } };
+ } finally {
+ window.close();
+ }
+}
+
+async function media(revision) {
+ const dom = new JSDOM(Array.from({ length: 100 }, () => ' ').join(''), { url: 'https://njav.com/watch/fixture', runScripts: 'outside-only' });
+ const { window } = dom;
+ const frames = [];
+ const counts = { documentScans: 0, videoScans: 0, requests: [] };
+ window.requestAnimationFrame = callback => { frames.push(callback); return frames.length; };
+ window.XMLHttpRequest = class {
+ open(_method, url) { counts.requests.push(url); }
+ send() {}
+ };
+ const query = window.document.querySelectorAll.bind(window.document);
+ window.document.querySelectorAll = selector => {
+ if (selector === 'video') counts.documentScans += 1;
+ return query(selector);
+ };
+ const elementQuery = window.Element.prototype.querySelectorAll;
+ window.Element.prototype.querySelectorAll = function (selector) {
+ if (this.tagName === 'VIDEO' && selector === 'source') counts.videoScans += 1;
+ return elementQuery.call(this, selector);
+ };
+ try {
+ window.eval(readSource('scripts/m3u8-downloader.user.js', revision));
+ await new Promise(setImmediate);
+ await flushFrames(frames);
+ counts.documentScans = counts.videoScans = 0;
+ window.document.querySelector('source').src = 'https://media.example/changed.m3u8';
+ await flushFrames(frames);
+ assert.deepEqual(counts.requests, ['https://media.example/changed.m3u8']);
+ return { existingVideos: 100, ...counts };
+ } finally {
+ window.close();
+ }
+}
+
+async function summary(revision) {
+ const path = 'src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js';
+ // The panel's dependencies are unchanged; only its implementation is compared.
+ const built = await build({
+ stdin: { contents: readSource(path, revision), resolveDir: dirname(join(root, path)), loader: 'js' },
+ bundle: true, format: 'esm', platform: 'node', write: false,
+ });
+ const { createStrategy29SummaryPanel } = await import(`data:text/javascript;base64,${Buffer.from(built.outputFiles[0].text).toString('base64')}`);
+ const fixture = JSON.parse(readFileSync(join(root, 'test/fixtures/strategy29-gateway-events.json'), 'utf8'));
+ const dom = new JSDOM('', { url: 'https://www.binance.com/en/futures/BTCUSDT' });
+ const document = dom.window.document;
+ const panel = createStrategy29SummaryPanel(document, 'BTC/USDT:USDT', { loadPosition: () => null, savePosition() {} });
+ try {
+ panel.addEvents(Array.from({ length: 20 }, (_, index) => ({
+ ...fixture.events[0], event_id: `performance-${index}`, sequence: index + 1,
+ bar_close_ms: fixture.events[0].bar_close_ms + index * 60_000,
+ })), fixture.observed_at_ms);
+ const container = document.querySelector('[data-role="events"]');
+ const firstRow = container.firstChild;
+ const counts = { replacements: 0, elementCreations: 0 };
+ const replace = container.replaceChildren.bind(container);
+ container.replaceChildren = (...args) => { counts.replacements += 1; return replace(...args); };
+ const create = document.createElement.bind(document);
+ document.createElement = (...args) => { counts.elementCreations += 1; return create(...args); };
+ for (let index = 1; index <= 10; index += 1) panel.addEvents([], fixture.observed_at_ms + index * 5000);
+ assert.equal(panel.size, 20);
+ return { emptyPolls: 10, retainedRows: panel.size, ...counts, firstRowRetained: firstRow === container.firstChild };
+ } finally {
+ panel.destroy();
+ dom.window.close();
+ }
+}
+
+async function measure(revision) {
+ return { valuation: await valuation(revision), media: await media(revision), summary: await summary(revision) };
+}
+
+const report = {
+ scope: 'Deterministic synthetic DOM operation counts; not live site CPU or wall-clock performance.',
+ baselineRevision: baseline ?? null,
+ baseline: baseline === undefined ? null : await measure(baseline),
+ current: await measure(),
+};
+console.log(JSON.stringify(report, null, 2));
diff --git a/test/unit/binance-orderbook-trade/depth-profile-render-cycle.test.js b/test/unit/binance-orderbook-trade/depth-profile-render-cycle.test.js
new file mode 100644
index 0000000..e2845d1
--- /dev/null
+++ b/test/unit/binance-orderbook-trade/depth-profile-render-cycle.test.js
@@ -0,0 +1,100 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import test from 'node:test';
+import vm from 'node:vm';
+
+const source = readFileSync(new URL('../../../src/binance-orderbook-trade/index.user.js', import.meta.url), 'utf8');
+
+function declaration(name) {
+ const start = source.indexOf(`function ${name}(`);
+ assert.notEqual(start, -1);
+ const brace = source.indexOf('{', start);
+ let depth = 0;
+ for (let index = brace; index < source.length; index += 1) {
+ if (source[index] === '{') depth += 1;
+ if (source[index] === '}') depth -= 1;
+ if (depth === 0) return source.slice(start, index + 1);
+ }
+ assert.fail('function declaration must be complete');
+}
+
+function harness() {
+ const view = { isConnected: true };
+ const frame = {};
+ const geometryReads = [];
+ const painted = [];
+ let nextHeight = 400;
+ const context = vm.createContext({
+ document: { hidden: false },
+ depthProfileView: view, depthProfileFrame: frame,
+ depthProfileEnabled: true, depthProfileData: { symbol: '4USDT', bids: [], asks: [] },
+ depthProfileSession: { symbol: '4USDT', isActive: () => true },
+ depthProfileFailedSymbol: null,
+ isFuturesTradingPage: () => true,
+ ensureDepthProfileObserver() {},
+ findDepthProfileHost: () => ({ frame, host: {} }),
+ getTradingViewDepthProfileGeometry: receivedFrame => {
+ assert.equal(receivedFrame, frame);
+ const geometry = nextHeight === null ? null : { height: nextHeight, top: 0, rightInset: 60 };
+ geometryReads.push(geometry);
+ return geometry;
+ },
+ ensureDepthProfileView: () => view,
+ setDepthProfileGeometry() {},
+ depthProfileStatusPresentation: () => ({ text: '', title: '' }),
+ localizedText: (_zh, en) => en,
+ ui: value => value,
+ setDepthProfileViewState() {},
+ getCurrentDepthProfilePrice: () => 42,
+ renderDepthProfile: (receivedView, data, geometry, price) => {
+ assert.equal(receivedView, view);
+ assert.equal(data.symbol, '4USDT');
+ assert.equal(price, 42);
+ painted.push(geometry);
+ },
+ clearDepthProfile() {},
+ scheduleDepthProfileSync() {},
+ getCurrentSymbol: () => '4USDT',
+ stopDepthProfileSession() {},
+ removeDepthProfileRuntimeView: () => { context.depthProfileView = null; },
+ err: (_label, error) => { throw error; },
+ });
+ const renderStart = source.indexOf(' function renderDepthProfileUi(');
+ const renderEnd = source.indexOf(' function scheduleDepthProfileRender(', renderStart);
+ vm.runInContext(source.slice(renderStart, renderEnd), context);
+ vm.runInContext(declaration('syncDepthProfile'), context);
+ return { context, geometryReads, painted, view, setHeight: height => { nextHeight = height; } };
+}
+
+test('depth synchronization measures geometry once and paints that exact snapshot', () => {
+ const h = harness();
+ h.context.syncDepthProfile();
+ assert.equal(h.geometryReads.length, 1);
+ assert.equal(h.painted.length, 1);
+ assert.equal(h.painted[0], h.geometryReads[0]);
+ assert.equal(h.painted[0].height, 400);
+});
+
+test('the next independent depth render samples changed geometry again', () => {
+ const h = harness();
+ h.context.syncDepthProfile();
+ h.setHeight(600);
+ h.context.renderDepthProfileUi();
+ assert.equal(h.geometryReads.length, 2);
+ assert.deepEqual(h.painted.map(geometry => geometry.height), [400, 600]);
+ assert.equal(h.painted[1], h.geometryReads[1]);
+});
+
+test('unavailable geometry, detached view and hidden documents do not paint', () => {
+ const h = harness();
+ h.setHeight(null);
+ h.context.syncDepthProfile();
+ assert.equal(h.geometryReads.length, 1);
+ assert.equal(h.context.depthProfileView, null);
+ assert.equal(h.painted.length, 0);
+ h.context.renderDepthProfileUi();
+ assert.equal(h.geometryReads.length, 1);
+ h.context.document.hidden = true;
+ h.context.syncDepthProfile();
+ assert.equal(h.geometryReads.length, 1);
+});
diff --git a/test/unit/binance-strategy29-bollinger/source-regressions.test.js b/test/unit/binance-strategy29-bollinger/source-regressions.test.js
index b440cf4..14bd5c4 100644
--- a/test/unit/binance-strategy29-bollinger/source-regressions.test.js
+++ b/test/unit/binance-strategy29-bollinger/source-regressions.test.js
@@ -52,7 +52,7 @@ test('Bollinger chart alert failures distinguish snapshot races from contract fa
});
test('Strategy29 sandbox metadata is exact and coordinates through unsafeWindow', () => {
- assert.equal(readUserscriptVersion(entrySource), '0.5.3');
+ assert.equal(readUserscriptVersion(entrySource), '0.5.4');
assert.deepEqual(
[...entrySource.matchAll(/^\/\/ @grant\s+(\S+)\s*$/gm)].map(match => match[1]),
['unsafeWindow', 'GM_getValue', 'GM_setValue'],
diff --git a/test/unit/userscript-release-contract.test.js b/test/unit/userscript-release-contract.test.js
index 0d8c393..6fb0f9d 100644
--- a/test/unit/userscript-release-contract.test.js
+++ b/test/unit/userscript-release-contract.test.js
@@ -21,7 +21,7 @@ test('Strategy29 has an independent observation-only install identity', async ()
const metadata = parseUserscriptMetadata(text);
assert.equal(contract.name, '【自写】Binance Strategy 29 布林带信号');
assert.equal(contract.namespace, 'binance.strategy29.bollinger');
- assert.equal(contract.version, '0.5.3');
+ assert.equal(contract.version, '0.5.4');
assert.equal(contract.runAt, 'document-start');
assert.equal(contract.updateURL, 'https://raw.githubusercontent.com/jackhai9/userscripts/main/scripts/binance-strategy29-bollinger.user.js');
assert.equal(contract.downloadURL, contract.updateURL);
@@ -61,7 +61,7 @@ test('release contract identifies the generated Strategy 27 annotation artifact'
assert.equal(contract.name, '【自写】Binance Strategy 27 事件标注');
assert.equal(contract.namespace, 'binance.strategy27.events');
- assert.equal(contract.version, '0.6.3');
+ assert.equal(contract.version, '0.6.4');
assert.equal(contract.runAt, 'document-idle');
assert.equal(contract.updateURL, contract.downloadURL);
assert.deepEqual(contract.matches, [