您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Jump to the page containing a target rating (MMR) using bounded binary search. UI injected into LeaderboardsFilters; live status shown bottom-right.
// ==UserScript== // @name Hearthstone Leaderboard Rating Navigator // @namespace https://hearthstone.blizzard.com/ // @version 1.0.0 // @description Jump to the page containing a target rating (MMR) using bounded binary search. UI injected into LeaderboardsFilters; live status shown bottom-right. // @match https://hearthstone.blizzard.com/community/leaderboards* // @match https://hearthstone.blizzard.com/*/community/leaderboards* // @grant none // @run-at document-idle // @license MIT // ==/UserScript== (function() { 'use strict'; // ------- Configuration ------- const UI_ID = 'hs-rating-navigator'; const STATUS_ID = 'hs-rating-navigator-status'; const STATE_KEY = 'hsRatingSearchState'; const MAX_STEPS = 50; // safety cap const TABLE_SELECTOR = "[class*='LeaderboardsTable-Rendered']"; const FILTERS_SELECTOR = ".LeaderboardsFilters"; const ROW_SELECTOR = ".row"; const COL_RANK_SELECTOR = ".col-rank"; const COL_RATING_SELECTOR = ".col-rating"; const PAGINATION_CONTAINER = ".PaginationContainer"; const MIN_RATING = 8000; // ------- Utilities ------- const sleep = (ms) => new Promise(res => setTimeout(res, ms)); function getCurrentPage() { const url = new URL(location.href); const p = parseInt(url.searchParams.get('page') || '1', 10); return Number.isFinite(p) && p >= 1 ? p : 1; } function writeState(s) { sessionStorage.setItem(STATE_KEY, JSON.stringify(s)); } function readState() { try { const raw = sessionStorage.getItem(STATE_KEY); return raw ? JSON.parse(raw) : null; } catch { return null; } } function clearState() { sessionStorage.removeItem(STATE_KEY); } function ensureFiltersContainer() { return document.querySelector(FILTERS_SELECTOR); } function ensureTableContainer() { return document.querySelector(TABLE_SELECTOR); } async function waitForTable(timeoutMs = 10000) { const start = Date.now(); while (Date.now() - start < timeoutMs) { const table = ensureTableContainer(); if (table) { const rows = table.querySelectorAll(ROW_SELECTOR); if (rows && rows.length > 0) return table; } await sleep(100); } return ensureTableContainer(); } async function waitForPagination(timeoutMs = 8000) { const start = Date.now(); while (Date.now() - start < timeoutMs) { const maxPage = getMaxPageFromUI(); if (maxPage != null) return maxPage; await sleep(100); } return getMaxPageFromUI(); } function getMaxPageFromUI() { const cont = document.querySelector(PAGINATION_CONTAINER); if (!cont) return null; const btns = Array.from(cont.querySelectorAll('button')); let maxNum = null; for (const b of btns) { const t = (b.textContent || '').trim(); const n = parseInt(t, 10); if (Number.isFinite(n)) { if (maxNum == null || n > maxNum) maxNum = n; } } return maxNum; // could be null if no numeric buttons yet (single-page case) } function parsePage() { const table = ensureTableContainer(); if (!table) return null; const rows = Array.from(table.querySelectorAll(ROW_SELECTOR)); const parsed = []; for (const r of rows) { const rankEl = r.querySelector(COL_RANK_SELECTOR); const ratingEl = r.querySelector(COL_RATING_SELECTOR); if (!rankEl || !ratingEl) continue; const rank = parseInt((rankEl.textContent || '').trim().replace(/[, ]+/g, ''), 10); const rating = parseInt((ratingEl.textContent || '').trim().replace(/[, ]+/g, ''), 10); if (Number.isFinite(rank) && Number.isFinite(rating)) { parsed.push({ row: r, rank, rating }); } } if (parsed.length === 0) return null; // ranks ascend down the page; ratings descend with rank parsed.sort((a, b) => a.rank - b.rank); const ratings = parsed.map(p => p.rating); const minRating = Math.min(...ratings); const maxRating = Math.max(...ratings); return { rows: parsed, minRating, maxRating, minRank: parsed[0].rank, maxRank: parsed[parsed.length - 1].rank, count: parsed.length }; } function highlightByRating(targetRating) { const table = ensureTableContainer(); if (!table) return false; const rows = Array.from(table.querySelectorAll(ROW_SELECTOR)).map(r => { const ratingEl = r.querySelector(COL_RATING_SELECTOR); const rankEl = r.querySelector(COL_RANK_SELECTOR); if (!ratingEl || !rankEl) return null; const rating = parseInt((ratingEl.textContent || '').trim().replace(/[, ]+/g, ''), 10); const rank = parseInt((rankEl.textContent || '').trim().replace(/[, ]+/g, ''), 10); if (!Number.isFinite(rating) || !Number.isFinite(rank)) return null; return { r, rating, rank }; }).filter(Boolean); if (!rows.length) return false; // exact match preferred; otherwise nearest rating (if tie, prefer higher rating / better rank) let best = null; let bestDiff = Infinity; for (const x of rows) { const diff = Math.abs(x.rating - targetRating); if (diff < bestDiff || (diff === bestDiff && x.rating > (best?.rating ?? -Infinity))) { best = x; bestDiff = diff; } if (diff === 0) break; } if (!best) return false; const elem = best.r; elem.scrollIntoView({ behavior: 'smooth', block: 'center' }); elem.style.outline = '3px solid gold'; elem.style.borderRadius = '6px'; elem.style.background = 'rgba(255, 215, 0, 0.12)'; return true; } // ------- UI ------- function ensureControlUI() { if (document.getElementById(UI_ID)) return; const filters = ensureFiltersContainer(); if (!filters) return; const wrap = document.createElement('div'); wrap.id = UI_ID; wrap.style.display = 'flex'; wrap.style.alignItems = 'center'; wrap.style.gap = '8px'; wrap.style.marginLeft = '8px'; wrap.style.marginRight = '-40px'; wrap.style.padding = '8px'; wrap.style.border = '2px solid rgba(255,255,255,0.15)'; wrap.style.borderRadius = '6px'; wrap.style.fontFamily = 'Belwe Bold,Open Sans,Helvetica,Arial,sans-serif'; wrap.style.background = 'rgb(195, 177, 137)'; wrap.style.boxShadow = 'inset 0px 0px 4px #0000007d'; const label = document.createElement('label'); label.textContent = 'Jump to Rating:'; label.style.whiteSpace = 'nowrap'; label.style.fontWeight = '600'; label.style.color = 'rgb(97, 67, 38)'; const input = document.createElement('input'); input.type = 'number'; input.min = String(MIN_RATING); input.step = '1'; input.placeholder = 'e.g., 8200'; input.style.padding = '4px 8px'; input.style.width = '120px'; input.setAttribute('aria-label', 'Target Rating'); const btn = document.createElement('button'); btn.textContent = 'Search'; btn.style.padding = '4px 10px'; btn.style.cursor = 'pointer'; btn.style.border = '2px solid rgb(97, 67, 38)'; btn.style.backgroundColor = 'rgb(240, 223, 182)'; btn.style.borderRadius = '4px'; btn.style.color = 'rgb(35, 58, 110)'; //const stopBtn = document.createElement('button'); //stopBtn.textContent = 'Stop'; //stopBtn.style.padding = '4px 10px'; //stopBtn.style.cursor = 'pointer'; filters.appendChild(wrap); // append as last child per requirement wrap.appendChild(label); wrap.appendChild(input); wrap.appendChild(btn); //wrap.appendChild(stopBtn); const st = readState(); if (st && st.targetRating) input.value = String(st.targetRating); btn.addEventListener('click', () => { const val = parseInt((input.value || '').trim(), 10); if (!Number.isFinite(val) || val < MIN_RATING) { setStatus(`Enter a rating ≥ ${MIN_RATING}.`); return; } startSearch(val); }); //stopBtn.addEventListener('click', () => stopSearch('Stopped.')); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { const val = parseInt((input.value || '').trim(), 10); if (!Number.isFinite(val) || val < MIN_RATING) { setStatus(`Enter a rating ≥ ${MIN_RATING}.`); return; } startSearch(val); } }); } function ensureStatusUI() { let box = document.getElementById(STATUS_ID); if (box) return box; box = document.createElement('div'); box.id = STATUS_ID; box.style.position = 'fixed'; box.style.right = '12px'; box.style.bottom = '12px'; box.style.zIndex = '2147483647'; box.style.maxWidth = '360px'; box.style.background = 'rgba(20,22,30,0.9)'; box.style.backdropFilter = 'blur(4px)'; box.style.color = '#fff'; box.style.fontFamily = 'monospace'; box.style.fontSize = '12px'; box.style.lineHeight = '1.4'; box.style.padding = '10px 12px'; box.style.border = '1px solid rgba(255,255,255,0.2)'; box.style.borderRadius = '8px'; box.style.boxShadow = '0 4px 16px rgba(0,0,0,0.35)'; box.innerHTML = ` <div style="display:flex;align-items:center;justify-content:space-between;gap:8px;"> <strong>Rating Search</strong> <button id="${STATUS_ID}-close" style="cursor:pointer;font-family:inherit;font-size:11px;padding:2px 6px;">✕</button> </div> <div id="${STATUS_ID}-content" style="margin-top:6px;white-space:pre;"></div> <div style="margin-top:6px;display:flex;gap:8px;"> <button id="${STATUS_ID}-step" style="cursor:pointer;font-size:11px;padding:2px 6px;">Step</button> <button id="${STATUS_ID}-stop" style="cursor:pointer;font-size:11px;padding:2px 6px;">Stop</button> </div> `; document.body.appendChild(box); document.getElementById(`${STATUS_ID}-close`)?.addEventListener('click', () => box.remove()); document.getElementById(`${STATUS_ID}-stop`)?.addEventListener('click', () => stopSearch('Stopped.')); document.getElementById(`${STATUS_ID}-step`)?.addEventListener('click', () => continueSearchStep()); return box; } function setStatus(text) { ensureStatusUI(); const el = document.getElementById(`${STATUS_ID}-content`); if (el) el.textContent = text; } // ------- Search control flow (by rating) ------- function startSearch(targetRating) { const currentPage = getCurrentPage(); const st = { active: true, targetRating, lo: null, // page known to be "too early" (ratings too high) → need later page hi: null, // page known to be "too late" (ratings too low) → need earlier page maxPage: null, // discovered from pagination UI steps: 0, startedAt: Date.now(), last: null }; writeState(st); setStatus(`Searching for rating ${targetRating}… starting at page ${currentPage}…`); continueSearchStep(); } function stopSearch(msg = 'Search ended.') { const st = readState(); if (st) { st.active = false; writeState(st); } setStatus(msg); } function navigate(nextPage, st) { // Clamp to discovered bounds const maxPage = st.maxPage || getMaxPageFromUI() || null; let target = Math.floor(nextPage); if (target < 1) target = 1; if (maxPage != null && target > maxPage) { st.hi = maxPage; writeState(st); setStatus(renderStatus(st, `Hit max page (${maxPage}).`)); return finalizeIfConvergedOrStop(st); } const url = new URL(location.href); url.searchParams.set('page', String(target)); location.assign(url.toString()); } function renderStatus(st, prefix = '') { const last = st.last; const lines = []; if (prefix) lines.push(prefix); lines.push(`target rating: ${st.targetRating}`); lines.push(`page: ${last ? last.page : getCurrentPage()} steps: ${st.steps}`); if (st.lo != null || st.hi != null) { lines.push(`bounds (pages): [${st.lo ?? '?'} .. ${st.hi ?? '?'}]`); } if (last) { const rr = `ratings ${last.minRating}–${last.maxRating}`; const ranks = `ranks ${last.minRank}–${last.maxRank}`; lines.push(`range: ${rr} | ${ranks}`); if (st.maxPage) lines.push(`maxPage discovered: ${st.maxPage}`); } return lines.join('\n'); } async function continueSearchStep() { const st = readState(); if (!st || !st.active) return; ensureStatusUI(); await waitForTable(15000); const discoveredMax = await waitForPagination(6000); if (discoveredMax != null && st.maxPage !== discoveredMax) { st.maxPage = discoveredMax; writeState(st); // Initialize global bounds if unknown if (st.lo == null) st.lo = 1; if (st.hi == null) st.hi = discoveredMax; } const currentPage = getCurrentPage(); const page = parsePage(); if (!page) { st.steps++; writeState(st); if (st.steps > MAX_STEPS) return stopSearch('No rows found. Aborting.'); setStatus(renderStatus(st, `Page ${currentPage}: no rows found.`)); return; } const { minRating, maxRating, minRank, maxRank, count } = page; st.last = { page: currentPage, minRating, maxRating, minRank, maxRank, rows: count }; st.steps++; writeState(st); // Found target page? (ratings are descending with earlier pages) if (st.targetRating >= minRating && st.targetRating <= maxRating) { setStatus(renderStatus(st, `Found page ${currentPage}.`)); highlightByRating(st.targetRating); st.active = false; writeState(st); return; } // Decide direction: // - If target > maxRating: need earlier page (smaller page index) → tighten hi // - If target < minRating: need later page (larger page index) → tighten lo if (st.targetRating > maxRating) { st.hi = currentPage; writeState(st); } else if (st.targetRating < minRating) { st.lo = currentPage; writeState(st); } // Choose next by binary search if we know both bounds; otherwise, try to discover bounds from maxPage. let next; if (st.lo != null && st.hi != null) { next = Math.floor((st.lo + st.hi) / 2); } else if (st.maxPage != null) { // Fallback to midpoint of [1..maxPage] next = Math.floor((1 + st.maxPage) / 2); } else { // Without maxPage, expand conservatively next = st.targetRating > maxRating ? Math.max(1, currentPage - 1) : currentPage + 1; } setStatus(renderStatus(st, `Narrowing → page ${next}`)); if (next === currentPage || st.steps > MAX_STEPS) return finalizeIfConvergedOrStop(st); return navigate(next, st); } function finalizeIfConvergedOrStop(st) { st.active = false; writeState(st); setStatus(renderStatus(st, 'Converged on bound. Try an adjacent page if needed.')); } // ------- Boot ------- function init() { ensureControlUI(); ensureStatusUI(); const st = readState(); if (st && st.active) { continueSearchStep(); } else { // Show current page summary waitForTable(6000).then(() => { const p = getCurrentPage(); const info = parsePage(); if (info) { setStatus(`Idle. Page ${p} ratings ${info.minRating}–${info.maxRating} (${info.count} rows) | ranks ${info.minRank}–${info.maxRank}`); } else { setStatus('Idle. Waiting for table…'); } waitForPagination(4000).then(mp => { if (mp != null) setStatus(`${document.getElementById(`${STATUS_ID}-content`).textContent}\nmaxPage discovered: ${mp}`); }); }); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } // Re-inject if site re-renders const mo = new MutationObserver(() => { if (!document.getElementById(UI_ID)) ensureControlUI(); if (!document.getElementById(STATUS_ID)) ensureStatusUI(); }); mo.observe(document.documentElement, { childList: true, subtree: true }); })();