Last active
June 6, 2026 22:11
-
-
Save preraku/0583fda749febd5f12fb7e8cce899d2d to your computer and use it in GitHub Desktop.
20-0.com player ratings userscript for Violentmonkey/Tampermonkey
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name 20-0.com — Show Player Ratings | |
| // @namespace https://www.20-0.com/ | |
| // @version 0.2 | |
| // @description Injects each player's hidden OVR rating next to their name during the draft. | |
| // @match https://www.20-0.com/play* | |
| // @match https://www.20-0.com/daily* | |
| // @updateURL https://gist.github.com/preraku/0583fda749febd5f12fb7e8cce899d2d/raw/20-0-ratings.user.js | |
| // @downloadURL https://gist.github.com/preraku/0583fda749febd5f12fb7e8cce899d2d/raw/20-0-ratings.user.js | |
| // @grant none | |
| // ==/UserScript== | |
| (async function () { | |
| 'use strict'; | |
| // LOG_LEVEL: 'DEBUG' | 'INFO' | 'ERROR' | |
| const LOG_LEVEL = 'ERROR'; | |
| const LEVELS = { DEBUG: 0, INFO: 1, ERROR: 2 }; | |
| const lvl = LEVELS[LOG_LEVEL] ?? 0; | |
| const DBG = (...args) => lvl <= 0 && console.debug('[20-0]', ...args); | |
| const INFO = (...args) => lvl <= 1 && console.info('[20-0]', ...args); | |
| const ERR = (...args) => console.error('[20-0 Ratings]', ...args); | |
| // ── Step 1: Discover the data chunk URL ───────────────────────────────── | |
| // Walk: inline bootstrap script → app entry JS → play node → data chunk. | |
| // Three strategies for finding the data chunk, in order of specificity: | |
| // A) __vite__mapDeps in the play node (old Vite builds) | |
| // B) Static chunk imports inside the play node | |
| // C) Scan every chunk listed in the app entry deps | |
| function findAppEntryUrl() { | |
| for (const s of document.querySelectorAll('script:not([src])')) { | |
| const m = s.textContent.match(/import\("([^"]*\/_app\/immutable\/entry\/app\.[^"]+\.js)"\)/); | |
| if (m) return new URL(m[1], location.href).href; | |
| } | |
| return null; | |
| } | |
| function parseDepsArray(js) { | |
| const m = js.match(/m\.f\|\|\(m\.f=(\[.*?\])\)/); | |
| if (!m) return null; | |
| try { return JSON.parse(m[1]); } catch { return null; } | |
| } | |
| async function findDataChunkUrl(appEntryUrl) { | |
| const cacheKey = `20-0-chunk-url:${appEntryUrl}`; | |
| const cached = sessionStorage.getItem(cacheKey); | |
| if (cached) { INFO('data chunk URL (cached):', cached); return cached; } | |
| INFO('fetching app entry:', appEntryUrl); | |
| const appJs = await fetch(appEntryUrl).then(r => r.text()); | |
| const appDeps = parseDepsArray(appJs); | |
| DBG('app deps:', appDeps); | |
| if (!appDeps) throw new Error('App deps array not found in app entry'); | |
| // Dynamically find which node number handles /play or /daily | |
| const routeM = appJs.match(/"\/(?:play|daily)":\[(\d+)/); | |
| const playNode = routeM ? parseInt(routeM[1], 10) : null; | |
| INFO('play route node number:', playNode); | |
| const isDataChunk = js => js.includes('JSON.parse(`['); | |
| if (playNode !== null) { | |
| const nodePath = appDeps.find(d => new RegExp(`/nodes/${playNode}\\.`).test(d)); | |
| INFO('play node path in deps:', nodePath); | |
| if (nodePath) { | |
| const nodeUrl = new URL(nodePath, appEntryUrl).href; | |
| INFO('fetching play node:', nodeUrl); | |
| const nodeJs = await fetch(nodeUrl).then(r => r.text()); | |
| // Strategy A: __vite__mapDeps (works in older Vite builds) | |
| const nodeDeps = parseDepsArray(nodeJs); | |
| DBG('play node __vite__mapDeps:', nodeDeps); | |
| if (nodeDeps?.[0]) { | |
| const url = new URL(nodeDeps[0], nodeUrl).href; | |
| INFO('strategy A: data chunk via __vite__mapDeps:', url); | |
| sessionStorage.setItem(cacheKey, url); | |
| return url; | |
| } | |
| INFO('strategy A: no __vite__mapDeps in play node, trying B'); | |
| // Strategy B: scan static chunk imports of the play node, | |
| // and follow one level of __vite__mapDeps in each import. | |
| const importRe = /from"([^"]*\/chunks\/[^"]+\.js)"/g; | |
| const staticImports = [...nodeJs.matchAll(importRe)].map(m => m[1]); | |
| INFO('strategy B: static chunk imports found in play node:', staticImports); | |
| for (const imp of staticImports) { | |
| const chunkUrl = new URL(imp, nodeUrl).href; | |
| DBG('strategy B: checking', chunkUrl); | |
| const chunkJs = await fetch(chunkUrl).then(r => r.text()); | |
| if (isDataChunk(chunkJs)) { | |
| INFO('strategy B: data chunk found directly:', chunkUrl); | |
| sessionStorage.setItem(cacheKey, chunkUrl); | |
| return chunkUrl; | |
| } | |
| // Follow transitive __vite__mapDeps one level deep | |
| const transDeps = parseDepsArray(chunkJs); | |
| if (transDeps) { | |
| DBG('strategy B: transitive deps of', chunkUrl, ':', transDeps); | |
| for (const dep of transDeps) { | |
| const depUrl = new URL(dep, chunkUrl).href; | |
| DBG('strategy B: checking transitive dep', depUrl); | |
| const depJs = await fetch(depUrl).then(r => r.text()); | |
| if (isDataChunk(depJs)) { | |
| INFO('strategy B: data chunk found via transitive dep:', depUrl); | |
| sessionStorage.setItem(cacheKey, depUrl); | |
| return depUrl; | |
| } | |
| } | |
| } | |
| } | |
| INFO('strategy B: data chunk not found in play node imports, trying C'); | |
| } else { | |
| INFO('play node path not found in app deps, skipping A+B, trying C'); | |
| } | |
| } else { | |
| INFO('play route node not found in app entry, skipping A+B, trying C'); | |
| } | |
| // Strategy C: scan every chunk listed in the app entry deps, | |
| // plus one level of __vite__mapDeps in each. | |
| const allChunks = appDeps.filter(d => /\/chunks\//.test(d)); | |
| INFO('strategy C: scanning', allChunks.length, 'chunks from app deps:', allChunks); | |
| for (const path of allChunks) { | |
| const chunkUrl = new URL(path, appEntryUrl).href; | |
| DBG('strategy C: checking', chunkUrl); | |
| const chunkJs = await fetch(chunkUrl).then(r => r.text()); | |
| if (isDataChunk(chunkJs)) { | |
| INFO('strategy C: data chunk found directly:', chunkUrl); | |
| sessionStorage.setItem(cacheKey, chunkUrl); | |
| return chunkUrl; | |
| } | |
| const transDeps = parseDepsArray(chunkJs); | |
| if (transDeps) { | |
| DBG('strategy C: transitive deps of', chunkUrl, ':', transDeps); | |
| for (const dep of transDeps) { | |
| const depUrl = new URL(dep, chunkUrl).href; | |
| DBG('strategy C: checking transitive dep', depUrl); | |
| const depJs = await fetch(depUrl).then(r => r.text()); | |
| if (isDataChunk(depJs)) { | |
| INFO('strategy C: data chunk found via transitive dep:', depUrl); | |
| sessionStorage.setItem(cacheKey, depUrl); | |
| return depUrl; | |
| } | |
| } | |
| } | |
| } | |
| throw new Error('Player data chunk not found'); | |
| } | |
| // ── Step 2: Parse all player ratings from the data chunk ──────────────── | |
| // The chunk contains two player datasets: | |
| // • Classic era (pre-1999): backtick template-literal objects | |
| // • Modern era (1999-present): a JSON.parse(`[...]`) blob | |
| // Lookup key: "Player Name|TEAM|YEAR" | |
| async function loadRatings(dataChunkUrl) { | |
| const cacheKey = `20-0-ratings:${dataChunkUrl}`; | |
| const cached = sessionStorage.getItem(cacheKey); | |
| if (cached) return new Map(JSON.parse(cached)); | |
| const js = await fetch(dataChunkUrl).then(r => r.text()); | |
| const map = new Map(); | |
| // Classic era: name:`...`,position:`...`,teamAbbr:`...`,season:NNNN,rating:NN | |
| const classicRe = /name:`([^`]+)`,position:`[^`]+`,teamAbbr:`([^`]+)`,season:(\d+),rating:(\d+)/g; | |
| for (const m of js.matchAll(classicRe)) { | |
| map.set(`${m[1]}|${m[2]}|${m[3]}`, parseInt(m[4], 10)); | |
| } | |
| // Modern era: JSON.parse(`[{"id":"...","name":"...","teamAbbr":"...","season":NNNN,...,"rating":NN},...]`) | |
| const jsonIdx = js.indexOf('JSON.parse(`['); | |
| if (jsonIdx !== -1) { | |
| const start = jsonIdx + 'JSON.parse(`'.length; | |
| const end = js.indexOf('`)', start); | |
| if (end !== -1) { | |
| try { | |
| const players = JSON.parse(js.slice(start, end)); | |
| for (const p of players) { | |
| if (p.name && p.teamAbbr && p.season != null && p.rating != null) { | |
| map.set(`${p.name}|${p.teamAbbr}|${p.season}`, p.rating); | |
| } | |
| } | |
| } catch (e) { | |
| ERR('Failed to parse modern player JSON:', e); | |
| } | |
| } | |
| } | |
| sessionStorage.setItem(cacheKey, JSON.stringify([...map])); | |
| return map; | |
| } | |
| // ── Step 3: Badge injection ────────────────────────────────────────────── | |
| function ratingColor(r) { | |
| if (r >= 95) return ['#7c3aed', '#fff']; // purple — legendary | |
| if (r >= 90) return ['#ea580c', '#fff']; // orange — elite | |
| if (r >= 85) return ['#16a34a', '#fff']; // green — great | |
| if (r >= 80) return ['#2563eb', '#fff']; // blue — good | |
| if (r >= 75) return ['#475569', '#cbd5e1']; // slate — average | |
| return ['#1e293b', '#94a3b8']; // dim — below average | |
| } | |
| // DOM structure (from Svelte template): | |
| // <button class="... rounded-xl ..."> | |
| // <div class="min-w-0 flex-1"> | |
| // <div class="truncate text-sm font-semibold">Name</div> ← nameEl | |
| // <div class="truncate text-xs text-slate-500">TEAM · YEAR</div> | |
| // </div> | |
| // </button> | |
| function tryInject(nameEl, ratingsMap) { | |
| if (nameEl.dataset.ratingDone) return; | |
| if (nameEl.classList.contains('text-white')) return; | |
| if (!nameEl.closest('button')) return; | |
| const name = nameEl.textContent.trim(); | |
| if (!name) return; | |
| const infoEl = nameEl.nextElementSibling; | |
| if (!infoEl) return; | |
| const parts = infoEl.textContent.split('·').map(s => s.trim()).filter(Boolean); | |
| if (parts.length < 2) return; | |
| const team = parts[0]; | |
| const year = parts[1].split(/\s/)[0]; | |
| const rating = ratingsMap.get(`${name}|${team}|${year}`); | |
| if (rating === undefined) return; | |
| nameEl.dataset.ratingDone = '1'; | |
| const [bg, color] = ratingColor(rating); | |
| const badge = document.createElement('span'); | |
| badge.dataset.ratingBadge = '1'; | |
| badge.textContent = rating; | |
| badge.style.cssText = | |
| `display:inline-block;margin-left:5px;padding:1px 5px;border-radius:4px;` + | |
| `font-size:11px;font-weight:800;line-height:1.4;vertical-align:middle;` + | |
| `background:${bg};color:${color}`; | |
| nameEl.appendChild(badge); | |
| } | |
| // ── Step 4: Main ───────────────────────────────────────────────────────── | |
| let ratingsMap = null; | |
| try { | |
| const appEntryUrl = findAppEntryUrl(); | |
| if (!appEntryUrl) throw new Error('App entry URL not found in page'); | |
| const dataChunkUrl = await findDataChunkUrl(appEntryUrl); | |
| ratingsMap = await loadRatings(dataChunkUrl); | |
| } catch (e) { | |
| ERR('Initialisation failed:', e); | |
| return; | |
| } | |
| function scan(root) { | |
| root.querySelectorAll('div.truncate.text-sm.font-semibold').forEach(el => tryInject(el, ratingsMap)); | |
| } | |
| const observer = new MutationObserver(mutations => { | |
| for (const m of mutations) { | |
| for (const node of m.addedNodes) { | |
| if (!(node instanceof Element)) continue; | |
| if (node.matches?.('div.truncate.text-sm.font-semibold')) { | |
| tryInject(node, ratingsMap); | |
| } else { | |
| scan(node); | |
| } | |
| } | |
| } | |
| }); | |
| observer.observe(document.body, { childList: true, subtree: true }); | |
| scan(document.body); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment