Ship Steam login, D1 player sync, and cached「我」dashboard.

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>
This commit is contained in:
voson
2026-08-01 01:24:30 +08:00
co-authored by Cursor
parent 4a61aeeb26
commit f5b7011c45
65 changed files with 7304 additions and 552 deletions
+27
View File
@@ -0,0 +1,27 @@
"""One-off: enrich a player profile (no secrets printed)."""
from __future__ import annotations
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "pc"))
from player_pages import enrich_profile_recent # noqa: E402
AID = int(sys.argv[1]) if len(sys.argv) > 1 else 143712136
cfg = json.loads((ROOT / "pc" / "config.json").read_text(encoding="utf-8"))
p = enrich_profile_recent(cfg, AID, include_gsi=True)
print(
"name",
p.get("personaname"),
"rank",
p.get("rank_tier"),
"lb",
p.get("leaderboard_rank"),
"recent",
len(p.get("recent") or []),
)
+119
View File
@@ -0,0 +1,119 @@
"""Probe OpenDota + Steam for a player (no secret echo)."""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.parse
import urllib.request
AID = 143712136
SID = AID + 76561197960265728
UA = "climperor-probe"
def _get(url: str) -> tuple[int, object]:
req = urllib.request.Request(url, headers={"User-Agent": UA})
try:
with urllib.request.urlopen(req, timeout=25) as resp:
return resp.status, json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
body = e.read().decode(errors="replace")[:200]
return e.code, {"error": body}
except Exception as e: # noqa: BLE001
return 0, {"error": str(e)}
def main() -> None:
key = (
os.environ.get("STEAM_API_KEY")
or os.environ.get("KEYZOO_ASSET_SECRET_WEB_API_KEY")
or ""
).strip()
od_key = (os.environ.get("OPENDOTA_API_KEY") or "").strip()
print(f"steam_key={bool(key)} opendota_key={bool(od_key)}")
od = f"https://api.opendota.com/api/players/{AID}"
if od_key:
od += f"?api_key={urllib.parse.quote(od_key)}"
code, data = _get(od)
if isinstance(data, dict) and "error" not in data:
profile = data.get("profile") if isinstance(data.get("profile"), dict) else {}
print(
"opendota player",
code,
"name=",
profile.get("personaname") or data.get("personaname"),
"rank_tier=",
data.get("rank_tier"),
"lb=",
data.get("leaderboard_rank"),
)
else:
print("opendota player", code, data)
od_r = f"https://api.opendota.com/api/players/{AID}/recentMatches"
if od_key:
od_r += f"?api_key={urllib.parse.quote(od_key)}"
code, data = _get(od_r)
if isinstance(data, list):
print("opendota recent", code, "n=", len(data))
if data:
print(" first", data[0].get("match_id"), data[0].get("start_time"))
else:
print("opendota recent", code, data)
if key:
q = urllib.parse.urlencode({"key": key, "steamids": str(SID)})
code, data = _get(
f"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?{q}"
)
players = (((data or {}).get("response") or {}).get("players")) or []
if players:
p = players[0]
print(
"steam summary",
code,
"name=",
p.get("personaname"),
"loc=",
p.get("loccountrycode"),
)
else:
print("steam summary", code, data)
q = urllib.parse.urlencode(
{
"key": key,
"account_id": AID,
"matches_requested": 10,
}
)
code, data = _get(
"https://api.steampowered.com/IDOTA2Match_570/GetMatchHistory/v1/?" + q
)
if isinstance(data, dict):
result = (data.get("result") or {})
print(
"steam match_history",
code,
"status=",
result.get("status"),
"statusDetail=",
result.get("statusDetail"),
"num=",
result.get("num_results"),
"total=",
result.get("total_results"),
)
matches = result.get("matches") or []
if matches:
m0 = matches[0]
print(" first match_id", m0.get("match_id"), "start", m0.get("start_time"))
else:
print("steam match_history", code, data)
if __name__ == "__main__":
main()
+41
View File
@@ -0,0 +1,41 @@
"""Launch serve_relations with STEAM_API_KEY / SESSION_SECRET from keyzoo env.
Maps KEYZOO_ASSET_SECRET_WEB_API_KEY / KEYZOO_ASSET_SECRET_SESSION_SECRET
when the plain names are unset. Does not print secret values.
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def _map_env() -> None:
if not (os.environ.get("STEAM_API_KEY") or "").strip():
alt = (os.environ.get("KEYZOO_ASSET_SECRET_WEB_API_KEY") or "").strip()
if alt:
os.environ["STEAM_API_KEY"] = alt
if not (os.environ.get("SESSION_SECRET") or "").strip():
alt = (os.environ.get("KEYZOO_ASSET_SECRET_SESSION_SECRET") or "").strip()
if alt:
os.environ["SESSION_SECRET"] = alt
def main() -> int:
_map_env()
steam_ok = bool((os.environ.get("STEAM_API_KEY") or "").strip())
sess_ok = bool((os.environ.get("SESSION_SECRET") or "").strip())
print(f"steam_auth configured: steam={steam_ok} session={sess_ok}", flush=True)
if not steam_ok or not sess_ok:
print("missing STEAM_API_KEY or SESSION_SECRET", file=sys.stderr)
return 2
cmd = [sys.executable, "-u", str(ROOT / "web" / "serve_relations.py"), *sys.argv[1:]]
return subprocess.call(cmd, cwd=str(ROOT))
if __name__ == "__main__":
raise SystemExit(main())
+84
View File
@@ -0,0 +1,84 @@
"""Background-start serve_relations with keyzoo-mapped Steam auth env."""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PORT = "8765"
def _map_env() -> dict[str, str]:
env = os.environ.copy()
if not (env.get("STEAM_API_KEY") or "").strip():
alt = (env.get("KEYZOO_ASSET_SECRET_WEB_API_KEY") or "").strip()
if alt:
env["STEAM_API_KEY"] = alt
if not (env.get("SESSION_SECRET") or "").strip():
alt = (env.get("KEYZOO_ASSET_SECRET_SESSION_SECRET") or "").strip()
if alt:
env["SESSION_SECRET"] = alt
return env
def main() -> int:
env = _map_env()
steam_ok = bool((env.get("STEAM_API_KEY") or "").strip())
sess_ok = bool((env.get("SESSION_SECRET") or "").strip())
print(f"steam_auth configured: steam={steam_ok} session={sess_ok}", flush=True)
if not steam_ok or not sess_ok:
print("missing STEAM_API_KEY or SESSION_SECRET", file=sys.stderr)
return 2
# Free the port if an old serve is still listening.
try:
subprocess.run(
[
"powershell",
"-NoProfile",
"-Command",
(
f"$c=Get-NetTCPConnection -LocalPort {PORT} -ErrorAction SilentlyContinue;"
"if($c){$c.OwningProcess|Sort-Object -Unique|ForEach-Object{"
"Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue}}"
),
],
check=False,
capture_output=True,
text=True,
)
except OSError:
pass
log_path = ROOT / "web" / ".refresh" / "serve_steam.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
cmd = [
sys.executable,
"-u",
str(ROOT / "web" / "serve_relations.py"),
"--port",
PORT,
"--no-browser",
]
flags = 0
if sys.platform == "win32":
flags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
with log_path.open("ab") as log:
proc = subprocess.Popen(
cmd,
cwd=str(ROOT),
env=env,
stdout=log,
stderr=subprocess.STDOUT,
creationflags=flags,
close_fds=True,
)
print(f"started pid={proc.pid} port={PORT}", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+17
View File
@@ -0,0 +1,17 @@
"""Smoke-check STEAM_API_KEY / KEYZOO_ASSET_SECRET_WEB_API_KEY (no secret echo)."""
from __future__ import annotations
import json
import os
import urllib.request
k = (os.environ.get("STEAM_API_KEY") or os.environ.get("KEYZOO_ASSET_SECRET_WEB_API_KEY") or "").strip()
if not k:
raise SystemExit("missing key")
url = (
"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/"
f"?key={k}&steamids=76561198000000000"
)
with urllib.request.urlopen(url, timeout=20) as resp:
data = json.loads(resp.read().decode())
print("ok" if isinstance(data, dict) and "response" in data else "bad")
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+21
View File
@@ -0,0 +1,21 @@
{
"account_id": "510534f7f6284344aadaf2f5a0794d48",
"d1": {
"name": "climperor-users",
"id": "9eeb24ba-acc5-4520-b4e7-754ea776394e"
},
"r2": {
"name": "climperor-player-data",
"ready": true
},
"queue": {
"name": "climperor-player-sync",
"id": "371f11c7b4114f9b99ab10062a38ecd7"
},
"dlq": {
"name": "climperor-player-sync-dlq",
"id": "f7d1d45494f744e59effafb75ec62249"
},
"worker": "climperor-player-sync",
"pages_project": "climperor-relations"
}
+55
View File
@@ -0,0 +1,55 @@
# Climperor Cloudflare player data layer
Production multi-user storage for Steam-login player pages:
| Resource | Name | Role |
|----------|------|------|
| D1 | `climperor-users` | users, stats, heroes, match index, sync jobs |
| R2 | `climperor-player-data` | private full match JSON (`matches/{id}.json`) |
| Queue | `climperor-player-sync` | async OpenDota/Steam refresh |
| DLQ | `climperor-player-sync-dlq` | failed sync messages |
| Worker | `climperor-player-sync` | queue consumer |
## Provision
```powershell
# via keyzoo refining/cloudflare asset
python web/cloudflare/provision.py
```
Then deploy the worker:
```powershell
cd web/cloudflare/player-sync
npx wrangler@3 deploy
npx wrangler@3 secret put STEAM_API_KEY
# optional:
npx wrangler@3 secret put OPENDOTA_API_KEY
```
Pages project `climperor-relations` needs bindings `DB`, `MATCHES`, `SYNC_QUEUE`
(see `web/frontend/wrangler.toml`). `provision.py` binds production **and** preview
with matching `fail_open`. Secrets already used by auth:
`STEAM_API_KEY`, `SESSION_SECRET`, optional `PLAYER_PAGES_PUBLISH_SECRET`.
Worker also needs `STEAM_API_KEY` (`python web/cloudflare/put_worker_secrets.py`
after staging via `_stage_steam_key.py`).
**R2**: if create returns “enable R2”, open
https://dash.cloudflare.com/?to=/:account/r2 once, then re-run `provision.py`
and uncomment the `[[r2_buckets]]` block in `player-sync/wrangler.toml`, redeploy Worker.
Until then, match list/stats still work via D1; full match JSON download is deferred.
Ids are recorded in `.resources.json` (no secrets).
## Local
`python web/serve_relations.py` continues to use `pc/player_pages/` JSON + enrich
(`GET /api/players/me` runs `enrich_profile_recent`).
Production `/home` uses `GET /api/players/me` (D1 + queue).
## Tests
```powershell
python -m unittest pc.tests.test_player_stats -v
node web/cloudflare/player-sync/test_stats.mjs
```
+219
View File
@@ -0,0 +1,219 @@
"""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())
+65
View File
@@ -0,0 +1,65 @@
"""Diagnose Cloudflare API auth without printing secrets."""
from __future__ import annotations
import os
import sys
import urllib.error
import urllib.request
from pathlib import Path
STAGED = Path(__file__).resolve().parents[1] / ".refresh" / "cf_creds.env"
def load_staged() -> None:
if not STAGED.is_file():
return
for line in STAGED.read_text(encoding="utf-8").splitlines():
if "=" not in line or line.startswith("#"):
continue
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
def probe(path: str, email: str, key: str) -> None:
req = urllib.request.Request(
"https://api.cloudflare.com/client/v4" + path,
headers={
"X-Auth-Email": email,
"X-Auth-Key": key,
"User-Agent": "climperor-cf-diag",
},
)
try:
with urllib.request.urlopen(req, timeout=45) as resp:
body = resp.read(120).decode("utf-8", errors="replace")
print(f"{path} -> {resp.status} ray={resp.headers.get('cf-ray')} body={body[:80]!r}")
except urllib.error.HTTPError as e:
raw = e.read(200).decode("utf-8", errors="replace")
print(
f"{path} -> HTTP {e.code} ray={e.headers.get('cf-ray') if e.headers else None} "
f"body={raw[:120]!r}"
)
except Exception as e:
print(f"{path} -> {type(e).__name__}: {e}")
def main() -> int:
load_staged()
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:
print("missing credentials")
return 2
print(f"email_len={len(email)} key_len={len(key)} email_has_at={'@' in email}")
for path in ("/user", "/accounts", "/user/tokens/verify"):
probe(path, email, key)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+37
View File
@@ -0,0 +1,37 @@
"""Quick Cloudflare API reachability check (no secrets printed)."""
from __future__ import annotations
import os
import urllib.error
import urllib.request
def main() -> int:
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:
print("missing credentials")
return 2
req = urllib.request.Request(
"https://api.cloudflare.com/client/v4/user",
headers={"X-Auth-Email": email, "X-Auth-Key": key},
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
print(f"ok status={resp.status}")
return 0
except urllib.error.HTTPError as e:
print(f"http {e.code}")
return 1
except Exception as e:
print(f"{type(e).__name__}: {e}")
return 1
if __name__ == "__main__":
raise SystemExit(main())
+82
View File
@@ -0,0 +1,82 @@
"""Try R2 create via curl + Global API Key from staged env (no secret echo)."""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
STAGED = Path(__file__).resolve().parents[1] / ".refresh" / "cf_creds.env"
ACCT = "510534f7f6284344aadaf2f5a0794d48"
BUCKET = "climperor-player-data"
def load() -> tuple[str, str]:
email = os.environ.get("CLOUDFLARE_EMAIL")
key = os.environ.get("CLOUDFLARE_API_KEY")
if STAGED.is_file():
for line in STAGED.read_text(encoding="utf-8").splitlines():
if "=" not in line:
continue
k, v = line.split("=", 1)
if k.strip() == "CLOUDFLARE_EMAIL":
email = v.strip()
elif k.strip() == "CLOUDFLARE_API_KEY":
key = v.strip()
if not email or not key:
raise SystemExit("missing credentials")
return email, key
def curl_json(method: str, url: str, email: str, key: str, body: dict | None = None) -> tuple[int, str]:
cmd = [
"curl.exe",
"-sS",
"-w",
"\nHTTP_CODE:%{http_code}",
"--max-time",
"60",
"-X",
method,
url,
"-H",
f"X-Auth-Email: {email}",
"-H",
f"X-Auth-Key: {key}",
"-H",
"Content-Type: application/json",
]
if body is not None:
cmd.extend(["-d", json.dumps(body)])
proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
out = proc.stdout or ""
code = 0
if "HTTP_CODE:" in out:
body_text, _, code_s = out.rpartition("HTTP_CODE:")
try:
code = int(code_s.strip())
except ValueError:
code = 0
out = body_text.strip()
else:
out = (proc.stderr or out)[:300]
return code, out[:400]
def main() -> int:
email, key = load()
base = f"https://api.cloudflare.com/client/v4/accounts/{ACCT}/r2/buckets"
code, body = curl_json("GET", f"{base}/{BUCKET}", email, key)
print(f"GET bucket -> {code} {body[:160]!r}")
if code == 200:
print("r2 already exists")
return 0
code, body = curl_json("POST", base, email, key, {"name": BUCKET})
print(f"POST bucket -> {code} {body[:200]!r}")
return 0 if code in (200, 201) or "already exists" in body.lower() else 1
if __name__ == "__main__":
raise SystemExit(main())
+46
View File
@@ -0,0 +1,46 @@
"""Print Pages deployment_configs keys relevant to bindings (no secrets)."""
from __future__ import annotations
import json
import os
import urllib.request
ACCT = "510534f7f6284344aadaf2f5a0794d48"
PROJ = "climperor-relations"
def main() -> int:
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"
)
req = urllib.request.Request(
f"https://api.cloudflare.com/client/v4/accounts/{ACCT}/pages/projects/{PROJ}",
headers={"X-Auth-Email": email, "X-Auth-Key": key},
)
with urllib.request.urlopen(req, timeout=60) as resp:
data = json.load(resp)
dc = (data.get("result") or {}).get("deployment_configs") or {}
for env in ("production", "preview"):
cfg = dc.get(env) or {}
print(
env,
"fail_open=",
cfg.get("fail_open"),
"placement=",
cfg.get("placement"),
"d1=",
sorted((cfg.get("d1_databases") or {}).keys()),
"r2=",
sorted((cfg.get("r2_buckets") or {}).keys()),
"queues=",
sorted((cfg.get("queue_producers") or {}).keys()),
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+177
View File
@@ -0,0 +1,177 @@
"""Create R2 bucket, bind Pages, redeploy Worker using keyzoo-injected CF creds."""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
WORKER = Path(__file__).resolve().parent / "player-sync"
ACCT = "510534f7f6284344aadaf2f5a0794d48"
BUCKET = "climperor-player-data"
PAGES = "climperor-relations"
QUEUE = "climperor-player-sync"
D1_ID = "9eeb24ba-acc5-4520-b4e7-754ea776394e"
API = "https://api.cloudflare.com/client/v4"
RESOURCES = Path(__file__).resolve().parent / ".resources.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 CF credentials")
return email, key
def api(method: str, path: str, body: dict | None = None, retries: int = 6) -> dict:
email, key = creds()
data = None if body is None else json.dumps(body).encode("utf-8")
last = None
for i in range(retries):
req = urllib.request.Request(
API + path,
data=data,
method=method,
headers={
"X-Auth-Email": email,
"X-Auth-Key": key,
"Content-Type": "application/json",
"User-Agent": "climperor-enable-r2",
},
)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", errors="replace")
last = f"{e.code}: {raw[:200]}"
if e.code in (521, 522, 523, 524, 525, 429, 503) and i + 1 < retries:
wait = 4 + i * 3
print(f"retry {i+1}/{retries} after {e.code}, sleep {wait}s", flush=True)
time.sleep(wait)
continue
raise SystemExit(f"CF API {method} {path} -> {last}") from e
except Exception as e:
last = str(e)
if i + 1 < retries:
wait = 4 + i * 3
print(f"retry {i+1}/{retries} after {type(e).__name__}, sleep {wait}s", flush=True)
time.sleep(wait)
continue
raise SystemExit(f"CF API {method} {path} -> {last}") from e
raise SystemExit(f"CF API failed: {last}")
def ensure_r2() -> None:
try:
api("GET", f"/accounts/{ACCT}/r2/buckets/{BUCKET}", retries=3)
print(f"r2 exists: {BUCKET}", flush=True)
return
except SystemExit:
pass
payload = api("POST", f"/accounts/{ACCT}/r2/buckets", {"name": BUCKET})
if not payload.get("success"):
err = str(payload.get("errors") or "")
if "already exists" in err.lower() or "10004" in err:
print(f"r2 exists: {BUCKET}", flush=True)
return
raise SystemExit(f"r2 create failed: {payload.get('errors')}")
print(f"r2 created: {BUCKET}", flush=True)
def bind_pages() -> None:
path = f"/accounts/{ACCT}/pages/projects/{PAGES}"
project = api("GET", path)["result"]
dc = project.get("deployment_configs") or {}
prod = dict(dc.get("production") or {})
preview = dict(dc.get("preview") or {})
fail_open = prod.get("fail_open")
if fail_open is None:
fail_open = preview.get("fail_open")
if fail_open is None:
fail_open = False
def one(base: dict) -> dict:
out = {
k: v
for k, v in base.items()
if k not in ("d1_databases", "queue_producers", "r2_buckets", "fail_open")
}
d1 = dict(base.get("d1_databases") or {})
d1["DB"] = {"id": D1_ID}
out["d1_databases"] = d1
producers = dict(base.get("queue_producers") or {})
producers["SYNC_QUEUE"] = {"name": QUEUE}
out["queue_producers"] = producers
buckets = dict(base.get("r2_buckets") or {})
buckets["MATCHES"] = {"name": BUCKET}
out["r2_buckets"] = buckets
out["fail_open"] = bool(fail_open)
return out
api(
"PATCH",
path,
{
"deployment_configs": {
"production": one(prod),
"preview": one(preview),
}
},
)
print("pages bindings: DB + SYNC_QUEUE + MATCHES", flush=True)
def update_resources() -> None:
data = {}
if RESOURCES.is_file():
data = json.loads(RESOURCES.read_text(encoding="utf-8"))
data["account_id"] = ACCT
data["r2"] = {"name": BUCKET, "ready": True}
data.setdefault("d1", {"name": "climperor-users", "id": D1_ID})
data.setdefault("queue", {"name": QUEUE})
data.setdefault("pages_project", PAGES)
data.setdefault("worker", "climperor-player-sync")
RESOURCES.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
print(f"updated {RESOURCES}", flush=True)
def deploy_worker() -> None:
email, key = creds()
env = os.environ.copy()
env["CLOUDFLARE_EMAIL"] = email
env["CLOUDFLARE_API_KEY"] = key
env["CLOUDFLARE_ACCOUNT_ID"] = ACCT
print("deploying worker…", flush=True)
proc = subprocess.run(
"npx --yes wrangler@3 deploy",
cwd=str(WORKER),
env=env,
shell=True,
)
if proc.returncode != 0:
raise SystemExit(f"worker deploy failed: {proc.returncode}")
print("worker deployed", flush=True)
def main() -> int:
ensure_r2()
bind_pages()
update_resources()
deploy_worker()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+8
View File
@@ -0,0 +1,8 @@
"""Force Worker HTTP login_refresh for TARGET account."""
import os
import runpy
from pathlib import Path
os.environ["CLIMPEROR_FORCE_HTTP_SYNC"] = "1"
runpy.run_path(str(Path(__file__).with_name("_trigger_player_sync.py")), run_name="__main__")
+147
View File
@@ -0,0 +1,147 @@
"""Deep-probe D1 + OpenDota for one account (no secret echo)."""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.request
ACCOUNT = "510534f7f6284344aadaf2f5a0794d48"
DB = "9eeb24ba-acc5-4520-b4e7-754ea776394e"
AID = int(os.environ.get("CLIMPEROR_SYNC_ACCOUNT_ID", "143712136"))
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 d1(sql: str) -> list:
email, key = _creds()
body = json.dumps({"sql": sql}).encode()
req = urllib.request.Request(
f"https://api.cloudflare.com/client/v4/accounts/{ACCOUNT}/d1/database/{DB}/query",
data=body,
method="POST",
headers={
"Content-Type": "application/json",
"X-Auth-Email": email,
"X-Auth-Key": key,
},
)
with urllib.request.urlopen(req, timeout=30) as r:
out = json.loads(r.read().decode())
results = out.get("result") or []
if results:
return results[0].get("results") or []
return []
def od(path: str):
req = urllib.request.Request(
f"https://api.opendota.com/api{path}",
headers={"User-Agent": "climperor-probe"},
)
try:
with urllib.request.urlopen(req, timeout=25) as r:
return json.loads(r.read().decode())
except urllib.error.HTTPError as e:
return {"_error": e.code, "_body": e.read()[:200].decode("utf-8", "replace")}
def main() -> int:
print("users", json.dumps(d1(f"SELECT * FROM users WHERE account_id={AID}"), ensure_ascii=False))
print(
"stats",
json.dumps(
d1(
f"SELECT scope, sample, wins, losses, winrate, length(payload_json) AS plen "
f"FROM player_stats WHERE account_id={AID}"
),
ensure_ascii=False,
),
)
print(
"matches",
json.dumps(
d1(f"SELECT COUNT(*) AS n FROM player_matches WHERE account_id={AID}"),
ensure_ascii=False,
),
)
print(
"heroes",
json.dumps(
d1(f"SELECT COUNT(*) AS n FROM player_heroes WHERE account_id={AID}"),
ensure_ascii=False,
),
)
print(
"peers",
json.dumps(
d1(f"SELECT COUNT(*) AS n FROM player_peers WHERE account_id={AID}"),
ensure_ascii=False,
),
)
print(
"profile",
json.dumps(
d1(
f"SELECT account_id, rank_tier, leaderboard_rank, "
f"availability_status, availability_note, "
f"availability_complete, source, fetched_at, enriched_at "
f"FROM player_profiles WHERE account_id={AID}"
),
ensure_ascii=False,
),
)
print(
"stats_all",
json.dumps(
d1(
f"SELECT scope, sample, wins, losses, kda, avg_gpm "
f"FROM player_stats WHERE account_id={AID} ORDER BY scope"
),
ensure_ascii=False,
),
)
player = od(f"/players/{AID}")
if isinstance(player, dict) and "profile" in player:
p = player.get("profile") or {}
print(
"OD player",
json.dumps(
{
"personaname": p.get("personaname"),
"rank_tier": player.get("rank_tier"),
"fh_unavailable": player.get("fh_unavailable"),
},
ensure_ascii=False,
),
)
else:
print("OD player", player)
wl = od(f"/players/{AID}/wl")
print("OD wl", wl)
recent = od(f"/players/{AID}/recentMatches")
if isinstance(recent, list):
print("OD recentMatches", len(recent))
if recent:
print("OD recent[0].match_id", recent[0].get("match_id"))
else:
print("OD recentMatches", recent)
return 0
if __name__ == "__main__":
sys.exit(main())
+48
View File
@@ -0,0 +1,48 @@
"""Probe which CF API paths work with Global API Key (no secrets printed)."""
from __future__ import annotations
import os
import urllib.error
import urllib.request
ACCT = "510534f7f6284344aadaf2f5a0794d48"
PATHS = [
"/user",
"/accounts",
f"/accounts/{ACCT}/d1/database",
f"/accounts/{ACCT}/queues",
f"/accounts/{ACCT}/r2/buckets",
f"/accounts/{ACCT}/pages/projects/climperor-relations",
]
def main() -> int:
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"
)
for path in PATHS:
req = urllib.request.Request(
"https://api.cloudflare.com/client/v4" + path,
headers={
"X-Auth-Email": email,
"X-Auth-Key": key,
"User-Agent": "climperor-probe",
},
)
try:
with urllib.request.urlopen(req, timeout=40) as resp:
print(f"{path} -> {resp.status}")
except urllib.error.HTTPError as e:
snippet = e.read(80).decode("utf-8", errors="replace").replace("\n", " ")
print(f"{path} -> HTTP {e.code} {snippet[:60]!r}")
except Exception as e:
print(f"{path} -> {type(e).__name__}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+93
View File
@@ -0,0 +1,93 @@
"""Hammer R2 create/list until Cloudflare API stops returning 52x."""
from __future__ import annotations
import json
import os
import time
import urllib.error
import urllib.request
ACCT = "510534f7f6284344aadaf2f5a0794d48"
BUCKET = "climperor-player-data"
API = "https://api.cloudflare.com/client/v4"
def creds():
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 credentials")
return email, key
def call(method: str, path: str, body: dict | None = None) -> tuple[int, dict | str]:
email, key = creds()
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(
API + path,
data=data,
method=method,
headers={
"X-Auth-Email": email,
"X-Auth-Key": key,
"Content-Type": "application/json",
"User-Agent": "climperor-retry-r2",
},
)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return resp.status, json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", errors="replace")
try:
return e.code, json.loads(raw)
except Exception:
return e.code, raw[:120]
def main() -> int:
list_path = f"/accounts/{ACCT}/r2/buckets"
get_path = f"/accounts/{ACCT}/r2/buckets/{BUCKET}"
for i in range(20):
code, payload = call("GET", get_path)
print(f"[{i+1}] GET {BUCKET} -> {code}", flush=True)
if code == 200:
print("bucket ready", flush=True)
return 0
if code == 404 or (isinstance(payload, dict) and not payload.get("success")):
# not found → create
code2, payload2 = call("POST", list_path, {"name": BUCKET})
print(f"[{i+1}] POST create -> {code2}", flush=True)
if code2 in (200, 201):
print("created", flush=True)
return 0
text = str(payload2).lower()
if "already exists" in text or "10004" in text:
print("exists", flush=True)
return 0
# also try list
code3, payload3 = call("GET", list_path)
names = []
if isinstance(payload3, dict):
buckets = (payload3.get("result") or {}).get("buckets") or payload3.get(
"result"
)
if isinstance(buckets, list):
for b in buckets:
if isinstance(b, dict):
names.append(b.get("name"))
print(f"[{i+1}] LIST -> {code3} names={names}", flush=True)
if BUCKET in names:
print("bucket ready via list", flush=True)
return 0
time.sleep(5 + (i % 5))
return 1
if __name__ == "__main__":
raise SystemExit(main())
+36
View File
@@ -0,0 +1,36 @@
"""Load staged CF creds into env, run argv command, then delete the staging file."""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
STAGED = Path(__file__).resolve().parents[1] / ".refresh" / "cf_creds.env"
def main() -> int:
if len(sys.argv) < 2:
print("usage: _run_with_staged_cf.py <cmd> [args...]", file=sys.stderr)
return 2
if not STAGED.is_file():
print("missing staged cf_creds.env", file=sys.stderr)
return 2
env = os.environ.copy()
for line in STAGED.read_text(encoding="utf-8").splitlines():
if "=" not in line or line.startswith("#"):
continue
k, v = line.split("=", 1)
env[k.strip()] = v.strip()
try:
STAGED.unlink()
except OSError:
pass
env.setdefault("CLOUDFLARE_ACCOUNT_ID", "510534f7f6284344aadaf2f5a0794d48")
proc = subprocess.run(sys.argv[1:], cwd=str(Path.cwd()), env=env)
return proc.returncode
if __name__ == "__main__":
raise SystemExit(main())
+40
View File
@@ -0,0 +1,40 @@
"""Stage Cloudflare email+key for the next local provision (no echo of secrets)."""
from __future__ import annotations
import os
import sys
from pathlib import Path
OUT = Path(__file__).resolve().parents[1] / ".refresh" / "cf_creds.env"
def main() -> int:
email = (
os.environ.get("CLOUDFLARE_EMAIL")
or os.environ.get("KEYZOO_ASSET_META_USERNAME")
or ""
).strip()
key = (
os.environ.get("CLOUDFLARE_API_KEY")
or os.environ.get("KEYZOO_ASSET_SECRET_GLOBAL_API_KEY")
or ""
).strip()
if not email or not key:
print("missing credentials", file=sys.stderr)
return 2
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(
f"CLOUDFLARE_EMAIL={email}\nCLOUDFLARE_API_KEY={key}\n",
encoding="utf-8",
)
try:
os.chmod(OUT, 0o600)
except OSError:
pass
print(f"staged {OUT.name}", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+32
View File
@@ -0,0 +1,32 @@
"""Stage Steam API key to a temp file for the next CF secret put (no echo)."""
from __future__ import annotations
import os
import sys
from pathlib import Path
OUT = Path(__file__).resolve().parents[1] / ".refresh" / "steam_key.tmp"
def main() -> int:
key = (
os.environ.get("STEAM_API_KEY")
or os.environ.get("KEYZOO_ASSET_SECRET_WEB_API_KEY")
or ""
).strip()
if not key:
print("missing steam key", file=sys.stderr)
return 2
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(key, encoding="utf-8")
try:
os.chmod(OUT, 0o600)
except OSError:
pass
print(f"staged {OUT.name} len={len(key)}", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+264
View File
@@ -0,0 +1,264 @@
"""Probe D1 for an account, enqueue login_refresh, wait, probe again.
Uses Cloudflare Global API Key env (no secret echo).
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
HERE = Path(__file__).resolve().parent
WORKER = HERE / "player-sync"
ACCOUNT_ID = "510534f7f6284344aadaf2f5a0794d48"
QUEUE_ID = "371f11c7b4114f9b99ab10062a38ecd7"
TARGET = int(os.environ.get("CLIMPEROR_SYNC_ACCOUNT_ID", "143712136"))
def _cf_env() -> dict[str, str]:
env = os.environ.copy()
email = env.get("CLOUDFLARE_EMAIL") or env.get("KEYZOO_ASSET_META_USERNAME")
key = env.get("CLOUDFLARE_API_KEY") or env.get(
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
)
if not email or not key:
raise SystemExit("missing Cloudflare credentials")
env["CLOUDFLARE_EMAIL"] = email
env["CLOUDFLARE_API_KEY"] = key
env["CLOUDFLARE_ACCOUNT_ID"] = ACCOUNT_ID
return env
def d1_query(sql: str, env: dict[str, str]) -> list:
cmd = (
f'npx --yes wrangler@3 d1 execute climperor-users --remote --json '
f'--command "{sql}"'
)
r = subprocess.run(
cmd,
cwd=str(WORKER),
env=env,
shell=True,
capture_output=True,
text=True,
)
if r.returncode != 0:
print(r.stderr or r.stdout, file=sys.stderr)
raise SystemExit(r.returncode)
try:
data = json.loads(r.stdout)
except json.JSONDecodeError:
print(r.stdout)
return []
if isinstance(data, list) and data:
return data[0].get("results") or []
return []
class CfApiError(RuntimeError):
def __init__(self, code: int, body: str):
super().__init__(f"CF API {code}")
self.code = code
self.body = body
def cf_api(method: str, path: str, env: dict[str, str], body: dict | None = None) -> dict:
url = f"https://api.cloudflare.com/client/v4{path}"
data = None if body is None else json.dumps(body).encode("utf-8")
req = urllib.request.Request(
url,
data=data,
method=method,
headers={
"X-Auth-Email": env["CLOUDFLARE_EMAIL"],
"X-Auth-Key": env["CLOUDFLARE_API_KEY"],
"Content-Type": "application/json",
"User-Agent": "climperor-trigger-sync",
},
)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", errors="replace")
raise CfApiError(e.code, raw[:1200]) from e
def probe(env: dict[str, str], label: str) -> None:
print(f"\n=== {label} account_id={TARGET} ===", flush=True)
users = d1_query(
f"SELECT account_id, personaname, last_login_at FROM users "
f"WHERE account_id={TARGET}",
env,
)
print("users:", json.dumps(users, ensure_ascii=False), flush=True)
jobs = d1_query(
f"SELECT kind, status, substr(COALESCE(error,''),1,160) AS error, "
f"updated_at FROM sync_jobs WHERE account_id={TARGET} "
f"ORDER BY updated_at DESC LIMIT 5",
env,
)
print("sync_jobs:", json.dumps(jobs, ensure_ascii=False), flush=True)
matches = d1_query(
f"SELECT COUNT(*) AS n, "
f"SUM(CASE WHEN r2_key IS NOT NULL AND r2_key != '' THEN 1 ELSE 0 END) AS r2 "
f"FROM player_matches WHERE account_id={TARGET}",
env,
)
print("matches:", json.dumps(matches, ensure_ascii=False), flush=True)
stats = d1_query(
f"SELECT scope, sample, winrate FROM player_stats "
f"WHERE account_id={TARGET}",
env,
)
print("stats:", json.dumps(stats, ensure_ascii=False), flush=True)
heroes = d1_query(
f"SELECT COUNT(*) AS n FROM player_heroes WHERE account_id={TARGET}",
env,
)
print("heroes:", json.dumps(heroes, ensure_ascii=False), flush=True)
def worker_http_sync(env: dict[str, str], msg: dict) -> str:
"""POST Worker fetch handler. Returns 'http'."""
print("Worker HTTP sync …", flush=True)
try:
cf_api(
"POST",
f"/accounts/{ACCOUNT_ID}/workers/scripts/climperor-player-sync/subdomain",
env,
{"enabled": True},
)
except CfApiError as e:
print(f"enable subdomain: {e.code} {e.body[:400]}", flush=True)
sub_name = ""
try:
sub = cf_api("GET", f"/accounts/{ACCOUNT_ID}/workers/subdomain", env)
sub_name = ((sub.get("result") or {}).get("subdomain") or "").strip()
except CfApiError as e:
print(f"get subdomain: {e.code} {e.body[:400]}", flush=True)
if not sub_name:
raise SystemExit("cannot resolve workers.dev subdomain")
url = f"https://climperor-player-sync.{sub_name}.workers.dev/"
print(f"POST {url}", flush=True)
data = json.dumps(msg).encode("utf-8")
req = urllib.request.Request(
url,
data=data,
method="POST",
headers={
"Content-Type": "application/json",
"User-Agent": "climperor-trigger-sync",
},
)
try:
with urllib.request.urlopen(req, timeout=120) as resp:
raw = resp.read().decode("utf-8", errors="replace")
print(f"worker HTTP {resp.status} len={len(raw)}", flush=True)
print(raw[:800], flush=True)
return "http"
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", errors="replace")
print(f"worker HTTP {e.code}: {raw[:800]}", file=sys.stderr)
raise SystemExit(1) from e
except urllib.error.URLError as e:
print(f"worker URL error: {e}", file=sys.stderr)
raise SystemExit(1) from e
def enqueue(env: dict[str, str]) -> str:
"""Enqueue or HTTP-sync. Returns 'queue' | 'http'."""
steamid = str(TARGET + 76561197960265728)
msg = {
"kind": "login_refresh",
"account_id": TARGET,
"steamid": steamid,
"personaname": "refining",
}
if os.environ.get("CLIMPEROR_FORCE_HTTP_SYNC", "").strip() in (
"1",
"true",
"yes",
):
return worker_http_sync(env, msg)
print("\nenqueue login_refresh …", flush=True)
# https://developers.cloudflare.com/queues/configuration/javascript-apis/#producer
# HTTP: POST /accounts/:account_id/queues/:queue_id/messages
attempts = [
(
f"/accounts/{ACCOUNT_ID}/queues/{QUEUE_ID}/messages",
{"body": msg},
),
(
f"/accounts/{ACCOUNT_ID}/queues/{QUEUE_ID}/messages",
{"messages": [{"body": json.dumps(msg)}]},
),
(
f"/accounts/{ACCOUNT_ID}/queues/{QUEUE_ID}/messages/batch",
{"messages": [{"body": msg}]},
),
(
f"/accounts/{ACCOUNT_ID}/queues/{QUEUE_ID}/messages/batch",
{"messages": [{"body": json.dumps(msg)}]},
),
]
for path, body in attempts:
try:
out = cf_api("POST", path, env, body)
except CfApiError as e:
print(f"try {path.split('/')[-1]} HTTP {e.code}: {e.body[:400]}", flush=True)
continue
ok = bool(out.get("success"))
print(f"try {path.split('/')[-1]} success={ok}", flush=True)
if ok:
return "queue"
print(json.dumps(out, ensure_ascii=False)[:600], flush=True)
# Last resort: enable workers.dev and POST the Worker fetch handler.
return worker_http_sync(env, msg)
def check_worker(env: dict[str, str]) -> None:
out = cf_api("GET", f"/accounts/{ACCOUNT_ID}/workers/scripts", env)
names = [r.get("id") or r.get("name") for r in (out.get("result") or [])]
print("workers:", ", ".join(n for n in names if n)[:500], flush=True)
has = "climperor-player-sync" in names
print(f"climperor-player-sync deployed={has}", flush=True)
def main() -> int:
env = _cf_env()
check_worker(env)
probe(env, "before")
mode = enqueue(env)
waits = 2 if mode == "http" else 8
for i in range(1, waits + 1):
delay = 3 if mode == "http" else 8
time.sleep(delay)
probe(env, f"after wait #{i} ({i * delay}s)")
users = d1_query(
f"SELECT account_id FROM users WHERE account_id={TARGET}", env
)
stats = d1_query(
f"SELECT scope FROM player_stats WHERE account_id={TARGET}", env
)
filled = d1_query(
f"SELECT scope, sample FROM player_stats WHERE account_id={TARGET} "
f"AND sample > 0",
env,
)
if users and filled:
print("\nSYNC OK — user + non-empty stats", flush=True)
return 0
print("\nSYNC PENDING/FAILED — no non-empty stats after waits", flush=True)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+36
View File
@@ -0,0 +1,36 @@
"""Deploy climperor-player-sync Worker via wrangler (no secret echo)."""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
WORKER = HERE / "player-sync"
def main() -> int:
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:
print("missing Cloudflare credentials", file=sys.stderr)
return 2
env = os.environ.copy()
env["CLOUDFLARE_EMAIL"] = email
env["CLOUDFLARE_API_KEY"] = key
env["CLOUDFLARE_ACCOUNT_ID"] = env.get(
"CLOUDFLARE_ACCOUNT_ID", "510534f7f6284344aadaf2f5a0794d48"
)
cmd = "npx --yes wrangler@3 deploy"
print("deploying climperor-player-sync …", flush=True)
return subprocess.call(cmd, cwd=str(WORKER), env=env, shell=True)
if __name__ == "__main__":
raise SystemExit(main())
+114
View File
@@ -0,0 +1,114 @@
-- Climperor multi-user player data (D1 climperor-users)
CREATE TABLE IF NOT EXISTS users (
account_id INTEGER PRIMARY KEY,
steamid TEXT NOT NULL UNIQUE,
personaname TEXT,
avatar TEXT,
public_share INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
last_login_at TEXT
);
CREATE TABLE IF NOT EXISTS player_profiles (
account_id INTEGER PRIMARY KEY REFERENCES users(account_id),
rank_tier INTEGER,
leaderboard_rank INTEGER,
availability_status TEXT,
availability_note TEXT,
availability_complete INTEGER NOT NULL DEFAULT 0,
source TEXT,
fetched_at TEXT,
enriched_at TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS player_stats (
account_id INTEGER NOT NULL REFERENCES users(account_id),
scope TEXT NOT NULL, -- career | recent20 | recent180
sample INTEGER NOT NULL DEFAULT 0,
wins INTEGER NOT NULL DEFAULT 0,
losses INTEGER NOT NULL DEFAULT 0,
winrate REAL,
kills INTEGER,
deaths INTEGER,
assists INTEGER,
kda REAL,
avg_kills REAL,
avg_deaths REAL,
avg_assists REAL,
avg_gpm REAL,
avg_xpm REAL,
avg_hero_damage REAL,
payload_json TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (account_id, scope)
);
CREATE TABLE IF NOT EXISTS player_heroes (
account_id INTEGER NOT NULL REFERENCES users(account_id),
hero_id INTEGER NOT NULL,
hero_key TEXT,
hero_name_loc TEXT,
games INTEGER NOT NULL DEFAULT 0,
wins INTEGER NOT NULL DEFAULT 0,
winrate REAL,
last_played INTEGER,
updated_at TEXT NOT NULL,
PRIMARY KEY (account_id, hero_id)
);
CREATE TABLE IF NOT EXISTS player_matches (
account_id INTEGER NOT NULL REFERENCES users(account_id),
match_id INTEGER NOT NULL,
start_time INTEGER,
duration INTEGER,
won INTEGER,
hero_id INTEGER,
hero_key TEXT,
hero_name_loc TEXT,
kills INTEGER,
deaths INTEGER,
assists INTEGER,
kda REAL,
gpm INTEGER,
xpm INTEGER,
hero_damage INTEGER,
game_mode INTEGER,
lobby_type INTEGER,
r2_key TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (account_id, match_id)
);
CREATE INDEX IF NOT EXISTS idx_player_matches_start
ON player_matches(account_id, start_time DESC);
CREATE TABLE IF NOT EXISTS player_peers (
account_id INTEGER NOT NULL REFERENCES users(account_id),
peer_account_id INTEGER NOT NULL,
personaname TEXT,
avatar TEXT,
games INTEGER NOT NULL DEFAULT 0,
wins INTEGER NOT NULL DEFAULT 0,
winrate REAL,
updated_at TEXT NOT NULL,
PRIMARY KEY (account_id, peer_account_id)
);
CREATE TABLE IF NOT EXISTS sync_jobs (
id TEXT PRIMARY KEY,
account_id INTEGER NOT NULL,
kind TEXT NOT NULL, -- login_refresh | publish_match | backfill
match_id INTEGER,
status TEXT NOT NULL, -- queued | running | done | error
attempts INTEGER NOT NULL DEFAULT 0,
lease_until TEXT,
error TEXT,
next_retry_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sync_jobs_status
ON sync_jobs(status, next_retry_at);
+296
View File
@@ -0,0 +1,296 @@
export function utcNow() {
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
}
export async function upsertUser(db, user) {
const now = utcNow();
await db
.prepare(
`INSERT INTO users (account_id, steamid, personaname, avatar, public_share, created_at, last_login_at)
VALUES (?, ?, ?, ?, COALESCE(?, 0), ?, ?)
ON CONFLICT(account_id) DO UPDATE SET
steamid=excluded.steamid,
personaname=COALESCE(excluded.personaname, users.personaname),
avatar=COALESCE(excluded.avatar, users.avatar),
public_share=COALESCE(excluded.public_share, users.public_share),
last_login_at=excluded.last_login_at`
)
.bind(
user.account_id,
String(user.steamid),
user.personaname || null,
user.avatar || null,
user.public_share == null ? null : user.public_share ? 1 : 0,
now,
now
)
.run();
}
export async function upsertProfile(db, accountId, profile) {
const now = utcNow();
const avail = profile.availability || {};
await db
.prepare(
`INSERT INTO player_profiles (
account_id, rank_tier, leaderboard_rank, availability_status, availability_note,
availability_complete, source, fetched_at, enriched_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET
rank_tier=excluded.rank_tier,
leaderboard_rank=excluded.leaderboard_rank,
availability_status=excluded.availability_status,
availability_note=excluded.availability_note,
availability_complete=excluded.availability_complete,
source=excluded.source,
fetched_at=excluded.fetched_at,
enriched_at=excluded.enriched_at,
updated_at=excluded.updated_at`
)
.bind(
accountId,
profile.rank_tier ?? null,
profile.leaderboard_rank ?? null,
avail.status || null,
avail.note || null,
avail.complete ? 1 : 0,
avail.source || "opendota",
avail.fetched_at || now,
profile.enriched_at || now,
now
)
.run();
}
export async function upsertStats(db, accountId, scope, stats) {
if (!stats) return;
const now = utcNow();
await db
.prepare(
`INSERT INTO player_stats (
account_id, scope, sample, wins, losses, winrate, kills, deaths, assists, kda,
avg_kills, avg_deaths, avg_assists, avg_gpm, avg_xpm, avg_hero_damage, payload_json, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(account_id, scope) DO UPDATE SET
sample=excluded.sample, wins=excluded.wins, losses=excluded.losses, winrate=excluded.winrate,
kills=excluded.kills, deaths=excluded.deaths, assists=excluded.assists, kda=excluded.kda,
avg_kills=excluded.avg_kills, avg_deaths=excluded.avg_deaths, avg_assists=excluded.avg_assists,
avg_gpm=excluded.avg_gpm, avg_xpm=excluded.avg_xpm, avg_hero_damage=excluded.avg_hero_damage,
payload_json=excluded.payload_json, updated_at=excluded.updated_at`
)
.bind(
accountId,
scope,
stats.sample ?? stats.games ?? 0,
stats.wins ?? 0,
stats.losses ?? 0,
stats.winrate ?? null,
stats.kills ?? null,
stats.deaths ?? null,
stats.assists ?? null,
stats.kda ?? null,
stats.avg_kills ?? null,
stats.avg_deaths ?? null,
stats.avg_assists ?? null,
stats.avg_gpm ?? null,
stats.avg_xpm ?? null,
stats.avg_hero_damage ?? null,
JSON.stringify(stats),
now
)
.run();
}
export async function replaceHeroes(db, accountId, heroes) {
const now = utcNow();
await db.prepare(`DELETE FROM player_heroes WHERE account_id = ?`).bind(accountId).run();
for (const h of heroes || []) {
await db
.prepare(
`INSERT INTO player_heroes (
account_id, hero_id, hero_key, hero_name_loc, games, wins, winrate, last_played, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.bind(
accountId,
h.hero_id,
h.hero_key || null,
h.hero_name_loc || null,
h.games || 0,
h.wins || 0,
h.winrate ?? null,
h.last_played ?? null,
now
)
.run();
}
}
export async function replacePeers(db, accountId, peers) {
const now = utcNow();
await db.prepare(`DELETE FROM player_peers WHERE account_id = ?`).bind(accountId).run();
for (const p of peers || []) {
await db
.prepare(
`INSERT INTO player_peers (
account_id, peer_account_id, personaname, avatar, games, wins, winrate, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
.bind(
accountId,
p.account_id,
p.personaname || null,
p.avatar || null,
p.games || 0,
p.wins || 0,
p.winrate ?? null,
now
)
.run();
}
}
export async function upsertMatches(db, accountId, recent) {
const now = utcNow();
for (const r of recent || []) {
await db
.prepare(
`INSERT INTO player_matches (
account_id, match_id, start_time, duration, won, hero_id, hero_key, hero_name_loc,
kills, deaths, assists, kda, gpm, xpm, hero_damage, game_mode, lobby_type, r2_key, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(account_id, match_id) DO UPDATE SET
start_time=excluded.start_time, duration=excluded.duration, won=excluded.won,
hero_id=excluded.hero_id, hero_key=excluded.hero_key, hero_name_loc=excluded.hero_name_loc,
kills=excluded.kills, deaths=excluded.deaths, assists=excluded.assists, kda=excluded.kda,
gpm=excluded.gpm, xpm=excluded.xpm, hero_damage=excluded.hero_damage,
game_mode=excluded.game_mode, lobby_type=excluded.lobby_type, updated_at=excluded.updated_at`
)
.bind(
accountId,
r.match_id,
r.start_time ?? null,
r.duration ?? null,
r.won ? 1 : 0,
r.hero_id ?? null,
r.hero_key || null,
r.hero_name_loc || null,
r.kills ?? null,
r.deaths ?? null,
r.assists ?? null,
r.kda ?? null,
r.gpm ?? null,
r.xpm ?? null,
r.hero_damage ?? null,
r.game_mode ?? null,
r.lobby_type ?? null,
r.r2_key || null,
now
)
.run();
}
}
export async function loadPlayerBundle(db, accountId) {
const user = await db
.prepare(`SELECT * FROM users WHERE account_id = ?`)
.bind(accountId)
.first();
if (!user) return null;
const profile = await db
.prepare(`SELECT * FROM player_profiles WHERE account_id = ?`)
.bind(accountId)
.first();
const statsRows = await db
.prepare(`SELECT * FROM player_stats WHERE account_id = ?`)
.bind(accountId)
.all();
const heroes = await db
.prepare(
`SELECT * FROM player_heroes WHERE account_id = ? ORDER BY games DESC LIMIT 8`
)
.bind(accountId)
.all();
const peers = await db
.prepare(
`SELECT * FROM player_peers WHERE account_id = ? ORDER BY games DESC LIMIT 8`
)
.bind(accountId)
.all();
const recent = await db
.prepare(
`SELECT * FROM player_matches WHERE account_id = ? ORDER BY start_time DESC LIMIT 20`
)
.bind(accountId)
.all();
const statsByScope = {};
for (const row of (statsRows && statsRows.results) || []) {
try {
statsByScope[row.scope] = row.payload_json
? JSON.parse(row.payload_json)
: row;
} catch {
statsByScope[row.scope] = row;
}
}
return {
account_id: accountId,
personaname: user.personaname,
avatar: user.avatar,
public_share: !!user.public_share,
rank_tier: profile && profile.rank_tier,
leaderboard_rank: profile && profile.leaderboard_rank,
availability: profile
? {
status: profile.availability_status,
note: profile.availability_note,
complete: !!profile.availability_complete,
source: profile.source,
fetched_at: profile.fetched_at,
stale: false,
}
: null,
career: statsByScope.career || null,
recent_20: statsByScope.recent20 || null,
activity_180: statsByScope.recent180 || null,
top_heroes: ((heroes && heroes.results) || []).map((h) => ({
hero_id: h.hero_id,
hero_key: h.hero_key,
hero_name_loc: h.hero_name_loc,
games: h.games,
wins: h.wins,
winrate: h.winrate,
last_played: h.last_played,
})),
peers: ((peers && peers.results) || []).map((p) => ({
account_id: p.peer_account_id,
personaname: p.personaname,
avatar: p.avatar,
games: p.games,
wins: p.wins,
winrate: p.winrate,
})),
recent: ((recent && recent.results) || []).map((r) => ({
match_id: r.match_id,
start_time: r.start_time,
duration: r.duration,
won: !!r.won,
hero_id: r.hero_id,
hero_key: r.hero_key,
hero_name_loc: r.hero_name_loc,
kills: r.kills,
deaths: r.deaths,
assists: r.assists,
kda: r.kda,
gpm: r.gpm,
xpm: r.xpm,
hero_damage: r.hero_damage,
game_mode: r.game_mode,
lobby_type: r.lobby_type,
})),
updated_at: (profile && profile.updated_at) || user.last_login_at,
enriched_at: profile && profile.enriched_at,
};
}
+326
View File
@@ -0,0 +1,326 @@
/**
* Queue consumer: refresh player stats from OpenDota into D1 (+ optional R2 match detail).
*
* Message shapes:
* { kind: "login_refresh"|"backfill", account_id, steamid?, personaname?, avatar? }
* { kind: "publish_match", account_id, match_id }
*/
import {
loadPlayerBundle,
replaceHeroes,
replacePeers,
upsertMatches,
upsertProfile,
upsertStats,
upsertUser,
utcNow,
} from "./db.js";
import {
loadHeroMap,
odFetch,
steamMatchHistoryStatus,
summaryFromRecentRow,
} from "./opendota.js";
import {
aggregateFromRows,
careerFromOpenDota,
mergeAvailability,
winrate,
} from "./stats.js";
function winrateGames(wins, games) {
return winrate(wins, Math.max(0, games - wins));
}
async function syncAccount(env, msg) {
const accountId = Number(msg.account_id);
if (!Number.isFinite(accountId) || accountId <= 0) {
throw new Error("bad account_id");
}
const steamid =
msg.steamid || String(BigInt(accountId) + 76561197960265728n);
await upsertUser(env.DB, {
account_id: accountId,
steamid,
personaname: msg.personaname || null,
avatar: msg.avatar || null,
public_share: msg.public_share,
});
const heroMap = await loadHeroMap(env);
// Serial OpenDota calls — CF edge IPs hit 429 hard under Promise.all.
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const player = await odFetch(`/players/${accountId}`, env);
await sleep(200);
const wl = await odFetch(`/players/${accountId}/wl`, env);
await sleep(200);
const totals = await odFetch(`/players/${accountId}/totals`, env);
await sleep(200);
const heroes = await odFetch(`/players/${accountId}/heroes`, env);
await sleep(200);
const peers = await odFetch(`/players/${accountId}/peers`, env);
await sleep(200);
const recentRaw = await odFetch(`/players/${accountId}/recentMatches`, env);
await sleep(200);
const matches180 = await odFetch(`/players/${accountId}/matches`, env, {
date: 180,
significant: 0,
});
const steamStatus = await steamMatchHistoryStatus(accountId, env);
const profileBlock = player && player.profile ? player.profile : {};
const personaname = profileBlock.personaname || msg.personaname || null;
const avatar =
profileBlock.avatarfull ||
profileBlock.avatarmedium ||
profileBlock.avatar ||
msg.avatar ||
null;
await upsertUser(env.DB, {
account_id: accountId,
steamid,
personaname,
avatar,
});
const recent = [];
if (Array.isArray(recentRaw)) {
for (const row of recentRaw) {
const summary = summaryFromRecentRow(row, heroMap);
if (summary) recent.push(summary);
}
}
recent.sort((a, b) => (b.start_time || 0) - (a.start_time || 0));
const recent20 = recent.slice(0, 20);
let career = careerFromOpenDota(wl, totals);
// Do not wipe previous career on empty OpenDota response.
if (!career) {
const existing = await loadPlayerBundle(env.DB, accountId);
if (existing && existing.career && existing.career.games > 0) {
career = existing.career;
}
}
const topHeroes = [];
if (Array.isArray(heroes)) {
const scored = heroes
.filter((h) => h && Number(h.games) > 0)
.sort((a, b) => Number(b.games) - Number(a.games))
.slice(0, 5);
for (const h of scored) {
const hid = Number(h.hero_id) || 0;
const meta = heroMap.get(hid) || {};
const games = Number(h.games) || 0;
const wins = Number(h.win) || 0;
topHeroes.push({
hero_id: hid || null,
hero_key: meta.key || null,
hero_name_loc: meta.name_loc || meta.key || null,
games,
wins,
winrate: winrateGames(wins, games),
last_played: h.last_played != null ? Number(h.last_played) : null,
});
}
}
const peerRows = [];
if (Array.isArray(peers)) {
for (const p of peers.slice(0, 8)) {
if (!p || !p.account_id) continue;
const games = Number(p.games) || 0;
if (games <= 0) continue;
const wins = Number(p.win) || 0;
peerRows.push({
account_id: Number(p.account_id),
personaname: p.personaname || `玩家 ${p.account_id}`,
avatar: p.avatarfull || p.avatar || null,
games,
wins,
winrate: winrateGames(wins, games),
});
}
}
let activity180 = null;
if (Array.isArray(matches180) && matches180.length) {
const byDay = new Map();
let wins = 0;
let losses = 0;
let maxKills = null;
let maxAssists = null;
let maxGpm = null;
for (const row of matches180) {
if (!row || row.start_time == null) continue;
const st = Number(row.start_time);
const day = new Date(st * 1000).toISOString().slice(0, 10);
const cell = byDay.get(day) || { games: 0, wins: 0 };
cell.games += 1;
const slot = Number(row.player_slot) || 0;
const won = slot < 128 ? !!row.radiant_win : !row.radiant_win;
if (won) {
cell.wins += 1;
wins += 1;
} else losses += 1;
byDay.set(day, cell);
const kills = Number(row.kills) || 0;
const assists = Number(row.assists) || 0;
const gpm = Number(row.gold_per_min) || 0;
const heroId = Number(row.hero_id) || null;
const mid = Number(row.match_id) || 0;
if (!maxKills || kills > maxKills.value) {
maxKills = { value: kills, hero_id: heroId, match_id: mid };
}
if (!maxAssists || assists > maxAssists.value) {
maxAssists = { value: assists, hero_id: heroId, match_id: mid };
}
if (gpm > 0 && (!maxGpm || gpm > maxGpm.value)) {
maxGpm = { value: gpm, hero_id: heroId, match_id: mid };
}
}
activity180 = {
days: 180,
sample: wins + losses,
wins,
losses,
winrate: winrate(wins, losses),
heatmap: [...byDay.entries()]
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
.map(([date, v]) => ({ date, games: v.games, wins: v.wins })),
highs: { kills: maxKills, assists: maxAssists, gpm: maxGpm },
label: "最近 180 天样本",
};
}
const fetched = utcNow();
const availability = mergeAvailability({
opendotaRecentN: Array.isArray(recentRaw) ? recentRaw.length : 0,
career,
steamHistoryStatus: steamStatus,
fetchedAt: fetched,
});
if (profileBlock.fh_unavailable && availability.status === "unknown") {
availability.status = "private";
availability.note = "未公开比赛数据";
}
// Steam public but OpenDota empty → almost always rate-limit; retry queue.
if (
availability.status === "syncing" &&
(!Array.isArray(recentRaw) || recentRaw.length === 0) &&
!career
) {
throw new Error("OpenDota empty while Steam public — retry");
}
const profile = {
rank_tier: player && player.rank_tier != null ? Number(player.rank_tier) : null,
leaderboard_rank:
player && player.leaderboard_rank != null
? Number(player.leaderboard_rank)
: null,
availability,
enriched_at: fetched,
};
await upsertProfile(env.DB, accountId, profile);
if (career) await upsertStats(env.DB, accountId, "career", career);
await upsertStats(env.DB, accountId, "recent20", aggregateFromRows(recent20, 20));
if (activity180) await upsertStats(env.DB, accountId, "recent180", activity180);
await replaceHeroes(env.DB, accountId, topHeroes);
await replacePeers(env.DB, accountId, peerRows);
await upsertMatches(env.DB, accountId, recent20);
// Optional: store a published match detail into R2 (deduped by match_id).
if (msg.kind === "publish_match" && msg.match_id && env.MATCHES) {
const matchId = Number(msg.match_id);
const match = await odFetch(`/matches/${matchId}`, env);
if (match && Array.isArray(match.players)) {
const key = `matches/${matchId}.json`;
await env.MATCHES.put(key, JSON.stringify(match), {
httpMetadata: { contentType: "application/json; charset=utf-8" },
});
await env.DB.prepare(
`UPDATE player_matches SET r2_key = ?, updated_at = ? WHERE account_id = ? AND match_id = ?`
)
.bind(key, utcNow(), accountId, matchId)
.run();
}
}
return loadPlayerBundle(env.DB, accountId);
}
async function markJob(env, jobId, patch) {
if (!jobId) return;
const now = utcNow();
await env.DB.prepare(
`UPDATE sync_jobs SET status = ?, attempts = COALESCE(attempts, 0) + ?,
error = ?, lease_until = ?, updated_at = ?
WHERE id = ?`
)
.bind(
patch.status,
patch.bumpAttempts ? 1 : 0,
patch.error || null,
patch.lease_until || null,
now,
jobId
)
.run();
}
export default {
async queue(batch, env) {
for (const message of batch.messages) {
let body = message.body;
if (typeof body === "string") {
try {
body = JSON.parse(body);
} catch {
message.ack();
continue;
}
}
const jobId = body && body.job_id;
try {
if (jobId) {
await markJob(env, jobId, {
status: "running",
lease_until: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
});
}
await syncAccount(env, body || {});
if (jobId) await markJob(env, jobId, { status: "done" });
message.ack();
} catch (e) {
const err = String((e && e.message) || e);
if (jobId) {
await markJob(env, jobId, {
status: "error",
bumpAttempts: true,
error: err.slice(0, 500),
});
}
message.retry();
}
}
},
// Manual HTTP trigger for smoke tests (requires SYNC_HTTP_TOKEN secret).
async fetch(request, env) {
if (request.method !== "POST") {
return new Response("climperor-player-sync", { status: 200 });
}
const token = (env.SYNC_HTTP_TOKEN || "").trim();
const auth = (request.headers.get("Authorization") || "").trim();
if (!token || auth !== `Bearer ${token}`) {
return new Response("unauthorized", { status: 401 });
}
const body = await request.json().catch(() => ({}));
const out = await syncAccount(env, body);
return new Response(JSON.stringify(out), {
headers: { "Content-Type": "application/json" },
});
},
};
@@ -0,0 +1,93 @@
const OPENDOTA = "https://api.opendota.com/api";
const STEAM_API = "https://api.steampowered.com";
export async function odFetch(path, env, query = {}) {
const url = new URL(`${OPENDOTA}${path}`);
for (const [k, v] of Object.entries(query)) {
if (v != null) url.searchParams.set(k, String(v));
}
const key = (env.OPENDOTA_API_KEY || "").trim();
if (key) url.searchParams.set("api_key", key);
const headers = {
Accept: "application/json",
"User-Agent": "climperor-player-sync",
};
// CF edge IPs are often rate-limited; retry 429/5xx before giving up.
let lastStatus = 0;
for (let attempt = 0; attempt < 4; attempt++) {
if (attempt > 0) {
await new Promise((r) => setTimeout(r, 400 * 2 ** (attempt - 1)));
}
const res = await fetch(url.toString(), { headers });
lastStatus = res.status;
if (res.status === 403 || res.status === 404) return null;
if (res.status === 429 || res.status >= 500) continue;
if (!res.ok) throw new Error(`OpenDota ${res.status} ${path}`);
return res.json();
}
if (lastStatus === 429 || lastStatus >= 500) return null;
throw new Error(`OpenDota ${lastStatus} ${path}`);
}
export async function steamMatchHistoryStatus(accountId, env) {
const key = (env.STEAM_API_KEY || "").trim();
if (!key) return null;
const url = new URL(`${STEAM_API}/IDOTA2Match_570/GetMatchHistory/v1/`);
url.searchParams.set("key", key);
url.searchParams.set("account_id", String(accountId));
url.searchParams.set("matches_requested", "1");
try {
const res = await fetch(url.toString(), {
headers: { "User-Agent": "climperor-player-sync" },
});
if (!res.ok) return null;
const data = await res.json();
const status = data && data.result && data.result.status;
return status == null ? null : Number(status);
} catch {
return null;
}
}
export function summaryFromRecentRow(row, heroMap) {
const mid = Number(row.match_id) || 0;
if (mid <= 0) return null;
const heroId = Number(row.hero_id) || 0;
const hero = heroMap.get(heroId) || {};
const kills = Number(row.kills) || 0;
const deaths = Number(row.deaths) || 0;
const assists = Number(row.assists) || 0;
const playerSlot = Number(row.player_slot) || 0;
const radiantWin = !!row.radiant_win;
const isRadiant = playerSlot < 128;
return {
match_id: mid,
start_time: row.start_time != null ? Number(row.start_time) : null,
duration: Number(row.duration) || 0,
won: isRadiant ? radiantWin : !radiantWin,
hero_id: heroId || null,
hero_key: hero.key || null,
hero_name_loc: hero.name_loc || hero.key || null,
kills,
deaths,
assists,
kda: Math.round(((kills + assists) / Math.max(deaths, 1)) * 10) / 10,
gpm: row.gold_per_min != null ? Number(row.gold_per_min) || 0 : null,
xpm: row.xp_per_min != null ? Number(row.xp_per_min) || 0 : null,
hero_damage: row.hero_damage != null ? Number(row.hero_damage) || 0 : null,
game_mode: row.game_mode != null ? Number(row.game_mode) : null,
lobby_type: row.lobby_type != null ? Number(row.lobby_type) : null,
};
}
export async function loadHeroMap(env) {
const rows = await odFetch("/heroes", env);
const map = new Map();
if (!Array.isArray(rows)) return map;
for (const h of rows) {
if (!h || h.id == null) continue;
const key = String(h.name || "").replace(/^npc_dota_hero_/, "") || null;
map.set(Number(h.id), { key, name_loc: h.localized_name || key });
}
return map;
}
+143
View File
@@ -0,0 +1,143 @@
/** Shared aggregate helpers for the player-sync Worker (mirrors pc/player_stats.py). */
export function kda(kills, deaths, assists) {
return Math.round(((kills + assists) / Math.max(deaths, 1)) * 10) / 10;
}
export function winrate(wins, losses) {
const total = wins + losses;
if (total <= 0) return null;
return Math.round((wins / total) * 1000) / 10;
}
export function aggregateFromRows(rows, limit = 20) {
const sample = (Array.isArray(rows) ? rows : []).slice(0, limit);
let wins = 0;
let losses = 0;
let kills = 0;
let deaths = 0;
let assists = 0;
let gpmSum = 0;
let xpmSum = 0;
let dmgSum = 0;
let gpmN = 0;
let xpmN = 0;
let dmgN = 0;
const heroes = [];
for (const r of sample) {
if (!r || typeof r !== "object") continue;
if (r.won) wins += 1;
else losses += 1;
const k = Number(r.kills) || 0;
const d = Number(r.deaths) || 0;
const a = Number(r.assists) || 0;
kills += k;
deaths += d;
assists += a;
if (r.gpm != null) {
gpmSum += Number(r.gpm) || 0;
gpmN += 1;
}
if (r.xpm != null) {
xpmSum += Number(r.xpm) || 0;
xpmN += 1;
}
if (r.hero_damage != null) {
dmgSum += Number(r.hero_damage) || 0;
dmgN += 1;
}
heroes.push({
match_id: Number(r.match_id) || 0,
hero_id: r.hero_id ?? null,
hero_key: r.hero_key || null,
hero_name_loc: r.hero_name_loc || null,
won: !!r.won,
});
}
const n = wins + losses;
return {
sample: n,
wins,
losses,
winrate: winrate(wins, losses),
kills,
deaths,
assists,
kda: n ? kda(kills, deaths, assists) : null,
avg_kills: n ? Math.round((kills / n) * 10) / 10 : null,
avg_deaths: n ? Math.round((deaths / n) * 10) / 10 : null,
avg_assists: n ? Math.round((assists / n) * 10) / 10 : null,
avg_gpm: gpmN ? Math.round(gpmSum / gpmN) : null,
avg_xpm: xpmN ? Math.round(xpmSum / xpmN) : null,
avg_hero_damage: dmgN ? Math.round(dmgSum / dmgN) : null,
heroes,
};
}
export function careerFromOpenDota(wl, totals) {
const wins = Number(wl && wl.win) || 0;
const losses = Number(wl && wl.lose) || 0;
if (wins <= 0 && losses <= 0) return null;
const byField = new Map();
if (Array.isArray(totals)) {
for (const row of totals) {
if (row && row.field) byField.set(String(row.field), row);
}
}
const sumOf = (f) => Number((byField.get(f) || {}).sum) || 0;
const nOf = (f) => Number((byField.get(f) || {}).n) || 0;
const n = wins + losses;
const kills = sumOf("kills");
const deaths = sumOf("deaths");
const assists = sumOf("assists");
const gpmN = nOf("gold_per_min");
const xpmN = nOf("xp_per_min");
const dmgN = nOf("hero_damage");
return {
games: n,
wins,
losses,
winrate: winrate(wins, losses),
kills,
deaths,
assists,
kda: n ? kda(kills, deaths, assists) : null,
avg_kills: n ? Math.round((kills / n) * 10) / 10 : null,
avg_deaths: n ? Math.round((deaths / n) * 10) / 10 : null,
avg_assists: n ? Math.round((assists / n) * 10) / 10 : null,
avg_gpm: gpmN ? Math.round(sumOf("gold_per_min") / gpmN) : null,
avg_xpm: xpmN ? Math.round(sumOf("xp_per_min") / xpmN) : null,
avg_hero_damage: dmgN ? Math.round(sumOf("hero_damage") / dmgN) : null,
source: "opendota",
};
}
export function mergeAvailability({ opendotaRecentN, career, steamHistoryStatus, fetchedAt }) {
const odPublic = opendotaRecentN > 0 || !!(career && career.games);
const steamAllowed = steamHistoryStatus === 1;
const steamDenied = steamHistoryStatus === 15;
let status = "unknown";
let complete = false;
let note = "暂无公开战绩";
if (odPublic) {
status = "public";
complete = true;
note = null;
} else if (steamAllowed) {
status = "syncing";
note = "Steam 已公开,OpenDota 同步中";
} else if (steamDenied) {
status = "private";
note = "未公开比赛数据";
}
return {
status,
complete,
opendota_public: odPublic,
steam_history_status: steamHistoryStatus ?? null,
source: "opendota+steam",
fetched_at: fetchedAt,
note,
stale: false,
};
}
+58
View File
@@ -0,0 +1,58 @@
/** Node smoke tests for Worker stats helpers (no wrangler). */
import assert from "node:assert/strict";
import {
aggregateFromRows,
careerFromOpenDota,
kda,
mergeAvailability,
winrate,
} from "./src/stats.js";
assert.equal(kda(10, 0, 5), 15);
assert.equal(winrate(0, 0), null);
assert.equal(winrate(1, 1), 50);
const emptyCareer = careerFromOpenDota({ win: 0, lose: 0 }, []);
assert.equal(emptyCareer, null);
const career = careerFromOpenDota(
{ win: 2, lose: 1 },
[
{ field: "kills", sum: 30, n: 3 },
{ field: "deaths", sum: 6, n: 3 },
{ field: "assists", sum: 15, n: 3 },
]
);
assert.equal(career.games, 3);
assert.equal(career.winrate, 66.7);
assert.ok(career.kda > 0);
const recent = aggregateFromRows(
[
{ match_id: 1, won: true, kills: 5, deaths: 1, assists: 3, gpm: 500 },
{ match_id: 1, won: true, kills: 5, deaths: 1, assists: 3, gpm: 500 }, // duplicate row ok in unit
{ match_id: 2, won: false, kills: 0, deaths: 0, assists: 2, gpm: 400 },
],
20
);
assert.equal(recent.sample, 3);
assert.equal(recent.wins, 2);
const syncing = mergeAvailability({
opendotaRecentN: 0,
career: null,
steamHistoryStatus: 1,
fetchedAt: "2026-07-31T00:00:00Z",
});
assert.equal(syncing.status, "syncing");
assert.match(syncing.note, /OpenDota/);
const priv = mergeAvailability({
opendotaRecentN: 0,
career: null,
steamHistoryStatus: 15,
fetchedAt: "2026-07-31T00:00:00Z",
});
assert.equal(priv.status, "private");
console.log("stats.js ok");
+27
View File
@@ -0,0 +1,27 @@
name = "climperor-player-sync"
main = "src/index.js"
compatibility_date = "2024-11-01"
workers_dev = false
[[d1_databases]]
binding = "DB"
database_name = "climperor-users"
database_id = "9eeb24ba-acc5-4520-b4e7-754ea776394e"
[[r2_buckets]]
binding = "MATCHES"
bucket_name = "climperor-player-data"
[[queues.consumers]]
queue = "climperor-player-sync"
max_batch_size = 5
max_retries = 5
dead_letter_queue = "climperor-player-sync-dlq"
[[queues.producers]]
binding = "SYNC_QUEUE"
queue = "climperor-player-sync"
# Secrets (wrangler secret put):
# STEAM_API_KEY
# OPENDOTA_API_KEY (optional)
+278
View File
@@ -0,0 +1,278 @@
"""Create Cloudflare D1 / R2 / Queues for Climperor player data and bind Pages.
Credentials: CLOUDFLARE_EMAIL + CLOUDFLARE_API_KEY (or keyzoo refining/cloudflare).
Writes web/cloudflare/.resources.json with ids (no secrets).
"""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
OUT = Path(__file__).resolve().parent / ".resources.json"
MIGRATION = Path(__file__).resolve().parent / "migrations" / "0001_init.sql"
PAGES_PROJECT = "climperor-relations"
D1_NAME = "climperor-users"
R2_NAME = "climperor-player-data"
QUEUE_NAME = "climperor-player-sync"
DLQ_NAME = "climperor-player-sync-dlq"
WORKER_NAME = "climperor-player-sync"
API = "https://api.cloudflare.com/client/v4"
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_EMAIL / CLOUDFLARE_API_KEY")
return email, key
def api(method: str, path: str, body: dict | None = None) -> dict:
email, key = creds()
data = None if body is None else json.dumps(body).encode("utf-8")
req = urllib.request.Request(
API + path,
data=data,
method=method,
headers={
"X-Auth-Email": email,
"X-Auth-Key": key,
"Content-Type": "application/json",
"User-Agent": "climperor-provision",
},
)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
payload = json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", errors="replace")
raise SystemExit(f"CF API {method} {path} -> {e.code}: {raw[:400]}") from e
if not payload.get("success"):
raise SystemExit(f"CF API failed: {payload.get('errors')}")
return payload
def account_id() -> str:
rows = api("GET", "/accounts")["result"]
if not rows:
raise SystemExit("no Cloudflare accounts")
return rows[0]["id"]
def ensure_d1(acct: str) -> str:
listed = api("GET", f"/accounts/{acct}/d1/database")["result"] or []
for row in listed:
if row.get("name") == D1_NAME:
print(f"d1 exists: {row['uuid']}")
return row["uuid"]
created = api(
"POST",
f"/accounts/{acct}/d1/database",
{"name": D1_NAME},
)["result"]
print(f"d1 created: {created['uuid']}")
return created["uuid"]
def run_migration(acct: str, db_id: str) -> None:
sql = MIGRATION.read_text(encoding="utf-8")
api(
"POST",
f"/accounts/{acct}/d1/database/{db_id}/query",
{"sql": sql},
)
print("d1 migration applied")
def ensure_r2(acct: str) -> bool:
"""Return True if bucket exists/created. False if R2 not enabled on account."""
try:
api("GET", f"/accounts/{acct}/r2/buckets/{R2_NAME}")
print(f"r2 exists: {R2_NAME}")
return True
except SystemExit:
pass
try:
api("POST", f"/accounts/{acct}/r2/buckets", {"name": R2_NAME})
print(f"r2 created: {R2_NAME}")
return True
except SystemExit as e:
msg = str(e)
if "already exists" in msg.lower() or "10004" in msg:
print(f"r2 exists: {R2_NAME}")
return True
if "10042" in msg or "enable R2" in msg:
print(
"r2 skipped: enable R2 in Cloudflare Dashboard "
"(https://dash.cloudflare.com/?to=/:account/r2), then re-run"
)
return False
raise
def ensure_queue(acct: str, name: str) -> str:
listed = api("GET", f"/accounts/{acct}/queues")["result"] or []
# API shape may be {result: [...]} or {result: {queues: [...]}}
rows = listed if isinstance(listed, list) else (listed.get("queues") or [])
for row in rows:
if row.get("queue_name") == name or row.get("name") == name:
qid = row.get("queue_id") or row.get("id")
print(f"queue exists: {name} ({qid})")
return qid
created = api("POST", f"/accounts/{acct}/queues", {"queue_name": name})["result"]
qid = created.get("queue_id") or created.get("id")
print(f"queue created: {name} ({qid})")
return qid
def patch_wrangler(d1_id: str) -> None:
path = Path(__file__).resolve().parent / "player-sync" / "wrangler.toml"
text = path.read_text(encoding="utf-8")
text = text.replace("REPLACE_D1_ID", d1_id)
path.write_text(text, encoding="utf-8")
print(f"updated {path}")
def write_resources(
acct: str,
d1_id: str,
queue_id: str | None,
dlq_id: str | None,
*,
r2_ok: bool,
) -> None:
payload = {
"account_id": acct,
"d1": {"name": D1_NAME, "id": d1_id},
"r2": {"name": R2_NAME, "ready": r2_ok},
"queue": {"name": QUEUE_NAME, "id": queue_id},
"dlq": {"name": DLQ_NAME, "id": dlq_id},
"worker": WORKER_NAME,
"pages_project": PAGES_PROJECT,
}
OUT.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
print(f"wrote {OUT}")
def ensure_queue_soft(acct: str, name: str) -> str | None:
try:
return ensure_queue(acct, name)
except SystemExit as e:
print(f"queue skipped ({name}): {e}")
return None
def _env_bindings(
base: dict,
*,
d1_id: str,
queue_id: str | None,
r2_ok: bool,
fail_open: bool | None,
) -> dict:
"""Build one environment's deployment_config with required bindings."""
out = {
k: v
for k, v in base.items()
if k
not in (
"d1_databases",
"queue_producers",
"r2_buckets",
"fail_open",
)
}
d1_bindings = dict(base.get("d1_databases") or {})
d1_bindings["DB"] = {"id": d1_id}
out["d1_databases"] = d1_bindings
if queue_id:
producers = dict(base.get("queue_producers") or {})
producers["SYNC_QUEUE"] = {"name": QUEUE_NAME}
out["queue_producers"] = producers
if r2_ok:
buckets = dict(base.get("r2_buckets") or {})
buckets["MATCHES"] = {"name": R2_NAME}
out["r2_buckets"] = buckets
# Cloudflare requires fail_open equal on production and preview.
if fail_open is not None:
out["fail_open"] = bool(fail_open)
return out
def bind_pages(
acct: str,
d1_id: str,
queue_id: str | None,
*,
r2_ok: bool = False,
) -> None:
"""Attach D1 (+ Queue / R2) to Pages production and preview bindings."""
path = f"/accounts/{acct}/pages/projects/{PAGES_PROJECT}"
try:
project = api("GET", path)["result"]
except SystemExit as e:
print(f"pages bind skipped (project missing?): {e}")
return
dc = project.get("deployment_configs") or {}
prod = dict(dc.get("production") or {})
preview = dict(dc.get("preview") or {})
fail_open = prod.get("fail_open")
if fail_open is None:
fail_open = preview.get("fail_open")
if fail_open is None:
fail_open = False
body = {
"deployment_configs": {
"production": _env_bindings(
prod,
d1_id=d1_id,
queue_id=queue_id,
r2_ok=r2_ok,
fail_open=fail_open,
),
"preview": _env_bindings(
preview,
d1_id=d1_id,
queue_id=queue_id,
r2_ok=r2_ok,
fail_open=fail_open,
),
}
}
try:
api("PATCH", path, body)
print(f"pages bindings updated on {PAGES_PROJECT} (prod+preview)")
except SystemExit as e:
print(f"pages bind soft-fail (set manually): {e}")
def main() -> int:
acct = account_id()
print(f"account_id: {acct}")
d1_id = ensure_d1(acct)
run_migration(acct, d1_id)
r2_ok = ensure_r2(acct)
queue_id = ensure_queue_soft(acct, QUEUE_NAME)
dlq_id = ensure_queue_soft(acct, DLQ_NAME)
patch_wrangler(d1_id)
bind_pages(acct, d1_id, queue_id, r2_ok=r2_ok)
write_resources(acct, d1_id, queue_id, dlq_id, r2_ok=r2_ok)
print(
"next: deploy worker with wrangler + bind D1/R2/Queue to Pages project "
f"{PAGES_PROJECT} (see web/cloudflare/README.md)"
)
return 0 if d1_id else 1
if __name__ == "__main__":
raise SystemExit(main())
+64
View File
@@ -0,0 +1,64 @@
"""Put STEAM_API_KEY on climperor-player-sync from staged temp or env."""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
WORKER = Path(__file__).resolve().parent / "player-sync"
STAGED = Path(__file__).resolve().parents[1] / ".refresh" / "steam_key.tmp"
def put_secret(name: str, value: str, env: dict) -> int:
print(f"putting secret {name}", flush=True)
proc = subprocess.run(
f"npx --yes wrangler@3 secret put {name}",
cwd=str(WORKER),
env=env,
shell=True,
input=value + "\n",
text=True,
capture_output=True,
)
if proc.returncode != 0:
print(proc.stderr or proc.stdout, file=sys.stderr)
return proc.returncode
def main() -> int:
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
"KEYZOO_ASSET_META_USERNAME"
)
cf_key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
)
steam = (
os.environ.get("STEAM_API_KEY")
or os.environ.get("KEYZOO_ASSET_SECRET_WEB_API_KEY")
or ""
).strip()
if not steam and STAGED.is_file():
steam = STAGED.read_text(encoding="utf-8").strip()
try:
STAGED.unlink()
except OSError:
pass
if not email or not cf_key:
print("missing Cloudflare credentials", file=sys.stderr)
return 2
if not steam:
print("missing STEAM_API_KEY (stage via _stage_steam_key.py first)", file=sys.stderr)
return 2
env = os.environ.copy()
env["CLOUDFLARE_EMAIL"] = email
env["CLOUDFLARE_API_KEY"] = cf_key
env["CLOUDFLARE_ACCOUNT_ID"] = "510534f7f6284344aadaf2f5a0794d48"
code = put_secret("STEAM_API_KEY", steam, env)
print("ok" if code == 0 else "failed", flush=True)
return code
if __name__ == "__main__":
raise SystemExit(main())
+37
View File
@@ -409,6 +409,42 @@ def bind_domain(
ensure_cname(email, api_key, domain, project)
def check_player_bindings(email: str, api_key: str, account_id: str, project: str) -> None:
"""Warn (do not abort) if D1/Queue bindings for player pages are missing."""
data = cf_api(
"GET",
f"/accounts/{account_id}/pages/projects/{project}",
email=email,
api_key=api_key,
)
if not data.get("success"):
print("warning: could not verify Pages player bindings")
return
prod = ((data.get("result") or {}).get("deployment_configs") or {}).get(
"production"
) or {}
d1 = prod.get("d1_databases") or {}
queues = prod.get("queue_producers") or {}
r2 = prod.get("r2_buckets") or {}
missing = []
if "DB" not in d1:
missing.append("D1:DB")
if "SYNC_QUEUE" not in queues:
missing.append("Queue:SYNC_QUEUE")
if missing:
print(
"warning: Pages missing player bindings "
f"{', '.join(missing)} — run python web/cloudflare/provision.py"
)
else:
print("pages player bindings: DB + SYNC_QUEUE ok")
if "MATCHES" not in r2:
print(
"note: R2 MATCHES not bound yet "
"(enable R2 in Dashboard, then re-run provision.py)"
)
def main() -> None:
ap = argparse.ArgumentParser(description="Deploy Climperor web site to Cloudflare Pages")
ap.add_argument("--no-export", action="store_true", help="skip re-export, deploy existing dist")
@@ -441,6 +477,7 @@ def main() -> None:
print(f"account_id: {account_id}")
ensure_project(email, api_key, account_id, args.project_name)
check_player_bindings(email, api_key, account_id, args.project_name)
if not args.no_export:
run_export(args.ability_video_base, args.static_asset_base)
+1 -1
View File
@@ -55,7 +55,7 @@ from shared.paths import (
from seo_prerender import DEFAULT_SITE_ORIGIN, write_seo_bundle
from serve_relations import WEB_DIR, build_payload
SITE_VERSION = "0.6.16"
SITE_VERSION = "0.6.54"
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
+910 -101
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,5 +1,5 @@
/* Local defaults; production export overwrites via export_relations_site.py. */
var SITE_VERSION = "0.6.16";
var SITE_VERSION = "0.6.54";
var SITE_ORIGIN = "";
var ABILITY_VIDEO_BASE = "";
var STATIC_ASSET_BASE = "";
@@ -0,0 +1,209 @@
/**
* Shared Steam OpenID + signed-session helpers for Pages Functions.
*
* Env: STEAM_API_KEY, SESSION_SECRET (required for login/me).
* Cookie: climperor_steam (HttpOnly, signed payload).
*/
const COOKIE_NAME = "climperor_steam";
const SESSION_DAYS = 30;
const STEAM_OPENID = "https://steamcommunity.com/openid/login";
const STEAM_ID_PREFIX = "https://steamcommunity.com/openid/id/";
export function jsonResponse(body, status = 200, extraHeaders = {}) {
return new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
...extraHeaders,
},
});
}
export function envOf(context) {
return (context && context.env) || {};
}
function b64urlEncode(bytes) {
let bin = "";
const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
for (let i = 0; i < arr.length; i++) bin += String.fromCharCode(arr[i]);
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function b64urlDecode(str) {
const pad = "=".repeat((4 - (str.length % 4)) % 4);
const b64 = (str + pad).replace(/-/g, "+").replace(/_/g, "/");
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
async function hmacSign(secret, message) {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(message));
return b64urlEncode(sig);
}
export function steamId64ToAccountId(steamId64) {
try {
const n = BigInt(String(steamId64));
const account = n - 76561197960265728n;
if (account <= 0n) return null;
return Number(account);
} catch {
return null;
}
}
export function parseSteamIdFromClaimedId(claimedId) {
if (!claimedId || typeof claimedId !== "string") return null;
if (!claimedId.startsWith(STEAM_ID_PREFIX)) return null;
const id = claimedId.slice(STEAM_ID_PREFIX.length).replace(/\/$/, "");
if (!/^\d{17}$/.test(id)) return null;
return id;
}
export async function createSessionToken(secret, payload) {
const body = {
...payload,
exp: Math.floor(Date.now() / 1000) + SESSION_DAYS * 86400,
};
const raw = b64urlEncode(new TextEncoder().encode(JSON.stringify(body)));
const sig = await hmacSign(secret, raw);
return `${raw}.${sig}`;
}
export async function verifySessionToken(secret, token) {
if (!secret || !token || typeof token !== "string") return null;
const parts = token.split(".");
if (parts.length !== 2) return null;
const [raw, sig] = parts;
const expect = await hmacSign(secret, raw);
if (sig.length !== expect.length) return null;
let ok = 0;
for (let i = 0; i < sig.length; i++) ok |= sig.charCodeAt(i) ^ expect.charCodeAt(i);
if (ok !== 0) return null;
try {
const json = new TextDecoder().decode(b64urlDecode(raw));
const data = JSON.parse(json);
if (!data || !data.exp || data.exp < Math.floor(Date.now() / 1000)) return null;
if (!data.steamid || !data.account_id) return null;
return data;
} catch {
return null;
}
}
export function readCookie(request, name = COOKIE_NAME) {
const header = request.headers.get("Cookie") || "";
const parts = header.split(";").map((s) => s.trim());
for (const p of parts) {
if (p.startsWith(name + "=")) {
return decodeURIComponent(p.slice(name.length + 1));
}
}
return null;
}
function sessionCookieHeader(token, { clear = false } = {}) {
if (clear) {
return `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
}
const maxAge = SESSION_DAYS * 86400;
return `${COOKIE_NAME}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`;
}
/** Add Secure on non-localhost. */
export function sessionCookieHeaderForRequest(request, token, { clear = false } = {}) {
let base = sessionCookieHeader(token, { clear });
const host = new URL(request.url).hostname;
if (host !== "127.0.0.1" && host !== "localhost") {
base = base.replace("SameSite=Lax", "Secure; SameSite=Lax");
}
return base;
}
export function steamLoginRedirectUrl(origin) {
const returnTo = `${origin}/api/auth/steam/callback`;
const params = new URLSearchParams({
"openid.ns": "http://specs.openid.net/auth/2.0",
"openid.mode": "checkid_setup",
"openid.return_to": returnTo,
"openid.realm": origin,
"openid.identity": "http://specs.openid.net/auth/2.0/identifier_select",
"openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select",
});
return `${STEAM_OPENID}?${params.toString()}`;
}
export async function verifySteamOpenId(query) {
const mode = query.get("openid.mode");
if (mode !== "id_res") return { ok: false, error: "bad mode" };
const claimed = query.get("openid.claimed_id");
const steamid = parseSteamIdFromClaimedId(claimed);
if (!steamid) return { ok: false, error: "bad claimed_id" };
const body = new URLSearchParams();
for (const [k, v] of query.entries()) {
if (k.startsWith("openid.")) body.set(k, v);
}
body.set("openid.mode", "check_authentication");
const res = await fetch(STEAM_OPENID, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "climperor-steam-auth",
},
body,
});
const text = await res.text();
if (!/is_valid\s*:\s*true/i.test(text)) {
return { ok: false, error: "openid invalid" };
}
return { ok: true, steamid };
}
export async function fetchSteamPersona(apiKey, steamid) {
if (!apiKey) return { personaname: null, avatar: null };
const url = new URL(
"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/"
);
url.searchParams.set("key", apiKey);
url.searchParams.set("steamids", steamid);
try {
const res = await fetch(url.toString(), {
headers: { "User-Agent": "climperor-steam-auth" },
});
if (!res.ok) return { personaname: null, avatar: null };
const data = await res.json();
const players = data && data.response && data.response.players;
const p = Array.isArray(players) && players[0] ? players[0] : null;
if (!p) return { personaname: null, avatar: null };
return {
personaname: p.personaname || null,
avatar: p.avatarfull || p.avatarmedium || p.avatar || null,
};
} catch {
return { personaname: null, avatar: null };
}
}
export async function sessionFromRequest(context) {
const env = envOf(context);
const secret = (env.SESSION_SECRET || "").trim();
if (!secret) return null;
const token = readCookie(context.request);
if (!token) return null;
return verifySessionToken(secret, token);
}
+34
View File
@@ -0,0 +1,34 @@
/**
* POST|GET /api/auth/logout clear session cookie.
*/
import { jsonResponse, sessionCookieHeaderForRequest } from "./_steam_common.js";
function clear(context) {
const origin = new URL(context.request.url).origin;
const wantsHtml = (context.request.headers.get("Accept") || "").includes("text/html");
if (wantsHtml || context.request.method === "GET") {
return new Response(null, {
status: 302,
headers: {
Location: `${origin}/`,
"Set-Cookie": sessionCookieHeaderForRequest(context.request, "", { clear: true }),
"Cache-Control": "no-store",
},
});
}
return jsonResponse(
{ ok: true },
200,
{
"Set-Cookie": sessionCookieHeaderForRequest(context.request, "", { clear: true }),
}
);
}
export async function onRequestGet(context) {
return clear(context);
}
export async function onRequestPost(context) {
return clear(context);
}
+22
View File
@@ -0,0 +1,22 @@
/**
* GET /api/auth/me current Steam session (or { authenticated: false }).
*/
import { jsonResponse, sessionFromRequest } from "./_steam_common.js";
export async function onRequestGet(context) {
try {
const session = await sessionFromRequest(context);
if (!session) {
return jsonResponse({ authenticated: false });
}
return jsonResponse({
authenticated: true,
steamid: session.steamid,
account_id: session.account_id,
personaname: session.personaname || null,
avatar: session.avatar || null,
});
} catch {
return jsonResponse({ authenticated: false });
}
}
+13
View File
@@ -0,0 +1,13 @@
/**
* GET /api/auth/steam redirect to Steam OpenID login.
*/
import { envOf, steamLoginRedirectUrl } from "./_steam_common.js";
export async function onRequestGet(context) {
const env = envOf(context);
if (!(env.SESSION_SECRET || "").trim() || !(env.STEAM_API_KEY || "").trim()) {
return new Response("Steam login not configured", { status: 503 });
}
const origin = new URL(context.request.url).origin;
return Response.redirect(steamLoginRedirectUrl(origin), 302);
}
@@ -0,0 +1,56 @@
/**
* GET /api/auth/steam/callback Steam OpenID return_to.
*/
import {
createSessionToken,
envOf,
fetchSteamPersona,
sessionCookieHeaderForRequest,
steamId64ToAccountId,
verifySteamOpenId,
} from "../_steam_common.js";
export async function onRequestGet(context) {
const env = envOf(context);
const secret = (env.SESSION_SECRET || "").trim();
const apiKey = (env.STEAM_API_KEY || "").trim();
const origin = new URL(context.request.url).origin;
if (!secret || !apiKey) {
return Response.redirect(`${origin}/?auth=unconfigured`, 302);
}
const url = new URL(context.request.url);
let verified;
try {
verified = await verifySteamOpenId(url.searchParams);
} catch {
return Response.redirect(`${origin}/?auth=error`, 302);
}
if (!verified.ok) {
return Response.redirect(`${origin}/?auth=denied`, 302);
}
const steamid = verified.steamid;
const accountId = steamId64ToAccountId(steamid);
if (!accountId) {
return Response.redirect(`${origin}/?auth=error`, 302);
}
const persona = await fetchSteamPersona(apiKey, steamid);
const token = await createSessionToken(secret, {
steamid,
account_id: accountId,
personaname: persona.personaname,
avatar: persona.avatar,
});
return new Response(null, {
status: 302,
headers: {
Location: `${origin}/home`,
"Set-Cookie": sessionCookieHeaderForRequest(context.request, token),
"Cache-Control": "no-store",
},
});
}
@@ -0,0 +1,29 @@
/**
* GET /api/players/:account_id public or self profile from D1.
*/
import { sessionFromRequest } from "../auth/_steam_common.js";
import { jsonResponse, loadPlayerBundle } from "./_db.js";
export async function onRequestGet(context) {
try {
const env = context.env || {};
if (!env.DB) return jsonResponse({ error: "database not configured" }, 503);
const accountId = Number(context.params && context.params.account_id);
if (!Number.isFinite(accountId) || accountId <= 0) {
return jsonResponse({ error: "bad account_id" }, 400);
}
const session = await sessionFromRequest(context);
const self = session && Number(session.account_id) === accountId;
const bundle = await loadPlayerBundle(env.DB, accountId);
if (!bundle) return jsonResponse({ error: "not found" }, 404);
if (!self && !bundle.public_share) {
return jsonResponse({ error: "private" }, 404);
}
return jsonResponse(bundle);
} catch (e) {
return jsonResponse(
{ error: "player get failed", detail: String((e && e.message) || e) },
500
);
}
}
@@ -0,0 +1,49 @@
/**
* GET /api/players/:account_id/:match_id match detail from R2 (authz via D1).
*/
import { sessionFromRequest } from "../../auth/_steam_common.js";
import { jsonResponse } from "../_db.js";
export async function onRequestGet(context) {
try {
const env = context.env || {};
if (!env.DB) return jsonResponse({ error: "database not configured" }, 503);
const accountId = Number(context.params && context.params.account_id);
const matchId = Number(context.params && context.params.match_id);
if (!Number.isFinite(accountId) || accountId <= 0 || !Number.isFinite(matchId)) {
return jsonResponse({ error: "bad ids" }, 400);
}
const session = await sessionFromRequest(context);
const self = session && Number(session.account_id) === accountId;
const user = await env.DB.prepare(
`SELECT public_share FROM users WHERE account_id = ?`
)
.bind(accountId)
.first();
if (!user) return jsonResponse({ error: "not found" }, 404);
if (!self && !user.public_share) return jsonResponse({ error: "private" }, 404);
const row = await env.DB.prepare(
`SELECT r2_key FROM player_matches WHERE account_id = ? AND match_id = ?`
)
.bind(accountId, matchId)
.first();
const key = (row && row.r2_key) || `matches/${matchId}.json`;
if (!env.MATCHES) return jsonResponse({ error: "storage not configured" }, 503);
const obj = await env.MATCHES.get(key);
if (!obj) return jsonResponse({ error: "match not found" }, 404);
const text = await obj.text();
return new Response(text, {
status: 200,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
},
});
} catch (e) {
return jsonResponse(
{ error: "match get failed", detail: String((e && e.message) || e) },
500
);
}
}
+151
View File
@@ -0,0 +1,151 @@
/** D1 helpers for Pages Functions (subset of cloudflare/player-sync/src/db.js). */
export function utcNow() {
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
}
export function jsonResponse(body, status = 200, extra = {}) {
return new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
...extra,
},
});
}
export async function loadPlayerBundle(db, accountId) {
if (!db) return null;
const user = await db
.prepare(`SELECT * FROM users WHERE account_id = ?`)
.bind(accountId)
.first();
if (!user) return null;
const profile = await db
.prepare(`SELECT * FROM player_profiles WHERE account_id = ?`)
.bind(accountId)
.first();
const statsRows = await db
.prepare(`SELECT * FROM player_stats WHERE account_id = ?`)
.bind(accountId)
.all();
const heroes = await db
.prepare(
`SELECT * FROM player_heroes WHERE account_id = ? ORDER BY games DESC LIMIT 8`
)
.bind(accountId)
.all();
const peers = await db
.prepare(
`SELECT * FROM player_peers WHERE account_id = ? ORDER BY games DESC LIMIT 8`
)
.bind(accountId)
.all();
const recent = await db
.prepare(
`SELECT * FROM player_matches WHERE account_id = ? ORDER BY start_time DESC LIMIT 20`
)
.bind(accountId)
.all();
const statsByScope = {};
for (const row of (statsRows && statsRows.results) || []) {
try {
statsByScope[row.scope] = row.payload_json
? JSON.parse(row.payload_json)
: row;
} catch {
statsByScope[row.scope] = row;
}
}
return {
account_id: accountId,
personaname: user.personaname,
avatar: user.avatar,
public_share: !!user.public_share,
rank_tier: profile && profile.rank_tier,
leaderboard_rank: profile && profile.leaderboard_rank,
availability: profile
? {
status: profile.availability_status,
note: profile.availability_note,
complete: !!profile.availability_complete,
source: profile.source,
fetched_at: profile.fetched_at,
stale: false,
}
: null,
career: statsByScope.career || null,
recent_20: statsByScope.recent20 || null,
activity_180: statsByScope.recent180 || null,
top_heroes: ((heroes && heroes.results) || []).map((h) => ({
hero_id: h.hero_id,
hero_key: h.hero_key,
hero_name_loc: h.hero_name_loc,
games: h.games,
wins: h.wins,
winrate: h.winrate,
last_played: h.last_played,
})),
peers: ((peers && peers.results) || []).map((p) => ({
account_id: p.peer_account_id,
personaname: p.personaname,
avatar: p.avatar,
games: p.games,
wins: p.wins,
winrate: p.winrate,
})),
recent: ((recent && recent.results) || []).map((r) => ({
match_id: r.match_id,
start_time: r.start_time,
duration: r.duration,
won: !!r.won,
hero_id: r.hero_id,
hero_key: r.hero_key,
hero_name_loc: r.hero_name_loc,
kills: r.kills,
deaths: r.deaths,
assists: r.assists,
kda: r.kda,
gpm: r.gpm,
xpm: r.xpm,
hero_damage: r.hero_damage,
game_mode: r.game_mode,
lobby_type: r.lobby_type,
})),
updated_at: (profile && profile.updated_at) || user.last_login_at,
enriched_at: profile && profile.enriched_at,
};
}
export function isStale(fetchedAt, maxAgeMs = 10 * 60 * 1000) {
if (!fetchedAt) return true;
const t = Date.parse(fetchedAt);
if (!Number.isFinite(t)) return true;
return Date.now() - t > maxAgeMs;
}
export async function enqueueRefresh(env, payload) {
if (!env.SYNC_QUEUE) return false;
const jobId = crypto.randomUUID();
const now = utcNow();
if (env.DB) {
await env.DB.prepare(
`INSERT INTO sync_jobs (id, account_id, kind, match_id, status, attempts, created_at, updated_at)
VALUES (?, ?, ?, ?, 'queued', 0, ?, ?)`
)
.bind(
jobId,
payload.account_id,
payload.kind || "login_refresh",
payload.match_id || null,
now,
now
)
.run();
}
await env.SYNC_QUEUE.send({ ...payload, job_id: jobId });
return true;
}
+92
View File
@@ -0,0 +1,92 @@
/**
* GET /api/players/me logged-in user's profile from D1; enqueue refresh if stale.
*/
import { sessionFromRequest } from "../auth/_steam_common.js";
import {
enqueueRefresh,
isStale,
jsonResponse,
loadPlayerBundle,
} from "./_db.js";
export async function onRequestGet(context) {
try {
const session = await sessionFromRequest(context);
if (!session || !session.account_id) {
return jsonResponse({ authenticated: false }, 401);
}
const env = context.env || {};
const accountId = Number(session.account_id);
if (!env.DB) {
return jsonResponse(
{ error: "database not configured", account_id: accountId },
503
);
}
// Ensure user row exists for first login.
const now = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
await env.DB.prepare(
`INSERT INTO users (account_id, steamid, personaname, avatar, public_share, created_at, last_login_at)
VALUES (?, ?, ?, ?, 0, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET
personaname=COALESCE(excluded.personaname, users.personaname),
avatar=COALESCE(excluded.avatar, users.avatar),
last_login_at=excluded.last_login_at`
)
.bind(
accountId,
String(session.steamid || ""),
session.personaname || null,
session.avatar || null,
now,
now
)
.run();
let bundle = await loadPlayerBundle(env.DB, accountId);
const fetchedAt =
(bundle && bundle.availability && bundle.availability.fetched_at) ||
(bundle && bundle.enriched_at) ||
null;
let stale = !bundle || isStale(fetchedAt);
if (stale) {
await enqueueRefresh(env, {
kind: "login_refresh",
account_id: accountId,
steamid: session.steamid,
personaname: session.personaname,
avatar: session.avatar,
});
}
if (!bundle) {
return jsonResponse({
authenticated: true,
account_id: accountId,
personaname: session.personaname || null,
avatar: session.avatar || null,
public_share: false,
recent: [],
career: null,
recent_20: null,
top_heroes: [],
peers: [],
activity_180: null,
availability: {
status: "unknown",
note: "正在同步…",
complete: false,
stale: true,
},
stale: true,
});
}
if (bundle.availability) bundle.availability.stale = stale;
return jsonResponse({ ...bundle, authenticated: true, stale });
} catch (e) {
return jsonResponse(
{ error: "me failed", detail: String((e && e.message) || e) },
500
);
}
}
+33 -348
View File
@@ -1,35 +1,12 @@
/**
* Pages Function: POST /api/players/publish
* POST /api/players/publish
*
* Body: { account_id, match_id }
* Optional header: X-Climperor-Publish-Secret (when PLAYER_PAGES_PUBLISH_SECRET set).
* Optional header: X-Climperor-Publish-Secret
*
* Fetches OpenDota match, verifies account_id is in the lobby, normalizes Max+-style
* JSON, merges profile.recent, PUTs to Aliyun OSS:
* players/{account_id}/profile.json
* players/{account_id}/matches/{match_id}.json
*
* Secrets (Pages env): OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET,
* optional OSS_BUCKET, OSS_ENDPOINT, PLAYER_PAGES_PUBLISH_SECRET, OPENDOTA_API_KEY.
*
* Soft-fail: match not ready 202; bad membership 403; never echo secrets.
* Enqueues Cloudflare Queue sync (D1 + R2). No longer writes OSS profile JSON.
*/
const OPENDOTA = "https://api.opendota.com/api";
const DEFAULT_BUCKET = "climperor";
const DEFAULT_ENDPOINT = "oss-cn-shanghai.aliyuncs.com";
const RECENT_LIMIT = 30;
function jsonResponse(body, status = 200, extraHeaders = {}) {
return new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
...extraHeaders,
},
});
}
import { enqueueRefresh, jsonResponse } from "./_db.js";
function envOf(context) {
return (context && context.env) || {};
@@ -40,278 +17,14 @@ function intField(v, fallback = 0) {
return Number.isFinite(n) ? Math.trunc(n) : fallback;
}
function kda(kills, deaths, assists) {
return Math.round(((kills + assists) / Math.max(deaths, 1)) * 10) / 10;
}
function mvpScore(p) {
const k = intField(p.kills);
const d = intField(p.deaths);
const a = intField(p.assists);
const dmg = intField(p.hero_damage);
const nw = intField(p.net_worth) || intField(p.gold) + intField(p.gold_spent);
return (k * 1.5 + a + dmg / 1000 + nw / 2000) / Math.max(d, 1);
}
function itemIds(player) {
const out = [];
for (let i = 0; i < 6; i++) {
const id = intField(player[`item_${i}`]);
if (id > 0) out.push(id);
}
return out;
}
function accountInMatch(match, accountId) {
const players = match.players || [];
for (const p of players) {
if (p && intField(p.account_id, -1) === accountId) return true;
}
return false;
}
function utcNow() {
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
}
async function fetchJson(url, { headers } = {}) {
const res = await fetch(url, {
headers: { Accept: "application/json", "User-Agent": "climperor-publish", ...(headers || {}) },
});
if (!res.ok) {
const err = new Error(`HTTP ${res.status}`);
err.status = res.status;
throw err;
}
return res.json();
}
async function loadHeroMap(opendotaKey) {
const q = opendotaKey ? `?api_key=${encodeURIComponent(opendotaKey)}` : "";
try {
const rows = await fetchJson(`${OPENDOTA}/heroes${q}`);
const map = new Map();
if (Array.isArray(rows)) {
for (const h of rows) {
if (!h || h.id == null) continue;
const key = String(h.name || "").replace(/^npc_dota_hero_/, "") || null;
map.set(intField(h.id), {
key,
name_loc: h.localized_name || key,
});
}
}
return map;
} catch {
return new Map();
}
}
function normalizeMatch(match, focusAccountId, heroMap) {
const playersRaw = match.players;
if (!Array.isArray(playersRaw) || !playersRaw.length) return null;
const matchId = intField(match.match_id);
if (matchId <= 0) return null;
const radiantWin = !!match.radiant_win;
const teamKills = [0, 0];
const teamNw = [0, 0];
const teamDmg = [0, 0];
const slim = [];
for (const p of playersRaw) {
if (!p || typeof p !== "object") continue;
const slot = intField(p.player_slot);
const isRadiant = slot < 128;
const side = isRadiant ? 0 : 1;
const kills = intField(p.kills);
const deaths = intField(p.deaths);
const assists = intField(p.assists);
const heroDamage = intField(p.hero_damage);
let netWorth = intField(p.net_worth);
if (netWorth <= 0) netWorth = intField(p.gold) + intField(p.gold_spent);
teamKills[side] += kills;
teamNw[side] += netWorth;
teamDmg[side] += heroDamage;
const heroId = intField(p.hero_id);
const hero = heroMap.get(heroId) || {};
let accountId = null;
if (p.account_id != null) {
const a = intField(p.account_id, -1);
accountId = a >= 0 ? a : null;
}
let personaname = typeof p.personaname === "string" ? p.personaname.trim() : null;
if (!personaname) personaname = null;
slim.push({
account_id: accountId,
personaname,
hero_id: heroId,
hero_key: hero.key || null,
hero_name_loc: hero.name_loc || hero.key || null,
level: intField(p.level),
kills,
deaths,
assists,
kda: kda(kills, deaths, assists),
hero_damage: heroDamage,
net_worth: netWorth,
items: itemIds(p),
is_radiant: isRadiant,
won: isRadiant ? radiantWin : !radiantWin,
_mvp: mvpScore(p),
_side: side,
});
}
if (slim.length < 2) return null;
for (const p of slim) {
const side = p._side;
const tk = teamKills[side] || 1;
const td = teamDmg[side] || 1;
p.participation = Math.round(((p.kills + p.assists) / tk) * 1000) / 1000;
p.damage_share = Math.round((p.hero_damage / td) * 1000) / 1000;
}
let mvp = slim[0];
for (const p of slim) {
if (p._mvp > mvp._mvp) mvp = p;
}
const mvpAccount = mvp.account_id;
for (const p of slim) {
p.is_mvp = mvpAccount != null && p.account_id === mvpAccount;
delete p._mvp;
delete p._side;
}
let startTime = null;
if (match.start_time != null) {
const t = intField(match.start_time, -1);
startTime = t >= 0 ? t : null;
}
return {
match_id: matchId,
start_time: startTime,
duration: intField(match.duration),
radiant_win: radiantWin,
radiant: { kills: teamKills[0], net_worth: teamNw[0] },
dire: { kills: teamKills[1], net_worth: teamNw[1] },
mvp_account_id: mvpAccount,
players: slim,
focus_account_id: focusAccountId,
fetched_at: utcNow(),
source: "opendota",
};
}
function summaryForProfile(detail, accountId) {
const focus = (detail.players || []).find((p) => p.account_id === accountId);
if (!focus) return null;
return {
match_id: detail.match_id,
start_time: detail.start_time,
duration: detail.duration,
won: !!focus.won,
hero_id: focus.hero_id,
hero_key: focus.hero_key,
hero_name_loc: focus.hero_name_loc,
kills: focus.kills,
deaths: focus.deaths,
assists: focus.assists,
kda: focus.kda,
};
}
async function ossGetJson(env, key) {
const bucket = env.OSS_BUCKET || DEFAULT_BUCKET;
const endpoint = env.OSS_ENDPOINT || DEFAULT_ENDPOINT;
const url = `https://${bucket}.${endpoint}/${key}`;
try {
const res = await fetch(url, { headers: { Accept: "application/json" } });
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
async function hmacSha1Base64(secret, stringToSign) {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-1" },
false,
["sign"]
);
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(stringToSign));
const bytes = new Uint8Array(sig);
let bin = "";
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
}
async function ossPutJson(env, key, obj) {
const accessKeyId = env.OSS_ACCESS_KEY_ID;
const accessKeySecret = env.OSS_ACCESS_KEY_SECRET;
if (!accessKeyId || !accessKeySecret) {
const err = new Error("OSS credentials missing");
err.status = 503;
throw err;
}
const bucket = env.OSS_BUCKET || DEFAULT_BUCKET;
const endpoint = env.OSS_ENDPOINT || DEFAULT_ENDPOINT;
const body = JSON.stringify(obj);
const contentType = "application/json; charset=utf-8";
const date = new Date().toUTCString();
const resource = `/${bucket}/${key}`;
// Rely on bucket/prefix public-read policy (no x-oss-object-acl; some buckets disallow ACL).
const stringToSign = `PUT\n\n${contentType}\n${date}\n${resource}`;
const signature = await hmacSha1Base64(accessKeySecret, stringToSign);
const url = `https://${bucket}.${endpoint}/${key}`;
const res = await fetch(url, {
method: "PUT",
headers: {
"Content-Type": contentType,
Date: date,
Authorization: `OSS ${accessKeyId}:${signature}`,
"Cache-Control": "public, max-age=60",
},
body,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
const err = new Error(`OSS PUT ${res.status}: ${text.slice(0, 200)}`);
err.status = 502;
throw err;
}
}
function mergeProfile(existing, accountId, summary, personaname) {
const profile =
existing && typeof existing === "object"
? { ...existing }
: { account_id: accountId, personaname: null, recent: [] };
let recent = Array.isArray(profile.recent) ? profile.recent.filter((r) => r && typeof r === "object") : [];
recent = recent.filter((r) => intField(r.match_id) !== summary.match_id);
recent.unshift(summary);
profile.recent = recent.slice(0, RECENT_LIMIT);
profile.account_id = accountId;
if (personaname) profile.personaname = personaname;
profile.public_share = true;
profile.updated_at = utcNow();
return profile;
}
export async function onRequestPost(context) {
try {
const env = envOf(context);
const expected = (env.PLAYER_PAGES_PUBLISH_SECRET || "").trim();
if (expected) {
const got = (context.request.headers.get("X-Climperor-Publish-Secret") || "").trim();
const got = (
context.request.headers.get("X-Climperor-Publish-Secret") || ""
).trim();
if (got !== expected) {
return jsonResponse({ error: "forbidden" }, 403);
}
@@ -330,70 +43,42 @@ export async function onRequestPost(context) {
return jsonResponse({ error: "account_id and match_id required" }, 400);
}
const odKey = (env.OPENDOTA_API_KEY || "").trim();
const q = odKey ? `?api_key=${encodeURIComponent(odKey)}` : "";
let match;
try {
match = await fetchJson(`${OPENDOTA}/matches/${matchId}${q}`);
} catch (e) {
if (e && e.status === 404) {
return jsonResponse(
{ ok: false, pending: true, message: "match not ready on OpenDota" },
202
);
}
return jsonResponse({ error: "opendota fetch failed", detail: String(e.message || e) }, 502);
if (!env.SYNC_QUEUE) {
return jsonResponse({ error: "sync queue not configured" }, 503);
}
if (!match || !Array.isArray(match.players) || !match.players.length) {
return jsonResponse(
{ ok: false, pending: true, message: "match incomplete on OpenDota" },
202
);
// Mark public_share when PC publishes intentionally.
if (env.DB) {
const now = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
const steamid = String(BigInt(accountId) + 76561197960265728n);
await env.DB.prepare(
`INSERT INTO users (account_id, steamid, personaname, avatar, public_share, created_at, last_login_at)
VALUES (?, ?, NULL, NULL, 1, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET public_share = 1, last_login_at = excluded.last_login_at`
)
.bind(accountId, steamid, now, now)
.run();
}
if (!accountInMatch(match, accountId)) {
return jsonResponse({ error: "account not in match" }, 403);
}
const heroMap = await loadHeroMap(odKey);
const detail = normalizeMatch(match, accountId, heroMap);
if (!detail) {
return jsonResponse({ error: "normalize failed" }, 500);
}
const summary = summaryForProfile(detail, accountId);
if (!summary) {
return jsonResponse({ error: "focus player missing" }, 500);
}
let personaname = null;
for (const p of detail.players) {
if (p.account_id === accountId && p.personaname) {
personaname = p.personaname;
break;
}
}
const profileKey = `players/${accountId}/profile.json`;
const matchKey = `players/${accountId}/matches/${matchId}.json`;
const existing = await ossGetJson(env, profileKey);
const profile = mergeProfile(existing, accountId, summary, personaname);
await ossPutJson(env, matchKey, detail);
await ossPutJson(env, profileKey, profile);
return jsonResponse({
ok: true,
const queued = await enqueueRefresh(env, {
kind: "publish_match",
account_id: accountId,
match_id: matchId,
public_share: true,
});
if (!queued) {
return jsonResponse({ error: "enqueue failed" }, 503);
}
return jsonResponse({
ok: true,
queued: true,
account_id: accountId,
match_id: matchId,
profile_key: profileKey,
match_key: matchKey,
});
} catch (e) {
const status = (e && e.status) || 500;
return jsonResponse(
{ error: "publish failed", detail: String((e && e.message) || e) },
status >= 400 && status < 600 ? status : 500
500
);
}
}
+14 -5
View File
@@ -43,8 +43,8 @@
}
</script>
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
<link rel="stylesheet" href="/style.css?v=0.6.16" />
<script src="/mobile-gate.js?v=0.6.16"></script>
<link rel="stylesheet" href="/style.css?v=0.6.54" />
<script src="/mobile-gate.js?v=0.6.54"></script>
</head>
<body>
<h1 class="sr-only">DOTA2 上分帝</h1>
@@ -87,6 +87,7 @@
<span class="brand-title">上分帝</span>
</div>
<nav class="main-tabs" aria-label="主分页">
<button type="button" class="main-tab hidden" data-page="home" id="tab-home"></button>
<button type="button" class="main-tab active" data-page="heroes">英雄</button>
<button type="button" class="main-tab" data-page="rankings">排行</button>
<button type="button" class="main-tab" data-page="streamers">主播</button>
@@ -99,6 +100,14 @@
<div class="topbar-tools">
<input id="q" class="search" type="search" placeholder="搜索英雄或别名" autocomplete="off" spellcheck="false" aria-label="搜索英雄" />
<input id="q-item" class="search search-inline hidden" type="search" placeholder="搜索物品" autocomplete="off" spellcheck="false" aria-label="搜索物品" />
<div class="auth-box" id="auth-box">
<a class="steam-login-btn" id="steam-login-btn" href="/api/auth/steam">Steam 登录</a>
<div class="auth-user hidden" id="auth-user">
<img class="auth-avatar" id="auth-avatar" alt="" width="28" height="28" />
<span class="auth-name" id="auth-name"></span>
<button type="button" class="auth-logout" id="auth-logout" title="退出登录">退出</button>
</div>
</div>
<a
class="contact-mail"
href="mailto:c9mhs8vfmuyv@outlook.com"
@@ -247,8 +256,8 @@
</div>
<footer class="heroes-site-foot" id="heroes-site-foot" aria-hidden="true"></footer>
<script src="/config.js?v=0.6.16"></script>
<script src="/router.js?v=0.6.16"></script>
<script src="/app.js?v=0.6.16"></script>
<script src="/config.js?v=0.6.54"></script>
<script src="/router.js?v=0.6.54"></script>
<script src="/app.js?v=0.6.54"></script>
</body>
</html>
+13 -1
View File
@@ -4,7 +4,7 @@
* Path-based router for the Climperor web site (web/frontend).
*
* Synchronizes the browser URL with app state across these dimensions:
* - page: heroes | rankings | matches | streamers | trends | mechanics | items | patches | players
* - page: home | heroes | rankings | matches | streamers | trends | mechanics | items | patches | players
* - hero: selected hero key + detail sub-tab
* (skills|core|fears|trends|matchups|matches|streamers|patches; legacy stats trends)
* - rankings: Immortal leaderboard region
@@ -12,6 +12,8 @@
* - matches: star-player recent matches (pro_matches)
* /matches[/account_id][?origin=pro|china][&page=N]
* (default origin all omitted; page=1 omitted)
* - home: logged-in Steam self homepage (players view + auth.account_id)
* /home[/{match_id}]
* - players: PC post-match player home + match detail (local/OSS JSON)
* /players/{account_id}[/{match_id}]
* - streamers: curated streamer directory
@@ -41,6 +43,7 @@
const ROUTE_DEFAULT = "/heroes";
const VALID_PAGES = [
"home",
"heroes",
"rankings",
"matches",
@@ -161,6 +164,11 @@ function parseHash(hashOrPath) {
if (segs[1] && /^\d+$/.test(segs[1])) {
out.matchesPlayerId = segs[1];
}
} else if (page === "home") {
// Logged-in self homepage; optional match detail: /home/{match_id}
if (segs[1] && /^\d+$/.test(segs[1])) {
out.playerMatchId = segs[1];
}
} else if (page === "players") {
if (segs[1] && /^\d+$/.test(segs[1])) {
out.playerAccountId = segs[1];
@@ -240,6 +248,10 @@ function serializeHash(state) {
if (state.matchesPlayerId) {
path += "/" + encodeURIComponent(String(state.matchesPlayerId));
}
} else if (state.page === "home") {
if (state.playerMatchId) {
path += "/" + encodeURIComponent(String(state.playerMatchId));
}
} else if (state.page === "players") {
if (state.playerAccountId) {
path += "/" + encodeURIComponent(String(state.playerAccountId));
+656 -61
View File
@@ -65,6 +65,8 @@
/* Matches list + right filter aside (centered column must leave aside room). */
--content-data: 1200px;
--aside-gap: 20px;
/* Patches "AI 解读": grows with right gutter up to this cap. */
--patches-aside-max: 420px;
--focus-ring: 2px solid var(--sel);
}
@@ -106,6 +108,8 @@ body {
.tagbar button:focus-visible,
.search:focus-visible,
.rankings-region-btn:focus-visible,
.steam-login-btn:focus-visible,
.auth-logout:focus-visible,
.contact-mail:focus-visible,
.mobile-demand-btn:focus-visible,
#patch-select:focus-visible {
@@ -232,6 +236,79 @@ body {
width: 44px;
height: 44px;
}
.auth-box {
display: flex;
align-items: center;
gap: var(--space-xs);
flex: 0 0 auto;
min-height: 44px;
}
.steam-login-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 36px;
padding: 0 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
font: inherit;
font-size: var(--fs-body-sm);
line-height: var(--lh-body);
letter-spacing: 0.04em;
text-decoration: none;
white-space: nowrap;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.steam-login-btn:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.08);
border-color: rgba(140, 170, 210, 0.35);
}
.auth-user {
display: flex;
align-items: center;
gap: var(--space-xs);
min-width: 0;
}
.auth-avatar {
width: 28px;
height: 28px;
border-radius: var(--radius-full);
object-fit: cover;
flex: 0 0 auto;
background: rgba(255, 255, 255, 0.06);
}
.auth-name {
max-width: 9em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
font-size: var(--fs-body-sm);
line-height: var(--lh-body);
}
.auth-logout {
min-height: 32px;
padding: 0 8px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--muted);
font: inherit;
font-size: var(--fs-label-sm);
letter-spacing: 0.04em;
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.auth-logout:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.06);
}
.auth-home-gate .steam-login-btn {
margin-top: var(--space-sm);
}
.contact-mail {
display: flex;
align-items: center;
@@ -278,7 +355,7 @@ body {
cursor: pointer;
transition: color 0.15s;
}
.main-tab + .main-tab::before {
.main-tab:not(.hidden) + .main-tab:not(.hidden)::before {
content: "";
position: absolute;
top: 10px;
@@ -443,6 +520,7 @@ body.detail-drawer-open .hero-role-toolbar {
}
@media (max-width: 900px) {
.brand-title { display: none; }
.auth-name { display: none; }
.main-tab {
min-width: 56px;
padding: 10px 10px;
@@ -2919,7 +2997,12 @@ body:has(#items-view:not(.hidden)) {
left: calc(100% + var(--aside-gap));
top: 0;
bottom: 0;
width: 280px;
/* Fill available right gutter (centered 820px main); floor 280, cap 420. */
width: clamp(
280px,
calc((100vw - var(--content-read)) / 2 - var(--aside-gap) - 24px),
var(--patches-aside-max)
);
pointer-events: none;
box-sizing: border-box;
}
@@ -3047,7 +3130,7 @@ button.patch-summary-chip.is-clickable:hover .patch-summary-chip-name {
color: var(--muted);
line-height: 1.4;
}
/* Need ~820 main + 300 aside gutter on each side of optical center. */
/* Need ~820 main + ~300 aside gutter on each side of optical center. */
@media (max-width: 1440px) {
.patches-center-wrap {
display: flex;
@@ -4423,9 +4506,11 @@ html.mobile-client #mobile-gate {
/* —— PC post-match player pages (/players) —— */
.players-board .players-cluster {
max-width: 1100px;
width: 100%;
max-width: 1224px;
margin: 0 auto;
padding: var(--space-lg) var(--space-md) 48px;
box-sizing: border-box;
}
.players-body {
display: flex;
@@ -4435,27 +4520,301 @@ html.mobile-client #mobile-gate {
.players-back {
appearance: none;
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 36px;
margin: 0;
padding: 0 14px 0 10px;
border: 1px solid var(--border);
background: transparent;
color: var(--muted);
font: inherit;
font-size: 13px;
padding: 6px 12px;
border-radius: var(--radius-md);
cursor: pointer;
}
.players-back:hover {
background: var(--surface-raised);
color: var(--text);
border-color: rgba(94, 200, 255, 0.45);
font: inherit;
font-size: 14px;
font-weight: var(--fw-semibold);
line-height: 1;
cursor: pointer;
white-space: nowrap;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.players-profile-head,
.players-match-head {
.players-back-icon {
flex: 0 0 auto;
display: block;
opacity: 0.9;
}
.players-back:hover,
.players-back:focus-visible {
color: var(--accent, #5ec8ff);
border-color: rgba(94, 200, 255, 0.5);
background: rgba(94, 200, 255, 0.08);
outline: none;
}
.players-profile-head {
margin-bottom: var(--space-sm);
}
.players-home {
width: 100%;
margin: 0 auto;
padding: var(--space-md) 0 var(--space-xl);
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.players-identity {
display: flex;
align-items: center;
gap: var(--space-md);
padding: 12px 16px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--panel-soft);
}
.players-identity-avatar {
width: 56px;
height: 56px;
border-radius: var(--radius-full);
object-fit: cover;
flex: 0 0 auto;
background: rgba(255, 255, 255, 0.06);
}
.players-identity-body {
min-width: 0;
flex: 1 1 auto;
}
.players-profile-title {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-sm);
}
.players-profile-title .page-title {
margin: 0;
}
.players-identity .page-sub {
margin: 4px 0 0;
}
.rank-medal {
position: relative;
display: inline-block;
width: 36px;
height: 36px;
flex: 0 0 auto;
line-height: 0;
}
.rank-medal-base,
.rank-medal-stars {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
}
/* OpenDota medals keep ~1520% canvas padding; size the box to match the 56px avatar so the glyph reads at similar optical weight. */
.players-rank-medal {
width: 64px;
height: 64px;
margin-inline: -4px;
}
.players-rank-board {
color: var(--muted);
font-size: var(--fs-body-sm);
font-variant-numeric: tabular-nums;
letter-spacing: 0.04em;
}
.match-rank-medal {
width: 28px;
height: 28px;
}
.players-section-title {
margin: 0 0 8px;
font-size: 14px;
font-weight: var(--fw-semibold);
letter-spacing: 0.06em;
color: var(--muted);
text-transform: none;
}
.players-snapshot {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-md);
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--panel-soft);
}
.players-snapshot-col {
min-width: 0;
}
.players-stat-row {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 6px;
}
.players-stat-card {
min-width: 0;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface-raised);
display: flex;
flex-direction: column;
gap: 2px;
}
.players-stat-label {
color: var(--muted);
font-size: 11px;
letter-spacing: 0.04em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.players-stat-value {
font-variant-numeric: tabular-nums;
font-size: 16px;
font-weight: var(--fw-semibold);
color: var(--text);
line-height: 1.2;
}
.players-analysis {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
gap: var(--space-md);
}
.players-analysis-side {
display: flex;
flex-direction: column;
gap: var(--space-md);
min-width: 0;
}
.players-analysis-block {
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--panel-soft);
min-width: 0;
}
.players-top-heroes {
display: flex;
flex-direction: column;
gap: 6px;
}
.players-peers {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 6px;
}
.players-top-hero,
.players-peer {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border: 1px solid transparent;
border-radius: var(--radius-md);
background: var(--surface-raised);
color: inherit;
text-align: left;
font: inherit;
cursor: default;
min-width: 0;
}
.players-peer {
cursor: pointer;
border-color: var(--border);
}
.players-peer:hover {
border-color: rgba(94, 200, 255, 0.4);
}
.players-top-hero img {
width: 48px;
height: 27px;
object-fit: cover;
border-radius: var(--radius-sm);
flex: 0 0 auto;
}
.players-peer img {
width: 28px;
height: 28px;
border-radius: var(--radius-full);
object-fit: cover;
flex: 0 0 auto;
}
.players-top-hero > div,
.players-peer > div {
min-width: 0;
}
.players-top-hero strong,
.players-peer strong {
display: block;
font-size: var(--fs-body-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.players-top-hero span,
.players-peer span {
color: var(--muted);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.players-highs {
color: var(--muted);
font-size: 12px;
margin: 0 0 8px;
font-variant-numeric: tabular-nums;
}
.players-heatmap {
display: grid;
grid-template-rows: repeat(7, 9px);
grid-auto-flow: column;
grid-auto-columns: 9px;
gap: 2px;
max-width: 100%;
overflow: hidden;
}
.players-heatmap-wrap {
display: flex;
align-items: flex-end;
justify-content: flex-start;
gap: 12px;
}
.players-heatmap-legend {
display: flex;
align-items: center;
gap: 3px;
color: var(--muted);
font-size: 11px;
white-space: nowrap;
}
.players-heatmap-legend .players-heat-cell {
display: inline-block;
flex: 0 0 auto;
}
.players-heat-cell {
width: 9px;
height: 9px;
border-radius: 2px;
background: rgba(140, 170, 210, 0.12);
}
.players-heat-cell.lv1 { background: rgba(61, 206, 122, 0.25); }
.players-heat-cell.lv2 { background: rgba(61, 206, 122, 0.45); }
.players-heat-cell.lv3 { background: rgba(61, 206, 122, 0.7); }
.players-heat-cell.lv4 { background: rgba(61, 206, 122, 0.95); }
.players-recent-section {
margin-top: 4px;
}
.players-recent-section > .players-section-title {
margin-bottom: 10px;
color: var(--text);
font-size: 16px;
letter-spacing: 0;
}
.players-recent {
display: flex;
flex-direction: column;
gap: 8px;
gap: 4px;
}
.players-recent-row {
appearance: none;
@@ -4468,12 +4827,14 @@ html.mobile-client #mobile-gate {
background: var(--surface-raised);
color: var(--text);
border-radius: var(--radius-md);
padding: 10px 12px;
padding: 8px 12px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.players-recent-row:hover {
border-color: rgba(94, 200, 255, 0.4);
.players-recent-row:hover,
.players-recent-row:focus-visible {
border-color: rgba(94, 200, 255, 0.45);
outline: none;
}
.players-recent-row.won {
border-left: 3px solid var(--good);
@@ -4482,49 +4843,186 @@ html.mobile-client #mobile-gate {
border-left: 3px solid var(--danger);
}
.players-recent-portrait {
width: 64px;
height: 36px;
width: 56px;
height: 32px;
object-fit: cover;
border-radius: 4px;
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.players-recent-body {
flex: 1;
min-width: 0;
display: grid;
/* hero/KDA grows left | duration/when + result/id tight on the right */
grid-template-columns: minmax(0, 1fr) auto auto;
column-gap: 20px;
align-items: center;
}
.players-recent-top,
.players-recent-bot {
display: flex;
justify-content: space-between;
gap: 12px;
.players-recent-hero-col,
.players-recent-meta,
.players-recent-end {
display: grid;
grid-template-rows: 1.3em 1.15em;
row-gap: 2px;
align-items: center;
min-width: 0;
}
.players-recent-top {
.players-recent-hero-col {
min-width: 0;
}
.players-recent-meta {
justify-items: end;
flex-shrink: 0;
}
.players-recent-end {
justify-items: end;
flex-shrink: 0;
}
.players-recent-hero {
font-size: 14px;
font-weight: 700;
line-height: 1.3em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.players-recent-bot {
margin-top: 4px;
.players-recent-kda {
font-size: 12px;
line-height: 1.15em;
color: var(--muted);
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.players-recent-dur {
font-size: 14px;
font-weight: 600;
line-height: 1.2;
font-variant-numeric: tabular-nums;
white-space: nowrap;
color: var(--text);
}
.players-recent-when {
font-size: 12px;
line-height: 1.2;
color: var(--muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.players-recent-wl {
font-size: 13px;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.04em;
line-height: 1.3em;
white-space: nowrap;
}
.players-recent-id {
font-size: 12px;
line-height: 1.15em;
color: var(--muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.players-recent-wl.won {
color: var(--good);
}
.players-recent-wl.lost {
color: var(--danger);
}
@media (max-width: 980px) {
.players-snapshot {
grid-template-columns: 1fr;
}
.players-analysis {
grid-template-columns: 1fr;
}
.players-stat-row {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.players-board .players-cluster {
padding-inline: 12px;
}
.players-recent-body {
column-gap: 14px;
}
}
.players-match-shell {
width: 100%;
margin: 0 auto;
padding: var(--space-md) 0 var(--space-xl);
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.players-match-shell > .players-back {
margin-bottom: -4px;
}
.players-match-head {
margin: 0;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--panel-soft);
}
.players-match-summary .page-title {
margin: 0;
font-size: 18px;
}
.players-match-summary .page-sub {
margin: 6px 0 0;
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
font-variant-numeric: tabular-nums;
}
.players-match-winner {
color: var(--good);
font-weight: 700;
}
.player-scoreboard {
display: flex;
flex-direction: column;
gap: 18px;
gap: 12px;
}
.player-scoreboard-columns,
.player-match-row {
/* name capped | metrics fill remaining | items fixed */
grid-template-columns: minmax(11rem, 16rem) minmax(0, 1fr) 14.5rem;
}
.player-scoreboard-columns {
display: grid;
column-gap: 12px;
align-items: center;
padding: 0 12px;
color: var(--muted);
font-size: 11px;
letter-spacing: 0.04em;
}
.player-scoreboard-metric-labels,
.player-match-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
column-gap: 12px;
width: 100%;
justify-self: stretch;
box-sizing: border-box;
}
.player-scoreboard-items-label {
text-align: right;
}
.player-team {
border: 1px solid var(--border);
border-radius: var(--radius-md);
border-radius: var(--radius-lg);
background: var(--panel-soft);
overflow: hidden;
}
.player-team.radiant {
.player-team.won {
border-color: rgba(61, 206, 122, 0.35);
}
.player-team.dire {
.player-team.lost {
border-color: rgba(232, 106, 106, 0.35);
}
.player-team-head {
@@ -4532,13 +5030,13 @@ html.mobile-client #mobile-gate {
justify-content: space-between;
align-items: baseline;
gap: 12px;
padding: 10px 14px;
font-size: 14px;
padding: 8px 12px;
font-size: 13px;
}
.player-team.radiant .player-team-head {
.player-team.won .player-team-head {
background: rgba(61, 206, 122, 0.12);
}
.player-team.dire .player-team-head {
.player-team.lost .player-team-head {
background: rgba(232, 106, 106, 0.12);
}
.player-team-name {
@@ -4547,7 +5045,8 @@ html.mobile-client #mobile-gate {
}
.player-team-stats {
color: var(--muted);
font-size: 13px;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.player-team-rows {
display: flex;
@@ -4555,10 +5054,9 @@ html.mobile-client #mobile-gate {
}
.player-match-row {
display: grid;
grid-template-columns: minmax(160px, 1.2fr) minmax(220px, 1.4fr) auto;
gap: 12px;
column-gap: 12px;
align-items: center;
padding: 10px 14px;
padding: 8px 12px;
border-top: 1px solid var(--border);
}
.player-match-row.is-focus {
@@ -4574,24 +5072,48 @@ html.mobile-client #mobile-gate {
min-width: 0;
}
.player-match-portrait {
width: 72px;
height: 40px;
width: 64px;
height: 36px;
object-fit: cover;
border-radius: 4px;
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.player-match-meta {
min-width: 0;
flex: 1 1 auto;
}
.player-match-name {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
min-width: 0;
}
a.player-match-name-link,
.player-match-name-anon {
flex: 1 1 auto;
min-width: 0;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
a.player-match-name-link {
color: inherit;
text-decoration: none;
border-bottom: 1px solid transparent;
}
a.player-match-name-link:hover {
color: var(--accent, #5ec8ff);
border-bottom-color: rgba(94, 200, 255, 0.45);
}
.player-match-name-anon {
color: var(--muted, #8fa3bc);
font-weight: 600;
}
.players-enrich-status {
margin: 0;
font-size: 13px;
}
.player-mvp-badge {
flex-shrink: 0;
font-size: 10px;
@@ -4606,28 +5128,78 @@ html.mobile-client #mobile-gate {
margin-top: 2px;
font-size: 12px;
color: var(--muted);
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.player-match-sub > span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.player-match-badges {
display: inline-flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.player-party-badge {
flex-shrink: 0;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.06em;
border-radius: 3px;
padding: 1px 5px;
color: #0b1018;
background: #8fa3bc;
}
.player-party-badge.party-a {
background: #5ec8ff;
}
.player-party-badge.party-b {
background: #e8c878;
}
.player-party-badge.party-c {
background: #9b7ebd;
}
.player-party-badge.party-d {
background: #3dce7a;
}
.player-match-metrics {
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
align-items: baseline;
font-size: 13px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.player-match-metrics > span {
display: inline-flex;
align-items: baseline;
justify-content: center;
gap: 4px;
white-space: nowrap;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.player-scoreboard-metric-labels > span {
text-align: center;
}
.player-match-metrics em {
font-style: normal;
color: var(--muted);
margin-right: 4px;
font-size: 11px;
display: none;
}
.player-match-items {
display: flex;
flex-wrap: wrap;
gap: 4px;
justify-content: flex-end;
display: grid;
grid-template-columns: repeat(6, 34px);
gap: 3px;
justify-content: end;
width: 14.5rem;
box-sizing: border-box;
}
.player-match-item {
width: 36px;
height: 28px;
width: 34px;
height: 26px;
border-radius: 3px;
background: rgba(8, 12, 20, 0.55);
overflow: hidden;
@@ -4643,11 +5215,34 @@ html.mobile-client #mobile-gate {
.player-match-item.empty {
opacity: 0.35;
}
@media (max-width: 900px) {
@media (max-width: 1100px) {
.player-scoreboard-columns,
.player-match-row {
grid-template-columns: minmax(10rem, 14rem) minmax(0, 1fr) 14.5rem;
}
}
@media (max-width: 760px) {
.player-scoreboard-columns {
display: none;
}
.player-match-row {
grid-template-columns: 1fr;
gap: 8px;
}
.player-match-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 10px;
row-gap: 4px;
width: 100%;
justify-self: stretch;
}
.player-match-metrics em {
display: inline;
font-style: normal;
color: var(--muted);
font-size: 11px;
flex-shrink: 0;
}
.player-match-items {
justify-content: flex-start;
}
+20
View File
@@ -0,0 +1,20 @@
# Cloudflare Pages project bindings for Climperor Web.
# Deploy still uses web/deploy_relations.py (direct upload). Bindings below
# are applied via `wrangler pages project ...` / dashboard / provision script.
name = "climperor-relations"
compatibility_date = "2024-11-01"
pages_build_output_dir = "../dist/relations"
[[d1_databases]]
binding = "DB"
database_name = "climperor-users"
database_id = "9eeb24ba-acc5-4520-b4e7-754ea776394e"
[[r2_buckets]]
binding = "MATCHES"
bucket_name = "climperor-player-data"
[[queues.producers]]
binding = "SYNC_QUEUE"
queue = "climperor-player-sync"
+223 -10
View File
@@ -6,7 +6,8 @@ Usage:
Hero relations, rankings, streamers, mechanics, items, patches, players
read-only browser UI. Edit data/*.json directly, then refresh the page.
Player pages: GET /api/players/{account_id}[/{match_id}] from pc/player_pages/.
Player pages: GET /api/players/{account_id}[/{match_id}] from pc/player_pages/;
POST /api/players/enrich and /api/players/ensure-match (local OpenDota backfill).
"""
from __future__ import annotations
@@ -25,6 +26,7 @@ import webbrowser
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
import urllib.parse
import urllib.error
@@ -39,6 +41,7 @@ from shared.paths import (
HERO_PORTRAITS,
ITEM_CAT_ICONS,
ITEM_ICONS,
PC_DIR,
PC_PLAYER_PAGES,
RANK_ICONS,
ROLE_ICONS,
@@ -53,6 +56,25 @@ from shared.relations import DEFAULT_RELATIONS, load_relations
from fetch_streamer_live import probe_streamers
from mechanic_tags import QUERY_MECHANIC_ORDER, mechanic_query_payload
from steam_auth import (
cookie_header as _steam_cookie_header,
create_session_token as _steam_create_token,
env_secrets as _steam_env_secrets,
fetch_steam_persona as _steam_fetch_persona,
read_cookie as _steam_read_cookie,
steam_id64_to_account_id as _steam_id64_to_account,
steam_login_redirect_url as _steam_login_url,
verify_session_token as _steam_verify_token,
verify_steam_openid as _steam_verify_openid,
)
sys.path.insert(0, str(PC_DIR))
from common import load_config as _load_pc_config # noqa: E402
from player_pages import ( # noqa: E402
enrich_profile_recent as _enrich_profile_recent,
get_profile_for_web as _get_profile_for_web,
ensure_match_detail as _ensure_match_detail,
)
WEB_DIR = WEB_FRONTEND
MOBILE_DEMAND_PATH = ROOT / "web" / ".refresh" / "mobile_demand.json"
@@ -853,7 +875,14 @@ class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt: str, *args) -> None:
print(f"[relations] {self.address_string()} {fmt % args}")
def _send(self, code: int, body: bytes, content_type: str) -> None:
def _send(
self,
code: int,
body: bytes,
content_type: str,
*,
extra_headers: dict[str, str] | None = None,
) -> None:
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
@@ -861,13 +890,92 @@ class Handler(BaseHTTPRequestHandler):
self.send_header(
"Content-Security-Policy",
"default-src 'self'; "
"img-src 'self' data: https://climperor.oss-cn-shanghai.aliyuncs.com; "
"img-src 'self' data: https://climperor.oss-cn-shanghai.aliyuncs.com "
"https://avatars.steamstatic.com; "
"style-src 'self' 'unsafe-inline'; "
"script-src 'self'; "
"media-src 'self' https://climperor.oss-cn-shanghai.aliyuncs.com",
)
if extra_headers:
for k, v in extra_headers.items():
self.send_header(k, v)
self.end_headers()
self.wfile.write(body)
if body:
self.wfile.write(body)
def _redirect(self, location: str, *, set_cookie: str | None = None) -> None:
headers = {"Location": location}
if set_cookie:
headers["Set-Cookie"] = set_cookie
self._send(302, b"", "text/plain; charset=utf-8", extra_headers=headers)
def _request_origin(self) -> str:
host = self.headers.get("Host") or "127.0.0.1:8765"
# Local serve is always http.
return f"http://{host}"
def _serve_steam_auth_get(self, path: str) -> bool:
"""Handle Steam auth GETs. Returns True if handled."""
api_key, session_secret = _steam_env_secrets()
origin = self._request_origin()
if path == "/api/auth/steam":
if not api_key or not session_secret:
self._send(503, b"Steam login not configured", "text/plain; charset=utf-8")
return True
self._redirect(_steam_login_url(origin))
return True
if path == "/api/auth/steam/callback":
if not api_key or not session_secret:
self._redirect(f"{origin}/?auth=unconfigured")
return True
qs = urllib.parse.parse_qs(urlparse(self.path).query)
ok, steamid = _steam_verify_openid(qs)
if not ok or not steamid:
self._redirect(f"{origin}/?auth=denied")
return True
account_id = _steam_id64_to_account(steamid)
if not account_id:
self._redirect(f"{origin}/?auth=error")
return True
persona = _steam_fetch_persona(api_key, steamid)
token = _steam_create_token(
session_secret,
{
"steamid": steamid,
"account_id": account_id,
"personaname": persona.get("personaname"),
"avatar": persona.get("avatar"),
},
)
self._redirect(
f"{origin}/home",
set_cookie=_steam_cookie_header(token, secure=False),
)
return True
if path == "/api/auth/me":
token = _steam_read_cookie(self.headers.get("Cookie"))
session = _steam_verify_token(session_secret, token) if session_secret else None
if not session:
self._json(200, {"authenticated": False})
return True
self._json(
200,
{
"authenticated": True,
"steamid": session.get("steamid"),
"account_id": session.get("account_id"),
"personaname": session.get("personaname"),
"avatar": session.get("avatar"),
},
)
return True
if path == "/api/auth/logout":
self._redirect(
f"{origin}/",
set_cookie=_steam_cookie_header("", clear=True, secure=False),
)
return True
return False
def _send_file(self, fpath: Path, content_type: str) -> None:
"""Stream a file with optional HTTP Range (needed for HTML5 video seek)."""
@@ -907,7 +1015,8 @@ class Handler(BaseHTTPRequestHandler):
self.send_header(
"Content-Security-Policy",
"default-src 'self'; "
"img-src 'self' data: https://climperor.oss-cn-shanghai.aliyuncs.com; "
"img-src 'self' data: https://climperor.oss-cn-shanghai.aliyuncs.com "
"https://avatars.steamstatic.com; "
"style-src 'self' 'unsafe-inline'; "
"script-src 'self'; "
"media-src 'self' https://climperor.oss-cn-shanghai.aliyuncs.com",
@@ -929,6 +1038,9 @@ class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802
path = urlparse(self.path).path
if path.startswith("/api/auth/"):
if self._serve_steam_auth_get(path):
return
if path in ("/", "/index.html"):
index = WEB_DIR / "index.html"
if not index.is_file():
@@ -962,6 +1074,7 @@ class Handler(BaseHTTPRequestHandler):
if path.startswith("/rank/"):
key = path[len("/rank/") :]
allowed = {f"rank_icon_{i}.png" for i in range(1, 9)}
allowed |= {f"rank_star_{i}.png" for i in range(1, 6)}
if key not in allowed:
self._json(400, {"error": "bad rank icon"})
return
@@ -1169,6 +1282,7 @@ class Handler(BaseHTTPRequestHandler):
return
# History SPA fallback: /heroes/axe → index.html (client router).
spa_pages = {
"home",
"heroes",
"rankings",
"matches",
@@ -1195,6 +1309,30 @@ class Handler(BaseHTTPRequestHandler):
self._json(400, {"error": "bad players path"})
return
account_id = parts[2]
if account_id == "me" and len(parts) == 3:
api_key, session_secret = _steam_env_secrets()
token = _steam_read_cookie(self.headers.get("Cookie"))
session = (
_steam_verify_token(session_secret, token) if session_secret else None
)
if not session or not session.get("account_id"):
self._json(401, {"authenticated": False})
return
aid = int(session["account_id"])
try:
cfg = _load_pc_config()
# Cache-first (TTL); cold miss syncs once, stale refresh is async.
profile = _get_profile_for_web(cfg, aid, force=False, include_gsi=True)
except Exception as e: # noqa: BLE001
self._json(500, {"error": "enrich failed", "detail": str(e)})
return
if not profile.get("personaname") and session.get("personaname"):
profile["personaname"] = session.get("personaname")
if not profile.get("avatar") and session.get("avatar"):
profile["avatar"] = session.get("avatar")
profile["authenticated"] = True
self._json(200, profile)
return
if not account_id.isdigit():
self._json(400, {"error": "bad account_id"})
return
@@ -1231,18 +1369,31 @@ class Handler(BaseHTTPRequestHandler):
return
self._json(400, {"error": "bad players path"})
def _read_json_body(self) -> dict | None:
length = int(self.headers.get("Content-Length", 0) or 0)
raw = self.rfile.read(length) if length else b"{}"
try:
body = json.loads(raw.decode("utf-8"))
except (ValueError, UnicodeDecodeError):
return None
return body if isinstance(body, dict) else {}
def do_POST(self) -> None: # noqa: N802
path = urlparse(self.path).path
if path == "/api/mobile-demand":
self._json(200, {"count": _inc_mobile_demand_count(), "voted": True})
return
if path == "/api/auth/logout":
origin = self._request_origin()
self._redirect(
f"{origin}/",
set_cookie=_steam_cookie_header("", clear=True, secure=False),
)
return
if path == "/api/players/publish":
# Local dev: no OSS write; PC already wrote pc/player_pages/.
length = int(self.headers.get("Content-Length", 0) or 0)
raw = self.rfile.read(length) if length else b"{}"
try:
body = json.loads(raw.decode("utf-8"))
except (ValueError, UnicodeDecodeError):
body = self._read_json_body()
if body is None:
self._json(400, {"error": "invalid json"})
return
account_id = body.get("account_id")
@@ -1258,6 +1409,68 @@ class Handler(BaseHTTPRequestHandler):
},
)
return
if path == "/api/players/enrich":
body = self._read_json_body()
if body is None:
self._json(400, {"error": "invalid json"})
return
account_id = body.get("account_id")
try:
aid = int(account_id)
except (TypeError, ValueError):
aid = 0
if aid <= 0:
self._json(400, {"error": "account_id required"})
return
include_gsi = body.get("include_gsi")
if include_gsi is None:
include_gsi = True
force = bool(body.get("force", True))
try:
cfg = _load_pc_config()
if force:
profile = _enrich_profile_recent(
cfg, aid, include_gsi=bool(include_gsi)
)
else:
profile = _get_profile_for_web(
cfg, aid, force=False, include_gsi=bool(include_gsi)
)
except Exception as e: # noqa: BLE001
self._json(500, {"error": "enrich failed", "detail": str(e)})
return
if profile.get("error"):
self._json(400, profile)
return
self._json(200, {"ok": True, "profile": profile})
return
if path == "/api/players/ensure-match":
body = self._read_json_body()
if body is None:
self._json(400, {"error": "invalid json"})
return
try:
aid = int(body.get("account_id"))
mid = int(body.get("match_id"))
except (TypeError, ValueError):
aid, mid = 0, 0
if aid <= 0 or mid <= 0:
self._json(400, {"error": "account_id and match_id required"})
return
try:
cfg = _load_pc_config()
detail, err = _ensure_match_detail(cfg, aid, mid)
except Exception as e: # noqa: BLE001
self._json(500, {"error": "ensure-match failed", "detail": str(e)})
return
if err or not detail:
self._json(
404 if err else 500,
{"error": err or "unknown", "account_id": aid, "match_id": mid},
)
return
self._json(200, {"ok": True, "match": detail})
return
self.send_error(404)
+179
View File
@@ -0,0 +1,179 @@
"""Steam OpenID login + signed cookie sessions for local serve_relations.
Env: STEAM_API_KEY, SESSION_SECRET.
Mirrors web/frontend/functions/api/auth/* Pages Functions.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
COOKIE_NAME = "climperor_steam"
SESSION_DAYS = 30
STEAM_OPENID = "https://steamcommunity.com/openid/login"
STEAM_ID_PREFIX = "https://steamcommunity.com/openid/id/"
UA = "climperor-steam-auth"
def _b64url_encode(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _b64url_decode(s: str) -> bytes:
pad = "=" * (-len(s) % 4)
return base64.urlsafe_b64decode(s + pad)
def steam_id64_to_account_id(steam_id64: str | int) -> int | None:
try:
n = int(steam_id64)
except (TypeError, ValueError):
return None
account = n - 76561197960265728
return account if account > 0 else None
def parse_steam_id_from_claimed(claimed_id: str | None) -> str | None:
if not claimed_id or not claimed_id.startswith(STEAM_ID_PREFIX):
return None
sid = claimed_id[len(STEAM_ID_PREFIX) :].rstrip("/")
return sid if sid.isdigit() and len(sid) == 17 else None
def create_session_token(secret: str, payload: dict) -> str:
body = dict(payload)
body["exp"] = int(time.time()) + SESSION_DAYS * 86400
raw = _b64url_encode(json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode())
sig = _b64url_encode(hmac.new(secret.encode(), raw.encode(), hashlib.sha256).digest())
return f"{raw}.{sig}"
def verify_session_token(secret: str, token: str | None) -> dict | None:
if not secret or not token or "." not in token:
return None
raw, sig = token.split(".", 1)
expect = _b64url_encode(hmac.new(secret.encode(), raw.encode(), hashlib.sha256).digest())
if not hmac.compare_digest(sig, expect):
return None
try:
data = json.loads(_b64url_decode(raw).decode("utf-8"))
except (ValueError, UnicodeDecodeError):
return None
if not isinstance(data, dict):
return None
if int(data.get("exp") or 0) < int(time.time()):
return None
if not data.get("steamid") or not data.get("account_id"):
return None
return data
def read_cookie(cookie_header: str | None, name: str = COOKIE_NAME) -> str | None:
if not cookie_header:
return None
for part in cookie_header.split(";"):
part = part.strip()
if part.startswith(name + "="):
return urllib.parse.unquote(part[len(name) + 1 :])
return None
def cookie_header(token: str, *, clear: bool = False, secure: bool = False) -> str:
if clear:
base = f"{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"
else:
max_age = SESSION_DAYS * 86400
base = (
f"{COOKIE_NAME}={urllib.parse.quote(token)}; Path=/; HttpOnly; "
f"SameSite=Lax; Max-Age={max_age}"
)
if secure:
base = base.replace("SameSite=Lax", "Secure; SameSite=Lax")
return base
def steam_login_redirect_url(origin: str) -> str:
return_to = f"{origin.rstrip('/')}/api/auth/steam/callback"
params = {
"openid.ns": "http://specs.openid.net/auth/2.0",
"openid.mode": "checkid_setup",
"openid.return_to": return_to,
"openid.realm": origin.rstrip("/"),
"openid.identity": "http://specs.openid.net/auth/2.0/identifier_select",
"openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select",
}
return f"{STEAM_OPENID}?{urllib.parse.urlencode(params)}"
def verify_steam_openid(query: dict[str, list[str] | str]) -> tuple[bool, str | None]:
def one(key: str) -> str | None:
v = query.get(key)
if isinstance(v, list):
return v[0] if v else None
return v
if one("openid.mode") != "id_res":
return False, None
steamid = parse_steam_id_from_claimed(one("openid.claimed_id"))
if not steamid:
return False, None
body: dict[str, str] = {}
for k, v in query.items():
if not k.startswith("openid."):
continue
body[k] = v[0] if isinstance(v, list) else str(v)
body["openid.mode"] = "check_authentication"
data = urllib.parse.urlencode(body).encode()
req = urllib.request.Request(
STEAM_OPENID,
data=data,
headers={"User-Agent": UA, "Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
text = resp.read().decode("utf-8", errors="replace")
except (urllib.error.URLError, TimeoutError, OSError):
return False, None
normalized = "".join(text.split()).lower()
if "is_valid:true" not in normalized:
return False, None
return True, steamid
def fetch_steam_persona(api_key: str, steamid: str) -> dict[str, Any]:
if not api_key:
return {"personaname": None, "avatar": None}
url = (
"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/"
f"?key={urllib.parse.quote(api_key)}&steamids={urllib.parse.quote(steamid)}"
)
req = urllib.request.Request(url, headers={"User-Agent": UA})
try:
with urllib.request.urlopen(req, timeout=20) as resp:
data = json.loads(resp.read().decode())
except (urllib.error.URLError, TimeoutError, OSError, ValueError):
return {"personaname": None, "avatar": None}
players = (((data or {}).get("response") or {}).get("players")) or []
if not players:
return {"personaname": None, "avatar": None}
p = players[0]
return {
"personaname": p.get("personaname") or None,
"avatar": p.get("avatarfull") or p.get("avatarmedium") or p.get("avatar"),
}
def env_secrets() -> tuple[str, str]:
key = (os.environ.get("STEAM_API_KEY") or "").strip()
secret = (os.environ.get("SESSION_SECRET") or "").strip()
return key, secret