Skip to content

Instantly share code, notes, and snippets.

@preraku
Last active June 6, 2026 22:11
Show Gist options
  • Select an option

  • Save preraku/0583fda749febd5f12fb7e8cce899d2d to your computer and use it in GitHub Desktop.

Select an option

Save preraku/0583fda749febd5f12fb7e8cce899d2d to your computer and use it in GitHub Desktop.
20-0.com player ratings userscript for Violentmonkey/Tampermonkey
// ==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