您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Captures all WS frames site-wide; shows roster panel ONLY on /race (with Copy WS)
// ==UserScript== // @name Nitro Type WS Logger (UI only on /race) // @namespace https://nitrotype.com/ // @version 3.4 // @description Captures all WS frames site-wide; shows roster panel ONLY on /race (with Copy WS) // @match *://www.nitrotype.com/race* // @match *://www.nitrotype.com/race/* // @exclude *://www.nitrotype.com/racer* // @grant none // @run-at document-idle // @author Day // @license MIT // ==/UserScript== (function () { 'use strict'; const PANEL_ID = 'nt-ws-roster-panel'; const CONTENT_ID = 'nt-ws-roster-content'; const MAX_WS_LOG = 2000; // cap for raw frames // Live race bits we keep (nitros/errors only) const raceLive = new Map(); // uid -> { n, e } // Profiles from setup/joined const profiles = new Map(); // uid -> profile // Raw WS messages store (for Copy WS) const wsLog = []; // {ts: number, data: string} let myUserId = null; let ui = null; let copyNote = null; // ---------- Route helpers (SPA-safe) ---------- const isRace = () => location.pathname.startsWith('/race'); function onRouteChange() { if (isRace()) mountPanel(); else unmountPanel(); } // watch pushState/replaceState + popstate (function hookHistory(){ const ps = history.pushState, rs = history.replaceState; history.pushState = function() { const r = ps.apply(this, arguments); queueMicrotask(onRouteChange); return r; }; history.replaceState = function() { const r = rs.apply(this, arguments); queueMicrotask(onRouteChange); return r; }; window.addEventListener('popstate', () => queueMicrotask(onRouteChange)); })(); // also poll occasionally in case the app swaps without history events setInterval(() => { onRouteChange(); }, 750); // ---------- UI ---------- function ensurePanelElement() { let panel = document.getElementById(PANEL_ID); if (panel) return panel; panel = document.createElement('div'); panel.id = PANEL_ID; Object.assign(panel.style, { position: 'fixed', top: '10px', right: '10px', width: '360px', maxHeight: '460px', background: '#000', color: '#0f0', fontSize: '11px', fontFamily: 'monospace', padding: '8px', border: '2px solid lime', borderRadius: '6px', zIndex: '999999', overflowY: 'auto', whiteSpace: 'pre-wrap', lineHeight: '1.35', boxSizing: 'border-box', }); const content = document.createElement('div'); content.id = CONTENT_ID; content.textContent = '🟢 WS Logger Ready...\n(Waiting for roster & updates on /race)'; panel.appendChild(content); // bottom toolbar const toolbar = document.createElement('div'); Object.assign(toolbar.style, { position: 'sticky', bottom: '0', left: '0', right: '0', marginTop: '6px', paddingTop: '6px', background: 'linear-gradient(to top, #000, rgba(0,0,0,0.6))', display: 'flex', gap: '6px', alignItems: 'center', borderTop: '1px solid #0f0', }); const copyBtn = document.createElement('button'); copyBtn.textContent = '📋 Copy WS'; Object.assign(copyBtn.style, { background: '#0f0', color: '#000', border: 'none', padding: '4px 8px', fontFamily: 'monospace', fontSize: '11px', borderRadius: '4px', cursor: 'pointer', }); copyBtn.addEventListener('click', copyAllWS); copyNote = document.createElement('span'); Object.assign(copyNote.style, { color: '#9f9', fontSize: '10px', opacity: '0.9', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '220px', }); copyNote.textContent = 'WS captured: 0'; toolbar.appendChild(copyBtn); toolbar.appendChild(copyNote); panel.appendChild(toolbar); return panel; } function mountPanel() { if (!document.body) return; if (ui && document.body.contains(ui)) return; ui = ensurePanelElement(); document.body.appendChild(ui); updateDisplay(); // render latest when (re)mounting } function unmountPanel() { if (ui && ui.parentNode) { ui.parentNode.removeChild(ui); } } const fmtNum = (n) => (typeof n === 'number' ? n.toLocaleString() : undefined); const asNum = (n) => (typeof n === 'number' ? n : undefined); function nameFor(uid) { const p = profiles.get(uid); if (p?.displayName && String(p.displayName).trim()) return String(p.displayName).trim(); if (p?.username) return p.username; if (typeof uid === 'string' && uid.startsWith('robot')) return 'Bot'; return String(uid); } function lineIf(label, v, suffix = '') { if (v === undefined || v === null || v === '') return null; return ` ${label}: ${v}${suffix}`; } function liveLine(uid) { const r = raceLive.get(uid) || {}; const hasN = typeof r.n === 'number'; const hasE = typeof r.e === 'number'; if (!hasN && !hasE) return null; return ` This Race: ${hasN ? `${r.n} nitros` : ''}${hasN && hasE ? ', ' : ''}${hasE ? `${r.e} errors` : ''}`; } function extrasLine(p) { const extras = []; if (typeof p.avgAcc === 'number') extras.push(`Avg Acc ${p.avgAcc}%`); if (typeof p.wampusWins === 'number') extras.push(`${p.wampusWins} Wampus wins`); if (typeof p.consecDaysRaced === 'number') extras.push(`${p.consecDaysRaced} consec days`); if (!extras.length) return null; return ` ${extras.join(' · ')}`; } function updateDisplay() { if (!isRace()) return; // UI only on /race const panel = document.getElementById(PANEL_ID); if (!panel) return; const content = document.getElementById(CONTENT_ID); if (!content) return; const uids = new Set([...profiles.keys(), ...raceLive.keys()]); // Sort: you first, then alphabetical const sorted = [...uids].sort((a, b) => { if (a === myUserId) return -1; if (b === myUserId) return 1; return nameFor(a).toLowerCase().localeCompare(nameFor(b).toLowerCase()); }); const out = ['📋 Race Roster (Account Stats + n/e)\n']; if (!sorted.length) { out.push('(waiting for WS setup/joined/update...)'); } else { for (const uid of sorted) { const p = profiles.get(uid) || {}; const you = uid === myUserId ? '🟩 You' : `👤 ${nameFor(uid)}`; out.push(you); const cls = asNum(p.inClass); const sess = asNum(p.sessionRaces); const avg = asNum(p.avgSpeed); const hi = asNum(p.highestSpeed); const total = asNum(p.racesPlayed); const longS = asNum(p.longestSession); const lines = [ lineIf('Class', cls), lineIf('Session Races', fmtNum(sess)), lineIf('Avg WPM', fmtNum(avg)), lineIf('High WPM', fmtNum(hi)), lineIf('Total Races', fmtNum(total)), lineIf('Longest Session', fmtNum(longS)), extrasLine(p), liveLine(uid), ].filter(Boolean); if (lines.length) out.push(...lines); out.push(''); } } content.innerHTML = out.join('\n'); if (copyNote) copyNote.textContent = `WS captured: ${wsLog.length}`; } // ---------- Copy raw WS ---------- async function copyAllWS() { const lines = wsLog.map(({ ts, data }) => `[${new Date(ts).toISOString()}] ${String(data)}`); const blob = lines.join('\n'); try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(blob); } else { const ta = document.createElement('textarea'); ta.value = blob; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); } if (copyNote) copyNote.textContent = `Copied ${lines.length} WS msgs`; } catch (e) { if (copyNote) copyNote.textContent = `Copy failed: ${e?.message || e}`; } } // ---------- WS hook (captures all pages) ---------- const nativeAddEventListener = WebSocket.prototype.addEventListener; WebSocket.prototype.addEventListener = function (type, listener, options) { if (type === 'message') { const wrapped = function (event) { try { // store everything raw wsLog.push({ ts: Date.now(), data: event.data }); if (wsLog.length > MAX_WS_LOG) wsLog.splice(0, wsLog.length - MAX_WS_LOG); // parse race frames for panel let raw = event.data; if (typeof raw === 'string' && (raw.startsWith('4') || raw.startsWith('5'))) { const body = raw.slice(1); let msg; try { msg = JSON.parse(body); } catch { /* not JSON */ } if (msg?.stream === 'race') { const { msg: kind, payload = {} } = msg; // setup/joined → harvest profiles if (kind === 'setup' && Array.isArray(payload.racers)) { for (const r of payload.racers) { const uid = r.userID ?? r.u ?? r.profile?.userID; if (uid != null && r.profile) profiles.set(uid, r.profile); } updateDisplay(); } if (kind === 'joined' && payload) { const uid = payload.userID ?? payload.u ?? payload.profile?.userID; if (uid != null && payload.profile) profiles.set(uid, payload.profile); updateDisplay(); } // update → keep n/e + learn myUserId if (kind === 'update' && Array.isArray(payload.racers)) { for (const r of payload.racers) { if (r.u == null) continue; const prev = raceLive.get(r.u) || {}; const next = { n: typeof r.n === 'number' ? r.n : prev.n, e: typeof r.e === 'number' ? r.e : prev.e }; raceLive.set(r.u, next); if (myUserId === null && typeof r.u === 'number') myUserId = r.u; } updateDisplay(); } } } } catch { // never break the page/socket } return listener.call(this, event); }; return nativeAddEventListener.call(this, type, wrapped, options); } return nativeAddEventListener.call(this, type, listener, options); }; // Initial mount state if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', onRouteChange); } else { onRouteChange(); } })();