// Smurf Tracker — Cloudflare Worker backend // Deploy this, paste the worker's URL into the app's Settings > "Backend URL". // // GET /?name=&tag=®ion= // -> {"found":true,"tier":"GOLD","division":"II","lp":45,...,"uncertain":false,"source":"meta"} // -> {"found":false} (summoner genuinely doesn't exist) // // POST / body: {"accounts":[{"name":"...","tag":"...","region":"euw"}, ...]} (max 20) // -> {"results":[{"name":"...","tag":"...","region":"euw","ok":true,...} | {"ok":false,"error":"..."}]} // Parallel scrapes on the worker so Check-all does one round-trip instead of N. // // Riot API (optional, recommended): add a secret RIOT_API_KEY to the worker. Rank, // level and icon then come from Riot's own API instead of op.gg's web page, which // breaks whenever op.gg changes its layout. op.gg is still the fallback (an // expired key, a rate limit), and on a single lookup it still supplies what Riot // does not have: peak, past seasons, champions. Riot's account id (puuid) comes // back with each result; the app stores it and sends it as &puuid= / "puuid", so // a renamed account keeps its history. // // GET /status -> {"ok":true,"riot":true|false,"vault":true|false} (what this worker has set up) // A lookup (GET with &mastery=1, or a batch row with "mastery":true) also returns the // account's top five champions by mastery, when there is a Riot key: // "mastery":[{"name":"Ahri","level":7,"points":312045}, ...] // GET /form?puuid=®ion= -> {"games":[{"win","champ","role","k","d","a","min","at"}]} // The last five ranked solo games, from Riot's match-v5. Needs RIOT_API_KEY. // // Device sync (encrypted vault blob only — not a scrape cache): // Bind a KV namespace as VAULT (or SMURF_VAULT) on the worker. // GET /vault Authorization: Bearer // PUT /vault Authorization: Bearer // body: {"updatedAt": , "envelope": {"__enc":true,"salt","iv","data"}} // Last-write-wins on updatedAt; older PUT → 409 with the stored record. // // Discord alerts while the app is closed (needs the KV binding above): // PUT /watch Authorization: Bearer // body: {"webhook": , "accounts": [{name, tag, region, puuid?, label?, // tier, games, bank, at}]} (Diamond and up; bank = days of decay protection at `at`) // GET /watch -> {"ok":true,"accounts":n,"lastRun":ms|null,"lastSent":ms|null} // DELETE /watch stops it. // One list per worker, owned by the token that set it up. The daily cron in // wrangler.jsonc re-reads each account and posts when one is about to decay. // // -> HTTP 4xx/5xx on transient failures (missing params, op.gg unreachable, page didn't parse) — // the app falls back to the free proxy chain / manual entry on any non-2xx response. // // The parsers below are ported from the app's client-side ones and are exported so // tests/worker.test.mjs can run them against real op.gg fixtures. Cloudflare only // ever uses the default export, so the extra named exports cost nothing. const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; const BATCH_MAX = 20; const VAULT_MAX_BYTES = 1_500_000; // ~1.5 MB ciphertext envelope const CORS = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization", }; export default { async fetch(request, env) { if (request.method === "OPTIONS") return new Response(null, { headers: CORS }); try { const url = new URL(request.url); if (isVaultPath(url.pathname)) return await handleVault(request, env); if (isWatchPath(url.pathname)) return await handleWatch(request, env); if (/\/status\/?$/.test(url.pathname)) return json({ ok: true, riot: !!riotKey(env), vault: !!vaultStore(env) }, 200); if (/\/form\/?$/.test(url.pathname)) { const key = riotKey(env); if (!key) return json({ error: "no Riot API key on this worker" }, 404); const r = await riotForm(url.searchParams.get("puuid"), (url.searchParams.get("region") || "euw").toLowerCase(), key); return r.body ? json(r.body, 200) : json({ error: r.error }, r.status || 502); } if (request.method === "POST") return await handleBatch(request, env); if (request.method !== "GET") return json({ error: "method not allowed" }, 405); const name = url.searchParams.get("name"); const tag = url.searchParams.get("tag"); const region = (url.searchParams.get("region") || "euw").toLowerCase(); if (!name || !tag) return json({ error: "missing name/tag" }, 400); const result = await lookupOne(name, tag, region, env, url.searchParams.get("puuid"), url.searchParams.get("mastery") === "1"); if (result.error && result.status) return json({ error: result.error }, result.status); return json(result.body, 200); } catch (e) { return json({ error: "worker error: " + (e && e.message) }, 500); } }, // the daily cron (wrangler.jsonc): decay alerts on Discord async scheduled(controller, env, ctx) { ctx.waitUntil(runWatch(env)); }, }; /* ---- Discord alerts while the app is closed ---- The app hands this worker a short watch list: Riot IDs, labels, and each Diamond+ account's decay estimate, never logins, plus a Discord webhook. Once a day the worker re-reads those accounts, carries the estimate forward the way the app does, and posts when one enters "decays within two days" or "decaying". One message per change of state, not one a day. One list per worker: the token that set it up owns it, so nobody else who finds the URL can point it at their own channel. */ const WATCH_KEY = "watch:slot", WATCH_MAX = 20; const DECAY = { DIAMOND: { cap: 28, per: 7, lp: 50 }, MASTER: { cap: 14, per: 1, lp: 75 }, GRANDMASTER: { cap: 14, per: 1, lp: 75 }, CHALLENGER: { cap: 14, per: 1, lp: 75 } }; const DISCORD_RE = /^https:\/\/(?:(?:canary|ptb)\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/[\w-]+$/; export function isWatchPath(pathname) { const p = String(pathname || "").replace(/\/+$/, "") || "/"; return p === "/watch" || p.endsWith("/watch"); } function cleanWatchAccount(a) { if (!a || typeof a !== "object") return null; const str = (v, n) => typeof v === "string" && v.trim() ? v.trim().slice(0, n) : null; const name = str(a.name, 32), tag = str(a.tag, 8), region = String(a.region || "").toLowerCase(); const tier = String(a.tier || "").toUpperCase(), rule = DECAY[tier]; if (!name || !tag || !RIOT_PLATFORM[region] || !rule) return null; const games = Number(a.games), bank = Number(a.bank), at = Number(a.at); if (!Number.isFinite(games) || games < 0 || !Number.isFinite(bank) || !Number.isFinite(at) || at <= 0) return null; return { name, tag, region, puuid: typeof a.puuid === "string" && PUUID_RE.test(a.puuid) ? a.puuid : null, label: str(a.label, 40), tier, division: str(a.division, 4), lp: Number.isFinite(Number(a.lp)) ? Number(a.lp) : null, games, bank: Math.max(0, Math.min(rule.cap, bank)), at }; } export async function handleWatch(request, env) { const store = vaultStore(env); if (!store) return json({ error: "alerts need a KV namespace bound as VAULT on this worker", code: "no_kv" }, 503); const token = bearerToken(request); if (!token || token.length < 16) return json({ error: "missing or short watch token" }, 401); const owner = (await vaultKeyFromToken(token)).slice(6); let slot = null; try { slot = JSON.parse((await store.get(WATCH_KEY)) || "null"); } catch (e) { slot = null; } if (slot && slot.owner !== owner) return json({ error: "this worker already sends alerts for another browser", code: "taken" }, 409); if (request.method === "GET") { if (!slot) return json({ error: "not watching" }, 404); return json({ ok: true, accounts: slot.accounts.length, lastRun: slot.lastRun || null, lastSent: slot.lastSent || null }, 200); } if (request.method === "DELETE") { if (slot) await store.delete(WATCH_KEY); return json({ ok: true }, 200); } if (request.method === "PUT") { let body; try { body = await request.json(); } catch (e) { return json({ error: "invalid JSON body" }, 400); } if (!DISCORD_RE.test(String(body && body.webhook || ""))) return json({ error: "not a Discord webhook URL" }, 400); const list = Array.isArray(body.accounts) ? body.accounts : []; if (list.length > WATCH_MAX) return json({ error: "at most " + WATCH_MAX + " accounts" }, 400); const accounts = list.map(cleanWatchAccount).filter(Boolean); // an alert already sent stays sent across the app's refreshes of the list const was = new Map(((slot && slot.accounts) || []).map(a => [`${a.name}#${a.tag}@${a.region}`.toLowerCase(), a.alerted])); for (const a of accounts) a.alerted = was.get(`${a.name}#${a.tag}@${a.region}`.toLowerCase()) || null; await store.put(WATCH_KEY, JSON.stringify({ owner, webhook: body.webhook, accounts, lastRun: slot ? slot.lastRun || null : null, lastSent: slot ? slot.lastSent || null : null })); return json({ ok: true, accounts: accounts.length }, 200); } return json({ error: "method not allowed" }, 405); } // Half the gap's decay before the games and half after, as the app counts it. export function carryBank(bank, rule, days, games) { let b = Math.max(0, bank - days / 2); if (games > 0) b = Math.min(rule.cap, b + games * rule.per); return Math.max(0, b - days / 2); } async function readSolo(a, env) { const key = riotKey(env); if (key && a.puuid) { const r = await riotGet(`https://${RIOT_PLATFORM[a.region]}.api.riotgames.com/lol/league/v4/entries/by-puuid/${a.puuid}`, key); if (r.ok) { const e = (await r.json()).find(x => x && x.queueType === "RANKED_SOLO_5x5"); if (!e) return { tier: "UNRANKED", games: 0 }; const tier = String(e.tier || "").toUpperCase(); return { tier, division: MASTER_PLUS.includes(tier) ? null : e.rank || null, lp: e.leaguePoints ?? null, games: (e.wins || 0) + (e.losses || 0) }; } } // rank and games are all the decay check needs: the profile page, not the // champions page as well, so twenty accounts stay inside a run's 50 requests // with room for the Discord posts const s = await scrapeOne(a.name, a.tag, a.region, { champions: false }); if (!s.body || !s.body.found || s.body.wins == null) return null; return { tier: String(s.body.tier || "").toUpperCase(), division: s.body.division || null, lp: s.body.lp ?? null, games: (s.body.wins || 0) + (s.body.losses || 0) }; } function alertText(a, state, rule, days) { const who = a.label || a.name; const rank = a.tier.charAt(0) + a.tier.slice(1).toLowerCase() + (a.division ? " " + a.division : "") + (a.lp != null ? " · " + a.lp + " LP" : ""); return state === "now" ? `📉 **${who}** (${rank}) is decaying: −${rule.lp} LP a day until it plays a ranked game.` : `⏳ **${who}** (${rank}) decays in ~${days} day${days === 1 ? "" : "s"}. One ranked game banks ${rule.per === 1 ? "a day" : rule.per + " days"}.`; } export async function runWatch(env, now = Date.now()) { const store = vaultStore(env); if (!store) return { ran: false }; let slot = null; try { slot = JSON.parse((await store.get(WATCH_KEY)) || "null"); } catch (e) { slot = null; } if (!slot || !Array.isArray(slot.accounts)) return { ran: false }; const posts = []; for (const a of slot.accounts) { let cur = null; try { cur = await readSolo(a, env); } catch (e) { cur = null; } if (!cur) continue; // unreadable today: tomorrow, from the same estimate const rule = DECAY[cur.tier], before = DECAY[a.tier]; let bank = 0; if (rule) { if (cur.games < a.games || !before || before.cap !== rule.cap) bank = rule.cap; // new season, or a new tier's own start else bank = carryBank(a.bank, rule, (now - a.at) / 86400000, cur.games - a.games); } Object.assign(a, { tier: cur.tier, division: cur.division ?? null, lp: cur.lp ?? null, games: cur.games, bank, at: now }); const days = Math.floor(bank); const state = !rule ? null : bank <= 0 ? "now" : days <= 2 ? "soon" : null; if (state && state !== a.alerted) posts.push(alertText(a, state, rule, days)); a.alerted = state; } let sent = 0; for (const content of posts) { try { const r = await fetch(slot.webhook, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content, allowed_mentions: { parse: [] } }) }); if (r.ok) sent++; } catch (e) { /* the next change of state will try again */ } } slot.lastRun = now; if (sent) slot.lastSent = now; await store.put(WATCH_KEY, JSON.stringify(slot)); return { ran: true, posts: posts.length, sent }; } export function isVaultPath(pathname) { const p = String(pathname || "").replace(/\/+$/, "") || "/"; return p === "/vault" || p.endsWith("/vault"); } export async function vaultKeyFromToken(token) { const raw = new TextEncoder().encode(String(token || "")); const dig = await crypto.subtle.digest("SHA-256", raw); const hex = [...new Uint8Array(dig)].map(b => b.toString(16).padStart(2, "0")).join(""); return "vault:" + hex; } function vaultStore(env) { return (env && (env.VAULT || env.SMURF_VAULT)) || null; } function bearerToken(request) { const h = request.headers.get("Authorization") || ""; const m = h.match(/^Bearer\s+(\S+)/i); return m ? m[1] : ""; } function isEncEnvelope(e) { return !!(e && e.__enc === true && typeof e.salt === "string" && typeof e.iv === "string" && typeof e.data === "string"); } export async function handleVault(request, env) { const store = vaultStore(env); if (!store) { return json({ error: "vault sync not configured — bind a KV namespace as VAULT (or SMURF_VAULT) on this worker", code: "no_kv", }, 503); } const token = bearerToken(request); if (!token || token.length < 16) return json({ error: "missing or short sync token" }, 401); const key = await vaultKeyFromToken(token); if (request.method === "GET") { const raw = await store.get(key); if (!raw) return json({ error: "no vault for this token" }, 404); let rec; try { rec = JSON.parse(raw); } catch (e) { return json({ error: "corrupt vault record" }, 500); } return json(rec, 200); } if (request.method === "PUT") { let body; try { body = await request.json(); } catch (e) { return json({ error: "invalid JSON body" }, 400); } const updatedAt = Number(body && body.updatedAt); if (!Number.isFinite(updatedAt) || updatedAt <= 0) return json({ error: "missing updatedAt" }, 400); if (!isEncEnvelope(body && body.envelope)) { return json({ error: "envelope must be an encrypted vault ({__enc,salt,iv,data})" }, 400); } const rec = { updatedAt, envelope: { __enc: true, salt: body.envelope.salt, iv: body.envelope.iv, data: body.envelope.data, }}; const serialized = JSON.stringify(rec); if (serialized.length > VAULT_MAX_BYTES) return json({ error: "vault too large" }, 413); const existingRaw = await store.get(key); if (existingRaw) { let existing; try { existing = JSON.parse(existingRaw); } catch (e) { existing = null; } if (existing && Number(existing.updatedAt) > updatedAt) { return json({ error: "conflict — remote is newer", code: "conflict", remote: existing }, 409); } } await store.put(key, serialized); return json({ ok: true, updatedAt }, 200); } return json({ error: "method not allowed" }, 405); } export async function handleBatch(request, env) { let body; try { body = await request.json(); } catch (e) { return json({ error: "invalid JSON body" }, 400); } const list = Array.isArray(body && body.accounts) ? body.accounts : null; if (!list || !list.length) return json({ error: "missing accounts[]" }, 400); if (list.length > BATCH_MAX) return json({ error: "max " + BATCH_MAX + " accounts per batch" }, 400); const key = riotKey(env); const row = async (a) => { const name = a && a.name, tag = a && a.tag; const region = String((a && a.region) || "euw").toLowerCase(); if (!name || !tag) return { name, tag, region, ok: false, error: "missing name/tag" }; try { // In a batch the Riot path does not fall back to op.gg: a row it cannot answer // is handed back as failed, and the app retries it on its own GET — a fresh // request with a fresh subrequest budget. const result = key ? await riotOne(String(name), String(tag), region, key, a.puuid, false, !!a.mastery) : await scrapeOne(String(name), String(tag), region); if (result.error) return { name, tag, region, ok: false, error: result.error }; return { name, tag, region, ok: true, ...result.body }; } catch (e) { return { name, tag, region, ok: false, error: (e && e.message) || "failed" }; } }; if (!key) return json({ results: await Promise.all(list.map(row)) }, 200); // A worker on the free plan may make 50 subrequests per request. A Riot lookup // is 2 when the app already knows the account's puuid and 3 when it does not, so // a batch of twenty can overrun it. Rows past the budget are handed back to be // retried one at a time. Five at a time also keeps clear of Riot's 20-a-second // limit for personal keys. // Mastery is one more call a row, and naming the champions costs two the first // time an isolate needs Data Dragon's list; that pair is set aside up front. let budget = SUBREQUEST_BUDGET - (list.some(a => a && a.mastery) && !champNamesFresh() ? 2 : 0); const plan = list.map(a => { const cost = (a && a.puuid ? 2 : 3) + (a && a.mastery ? 1 : 0); if (budget < cost) return false; budget -= cost; return true; }); const results = new Array(list.length); for (let i = 0; i < list.length; i += 5) { await Promise.all(list.slice(i, i + 5).map(async (a, k) => { const j = i + k; results[j] = plan[j] ? await row(a) : { name: a && a.name, tag: a && a.tag, region: a && a.region, ok: false, error: "deferred — over this request's budget" }; })); } return json({ results }, 200); } /* ---- Riot API ---- */ const SUBREQUEST_BUDGET = 48; // op.gg's region codes (what the app sends) to Riot's routing values. Account data // is global, so any cluster answers for any account; the nearest one is used. export const RIOT_PLATFORM = { euw: "euw1", eune: "eun1", na: "na1", kr: "kr", jp: "jp1", br: "br1", lan: "la1", las: "la2", oce: "oc1", tr: "tr1", ru: "ru", me: "me1", sg: "sg2", tw: "tw2", vn: "vn2", ph: "ph2", th: "th2", }; const RIOT_CLUSTER = { euw: "europe", eune: "europe", tr: "europe", ru: "europe", me: "europe", na: "americas", br: "americas", lan: "americas", las: "americas", kr: "asia", jp: "asia", oce: "asia", sg: "asia", tw: "asia", vn: "asia", ph: "asia", th: "asia", }; function riotKey(env) { const k = env && env.RIOT_API_KEY; return typeof k === "string" && /^RGAPI-[0-9a-f-]{20,}$/i.test(k.trim()) ? k.trim() : null; } function riotGet(u, key) { const c = new AbortController(); const t = setTimeout(() => c.abort(), 10000); return fetch(u, { headers: { "X-Riot-Token": key }, signal: c.signal }).finally(() => clearTimeout(t)); } function riotFailure(res, what) { if (res.status === 401 || res.status === 403) return { error: "Riot API key rejected (" + res.status + ") — expired or wrong", riotError: "key", status: 502 }; if (res.status === 429) return { error: "Riot API rate limit", riotError: "rate", status: 503 }; return { error: "Riot " + what + " HTTP " + res.status, riotError: "http", status: 502 }; } const PUUID_RE = /^[A-Za-z0-9_-]{30,100}$/; /* One account through Riot's API: account-v1 for the puuid and the current Riot ID, summoner-v4 for level and icon, league-v4 for the ranks. `withAccount` false skips account-v1 when the puuid is already known (a batch), which is the difference between 2 and 3 subrequests. A puuid is encrypted per API key, so one stored under an earlier key (a 24-hour development key, say) answers 400/404 — the account is then found again by Riot ID. */ export async function riotOne(name, tag, region, key, puuid, withAccount = true, withMastery = false) { const plat = RIOT_PLATFORM[region], cluster = RIOT_CLUSTER[region]; if (!plat || !cluster) return { error: "no Riot routing for region " + region, riotError: "http", status: 400 }; const known = typeof puuid === "string" && PUUID_RE.test(puuid) ? puuid : null; let acct = null; if (withAccount || !known) { let res = known ? await riotGet(`https://${cluster}.api.riotgames.com/riot/account/v1/accounts/by-puuid/${known}`, key) : null; if (!res || res.status === 400 || res.status === 404) { res = await riotGet(`https://${cluster}.api.riotgames.com/riot/account/v1/accounts/by-riot-id/${encodeURIComponent(name)}/${encodeURIComponent(tag)}`, key); if (res.status === 404) return { body: { found: false, source: "riot" } }; } if (!res.ok) return riotFailure(res, "account"); acct = await res.json(); } const id = acct ? acct.puuid : known; const [sr, lr, mastery] = await Promise.all([ riotGet(`https://${plat}.api.riotgames.com/lol/summoner/v4/summoners/by-puuid/${id}`, key), riotGet(`https://${plat}.api.riotgames.com/lol/league/v4/entries/by-puuid/${id}`, key), withMastery ? riotMastery(plat, id, key) : null, ]); // A Riot account with no League profile on this server: the region is wrong. if (sr.status === 404) return { body: { found: false, source: "riot" } }; if (!sr.ok) return riotFailure(sr, "summoner"); if (!lr.ok) return riotFailure(lr, "league"); const summ = await sr.json(), entries = await lr.json(); const queue = q => (Array.isArray(entries) ? entries : []).find(e => e && e.queueType === q); const rank = e => e ? { tier: String(e.tier || "").toUpperCase(), division: MASTER_PLUS.includes(String(e.tier || "").toUpperCase()) ? null : (e.rank || null), lp: e.leaguePoints ?? null, } : { tier: "UNRANKED", division: null, lp: null }; const solo = queue("RANKED_SOLO_5x5"), flex = queue("RANKED_FLEX_SR"); return { body: { found: true, ...rank(solo), wins: solo ? solo.wins ?? null : null, losses: solo ? solo.losses ?? null : null, level: summ.summonerLevel ?? null, flex: rank(flex), icon: summ.profileIconId != null ? `https://opgg-static.akamaized.net/meta/images/profile_icons/profileIcon${summ.profileIconId}.jpg` : null, puuid: id, riotId: acct ? { name: acct.gameName, tag: acct.tagLine } : null, ...(mastery ? { mastery } : {}), uncertain: false, source: "riot", }, }; } /* Champion mastery, top five. Riot answers with champion numbers, so the names come from Data Dragon's champion list, kept for a day in the isolate. Best effort: a failure here leaves the rank reading untouched and simply has no mastery. */ let champNameCache = null; function champNamesFresh() { return !!(champNameCache && Date.now() - champNameCache.at < 86400000); } export function resetChampNames() { champNameCache = null; } async function champNames() { if (champNamesFresh()) return champNameCache.map; const vr = await fetch("https://ddragon.leagueoflegends.com/api/versions.json"); if (!vr.ok) return null; const v = (await vr.json())[0]; const cr = await fetch(`https://ddragon.leagueoflegends.com/cdn/${encodeURIComponent(v)}/data/en_US/champion.json`); if (!cr.ok) return null; const map = {}; for (const c of Object.values((await cr.json()).data || {})) map[String(c.key)] = c.name; champNameCache = { map, at: Date.now() }; return map; } async function riotMastery(plat, puuid, key) { try { const [res, names] = await Promise.all([ riotGet(`https://${plat}.api.riotgames.com/lol/champion-mastery/v4/champion-masteries/by-puuid/${puuid}/top?count=5`, key), champNames(), ]); if (!res.ok || !names) return null; return (await res.json()).slice(0, 5).filter(m => names[String(m.championId)]) .map(m => ({ name: names[String(m.championId)], level: m.championLevel ?? null, points: m.championPoints ?? null })); } catch (e) { return null; } } // match-v5 routes by these four clusters, which are not account-v1's three const MATCH_CLUSTER = { euw: "europe", eune: "europe", tr: "europe", ru: "europe", me: "europe", na: "americas", br: "americas", lan: "americas", las: "americas", kr: "asia", jp: "asia", oce: "sea", sg: "sea", tw: "sea", vn: "sea", ph: "sea", th: "sea", }; /* The last five ranked solo games (queue 420): the list of match ids, then each match, reduced to this player's line. Six subrequests, asked for one account at a time when its details are opened — never in a batch. */ const ROLE_NAME = { TOP: "Top", JUNGLE: "Jungle", MIDDLE: "Mid", BOTTOM: "Bot", UTILITY: "Support" }; export async function riotForm(puuid, region, key) { const cluster = MATCH_CLUSTER[region]; if (!cluster || !PUUID_RE.test(String(puuid || ""))) return { error: "bad puuid or region", status: 400 }; const base = `https://${cluster}.api.riotgames.com/lol/match/v5/matches`; const ir = await riotGet(`${base}/by-puuid/${puuid}/ids?queue=420&count=5`, key); if (!ir.ok) return riotFailure(ir, "match list"); const ids = (await ir.json()).slice(0, 5); const matches = await Promise.all(ids.map(id => riotGet(`${base}/${encodeURIComponent(id)}`, key).catch(() => null))); const games = []; for (const m of matches) { if (!m || !m.ok) continue; const d = await m.json(); const info = d && d.info, p = info && (info.participants || []).find(x => x.puuid === puuid); if (!p) continue; games.push({ win: !!p.win, champ: String(p.championName || "?"), role: ROLE_NAME[p.teamPosition] || null, k: p.kills, d: p.deaths, a: p.assists, min: Math.round((info.gameDuration || 0) / 60), at: info.gameEndTimestamp || info.gameCreation || null }); } return { body: { games } }; } /* One lookup, the way a single GET wants it. With a Riot key: Riot for the numbers, then op.gg (best effort) for what Riot does not know — peak, past seasons, champions, the dates LP was reached. Without one, or when Riot refuses, op.gg alone, with Riot's complaint passed along so the app can say the key expired. */ export async function lookupOne(name, tag, region, env, puuid, withMastery = false) { const key = riotKey(env); if (!key) return scrapeOne(name, tag, region); const r = await riotOne(name, tag, region, key, puuid, true, withMastery); if (r.body && r.body.found === false) return r; if (r.body) { try { const now = r.body.riotId || { name, tag }; // a renamed account is on op.gg under its new name const extra = await scrapeOne(now.name, now.tag, region); if (extra.body && extra.body.found) { const b = extra.body; Object.assign(r.body, { peak: b.peak, seasons: b.seasons, champs: b.champs, lpAt: b.lpAt }); } } catch (e) { /* the Riot numbers stand on their own */ } return r; } const fallback = await scrapeOne(name, tag, region); if (fallback.body) fallback.body.riotError = r.riotError; return fallback; } function opggGet(u) { const c = new AbortController(); const t = setTimeout(() => c.abort(), 12000); return fetch(u, { headers: { "User-Agent": UA, "Accept-Language": "en-US,en;q=0.9" }, signal: c.signal, }).finally(() => clearTimeout(t)); } async function scrapeOne(name, tag, region, { champions = true } = {}) { const base = `https://op.gg/lol/summoners/${encodeURIComponent(region)}/${encodeURIComponent(name)}-${encodeURIComponent(tag)}`; let res; try { res = await opggGet(base); } catch (e) { return { error: "fetch failed: " + (e && e.message), status: 502 }; } if (res.status === 404) return { body: { found: false } }; if (!res.ok) return { error: "op.gg HTTP " + res.status, status: 502 }; const html = await res.text(); // op.gg sometimes serves a 200 "summoner not found" page instead of HTTP 404. // Treating that as a parse failure made the app retry proxies and leave a // failure note on a Riot ID that simply does not exist. if (/summoner\s+not\s+found|this\s+summoner\s+is\s+unregistered/i.test(html) && !parseRankFromMeta(html)) { return { body: { found: false } }; } const fromMeta = parseRankFromMeta(html); const parsed = fromMeta || parseRankText(html); if (!parsed) return { error: "no rank data parsed from page", status: 502 }; // Champions only after a successful profile parse — a miss used to still burn a // subrequest (×20 in a batch), which sits close to Workers' subrequest limit. let champHtml = null; if (champions) try { const champs = await opggGet(base + "/champions"); if (champs.ok) champHtml = await champs.text(); } catch (e) { /* optional */ } // Meta is the clean SSR signal. Body-only matches are marked uncertain so the // app can warn without failing the check. const source = fromMeta ? "meta" : "body"; return { body: { found: true, tier: parsed.tier || "UNRANKED", division: parsed.division || null, lp: parsed.lp, wins: parsed.wins, losses: parsed.losses, level: parsed.level != null ? parsed.level : parseLevelText(html, name, tag), peak: parsePeakText(html), seasons: parseSeasons(html), flex: parseFlex(html), champs: (champHtml && parseChampionTable(champHtml)) || parseChampionsMeta(html) || parseChampions(html), lpAt: parseLpHistory(html), icon: parseProfileIcon(html), mmr: null, avgRecent: null, uncertain: !fromMeta, source, }, }; } function json(obj, status) { return new Response(JSON.stringify(obj), { status, headers: { "Content-Type": "application/json", ...CORS }, }); } const TIER_WORD = "challenger|grandmaster|master|diamond|emerald|platinum|gold|silver|bronze|iron"; const MASTER_PLUS = ["MASTER", "GRANDMASTER", "CHALLENGER"]; const DIV_MAP = { "1": "I", "2": "II", "3": "III", "4": "IV" }; // op.gg's body text uses thousands separators ("4,011 LP"); the meta description // keeps the bare digits ("4011LP"). Both have to match, or every Master+ check // fails the moment LP hits 1,000. const LP_NUM = String.raw`(\d{1,3}(?:,\d{3})+|\d{1,5})`; function lpNum(s) { return +String(s).replace(/,/g, ""); } // Flattens a page to one line of plain words. Everything that keys off word order // rather than page structure reads from this. export function htmlToText(html) { return String(html) .replace(//gi, " ") .replace(//gi, " ") .replace(/<[^>]+>/g, " ") .replace(/ /gi, " ") .replace(/</gi, "<") .replace(/>/gi, ">") .replace(/"/gi, '"') .replace(/'/gi, "'") .replace(/&/gi, "&") .replace(/\s+/g, " ") .trim(); } // Same idea, but row boundaries survive: the season tables and the champion list // are only readable as rows, so block tags become newlines instead of spaces. // The markdown rules are no-ops on the raw HTML op.gg serves, and are kept only so // this behaves identically to the app's copy on any input. export function stripRows(raw) { return String(raw) .replace(//gi, " ") .replace(//gi, " ") .replace(/<\/(tr|li|div|p|h[1-6]|section|article)\s*>/gi, "\n") .replace(//gi, "\n") .replace(/<[^>]*>/g, " ") .replace(/!\[[^\]]*\]\([^)]*\)/g, " ") // markdown images: their URLs carry tier names .replace(/\[([^\]]*)\]\([^)]*\)/g, " $1 ") // markdown links: keep the label, drop the URL .replace(/[*`_]+/g, " ") .replace(/[ \t]+/g, " ") .split("\n").map(l => l.trim()).filter(Boolean); } // Solo and Flex share the page. The queue-filter chips also say "Ranked Flex" // (right next to Solo/Duo / ARAM), so a naive split on the first hit used to cut // the Solo rank off entirely. Only treat "Ranked Flex" as the flex section when // what follows looks like a rank (Unranked or tier+LP), not another queue name. function soloHalf(raw) { const s = String(raw); const re = /\bRanked\s*Flex\b/gi; let m; while ((m = re.exec(s))) { const after = s.slice(m.index + m[0].length, m.index + m[0].length + 100).replace(/\s+/g, " ").trim(); if (/^(ARAM|Normal|All|Ranked\s*Solo)\b/i.test(after)) continue; if (/^Unranked\b/i.test(after) || new RegExp("^(" + TIER_WORD + ")\\b", "i").test(after)) { return s.slice(0, m.index); } } return s; } // The current rank. Prefer op.gg's meta description when present — it still // carries "Challenger 1 4011LP" without thousands separators. Two passes on the // Solo half follow: the strict pattern on the flattened text, then — only if that // found no tier — the same pattern on the row-stripped version. export function parseRankText(raw) { if (!raw) return null; const fromMeta = parseRankFromMeta(raw); if (fromMeta) return fromMeta; const soloRaw = soloHalf(raw); const flat = htmlToText(soloRaw); // "Unranked" before any "tier N LP" means Solo is Unranked — a later LP figure // is the season peak (Top tier), not the current rank. const unIdx = flat.search(/\bUnranked\b/i); const rankRe = new RegExp( "\\b(" + TIER_WORD + ")\\b(?:\\s+([1-4]|IV|III|II|I)(?![\\dA-Za-z,]))?(?:\\s+\\2(?![\\dA-Za-z,]))?\\s*" + LP_NUM + "\\s*LP\\b", "i" ); const rankAt = flat.search(rankRe); if (unIdx >= 0 && (rankAt < 0 || unIdx < rankAt)) { const lv = flat.match(/\bLv(?:l)?\.?\s*(\d{1,4})\b/i) || flat.match(/\bLevel\s*(\d{1,4})\b/i); return { tier: "UNRANKED", division: null, lp: null, wins: null, losses: null, level: lv ? +lv[1] : null }; } const first = parseRankFlat(flat); if (first && first.tier) return first; const second = parseRankFlat(stripRows(soloRaw).join(" ")); if (second && second.tier) return second; const hit = first || second; if (hit && hit.level != null) { hit.tier = hit.tier || "UNRANKED"; return hit; } if (/\bUnranked\b/i.test(soloRaw)) return { tier: "UNRANKED", division: null, lp: null, wins: null, losses: null, level: null }; return hit; } // op.gg's page description is the most reliable SSR signal left: // "Name#Tag / Challenger 1 4011LP / 772Win 650Lose Win rate 54% / ..." // Unranked accounts only get "/ Lv. 752". export function parseRankFromMeta(raw) { if (!raw) return null; const m = String(raw).match(/, where no number precedes it, and every // backend check came back with level: null because of it. // Deliberately a tight window and a sane range — a wrong number here would render // as a confident, wrong "Level". export function parseLevelText(raw, name, tag) { if (!raw || !name) return null; const text = htmlToText(raw); // Same window as the app: reader-mode proxies leave a markdown heading marker // between the level and the Riot ID ("764 # name#tag"), and the "#" around the // tag is optional once the tags are gone. const re = new RegExp("(\\d{1,4})[\\s#>]{0,4}" + reEsc(name) + "\\s*#?\\s*" + reEsc(tag || "") + "\\b", "i"); const m = text.match(re); if (!m) return null; const lv = +m[1]; return lv >= 1 && lv <= 5000 ? lv : null; } // op.gg's season history and champion list are real s, and the cells only // line up when the row is read as a row. stripRows breaks on , which lands // mid-cell and scattered one season across three lines — that is why past seasons // came back with the division digit parsed as the LP ("Emerald · 2 LP"). function tableRows(html) { return [...String(html).matchAll(//gi)].map(m => ({ at: m.index, rows: (m[0].match(//gi) || []).map(r => htmlToText(r)), })); } // The season peak: op.gg prints the highest rank reached under the current one and // badges it "Top tier", so the peak is the last rank printed before that badge. // The division must not be allowed to eat the first digit of the LP — without the // lookahead, "master 393 LP" parses as division 3, 93 LP. export function parsePeakText(raw) { if (!raw) return null; const text = htmlToText(raw); const i = text.search(/Top\s*tier/i); if (i < 0) return null; const before = text.slice(Math.max(0, i - 160), i); const re = new RegExp("\\b(" + TIER_WORD + ")\\b(?:\\s+([1-4]|IV|III|II|I)(?![\\dA-Za-z,]))?\\s*" + LP_NUM + "\\s*LP\\b", "ig"); let m, last = null; while ((m = re.exec(before))) last = m; if (!last) return null; const tier = last[1].toUpperCase(); return { tier, division: (MASTER_PLUS.includes(tier) || !last[2]) ? null : (DIV_MAP[last[2]] || last[2].toUpperCase()), lp: lpNum(last[3]), }; } // One row per past season, for both queues. Two shapes have to work: the real //
op.gg serves, and the pipe-table markdown the r.jina.ai proxy returns. export function parseSeasons(raw) { return parseSeasonTables(raw) || parseSeasonLines(raw); } // The
shape. Which queue a table belongs to is decided by the heading // immediately above it — "Ranked Flex" sits right on top of the flex one — with // document order as the fallback, since op.gg always prints solo first. function parseSeasonTables(raw) { if (!raw) return null; const html = String(raw); const rowRe = new RegExp("^(S\\d{4}(?:\\s*S\\d)?)\\s+(" + TIER_WORD + ")(?:\\s+([1-4])(?!\\d))?\\s+" + LP_NUM + "$", "i"); const out = { solo: [], flex: [] }; let n = 0; for (const t of tableRows(html)) { if (!t.rows.some(r => /^season\s+tier\s+lp$/i.test(r))) continue; // not a season table const above = htmlToText(html.slice(Math.max(0, t.at - 400), t.at)); const bucket = /ranked\s*flex\s*[^<]{0,30}$/i.test(above) ? "flex" : (n === 0 ? "solo" : "flex"); n++; for (const r of t.rows) { const m = r.trim().match(rowRe); if (!m) continue; const tier = m[2].toUpperCase(); out[bucket].push({ season: m[1].replace(/\s+/g, " "), tier, division: (MASTER_PLUS.includes(tier) || !m[3]) ? null : DIV_MAP[m[3]], lp: lpNum(m[4]), }); } } return (out.solo.length || out.flex.length) ? out : null; } function parseSeasonLines(raw) { if (!raw) return null; // The division is optional and must not be allowed to eat the first digit of the // LP: without the lookahead, "S2025 master 135" reads as division 1, 35 LP. The // markdown shape hides this because its pipes separate the cells; raw table HTML // does not, and raw HTML is what op.gg serves this worker. const rowRe = new RegExp("(S\\d{4}(?:\\s*S\\d)?)\\s*\\|?\\s*(?:\\|\\s*)?(" + TIER_WORD + ")(?:\\s+([1-4])(?!\\d))?\\s*\\|?\\s*" + LP_NUM + "\\s*\\|?\\s*$", "i"); const out = { solo: [], flex: [] }; let bucket = null; for (const line of stripRows(raw)) { if (/ranked\s*solo/i.test(line) && /season/i.test(line) && /\bLP\b/i.test(line)) { bucket = "solo"; continue; } if (/ranked\s*flex/i.test(line) && /season/i.test(line) && /\bLP\b/i.test(line)) { bucket = "flex"; continue; } if (!bucket) continue; const m = line.match(rowRe); if (!m) { if (/^\|?\s*:?-{2,}/.test(line) || /season/i.test(line)) continue; if (out[bucket].length) bucket = null; continue; } const tier = m[2].toUpperCase(); out[bucket].push({ season: m[1].replace(/\s+/g, " "), tier, division: (MASTER_PLUS.includes(tier) || !m[3]) ? null : DIV_MAP[m[3]], lp: lpNum(m[4]), }); } return (out.solo.length || out.flex.length) ? out : null; } // The flex rank sits under its own heading and is usually just "Unranked". Only // trusted when a tier and an LP figure appear together right after that heading. export function parseFlex(raw) { if (!raw) return null; const lines = stripRows(raw); for (let i = 0; i < lines.length; i++) { if (!/^ranked\s*flex$/i.test(lines[i])) continue; const near = lines.slice(i + 1, i + 4).join(" "); if (/^\s*unranked/i.test(near)) return { tier: "UNRANKED", division: null, lp: null }; const m = near.match(new RegExp("\\b(" + TIER_WORD + ")\\b(?:\\s+([1-4]|IV|III|II|I)(?![\\dA-Za-z,]))?\\s*" + LP_NUM + "\\s*LP\\b", "i")); if (m) { const tier = m[1].toUpperCase(); return { tier, division: (MASTER_PLUS.includes(tier) || !m[2]) ? null : (DIV_MAP[m[2]] || m[2].toUpperCase()), lp: lpNum(m[3]), }; } } return null; } // The season champion table lives on the /champions sub-page, not the profile — // the profile page carries no per-champion season totals at all, which is why // this came back null however the regex was written. Rows read: // "1 Ashe 15 W 10 L 60% 2.25:1 6 / 6.8 / 9.3 (48%) ..." // The rank number at the front is what separates a real row from the "All // champions" summary and the "vs " matchup rows folded underneath. export function parseChampionTable(raw) { if (!raw) return null; // Every champion's matchup breakdown is its own
nested inside the row, // so a non-greedy
…
match closes on the inner one and loses all // but the first champion. Scanning across the whole document sidesteps the // nesting entirely — the leading rank number is already what identifies a real // row, against the "All champions" summary and the "vs " sub-rows. const re = /^(\d{1,3})\s+(.{2,26}?)\s+(\d{1,4})\s*W\s+(\d{1,4})\s*L\s+(\d{1,3})%\s+([\d.]+):1\s+([\d.]+)\s*\/\s*([\d.]+)\s*\/\s*([\d.]+)/i; const out = []; for (const m0 of String(raw).matchAll(//gi)) { const m = htmlToText(m0[0]).trim().match(re); if (!m) continue; const name = m[2].replace(/\s+/g, " ").trim(); if (!name || out.some(c => c.name === name)) continue; out.push({ name, wins: +m[3], losses: +m[4], games: +m[3] + +m[4], wr: +m[5], kda: +m[6], k: +m[7], d: +m[8], a: +m[9] }); } return out.length ? out.slice(0, 10) : null; } // Fallback, and the only champion data the profile page itself carries: op.gg // puts the season's top five in the page description, with wins and losses but // no KDA. Worth having — five champions with real win rates beat none. export function parseChampionsMeta(raw) { if (!raw) return null; const m = String(raw).match(/ tag — // same fallback the app uses, so the backend path does not go quiet where the // proxy path would still find five champions. const src = m ? m[1] : String(raw).slice(0, 4000); const out = []; const re = /([A-Za-z0-9'.\- ]{2,26}?)\s*-\s*(\d{1,4})Win\s+(\d{1,4})Lose\s+Win rate\s+(\d{1,3})%/gi; let r; while ((r = re.exec(src))) { const name = r[1].replace(/\s+/g, " ").trim(); if (!name || out.some(c => c.name === name)) continue; out.push({ name, wins: +r[2], losses: +r[3], games: +r[2] + +r[3], wr: +r[4], kda: null }); } return out.length ? out.slice(0, 10) : null; } // Kept for the markdown a rendering proxy returns, where the champion list comes // through as "Ashe CS 209 (7.2) 2.25:1 KDA 6 / 6.8 / 9.3 60%25 Games". export function parseChampions(raw) { if (!raw) return null; const re = /^(.{2,26}?)\s+CS\s+[\d.,]+\s*\([\d.]+\)\s+([\d.]+):1\s+KDA\s+([\d.]+)\s*\/\s*([\d.]+)\s*\/\s*([\d.]+)\s+(\d{1,3})%\s*(\d{1,3})\s+Games\b/i; const out = []; for (const line of stripRows(raw)) { const m = line.match(re); if (!m) continue; const name = m[1].replace(/\s+/g, " ").trim(); if (!name || out.some(c => c.name === name)) continue; out.push({ name, kda: +m[2], k: +m[3], d: +m[4], a: +m[5], wr: +m[6], games: +m[7] }); } return out.length ? out.slice(0, 10) : null; } // op.gg's tier graph runs off an lpHistories array; exactly one entry of it is // server-rendered into the page and the rest arrives over an internal RPC keyed // to their build, which is not something to depend on. The one entry still beats // nothing: it carries the moment the LP actually changed, so a check made a week // later dates the point correctly instead of stamping it "now". export function parseLpHistory(raw) { if (!raw) return null; const text = String(raw).replace(/\\"/g, '"'); const m = text.match(/"created_at"\s*:\s*"([^"]+)"\s*,\s*"tier_info"\s*:\s*\{([^}]*)\}\s*(?:,\s*"elo_point"\s*:\s*(\d+))?/); if (!m) return null; const t = Date.parse(m[1]); if (!t || t > Date.now() + 86400000) return null; const tier = m[2].match(/"tier"\s*:\s*"([A-Z]+)"/i); // the name has to be a real tier, or a typo upstream becomes a point on a chart if (!tier || !new RegExp("^(" + TIER_WORD + ")$", "i").test(tier[1])) return null; const T = tier[1].toUpperCase(); const lp = m[2].match(/"lp"\s*:\s*(\d+)/); const label = m[2].match(/"label"\s*:\s*"([^"]*)"/); const div = label && label[1].match(/\b([1-4])\b/); return { t, tier: T, division: (MASTER_PLUS.includes(T) || !div) ? null : DIV_MAP[div[1]], lp: lp ? +lp[1] : 0, elo: m[3] ? +m[3] : null, }; } export function parseProfileIcon(raw) { if (!raw) return null; const m = String(raw).match(/profile_icons?\/profileicon(\d+)/i); return m ? `https://opgg-static.akamaized.net/meta/images/profile_icons/profileIcon${m[1]}.jpg` : null; }