feat: add Stat-Check ELO column with daily auto-refresh
- Scraper (statcheck-elo-scraper/refresh_elo.py) downloads the stat-check ELO workbook from OneDrive and builds public/elo-data.json - GitHub Actions workflow (.github/workflows/refresh-elo.yml) runs daily at 06:00 UTC and commits updated ELO data - Frontend fetches elo-data.json and matches players by name, using BCP career event history to disambiguate same-name candidates - ELO score shown in new sortable Stat-Check ELO column, visible on all screen sizes; unmatched players show — Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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
|
||||||
@@ -76,6 +76,7 @@
|
|||||||
<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="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="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="rank">ITC Rank</th>
|
||||||
|
<th class="text-center cursor-pointer select-none" data-sort="elo">Stat-Check ELO</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="rows"></tbody>
|
<tbody id="rows"></tbody>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+64
-6
@@ -68,10 +68,11 @@ async function fetchCareer(userId) {
|
|||||||
`${API}/placings?placingsType=player&userId=${userId}&limit=1000`,
|
`${API}/placings?placingsType=player&userId=${userId}&limit=1000`,
|
||||||
{ headers: HEADERS, credentials: 'omit' }
|
{ headers: HEADERS, credentials: 'omit' }
|
||||||
);
|
);
|
||||||
if (!r.ok) return { w: 0, l: 0, t: 0 };
|
if (!r.ok) return { w: 0, l: 0, t: 0, events: [] };
|
||||||
const p = await r.json();
|
const p = await r.json();
|
||||||
const items = Array.isArray(p) ? p : (p.data || []);
|
const items = Array.isArray(p) ? p : (p.data || []);
|
||||||
let w = 0, l = 0, t = 0;
|
let w = 0, l = 0, t = 0;
|
||||||
|
const events = [];
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const v = item.value || item;
|
const v = item.value || item;
|
||||||
if (v.wins != null || v.losses != null) {
|
if (v.wins != null || v.losses != null) {
|
||||||
@@ -79,8 +80,10 @@ async function fetchCareer(userId) {
|
|||||||
l += +(v.losses || 0);
|
l += +(v.losses || 0);
|
||||||
t += +(v.ties || 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 };
|
return { w, l, t, events };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCareerStats(kept) {
|
async function loadCareerStats(kept) {
|
||||||
@@ -93,11 +96,56 @@ async function loadCareerStats(kept) {
|
|||||||
await Promise.allSettled(toFetch.map(async r => {
|
await Promise.allSettled(toFetch.map(async r => {
|
||||||
const uid = r.userId || r.user?.id;
|
const uid = r.userId || r.user?.id;
|
||||||
try { careerData[uid] = await fetchCareer(uid); }
|
try { careerData[uid] = await fetchCareer(uid); }
|
||||||
catch { careerData[uid] = { w: 0, l: 0, t: 0 }; }
|
catch { careerData[uid] = { w: 0, l: 0, t: 0, events: [] }; }
|
||||||
}));
|
}));
|
||||||
if (lastState) render(lastState);
|
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 ---- */
|
/* ---- Sort state ---- */
|
||||||
let sortState = { col: 'pts', dir: 'desc' };
|
let sortState = { col: 'pts', dir: 'desc' };
|
||||||
let lastState = null;
|
let lastState = null;
|
||||||
@@ -107,7 +155,8 @@ const sortCols = {
|
|||||||
name: { key: r => fullName(r.user || {}).toLowerCase() || '', defaultDir: 'asc' },
|
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' },
|
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' },
|
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' },
|
rank: { key: r => +(r.placing || 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) {
|
function applySort(rows) {
|
||||||
@@ -282,7 +331,14 @@ function render(state) {
|
|||||||
`<td class="text-right font-mono hidden sm:table-cell">${fmtWpct(r.wins, r.losses, r.ties)}` +
|
`<td class="text-right font-mono hidden sm:table-cell">${fmtWpct(r.wins, r.losses, r.ties)}` +
|
||||||
careerPct + `</td>`;
|
careerPct + `</td>`;
|
||||||
})() +
|
})() +
|
||||||
`<td class="text-right font-mono text-base-content/40 hidden sm:table-cell">#${r.placing ?? "—"}</td>`;
|
`<td class="text-right font-mono text-base-content/40 hidden sm:table-cell">#${r.placing ?? "—"}</td>` +
|
||||||
|
(() => {
|
||||||
|
const uid = r.userId || r.user?.id;
|
||||||
|
const e = findElo(fullName(r.user || {}), uid);
|
||||||
|
if (e === undefined) return `<td class="text-center font-mono"><span class="opacity-20">…</span></td>`;
|
||||||
|
if (!e) return `<td class="text-center font-mono text-base-content/40">—</td>`;
|
||||||
|
return `<td class="text-center font-mono">${Math.round(e.elo)}</td>`;
|
||||||
|
})();
|
||||||
tb.appendChild(tr);
|
tb.appendChild(tr);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -349,7 +405,8 @@ function showSkeleton() {
|
|||||||
`<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><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-14 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-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><div class="skeleton h-4 w-12 mx-auto"></div></td>`;
|
||||||
tb.appendChild(tr);
|
tb.appendChild(tr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -384,4 +441,5 @@ const SAMPLE = (() => {
|
|||||||
$("refresh").addEventListener("click", () => load(true));
|
$("refresh").addEventListener("click", () => load(true));
|
||||||
initSortHeaders();
|
initSortHeaders();
|
||||||
paintCache();
|
paintCache();
|
||||||
|
loadEloData();
|
||||||
load(false);
|
load(false);
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
*.xlsx
|
||||||
|
*.csv
|
||||||
|
__pycache__/
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
,RX-78-2/samle,RX-78-2,28.06.2026 23:38,file:///C:/Users/samle/AppData/Roaming/LibreOffice/4;
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user