Import 40k-rankings.gateway-gamers.net into monorepo
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
name: Refresh ELO Data
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * *' # 06:00 UTC daily
|
||||
workflow_dispatch: # allow manual runs from the Actions tab
|
||||
|
||||
jobs:
|
||||
refresh:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Download ELO workbook and build JSON
|
||||
working-directory: statcheck-elo-scraper
|
||||
run: python refresh_elo.py
|
||||
|
||||
- name: Commit updated data if changed
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add public/elo-data.json
|
||||
git diff --cached --quiet || git commit -m "data: refresh stat-check ELO rankings [skip ci]"
|
||||
git push
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
@@ -0,0 +1,49 @@
|
||||
stages:
|
||||
- build
|
||||
- deploy
|
||||
- refresh
|
||||
|
||||
build:
|
||||
stage: build
|
||||
script:
|
||||
- npm ci
|
||||
- npm run build
|
||||
artifacts:
|
||||
paths:
|
||||
- dist/
|
||||
expire_in: 1 hour
|
||||
only:
|
||||
- main
|
||||
except:
|
||||
- schedules
|
||||
|
||||
deploy:
|
||||
stage: deploy
|
||||
script:
|
||||
- rsync -avz --delete dist/ /var/www/domains/gateway-gamers.net/40k-rankings/
|
||||
only:
|
||||
- main
|
||||
except:
|
||||
- schedules
|
||||
environment:
|
||||
name: production
|
||||
url: https://40k-rankings.gateway-gamers.net
|
||||
|
||||
refresh-elo:
|
||||
stage: refresh
|
||||
image: python:3.12-slim
|
||||
variables:
|
||||
GIT_STRATEGY: clone
|
||||
script:
|
||||
- cd statcheck-elo-scraper && python refresh_elo.py && cd ..
|
||||
- git config user.email "gitlab-ci@gateway-gamers.net"
|
||||
- git config user.name "GitLab CI"
|
||||
- git remote set-url origin "https://oauth2:${GITLAB_ACCESS_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git"
|
||||
- git add public/elo-data.json
|
||||
- |
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "data: refresh stat-check ELO rankings"
|
||||
git push origin HEAD:main
|
||||
fi
|
||||
only:
|
||||
- schedules
|
||||
@@ -0,0 +1,36 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name 40k-rankings.gateway-gamers.net;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name 40k-rankings.gateway-gamers.net;
|
||||
|
||||
root /var/www/domains/gateway-gamers.net/40k-rankings;
|
||||
index index.html;
|
||||
|
||||
# SSL — managed by Certbot
|
||||
# ssl_certificate /etc/letsencrypt/live/40k-rankings.gateway-gamers.net/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/40k-rankings.gateway-gamers.net/privkey.pem;
|
||||
# include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
# ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
# Hide nginx version
|
||||
server_tokens off;
|
||||
|
||||
# Security headers
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self' https://newprod-api.bestcoastpairings.com; img-src 'self' data:;" always;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Gateway Gamers — 40K ITC Standings</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Saira+Condensed:wght@500;600;700&family=Inter:wght@400;500;600&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
/* ---- Palette: "command ledger" — gunmetal base, signal amber ---- */
|
||||
:root{
|
||||
--ink:#0d1014; --panel:#151b22; --panel-2:#1b232c; --edge:#28323d;
|
||||
--steel:#8a97a6; --steel-dim:#5d6873; --bone:#eceef1;
|
||||
--signal:#e0a73e; --signal-dim:#7a5e25;
|
||||
--win:#5fa88c; --loss:#bf5a52;
|
||||
--r:10px;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0}
|
||||
body{
|
||||
background:
|
||||
radial-gradient(1200px 600px at 80% -10%, #1a222c 0%, transparent 60%),
|
||||
var(--ink);
|
||||
color:var(--bone);
|
||||
font-family:Inter,system-ui,sans-serif;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
line-height:1.45;
|
||||
min-height:100vh;
|
||||
}
|
||||
.wrap{max-width:920px;margin:0 auto;padding:28px 20px 64px}
|
||||
|
||||
/* ---- Masthead ---- */
|
||||
.masthead{display:flex;align-items:center;gap:16px;padding-bottom:20px;
|
||||
border-bottom:1px solid var(--edge);flex-wrap:wrap}
|
||||
.crest{width:54px;height:54px;flex:0 0 auto;display:grid;place-items:center;
|
||||
border:1.5px solid var(--signal);color:var(--signal);border-radius:50%;
|
||||
font-family:"Saira Condensed",sans-serif;font-weight:700;font-size:22px;
|
||||
letter-spacing:.5px;background:rgba(224,167,62,.06)}
|
||||
.titles{flex:1 1 240px;min-width:0}
|
||||
h1{font-family:"Saira Condensed",sans-serif;font-weight:700;letter-spacing:.5px;
|
||||
text-transform:uppercase;margin:0;font-size:clamp(28px,5vw,42px);line-height:.95}
|
||||
.kicker{margin:4px 0 0;color:var(--steel);font-size:13px;letter-spacing:.18em;
|
||||
text-transform:uppercase}
|
||||
.status{display:flex;align-items:center;gap:12px;margin-left:auto}
|
||||
.live{display:flex;align-items:center;gap:7px;color:var(--steel);font-size:12.5px}
|
||||
.dot{width:8px;height:8px;border-radius:50%;background:var(--steel-dim)}
|
||||
.dot.on{background:var(--win);box-shadow:0 0 0 0 rgba(95,168,140,.6);
|
||||
animation:pulse 2.4s infinite}
|
||||
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(95,168,140,.5)}
|
||||
70%{box-shadow:0 0 0 7px rgba(95,168,140,0)}100%{box-shadow:0 0 0 0 transparent}}
|
||||
button#refresh{font-family:"Saira Condensed",sans-serif;text-transform:uppercase;
|
||||
letter-spacing:.1em;font-weight:600;font-size:13px;color:var(--ink);
|
||||
background:var(--signal);border:0;border-radius:6px;padding:8px 14px;cursor:pointer}
|
||||
button#refresh:hover{filter:brightness(1.08)}
|
||||
button#refresh:disabled{opacity:.5;cursor:wait}
|
||||
button#refresh:focus-visible{outline:2px solid var(--bone);outline-offset:2px}
|
||||
|
||||
/* ---- Team stat strip ---- */
|
||||
.strip{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;margin:22px 0;
|
||||
background:var(--edge);border:1px solid var(--edge);border-radius:var(--r);overflow:hidden}
|
||||
.stat{background:var(--panel);padding:14px 16px}
|
||||
.stat .n{font-family:"Space Mono",monospace;font-size:22px;font-weight:700}
|
||||
.stat .l{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--steel);margin-top:2px}
|
||||
@media(max-width:560px){.strip{grid-template-columns:repeat(2,1fr)}}
|
||||
|
||||
/* ---- Banner (warnings/errors) ---- */
|
||||
.banner{border-radius:var(--r);padding:12px 16px;margin:16px 0;font-size:14px;
|
||||
border:1px solid var(--edge);background:var(--panel);display:none}
|
||||
.banner.show{display:block}
|
||||
.banner.warn{border-color:var(--signal-dim);background:rgba(224,167,62,.08)}
|
||||
.banner.err{border-color:#5a2f2c;background:rgba(191,90,82,.1)}
|
||||
.banner b{color:var(--signal)}
|
||||
.banner code{font-family:"Space Mono",monospace;font-size:12.5px;color:var(--bone)}
|
||||
|
||||
/* ---- Champion ---- */
|
||||
.champion{display:none;align-items:center;gap:18px;margin:18px 0;
|
||||
background:linear-gradient(100deg,var(--panel-2),var(--panel));
|
||||
border:1px solid var(--signal-dim);border-radius:var(--r);padding:18px 20px;position:relative}
|
||||
.champion.show{display:flex}
|
||||
.champion .rk{font-family:"Saira Condensed",sans-serif;font-weight:700;font-size:42px;
|
||||
color:var(--signal);line-height:1;width:46px;text-align:center}
|
||||
.champion .who{flex:1 1 auto;min-width:0}
|
||||
.champion .name{font-family:"Saira Condensed",sans-serif;font-weight:600;
|
||||
font-size:26px;text-transform:uppercase;letter-spacing:.5px}
|
||||
.champion .sub{color:var(--steel);font-size:13px}
|
||||
.champion .pts{font-family:"Space Mono",monospace;font-weight:700;font-size:30px;color:var(--signal)}
|
||||
.champion .pts span{display:block;font-family:Inter;font-weight:500;font-size:11px;
|
||||
letter-spacing:.14em;text-transform:uppercase;color:var(--steel);text-align:right}
|
||||
.laurel{position:absolute;top:10px;right:14px;font-size:11px;letter-spacing:.18em;
|
||||
text-transform:uppercase;color:var(--signal)}
|
||||
|
||||
/* ---- Board ---- */
|
||||
table{width:100%;border-collapse:collapse;margin-top:8px}
|
||||
thead th{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--steel-dim);
|
||||
text-align:left;padding:0 12px 10px;font-weight:600}
|
||||
thead th.num{text-align:right}
|
||||
tbody tr{border-top:1px solid var(--edge);opacity:0;transform:translateY(6px);
|
||||
animation:rise .4s ease forwards}
|
||||
@keyframes rise{to{opacity:1;transform:none}}
|
||||
tbody td{padding:13px 12px;vertical-align:middle}
|
||||
.c-rank{font-family:"Space Mono",monospace;color:var(--steel);width:48px}
|
||||
.c-rank b{color:var(--bone)}
|
||||
.c-name{font-weight:600}
|
||||
.c-name .nick{display:block;font-weight:400;font-size:12px;color:var(--steel-dim)}
|
||||
.c-pts{font-family:"Space Mono",monospace;font-weight:700;text-align:right;font-size:16px}
|
||||
.c-rec{font-family:"Space Mono",monospace;text-align:right;color:var(--steel);font-size:13px;white-space:nowrap}
|
||||
.c-rec .w{color:var(--win)}.c-rec .l{color:var(--loss)}
|
||||
.c-lg{font-family:"Space Mono",monospace;text-align:right;color:var(--steel-dim);font-size:13px}
|
||||
@media(max-width:560px){.hide-sm{display:none}.c-pts{font-size:15px}}
|
||||
|
||||
/* ---- States ---- */
|
||||
.empty{display:none;text-align:center;color:var(--steel);padding:48px 20px}
|
||||
.empty.show{display:block}
|
||||
.skeleton td{padding:16px 12px}
|
||||
.sk{height:14px;border-radius:4px;background:linear-gradient(90deg,var(--panel),var(--panel-2),var(--panel));
|
||||
background-size:200% 100%;animation:shimmer 1.3s infinite}
|
||||
@keyframes shimmer{to{background-position:-200% 0}}
|
||||
|
||||
footer{margin-top:28px;padding-top:16px;border-top:1px solid var(--edge);
|
||||
color:var(--steel-dim);font-size:12px;display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap}
|
||||
footer a{color:var(--steel)}
|
||||
|
||||
@media(prefers-reduced-motion:reduce){
|
||||
*{animation:none!important}
|
||||
tbody tr{opacity:1;transform:none}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<header class="masthead">
|
||||
<div class="crest">GG</div>
|
||||
<div class="titles">
|
||||
<h1>Gateway Gamers</h1>
|
||||
<p class="kicker">Warhammer 40,000 · ITC Standings</p>
|
||||
</div>
|
||||
<div class="status">
|
||||
<span class="live"><span class="dot" id="dot"></span><span id="updated">Loading…</span></span>
|
||||
<button id="refresh">Refresh</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="strip" id="strip" aria-label="Team summary" hidden>
|
||||
<div class="stat"><div class="n" id="s-placed">—</div><div class="l">Players placed</div></div>
|
||||
<div class="stat"><div class="n" id="s-top">—</div><div class="l">Top ITC points</div></div>
|
||||
<div class="stat"><div class="n" id="s-rec">—</div><div class="l">Combined W–L–T</div></div>
|
||||
<div class="stat"><div class="n" id="s-best">—</div><div class="l">Best league rank</div></div>
|
||||
</section>
|
||||
|
||||
<div class="banner" id="banner"></div>
|
||||
|
||||
<section class="champion" id="champion" aria-label="Top player">
|
||||
<div class="rk">1</div>
|
||||
<div class="who">
|
||||
<div class="name" id="champ-name"></div>
|
||||
<div class="sub" id="champ-sub"></div>
|
||||
</div>
|
||||
<div class="pts" id="champ-pts"></div>
|
||||
<div class="laurel">Team Leader</div>
|
||||
</section>
|
||||
|
||||
<table id="board">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="c-rank">#</th>
|
||||
<th>Player</th>
|
||||
<th class="num">ITC Points</th>
|
||||
<th class="num hide-sm">W–L–T</th>
|
||||
<th class="num hide-sm">League Rank</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
|
||||
<div class="empty" id="empty"></div>
|
||||
|
||||
<footer>
|
||||
<span>Data from Best Coast Pairings · live on refresh</span>
|
||||
<span id="meta"></span>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ============================================================
|
||||
CONFIG — the only block you edit
|
||||
============================================================ */
|
||||
const CONFIG = {
|
||||
teamId: "2iGDVMgX0a",
|
||||
leagueId: "BYaaUfKum7z0",
|
||||
regionId: "VgQKgqmTPU",
|
||||
|
||||
// Fallback roster, used only if the live team fetch needs auth.
|
||||
// Paste the member user IDs your Python --debug run assembled, e.g.
|
||||
// ["yHkqr1DICY", "PO4GKS85dz", ...]
|
||||
memberIds: [
|
||||
// "yHkqr1DICY",
|
||||
],
|
||||
|
||||
// Single-call sizes to try (BCP ignores offset; rejects oversize with 409).
|
||||
limitLadder: [14000, 12000, 10000, 8000, 6000, 4000, 3000],
|
||||
|
||||
// Flip to true to preview the layout with placeholder rows (no network).
|
||||
useSample: false,
|
||||
};
|
||||
|
||||
const API = "https://newprod-api.bestcoastpairings.com/v1";
|
||||
const HEADERS = { "client-id": "web-app", "env": "bcp", "accept": "application/json" };
|
||||
const CACHE_KEY = "gg_board_v1";
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const fmtPts = (n) => Number(n||0).toLocaleString(undefined,{minimumFractionDigits:1,maximumFractionDigits:1});
|
||||
const fullName = (u={}) => [u.firstName,u.lastName].filter(Boolean).join(" ").trim();
|
||||
|
||||
/* ---- Roster ---- */
|
||||
async function getMemberIds(force){
|
||||
try{
|
||||
const r = await fetch(`${API}/teams/${CONFIG.teamId}?expand[]=owner`,
|
||||
{headers:HEADERS, cache: force?"reload":"default"});
|
||||
if(!r.ok) throw new Error("auth/"+r.status);
|
||||
let t = await r.json(); if(Array.isArray(t)) t = t[0]||{};
|
||||
const ids = new Set();
|
||||
if(Array.isArray(t.memberIds)) t.memberIds.forEach(x=>typeof x==="string"&&ids.add(x));
|
||||
if(typeof t.ownerId==="string") ids.add(t.ownerId);
|
||||
[t.users,t.members,t.teamMembers].forEach(list=>{
|
||||
if(Array.isArray(list)) list.forEach(o=>o&&o.id&&ids.add(o.id));
|
||||
});
|
||||
if(t.owner&&t.owner.id) ids.add(t.owner.id);
|
||||
if(ids.size) return {ids, name:t.name, live:true};
|
||||
throw new Error("empty");
|
||||
}catch(e){
|
||||
if(CONFIG.memberIds.length) return {ids:new Set(CONFIG.memberIds), name:"Gateway Gamers", live:false};
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Standings (largest accepted limit, one call) ---- */
|
||||
async function getPlacings(force){
|
||||
const base = `${API}/placings?placingsType=player&leagueId=${CONFIG.leagueId}`+
|
||||
`®ionId=${CONFIG.regionId}&sortAscending=false`;
|
||||
for(const lim of CONFIG.limitLadder){
|
||||
const r = await fetch(`${base}&limit=${lim}`, {headers:HEADERS, cache: force?"reload":"default"});
|
||||
if(r.status===409) continue;
|
||||
if(!r.ok) throw new Error("placings/"+r.status);
|
||||
const p = await r.json();
|
||||
const rows = Array.isArray(p) ? p : (p.data||p.placings||[]);
|
||||
return {rows, limit:lim, truncated: rows.length>=lim};
|
||||
}
|
||||
throw new Error("placings/limit-all-rejected");
|
||||
}
|
||||
|
||||
/* ---- Build leaderboard ---- */
|
||||
function build(rows, ids){
|
||||
const kept=[], matched=new Set();
|
||||
for(const rec of rows){
|
||||
const u = rec.user||{};
|
||||
const uid = rec.userId||u.id;
|
||||
if(uid && ids.has(uid)){ kept.push(rec); matched.add(uid); }
|
||||
}
|
||||
kept.sort((a,b)=> Number(b.ITCPoints||0)-Number(a.ITCPoints||0));
|
||||
return {kept, matchedCount:matched.size};
|
||||
}
|
||||
|
||||
/* ---- Render ---- */
|
||||
function render(state){
|
||||
const {kept, rosterSize, updated, truncated, limit, live, fromCache} = state;
|
||||
$("dot").className = "dot on";
|
||||
$("updated").textContent = (fromCache?"Cached ":"Updated ") +
|
||||
new Date(updated).toLocaleTimeString([], {hour:"2-digit",minute:"2-digit"});
|
||||
$("meta").textContent = `${kept.length} of ${rosterSize} members placed`+(live?"":" · roster (cached list)");
|
||||
|
||||
// Banner
|
||||
const b=$("banner"); b.className="banner";
|
||||
if(truncated){
|
||||
b.classList.add("show","warn");
|
||||
b.innerHTML = `<b>Heads up:</b> the league returned the full <code>limit=${limit}</code> rows, `+
|
||||
`so some lower-ranked players may be cut off. Tell the developer so the paging cursor can be added.`;
|
||||
}
|
||||
|
||||
const champ=$("champion"), strip=$("strip"), empty=$("empty"), tb=$("rows");
|
||||
tb.innerHTML="";
|
||||
|
||||
if(!kept.length){
|
||||
champ.classList.remove("show"); strip.hidden=true;
|
||||
empty.classList.add("show");
|
||||
empty.innerHTML = "No Gateway Gamers players found in this league’s standings yet. "+
|
||||
"Once members log games here, they’ll appear ranked by ITC points.";
|
||||
return;
|
||||
}
|
||||
empty.classList.remove("show"); strip.hidden=false;
|
||||
|
||||
// Champion card
|
||||
const top=kept[0], tu=top.user||{};
|
||||
champ.classList.add("show");
|
||||
$("champ-name").textContent = fullName(tu)||tu.nickname||"—";
|
||||
$("champ-sub").textContent = `${top.wins||0}–${top.losses||0}–${top.ties||0} · league #${top.placing??"—"}`;
|
||||
$("champ-pts").innerHTML = `${fmtPts(top.ITCPoints)}<span>ITC points</span>`;
|
||||
|
||||
// Stat strip
|
||||
const W=kept.reduce((s,r)=>s+(+r.wins||0),0),
|
||||
L=kept.reduce((s,r)=>s+(+r.losses||0),0),
|
||||
T=kept.reduce((s,r)=>s+(+r.ties||0),0),
|
||||
best=Math.min(...kept.map(r=>+r.placing||Infinity));
|
||||
$("s-placed").textContent=kept.length;
|
||||
$("s-top").textContent=fmtPts(top.ITCPoints);
|
||||
$("s-rec").textContent=`${W}–${L}–${T}`;
|
||||
$("s-best").textContent=isFinite(best)?("#"+best):"—";
|
||||
|
||||
// Rows
|
||||
kept.forEach((r,i)=>{
|
||||
const u=r.user||{}, name=fullName(u)||u.nickname||"Unknown";
|
||||
const tr=document.createElement("tr");
|
||||
tr.style.animationDelay=(Math.min(i,20)*22)+"ms";
|
||||
tr.innerHTML =
|
||||
`<td class="c-rank">${i===0?"<b>1</b>":(i+1)}</td>`+
|
||||
`<td class="c-name">${esc(name)}${u.nickname&&fullName(u)?`<span class="nick">"${esc(u.nickname)}"</span>`:""}</td>`+
|
||||
`<td class="c-pts">${fmtPts(r.ITCPoints)}</td>`+
|
||||
`<td class="c-rec hide-sm"><span class="w">${r.wins||0}</span>–<span class="l">${r.losses||0}</span>–${r.ties||0}</td>`+
|
||||
`<td class="c-lg hide-sm">#${r.placing??"—"}</td>`;
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
}
|
||||
function esc(s){return String(s).replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c]));}
|
||||
|
||||
/* ---- Orchestration ---- */
|
||||
function paintCache(){
|
||||
try{
|
||||
const c=JSON.parse(localStorage.getItem(CACHE_KEY)||"null");
|
||||
if(c&&c.kept) render({...c, fromCache:true});
|
||||
}catch(e){}
|
||||
}
|
||||
function saveCache(state){
|
||||
try{ localStorage.setItem(CACHE_KEY, JSON.stringify({
|
||||
kept:state.kept, rosterSize:state.rosterSize, updated:state.updated,
|
||||
truncated:state.truncated, limit:state.limit, live:state.live
|
||||
})); }catch(e){}
|
||||
}
|
||||
|
||||
async function load(force){
|
||||
const btn=$("refresh"); btn.disabled=true; $("dot").className="dot";
|
||||
$("updated").textContent="Fetching…";
|
||||
showSkeleton();
|
||||
try{
|
||||
if(CONFIG.useSample){ return render({...SAMPLE, fromCache:false}); }
|
||||
const {ids,name,live} = await getMemberIds(force);
|
||||
const {rows,limit,truncated} = await getPlacings(force);
|
||||
const {kept} = build(rows, ids);
|
||||
const state={kept, rosterSize:ids.size, updated:Date.now(), truncated, limit, live};
|
||||
render({...state, fromCache:false});
|
||||
saveCache(state);
|
||||
}catch(err){
|
||||
failure(err);
|
||||
}finally{
|
||||
btn.disabled=false;
|
||||
}
|
||||
}
|
||||
|
||||
function showSkeleton(){
|
||||
const tb=$("rows"); tb.innerHTML="";
|
||||
for(let i=0;i<6;i++){
|
||||
const tr=document.createElement("tr"); tr.className="skeleton";
|
||||
tr.innerHTML=`<td><div class="sk" style="width:20px"></div></td>
|
||||
<td><div class="sk" style="width:60%"></div></td>
|
||||
<td><div class="sk" style="width:50px;margin-left:auto"></div></td>
|
||||
<td class="hide-sm"><div class="sk" style="width:50px;margin-left:auto"></div></td>
|
||||
<td class="hide-sm"><div class="sk" style="width:40px;margin-left:auto"></div></td>`;
|
||||
tb.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
function failure(err){
|
||||
$("dot").className="dot";
|
||||
$("updated").textContent="Couldn’t update";
|
||||
$("rows").innerHTML=""; $("champion").classList.remove("show"); $("strip").hidden=true;
|
||||
const b=$("banner"); b.className="banner show err";
|
||||
const msg=String(err.message||err);
|
||||
if(msg.startsWith("auth")){
|
||||
b.innerHTML="The team roster needs a login. Add the member IDs to "+
|
||||
"<code>CONFIG.memberIds</code> in this file so the page can work without one.";
|
||||
}else if(msg.includes("placings")){
|
||||
b.innerHTML="Couldn’t reach the BCP standings feed. If you opened this file inside a "+
|
||||
"preview that blocks outside requests, host it (GitHub Pages, Netlify, your store site) "+
|
||||
"or open the file directly in a browser.";
|
||||
}else{
|
||||
b.innerHTML="Something went wrong reaching Best Coast Pairings: <code>"+esc(msg)+"</code>";
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Sample (preview only; CONFIG.useSample=true) ---- */
|
||||
const SAMPLE = (()=>{ const mk=(f,l,p,w,ls,t,pl)=>({user:{firstName:f,lastName:l},ITCPoints:p,wins:w,losses:ls,ties:t,placing:pl});
|
||||
const kept=[mk("Eric","Darais",920.7,19,4,0,453),mk("Jordan","Vance",812.4,16,6,1,712),
|
||||
mk("Mara","Singh",770.1,15,7,0,889),mk("Devon","Cole",655.9,12,8,2,1340)];
|
||||
return {kept, rosterSize:8, updated:Date.now(), truncated:false, limit:14000, live:true};
|
||||
})();
|
||||
|
||||
/* ---- Go ---- */
|
||||
$("refresh").addEventListener("click",()=>load(true));
|
||||
paintCache();
|
||||
load(false);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Gateway Gamers — 40K Rankings</title>
|
||||
<link rel="stylesheet" href="/src/style.css" />
|
||||
</head>
|
||||
<body class="min-h-screen flex flex-col">
|
||||
|
||||
<div id="header"></div>
|
||||
|
||||
<main class="flex-1 max-w-5xl w-full mx-auto px-6 py-10">
|
||||
|
||||
<!-- Page heading + live status + refresh -->
|
||||
<div class="flex items-start justify-between mb-6 flex-wrap gap-4">
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold uppercase tracking-wide">40K Rankings</h1>
|
||||
<p class="text-sm opacity-50 uppercase tracking-widest mt-1">Warhammer 40,000 · ITC Standings</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 pt-1">
|
||||
<span class="flex items-center gap-2 text-sm opacity-60">
|
||||
<span class="w-2 h-2 rounded-full bg-base-content/30 transition-colors" id="dot"></span>
|
||||
<span id="updated">Loading…</span>
|
||||
</span>
|
||||
<button id="refresh" class="btn btn-warning btn-sm uppercase tracking-wide">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats strip -->
|
||||
<div class="stats shadow w-full mb-6 bg-base-200" id="strip" hidden>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Players Placed</div>
|
||||
<div class="stat-value text-2xl" id="s-placed">—</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Top ITC Points</div>
|
||||
<div class="stat-value text-2xl text-warning" id="s-top">—</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Team Win %</div>
|
||||
<div class="stat-value text-2xl" id="s-rec">—</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Best League Rank</div>
|
||||
<div class="stat-value text-2xl" id="s-best">—</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Banner (warnings / errors) -->
|
||||
<div class="alert mb-4 hidden" id="banner"></div>
|
||||
|
||||
<!-- Champion card -->
|
||||
<div class="card card-border border-warning bg-base-200 mb-6 hidden" id="champion">
|
||||
<div class="card-body flex-row items-center gap-6 py-4">
|
||||
<span class="text-warning font-mono font-bold text-5xl w-12 text-center shrink-0">1</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-bold text-2xl uppercase tracking-wide" id="champ-name"></div>
|
||||
<div class="text-sm opacity-60 mt-1" id="champ-sub"></div>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<div class="text-warning font-mono font-bold text-3xl" id="champ-pts"></div>
|
||||
<div class="text-xs opacity-50 uppercase tracking-widest mt-1">Team Leader</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Leaderboard table -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra w-full" id="board">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-12">#</th>
|
||||
<th class="cursor-pointer select-none" data-sort="name">Player</th>
|
||||
<th class="text-right cursor-pointer select-none" data-sort="pts">ITC Points</th>
|
||||
<th class="text-right hidden sm:table-cell cursor-pointer select-none" data-sort="lmc">LMC Points</th>
|
||||
<th class="text-right hidden sm:table-cell cursor-pointer select-none" data-sort="wlt">W–L–T</th>
|
||||
<th class="text-right hidden sm:table-cell cursor-pointer select-none" data-sort="wpct">Win %</th>
|
||||
<th class="text-right hidden sm:table-cell cursor-pointer select-none" data-sort="rank">ITC Rank</th>
|
||||
<th class="text-right hidden sm:table-cell cursor-pointer select-none" data-sort="lmcrank">LMC Rank</th>
|
||||
<th class="text-center hidden sm:table-cell cursor-pointer select-none" data-sort="elo">Stat-Check ELO</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="text-center text-base-content/50 py-12 hidden" id="empty"></div>
|
||||
|
||||
<div class="divider mt-8"></div>
|
||||
|
||||
<p class="text-xs text-base-content/40 text-center" id="meta">
|
||||
Data from Best Coast Pairings · live on refresh
|
||||
</p>
|
||||
|
||||
</main>
|
||||
|
||||
<div id="footer"></div>
|
||||
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1847
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "40k-rankings-gateway-gamers",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"daisyui": "^5.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {}
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
|
||||
<footer class="footer footer-center bg-base-300 text-base-content p-4">
|
||||
<p>© 2026 Gateway Gamers. All rights reserved.</p>
|
||||
</footer>
|
||||
@@ -0,0 +1,59 @@
|
||||
<header class="bg-base-300 shadow-lg">
|
||||
|
||||
<!-- Hero: carousel background + centered logo -->
|
||||
<div class="relative overflow-hidden min-h-64 py-10 flex items-center justify-center">
|
||||
|
||||
<!-- Carousel images -->
|
||||
<div id="hero-carousel" class="absolute inset-0">
|
||||
<img src="/images/photo1.jpg" class="carousel-slide absolute inset-0 w-full h-full object-cover opacity-0 transition-opacity duration-1000" />
|
||||
<img src="/images/photo2.jpg" class="carousel-slide absolute inset-0 w-full h-full object-cover opacity-0 transition-opacity duration-1000" />
|
||||
<img src="/images/photo3.jpg" class="carousel-slide absolute inset-0 w-full h-full object-cover opacity-0 transition-opacity duration-1000" />
|
||||
<img src="/images/photo4.jpg" class="carousel-slide absolute inset-0 w-full h-full object-cover opacity-0 transition-opacity duration-1000" />
|
||||
<img src="/images/photo5.jpg" class="carousel-slide absolute inset-0 w-full h-full object-cover opacity-0 transition-opacity duration-1000" />
|
||||
</div>
|
||||
|
||||
<!-- Overlay so logo stays readable over busy images -->
|
||||
<div class="absolute inset-0 bg-black/40"></div>
|
||||
|
||||
<!-- Logo -->
|
||||
<div class="relative z-10">
|
||||
<a href="/index.html">
|
||||
<img src="/images/logo.png" alt="Gateway Gamers" class="h-48 w-auto drop-shadow-2xl" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Nav row -->
|
||||
<div class="navbar bg-base-200 px-4 min-h-0 py-2">
|
||||
<div class="navbar-center hidden lg:flex flex-1 justify-center">
|
||||
<ul class="menu menu-horizontal px-1 gap-1">
|
||||
<li><a href="/index.html">40K Rankings</a></li>
|
||||
<li><a href="https://aos-rankings.gateway-gamers.net">AOS Rankings</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navbar-end gap-2 ml-auto">
|
||||
<label class="swap swap-rotate btn btn-ghost btn-circle" title="Toggle light/dark">
|
||||
<input type="checkbox" class="theme-controller" value="light" />
|
||||
<svg class="swap-off h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M21.64 13a1 1 0 0 0-1.27-.66 8.5 8.5 0 0 1-10.71-10.71 1 1 0 0 0-1.32-1.27A10 10 0 1 0 22 14.29a1 1 0 0 0-.36-1.29z"/>
|
||||
</svg>
|
||||
<svg class="swap-on h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M5.64 17l-.71.71a1 1 0 0 0 1.41 1.41l.71-.71A1 1 0 0 0 5.64 17zM5 12a1 1 0 0 0-1-1H3a1 1 0 0 0 0 2h1a1 1 0 0 0 1-1zm7-7a1 1 0 0 0 1-1V3a1 1 0 0 0-2 0v1a1 1 0 0 0 1 1zM5.64 7.05a1 1 0 0 0 .7.29 1 1 0 0 0 .71-.29 1 1 0 0 0 0-1.41l-.71-.71a1 1 0 0 0-1.41 1.41zm12 .29a1 1 0 0 0 .7-.29l.71-.71a1 1 0 0 0-1.41-1.41l-.71.71a1 1 0 0 0 0 1.41 1 1 0 0 0 .71.29zM21 11h-1a1 1 0 0 0 0 2h1a1 1 0 0 0 0-2zm-9 8a1 1 0 0 0-1 1v1a1 1 0 0 0 2 0v-1a1 1 0 0 0-1-1zm6.36-2a1 1 0 0 0-1.41 1.41l.71.71a1 1 0 0 0 1.41-1.41zM12 6.5a5.5 5.5 0 1 0 5.5 5.5A5.51 5.51 0 0 0 12 6.5z"/>
|
||||
</svg>
|
||||
</label>
|
||||
<div class="dropdown dropdown-end lg:hidden">
|
||||
<label tabindex="0" class="btn btn-ghost btn-circle">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</label>
|
||||
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-200 rounded-box w-52">
|
||||
<li><a href="/index.html">40K Rankings</a></li>
|
||||
<li><a href="https://aos-rankings.gateway-gamers.net">AOS Rankings</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</header>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.9 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.4 MiB |
@@ -0,0 +1,508 @@
|
||||
/* ---- Shared: header / footer ---- */
|
||||
function loadHTML(file, elementId, callback) {
|
||||
fetch(file)
|
||||
.then(r => r.text())
|
||||
.then(html => {
|
||||
document.getElementById(elementId).innerHTML = html;
|
||||
if (callback) callback();
|
||||
})
|
||||
.catch(err => console.error('Error loading:', file, err));
|
||||
}
|
||||
|
||||
function highlightActiveNav() {
|
||||
const current = location.pathname.split('/').pop() || 'index.html';
|
||||
document.querySelectorAll('#header a[href]').forEach(link => {
|
||||
if (link.getAttribute('href') === `/${current}`) {
|
||||
link.classList.add('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function startCarousel() {
|
||||
const slides = document.querySelectorAll('#hero-carousel .carousel-slide');
|
||||
if (!slides.length) return;
|
||||
let current = 0;
|
||||
slides[current].style.opacity = '1';
|
||||
setInterval(() => {
|
||||
slides[current].style.opacity = '0';
|
||||
current = (current + 1) % slides.length;
|
||||
slides[current].style.opacity = '1';
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
loadHTML('/header.html', 'header', () => {
|
||||
highlightActiveNav();
|
||||
startCarousel();
|
||||
});
|
||||
loadHTML('/footer.html', 'footer');
|
||||
|
||||
/* ============================================================
|
||||
CONFIG — the only block you edit
|
||||
============================================================ */
|
||||
const CONFIG = {
|
||||
teamId: "2iGDVMgX0a",
|
||||
leagueId: "BYaaUfKum7z0",
|
||||
regionId: "VgQKgqmTPU",
|
||||
lmcLeagueId: "cY0LeCAPfyiG",
|
||||
|
||||
// Fallback roster if the team endpoint requires auth.
|
||||
memberIds: [],
|
||||
|
||||
// Names to hide from the leaderboard entirely (case-insensitive).
|
||||
// Match on full name ("First Last") or nickname, whichever the player goes by.
|
||||
blacklistNames: ["Hidden Player"],
|
||||
|
||||
// Flip to true to preview the layout with placeholder rows (no network).
|
||||
useSample: false,
|
||||
};
|
||||
|
||||
const BLACKLIST = new Set(CONFIG.blacklistNames.map(n => n.trim().toLowerCase()));
|
||||
function isBlacklisted(u = {}) {
|
||||
const full = fullName(u).toLowerCase();
|
||||
const nick = String(u.nickname || "").trim().toLowerCase();
|
||||
return (full && BLACKLIST.has(full)) || (nick && BLACKLIST.has(nick));
|
||||
}
|
||||
|
||||
const API = "https://newprod-api.bestcoastpairings.com/v1";
|
||||
const HEADERS = { "client-id": "web-app", "env": "bcp", "accept": "application/json" };
|
||||
const CACHE_KEY = "gg_board_v3";
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const fmtPts = (n) => Number(n || 0).toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 });
|
||||
const fmtWpct = (w, l, t) => { const g = (+w||0)+(+l||0)+(+t||0); return g ? ((+w||0)/g*100).toFixed(1)+'%' : '—'; };
|
||||
const fullName = (u = {}) => [u.firstName, u.lastName].filter(Boolean).join(" ").trim();
|
||||
|
||||
/* ---- Career stats ---- */
|
||||
const careerData = {}; // userId → { w, l, t } | null (in-flight)
|
||||
|
||||
async function fetchCareer(userId) {
|
||||
const r = await fetch(
|
||||
`${API}/placings?placingsType=player&userId=${userId}&limit=1000`,
|
||||
{ headers: HEADERS, credentials: 'omit' }
|
||||
);
|
||||
if (!r.ok) return { w: 0, l: 0, t: 0, events: [] };
|
||||
const p = await r.json();
|
||||
const items = Array.isArray(p) ? p : (p.data || []);
|
||||
let w = 0, l = 0, t = 0;
|
||||
const events = [];
|
||||
for (const item of items) {
|
||||
const v = item.value || item;
|
||||
if (v.wins != null || v.losses != null) {
|
||||
w += +(v.wins || 0);
|
||||
l += +(v.losses || 0);
|
||||
t += +(v.ties || 0);
|
||||
}
|
||||
const eName = v.event?.name || v.eventName || v.tournament?.name || v.tournamentName || '';
|
||||
if (eName) events.push(eName);
|
||||
}
|
||||
return { w, l, t, events };
|
||||
}
|
||||
|
||||
async function loadCareerStats(kept) {
|
||||
const toFetch = kept.filter(r => {
|
||||
const uid = r.userId || r.user?.id;
|
||||
return uid && !(uid in careerData);
|
||||
});
|
||||
if (!toFetch.length) return;
|
||||
toFetch.forEach(r => { careerData[r.userId || r.user?.id] = null; });
|
||||
await Promise.allSettled(toFetch.map(async r => {
|
||||
const uid = r.userId || r.user?.id;
|
||||
try { careerData[uid] = await fetchCareer(uid); }
|
||||
catch { careerData[uid] = { w: 0, l: 0, t: 0, events: [] }; }
|
||||
}));
|
||||
if (lastState) render(lastState);
|
||||
}
|
||||
|
||||
/* ---- ELO data (stat-check) ---- */
|
||||
let eloData; // undefined = loading, null = unavailable, Map = loaded
|
||||
|
||||
function normalizeEventName(s) {
|
||||
return String(s).toLowerCase().replace(/[^a-z0-9 ]/g, '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function eventNamesMatch(a, b) {
|
||||
const na = normalizeEventName(a), nb = normalizeEventName(b);
|
||||
if (!na || !nb || na.length < 6 || nb.length < 6) return na === nb;
|
||||
return na === nb || na.includes(nb) || nb.includes(na);
|
||||
}
|
||||
|
||||
async function loadEloData() {
|
||||
try {
|
||||
const r = await fetch('/elo-data.json');
|
||||
if (!r.ok) { eloData = null; return; }
|
||||
const d = await r.json();
|
||||
const map = new Map();
|
||||
for (const p of (d.players || [])) {
|
||||
const key = String(p.name || '').toLowerCase();
|
||||
if (!key) continue;
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key).push(p);
|
||||
}
|
||||
eloData = map;
|
||||
if (lastState) render(lastState);
|
||||
} catch { eloData = null; }
|
||||
}
|
||||
|
||||
// Returns: undefined = still loading, null = no verified match, object = verified match
|
||||
function findElo(name, uid) {
|
||||
if (eloData === undefined) return undefined;
|
||||
if (!eloData) return null;
|
||||
const candidates = eloData.get(String(name).toLowerCase()) || [];
|
||||
if (!candidates.length) return null;
|
||||
if (uid && !(uid in careerData)) return undefined; // career fetch not started yet
|
||||
const cd = uid ? careerData[uid] : undefined;
|
||||
if (cd === null) return undefined; // career in-flight
|
||||
const events = cd?.events || [];
|
||||
if (!events.length) return candidates.length === 1 ? candidates[0] : null;
|
||||
const verified = candidates.filter(c => events.some(e => eventNamesMatch(e, c.lastEvent)));
|
||||
return verified.length === 1 ? verified[0] : null;
|
||||
}
|
||||
|
||||
/* ---- Sort state ---- */
|
||||
let sortState = { col: 'pts', dir: 'desc' };
|
||||
let lastState = null;
|
||||
|
||||
const sortCols = {
|
||||
pts: { key: r => +(r.ITCPoints || 0), defaultDir: 'desc' },
|
||||
name: { key: r => fullName(r.user || {}).toLowerCase() || '', defaultDir: 'asc' },
|
||||
wlt: { key: r => (+r.wins||0) * 10000 - (+r.losses||0) * 100 - (+r.ties||0), defaultDir: 'desc' },
|
||||
wpct: { key: r => { const g=(+r.wins||0)+(+r.losses||0)+(+r.ties||0); return g?(+r.wins||0)/g:-1; }, defaultDir: 'desc' },
|
||||
rank: { key: r => +(r.placing || Infinity), defaultDir: 'asc' },
|
||||
lmc: { key: r => { const uid = r.userId || r.user?.id; return uid && lmcPoints[uid] != null ? lmcPoints[uid] : -Infinity; }, defaultDir: 'desc' },
|
||||
lmcrank: { key: r => { const uid = r.userId || r.user?.id; return uid && lmcRanks[uid] != null ? lmcRanks[uid] : Infinity; }, defaultDir: 'asc' },
|
||||
elo: { key: r => { const e = findElo(fullName(r.user || {}), r.userId || r.user?.id); return e?.elo ?? -Infinity; }, defaultDir: 'desc' },
|
||||
};
|
||||
|
||||
function applySort(rows) {
|
||||
const { col, dir } = sortState;
|
||||
const sc = sortCols[col] || sortCols.pts;
|
||||
return [...rows].sort((a, b) => {
|
||||
const av = sc.key(a), bv = sc.key(b);
|
||||
if (typeof av === 'string') return dir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
|
||||
return dir === 'asc' ? av - bv : bv - av;
|
||||
});
|
||||
}
|
||||
|
||||
function updateSortHeaders() {
|
||||
document.querySelectorAll('th[data-sort]').forEach(th => {
|
||||
const arrow = th.querySelector('.sort-arrow');
|
||||
if (arrow) arrow.textContent = th.dataset.sort === sortState.col ? (sortState.dir === 'asc' ? ' ▲' : ' ▼') : '';
|
||||
});
|
||||
}
|
||||
|
||||
function initSortHeaders() {
|
||||
document.querySelectorAll('th[data-sort]').forEach(th => {
|
||||
const arrow = document.createElement('span');
|
||||
arrow.className = 'sort-arrow text-xs opacity-60';
|
||||
th.appendChild(arrow);
|
||||
th.addEventListener('click', () => {
|
||||
const col = th.dataset.sort;
|
||||
if (sortState.col === col) {
|
||||
sortState.dir = sortState.dir === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
sortState.col = col;
|
||||
sortState.dir = (sortCols[col] || sortCols.pts).defaultDir;
|
||||
}
|
||||
updateSortHeaders();
|
||||
if (lastState) render(lastState);
|
||||
});
|
||||
});
|
||||
updateSortHeaders();
|
||||
}
|
||||
|
||||
/* ---- Roster ---- */
|
||||
async function getMemberIds(force) {
|
||||
try {
|
||||
const r = await fetch(`${API}/teams/${CONFIG.teamId}?expand[]=owner`,
|
||||
{ headers: HEADERS, credentials: "omit" });
|
||||
if (!r.ok) throw new Error("auth/" + r.status);
|
||||
let t = await r.json(); if (Array.isArray(t)) t = t[0] || {};
|
||||
const ids = new Set();
|
||||
if (Array.isArray(t.memberIds)) t.memberIds.forEach(x => typeof x === "string" && ids.add(x));
|
||||
if (typeof t.ownerId === "string") ids.add(t.ownerId);
|
||||
[t.users, t.members, t.teamMembers].forEach(list => {
|
||||
if (Array.isArray(list)) list.forEach(o => o && o.id && ids.add(o.id));
|
||||
});
|
||||
if (t.owner && t.owner.id) ids.add(t.owner.id);
|
||||
if (ids.size) return { ids, live: true };
|
||||
throw new Error("empty");
|
||||
} catch (e) {
|
||||
if (CONFIG.memberIds.length) return { ids: new Set(CONFIG.memberIds), live: false };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- LMC Points data ---- */
|
||||
const lmcPoints = {}; // userId → number
|
||||
const lmcRanks = {}; // userId → number
|
||||
let lmcLoaded = false;
|
||||
|
||||
async function loadLmcData(ids) {
|
||||
lmcLoaded = false;
|
||||
try {
|
||||
for await (const page of streamPlacings(CONFIG.lmcLeagueId)) {
|
||||
for (const rec of page) {
|
||||
const uid = rec.userId || rec.user?.id;
|
||||
if (uid && ids.has(uid)) {
|
||||
lmcPoints[uid] = +(rec.ITCPoints || 0);
|
||||
if (rec.placing != null) lmcRanks[uid] = rec.placing;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('LMC data load failed:', e);
|
||||
} finally {
|
||||
lmcLoaded = true;
|
||||
if (lastState) render(lastState);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Standings (cursor-paginated, circular-cursor safe) ---- */
|
||||
async function* streamPlacings(leagueId, regionId) {
|
||||
let base = `${API}/placings?placingsType=player&leagueId=${leagueId}&sortAscending=false&limit=1000`;
|
||||
if (regionId) base += `®ionId=${regionId}`;
|
||||
|
||||
let nextKey = null;
|
||||
let prevNextKey = undefined;
|
||||
|
||||
do {
|
||||
const url = nextKey ? `${base}&nextKey=${encodeURIComponent(nextKey)}` : base;
|
||||
const r = await fetch(url, { headers: HEADERS, credentials: "omit" });
|
||||
if (!r.ok) throw new Error("placings/" + r.status);
|
||||
const p = await r.json();
|
||||
const page = Array.isArray(p) ? p : (p.data || p.placings || []);
|
||||
|
||||
yield page;
|
||||
|
||||
const rawKey = p.nextKey;
|
||||
nextKey = !rawKey ? null
|
||||
: typeof rawKey === "string" ? rawKey
|
||||
: btoa(JSON.stringify(rawKey));
|
||||
|
||||
// BCP returns the same cursor when there are no more results — stop when it loops
|
||||
if (nextKey === prevNextKey) break;
|
||||
prevNextKey = nextKey;
|
||||
} while (nextKey);
|
||||
}
|
||||
|
||||
/* ---- Build leaderboard ---- */
|
||||
function build(rows, ids) {
|
||||
const kept = [];
|
||||
for (const rec of rows) {
|
||||
const uid = rec.userId || (rec.user && rec.user.id);
|
||||
if (uid && ids.has(uid) && !isBlacklisted(rec.user || {})) kept.push(rec);
|
||||
}
|
||||
kept.sort((a, b) => Number(b.ITCPoints || 0) - Number(a.ITCPoints || 0));
|
||||
return kept;
|
||||
}
|
||||
|
||||
/* ---- Render ---- */
|
||||
function render(state) {
|
||||
lastState = state;
|
||||
const { kept, rosterSize, updated, live, fromCache } = state;
|
||||
|
||||
$("dot").className = "w-2 h-2 rounded-full bg-success animate-pulse transition-colors";
|
||||
$("updated").textContent = (fromCache ? "Cached " : "Updated ") +
|
||||
new Date(updated).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
$("meta").textContent = `${kept.length} of ${rosterSize} members placed` + (live ? "" : " · roster (cached)");
|
||||
|
||||
$("banner").className = "alert mb-4 hidden";
|
||||
|
||||
const champ = $("champion"), strip = $("strip"), empty = $("empty"), tb = $("rows");
|
||||
tb.innerHTML = "";
|
||||
|
||||
if (!kept.length) {
|
||||
champ.classList.add("hidden"); strip.hidden = true;
|
||||
empty.classList.remove("hidden");
|
||||
empty.textContent = "No Gateway Gamers players found in this league's standings yet. " +
|
||||
"Once members log games here, they'll appear ranked by ITC points.";
|
||||
return;
|
||||
}
|
||||
empty.classList.add("hidden"); strip.hidden = false;
|
||||
|
||||
// Champion card
|
||||
const top = kept[0], tu = top.user || {};
|
||||
champ.classList.remove("hidden");
|
||||
$("champ-name").textContent = fullName(tu) || tu.nickname || "—";
|
||||
$("champ-sub").textContent = `${fmtWpct(top.wins, top.losses, top.ties)} Win Rate · ITC #${top.placing ?? "—"}`;
|
||||
$("champ-pts").textContent = fmtPts(top.ITCPoints);
|
||||
|
||||
// Stat strip
|
||||
const W = kept.reduce((s, r) => s + (+r.wins || 0), 0),
|
||||
L = kept.reduce((s, r) => s + (+r.losses || 0), 0),
|
||||
T = kept.reduce((s, r) => s + (+r.ties || 0), 0),
|
||||
best = Math.min(...kept.map(r => +r.placing || Infinity));
|
||||
$("s-placed").textContent = kept.length;
|
||||
$("s-top").textContent = fmtPts(top.ITCPoints);
|
||||
$("s-rec").textContent = fmtWpct(W, L, T);
|
||||
$("s-best").textContent = isFinite(best) ? ("#" + best) : "—";
|
||||
|
||||
// Kick off career stat fetches for any players not yet loaded (non-blocking)
|
||||
loadCareerStats(kept);
|
||||
|
||||
// Rows
|
||||
applySort(kept).forEach((r, i) => {
|
||||
const u = r.user || {}, name = fullName(u) || u.nickname || "Unknown";
|
||||
const uid = r.userId || r.user?.id;
|
||||
const eloMatch = findElo(name, uid);
|
||||
const lmcRankCell = (() => {
|
||||
if (!lmcLoaded) return `<td class="text-right font-mono text-base-content/40 hidden sm:table-cell"><span class="opacity-20">…</span></td>`;
|
||||
const rank = uid ? lmcRanks[uid] : undefined;
|
||||
return `<td class="text-right font-mono text-base-content/40 hidden sm:table-cell">${rank != null ? '#' + rank : '—'}</td>`;
|
||||
})();
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML =
|
||||
`<td class="font-mono text-base-content/60">${i === 0 ? `<strong class="text-base-content">1</strong>` : (i + 1)}</td>` +
|
||||
`<td><span class="font-semibold">${esc(name)}</span>` +
|
||||
`${u.nickname && fullName(u) ? `<span class="block text-xs text-base-content/40">"${esc(u.nickname)}"</span>` : ""}` +
|
||||
`<span class="block text-xs opacity-40 sm:hidden"><span class="text-success">${r.wins || 0}</span>–<span class="text-error">${r.losses || 0}</span>–${r.ties || 0} · ITC #${r.placing ?? "—"}</span>` +
|
||||
`</td>` +
|
||||
(() => {
|
||||
const d = uid && careerData[uid];
|
||||
const careerSub = d == null
|
||||
? `<span class="block text-xs opacity-20">…</span>`
|
||||
: (d.w + d.l + d.t) === 0 ? ''
|
||||
: `<span class="block text-xs opacity-40">${d.w}–${d.l}–${d.t} career</span>`;
|
||||
const careerPct = d == null
|
||||
? `<span class="block text-xs opacity-20">…</span>`
|
||||
: (d.w + d.l + d.t) === 0 ? ''
|
||||
: `<span class="block text-xs opacity-40">${fmtWpct(d.w, d.l, d.t)} career</span>`;
|
||||
const mobilePct = `<span class="block text-xs opacity-40 sm:hidden">${fmtWpct(r.wins, r.losses, r.ties)} win rate</span>`;
|
||||
const mobileElo = eloMatch ? `<span class="block text-xs opacity-40 sm:hidden">ELO ${Math.round(eloMatch.elo)}</span>` : '';
|
||||
const mobileLmc = (() => {
|
||||
if (!uid || !lmcLoaded || lmcPoints[uid] == null) return '';
|
||||
const rankStr = lmcRanks[uid] != null ? ` · LMC #${lmcRanks[uid]}` : '';
|
||||
return `<span class="block text-xs opacity-40 sm:hidden">LMC ${fmtPts(lmcPoints[uid])}${rankStr}</span>`;
|
||||
})();
|
||||
const lmcCell = (() => {
|
||||
if (!lmcLoaded) return `<td class="text-right font-mono hidden sm:table-cell"><span class="opacity-20">…</span></td>`;
|
||||
const pts = uid ? lmcPoints[uid] : undefined;
|
||||
return `<td class="text-right font-mono hidden sm:table-cell">${pts != null ? fmtPts(pts) : '<span class="text-base-content/40">—</span>'}</td>`;
|
||||
})();
|
||||
return `<td class="text-right font-mono font-bold">${fmtPts(r.ITCPoints)}` +
|
||||
mobilePct + mobileLmc + mobileElo + `</td>` +
|
||||
lmcCell +
|
||||
`<td class="text-right font-mono hidden sm:table-cell">` +
|
||||
`<span class="text-success">${r.wins || 0}</span>–<span class="text-error">${r.losses || 0}</span>–${r.ties || 0}` +
|
||||
careerSub + `</td>` +
|
||||
`<td class="text-right font-mono hidden sm:table-cell">${fmtWpct(r.wins, r.losses, r.ties)}` +
|
||||
careerPct + `</td>`;
|
||||
})() +
|
||||
`<td class="text-right font-mono text-base-content/40 hidden sm:table-cell">#${r.placing ?? "—"}</td>` +
|
||||
lmcRankCell +
|
||||
(() => {
|
||||
if (eloMatch === undefined) return `<td class="text-center font-mono hidden sm:table-cell"><span class="opacity-20">…</span></td>`;
|
||||
if (!eloMatch) return `<td class="text-center font-mono text-base-content/40 hidden sm:table-cell">—</td>`;
|
||||
return `<td class="text-center font-mono hidden sm:table-cell">${Math.round(eloMatch.elo)}</td>`;
|
||||
})();
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||
}
|
||||
|
||||
/* ---- Cache ---- */
|
||||
function paintCache() {
|
||||
try {
|
||||
const c = JSON.parse(localStorage.getItem(CACHE_KEY) || "null");
|
||||
if (c && c.kept) render({ ...c, fromCache: true });
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function saveCache(state) {
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify({
|
||||
kept: state.kept, rosterSize: state.rosterSize, updated: state.updated, live: state.live
|
||||
}));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
/* ---- Orchestration ---- */
|
||||
async function load(force) {
|
||||
const btn = $("refresh");
|
||||
btn.disabled = true;
|
||||
$("dot").className = "w-2 h-2 rounded-full bg-base-content/30 transition-colors";
|
||||
$("updated").textContent = "Fetching…";
|
||||
showSkeleton();
|
||||
try {
|
||||
if (CONFIG.useSample) { return render({ ...SAMPLE, fromCache: false }); }
|
||||
|
||||
const { ids, live } = await getMemberIds(force);
|
||||
const allRows = [];
|
||||
|
||||
// Reset and kick off LMC fetch non-blocking
|
||||
Object.keys(lmcPoints).forEach(k => delete lmcPoints[k]);
|
||||
Object.keys(lmcRanks).forEach(k => delete lmcRanks[k]);
|
||||
loadLmcData(ids);
|
||||
|
||||
for await (const page of streamPlacings(CONFIG.leagueId, CONFIG.regionId)) {
|
||||
allRows.push(...page);
|
||||
// Render incrementally so results appear as pages arrive
|
||||
const kept = build(allRows, ids);
|
||||
if (kept.length) render({ kept, rosterSize: ids.size, updated: Date.now(), live, fromCache: false });
|
||||
}
|
||||
|
||||
const kept = build(allRows, ids);
|
||||
const state = { kept, rosterSize: ids.size, updated: Date.now(), live };
|
||||
render({ ...state, fromCache: false });
|
||||
saveCache(state);
|
||||
} catch (err) {
|
||||
failure(err);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showSkeleton() {
|
||||
const tb = $("rows");
|
||||
tb.innerHTML = "";
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML =
|
||||
`<td><div class="skeleton h-4 w-5"></div></td>` +
|
||||
`<td><div class="skeleton h-4 w-3/5"></div><div class="skeleton h-3 w-2/5 mt-1 sm:hidden"></div></td>` +
|
||||
`<td><div class="skeleton h-4 w-12 ml-auto"></div><div class="skeleton h-3 w-10 mt-1 ml-auto sm:hidden"></div></td>` +
|
||||
`<td class="hidden sm:table-cell"><div class="skeleton h-4 w-12 ml-auto"></div></td>` +
|
||||
`<td class="hidden sm:table-cell"><div class="skeleton h-4 w-14 ml-auto"></div></td>` +
|
||||
`<td class="hidden sm:table-cell"><div class="skeleton h-4 w-12 ml-auto"></div></td>` +
|
||||
`<td class="hidden sm:table-cell"><div class="skeleton h-4 w-10 ml-auto"></div></td>` +
|
||||
`<td class="hidden sm:table-cell"><div class="skeleton h-4 w-10 ml-auto"></div></td>` +
|
||||
`<td class="hidden sm:table-cell"><div class="skeleton h-4 w-12 mx-auto"></div></td>`;
|
||||
tb.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
function failure(err) {
|
||||
$("dot").className = "w-2 h-2 rounded-full bg-base-content/30 transition-colors";
|
||||
$("updated").textContent = "Couldn't update";
|
||||
$("rows").innerHTML = "";
|
||||
$("champion").classList.add("hidden");
|
||||
$("strip").hidden = true;
|
||||
const b = $("banner");
|
||||
const msg = String(err.message || err);
|
||||
b.className = "alert alert-error mb-4";
|
||||
b.innerHTML = msg.includes("placings")
|
||||
? "Couldn't reach the BCP standings feed. Try refreshing, or open the page directly in a browser."
|
||||
: `Something went wrong: <code class="font-mono text-sm ml-1">${esc(msg)}</code>`;
|
||||
}
|
||||
|
||||
/* ---- Sample (preview only; CONFIG.useSample = true) ---- */
|
||||
const SAMPLE = (() => {
|
||||
const mk = (f, l, p, w, ls, t, pl) => ({ user: { firstName: f, lastName: l }, ITCPoints: p, wins: w, losses: ls, ties: t, placing: pl });
|
||||
const kept = [
|
||||
mk("Eric", "Darais", 920.7, 19, 4, 0, 453),
|
||||
mk("Jordan", "Vance", 812.4, 16, 6, 1, 712),
|
||||
mk("Mara", "Singh", 770.1, 15, 7, 0, 889),
|
||||
mk("Devon", "Cole", 655.9, 12, 8, 2, 1340),
|
||||
];
|
||||
return { kept, rosterSize: 8, updated: Date.now(), live: true };
|
||||
})();
|
||||
|
||||
/* ---- Go ---- */
|
||||
$("refresh").addEventListener("click", () => load(true));
|
||||
initSortHeaders();
|
||||
paintCache();
|
||||
loadEloData();
|
||||
load(false);
|
||||
@@ -0,0 +1,2 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "daisyui";
|
||||
@@ -0,0 +1,4 @@
|
||||
*.xlsx
|
||||
*.csv
|
||||
.~lock.*
|
||||
__pycache__/
|
||||
@@ -0,0 +1,56 @@
|
||||
# Stat Check Elo Scraper
|
||||
|
||||
This is a pure-Python scraper for the public Excel workbook embedded at:
|
||||
|
||||
`https://www.stat-check.com/elo`
|
||||
|
||||
It does not need Selenium, Playwright, Chromium, pandas, or openpyxl. It uses only the Python standard library.
|
||||
|
||||
## How It Works
|
||||
|
||||
The Stat Check page embeds a public OneDrive Excel workbook through a `1drv.ms` URL. Microsoft's web viewer does not expose a stable direct `.xlsx` URL in the page HTML, but it does use a public OneDrive metadata API behind the scenes.
|
||||
|
||||
The scraper reproduces the useful part of that flow:
|
||||
|
||||
1. Request an anonymous Microsoft "Badger" token from:
|
||||
`https://api-badgerp.svc.ms/v1.0/token`
|
||||
2. Convert the public OneDrive embed URL into Microsoft Graph's `shares/u!...` id format.
|
||||
3. Call:
|
||||
`https://my.microsoftpersonalcontent.com/_api/v2.0/shares/{share_id}/driveItem?action=EmbedView`
|
||||
with `Authorization: Badger <token>`.
|
||||
4. Read `@content.downloadUrl` from the metadata response.
|
||||
5. Download the real `.xlsx`.
|
||||
6. Convert the first worksheet to CSV by reading the XLSX ZIP/XML structure directly.
|
||||
|
||||
The Microsoft endpoints used here are undocumented and could change, but this currently works without browser automation.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
python3 pure_python_elo_scrape.py
|
||||
```
|
||||
|
||||
Default outputs:
|
||||
|
||||
- `elo-pure.xlsx`
|
||||
- `elo-pure.csv`
|
||||
|
||||
You can also pass output paths:
|
||||
|
||||
```bash
|
||||
python3 pure_python_elo_scrape.py statcheck-elo.xlsx statcheck-elo.csv
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
- `pure_python_elo_scrape.py` - gets the Badger token, resolves the workbook download URL, downloads the XLSX, writes CSV.
|
||||
- `download_and_parse.py` - helper functions for downloading and converting XLSX to CSV with stdlib only.
|
||||
- `sample-elo.csv` - sample output captured during testing.
|
||||
|
||||
## Verification From Testing
|
||||
|
||||
On June 28, 2026, this downloaded a `3,465,320` byte workbook and produced a `4,145,399` byte CSV with `40,945` rows. The CSV header was:
|
||||
|
||||
```csv
|
||||
Rank,Delta,Name,W,L,D,G,WR%,Elo,Delta,Last Event,Country
|
||||
```
|
||||
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env python3
|
||||
import csv
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from urllib.request import Request, urlopen
|
||||
from xml.etree.ElementTree import iterparse
|
||||
|
||||
|
||||
NS = {
|
||||
"main": "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
|
||||
"rel": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
||||
"pkgrel": "http://schemas.openxmlformats.org/package/2006/relationships",
|
||||
}
|
||||
|
||||
|
||||
def metadata_download_url(capture_path: Path) -> str:
|
||||
capture = json.loads(capture_path.read_text())
|
||||
for body in capture["documentBodies"]:
|
||||
payload = body.get("body", {})
|
||||
text = payload.get("body", "") if isinstance(payload, dict) else ""
|
||||
if '"@content.downloadUrl"' in text:
|
||||
return json.loads(text)["@content.downloadUrl"]
|
||||
raise RuntimeError("No @content.downloadUrl found in capture")
|
||||
|
||||
|
||||
def download(url: str, output: Path) -> None:
|
||||
req = Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
with urlopen(req, timeout=60) as resp:
|
||||
output.write_bytes(resp.read())
|
||||
|
||||
|
||||
def shared_strings(zf: zipfile.ZipFile) -> list[str]:
|
||||
if "xl/sharedStrings.xml" not in zf.namelist():
|
||||
return []
|
||||
values = []
|
||||
with zf.open("xl/sharedStrings.xml") as f:
|
||||
for event, elem in iterparse(f, events=("end",)):
|
||||
if elem.tag == f"{{{NS['main']}}}si":
|
||||
parts = []
|
||||
for t in elem.iter(f"{{{NS['main']}}}t"):
|
||||
parts.append(t.text or "")
|
||||
values.append("".join(parts))
|
||||
elem.clear()
|
||||
return values
|
||||
|
||||
|
||||
def first_sheet_path(zf: zipfile.ZipFile) -> str:
|
||||
workbook = zf.read("xl/workbook.xml")
|
||||
first_sheet = re.search(rb'<sheet[^>]+r:id="([^"]+)"', workbook)
|
||||
if not first_sheet:
|
||||
return "xl/worksheets/sheet1.xml"
|
||||
rel_id = first_sheet.group(1).decode()
|
||||
rels = zf.read("xl/_rels/workbook.xml.rels")
|
||||
pattern = rb'<Relationship[^>]+Id="' + re.escape(rel_id.encode()) + rb'"[^>]+Target="([^"]+)"'
|
||||
target = re.search(pattern, rels)
|
||||
if not target:
|
||||
return "xl/worksheets/sheet1.xml"
|
||||
path = html.unescape(target.group(1).decode())
|
||||
if path.startswith("/"):
|
||||
return path.lstrip("/")
|
||||
return "xl/" + path
|
||||
|
||||
|
||||
def column_number(cell_ref: str) -> int:
|
||||
letters = re.match(r"[A-Z]+", cell_ref).group(0)
|
||||
n = 0
|
||||
for ch in letters:
|
||||
n = n * 26 + ord(ch) - ord("A") + 1
|
||||
return n
|
||||
|
||||
|
||||
def xlsx_to_csv(xlsx_path: Path, csv_path: Path) -> None:
|
||||
with zipfile.ZipFile(xlsx_path) as zf, csv_path.open("w", newline="", encoding="utf-8") as out:
|
||||
strings = shared_strings(zf)
|
||||
sheet = first_sheet_path(zf)
|
||||
writer = csv.writer(out)
|
||||
row_values = []
|
||||
current_row = None
|
||||
current_cell = None
|
||||
current_type = None
|
||||
value = None
|
||||
inline_parts = []
|
||||
|
||||
with zf.open(sheet) as f:
|
||||
for event, elem in iterparse(f, events=("start", "end")):
|
||||
tag = elem.tag
|
||||
if event == "start" and tag == f"{{{NS['main']}}}row":
|
||||
current_row = int(elem.attrib.get("r", "0"))
|
||||
row_values = []
|
||||
elif event == "start" and tag == f"{{{NS['main']}}}c":
|
||||
current_cell = elem.attrib.get("r", "")
|
||||
current_type = elem.attrib.get("t")
|
||||
value = None
|
||||
inline_parts = []
|
||||
elif event == "end" and tag == f"{{{NS['main']}}}v":
|
||||
value = elem.text or ""
|
||||
elif event == "end" and tag == f"{{{NS['main']}}}t" and current_type == "inlineStr":
|
||||
inline_parts.append(elem.text or "")
|
||||
elif event == "end" and tag == f"{{{NS['main']}}}c":
|
||||
col = column_number(current_cell)
|
||||
while len(row_values) < col - 1:
|
||||
row_values.append("")
|
||||
if current_type == "s" and value not in (None, ""):
|
||||
row_values.append(strings[int(value)])
|
||||
elif current_type == "inlineStr":
|
||||
row_values.append("".join(inline_parts))
|
||||
else:
|
||||
row_values.append(value or "")
|
||||
elem.clear()
|
||||
elif event == "end" and tag == f"{{{NS['main']}}}row":
|
||||
writer.writerow(row_values)
|
||||
elem.clear()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
capture_path = Path(sys.argv[1] if len(sys.argv) > 1 else "iframe_capture2.json")
|
||||
xlsx_path = Path(sys.argv[2] if len(sys.argv) > 2 else "elo.xlsx")
|
||||
csv_path = Path(sys.argv[3] if len(sys.argv) > 3 else "elo.csv")
|
||||
url = metadata_download_url(capture_path)
|
||||
download(url, xlsx_path)
|
||||
xlsx_to_csv(xlsx_path, csv_path)
|
||||
print(f"downloaded={xlsx_path.stat().st_size} bytes csv={csv_path.stat().st_size} bytes")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from download_and_parse import download, xlsx_to_csv
|
||||
|
||||
|
||||
EMBED_URL = (
|
||||
"https://1drv.ms/x/c/9f33f29504402fa1/"
|
||||
"UQShL0AElfIzIICfIQ4AAAAAAGSZ8S-PJuKWQmM"
|
||||
"?em=2&ActiveCell='Sheet1'!A1&Item='Sheet1'!A1:L40318"
|
||||
"&wdHideGridlines=True&wdHideHeaders=True&wdDownloadButton=True"
|
||||
"&wdInConfigurator=True&wdInConfigurator=True"
|
||||
)
|
||||
|
||||
BADGER_APP_ID = "00000000-0000-0000-0000-0000481710a4"
|
||||
|
||||
|
||||
def request_json(url: str, *, method: str = "GET", data: bytes | None = None, headers: dict[str, str] | None = None) -> dict:
|
||||
req = Request(url, data=data, method=method, headers=headers or {})
|
||||
with urlopen(req, timeout=60) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def badger_token() -> str:
|
||||
payload = json.dumps({"appid": BADGER_APP_ID}, separators=(",", ":")).encode()
|
||||
data = request_json(
|
||||
"https://api-badgerp.svc.ms/v1.0/token",
|
||||
method="POST",
|
||||
data=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Origin": "https://onedrive.live.com",
|
||||
"Referer": "https://onedrive.live.com/",
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
},
|
||||
)
|
||||
if data.get("authScheme") != "badger" or not data.get("token"):
|
||||
raise RuntimeError(f"Unexpected Badger token response: {data!r}")
|
||||
return data["token"]
|
||||
|
||||
|
||||
def share_id(url: str) -> str:
|
||||
encoded = base64.urlsafe_b64encode(url.encode()).decode().rstrip("=")
|
||||
return "u!" + encoded
|
||||
|
||||
|
||||
def download_url_for_embed(embed_url: str) -> str:
|
||||
token = badger_token()
|
||||
select = "id,openWith,officebundle,currentUserRole,eTag,name,size,content.downloadUrl,file,sharepointIds,sensitivityLabel,webUrl,webDavUrl,parentReference,vault"
|
||||
url = (
|
||||
"https://my.microsoftpersonalcontent.com/_api/v2.0/shares/"
|
||||
f"{share_id(embed_url)}/driveItem?action=EmbedView&$select={quote(select, safe=',')}"
|
||||
)
|
||||
data = request_json(
|
||||
url,
|
||||
method="POST",
|
||||
data=b"",
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Badger {token}",
|
||||
"Origin": "https://onedrive.live.com",
|
||||
"Prefer": "autoredeem",
|
||||
"Referer": "https://onedrive.live.com/",
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
},
|
||||
)
|
||||
if "@content.downloadUrl" not in data:
|
||||
raise RuntimeError(f"No download URL in metadata response: {data!r}")
|
||||
return data["@content.downloadUrl"]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
xlsx_path = Path(sys.argv[1] if len(sys.argv) > 1 else "elo-pure.xlsx")
|
||||
csv_path = Path(sys.argv[2] if len(sys.argv) > 2 else "elo-pure.csv")
|
||||
url = download_url_for_embed(EMBED_URL)
|
||||
download(url, xlsx_path)
|
||||
xlsx_to_csv(xlsx_path, csv_path)
|
||||
print(f"downloaded={xlsx_path.stat().st_size} bytes csv={csv_path.stat().st_size} bytes")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Download the stat-check ELO workbook and write public/elo-data.json.
|
||||
|
||||
Run from the statcheck-elo-scraper/ directory:
|
||||
python refresh_elo.py
|
||||
"""
|
||||
import csv
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from download_and_parse import download, xlsx_to_csv
|
||||
from pure_python_elo_scrape import EMBED_URL, download_url_for_embed
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
ROOT = HERE.parent
|
||||
|
||||
|
||||
def csv_to_players(csv_path: Path) -> list[dict]:
|
||||
players = []
|
||||
with csv_path.open(newline="", encoding="utf-8-sig") as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
name = row.get("Name", "").strip()
|
||||
rank_s = row.get("Rank", "").strip()
|
||||
elo_s = row.get("Elo", "").strip()
|
||||
last_event = row.get("Last Event", "").strip()
|
||||
if not (name and rank_s and elo_s):
|
||||
continue
|
||||
try:
|
||||
players.append({
|
||||
"name": name,
|
||||
"rank": int(rank_s),
|
||||
"elo": round(float(elo_s), 1),
|
||||
"lastEvent": last_event,
|
||||
})
|
||||
except ValueError:
|
||||
pass
|
||||
return players
|
||||
|
||||
|
||||
def main() -> None:
|
||||
xlsx_path = Path(sys.argv[1]) if len(sys.argv) > 1 else HERE / "elo-pure.xlsx"
|
||||
csv_path = Path(sys.argv[2]) if len(sys.argv) > 2 else HERE / "elo-pure.csv"
|
||||
json_path = Path(sys.argv[3]) if len(sys.argv) > 3 else ROOT / "public" / "elo-data.json"
|
||||
|
||||
print("Resolving OneDrive download URL…")
|
||||
url = download_url_for_embed(EMBED_URL)
|
||||
|
||||
print("Downloading XLSX…")
|
||||
download(url, xlsx_path)
|
||||
print(f" {xlsx_path.stat().st_size:,} bytes → {xlsx_path}")
|
||||
|
||||
print("Converting to CSV…")
|
||||
xlsx_to_csv(xlsx_path, csv_path)
|
||||
print(f" {csv_path.stat().st_size:,} bytes → {csv_path}")
|
||||
|
||||
print("Building elo-data.json…")
|
||||
players = csv_to_players(csv_path)
|
||||
payload = {
|
||||
"updated": datetime.now(timezone.utc).isoformat(),
|
||||
"players": players,
|
||||
}
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
json_path.write_text(json.dumps(payload, separators=(",", ":")), encoding="utf-8")
|
||||
print(f" {len(players):,} players → {json_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'index.html'),
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user