Read the current OneDrive link from the stat-check.com/elo iframe on each run, falling back to EMBED_URL if the page can't be read or its link 404s. Retry 429/5xx and network errors up to three times with backoff, after a one-off 503 from the Badger token endpoint failed a run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
153 lines
5.6 KiB
Python
153 lines
5.6 KiB
Python
#!/usr/bin/env python3
|
|
import csv
|
|
import html
|
|
import json
|
|
import re
|
|
import sys
|
|
import time
|
|
import zipfile
|
|
from pathlib import Path
|
|
from urllib.error import HTTPError, URLError
|
|
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",
|
|
}
|
|
|
|
# Seconds to wait before each retry of a transient failure.
|
|
RETRY_DELAYS = (5, 15, 45)
|
|
|
|
|
|
def fetch(req: Request) -> bytes:
|
|
"""Read a URL, retrying 429/5xx responses and network errors."""
|
|
for attempt, delay in enumerate((*RETRY_DELAYS, None), start=1):
|
|
try:
|
|
with urlopen(req, timeout=60) as resp:
|
|
return resp.read()
|
|
except HTTPError as e:
|
|
if delay is None or not (e.code == 429 or e.code >= 500):
|
|
raise
|
|
reason = f"HTTP {e.code}"
|
|
except (URLError, TimeoutError, ConnectionError) as e:
|
|
if delay is None:
|
|
raise
|
|
reason = str(e)
|
|
print(f" {req.full_url.split('?')[0]}: {reason}; retry {attempt} in {delay}s", file=sys.stderr)
|
|
time.sleep(delay)
|
|
|
|
|
|
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"})
|
|
output.write_bytes(fetch(req))
|
|
|
|
|
|
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()
|