Sync works on the deployed site; add reproducible build and deploy script

The Pages deployment is now the primary copy, so two things had to change:

- Hosted pages fall back to steam-sync.py on 127.0.0.1:8765 instead of
  refusing to sync. steam-sync.py now answers Chrome's Private Network
  Access preflight (Access-Control-Allow-Private-Network), which is what a
  public https page needs before it may call into localhost.
- The build sources lived in /tmp and would have been lost. They now live in
  build/, and build.py outputs to the repo root. deploy.sh rebuilds,
  commits and pushes in one step.

Editing index.html directly is no longer the workflow - edit build/ and run
./deploy.sh.
This commit is contained in:
Rain 2026-09-15 17:40:06 -04:00
parent 4d5545e6c5
commit a63606b5c8
17 changed files with 6120 additions and 18 deletions

202
build/steam-sync.py Normal file
View file

@ -0,0 +1,202 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
steam-sync.py tiny local helper for la-li-lu-le-lo, the MGS2 Master Collection
100% companion.
It does two things:
1. serves the folder it lives in over http://127.0.0.1:8765/ (so the page gets a
real origin and localStorage saves reliably)
2. exposes /api/steam?profile=<vanity|steamid64> which fetches that profile's
public MGS2 Master Collection achievements from Steam and returns them as JSON
No API key. No third-party packages. Only one outbound request, to the public
Steam Community page for the profile you ask for.
python3 steam-sync.py # then open http://127.0.0.1:8765/
python3 steam-sync.py --open # ...and open the browser for you
PORT=9000 python3 steam-sync.py # different port
"""
import http.server
import json
import os
import re
import socket
import socketserver
import sys
import urllib.error
import urllib.parse
import urllib.request
import webbrowser
from xml.etree import ElementTree as ET
APPID = "2131640"
GAME = "METAL GEAR SOLID 2: Sons of Liberty - Master Collection Version"
PORT = int(os.environ.get("PORT", "8765"))
ROOT = os.path.dirname(os.path.abspath(__file__))
UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0 Safari/537.36")
def steam_url(profile):
profile = profile.strip()
if re.fullmatch(r"\d{17}", profile):
return "https://steamcommunity.com/profiles/%s/stats/%s/?tab=achievements&xml=1" % (profile, APPID)
return "https://steamcommunity.com/id/%s/stats/%s/?tab=achievements&xml=1" % (
urllib.parse.quote(profile), APPID)
def fetch_steam(profile):
url = steam_url(profile)
req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept-Language": "en"})
try:
with urllib.request.urlopen(req, timeout=25) as r:
raw = r.read()
except urllib.error.HTTPError as e:
return {"ok": False, "error": "Steam returned HTTP %s." % e.code,
"hint": "Try again in a moment; Steam rate-limits rapid requests."}
except Exception as e:
return {"ok": False, "error": "Could not reach Steam: %s" % e,
"hint": "Check your internet connection."}
try:
root = ET.fromstring(raw)
except ET.ParseError:
return {"ok": False, "error": "Steam sent something unreadable.",
"hint": "The profile may be private or Steam may be rate-limiting."}
err = root.find("error")
if err is not None and (err.text or "").strip():
msg = err.text.strip()
hint = "Check the spelling, or paste the whole profile URL."
if "private" in msg.lower():
hint = "Steam > your profile > Edit Profile > Privacy Settings > set 'Game details' to Public."
return {"ok": False, "error": msg, "hint": hint}
privacy = (root.findtext("privacyState") or "").strip()
if privacy and privacy != "public":
return {"ok": False,
"error": "That profile is %s." % privacy,
"hint": "Steam > Edit Profile > Privacy Settings > set 'Game details' to Public, then sync again."}
ach_nodes = root.findall("./achievements/achievement")
if not ach_nodes:
return {"ok": False,
"error": "No MGS2 Master Collection stats on that account.",
"hint": "The account must own the game (appid %s) and have launched it at least once." % APPID}
out = []
for a in ach_nodes:
out.append({
"api": (a.findtext("apiname") or "").strip(),
"name": (a.findtext("name") or "").strip(),
"desc": (a.findtext("description") or "").strip(),
"unlocked": a.get("closed") == "1",
"icon": (a.findtext("iconOpen") or "").strip(),
"iconLocked": (a.findtext("iconClosed") or "").strip(),
})
return {
"ok": True,
"game": GAME,
"appid": APPID,
"steamid64": (root.findtext("./player/steamID64") or "").strip(),
# This XML carries no persona name, only steamID64 and customURL.
"persona": ((root.findtext("./player/steamID")
or root.findtext("./player/customURL")
or profile) or "").strip(),
"unlockedCount": sum(1 for a in out if a["unlocked"]),
"total": len(out),
"achievements": out,
}
class Handler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kw):
super().__init__(*args, directory=ROOT, **kw)
def log_message(self, fmt, *args):
# Never let a closed/dead output pipe (e.g. when run in the background)
# take the whole server down with it.
if "/api/" not in (self.path or ""):
return
try:
sys.stderr.write(" steam-sync: %s %s\n" % (self.command, self.path.split("?")[0]))
sys.stderr.flush()
except Exception:
pass
def _cors(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Headers", "*")
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
# Chrome asks for this when a public https page (e.g. the Pages deployment)
# calls into 127.0.0.1. Without it the request is blocked before it is sent.
self.send_header("Access-Control-Allow-Private-Network", "true")
def do_OPTIONS(self):
self.send_response(204)
self._cors()
self.end_headers()
def do_GET(self):
if self.path.split("?")[0] != "/api/steam":
return super().do_GET()
qs = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
profile = (qs.get("profile") or [""])[0].strip()
if not profile:
payload = {"ok": False, "error": "No profile given.",
"hint": "Enter a vanity name or SteamID64."}
else:
try:
payload = fetch_steam(profile)
except Exception as e: # never 500 on the user
payload = {"ok": False, "error": "Unexpected error: %s" % e, "hint": ""}
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self._cors()
self.end_headers()
self.wfile.write(body)
class Server(socketserver.ThreadingTCPServer):
allow_reuse_address = True
daemon_threads = True
def main():
# A stalled connection to Steam can never hang a handler thread forever.
socket.setdefaulttimeout(30)
if not os.path.exists(os.path.join(ROOT, "index.html")):
print("!! index.html is not in %s — keep steam-sync.py next to it." % ROOT)
try:
httpd = Server(("127.0.0.1", PORT), Handler)
except OSError as e:
print("!! Could not bind port %d: %s" % (PORT, e))
print(" Another copy may already be running, or set PORT=<other> and retry.")
return 1
url = "http://127.0.0.1:%d/" % PORT
print("=" * 66)
print(" la-li-lu-le-lo — MGS2 100% companion (local helper)")
print("=" * 66)
print(" serving : %s" % ROOT)
print(" page : %s" % url)
print(" api : %sapi/steam?profile=<vanity or SteamID64>" % url)
print(" stop : Ctrl+C")
print("-" * 66)
if "--open" in sys.argv:
webbrowser.open(url)
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\n stopped.")
return 0
if __name__ == "__main__":
raise SystemExit(main())