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:
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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__")
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user