Players get a fast TTL-backed homepage (local profile / Cloudflare D1) with dense UI polish; login unlocks /home without blocking on every OpenDota refresh. Co-authored-by: Cursor <cursoragent@cursor.com>
220 lines
9.8 KiB
Python
220 lines
9.8 KiB
Python
"""Backfill D1 from local pc/player_pages profile (+ live OpenDota if needed).
|
|
|
|
Used when Worker edge cannot reach OpenDota (429). No secret echo.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
ACCOUNT = "510534f7f6284344aadaf2f5a0794d48"
|
|
DB = "9eeb24ba-acc5-4520-b4e7-754ea776394e"
|
|
AID = int(os.environ.get("CLIMPEROR_SYNC_ACCOUNT_ID", "143712136"))
|
|
PROFILE = ROOT / "pc" / "player_pages" / str(AID) / "profile.json"
|
|
|
|
|
|
def _creds() -> tuple[str, str]:
|
|
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
|
|
"KEYZOO_ASSET_META_USERNAME"
|
|
)
|
|
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
|
|
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
|
)
|
|
if not email or not key:
|
|
raise SystemExit("missing Cloudflare credentials")
|
|
return email, key
|
|
|
|
|
|
def utc_now() -> str:
|
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
def d1_batch(statements: list[dict]) -> None:
|
|
email, key = _creds()
|
|
for st in statements:
|
|
payload = {"sql": st["sql"]}
|
|
if "params" in st:
|
|
payload["params"] = st["params"]
|
|
data = json.dumps(payload).encode()
|
|
req = urllib.request.Request(
|
|
f"https://api.cloudflare.com/client/v4/accounts/{ACCOUNT}/d1/database/{DB}/query",
|
|
data=data,
|
|
method="POST",
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"X-Auth-Email": email,
|
|
"X-Auth-Key": key,
|
|
},
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=60) as r:
|
|
out = json.loads(r.read().decode())
|
|
except urllib.error.HTTPError as e:
|
|
raw = e.read().decode("utf-8", errors="replace")
|
|
raise SystemExit(f"D1 {e.code}: {raw[:500]}\nSQL: {st['sql'][:200]}") from e
|
|
if not out.get("success"):
|
|
raise SystemExit(json.dumps(out, ensure_ascii=False)[:800])
|
|
|
|
|
|
def esc(v) -> str:
|
|
if v is None:
|
|
return "NULL"
|
|
if isinstance(v, bool):
|
|
return "1" if v else "0"
|
|
if isinstance(v, (int, float)) and not isinstance(v, bool):
|
|
if isinstance(v, float) and (v != v): # NaN
|
|
return "NULL"
|
|
return str(v)
|
|
s = str(v).replace("'", "''")
|
|
return f"'{s}'"
|
|
|
|
|
|
def main() -> int:
|
|
if not PROFILE.is_file():
|
|
raise SystemExit(f"missing {PROFILE}")
|
|
p = json.loads(PROFILE.read_text(encoding="utf-8"))
|
|
now = utc_now()
|
|
steamid = str(AID + 76561197960265728)
|
|
personaname = p.get("personaname") or "refining"
|
|
avatar = p.get("avatar")
|
|
public_share = 1 if p.get("public_share") else 0
|
|
avail = p.get("availability") or {}
|
|
career = p.get("career") or {}
|
|
recent20 = p.get("recent_20") or {}
|
|
activity = p.get("activity_180") or {}
|
|
top_heroes = p.get("top_heroes") or []
|
|
peers = p.get("peers") or []
|
|
recent = (p.get("recent") or [])[:20]
|
|
|
|
stmts: list[dict] = []
|
|
stmts.append(
|
|
{
|
|
"sql": (
|
|
f"INSERT INTO users (account_id, steamid, personaname, avatar, public_share, created_at, last_login_at) "
|
|
f"VALUES ({AID}, {esc(steamid)}, {esc(personaname)}, {esc(avatar)}, {public_share}, {esc(now)}, {esc(now)}) "
|
|
f"ON CONFLICT(account_id) DO UPDATE SET "
|
|
f"personaname=excluded.personaname, avatar=COALESCE(excluded.avatar, users.avatar), "
|
|
f"last_login_at=excluded.last_login_at"
|
|
)
|
|
}
|
|
)
|
|
stmts.append(
|
|
{
|
|
"sql": (
|
|
f"INSERT INTO player_profiles ("
|
|
f"account_id, rank_tier, leaderboard_rank, availability_status, availability_note, "
|
|
f"availability_complete, source, fetched_at, enriched_at, updated_at) VALUES ("
|
|
f"{AID}, {esc(p.get('rank_tier'))}, {esc(p.get('leaderboard_rank'))}, "
|
|
f"{esc(avail.get('status') or 'public')}, {esc(avail.get('note'))}, "
|
|
f"{1 if avail.get('complete', True) else 0}, "
|
|
f"{esc(avail.get('source') or 'opendota+steam')}, "
|
|
f"{esc(avail.get('fetched_at') or now)}, {esc(p.get('enriched_at') or now)}, {esc(now)}) "
|
|
f"ON CONFLICT(account_id) DO UPDATE SET "
|
|
f"rank_tier=excluded.rank_tier, leaderboard_rank=excluded.leaderboard_rank, "
|
|
f"availability_status=excluded.availability_status, availability_note=excluded.availability_note, "
|
|
f"availability_complete=excluded.availability_complete, source=excluded.source, "
|
|
f"fetched_at=excluded.fetched_at, enriched_at=excluded.enriched_at, updated_at=excluded.updated_at"
|
|
)
|
|
}
|
|
)
|
|
|
|
def stats_sql(scope: str, stats: dict) -> dict:
|
|
payload = json.dumps(stats, ensure_ascii=False).replace("'", "''")
|
|
sample = stats.get("sample") if stats.get("sample") is not None else stats.get("games") or 0
|
|
return {
|
|
"sql": (
|
|
f"INSERT INTO player_stats ("
|
|
f"account_id, scope, sample, wins, losses, winrate, kills, deaths, assists, kda, "
|
|
f"avg_kills, avg_deaths, avg_assists, avg_gpm, avg_xpm, avg_hero_damage, payload_json, updated_at) "
|
|
f"VALUES ({AID}, {esc(scope)}, {int(sample)}, {int(stats.get('wins') or 0)}, "
|
|
f"{int(stats.get('losses') or 0)}, {esc(stats.get('winrate'))}, "
|
|
f"{esc(stats.get('kills'))}, {esc(stats.get('deaths'))}, {esc(stats.get('assists'))}, "
|
|
f"{esc(stats.get('kda'))}, {esc(stats.get('avg_kills'))}, {esc(stats.get('avg_deaths'))}, "
|
|
f"{esc(stats.get('avg_assists'))}, {esc(stats.get('avg_gpm'))}, {esc(stats.get('avg_xpm'))}, "
|
|
f"{esc(stats.get('avg_hero_damage'))}, '{payload}', {esc(now)}) "
|
|
f"ON CONFLICT(account_id, scope) DO UPDATE SET "
|
|
f"sample=excluded.sample, wins=excluded.wins, losses=excluded.losses, winrate=excluded.winrate, "
|
|
f"kills=excluded.kills, deaths=excluded.deaths, assists=excluded.assists, kda=excluded.kda, "
|
|
f"avg_kills=excluded.avg_kills, avg_deaths=excluded.avg_deaths, avg_assists=excluded.avg_assists, "
|
|
f"avg_gpm=excluded.avg_gpm, avg_xpm=excluded.avg_xpm, avg_hero_damage=excluded.avg_hero_damage, "
|
|
f"payload_json=excluded.payload_json, updated_at=excluded.updated_at"
|
|
)
|
|
}
|
|
|
|
if career:
|
|
stmts.append(stats_sql("career", career))
|
|
if recent20:
|
|
stmts.append(stats_sql("recent20", recent20))
|
|
if activity:
|
|
stmts.append(stats_sql("recent180", activity))
|
|
|
|
stmts.append({"sql": f"DELETE FROM player_heroes WHERE account_id={AID}"})
|
|
for h in top_heroes[:8]:
|
|
stmts.append(
|
|
{
|
|
"sql": (
|
|
f"INSERT INTO player_heroes ("
|
|
f"account_id, hero_id, hero_key, hero_name_loc, games, wins, winrate, last_played, updated_at) "
|
|
f"VALUES ({AID}, {int(h.get('hero_id') or 0)}, {esc(h.get('hero_key'))}, "
|
|
f"{esc(h.get('hero_name_loc'))}, {int(h.get('games') or 0)}, {int(h.get('wins') or 0)}, "
|
|
f"{esc(h.get('winrate'))}, {esc(h.get('last_played'))}, {esc(now)})"
|
|
)
|
|
}
|
|
)
|
|
|
|
stmts.append({"sql": f"DELETE FROM player_peers WHERE account_id={AID}"})
|
|
for peer in peers[:8]:
|
|
stmts.append(
|
|
{
|
|
"sql": (
|
|
f"INSERT INTO player_peers ("
|
|
f"account_id, peer_account_id, personaname, avatar, games, wins, winrate, updated_at) "
|
|
f"VALUES ({AID}, {int(peer.get('account_id') or 0)}, {esc(peer.get('personaname'))}, "
|
|
f"{esc(peer.get('avatar'))}, {int(peer.get('games') or 0)}, {int(peer.get('wins') or 0)}, "
|
|
f"{esc(peer.get('winrate'))}, {esc(now)})"
|
|
)
|
|
}
|
|
)
|
|
|
|
for r in recent:
|
|
mid = int(r.get("match_id") or 0)
|
|
if mid <= 0:
|
|
continue
|
|
stmts.append(
|
|
{
|
|
"sql": (
|
|
f"INSERT INTO player_matches ("
|
|
f"account_id, match_id, start_time, duration, won, hero_id, hero_key, hero_name_loc, "
|
|
f"kills, deaths, assists, kda, gpm, xpm, hero_damage, game_mode, lobby_type, r2_key, updated_at) "
|
|
f"VALUES ({AID}, {mid}, {esc(r.get('start_time'))}, {esc(r.get('duration'))}, "
|
|
f"{1 if r.get('won') else 0}, {esc(r.get('hero_id'))}, {esc(r.get('hero_key'))}, "
|
|
f"{esc(r.get('hero_name_loc'))}, {esc(r.get('kills'))}, {esc(r.get('deaths'))}, "
|
|
f"{esc(r.get('assists'))}, {esc(r.get('kda'))}, {esc(r.get('gpm'))}, {esc(r.get('xpm'))}, "
|
|
f"{esc(r.get('hero_damage'))}, {esc(r.get('game_mode'))}, {esc(r.get('lobby_type'))}, "
|
|
f"NULL, {esc(now)}) "
|
|
f"ON CONFLICT(account_id, match_id) DO UPDATE SET "
|
|
f"start_time=excluded.start_time, duration=excluded.duration, won=excluded.won, "
|
|
f"hero_id=excluded.hero_id, hero_key=excluded.hero_key, hero_name_loc=excluded.hero_name_loc, "
|
|
f"kills=excluded.kills, deaths=excluded.deaths, assists=excluded.assists, kda=excluded.kda, "
|
|
f"gpm=excluded.gpm, xpm=excluded.xpm, hero_damage=excluded.hero_damage, "
|
|
f"game_mode=excluded.game_mode, lobby_type=excluded.lobby_type, updated_at=excluded.updated_at"
|
|
)
|
|
}
|
|
)
|
|
|
|
print(f"backfill {AID} from {PROFILE.name}: {len(stmts)} statements …", flush=True)
|
|
d1_batch(stmts)
|
|
print("done", flush=True)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|