Reorganize repository into pc web shared monorepo

Separate the local recognition, web publishing, and shared data paths while preserving direct script execution and existing site content.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-07-29 14:29:08 +08:00
co-authored by Cursor
parent 96a9312194
commit 9c5aa5b610
280 changed files with 1451 additions and 450 deletions
+62
View File
@@ -0,0 +1,62 @@
"""Add the missing CNAME for dota2.refining.dev -> climperor-relations.pages.dev.
Cloudflare Pages bound the custom domain but did not auto-create the zone
CNAME, so SSL validation is stuck at 'CNAME record not set'. This adds it
(proxied so CF issues SSL + serves from the edge). Idempotent: skips if present.
"""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
ZONE = "refining.dev"
DOMAIN = "dota2.refining.dev"
TARGET = "climperor-relations.pages.dev"
def cf(method: str, path: str, email: str, key: str, body: dict | None = None) -> dict:
url = "https://api.cloudflare.com/client/v4" + path
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method, headers={
"X-Auth-Email": email, "X-Auth-Key": key, "Content-Type": "application/json",
})
try:
with urllib.request.urlopen(req) as r:
return json.loads(r.read().decode())
except urllib.error.HTTPError as e:
return json.loads(e.read().decode())
def main() -> None:
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")
zd = cf("GET", f"/zones?name={ZONE}", email, key)
zr = zd.get("result") or []
if not zr:
raise SystemExit(f"zone {ZONE} not found")
zid = zr[0]["id"]
print(f"zone {ZONE}: {zid}")
recs = cf("GET", f"/zones/{zid}/dns_records?name={DOMAIN}", email, key)
existing = [r for r in (recs.get("result") or []) if r.get("type") == "CNAME"]
if existing:
for r in existing:
print(f"CNAME already exists: {r.get('name')} -> {r.get('content')} (proxied={r.get('proxied')})")
return
print(f"adding CNAME {DOMAIN} -> {TARGET} (proxied=true)")
d = cf("POST", f"/zones/{zid}/dns_records", email, key, body={
"type": "CNAME", "name": DOMAIN, "content": TARGET, "proxied": True, "comment": "climperor-relations Pages",
})
if d.get("success"):
r = d.get("result", {})
print(f"created: {r.get('type')} {r.get('name')} -> {r.get('content')} (proxied={r.get('proxied')})")
else:
print(f"FAILED: {d.get('errors')}")
if __name__ == "__main__":
main()
+53
View File
@@ -0,0 +1,53 @@
"""Probe a single Cloudflare Pages custom domain + its zone DNS record."""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
DOMAIN = "dota2.refining.dev"
PROJECT = "climperor-relations"
def cf(method: str, path: str, email: str, key: str) -> dict:
url = "https://api.cloudflare.com/client/v4" + path
req = urllib.request.Request(url, method=method, headers={
"X-Auth-Email": email, "X-Auth-Key": key, "Content-Type": "application/json",
})
try:
with urllib.request.urlopen(req) as r:
return json.loads(r.read().decode())
except urllib.error.HTTPError as e:
return json.loads(e.read().decode())
def main() -> None:
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")
d = cf("GET", "/accounts", email, key)
aid = (d.get("result") or [{}])[0].get("id")
d = cf("GET", f"/accounts/{aid}/pages/projects/{PROJECT}/domains/{DOMAIN}", email, key)
print("domain detail:")
for k, v in (d.get("result") or {}).items():
if k != "zone_id":
print(f" {k}: {v}")
zd = cf("GET", "/zones?name=refining.dev", email, key)
zr = zd.get("result") or []
if not zr:
print("zone refining.dev: NOT FOUND on this account")
return
zid = zr[0]["id"]
recs = cf("GET", f"/zones/{zid}/dns_records?name={DOMAIN}", email, key)
print(f"dns records for {DOMAIN}:")
for rec in (recs.get("result") or []):
print(f" {rec.get('type')} {rec.get('name')} -> {rec.get('content')} (proxied={rec.get('proxied')})")
if not (recs.get("result") or []):
print(" (none -- CNAME not added yet)")
if __name__ == "__main__":
main()
+154
View File
@@ -0,0 +1,154 @@
"""Human homepage opens: path=/ AND known browser UA (exclude bots/unknown)."""
from __future__ import annotations
import json
import os
import urllib.request
from datetime import date, timedelta
HOST = "dota2.refining.dev"
ZONE = "refining.dev"
HUMAN_BROWSERS = [
"Chrome",
"Firefox",
"Safari",
"Edge",
"Opera",
"MobileSafari",
"ChromeMobile",
"ChromeMobileWebview",
"FirefoxMobile",
"SamsungInternet",
"EdgeMobile",
]
def headers() -> dict[str, str]:
email = os.environ["KEYZOO_ASSET_META_USERNAME"]
key = os.environ["KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"]
return {
"X-Auth-Email": email,
"X-Auth-Key": key,
"Content-Type": "application/json",
}
def gql(query: str, variables: dict) -> dict:
req = urllib.request.Request(
"https://api.cloudflare.com/client/v4/graphql",
data=json.dumps({"query": query, "variables": variables}).encode(),
method="POST",
headers=headers(),
)
with urllib.request.urlopen(req) as r:
return json.loads(r.read().decode())
def main() -> None:
zid = json.loads(
urllib.request.urlopen(
urllib.request.Request(
f"https://api.cloudflare.com/client/v4/zones?name={ZONE}",
headers=headers(),
)
).read()
)["result"][0]["id"]
q = """
query($zoneTag:string!,$day:Date!,$host:string!){
viewer{zones(filter:{zoneTag:$zoneTag}){
homeByUA:httpRequestsAdaptiveGroups(
limit:30
filter:{date:$day,clientRequestHTTPHost:$host,clientRequestPath:"/"}
orderBy:[count_DESC]
){dimensions{userAgentBrowser clientCountryName} count sum{visits}}
rumByUA:httpRequestsAdaptiveGroups(
limit:20
filter:{
date:$day
clientRequestHTTPHost:$host
clientRequestPath:"/cdn-cgi/rum"
}
orderBy:[count_DESC]
){dimensions{userAgentBrowser} count sum{visits}}
homeByCountry:httpRequestsAdaptiveGroups(
limit:15
filter:{date:$day,clientRequestHTTPHost:$host,clientRequestPath:"/"}
orderBy:[count_DESC]
){dimensions{clientCountryName} count sum{visits}}
}}
}
"""
print(f"{HOST} homepage (/) by UA | RUM by UA\n")
tot_human_home_req = tot_human_home_vis = 0
tot_rum = 0
tot_bot_home_vis = 0
for i in range(7, -1, -1):
day = date.today() - timedelta(days=i)
data = gql(q, {"zoneTag": zid, "day": day.isoformat(), "host": HOST})
if data.get("errors"):
print(day, data["errors"][0]["message"][:120])
continue
z = data["data"]["viewer"]["zones"][0]
home = z["homeByUA"] or []
rum = z["rumByUA"] or []
if not home and not rum:
continue
human_req = human_vis = bot_vis = rum_c = 0
print(f"=== {day} ===")
print(" homepage / by UA:")
for r in home:
ua = (r["dimensions"] or {}).get("userAgentBrowser")
c = r["count"] or 0
v = (r.get("sum") or {}).get("visits") or 0
tag = "HUMAN" if ua in HUMAN_BROWSERS else "bot/other"
print(f" {c:>4} req {v:>4} vis [{tag}] {ua}")
if ua in HUMAN_BROWSERS:
human_req += c
human_vis += v
else:
bot_vis += v
print(" RUM by UA:")
for r in rum:
ua = (r["dimensions"] or {}).get("userAgentBrowser")
c = r["count"] or 0
rum_c += c
print(f" {c:>4} beacons {ua}")
print(" homepage / by country:")
for r in z["homeByCountry"] or []:
cc = (r["dimensions"] or {}).get("clientCountryName")
v = (r.get("sum") or {}).get("visits") or 0
print(f" {r['count']:>4} req {v:>4} vis {cc}")
print(
f" day human home: {human_req} req / {human_vis} visits | "
f"bot-other home visits: {bot_vis} | rum: {rum_c}"
)
tot_human_home_req += human_req
tot_human_home_vis += human_vis
tot_bot_home_vis += bot_vis
tot_rum += rum_c
print("\n======== SUMMARY (8 days) ========")
print(f" Human browser homepage requests: {tot_human_home_req}")
print(f" Human browser homepage visits: {tot_human_home_vis}")
print(f" Non-human homepage visits: {tot_bot_home_vis}")
print(f" RUM beacons (JS pageviews): {tot_rum}")
print()
print(" Verdict (exclude crawlers):")
print(f" ~{tot_human_home_vis} real homepage visits (known browser UA + /)")
print(f" ~{tot_rum} RUM pageviews (browser executed JS)")
# If RUM > human home visits, same session may fire multiple beacons
# or SPA navigations; take human home visits as unique-ish sessions,
# RUM as pageview count.
lo = min(tot_human_home_vis, tot_rum) if tot_rum else tot_human_home_vis
hi = max(tot_human_home_vis, tot_rum) if tot_rum else tot_human_home_vis
print(f" Best estimate: ~{lo}{hi} real human opens")
if __name__ == "__main__":
main()
+386
View File
@@ -0,0 +1,386 @@
"""Estimate human traffic for dota2.refining.dev (exclude obvious bots).
Free plan has no BotScore; we use:
- HTML document hits (/) vs asset/bot paths
- RUM beacons (/cdn-cgi/rum) as JS-executing browsers
- UA browser family when available
- Country + path heuristics
"""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
from datetime import date, timedelta
HOST = "dota2.refining.dev"
ZONE_NAME = "refining.dev"
def headers() -> dict[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 credentials in env")
return {
"X-Auth-Email": email,
"X-Auth-Key": key,
"Content-Type": "application/json",
}
def api(path: str) -> dict:
req = urllib.request.Request(
"https://api.cloudflare.com/client/v4" + path,
method="GET",
headers=headers(),
)
with urllib.request.urlopen(req) as r:
return json.loads(r.read().decode())
def gql(query: str, variables: dict) -> dict:
req = urllib.request.Request(
"https://api.cloudflare.com/client/v4/graphql",
data=json.dumps({"query": query, "variables": variables}).encode(),
method="POST",
headers=headers(),
)
try:
with urllib.request.urlopen(req) as r:
return json.loads(r.read().decode())
except urllib.error.HTTPError as e:
return {"errors": [{"message": e.read().decode()[:1500]}]}
def try_query(label: str, query: str, variables: dict) -> dict | None:
data = gql(query, variables)
if data.get("errors"):
msg = data["errors"][0].get("message", "")[:200]
print(f" [{label}] unavailable: {msg}")
return None
return data
def main() -> None:
zid = api(f"/zones?name={ZONE_NAME}")["result"][0]["id"]
days = [date.today() - timedelta(days=i) for i in range(7, -1, -1)]
print(f"\n{HOST} human-traffic estimate (Free plan, no BotScore)\n")
# --- probe schema fields once ---
day0 = days[-2] if len(days) > 1 else days[-1] # prefer yesterday
probes = [
(
"UA browser",
"""
query($zoneTag:string!,$day:Date!,$host:string!){
viewer{zones(filter:{zoneTag:$zoneTag}){
g:httpRequestsAdaptiveGroups(
limit:20
filter:{date:$day,clientRequestHTTPHost:$host}
orderBy:[count_DESC]
){dimensions{userAgentBrowser} count sum{visits}}
}}
}
""",
),
(
"device type",
"""
query($zoneTag:string!,$day:Date!,$host:string!){
viewer{zones(filter:{zoneTag:$zoneTag}){
g:httpRequestsAdaptiveGroups(
limit:10
filter:{date:$day,clientRequestHTTPHost:$host}
orderBy:[count_DESC]
){dimensions{clientDeviceType} count sum{visits}}
}}
}
""",
),
(
"botScore",
"""
query($zoneTag:string!,$day:Date!,$host:string!){
viewer{zones(filter:{zoneTag:$zoneTag}){
g:httpRequestsAdaptiveGroups(
limit:10
filter:{date:$day,clientRequestHTTPHost:$host,botScore_geq:30}
orderBy:[count_DESC]
){count sum{visits} dimensions{botScore}}
}}
}
""",
),
(
"content type",
"""
query($zoneTag:string!,$day:Date!,$host:string!){
viewer{zones(filter:{zoneTag:$zoneTag}){
g:httpRequestsAdaptiveGroups(
limit:15
filter:{date:$day,clientRequestHTTPHost:$host}
orderBy:[count_DESC]
){dimensions{edgeResponseContentTypeName} count sum{visits}}
}}
}
""",
),
]
print(f"Schema probes on {day0}:")
available = {}
for label, q in probes:
data = try_query(
label,
q,
{"zoneTag": zid, "day": day0.isoformat(), "host": HOST},
)
if data:
available[label] = data["data"]["viewer"]["zones"][0]["g"]
print(f" [{label}] OK")
for label, rows in available.items():
print(f"\n--- {label} ({day0}) ---")
for r in rows[:12]:
dims = r.get("dimensions") or {}
key = next(iter(dims.values()), "?") if dims else "?"
v = (r.get("sum") or {}).get("visits") or 0
print(f" {r['count']:>6} req {v:>5} vis {key}")
# --- per-day: total / homepage / rum / likely-bot paths ---
day_q = """
query($zoneTag:string!,$day:Date!,$host:string!){
viewer{zones(filter:{zoneTag:$zoneTag}){
all:httpRequestsAdaptiveGroups(
limit:1
filter:{date:$day,clientRequestHTTPHost:$host}
){count sum{visits}}
home:httpRequestsAdaptiveGroups(
limit:1
filter:{date:$day,clientRequestHTTPHost:$host,clientRequestPath:"/"}
){count sum{visits}}
rum:httpRequestsAdaptiveGroups(
limit:1
filter:{date:$day,clientRequestHTTPHost:$host,clientRequestPath:"/cdn-cgi/rum"}
){count sum{visits}}
html:httpRequestsAdaptiveGroups(
limit:1
filter:{
date:$day
clientRequestHTTPHost:$host
edgeResponseContentTypeName:"text/html"
}
){count sum{visits}}
byPath:httpRequestsAdaptiveGroups(
limit:40
filter:{date:$day,clientRequestHTTPHost:$host}
orderBy:[count_DESC]
){dimensions{clientRequestPath} count sum{visits}}
byUA:httpRequestsAdaptiveGroups(
limit:25
filter:{date:$day,clientRequestHTTPHost:$host}
orderBy:[count_DESC]
){dimensions{userAgentBrowser} count sum{visits}}
}}
}
"""
BOT_PATH_MARKERS = (
"wp-",
"wordpress",
"xmlrpc",
"wlwmanifest",
".env",
"phpmyadmin",
"robots.txt",
"sitemap",
"favicon.ico", # often bots; keep separate
)
HUMAN_UA = {
"Chrome",
"Firefox",
"Safari",
"Edge",
"Opera",
"Samsung Internet",
"Mobile Safari",
"Chrome Mobile",
"Firefox Mobile",
"Edg",
"Chromium",
}
BOT_UA = {
"Bot",
"Spider",
"Crawler",
"curl",
"Go-http-client",
"python",
"Python",
"Scrapy",
"Headless",
"Empty",
"Unknown",
"",
None,
}
print(f"\n{'date':<12} {'all_req':>8} {'all_vis':>8} {'home_vis':>8} {'rum':>6} {'html_vis':>8}")
totals = {
"all_req": 0,
"all_vis": 0,
"home_vis": 0,
"rum": 0,
"html_vis": 0,
"human_ua_vis": 0,
"bot_path_req": 0,
}
best_day = None
best_z = None
best_home = 0
for day in days:
data = try_query(
day.isoformat(),
day_q,
{"zoneTag": zid, "day": day.isoformat(), "host": HOST},
)
if not data:
# retry without html / UA if fields fail
day_q_min = """
query($zoneTag:string!,$day:Date!,$host:string!){
viewer{zones(filter:{zoneTag:$zoneTag}){
all:httpRequestsAdaptiveGroups(
limit:1 filter:{date:$day,clientRequestHTTPHost:$host}
){count sum{visits}}
home:httpRequestsAdaptiveGroups(
limit:1
filter:{date:$day,clientRequestHTTPHost:$host,clientRequestPath:"/"}
){count sum{visits}}
rum:httpRequestsAdaptiveGroups(
limit:1
filter:{
date:$day
clientRequestHTTPHost:$host
clientRequestPath:"/cdn-cgi/rum"
}
){count sum{visits}}
byPath:httpRequestsAdaptiveGroups(
limit:40
filter:{date:$day,clientRequestHTTPHost:$host}
orderBy:[count_DESC]
){dimensions{clientRequestPath} count sum{visits}}
}}
}
"""
data = try_query(
day.isoformat() + "-min",
day_q_min,
{"zoneTag": zid, "day": day.isoformat(), "host": HOST},
)
if not data:
continue
z = data["data"]["viewer"]["zones"][0]
def one(name: str) -> tuple[int, int]:
rows = z.get(name) or []
if not rows:
return 0, 0
r = rows[0]
return r.get("count") or 0, (r.get("sum") or {}).get("visits") or 0
all_c, all_v = one("all")
home_c, home_v = one("home")
rum_c, _ = one("rum")
html_c, html_v = one("html") if "html" in z else (0, 0)
print(
f"{day.isoformat():<12} {all_c:>8} {all_v:>8} {home_v:>8} {rum_c:>6} {html_v:>8}"
)
totals["all_req"] += all_c
totals["all_vis"] += all_v
totals["home_vis"] += home_v
totals["rum"] += rum_c
totals["html_vis"] += html_v
# bot-looking paths
for r in z.get("byPath") or []:
p = (r["dimensions"] or {}).get("clientRequestPath") or ""
if any(m in p.lower() for m in BOT_PATH_MARKERS):
totals["bot_path_req"] += r["count"] or 0
# UA sum visits for human browsers
for r in z.get("byUA") or []:
ua = (r["dimensions"] or {}).get("userAgentBrowser")
v = (r.get("sum") or {}).get("visits") or 0
if ua in HUMAN_UA or (
ua
and not any(
str(b).lower() in str(ua).lower()
for b in BOT_UA
if b
)
and ua
not in ("", None)
):
# count known browsers only
if ua in HUMAN_UA or (
isinstance(ua, str)
and any(
x in ua
for x in (
"Chrome",
"Firefox",
"Safari",
"Edge",
"Opera",
)
)
):
totals["human_ua_vis"] += v
if home_v >= best_home:
best_home = home_v
best_day = day
best_z = z
print("\n--- 7-day totals ---")
print(f" all edge requests: {totals['all_req']}")
print(f" all edge visits: {totals['all_vis']}")
print(f" homepage visits (/): {totals['home_vis']}")
print(f" RUM beacons: {totals['rum']} (JS ran = real browser)")
print(f" HTML content visits: {totals['html_vis']}")
print(f" obvious bot-path req: {totals['bot_path_req']}")
# Best estimate narrative
rum = totals["rum"]
home = totals["home_vis"]
# Conservative human sessions: min(home visits, rum) .. max, with rum as best
print("\n=== Real-human estimate ===")
print(f" Best proxy (RUM / JS pageviews): ~{rum}")
print(f" Homepage edge visits (/): ~{home}")
print(
f" Suggested range: ~{min(rum, home) if rum and home else max(rum, home)}"
f" .. ~{max(rum, home)} unique-ish real opens"
)
print(
" (RUM fires only when a real browser runs JS; scrapers hitting wp-*/assets do not.)"
)
if best_z and best_day:
print(f"\n--- UA browsers on {best_day} (if available) ---")
for r in (best_z.get("byUA") or [])[:15]:
ua = (r["dimensions"] or {}).get("userAgentBrowser")
v = (r.get("sum") or {}).get("visits") or 0
print(f" {r['count']:>6} req {v:>5} vis {ua!r}")
if __name__ == "__main__":
main()
+85
View File
@@ -0,0 +1,85 @@
"""Read-only Cloudflare Pages status probe (diagnostic helper).
Prints account id, whether the target project exists, its latest deployment,
and bound custom domains. Credentials come from env (keyzoo inject).
Usage via keyzoo: python _cf_status.py [--project-name NAME]
"""
from __future__ import annotations
import argparse
import json
import os
import urllib.error
import urllib.request
DEFAULT_PROJECT = "climperor-relations"
def cf(method: str, path: str, email: str, key: str, body: dict | None = None) -> dict:
url = "https://api.cloudflare.com/client/v4" + path
headers = {"X-Auth-Email": email, "X-Auth-Key": key, "Content-Type": "application/json"}
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method, headers=headers)
try:
with urllib.request.urlopen(req) as r:
return json.loads(r.read().decode())
except urllib.error.HTTPError as e:
try:
return json.loads(e.read().decode())
except Exception:
return {"success": False, "errors": [{"code": e.code, "message": str(e)}]}
except urllib.error.URLError as e:
return {"success": False, "errors": [{"code": -1, "message": str(e)}]}
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--project-name", default=DEFAULT_PROJECT)
args = ap.parse_args()
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 in env")
d = cf("GET", "/accounts", email, key)
if not d.get("success"):
print("GET /accounts FAILED:", d.get("errors"))
return
accts = d.get("result") or []
print(f"accounts: {[ (a['id'], a.get('name')) for a in accts ]}")
if not accts:
return
aid = accts[0]["id"]
d = cf("GET", f"/accounts/{aid}/pages/projects/{args.project_name}", email, key)
if not d.get("success"):
print(f"project {args.project_name}: NOT created -> {d.get('errors')}")
return
print(f"project: {args.project_name} (created)")
d = cf("GET", f"/accounts/{aid}/pages/projects/{args.project_name}/deployments", email, key)
deps = d.get("result") or []
print(f"deployments: {len(deps)}")
for dep in deps[:3]:
print(f" - {dep.get('latest_stage', {}).get('name')} / {dep.get('latest_stage', {}).get('status')} | env={dep.get('environment')} | created={dep.get('created_on')} | url={dep.get('url')}")
d = cf("GET", f"/accounts/{aid}/pages/projects/{args.project_name}/domains", email, key)
doms = d.get("result") or []
print("custom domains:")
for x in doms:
print(f" - {x.get('name')} status={x.get('status')}")
# Confirm the CNAME was auto-added on the refining.dev zone.
zd = cf("GET", "/zones?name=refining.dev", email, key)
zr = zd.get("result") or []
if zr:
zid = zr[0]["id"]
recs = cf("GET", f"/zones/{zid}/dns_records?name=dota2.refining.dev", email, key)
for rec in (recs.get("result") or []):
print(f" dns: {rec.get('type')} {rec.get('name')} -> {rec.get('content')} (proxied={rec.get('proxied')})")
if __name__ == "__main__":
main()
+217
View File
@@ -0,0 +1,217 @@
"""Pull traffic for dota2.refining.dev via Cloudflare GraphQL Analytics API.
Credentials from env (keyzoo inject). Read-only; prints a short summary.
"""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
from datetime import date, timedelta
HOST = "dota2.refining.dev"
ZONE_NAME = "refining.dev"
DAYS = 14
def headers() -> dict[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 credentials in env")
return {
"X-Auth-Email": email,
"X-Auth-Key": key,
"Content-Type": "application/json",
}
def api(path: str) -> dict:
req = urllib.request.Request(
"https://api.cloudflare.com/client/v4" + path,
method="GET",
headers=headers(),
)
with urllib.request.urlopen(req) as r:
return json.loads(r.read().decode())
def gql(query: str, variables: dict) -> dict:
req = urllib.request.Request(
"https://api.cloudflare.com/client/v4/graphql",
data=json.dumps({"query": query, "variables": variables}).encode(),
method="POST",
headers=headers(),
)
try:
with urllib.request.urlopen(req) as r:
return json.loads(r.read().decode())
except urllib.error.HTTPError as e:
body = e.read().decode()
raise SystemExit(f"HTTP {e.code}: {body[:2000]}") from e
def fmt_bytes(n: int | float | None) -> str:
if n is None:
return "-"
n = float(n)
for unit in ("B", "KB", "MB", "GB", "TB"):
if abs(n) < 1024:
return f"{n:.1f} {unit}"
n /= 1024
return f"{n:.1f} PB"
def main() -> None:
zd = api(f"/zones?name={ZONE_NAME}")
zone = (zd.get("result") or [None])[0]
if not zone:
raise SystemExit(f"zone not found: {zd.get('errors')}")
zid = zone["id"]
end = date.today()
start = end - timedelta(days=DAYS)
# Free plan: httpRequests1dGroups works for multi-day zone totals.
# Adaptive (host/path filter) is limited to a 1-day window.
zone_q = """
query($zoneTag: string!, $start: Date!, $end: Date!) {
viewer {
zones(filter: {zoneTag: $zoneTag}) {
httpRequests1dGroups(
limit: 20
filter: {date_geq: $start, date_leq: $end}
orderBy: [date_ASC]
) {
dimensions { date }
sum { requests bytes cachedRequests threats }
uniq { uniques }
}
}
}
}
"""
data = gql(
zone_q,
{"zoneTag": zid, "start": start.isoformat(), "end": end.isoformat()},
)
if data.get("errors"):
raise SystemExit(json.dumps(data["errors"], indent=2)[:4000])
rows = data["data"]["viewer"]["zones"][0]["httpRequests1dGroups"]
print(f"\n{ZONE_NAME} zone (all hosts) {start} .. {end}")
print(f"{'date':<12} {'requests':>10} {'uniques':>10} {'bytes':>12} {'threats':>8}")
tot_r = tot_u = tot_b = tot_t = 0
for row in rows:
d = row["dimensions"]["date"]
s = row["sum"]
u = row["uniq"]["uniques"]
print(
f"{d:<12} {s['requests']:>10} {u:>10} "
f"{fmt_bytes(s['bytes']):>12} {s['threats']:>8}"
)
tot_r += s["requests"]
tot_u += u
tot_b += s["bytes"]
tot_t += s["threats"]
print(
f"{'TOTAL':<12} {tot_r:>10} {tot_u:>10} "
f"{fmt_bytes(tot_b):>12} {tot_t:>8}"
)
# Host-scoped detail: one day at a time (Free quota).
host_q = """
query($zoneTag: string!, $day: Date!, $host: string!) {
viewer {
zones(filter: {zoneTag: $zoneTag}) {
total: httpRequestsAdaptiveGroups(
limit: 1
filter: {date: $day, clientRequestHTTPHost: $host}
) {
count
sum { edgeResponseBytes visits }
}
byPath: httpRequestsAdaptiveGroups(
limit: 15
filter: {date: $day, clientRequestHTTPHost: $host}
orderBy: [count_DESC]
) {
dimensions { clientRequestPath }
count
sum { visits }
}
byCountry: httpRequestsAdaptiveGroups(
limit: 8
filter: {date: $day, clientRequestHTTPHost: $host}
orderBy: [count_DESC]
) {
dimensions { clientCountryName }
count
sum { visits }
}
byStatus: httpRequestsAdaptiveGroups(
limit: 8
filter: {date: $day, clientRequestHTTPHost: $host}
orderBy: [count_DESC]
) {
dimensions { edgeResponseStatus }
count
}
}
}
}
"""
print(f"\n{HOST} host detail (last {min(DAYS, 7)} days, Free=1d/query)")
print(f"{'date':<12} {'requests':>10} {'visits':>10} {'bytes':>12}")
host_days = []
for i in range(min(DAYS, 7), -1, -1):
day = end - timedelta(days=i)
hd = gql(
host_q,
{"zoneTag": zid, "day": day.isoformat(), "host": HOST},
)
if hd.get("errors"):
print(f"{day.isoformat():<12} error: {hd['errors'][0].get('message')}")
continue
z = hd["data"]["viewer"]["zones"][0]
tot = (z["total"] or [{}])[0]
c = tot.get("count") or 0
v = (tot.get("sum") or {}).get("visits") or 0
b = (tot.get("sum") or {}).get("edgeResponseBytes") or 0
print(f"{day.isoformat():<12} {c:>10} {v:>10} {fmt_bytes(b):>12}")
host_days.append((day, z, c, v, b))
# Detail for the busiest recent day.
pick = max(host_days, key=lambda x: x[2], default=None)
if pick and pick[2] > 0:
day, z, _, _, _ = pick
print(f"\nDetail for busiest day {day.isoformat()} ({HOST})")
print("Top paths:")
for row in z["byPath"][:15]:
path = row["dimensions"].get("clientRequestPath") or "/"
v = (row.get("sum") or {}).get("visits") or 0
print(f" {row['count']:>8} req {v:>6} visits {path}")
print("Top countries:")
for row in z["byCountry"]:
name = row["dimensions"].get("clientCountryName") or "?"
v = (row.get("sum") or {}).get("visits") or 0
print(f" {row['count']:>8} req {v:>6} visits {name}")
print("Status codes:")
for row in z["byStatus"]:
st = row["dimensions"].get("edgeResponseStatus")
print(f" {st}: {row['count']}")
print(
"\nNote: edge requests/visits via Cloudflare proxy (not RUM pageviews). "
"Includes assets/bots. Zone totals mix all refining.dev hosts; "
"host table is dota2-only."
)
if __name__ == "__main__":
main()
+179
View File
@@ -0,0 +1,179 @@
"""Stage / apply Gitea Actions secrets for site-traffic-notify.
Pass 1 (feishu): python _gitea_actions_secrets.py stash-feishu
Pass 2 (cloudflare): python _gitea_actions_secrets.py stash-cf
Pass 3 (gitea PAT): python _gitea_actions_secrets.py apply
Staging file is user-only and deleted after apply. Never prints secret values.
"""
from __future__ import annotations
import json
import os
import stat
import sys
import urllib.error
import urllib.request
from pathlib import Path
STASH = Path(os.environ.get("TEMP") or os.environ.get("TMP") or ".") / "climperor_actions_secrets.json"
GITEA_URL = os.environ.get("GITEA_URL") or os.environ.get("KEYZOO_ASSET_META_URL") or "https://gitea.refining.dev"
OWNER = "refining"
REPO = "climperor"
SECRET_NAMES = (
"CLOUDFLARE_EMAIL",
"CLOUDFLARE_API_KEY",
"FEISHU_WEBHOOK_URL",
)
def _load() -> dict:
if not STASH.exists():
return {}
return json.loads(STASH.read_text(encoding="utf-8"))
def _save(data: dict) -> None:
STASH.write_text(json.dumps(data), encoding="utf-8")
try:
os.chmod(STASH, stat.S_IRUSR | stat.S_IWUSR)
except OSError:
pass
def stash_feishu() -> None:
url = os.environ.get("KEYZOO_ASSET_SECRET_FEISHU_WEBHOOK_URL") or os.environ.get(
"FEISHU_WEBHOOK_URL"
)
if not url:
raise SystemExit("missing FEISHU webhook in env")
data = _load()
data["FEISHU_WEBHOOK_URL"] = url
_save(data)
print(f"stashed FEISHU_WEBHOOK_URL ({STASH.name})")
def stash_cf() -> None:
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/key in env")
data = _load()
data["CLOUDFLARE_EMAIL"] = email
data["CLOUDFLARE_API_KEY"] = key
_save(data)
print(f"stashed CLOUDFLARE_* ({STASH.name})")
def _token() -> str:
# Prefer keyzoo-injected secrets over a possibly stale GITEA_TOKEN in the shell.
candidates = (
"KEYZOO_ASSET_SECRET_PERSONAL_ACCESS_TOKEN_GITEA_1",
"KEYZOO_ASSET_SECRET_PERSONAL_ACCESS_TOKEN__GITEA_1",
"KEYZOO_ASSET_TOKEN",
"KEYZOO_ASSET_API_KEY",
"GITEA_TOKEN",
)
for name in candidates:
tok = os.environ.get(name)
if tok:
print(f"using token from {name} (len={len(tok)})")
return tok
raise SystemExit("missing Gitea token in env")
def _put_secret(name: str, value: str, token: str) -> None:
# Gitea Actions: PUT /api/v1/repos/{owner}/{repo}/actions/secrets/{secretname}
url = f"{GITEA_URL.rstrip('/')}/api/v1/repos/{OWNER}/{REPO}/actions/secrets/{name}"
body = json.dumps({"data": value}).encode()
req = urllib.request.Request(
url,
data=body,
method="PUT",
headers={
"Authorization": f"token {token}",
"Content-Type": "application/json",
"Accept": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=60) as r:
code = r.status
_ = r.read()
except urllib.error.HTTPError as e:
raw = e.read().decode(errors="replace")
raise SystemExit(f"PUT {name} HTTP {e.code}: {raw[:500]}") from e
if code not in (201, 204, 200):
raise SystemExit(f"PUT {name} unexpected status {code}")
print(f"ok: {name}")
def apply() -> None:
data = _load()
present = [n for n in SECRET_NAMES if data.get(n)]
missing = [n for n in SECRET_NAMES if n not in present]
if not present:
raise SystemExit(f"stash empty; expected one of {SECRET_NAMES}")
if missing:
print(f"note: not in stash (left unchanged on server): {missing}")
token = _token()
base = GITEA_URL.rstrip("/")
# Sanity: can we see the repo?
req = urllib.request.Request(
f"{base}/api/v1/repos/{OWNER}/{REPO}",
headers={"Authorization": f"token {token}", "Accept": "application/json"},
)
with urllib.request.urlopen(req, timeout=60) as r:
repo = json.loads(r.read().decode())
print(f"repo: {repo.get('full_name')} (id={repo.get('id')})")
for name in present:
_put_secret(name, data[name], token)
try:
STASH.unlink()
except OSError:
pass
print("stash deleted; secrets applied")
def list_secrets() -> None:
token = _token()
url = f"{GITEA_URL.rstrip('/')}/api/v1/repos/{OWNER}/{REPO}/actions/secrets"
req = urllib.request.Request(
url,
headers={"Authorization": f"token {token}", "Accept": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=60) as r:
rows = json.loads(r.read().decode())
except urllib.error.HTTPError as e:
raise SystemExit(f"list secrets HTTP {e.code}: {e.read().decode()[:500]}") from e
names = sorted(x.get("name") for x in (rows or []))
print("secrets:", names)
def main() -> int:
if len(sys.argv) < 2:
raise SystemExit("usage: stash-feishu | stash-cf | apply | list")
cmd = sys.argv[1]
if cmd == "stash-feishu":
stash_feishu()
elif cmd == "stash-cf":
stash_cf()
elif cmd == "apply":
apply()
elif cmd == "list":
list_secrets()
else:
raise SystemExit(f"unknown cmd: {cmd}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+153
View File
@@ -0,0 +1,153 @@
"""Push climperor and dispatch site-traffic-notify via Gitea API.
Env: KEYZOO_ASSET_SECRET_PERSONAL_ACCESS_TOKEN_GITEA_1 (or GITEA_TOKEN),
optional KEYZOO_ASSET_META_URL.
Never prints the token.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import urllib.error
import urllib.request
OWNER = "refining"
REPO = "climperor"
WORKFLOW_FILE = "site-traffic-notify.yml"
def token() -> str:
for name in (
"KEYZOO_ASSET_SECRET_PERSONAL_ACCESS_TOKEN_GITEA_1",
"KEYZOO_ASSET_SECRET_PERSONAL_ACCESS_TOKEN__GITEA_1",
"KEYZOO_ASSET_TOKEN",
"GITEA_TOKEN",
):
v = os.environ.get(name)
if v:
return v
raise SystemExit("missing Gitea token")
def base() -> str:
return (
os.environ.get("GITEA_URL")
or os.environ.get("KEYZOO_ASSET_META_URL")
or "https://gitea.refining.dev"
).rstrip("/")
def api(method: str, path: str, body: dict | None = None) -> tuple[int, dict | list | str]:
url = base() + path
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(
url,
data=data,
method=method,
headers={
"Authorization": f"token {token()}",
"Content-Type": "application/json",
"Accept": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=120) as r:
raw = r.read().decode()
if not raw:
return r.status, {}
try:
return r.status, json.loads(raw)
except json.JSONDecodeError:
return r.status, raw
except urllib.error.HTTPError as e:
raw = e.read().decode(errors="replace")
try:
return e.code, json.loads(raw)
except json.JSONDecodeError:
return e.code, raw
def push() -> None:
# Prefer HTTPS with token in URL for non-interactive push.
tok = token()
remote = f"https://oauth2:{tok}@gitea.refining.dev/{OWNER}/{REPO}.git"
env = os.environ.copy()
# Avoid leaking via git trace
env.pop("GIT_TRACE", None)
r = subprocess.run(
["git", "push", remote, "HEAD:main"],
cwd=os.path.dirname(os.path.abspath(__file__)) or ".",
capture_output=True,
text=True,
env=env,
timeout=180,
)
# Redact token if it somehow appears
out = (r.stdout or "") + (r.stderr or "")
out = out.replace(tok, "***")
print(out)
if r.returncode != 0:
raise SystemExit(f"git push failed: {r.returncode}")
def dispatch(day: str = "") -> None:
# Gitea: POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches
# workflow_id can be filename
body: dict = {"ref": "main"}
if day:
body["inputs"] = {"day": day}
code, resp = api(
"POST",
f"/api/v1/repos/{OWNER}/{REPO}/actions/workflows/{WORKFLOW_FILE}/dispatches",
body,
)
print(f"dispatch HTTP {code}: {resp}")
if code not in (200, 201, 204):
# Fallback: list workflows to find id
c2, workflows = api("GET", f"/api/v1/repos/{OWNER}/{REPO}/actions/workflows")
print(f"workflows HTTP {c2}: {workflows}")
raise SystemExit(1)
def latest_runs() -> None:
code, resp = api("GET", f"/api/v1/repos/{OWNER}/{REPO}/actions/runs?limit=5")
print(f"runs HTTP {code}")
if isinstance(resp, dict):
rows = resp.get("workflow_runs") or resp.get("runs") or []
elif isinstance(resp, list):
rows = resp
else:
print(resp)
return
for r in rows[:5]:
if isinstance(r, dict):
print(
f" - id={r.get('id')} name={r.get('name') or r.get('workflow_id')} "
f"status={r.get('status')} conclusion={r.get('conclusion')} "
f"event={r.get('event')} created={r.get('created_at')}"
)
def main() -> int:
cmd = sys.argv[1] if len(sys.argv) > 1 else "all"
if cmd == "push":
push()
elif cmd == "dispatch":
day = sys.argv[2] if len(sys.argv) > 2 else ""
dispatch(day)
elif cmd == "runs":
latest_runs()
elif cmd == "all":
push()
dispatch("2026-07-27")
latest_runs()
else:
raise SystemExit("usage: push | dispatch [day] | runs | all")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+77
View File
@@ -0,0 +1,77 @@
"""Two-pass keyzoo helper: stash Feishu URL then run notify with Cloudflare.
Pass 1 (feishu asset): python _notify_pass_feishu.py stash
Pass 2 (cloudflare): python _notify_pass_feishu.py run [--day ...] [--dry-run]
Temp file is user-only and deleted after run.
"""
from __future__ import annotations
import argparse
import os
import stat
import sys
from pathlib import Path
STASH = Path(os.environ.get("TEMP") or os.environ.get("TMP") or ".") / "climperor_feishu_wh.url"
GITEA_STASH = Path(os.environ.get("TEMP") or os.environ.get("TMP") or ".") / "climperor_gitea.tok"
def stash() -> None:
url = os.environ.get("KEYZOO_ASSET_SECRET_FEISHU_WEBHOOK_URL") or os.environ.get(
"FEISHU_WEBHOOK_URL"
)
if not url:
raise SystemExit("no feishu webhook in env")
STASH.write_text(url, encoding="utf-8")
try:
os.chmod(STASH, stat.S_IRUSR | stat.S_IWUSR)
except OSError:
pass
print(f"stashed webhook -> {STASH.name} ({STASH.exists()})")
def run(argv: list[str]) -> int:
if not STASH.exists():
raise SystemExit(f"missing stash {STASH}; run pass1 first")
url = STASH.read_text(encoding="utf-8").strip()
try:
STASH.unlink()
except OSError:
pass
os.environ["FEISHU_WEBHOOK_URL"] = url
if GITEA_STASH.exists():
tok = GITEA_STASH.read_text(encoding="utf-8").strip()
try:
GITEA_STASH.unlink()
except OSError:
pass
if tok:
os.environ["GITEA_TOKEN"] = tok
# Import after env is set
import notify_site_traffic as n
return n.main(argv)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("cmd", choices=("stash", "run"))
ap.add_argument("--day")
ap.add_argument("--dry-run", action="store_true")
args, rest = ap.parse_known_args()
if args.cmd == "stash":
stash()
return 0
argv: list[str] = []
if args.day:
argv += ["--day", args.day]
if args.dry_run:
argv += ["--dry-run"]
argv += rest
return run(argv)
if __name__ == "__main__":
raise SystemExit(main())
+296
View File
@@ -0,0 +1,296 @@
"""One-shot helper: create OSS bucket + sync ability videos (keyzoo inject).
Env (from keyzoo asset_exec on digitevents/voson-RAM):
KEYZOO_ASSET_META_ACCESSKEY_ID
KEYZOO_ASSET_SECRET_ACCESSKEY_SECRET
Usage:
python _oss_ability_videos.py setup
python _oss_ability_videos.py upload [--force]
python _oss_ability_videos.py verify
"""
from __future__ import annotations
import argparse
import mimetypes
import os
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import oss2
from oss2.models import BucketCors, CorsRule
from shared.paths import ABILITY_VIDEOS, ROOT
BUCKET_CANDIDATES = ("climperor", "climperor-videos", "climperor-ability-videos")
ENDPOINT = "https://oss-cn-shanghai.aliyuncs.com"
REGION = "cn-shanghai"
PREFIX = "ability-video/"
DEFAULT_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
CORS_ORIGINS = [
"https://dota2.refining.dev",
"https://climperor-relations.pages.dev",
"http://localhost:8080",
"http://localhost:8765",
"http://127.0.0.1:8080",
"http://127.0.0.1:8765",
"http://localhost:3000",
"http://127.0.0.1:3000",
]
def _creds() -> tuple[str, str]:
ak = os.environ.get("KEYZOO_ASSET_META_ACCESSKEY_ID") or os.environ.get(
"OSS_ACCESS_KEY_ID"
)
sk = os.environ.get("KEYZOO_ASSET_SECRET_ACCESSKEY_SECRET") or os.environ.get(
"OSS_ACCESS_KEY_SECRET"
)
if not ak or not sk:
raise SystemExit(
"missing AccessKey: inject via keyzoo or set OSS_ACCESS_KEY_ID / "
"OSS_ACCESS_KEY_SECRET"
)
return ak, sk
def _auth() -> oss2.Auth:
ak, sk = _creds()
return oss2.Auth(ak, sk)
def _list_bucket_names(auth: oss2.Auth) -> list[str]:
service = oss2.Service(auth, ENDPOINT)
return [b.name for b in oss2.BucketIterator(service)]
def _bucket(auth: oss2.Auth, name: str) -> oss2.Bucket:
return oss2.Bucket(auth, ENDPOINT, name)
def setup_bucket() -> str:
auth = _auth()
existing = _list_bucket_names(auth)
print(f"existing buckets: {', '.join(existing) or '(none)'}")
chosen = None
for name in BUCKET_CANDIDATES:
if name in existing:
chosen = name
print(f"bucket_exists: {name}")
break
bucket = _bucket(auth, name)
try:
bucket.create_bucket(oss2.BUCKET_ACL_PUBLIC_READ)
chosen = name
print(f"created: {name}")
break
except oss2.exceptions.BucketAlreadyExists:
print(f"name_conflict (other account?): {name}")
continue
except oss2.exceptions.OssError as exc:
print(f"create_failed {name}: {exc.code} {exc.message[:160]}")
continue
if not chosen:
raise SystemExit("could not create or reuse any candidate bucket name")
bucket = _bucket(auth, chosen)
try:
bucket.put_bucket_acl(oss2.BUCKET_ACL_PUBLIC_READ)
print("acl: public-read")
except oss2.exceptions.OssError as exc:
print(f"acl_warn: {exc.code} {exc.message[:120]}")
rule = CorsRule(
allowed_origins=CORS_ORIGINS,
allowed_methods=["GET", "HEAD"],
allowed_headers=["*"],
expose_headers=["ETag", "Content-Type", "Content-Length"],
max_age_seconds=86400,
)
try:
bucket.put_bucket_cors(BucketCors([rule]))
print("cors: ok")
except oss2.exceptions.OssError as exc:
print(f"cors_warn: {exc.code} {exc.message[:200]}")
info = bucket.get_bucket_info()
base = f"https://{chosen}.oss-{REGION}.aliyuncs.com"
print(f"bucket: {chosen}")
print(f"location: {info.location}")
print(f"acl: {info.acl.grant if info.acl else '?'}")
print(f"public_base: {base}")
# Persist chosen name for upload step.
state = ROOT / ".oss_ability_videos_bucket"
state.write_text(chosen, encoding="utf-8")
return chosen
def _resolve_bucket_name(explicit: str | None) -> str:
if explicit:
return explicit
state = ROOT / ".oss_ability_videos_bucket"
if state.is_file():
return state.read_text(encoding="utf-8").strip()
return BUCKET_CANDIDATES[0]
def _iter_local_videos() -> list[Path]:
if not ABILITY_VIDEOS.is_dir():
raise SystemExit(f"missing local videos: {ABILITY_VIDEOS}")
files = sorted(
p
for p in ABILITY_VIDEOS.rglob("*")
if p.is_file() and p.suffix.lower() in {".webm", ".mp4"}
)
return files
def _object_key(local: Path) -> str:
rel = local.relative_to(ABILITY_VIDEOS).as_posix()
return PREFIX + rel
def upload(
bucket_name: str,
*,
force: bool = False,
max_files: int | None = None,
offset: int = 0,
) -> None:
auth = _auth()
bucket = _bucket(auth, bucket_name)
files = _iter_local_videos()
if offset or max_files is not None:
end = None if max_files is None else offset + max_files
files = files[offset:end]
total = len(files)
total_bytes = sum(f.stat().st_size for f in files)
print(
f"upload -> oss://{bucket_name}/{PREFIX} "
f"({total} files in this batch, {total_bytes / 1e9:.2f} GB; "
f"offset={offset})"
)
# Remote index for skip-if-same-size.
remote_sizes: dict[str, int] = {}
if not force:
print("listing remote objects ...")
for obj in oss2.ObjectIterator(bucket, prefix=PREFIX):
remote_sizes[obj.key] = int(obj.size)
print(f"remote objects under prefix: {len(remote_sizes)}")
uploaded = skipped = failed = 0
t0 = time.time()
for i, path in enumerate(files, 1):
key = _object_key(path)
size = path.stat().st_size
if not force and remote_sizes.get(key) == size:
skipped += 1
if i % 50 == 0 or i == total:
print(f"[{i}/{total}] skip {key}")
continue
headers = {}
ctype, _ = mimetypes.guess_type(str(path))
if path.suffix.lower() == ".webm":
ctype = "video/webm"
elif path.suffix.lower() == ".mp4":
ctype = "video/mp4"
if ctype:
headers["Content-Type"] = ctype
try:
# Resumable multipart for large files.
oss2.resumable_upload(
bucket,
key,
str(path),
headers=headers,
multipart_threshold=8 * 1024 * 1024,
part_size=8 * 1024 * 1024,
num_threads=4,
)
uploaded += 1
if i % 10 == 0 or i == total or uploaded <= 3:
elapsed = time.time() - t0
print(
f"[{i}/{total}] ok {key} "
f"({size / 1e6:.1f} MB) uploaded={uploaded} skipped={skipped} "
f"elapsed={elapsed:.0f}s"
)
except Exception as exc: # noqa: BLE001 — keep uploading rest
failed += 1
print(f"[{i}/{total}] FAIL {key}: {type(exc).__name__}: {exc}")
elapsed = time.time() - t0
print(
f"done: uploaded={uploaded} skipped={skipped} failed={failed} "
f"elapsed={elapsed:.0f}s"
)
if failed:
raise SystemExit(1)
def verify(bucket_name: str, samples: int = 5) -> None:
import urllib.request
auth = _auth()
bucket = _bucket(auth, bucket_name)
base = f"https://{bucket_name}.oss-{REGION}.aliyuncs.com"
files = _iter_local_videos()
# Prefer a few heroes spread across the alphabet.
picks = []
if files:
step = max(1, len(files) // samples)
picks = [files[i] for i in range(0, len(files), step)][:samples]
print(f"spot-check {len(picks)} URLs against {base}")
ok = 0
for path in picks:
key = _object_key(path)
url = f"{base}/{key}"
try:
req = urllib.request.Request(url, method="HEAD")
with urllib.request.urlopen(req, timeout=30) as resp:
code = resp.status
ctype = resp.headers.get("Content-Type", "")
clen = resp.headers.get("Content-Length", "")
print(f" {code} {ctype} len={clen} {url}")
if code == 200:
ok += 1
except Exception as exc: # noqa: BLE001
print(f" FAIL {url}: {exc}")
# Count remote.
n = sum(1 for _ in oss2.ObjectIterator(bucket, prefix=PREFIX))
print(f"remote object count under {PREFIX}: {n}")
print(f"spot-check ok: {ok}/{len(picks)}")
if ok < len(picks):
raise SystemExit(1)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("cmd", choices=("setup", "upload", "verify", "setup-upload"))
ap.add_argument("--bucket", default=None)
ap.add_argument("--force", action="store_true")
ap.add_argument("--max-files", type=int, default=None, help="upload at most N files")
ap.add_argument("--offset", type=int, default=0, help="skip first N local files")
args = ap.parse_args()
if args.cmd in ("setup", "setup-upload"):
name = setup_bucket()
else:
name = _resolve_bucket_name(args.bucket)
if args.cmd in ("upload", "setup-upload"):
upload(name, force=args.force, max_files=args.max_files, offset=args.offset)
if args.cmd == "verify":
verify(name)
if __name__ == "__main__":
main()
+99
View File
@@ -0,0 +1,99 @@
"""Fix climperor OSS public-read access (Block Public Access + policy)."""
from __future__ import annotations
import json
import os
import sys
import oss2
BUCKET = "climperor"
ENDPOINT = "https://oss-cn-shanghai.aliyuncs.com"
POLICY = {
"Version": "1",
"Statement": [
{
"Sid": "PublicReadAbilityVideos",
"Effect": "Allow",
"Principal": "*",
"Action": ["oss:GetObject", "oss:GetObjectAcl"],
"Resource": [f"acs:oss:*:*:{BUCKET}/ability-video/*"],
}
],
}
def main() -> None:
ak = os.environ["KEYZOO_ASSET_META_ACCESSKEY_ID"]
sk = os.environ["KEYZOO_ASSET_SECRET_ACCESSKEY_SECRET"]
auth = oss2.Auth(ak, sk)
b = oss2.Bucket(auth, ENDPOINT, BUCKET)
# 1) Try disable Block Public Access (required on newer Aliyun accounts).
try:
# oss2 >= 2.18: put_bucket_public_access_block(block_public_access=False)
if hasattr(b, "put_bucket_public_access_block"):
b.put_bucket_public_access_block(False)
print("public_access_block: disabled via SDK")
else:
# Raw REST: PUT /?publicAccessBlock with XML
xml = (
'<?xml version="1.0" encoding="UTF-8"?>'
"<PublicAccessBlockConfiguration>"
"<BlockPublicAccess>false</BlockPublicAccess>"
"</PublicAccessBlockConfiguration>"
)
resp = b._do("PUT", "", params={"publicAccessBlock": ""}, data=xml)
print(f"public_access_block: raw PUT status={resp.status}")
except Exception as exc: # noqa: BLE001
print(f"public_access_block_warn: {type(exc).__name__}: {exc}")
# 2) Bucket ACL public-read (may be denied by account policy).
try:
b.put_bucket_acl(oss2.BUCKET_ACL_PUBLIC_READ)
print("acl: public-read set")
except Exception as exc: # noqa: BLE001
print(f"acl_warn: {type(exc).__name__}: {exc}")
# 3) Bucket policy for anonymous GetObject under ability-video/.
try:
b.put_bucket_policy(json.dumps(POLICY))
print("policy: public GetObject on ability-video/*")
except Exception as exc: # noqa: BLE001
print(f"policy_fail: {type(exc).__name__}: {exc}")
raise SystemExit(1)
info = b.get_bucket_info()
print(f"acl_now: {info.acl.grant if info.acl else '?'}")
# 4) Probe one known object if any exist.
import urllib.request
sample = None
for obj in oss2.ObjectIterator(b, prefix="ability-video/", max_keys=1):
sample = obj.key
break
if not sample:
print("no objects yet to probe")
return
url = f"https://{BUCKET}.oss-cn-shanghai.aliyuncs.com/{sample}"
try:
req = urllib.request.Request(url, method="HEAD")
with urllib.request.urlopen(req, timeout=20) as resp:
print(f"probe: {resp.status} {resp.headers.get('Content-Type')} {url}")
except Exception as exc: # noqa: BLE001
print(f"probe_fail: {exc}")
# Try signed URL to confirm object exists
signed = b.sign_url("HEAD", sample, 60)
print(f"signed_head_url_len={len(signed)} (object exists check via SDK)")
try:
meta = b.head_object(sample)
print(f"sdk_head: ok content_type={meta.content_type} size={meta.content_length}")
except Exception as exc2: # noqa: BLE001
print(f"sdk_head_fail: {exc2}")
if __name__ == "__main__":
main()
+46
View File
@@ -0,0 +1,46 @@
"""Launch OSS upload as a detached process inheriting current env (keyzoo inject)."""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
WEB_DIR = Path(__file__).resolve().parent
LOG = ROOT / "oss_upload.log"
ERR = ROOT / "oss_upload_err.log"
PID = ROOT / "oss_upload.pid"
def main() -> None:
if not os.environ.get("KEYZOO_ASSET_SECRET_ACCESSKEY_SECRET") and not os.environ.get(
"OSS_ACCESS_KEY_SECRET"
):
raise SystemExit("missing AccessKey secret in env")
# Clear previous logs.
for p in (LOG, ERR):
if p.exists():
p.unlink()
creationflags = 0
if sys.platform == "win32":
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
out = open(LOG, "w", encoding="utf-8")
err = open(ERR, "w", encoding="utf-8")
proc = subprocess.Popen(
[sys.executable, str(WEB_DIR / "_oss_ability_videos.py"), "upload"],
cwd=str(ROOT),
stdout=out,
stderr=err,
env=os.environ.copy(),
creationflags=creationflags,
close_fds=True,
)
PID.write_text(str(proc.pid), encoding="utf-8")
print(f"started_pid={proc.pid}")
print(f"log={LOG}")
if __name__ == "__main__":
main()
+189
View File
@@ -0,0 +1,189 @@
"""Sync Climperor web site static images to Aliyun OSS (keyzoo inject).
Builds the same portrait/item/ability/... tree as export_relations_site and
uploads under oss://<bucket>/{attr,item,portrait,...}/.
Env (from keyzoo asset_exec on digitevents/voson-RAM):
KEYZOO_ASSET_META_ACCESSKEY_ID
KEYZOO_ASSET_SECRET_ACCESSKEY_SECRET
Usage:
python _oss_static_assets.py upload [--force]
python _oss_static_assets.py verify
"""
from __future__ import annotations
import argparse
import mimetypes
import os
import shutil
import sys
import tempfile
import time
import urllib.request
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import oss2
from shared.paths import ROOT
from export_relations_site import DEFAULT_OSS_BASE, populate_static_assets
from serve_relations import build_payload
ENDPOINT = "https://oss-cn-shanghai.aliyuncs.com"
REGION = "cn-shanghai"
ASSET_DIRS = (
"attr",
"rank",
"item",
"item-cat",
"ability",
"ui-icon",
"portrait",
"streamer-avatar",
"streamer-video",
)
def _creds() -> tuple[str, str]:
ak = os.environ.get("KEYZOO_ASSET_META_ACCESSKEY_ID") or os.environ.get(
"OSS_ACCESS_KEY_ID"
)
sk = os.environ.get("KEYZOO_ASSET_SECRET_ACCESSKEY_SECRET") or os.environ.get(
"OSS_ACCESS_KEY_SECRET"
)
if not ak or not sk:
raise SystemExit(
"missing AccessKey: inject via keyzoo or set OSS_ACCESS_KEY_ID / "
"OSS_ACCESS_KEY_SECRET"
)
return ak, sk
def _bucket(name: str) -> oss2.Bucket:
return oss2.Bucket(oss2.Auth(*_creds()), ENDPOINT, name)
def _resolve_bucket(explicit: str | None) -> str:
if explicit:
return explicit
state = ROOT / ".oss_ability_videos_bucket"
if state.is_file():
return state.read_text(encoding="utf-8").strip()
return "climperor"
def _build_staging() -> tuple[Path, dict[str, int]]:
payload = build_payload()
staging = Path(tempfile.mkdtemp(prefix="climperor-static-"))
counts = populate_static_assets(staging, payload)
return staging, counts
def _iter_files(root: Path) -> list[Path]:
files: list[Path] = []
for sub in ASSET_DIRS:
d = root / sub
if not d.is_dir():
continue
files.extend(sorted(p for p in d.rglob("*") if p.is_file()))
return files
def upload(bucket_name: str, *, force: bool = False) -> None:
staging, counts = _build_staging()
try:
files = _iter_files(staging)
total_bytes = sum(f.stat().st_size for f in files)
print(f"staging {staging} ({len(files)} files, {total_bytes / 1e6:.1f} MB)")
for name, n in counts.items():
print(f" {name}/: {n}")
bucket = _bucket(bucket_name)
remote_sizes: dict[str, int] = {}
if not force:
print("listing remote static objects ...")
for sub in ASSET_DIRS:
for obj in oss2.ObjectIterator(bucket, prefix=f"{sub}/"):
remote_sizes[obj.key] = int(obj.size)
print(f"remote keys indexed: {len(remote_sizes)}")
uploaded = skipped = failed = 0
t0 = time.time()
for i, path in enumerate(files, 1):
key = path.relative_to(staging).as_posix()
size = path.stat().st_size
if not force and remote_sizes.get(key) == size:
skipped += 1
continue
headers: dict[str, str] = {}
ctype, _ = mimetypes.guess_type(str(path))
if ctype:
headers["Content-Type"] = ctype
try:
bucket.put_object_from_file(key, str(path), headers=headers)
uploaded += 1
if i % 100 == 0 or i == len(files) or uploaded <= 5:
print(f"[{i}/{len(files)}] ok {key} ({size} bytes)")
except Exception as exc: # noqa: BLE001
failed += 1
print(f"[{i}/{len(files)}] FAIL {key}: {exc}")
elapsed = time.time() - t0
print(
f"done: uploaded={uploaded} skipped={skipped} failed={failed} "
f"elapsed={elapsed:.0f}s"
)
if failed:
raise SystemExit(1)
finally:
shutil.rmtree(staging, ignore_errors=True)
def verify(bucket_name: str, samples: int = 8) -> None:
staging, _ = _build_staging()
try:
files = _iter_files(staging)
if not files:
raise SystemExit("no files in staging — run fetch scripts first")
base = f"https://{bucket_name}.oss-{REGION}.aliyuncs.com"
step = max(1, len(files) // samples)
picks = [files[i] for i in range(0, len(files), step)][:samples]
ok = 0
for path in picks:
key = path.relative_to(staging).as_posix()
url = f"{base}/{key}"
try:
req = urllib.request.Request(url, method="HEAD")
with urllib.request.urlopen(req, timeout=30) as resp:
print(f" {resp.status} {resp.headers.get('Content-Type', '')} {url}")
if resp.status == 200:
ok += 1
except Exception as exc: # noqa: BLE001
print(f" FAIL {url}: {exc}")
print(f"spot-check ok: {ok}/{len(picks)}")
if ok < len(picks):
raise SystemExit(1)
finally:
shutil.rmtree(staging, ignore_errors=True)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("cmd", choices=("upload", "verify"))
ap.add_argument("--bucket", default=None)
ap.add_argument("--force", action="store_true")
args = ap.parse_args()
name = _resolve_bucket(args.bucket)
print(f"bucket: {name} public_base: {DEFAULT_OSS_BASE}")
if args.cmd == "upload":
upload(name, force=args.force)
else:
verify(name)
if __name__ == "__main__":
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

+832
View File
@@ -0,0 +1,832 @@
{
"meta": {
"note": "Manual add/remove tags merged after auto-tagging in fetch_hero_abilities.py. Tags mean the ability applies the effect (not that it is dispellable)."
},
"abilities": {
"abaddon_aphotic_shield": {
"add": [
"basic_dispel"
],
"remove": []
},
"abaddon_borrowed_time": {
"add": [
"strong_dispel"
],
"remove": []
},
"legion_commander_press_the_attack": {
"add": [
"basic_dispel"
],
"remove": []
},
"oracle_false_promise": {
"add": [
"strong_dispel"
],
"remove": []
},
"oracle_fates_edict": {
"add": [
"disarm"
],
"remove": []
},
"keeper_of_the_light_chakra_magic": {
"add": [
"basic_dispel"
],
"remove": []
},
"shadow_demon_demonic_purge": {
"add": [
"basic_dispel"
],
"remove": []
},
"shadow_demon_demonic_cleanse": {
"add": [
"strong_dispel"
],
"remove": []
},
"brewmaster_storm_dispel_magic": {
"add": [
"basic_dispel"
],
"remove": []
},
"tidehunter_kraken_shell": {
"add": [
"strong_dispel"
],
"remove": []
},
"enchantress_enchant": {
"add": [
"basic_dispel"
],
"remove": []
},
"phoenix_supernova": {
"add": [
"strong_dispel"
],
"remove": []
},
"viper_nethertoxin": {
"add": [
"break"
],
"remove": []
},
"huskar_inner_fire": {
"add": [
"disarm"
],
"remove": []
},
"troll_warlord_whirling_axes_melee": {
"add": [
"disarm"
],
"remove": []
},
"doom_bringer_doom": {
"add": [
"mute",
"break"
],
"remove": [
"silence"
]
},
"lion_voodoo": {
"add": [
"hex"
],
"remove": [
"silence",
"disarm",
"mute"
]
},
"shadow_shaman_voodoo": {
"add": [
"hex"
],
"remove": [
"silence",
"disarm",
"mute"
]
},
"omniknight_purification": {
"add": [],
"remove": [
"basic_dispel"
]
},
"silencer_glaives_of_wisdom": {
"add": [],
"remove": [
"silence"
]
},
"silencer_brain_drain": {
"add": [],
"remove": [
"silence"
]
},
"silencer_curse_of_the_silent": {
"add": [],
"remove": [
"silence"
]
},
"spirit_breaker_bull_rush": {
"add": [],
"remove": [
"stun"
]
},
"lone_druid_spirit_bear": {
"add": [],
"remove": [
"root"
]
},
"ringmaster_the_box": {
"add": [],
"remove": [
"disarm",
"silence",
"mute"
]
},
"largo_amphibian_rhapsody": {
"add": [],
"remove": [
"disarm"
]
},
"kez_shodo_sai": {
"add": [],
"remove": [
"disarm"
]
},
"bane_enfeeble": {
"add": [],
"remove": [
"silence"
]
},
"axe_culling_blade": {
"add": [],
"remove": [
"basic_dispel",
"strong_dispel"
]
},
"chen_hand_of_god": {
"add": [],
"remove": [
"basic_dispel",
"strong_dispel"
]
},
"omniknight_martyr": {
"add": [],
"remove": [
"basic_dispel",
"strong_dispel"
]
},
"winter_wyvern_cold_embrace": {
"add": [],
"remove": [
"basic_dispel"
]
},
"disruptor_glimpse": {
"add": [],
"remove": [
"basic_dispel"
]
},
"legion_commander_duel": {
"add": [
"taunt"
],
"remove": [
"stun",
"disarm"
]
},
"axe_berserkers_call": {
"add": [
"taunt"
],
"remove": [
"stun"
]
},
"doom_bringer_infernal_blade": {
"add": [],
"remove": [
"stun"
]
},
"elder_titan_earth_splitter": {
"add": [],
"remove": [
"stun"
]
},
"ursa_earthshock": {
"add": [],
"remove": [
"stun"
]
},
"brewmaster_thunder_clap": {
"add": [],
"remove": [
"stun"
]
},
"batrider_sticky_napalm": {
"add": [],
"remove": [
"root"
]
},
"templar_assassin_trap": {
"add": [],
"remove": [
"root"
]
},
"enchantress_untouchable": {
"add": [],
"remove": [
"disarm",
"blind"
]
},
"keeper_of_the_light_blinding_light": {
"add": [
"blind"
],
"remove": [
"disarm"
]
},
"muerta_pierce_the_veil": {
"add": [],
"remove": [
"disarm",
"ethereal"
]
},
"mars_gods_rebuke": {
"add": [],
"remove": [
"disarm"
]
},
"pangolier_shield_crash": {
"add": [],
"remove": [
"disarm"
]
},
"bristleback_bristleback": {
"add": [],
"remove": [
"disarm"
]
},
"centaur_hoof_stomp": {
"add": [
"stun"
],
"remove": [
"disarm"
]
},
"queenofpain_blink": {
"add": [],
"remove": [
"silence"
]
},
"dazzle_nothl_projection": {
"add": [],
"remove": [
"hex",
"ethereal"
]
},
"naga_siren_ensnare": {
"add": [
"root"
],
"remove": [
"break"
]
},
"sven_storm_bolt": {
"add": [
"stun"
],
"remove": [
"basic_dispel"
]
},
"juggernaut_omni_slash": {
"add": [],
"remove": [
"basic_dispel",
"strong_dispel"
]
},
"juggernaut_blade_fury": {
"add": [],
"remove": [
"basic_dispel",
"strong_dispel"
]
},
"bounty_hunter_wind_walk": {
"add": [
"invis"
],
"remove": []
},
"riki_blink_strike": {
"add": [],
"remove": [
"invis"
]
},
"clinkz_wind_walk": {
"add": [
"invis"
],
"remove": []
},
"clinkz_skeleton_walk": {
"add": [
"invis"
],
"remove": []
},
"weaver_shukuchi": {
"add": [
"invis"
],
"remove": []
},
"mirana_invis": {
"add": [
"invis"
],
"remove": []
},
"invoker_ghost_walk": {
"add": [
"invis"
],
"remove": []
},
"nyx_assassin_vendetta": {
"add": [
"invis"
],
"remove": []
},
"sandking_sand_storm": {
"add": [
"invis"
],
"remove": []
},
"templar_assassin_meld": {
"add": [
"invis"
],
"remove": []
},
"treant_natures_guise": {
"add": [
"invis"
],
"remove": []
},
"windrunner_windrun": {
"add": [],
"remove": [
"invis"
]
},
"sniper_assassinate": {
"add": [],
"remove": [
"invis"
]
},
"drow_ranger_wave_of_silence": {
"add": [
"silence"
],
"remove": [
"invis"
]
},
"phantom_lancer_juxtapose": {
"add": [],
"remove": [
"invis"
]
},
"slardar_amplify_damage": {
"add": [],
"remove": [
"basic_dispel",
"strong_dispel",
"invis"
]
},
"riki_backstab": {
"add": [],
"remove": [
"invis"
]
},
"riki_smoke_screen": {
"add": [
"silence"
],
"remove": [
"invis"
]
},
"clinkz_strafe": {
"add": [],
"remove": [
"invis"
]
},
"bounty_hunter_track": {
"add": [],
"remove": [
"invis"
]
},
"lycan_summon_wolves": {
"add": [],
"remove": [
"invis"
]
},
"treant_overgrowth": {
"add": [
"root"
],
"remove": [
"invis"
]
},
"nyx_assassin_spiked_carapace": {
"add": [],
"remove": [
"invis"
]
},
"nyx_assassin_burrow": {
"add": [],
"remove": [
"invis"
]
},
"arc_warden_tempest_double": {
"add": [],
"remove": [
"invis"
]
},
"monkey_king_wukongs_command": {
"add": [],
"remove": [
"invis"
]
},
"hoodwink_decoy": {
"add": [],
"remove": [
"invis"
]
},
"ringmaster_spotlight": {
"add": [],
"remove": [
"invis"
]
},
"kez_ravens_veil": {
"add": [
"invis"
],
"remove": []
},
"slark_shadow_dance": {
"add": [
"invis"
],
"remove": []
},
"visage_silent_as_the_grave": {
"add": [
"invis"
],
"remove": []
},
"bounty_hunter_wind_walk_ally": {
"add": [
"invis"
],
"remove": []
},
"bane_nightmare": {
"add": [
"sleep"
],
"remove": []
},
"naga_siren_song_of_the_siren": {
"add": [
"sleep"
],
"remove": []
},
"dark_willow_terrorize": {
"add": [
"fear"
],
"remove": []
},
"lone_druid_savage_roar": {
"add": [
"fear"
],
"remove": [
"stun"
]
},
"nevermore_requiem": {
"add": [
"fear"
],
"remove": []
},
"brewmaster_cinder_brew": {
"add": [
"blind"
],
"remove": []
},
"brewmaster_drunken_haze": {
"add": [
"blind"
],
"remove": []
},
"tinker_laser": {
"add": [
"blind"
],
"remove": []
},
"troll_warlord_whirling_axes_ranged": {
"add": [
"blind"
],
"remove": [
"disarm"
]
},
"disruptor_kinetic_field": {
"add": [
"leash"
],
"remove": []
},
"grimstroke_soul_chain": {
"add": [
"leash"
],
"remove": []
},
"puck_dream_coil": {
"add": [
"leash",
"stun"
],
"remove": []
},
"mars_arena_of_blood": {
"add": [
"leash"
],
"remove": []
},
"pugna_decrepify": {
"add": [
"ethereal"
],
"remove": []
},
"necrolyte_ghost_shroud": {
"add": [
"ethereal"
],
"remove": []
},
"obsidian_destroyer_astral_imprisonment": {
"add": [
"cyclone"
],
"remove": []
},
"invoker_tornado": {
"add": [
"cyclone"
],
"remove": []
},
"brewmaster_storm_cyclone": {
"add": [
"cyclone"
],
"remove": []
},
"shadow_demon_disruption": {
"add": [
"cyclone"
],
"remove": []
},
"bane_fiends_grip": {
"add": [
"stun"
],
"remove": [
"sleep"
]
},
"bane_ichor_of_nyctasha": {
"add": [],
"remove": [
"fear"
]
},
"death_prophet_spirit_siphon": {
"add": [],
"remove": [
"fear"
]
},
"muerta_dead_shot": {
"add": [],
"remove": [
"fear"
]
},
"night_stalker_crippling_fear": {
"add": [
"silence"
],
"remove": [
"fear"
]
},
"ringmaster_tame_the_beasts": {
"add": [],
"remove": [
"fear"
]
},
"spectre_haunt": {
"add": [],
"remove": [
"fear"
]
},
"meepo_earthbind": {
"add": [
"root"
],
"remove": [
"leash"
]
},
"meepo_petrify": {
"add": [],
"remove": [
"root"
]
},
"meepo_megameepo": {
"add": [],
"remove": [
"leash"
]
},
"nevermore_necromastery": {
"add": [],
"remove": [
"leash"
]
},
"windrunner_shackleshot": {
"add": [
"stun"
],
"remove": [
"leash"
]
},
"leshrac_greater_lightning_storm": {
"add": [],
"remove": [
"ethereal"
]
},
"muerta_spectral_slug": {
"add": [],
"remove": [
"ethereal"
]
},
"void_spirit_aether_remnant": {
"add": [
"stun"
],
"remove": [
"ethereal"
]
},
"void_spirit_astral_step": {
"add": [],
"remove": [
"ethereal"
]
},
"void_spirit_dissimilate": {
"add": [],
"remove": [
"ethereal"
]
},
"void_spirit_intrinsic_edge": {
"add": [],
"remove": [
"ethereal"
]
},
"void_spirit_resonant_pulse": {
"add": [],
"remove": [
"ethereal"
]
},
"marci_special_delivery": {
"add": [],
"remove": [
"cyclone"
]
},
"slark_pounce": {
"add": [
"leash"
],
"remove": []
},
"tidehunter_dead_in_the_water": {
"add": [
"leash"
],
"remove": []
},
"terrorblade_terror_wave": {
"add": [
"fear"
],
"remove": []
}
}
}
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
{
"meta": {
"note": "Manual hero→item fears merged in item_fears.py (add/remove). Use for iconic counters auto-rules miss."
},
"heroes": {
"juggernaut": {
"add": [
{ "item": "lotus_orb", "reason": "反射无敌斩/迅捷斩", "tags": ["spell_reflect"] },
{ "item": "ghost", "reason": "虚无免疫无敌斩的物理攻击", "tags": ["ethereal"] },
{ "item": "abyssal_blade", "reason": "打断输出与无敌斩", "tags": [] },
{ "item": "butterfly", "reason": "闪避克制普攻与斩击", "tags": [] }
],
"remove": ["sphere"]
},
"phantom_assassin": {
"add": [
{ "item": "monkey_king_bar", "reason": "无视模糊闪避", "tags": ["true_strike"] },
{ "item": "ghost", "reason": "虚无规避幻影刺客的物理爆发", "tags": ["ethereal"] }
],
"remove": []
},
"lina": {
"add": [
{ "item": "lotus_orb", "reason": "反射神灭斩", "tags": ["spell_reflect"] },
{ "item": "black_king_bar", "reason": "魔免克制爆发", "tags": ["magic_immune"] },
{ "item": "sphere", "reason": "格挡神灭斩", "tags": ["spell_block"] }
],
"remove": []
},
"lion": {
"add": [
{ "item": "lotus_orb", "reason": "反射死亡一指", "tags": ["spell_reflect"] }
],
"remove": []
},
"necrolyte": {
"add": [
{ "item": "lotus_orb", "reason": "反射死神镰刀", "tags": ["spell_reflect"] }
],
"remove": []
},
"axe": {
"add": [
{ "item": "lotus_orb", "reason": "反射淘汰之刃", "tags": ["spell_reflect"] },
{ "item": "eternal_shroud", "reason": "抵消旋转魔法伤害", "tags": [] }
],
"remove": []
},
"legion_commander": {
"add": [
{ "item": "lotus_orb", "reason": "反射决斗", "tags": ["spell_reflect"] },
{ "item": "ethereal_blade", "reason": "虚无无法被决斗普攻", "tags": [] },
{ "item": "ghost", "reason": "虚无无法被决斗普攻", "tags": [] }
],
"remove": []
},
"bane": {
"add": [
{ "item": "lotus_orb", "reason": "反射虚弱/噩梦/蚀脑", "tags": ["spell_reflect"] }
],
"remove": []
},
"doom_bringer": {
"add": [
{ "item": "lotus_orb", "reason": "反射末日", "tags": ["spell_reflect"] }
],
"remove": []
},
"puck": {
"add": [
{ "item": "orchid", "reason": "沉默限制技能逃生与反打", "tags": ["silence"] }
],
"remove": []
},
"weaver": {
"add": [
{ "item": "ghost", "reason": "虚无规避连击与普攻爆发", "tags": ["ethereal"] },
{ "item": "cyclone", "reason": "驱散缩地状态并限制逃生", "tags": ["basic_dispel", "cyclone"] }
],
"remove": []
},
"pangolier": {
"add": [
{ "item": "ghost", "reason": "虚无规避剑舞与普攻伤害", "tags": ["ethereal"] },
{ "item": "sheepstick", "reason": "妖术限制技能连段与逃生", "tags": ["hex"] }
],
"remove": []
},
"void_spirit": {
"add": [
{ "item": "sheepstick", "reason": "妖术限制技能连段与逃生", "tags": ["hex"] }
],
"remove": []
}
}
}
+138
View File
@@ -0,0 +1,138 @@
{
"_comment": "CN in-client order: Unicode code-point of name_loc within each attr.",
"str": [
"elder_titan",
"undying",
"shredder",
"omniknight",
"legion_commander",
"skeleton_king",
"phoenix",
"centaur",
"rattletrap",
"huskar",
"life_stealer",
"earth_spirit",
"abyssal_underlord",
"tiny",
"tusk",
"pudge",
"earthshaker",
"axe",
"slardar",
"sven",
"kunkka",
"night_stalker",
"largo",
"doom_bringer",
"treant",
"chaos_knight",
"tidehunter",
"alchemist",
"lycan",
"primal_beast",
"mars",
"dawnbreaker",
"spirit_breaker",
"bristleback",
"ogre_magi",
"dragon_knight"
],
"agi": [
"juggernaut",
"clinkz",
"viper",
"kez",
"riki",
"drow_ranger",
"morphling",
"templar_assassin",
"vengefulspirit",
"naga_siren",
"troll_warlord",
"phantom_assassin",
"phantom_lancer",
"spectre",
"nevermore",
"terrorblade",
"antimage",
"slark",
"hoodwink",
"ember_spirit",
"ursa",
"sniper",
"lone_druid",
"gyrocopter",
"mirana",
"meepo",
"weaver",
"medusa",
"broodmother",
"faceless_void",
"bloodseeker",
"bounty_hunter",
"razor",
"luna",
"monkey_king"
],
"int": [
"tinker",
"keeper_of_the_light",
"skywrath_mage",
"grimstroke",
"zuus",
"winter_wyvern",
"witch_doctor",
"lich",
"puck",
"pugna",
"disruptor",
"leshrac",
"rubick",
"shadow_demon",
"shadow_shaman",
"warlock",
"jakiro",
"obsidian_destroyer",
"crystal_maiden",
"silencer",
"muerta",
"queenofpain",
"necrolyte",
"ringmaster",
"invoker",
"oracle",
"lina",
"lion",
"ancient_apparition",
"dark_willow",
"chen",
"storm_spirit",
"enchantress",
"dark_seer"
],
"all": [
"abaddon",
"beastmaster",
"venomancer",
"nyx_assassin",
"arc_warden",
"techies",
"dazzle",
"death_prophet",
"sand_king",
"marci",
"snapfire",
"pangolier",
"bane",
"visage",
"furion",
"wisp",
"void_spirit",
"batrider",
"enigma",
"brewmaster",
"windrunner",
"magnataur"
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+313
View File
@@ -0,0 +1,313 @@
{
"meta": {
"note": "Manual add/remove tags merged after auto-tagging in fetch_items_meta.py. Prefer offensive mechanics for fear mapping."
},
"items": {
"diffusal_blade": {
"add": [
"basic_dispel",
"mana_burn"
],
"remove": [
"illusion_clear"
]
},
"disperser": {
"add": [
"basic_dispel",
"mana_burn"
],
"remove": [
"illusion_clear"
]
},
"nullifier": {
"add": [
"basic_dispel"
],
"remove": []
},
"cyclone": {
"add": [
"basic_dispel",
"cyclone"
],
"remove": []
},
"wind_waker": {
"add": [
"basic_dispel",
"cyclone"
],
"remove": []
},
"satanic": {
"add": [],
"remove": [
"basic_dispel"
]
},
"lotus_orb": {
"add": [
"spell_reflect"
],
"remove": [
"basic_dispel",
"mana_burn"
]
},
"guardian_greaves": {
"add": [],
"remove": [
"basic_dispel"
]
},
"manta": {
"add": [],
"remove": [
"basic_dispel",
"illusion_clear"
]
},
"black_king_bar": {
"add": [
"magic_immune"
],
"remove": [
"basic_dispel",
"strong_dispel"
]
},
"aeon_disk": {
"add": [],
"remove": [
"strong_dispel",
"basic_dispel"
]
},
"sphere": {
"add": [
"spell_block"
],
"remove": []
},
"monkey_king_bar": {
"add": [
"true_strike"
],
"remove": []
},
"silver_edge": {
"add": [
"break",
"invis_break",
"invis"
],
"remove": []
},
"invis_sword": {
"add": [
"invis"
],
"remove": []
},
"spirit_vessel": {
"add": [
"heal_reduce"
],
"remove": []
},
"bfury": {
"add": [
"illusion_clear"
],
"remove": []
},
"radiance": {
"add": [
"illusion_clear"
],
"remove": []
},
"maelstrom": {
"add": [
"illusion_clear"
],
"remove": [
"true_strike"
]
},
"mjollnir": {
"add": [
"illusion_clear"
],
"remove": [
"true_strike"
]
},
"gungir": {
"add": [
"root",
"illusion_clear"
],
"remove": []
},
"gem": {
"add": [
"invis_detect"
],
"remove": [
"invis"
]
},
"dust": {
"add": [
"invis_detect"
],
"remove": [
"invis"
]
},
"glimmer_cape": {
"add": [
"invis"
],
"remove": []
},
"desolator": {
"add": [
"armor_reduce"
],
"remove": []
},
"solar_crest": {
"add": [
"armor_reduce"
],
"remove": []
},
"orchid": {
"add": [
"silence"
],
"remove": [
"mana_burn"
]
},
"bloodthorn": {
"add": [
"silence",
"true_strike"
],
"remove": []
},
"sheepstick": {
"add": [
"hex"
],
"remove": [
"illusion_clear",
"silence",
"disarm",
"mute"
]
},
"abyssal_blade": {
"add": [
"stun"
],
"remove": []
},
"basher": {
"add": [
"stun"
],
"remove": []
},
"heavens_halberd": {
"add": [
"disarm"
],
"remove": []
},
"ethereal_blade": {
"add": [
"ethereal"
],
"remove": [
"disarm"
]
},
"rod_of_atos": {
"add": [
"root"
],
"remove": []
},
"mask_of_madness": {
"add": [],
"remove": [
"silence"
]
},
"necronomicon": {
"add": [
"invis_detect"
],
"remove": [
"basic_dispel",
"mana_burn",
"break",
"invis"
]
},
"necronomicon_2": {
"add": [
"invis_detect"
],
"remove": [
"basic_dispel",
"mana_burn",
"break",
"invis"
]
},
"necronomicon_3": {
"add": [
"invis_detect"
],
"remove": [
"basic_dispel",
"mana_burn",
"break",
"invis"
]
},
"witch_blade": {
"add": [],
"remove": [
"true_strike"
]
},
"devastator": {
"add": [
"armor_reduce"
],
"remove": [
"true_strike"
]
},
"essence_distiller": {
"add": [],
"remove": [
"invis_detect"
]
},
"ghost": {
"add": [
"ethereal"
],
"remove": []
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+32223
View File
File diff suppressed because it is too large Load Diff
+242
View File
@@ -0,0 +1,242 @@
{
"fetched_at": "2026-07-28T14:20:00+00:00",
"source": "manual+douyin+bilibili",
"platform_meta": {
"douyin": {
"label_zh": "抖音",
"icon": "ui-icon/platform_douyin.png"
},
"bilibili": {
"label_zh": "哔哩哔哩",
"icon": "ui-icon/platform_bilibili.png"
}
},
"streamers": [
{
"id": "xiaowang",
"platform": "douyin",
"live_url": "https://live.douyin.com/343871812758",
"profile_url": "https://v.douyin.com/MM3U-EpqI2E/",
"tagline": "天怒绝活哥",
"heroes": [
"skywrath_mage"
],
"nickname": "DOTA2小王",
"unique_id": "chenshiweidota",
"signature": "有事请私信,最高东南亚前100,上车玩加同名绿泡泡抖音号",
"following_count": 87,
"follower_count": 14000,
"total_favorited": 591,
"avatar": "streamer_avatars/xiaowang.jpg",
"video": "streamer_videos/xiaowang.mp4",
"video_title": "7000分单排天怒如何乱杀",
"profile_fetched_at": "2026-07-28T08:30:00+00:00",
"is_live": true,
"live_probed_at": "2026-07-29T05:14:10.990062+00:00"
},
{
"id": "jianxin",
"platform": "douyin",
"live_url": "https://live.douyin.com/892762012034",
"profile_url": "https://v.douyin.com/FcUd6qHXYwc/",
"heroes": [
"juggernaut"
],
"nickname": "Dota2剑心犹在",
"unique_id": "33552682609",
"signature": "万分选手,前国服第一,绝活剑圣。土木双灵根,先天打灰圣体。",
"following_count": 353,
"follower_count": 35000,
"total_favorited": 4255,
"avatar": "streamer_avatars/jianxin.jpg",
"video": "streamer_videos/jianxin.mp4",
"video_title": "落后四十人头的惊天大翻盘!剑心犹在!!",
"profile_fetched_at": "2026-07-28T10:10:00+00:00",
"is_live": true,
"live_probed_at": "2026-07-29T05:14:12.853946+00:00"
},
{
"id": "yaseguilai",
"platform": "douyin",
"profile_url": "https://v.douyin.com/gQDc0B9yPv4/",
"heroes": [
"slark"
],
"nickname": "亚瑟归来(Dota2",
"unique_id": "yaseguilai",
"signature": "dota2主播,万分小鱼王! 上车+v375874361",
"following_count": 1024,
"follower_count": 23000,
"total_favorited": 5302,
"avatar": "streamer_avatars/yaseguilai.jpg",
"video": "streamer_videos/yaseguilai.mp4",
"video_aspect": "1920/2317",
"video_fit": "cover",
"video_title": "狂战小鱼6连暴走",
"profile_fetched_at": "2026-07-28T10:12:00+00:00"
},
{
"id": "gudu",
"platform": "douyin",
"profile_url": "https://v.douyin.com/411MHPZgDeY/",
"heroes": [
"kez"
],
"nickname": "Dota2孤独",
"unique_id": "71129362227",
"signature": "和dota2结婚的小哥一枚 12点左右直播,最高东南亚200",
"following_count": 7,
"follower_count": 2147,
"total_favorited": 4016,
"avatar": "streamer_avatars/gudu.jpg",
"video": "streamer_videos/gudu.mp4",
"video_title": "场均杀20个,双倍了一晚上没输过的神秘中单出现在对面? 看来只有我来终结你的无解连胜了",
"profile_fetched_at": "2026-07-28T10:14:00+00:00"
},
{
"id": "yangfanqiguo",
"platform": "douyin",
"live_url": "https://live.douyin.com/546982489107",
"profile_url": "https://v.douyin.com/bodeOPLFFuo/",
"heroes": [
"riki"
],
"nickname": "Dota2杨帆起锅",
"unique_id": "26535099603",
"signature": "完美官方主播 每天早9点到12点,晚6点半到10点",
"following_count": 14,
"follower_count": 8259,
"total_favorited": 270,
"avatar": "streamer_avatars/yangfanqiguo.jpg",
"video": "streamer_videos/yangfanqiguo.mp4",
"video_title": "冠绝中单力丸对线保姆级思路",
"profile_fetched_at": "2026-07-28T10:16:00+00:00",
"is_live": false,
"live_probed_at": "2026-07-29T05:14:14.619514+00:00"
},
{
"id": "dadigua",
"platform": "douyin",
"profile_url": "https://v.douyin.com/t1E-4M1xrJ4/",
"heroes": [
"axe"
],
"nickname": "DOTA2 大地瓜",
"unique_id": "189764720",
"signature": "6月10号播",
"following_count": 805,
"follower_count": 8006,
"total_favorited": 8015,
"avatar": "streamer_avatars/dadigua.jpg",
"video": "streamer_videos/dadigua.mp4",
"video_aspect": "1080/1440",
"video_fit": "cover",
"video_title": "电风扇斧王10秒肉山",
"profile_fetched_at": "2026-07-28T10:28:00+00:00"
},
{
"id": "dota25830",
"platform": "douyin",
"live_url": "https://live.douyin.com/336796244386",
"profile_url": "https://v.douyin.com/Bk6PZIs7kCE/",
"heroes": [
"lion"
],
"nickname": "5830 Dota2",
"unique_id": "583093455jj",
"signature": "dota2 国服第一lion 上牢车:dota258",
"following_count": 172,
"follower_count": 8466,
"total_favorited": 7074,
"avatar": "streamer_avatars/dota25830.jpg",
"video": "streamer_videos/dota25830.mp4",
"video_title": "Lion亡养成班,中单lion新手教学",
"profile_fetched_at": "2026-07-28T10:30:00+00:00",
"is_live": true,
"live_probed_at": "2026-07-29T05:14:16.578632+00:00"
},
{
"id": "mayi",
"platform": "douyin",
"profile_url": "https://v.douyin.com/9KwWjZPjB3U/",
"heroes": [
"weaver"
],
"nickname": "橙子dota2",
"unique_id": "72388849434",
"signature": "泡泡 : chengzidota (不只是带你赢,更是教会你赢)",
"following_count": 1424,
"follower_count": 2049,
"total_favorited": 300,
"avatar": "streamer_avatars/mayi.jpg",
"video": "streamer_videos/mayi.mp4",
"video_title": "蚂蚁暴走",
"profile_fetched_at": "2026-07-28T10:50:00+00:00"
},
{
"id": "huomao",
"platform": "douyin",
"live_url": "https://live.douyin.com/401878837924",
"profile_url": "https://v.douyin.com/GG79CCGREhc/",
"heroes": [
"ember_spirit"
],
"nickname": "dota2火影猫",
"unique_id": "44772013956",
"signature": "打号、上车、陪后台踢踢",
"following_count": 188,
"follower_count": 1781,
"total_favorited": 524,
"avatar": "streamer_avatars/huomao.jpg",
"video": "streamer_videos/huomao.mp4",
"video_title": "7500单排局火猫如何应对哈斯卡",
"profile_fetched_at": "2026-07-28T10:52:00+00:00",
"is_live": true,
"live_probed_at": "2026-07-29T05:14:18.605968+00:00"
},
{
"id": "gugong",
"platform": "douyin",
"live_url": "https://live.douyin.com/517090873115",
"profile_url": "https://v.douyin.com/T0eNOgDLB1g/",
"heroes": [
"clinkz"
],
"nickname": "爪爪熊想遇见樱岛麻衣",
"unique_id": "21811462417",
"signature": "dota2天梯最高9500分",
"following_count": 30,
"follower_count": 1499,
"total_favorited": 637,
"avatar": "streamer_avatars/gugong.jpg",
"video": "streamer_videos/gugong.mp4",
"video_title": "小骷髅后期教科书,大吹风魅力时刻",
"profile_fetched_at": "2026-07-28T10:55:00+00:00",
"is_live": false,
"live_probed_at": "2026-07-29T05:14:20.319003+00:00"
},
{
"id": "shawang",
"platform": "bilibili",
"live_url": "https://live.bilibili.com/1865937232",
"profile_url": "https://space.bilibili.com/1439688338",
"tagline": "三号位 · 天梯8500分",
"heroes": [
"doom_bringer",
"axe",
"sand_king",
"spirit_breaker"
],
"nickname": "沙王",
"unique_id": "1439688338",
"signature": "主播擅长三号位,天梯8500分。今年36岁,20年刀龄,真三dota,dota2 四泰斗末日斧王沙王白牛 直播时间 10.00-18.00",
"following_count": 231,
"follower_count": 7081,
"avatar": "streamer_avatars/shawang.jpg",
"video": "streamer_videos/shawang.mp4",
"video_title": "Dota2沙王直播录像",
"is_live": true,
"live_probed_at": "2026-07-29T05:14:20.981087+00:00"
}
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"meta": {
"note": "Manual name_loc overrides for talents Valve omits bonus data for (auto-resolves to '?'). Applied in fetch_hero_abilities.py talent_rows when a talent still shows '?' after bonus lookup. Value is the full final name_loc string."
},
"talents": {
"special_bonus_unique_naga_siren_reel_in_speed": "+125 捕捞速度"
}
}
+390
View File
@@ -0,0 +1,390 @@
"""Deploy the Climperor web site static bundle to Cloudflare Pages.
Bundles export + integrity check + wrangler direct upload + custom domain
binding into one local command. Designed to run with credentials injected
by keyzoo asset_exec, but also works with manually exported env vars.
Usage:
python deploy_relations.py [--no-export] [--project-name NAME] \\
[--domain DOMAIN] [--dist PATH]
Env vars (credentials, never printed):
CLOUDFLARE_EMAIL / CLOUDFLARE_API_KEY -- preferred
KEYZOO_ASSET_META_USERNAME -- fallback email (keyzoo inject)
KEYZOO_ASSET_SECRET_GLOBAL_API_KEY -- fallback key (keyzoo inject)
Flow:
1. resolve credentials + account_id (GET /accounts)
2. ensure Pages project exists (create if missing, production_branch=main)
3. run export_relations_site.py unless --no-export
4. asset-integrity check: abort if key dirs are empty
5. wrangler pages deploy dist/relations (CI=true, non-interactive)
6. bind custom domain if not already
7. print deployment + custom domain URLs
Security: the API key is only ever placed into HTTP headers and the
wrangler subprocess env. It is never printed, logged, or written to disk.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent
DEFAULT_PROJECT = "climperor-relations"
DEFAULT_DOMAIN = "dota2.refining.dev"
DEFAULT_DIST = ROOT / "dist" / "relations"
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
# Directories export_relations_site.py writes under dist/relations. Keys are
# the sub-directory names; values flag whether an empty dir is a hard failure
# (True) or just a warning (False). Portrait/ui-icon must be non-empty or the
# site is visibly broken; item/ability degrade with onerror but should exist.
INTEGRITY_DIRS: dict[str, bool] = {
"portrait": True,
"ui-icon": True,
"item": True,
"ability": False,
"attr": True,
"item-cat": True,
}
def resolve_credentials() -> tuple[str, str]:
"""Return (email, api_key) from env, preferring CLOUDFLARE_* vars."""
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
"KEYZOO_ASSET_META_USERNAME"
)
api_key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
)
if not email or not api_key:
raise SystemExit(
"missing credentials: set CLOUDFLARE_EMAIL + CLOUDFLARE_API_KEY "
"(or run via keyzoo asset_exec on the refining/cloudflare asset)"
)
return email, api_key
def cf_api(
method: str,
path: str,
*,
email: str,
api_key: str,
body: dict | None = None,
) -> dict:
"""Call the Cloudflare REST API with Global API Key auth.
Uses X-Auth-Email + X-Auth-Key headers. Returns the parsed JSON envelope.
Never raises on HTTP error; returns the error envelope so callers can
inspect `success` / `errors` without a traceback.
"""
url = "https://api.cloudflare.com/client/v4" + path
headers = {
"X-Auth-Email": email,
"X-Auth-Key": api_key,
"Content-Type": "application/json",
}
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, method=method, headers=headers)
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
# HTTPError still carries the CF JSON envelope; surface it verbatim
# (it contains no secrets, only error codes/messages).
try:
return json.loads(exc.read().decode("utf-8"))
except Exception:
return {"success": False, "errors": [{"code": exc.code, "message": str(exc)}]}
except urllib.error.URLError as exc:
return {"success": False, "errors": [{"code": -1, "message": str(exc)}]}
def get_account_id(email: str, api_key: str) -> str:
"""Resolve the Cloudflare account id via GET /accounts."""
d = cf_api("GET", "/accounts", email=email, api_key=api_key)
if not d.get("success"):
raise SystemExit(f"GET /accounts failed: {d.get('errors')}")
accounts = d.get("result") or []
if not accounts:
raise SystemExit("no Cloudflare accounts found for these credentials")
if len(accounts) > 1:
names = [a.get("name", a.get("id")) for a in accounts]
print(f"multiple accounts found, using the first: {names}", file=sys.stderr)
return str(accounts[0]["id"])
def ensure_project(email: str, api_key: str, account_id: str, project: str) -> None:
"""Create the Pages project if it does not already exist."""
d = cf_api(
"GET",
f"/accounts/{account_id}/pages/projects/{project}",
email=email,
api_key=api_key,
)
if d.get("success"):
print(f"project exists: {project}")
return
# 8000xxx = project not found; anything else is a real error.
print(f"project not found, creating: {project}")
d = cf_api(
"POST",
f"/accounts/{account_id}/pages/projects",
email=email,
api_key=api_key,
body={"name": project, "production_branch": "main"},
)
if not d.get("success"):
raise SystemExit(f"create project failed: {d.get('errors')}")
def run_export(
ability_video_base: str | None = None,
static_asset_base: str | None = None,
) -> None:
"""Re-run export_relations_site.py to refresh dist/relations."""
print("exporting static site ...")
cmd = [sys.executable, str(ROOT / "export_relations_site.py")]
oss = ability_video_base
if oss is None:
oss = os.environ.get("ABILITY_VIDEO_BASE", DEFAULT_OSS_BASE)
static = static_asset_base
if static is None:
static = os.environ.get("STATIC_ASSET_BASE", oss or DEFAULT_OSS_BASE)
if oss:
cmd.extend(["--ability-video-base", oss])
print(f"ABILITY_VIDEO_BASE={oss}")
if static:
cmd.extend(["--static-asset-base", static])
print(f"STATIC_ASSET_BASE={static}")
subprocess.run(cmd, cwd=str(ROOT), check=True)
def read_config_js_var(dist: Path, name: str) -> str:
"""Parse a string literal from generated config.js."""
cfg = dist / "config.js"
if not cfg.is_file():
return ""
prefix = f"var {name} = "
for line in cfg.read_text(encoding="utf-8").splitlines():
if line.startswith(prefix):
return json.loads(line[len(prefix) :].rstrip(";"))
return ""
def check_integrity(dist: Path) -> None:
"""Abort if a hard-required asset directory is empty; warn otherwise."""
static_base = read_config_js_var(dist, "STATIC_ASSET_BASE")
if static_base:
print(
f"asset integrity check: skipped local dirs "
f"(STATIC_ASSET_BASE={static_base})"
)
return
problems: list[str] = []
for sub, required in INTEGRITY_DIRS.items():
d = dist / sub
n = sum(1 for _ in d.glob("*") if _.is_file()) if d.is_dir() else 0
if n == 0:
tag = "MISSING (abort)" if required else "empty (warn)"
problems.append(f" {sub}/: {tag}")
hint = {
"portrait": "run: python fetch_cdn_templates.py && python fetch_hero_portraits.py",
"ability": "run: python fetch_hero_abilities.py --icons-only",
"item": "run: python fetch_hero_items.py",
"attr": "assets/attr_icons is committed; check git checkout",
"item-cat": "assets/item_cat_icons is committed; check git checkout",
"ui-icon": "assets/ui_icons is committed; check git checkout",
}.get(sub, "see AGENTS.md fetch commands")
problems.append(f" -> {hint}")
if not problems:
return
hard = [s for s, r in INTEGRITY_DIRS.items() if r and not any((dist / s).glob("*"))]
print("asset integrity check:")
print("\n".join(problems))
if hard:
raise SystemExit(
f"aborting deploy: required asset dirs empty: {hard}. "
"Run the fetch scripts first, or set STATIC_ASSET_BASE for OSS hosting."
)
def resolve_npx() -> str:
"""Locate the npx executable (Windows ships npx.cmd)."""
for name in ("npx", "npx.cmd"):
path = shutil.which(name)
if path:
return path
raise SystemExit("npx not found on PATH; install Node.js first")
def wrangler_deploy(
dist: Path,
project: str,
*,
email: str,
api_key: str,
account_id: str,
) -> None:
"""Upload dist via `wrangler pages deploy` (direct upload, non-interactive).
The whole dist directory is uploaded, so dist/functions/ (Pages Functions,
e.g. /api/live-status) is included automatically — Pages Functions need
no extra wrangler config or flags for direct-upload projects.
"""
env = os.environ.copy()
# wrangler reads Global API Key auth from these env vars. They are passed
# to the subprocess only; wrangler does not echo them.
env["CLOUDFLARE_EMAIL"] = email
env["CLOUDFLARE_API_KEY"] = api_key
env["CLOUDFLARE_ACCOUNT_ID"] = account_id
env["CI"] = "true"
cmd = [
resolve_npx(),
"--yes",
"wrangler@3",
"pages",
"deploy",
str(dist),
"--project-name",
project,
"--commit-dirty",
"--branch",
"main",
]
print(f"uploading {dist} via wrangler ...")
subprocess.run(cmd, cwd=str(ROOT), env=env, check=True)
def ensure_cname(email: str, api_key: str, domain: str, project: str) -> None:
"""Ensure the zone has a proxied CNAME: <domain> -> <project>.pages.dev.
Pages binds the custom domain but does not always auto-create the zone
CNAME; without it SSL validation stalls at 'CNAME record not set'.
Idempotent: skips if a CNAME already exists for the domain.
"""
zone = domain.split(".", 1)[1] if "." in domain else domain
target = f"{project}.pages.dev"
zd = cf_api("GET", f"/zones?name={zone}", email=email, api_key=api_key)
zr = zd.get("result") or []
if not zr:
print(f"warn: zone {zone} not found on this account; add CNAME manually")
return
zid = zr[0]["id"]
recs = cf_api(
"GET", f"/zones/{zid}/dns_records?name={domain}", email=email, api_key=api_key
)
existing = [r for r in (recs.get("result") or []) if r.get("type") == "CNAME"]
if existing:
r = existing[0]
print(f"CNAME exists: {r.get('name')} -> {r.get('content')} (proxied={r.get('proxied')})")
return
print(f"adding CNAME: {domain} -> {target} (proxied=true)")
d = cf_api(
"POST",
f"/zones/{zid}/dns_records",
email=email,
api_key=api_key,
body={"type": "CNAME", "name": domain, "content": target, "proxied": True},
)
if d.get("success"):
print("CNAME added; SSL will activate within a few minutes")
else:
print(f"warn: add CNAME failed: {d.get('errors')} (add manually in dashboard)")
def bind_domain(
email: str,
api_key: str,
account_id: str,
project: str,
domain: str,
) -> None:
"""Attach the custom domain to the Pages project and ensure its CNAME."""
d = cf_api(
"GET",
f"/accounts/{account_id}/pages/projects/{project}/domains",
email=email,
api_key=api_key,
)
if not d.get("success"):
raise SystemExit(f"list domains failed: {d.get('errors')}")
existing = [x.get("name") for x in (d.get("result") or [])]
if domain in existing:
print(f"custom domain already bound: {domain}")
else:
print(f"binding custom domain: {domain}")
d = cf_api(
"POST",
f"/accounts/{account_id}/pages/projects/{project}/domains",
email=email,
api_key=api_key,
body={"name": domain},
)
if not d.get("success"):
raise SystemExit(
f"bind domain failed: {d.get('errors')}. "
f"Ensure {domain} zone is hosted on this Cloudflare account."
)
ensure_cname(email, api_key, domain, project)
def main() -> None:
ap = argparse.ArgumentParser(description="Deploy Climperor web site to Cloudflare Pages")
ap.add_argument("--no-export", action="store_true", help="skip re-export, deploy existing dist")
ap.add_argument("--project-name", default=DEFAULT_PROJECT)
ap.add_argument("--domain", default=DEFAULT_DOMAIN)
ap.add_argument("--dist", default=str(DEFAULT_DIST))
ap.add_argument(
"--ability-video-base",
default=None,
help="passed to export as --ability-video-base (default: OSS climperor endpoint "
"or ABILITY_VIDEO_BASE env)",
)
ap.add_argument(
"--static-asset-base",
default=None,
help="passed to export as --static-asset-base (default: same as ability-video-base "
"or STATIC_ASSET_BASE env)",
)
args = ap.parse_args()
dist = Path(args.dist).resolve()
email, api_key = resolve_credentials()
account_id = get_account_id(email, api_key)
print(f"account_id: {account_id}")
ensure_project(email, api_key, account_id, args.project_name)
if not args.no_export:
run_export(args.ability_video_base, args.static_asset_base)
else:
print("--no-export: using existing dist")
if not dist.is_dir():
raise SystemExit(f"dist not found: {dist}. Run without --no-export first.")
check_integrity(dist)
wrangler_deploy(dist, args.project_name, email=email, api_key=api_key, account_id=account_id)
bind_domain(email, api_key, account_id, args.project_name, args.domain)
print()
print(f"deployment url : https://{args.project_name}.pages.dev")
print(f"custom domain : https://{args.domain}")
print("note: a freshly bound custom domain takes ~1-2 min to issue SSL.")
if __name__ == "__main__":
main()
+249
View File
@@ -0,0 +1,249 @@
"""Export the Climperor web site as a static bundle (no server needed).
Usage:
python export_relations_site.py [--out dist/relations] [--with-videos]
[--ability-video-base URL] [--static-asset-base URL]
Copies web/relations/ + a snapshot of the /api/data payload (data.json) +
the referenced image assets into one directory, ready for any static host
(GitHub Pages, Cloudflare Pages, nginx, ...).
Notes:
- Only already-cached assets are exported. For full ability-icon coverage
run `python fetch_hero_abilities.py --icons-only` first.
- For patch-notes names/icons on the 版本 page, run `python fetch_patches.py`
first (downloads referenced item + ability icons into assets/).
- Ability videos (several GB locally) are skipped unless --with-videos is
given; production deploys point the UI at OSS via --ability-video-base
(or ABILITY_VIDEO_BASE env) instead of bundling videos into Pages.
- When --static-asset-base is set, image dirs are omitted from dist/ and the
UI loads icons/portraits from OSS (see _oss_static_assets.py to sync).
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
import os
import shutil
from shared.paths import (
ABILITY_ICONS,
ABILITY_VIDEOS,
ATTR_ICONS,
HERO_PORTRAITS,
ITEM_CAT_ICONS,
ITEM_ICONS,
RANK_ICONS,
ROOT,
STREAMER_AVATARS,
STREAMER_VIDEOS,
TEMPLATES_CDN,
UI_ICONS,
WEB_DIST,
)
from serve_relations import WEB_DIR, build_payload
SITE_VERSION = "0.5.61"
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
def copy_glob(src: Path, dst: Path, pattern: str = "*.png") -> int:
if not src.is_dir():
return 0
dst.mkdir(parents=True, exist_ok=True)
n = 0
for f in sorted(src.glob(pattern)):
if f.is_file():
shutil.copy2(f, dst / f.name)
n += 1
return n
def populate_static_assets(out: Path, payload: dict) -> dict[str, int]:
"""Copy web site image dirs into ``out`` (portrait/item/ability/...)."""
counts = {
"attr": copy_glob(ATTR_ICONS, out / "attr"),
"rank": copy_glob(RANK_ICONS, out / "rank", "rank*.png"),
"item": copy_glob(ITEM_ICONS, out / "item"),
"item-cat": copy_glob(ITEM_CAT_ICONS, out / "item-cat", "itemcat_*.png"),
"ability": copy_glob(ABILITY_ICONS, out / "ability"),
"ui-icon": copy_glob(UI_ICONS, out / "ui-icon"),
"streamer-avatar": 0,
"streamer-video": 0,
}
if STREAMER_AVATARS.is_dir():
n_av = 0
for pattern in ("*.jpg", "*.jpeg", "*.png", "*.webp"):
n_av += copy_glob(STREAMER_AVATARS, out / "streamer-avatar", pattern)
counts["streamer-avatar"] = n_av
if STREAMER_VIDEOS.is_dir():
n_vid = 0
dst = out / "streamer-video"
dst.mkdir(parents=True, exist_ok=True)
for f in sorted(STREAMER_VIDEOS.glob("*.mp4")):
if not f.is_file() or f.name.startswith("_"):
continue
shutil.copy2(f, dst / f.name)
n_vid += 1
counts["streamer-video"] = n_vid
portrait_dst = out / "portrait"
portrait_dst.mkdir(exist_ok=True)
n_portrait = 0
for hero in payload.get("heroes") or []:
key = hero.get("key")
if not key:
continue
src = HERO_PORTRAITS / f"{key}.png"
if not src.is_file():
src = TEMPLATES_CDN / f"{key}.png"
if src.is_file():
shutil.copy2(src, portrait_dst / f"{key}.png")
n_portrait += 1
for cell in ((payload.get("patch_lookup") or {}).get("heroes") or {}).values():
key = cell.get("key") if isinstance(cell, dict) else None
if not key:
continue
src = HERO_PORTRAITS / f"{key}.png"
if src.is_file() and not (portrait_dst / f"{key}.png").is_file():
shutil.copy2(src, portrait_dst / f"{key}.png")
n_portrait += 1
counts["portrait"] = n_portrait
return counts
def write_config_js(
out: Path,
*,
ability_video_base: str,
static_asset_base: str,
site_version: str,
) -> None:
"""Write config.js consumed by app.js."""
video = (ability_video_base or "").strip().rstrip("/")
static = (static_asset_base or "").strip().rstrip("/")
ver = (site_version or "").strip()
(out / "config.js").write_text(
"/* generated by export_relations_site.py — do not edit */\n"
f"var SITE_VERSION = {json.dumps(ver, ensure_ascii=False)};\n"
f"var ABILITY_VIDEO_BASE = {json.dumps(video, ensure_ascii=False)};\n"
f"var STATIC_ASSET_BASE = {json.dumps(static, ensure_ascii=False)};\n",
encoding="utf-8",
)
def main() -> None:
ap = argparse.ArgumentParser(
description="Export Climperor web site as a static bundle"
)
ap.add_argument("--out", default=str(WEB_DIST / "relations"))
ap.add_argument(
"--with-videos",
action="store_true",
help="also copy assets/ability_videos (several GB)",
)
ap.add_argument(
"--ability-video-base",
default=None,
help="public base URL for ability demos (writes config.js); "
"falls back to ABILITY_VIDEO_BASE env, else empty (same-origin)",
)
ap.add_argument(
"--static-asset-base",
default=None,
help="public base URL for icons/portraits (writes config.js); "
"when set, image dirs are not copied into dist; "
"falls back to STATIC_ASSET_BASE env, else empty",
)
args = ap.parse_args()
video_base = (
args.ability_video_base
if args.ability_video_base is not None
else os.environ.get("ABILITY_VIDEO_BASE", "")
)
static_base = (
args.static_asset_base
if args.static_asset_base is not None
else os.environ.get("STATIC_ASSET_BASE", "")
)
out = Path(args.out).resolve()
if out == ROOT.resolve() or out.parent == out:
raise SystemExit(f"refusing unsafe --out: {out}")
if out.exists():
shutil.rmtree(out)
out.mkdir(parents=True)
for name in ("index.html", "router.js", "app.js", "style.css", "_headers"):
src = WEB_DIR / name
if src.is_file():
shutil.copy2(src, out / name)
# Cloudflare Pages Functions (functions/api/live-status.js -> /api/live-status).
functions_src = WEB_DIR / "functions"
n_functions = 0
if functions_src.is_dir():
shutil.copytree(functions_src, out / "functions")
n_functions = sum(1 for f in (out / "functions").rglob("*") if f.is_file())
write_config_js(
out,
ability_video_base=video_base,
static_asset_base=static_base,
site_version=SITE_VERSION,
)
payload = build_payload()
(out / "data.json").write_text(
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
encoding="utf-8",
)
if static_base:
counts = {
name: 0
for name in (
"attr",
"item",
"item-cat",
"ability",
"ui-icon",
"portrait",
"streamer-avatar",
"streamer-video",
)
}
print(" static assets: omitted (STATIC_ASSET_BASE set — served from OSS)")
else:
counts = populate_static_assets(out, payload)
n_videos = 0
if args.with_videos and ABILITY_VIDEOS.is_dir():
shutil.copytree(ABILITY_VIDEOS, out / "ability-video")
n_videos = sum(1 for f in (out / "ability-video").rglob("*") if f.is_file())
total = sum(f.stat().st_size for f in out.rglob("*") if f.is_file())
print(f"exported static site -> {out}")
for name, n in counts.items():
print(f" {name}/: {n} files")
if n_functions:
print(f" functions/: {n_functions} files (Pages Functions)")
if args.with_videos:
print(f" ability-video/: {n_videos} files")
print(
f" config.js SITE_VERSION={SITE_VERSION!r} "
f"ABILITY_VIDEO_BASE={video_base!r} STATIC_ASSET_BASE={static_base!r}"
)
print(f" total: {total / 1e6:.1f} MB")
print(f"local check: python -m http.server -d {out} 8080")
print("deploy: upload the directory to any static host (Pages / nginx / ...)")
if __name__ == "__main__":
main()
+359
View File
@@ -0,0 +1,359 @@
"""Download official ability demo clips from dota2.com / Steam CDN.
Source pattern (not GIF):
https://cdn.steamstatic.com/apps/dota2/videos/dota_react/abilities/{hero}/{file}.webm
https://cdn.steamstatic.com/apps/dota2/videos/dota_react/abilities/{hero}/{file}.mp4
https://cdn.steamstatic.com/apps/dota2/videos/dota_react/abilities/{hero}/{file}.jpg
{file} is normally the ability key (e.g. juggernaut_blade_fury). However,
abilities GRANTED by Aghanim's Scepter / Shard use the hero-prefixed upgrade
name on the CDN, not the ability key:
<hero>_aghanims_scepter (e.g. juggernaut_swift_slash -> juggernaut_aghanims_scepter)
<hero>_aghanims_shard
The local file is always saved as {ability_key}.{ext} so the web frontend
(app.js) can resolve it by ability key without knowing the grant type.
Ability keys come from data/hero_abilities.json (run fetch_hero_abilities.py first).
Many innate / shard / facet abilities have no clip (CDN 404) — those are skipped.
Rate limiting: polite delay + jitter between requests; longer backoff on 429/403.
Usage:
python fetch_ability_videos.py --heroes juggernaut
python fetch_ability_videos.py
python fetch_ability_videos.py --delay 2 --jitter 1
python fetch_ability_videos.py --fmt webm --poster
python fetch_ability_videos.py --force
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
import random
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from shared.paths import ABILITY_VIDEOS, DATA
HERO_ABILITIES = DATA / "hero_abilities.json"
CDN_BASE = "https://cdn.steamstatic.com/apps/dota2/videos/dota_react/abilities"
UA = "climperor-ability-video-fetch/1.0 (+https://github.com/local/climperor; respectful crawl)"
REFERER = "https://www.dota2.com/"
MANIFEST = ABILITY_VIDEOS / "manifest.json"
def polite_sleep(delay: float, jitter: float) -> None:
wait = max(0.0, delay) + random.uniform(0.0, max(0.0, jitter))
if wait > 0:
time.sleep(wait)
def http_get(url: str, *, timeout: float = 120) -> tuple[str, bytes | None, int | None]:
"""Return (status, body, http_code). status: ok|missing|rate_limited|error."""
req = urllib.request.Request(
url,
headers={
"User-Agent": UA,
"Referer": REFERER,
"Accept": "*/*",
},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = resp.read()
ctype = (resp.headers.get("Content-Type") or "").lower()
if "text/html" in ctype and len(data) < 4096:
return "error", None, getattr(resp, "status", 200)
return "ok", data, getattr(resp, "status", 200)
except urllib.error.HTTPError as e:
if e.code == 404:
return "missing", None, 404
if e.code in (403, 429):
return "rate_limited", None, e.code
return "error", None, e.code
except Exception: # noqa: BLE001
return "error", None, None
def _cdn_name(hero_key: str, ab: dict) -> str:
"""CDN demo-clip filename (without extension) for an ability.
Scepter/shard-granted abilities are published on the CDN under
``<hero>_aghanims_scepter`` / ``<hero>_aghanims_shard``, not the
ability key. The local file still uses the ability key (see app.js).
"""
if ab.get("granted_by_scepter"):
return f"{hero_key}_aghanims_scepter"
if ab.get("granted_by_shard"):
return f"{hero_key}_aghanims_shard"
return str(ab.get("key") or "").strip()
def load_ability_index(*, include_innate: bool = False) -> dict[str, list[dict]]:
"""hero_key -> [{key, cdn_name}] for abilities that may have a demo clip.
Innates are skipped by default (they almost never have a CDN clip);
pass include_innate=True to probe them too.
"""
if not HERO_ABILITIES.is_file():
raise SystemExit(
f"missing {HERO_ABILITIES}; run: python fetch_hero_abilities.py"
)
payload = json.loads(HERO_ABILITIES.read_text(encoding="utf-8"))
out: dict[str, list[dict]] = {}
for hero_key, cell in (payload.get("by_hero") or {}).items():
if not isinstance(cell, dict):
continue
entries: list[dict] = []
for ab in cell.get("abilities") or []:
if not isinstance(ab, dict):
continue
if not include_innate and ab.get("is_innate"):
continue
key = str(ab.get("key") or "").strip()
if not key:
continue
entries.append({"key": key, "cdn_name": _cdn_name(hero_key, ab)})
if entries:
out[str(hero_key)] = entries
return out
def load_manifest() -> dict:
if MANIFEST.is_file():
try:
return json.loads(MANIFEST.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
pass
return {"clips": {}, "missing": [], "meta": {}}
def save_manifest(manifest: dict) -> None:
ABILITY_VIDEOS.mkdir(parents=True, exist_ok=True)
manifest["meta"] = {
"source": "steamcdn/dota_react/abilities",
"attribution": "https://www.dota2.com",
"updated_at": datetime.now(timezone.utc).isoformat(),
"clips": len(manifest.get("clips") or {}),
"missing": len(manifest.get("missing") or []),
}
MANIFEST.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
def fetch_one(
url: str,
dest: Path,
*,
delay: float,
jitter: float,
force: bool,
backoff: float,
) -> str:
"""Download one URL. Returns: ok|skip|missing|fail|rate_limited."""
if dest.is_file() and dest.stat().st_size > 0 and not force:
return "skip"
status, body, code = http_get(url)
if status == "ok" and body:
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_suffix(dest.suffix + ".part")
tmp.write_bytes(body)
tmp.replace(dest)
polite_sleep(delay, jitter)
return "ok"
if status == "missing":
polite_sleep(delay * 0.5, jitter * 0.5)
return "missing"
if status == "rate_limited":
print(f" rate limited ({code}); sleeping {backoff:.0f}s...", flush=True)
time.sleep(backoff)
status2, body2, code2 = http_get(url)
if status2 == "ok" and body2:
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_suffix(dest.suffix + ".part")
tmp.write_bytes(body2)
tmp.replace(dest)
polite_sleep(delay, jitter)
return "ok"
if status2 == "missing":
polite_sleep(delay * 0.5, jitter * 0.5)
return "missing"
print(f" still blocked ({code2}); aborting batch", flush=True)
return "rate_limited"
polite_sleep(delay, jitter)
return "fail"
def main() -> None:
ap = argparse.ArgumentParser(
description="Download dota2.com ability demo videos (rate-limited)"
)
ap.add_argument(
"--heroes",
default="",
help="comma-separated hero keys (default: all in hero_abilities.json)",
)
ap.add_argument(
"--fmt",
choices=("webm", "mp4", "both"),
default="webm",
help="video container (default webm; smaller than mp4)",
)
ap.add_argument(
"--poster",
action="store_true",
help="also download .jpg poster frames",
)
ap.add_argument(
"--delay",
type=float,
default=1.5,
help="base seconds between requests (default 1.5)",
)
ap.add_argument(
"--jitter",
type=float,
default=0.75,
help="extra random seconds added to delay (default 0.75)",
)
ap.add_argument(
"--backoff",
type=float,
default=60.0,
help="seconds to wait after HTTP 403/429 (default 60)",
)
ap.add_argument("--force", action="store_true", help="re-download existing files")
ap.add_argument(
"--include-innate",
action="store_true",
help="also probe innate abilities (usually 404)",
)
args = ap.parse_args()
index = load_ability_index(include_innate=args.include_innate)
wanted = [h.strip() for h in args.heroes.split(",") if h.strip()]
if wanted:
missing_heroes = [h for h in wanted if h not in index]
if missing_heroes:
raise SystemExit(f"unknown heroes (not in hero_abilities.json): {missing_heroes}")
heroes = wanted
else:
heroes = sorted(index.keys())
exts = []
if args.fmt in ("webm", "both"):
exts.append("webm")
if args.fmt in ("mp4", "both"):
exts.append("mp4")
if args.poster:
exts.append("jpg")
manifest = load_manifest()
clips: dict = manifest.setdefault("clips", {})
missing_set = set(manifest.get("missing") or [])
ok = skip = miss = fail = 0
# (hero, ability_key, cdn_name, ext) — cdn_name differs from ability_key
# for scepter/shard-granted abilities.
jobs: list[tuple[str, str, str, str]] = []
for hero in heroes:
for entry in index[hero]:
for ext in exts:
jobs.append((hero, entry["key"], entry["cdn_name"], ext))
print(
f"heroes={len(heroes)} jobs={len(jobs)} "
f"delay={args.delay}+jitter[0,{args.jitter}] "
f"out={ABILITY_VIDEOS}",
flush=True,
)
for n, (hero, ability, cdn_name, ext) in enumerate(jobs, start=1):
rel = f"{hero}/{ability}.{ext}"
dest = ABILITY_VIDEOS / hero / f"{ability}.{ext}"
url = f"{CDN_BASE}/{hero}/{cdn_name}.{ext}"
clip_key = f"{hero}/{ability}"
if (
not args.force
and ext != "jpg"
and clip_key in missing_set
and not dest.is_file()
):
# Previously probed missing video; skip re-probe unless --force.
# Still allow poster retry independently.
if ext in ("webm", "mp4"):
miss += 1
print(f" [{n}/{len(jobs)}] miss(cached) {rel}", flush=True)
continue
status = fetch_one(
url,
dest,
delay=args.delay,
jitter=args.jitter,
force=args.force,
backoff=args.backoff,
)
if status == "ok":
ok += 1
if ext in ("webm", "mp4"):
cell = clips.setdefault(clip_key, {"hero": hero, "ability": ability})
cell[ext] = rel.replace("\\", "/")
cell["bytes_" + ext] = dest.stat().st_size
missing_set.discard(clip_key)
print(f" [{n}/{len(jobs)}] ok {rel} ({dest.stat().st_size} bytes)", flush=True)
elif status == "skip":
skip += 1
if ext in ("webm", "mp4"):
cell = clips.setdefault(clip_key, {"hero": hero, "ability": ability})
cell[ext] = rel.replace("\\", "/")
cell["bytes_" + ext] = dest.stat().st_size
missing_set.discard(clip_key)
if n == 1 or n % 25 == 0 or n == len(jobs):
print(f" [{n}/{len(jobs)}] skip {rel}", flush=True)
elif status == "missing":
miss += 1
if ext in ("webm", "mp4"):
missing_set.add(clip_key)
print(f" [{n}/{len(jobs)}] miss {rel}", flush=True)
elif status == "rate_limited":
fail += 1
manifest["missing"] = sorted(missing_set)
save_manifest(manifest)
print(
f"stopped early after rate limit: ok={ok} skip={skip} miss={miss} fail={fail}",
flush=True,
)
raise SystemExit(2)
else:
fail += 1
print(f" [{n}/{len(jobs)}] FAIL {rel}", flush=True)
if n % 10 == 0 or n == len(jobs):
manifest["missing"] = sorted(missing_set)
save_manifest(manifest)
manifest["missing"] = sorted(missing_set)
save_manifest(manifest)
print(
f"done: downloaded={ok} skipped={skip} missing={miss} failed={fail} "
f"manifest={MANIFEST}",
flush=True,
)
if __name__ == "__main__":
main()
+81
View File
@@ -0,0 +1,81 @@
"""Fetch the official Dota 2 emblem icon (no wordmark) for the web site header.
Source (discovered, not guessed) from the dota2.com.cn site HTML:
<link rel="Shortcut Icon" href="//www.dota2.com.cn/favicon.ico"/>
That .ico is a multi-frame container whose largest frames are 256x256 PNGs of
the red Dota 2 map-marker emblem with a transparent background — i.e. the
brand icon alone, no "DOTA 2" text. We extract the first 256x256 PNG frame,
decode with OpenCV, and write it to assets/ui_icons/dota2_logo.png so it rides
the existing /ui-icon/ route + static-export glob pipeline.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import struct
import cv2
import numpy as np
from shared.http_utils import http_bytes
from shared.paths import UI_ICONS
FAVICON_URL = "https://www.dota2.com.cn/favicon.ico"
OUT = UI_ICONS / "dota2_logo.png"
TARGET_SIZE = 256
def _extract_largest_png_frame(data: bytes) -> bytes | None:
"""Return the bytes of the largest PNG-embedded frame in an ICO blob."""
if len(data) < 6 or data[:4] != b"\x00\x00\x01\x00":
return None
count = struct.unpack("<H", data[4:6])[0]
best: tuple[int, bytes] | None = None
for i in range(count):
off = 6 + 16 * i
if off + 16 > len(data):
break
w = data[off]
size = struct.unpack("<I", data[off + 8 : off + 12])[0]
img_off = struct.unpack("<I", data[off + 12 : off + 16])[0]
width = 256 if w == 0 else w
blob = data[img_off : img_off + size]
if blob[:8] != b"\x89PNG\r\n\x1a\n":
continue
if best is None or width * width > best[0]:
best = (width * width, blob)
return best[1] if best else None
def main() -> int:
if OUT.is_file() and OUT.stat().st_size >= 512:
print(f"exists: {OUT} ({OUT.stat().st_size} bytes)")
return 0
data = http_bytes(FAVICON_URL, timeout=30)
if not data or len(data) < 16:
print("empty favicon download", file=sys.stderr)
return 1
png = _extract_largest_png_frame(data)
if png is None:
print("no PNG frame in .ico", file=sys.stderr)
return 1
arr = cv2.imdecode(np.frombuffer(png, dtype=np.uint8), cv2.IMREAD_UNCHANGED)
if arr is None:
print("decode failed", file=sys.stderr)
return 1
UI_ICONS.mkdir(parents=True, exist_ok=True)
ok = cv2.imwrite(str(OUT), arr)
if not ok:
print("write failed", file=sys.stderr)
return 1
h, w = arr.shape[:2]
channels = arr.shape[2] if arr.ndim == 3 else 1
transparent = int(np.count_nonzero(arr[:, :, 3] == 0)) if channels == 4 else 0
print(f"saved {OUT} ({w}x{h}, {OUT.stat().st_size} bytes, {transparent} px transparent)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+866
View File
@@ -0,0 +1,866 @@
"""Fetch hero ability notes into data/hero_abilities.json.
Sources:
- Valve herodata?language=schinese (Chinese desc + dispellable)
- OpenDota abilities.json (bkbpierce, English dispellable cross-check)
Usage:
python fetch_hero_abilities.py
python fetch_hero_abilities.py --force
python fetch_hero_abilities.py --delay 0.2
python fetch_hero_abilities.py --icons # also cache ability PNGs
python fetch_hero_abilities.py --icons-only # icons from existing JSON
python fetch_hero_abilities.py --tags-only # recompute mechanic tags only
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
import re
import time
import urllib.error
from datetime import datetime, timezone
from shared.grid import hero_table
from shared.hero_tags import ILLUSION_KEYS
from shared.http_utils import download_icons, http_json
from shared.paths import ABILITY_ICONS, DATA
from loc_format import format_loc, strip_html
from mechanic_tags import (
QUERY_MECHANIC_ORDER,
apply_mechanic_tags,
merge_tag_overrides,
)
HERODATA_URL = (
"https://www.dota2.com/datafeed/herodata?language=schinese&hero_id={hero_id}"
)
ABILITIES_URL = (
"https://raw.githubusercontent.com/odota/dotaconstants/master/build/abilities.json"
)
ABILITY_ICON_URL = (
"https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/abilities/{key}.png"
)
OUT = DATA / "hero_abilities.json"
ABILITY_TAG_OVERRIDES = DATA / "ability_tag_overrides.json"
DESC_MAX = 600
UPGRADE_MAX = 500
TALENT_LEVELS = (10, 10, 15, 15, 20, 20, 25, 25)
# Bundled badges — never fetched from CDN (many innate keys 404 there).
SKIP_ICON_KEYS = frozenset({"innate", "talent_tree"})
def _load_ability_tag_overrides() -> dict[str, dict]:
if not ABILITY_TAG_OVERRIDES.is_file():
return {}
try:
raw = json.loads(ABILITY_TAG_OVERRIDES.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
items = raw.get("abilities") or {}
return {str(k): v for k, v in items.items() if isinstance(v, dict)}
def ability_mechanic_tags(ab: dict, overrides: dict[str, dict] | None = None) -> list[str]:
"""Compute applies-* mechanic tags for one ability row."""
key = str(ab.get("key") or "")
blob = " ".join(
[
str(ab.get("name_loc") or ""),
str(ab.get("desc_loc") or ""),
str(ab.get("shard_loc") or ""),
str(ab.get("scepter_loc") or ""),
]
)
auto = apply_mechanic_tags(blob, key=key)
ov = (overrides or {}).get(key)
return merge_tag_overrides(auto, ov, QUERY_MECHANIC_ORDER)
def retag_abilities(by_hero: dict, overrides: dict[str, dict] | None = None) -> int:
"""Write ``tags`` onto every ability; return number of abilities with tags."""
ov = overrides if overrides is not None else _load_ability_tag_overrides()
tagged = 0
for cell in by_hero.values():
if not isinstance(cell, dict):
continue
for ab in cell.get("abilities") or []:
if not isinstance(ab, dict) or not ab.get("key"):
continue
tags = ability_mechanic_tags(ab, ov)
ab["tags"] = tags
if tags:
tagged += 1
return tagged
def _load_talent_overrides() -> dict[str, str]:
"""Load manual name_loc overrides for talents Valve omits bonus data for."""
p = DATA / "talent_overrides.json"
if not p.is_file():
return {}
try:
raw = json.loads(p.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
out: dict[str, str] = {}
items = (raw.get("talents") if isinstance(raw, dict) else None) or {}
if isinstance(items, dict):
for k, v in items.items():
if isinstance(k, str) and isinstance(v, str):
out[k] = v
return out
TALENT_OVERRIDES = _load_talent_overrides()
ABILITY_TAG_OVERRIDES_MAP = _load_ability_tag_overrides()
# Valve dispellable int → label
DISPEL_MAP = {
0: "none",
1: "strong_only",
2: "yes",
3: "no",
}
# DOTA_ABILITY_BEHAVIOR bits (Valve enum)
B_PASSIVE = 1 << 1
B_NO_TARGET = 1 << 2
B_UNIT_TARGET = 1 << 3
B_POINT = 1 << 4
B_CHANNELLED = 1 << 7
B_TOGGLE = 1 << 9
B_AUTOCAST = 1 << 12
B_AURA = 1 << 16
B_VECTOR = 1 << 30
# Valve damage int → 伤害类型 label
DAMAGE_MAP = {1: "物理", 2: "魔法", 4: "纯粹", 8: "纯粹"}
# Valve spell-immunity int → 无视技能免疫 label
IMMUNITY_MAP = {1: "", 2: "", 3: "", 4: "", 5: "友军是 / 敌军否"}
# Valve target_team int → prefix
TARGET_TEAM_MAP = {1: "友方", 2: "敌方", 3: "双方"}
OD_DISPEL_MAP = {
"yes": "yes",
"no": "no",
"strong dispels only": "strong_only",
}
EVASION_RE = re.compile(r"闪避|evasion|miss chance|落空", re.I)
INVIS_RE = re.compile(r"隐身|invisible|invisibility|渐隐|潜行|shadow walk|fade", re.I)
# Only "creates illusions" style — bare 「幻象不会」mentions are noise.
ILLUSION_CREATE_RE = re.compile(
r"创造幻象|制造幻象|召唤幻象|产生幻象|creates?\s+illusions?|summons?\s+illusions?",
re.I,
)
BREAKABLE_PASSIVE_RE = re.compile(
r"破坏会|Break|被动.*失效|禁用被动|disabled by break",
re.I,
)
MAGIC_NUKE_RE = re.compile(r"魔法伤害|magical damage|魔法伤害", re.I)
DISABLE_RE = re.compile(
r"眩晕|沉默|妖术|缠绕|击飞|定身|stun|silence|hex|root|cyclone",
re.I,
)
def ability_icon_keys(by_hero: dict) -> set[str]:
"""Non-innate ability keys shown in the relations skill row (CDN PNGs).
Innates (and Aghs chips of innates) use bundled ``innate.png`` in the UI —
many of those keys 404 on Steam CDN, so they are skipped here.
"""
keys: set[str] = set()
for cell in by_hero.values():
if not isinstance(cell, dict):
continue
for ab in cell.get("abilities") or []:
if not isinstance(ab, dict):
continue
key = ab.get("key")
if not isinstance(key, str) or not key or key in SKIP_ICON_KEYS:
continue
if ab.get("is_innate"):
continue
keys.add(key)
return keys
def download_ability_icons(
keys: set[str], *, force: bool = False, delay: float = 0.05
) -> tuple[int, int, int]:
"""Cache Steam ability icons. Returns (saved, skipped_existing, fail)."""
return download_icons(
keys, ABILITY_ICON_URL, ABILITY_ICONS,
force=force, delay=delay, skip_keys=SKIP_ICON_KEYS,
)
def truncate(text: str, n: int = DESC_MAX) -> str:
t = re.sub(r"\s+", " ", strip_html(text))
if len(t) <= n:
return t
return t[: n - 1] + ""
def load_odota_abilities() -> dict[str, dict]:
raw = http_json(ABILITIES_URL)
out: dict[str, dict] = {}
if not isinstance(raw, dict):
return out
for key, row in raw.items():
if isinstance(row, dict):
out[str(key)] = row
return out
def map_dispellable(valve_val: object, od_val: object) -> str:
try:
iv = int(valve_val) if valve_val is not None else None
except (TypeError, ValueError):
iv = None
if iv is not None and iv in DISPEL_MAP:
label = DISPEL_MAP[iv]
if label != "none":
return label
if isinstance(od_val, str):
return OD_DISPEL_MAP.get(od_val.strip().lower(), "none")
return "none"
def _int_of(val: object) -> int:
try:
return int(val) # behavior/damage/immunity arrive as str or int
except (TypeError, ValueError):
return 0
def fmt_num_list(vals: object, *, is_pct: bool = False) -> str:
"""[100, 200, 300] → '100 / 200 / 300'; integral floats shown as ints."""
out: list[str] = []
if not isinstance(vals, list):
return ""
for v in vals:
try:
f = float(v)
except (TypeError, ValueError):
continue
out.append(str(int(f)) if f == int(f) else f"{f:g}")
txt = " / ".join(out)
if txt and is_pct:
txt += "%"
return txt
def fmt_time_value(vals: object) -> str:
"""Cast point / channel time rarely vary per level; collapse repeats.
[0.3, 0.3, 0.3, 0.3] -> '0.3'; [1, 2, 3] -> '1 / 2 / 3'.
"""
txt = fmt_num_list(vals)
parts = [p for p in txt.split(" / ") if p]
if parts and len(set(parts)) == 1:
return parts[0]
return txt
def target_label(behavior: int, is_innate: bool) -> str:
"""技能 targeting label matching dota2.com (点目标 / 单位目标 / 无目标 / 被动…)."""
if behavior & B_VECTOR:
return "矢量目标"
if behavior & B_UNIT_TARGET and behavior & B_POINT:
return "单位或点目标"
if behavior & B_UNIT_TARGET:
return "单位目标"
if behavior & B_POINT:
return "点目标"
if behavior & B_NO_TARGET:
return "无目标"
if behavior & B_TOGGLE:
return "开关"
if behavior & B_AUTOCAST:
return "自动施法"
if behavior & B_CHANNELLED:
return "持续施法"
if behavior & B_AURA:
return "光环"
if behavior & B_PASSIVE or is_innate:
return "被动"
return ""
def affects_label(behavior: int, team: int, type_bits: int) -> str:
"""影响 label (e.g. 敌方单位) for unit-target abilities."""
if not (behavior & B_UNIT_TARGET):
return ""
prefix = TARGET_TEAM_MAP.get(team, "")
if not prefix:
return ""
hero = bool(type_bits & 1)
creep = bool(type_bits & 2)
building = bool(type_bits & 4)
if hero and creep:
noun = "单位"
elif hero:
noun = "英雄"
elif creep:
noun = "普通单位"
else:
noun = "单位"
if building:
noun += " / 建筑"
return prefix + noun
def special_rows(sv: list) -> list[dict]:
"""special_values → [{label, value}] rows with a non-empty Chinese heading."""
out: list[dict] = []
for row in sv:
if not isinstance(row, dict):
continue
heading = str(row.get("heading_loc") or "").strip().rstrip(":").strip()
if not heading:
continue
value = fmt_num_list(
row.get("values_float"), is_pct=bool(row.get("is_percentage"))
)
if not value:
continue
out.append({"label": heading, "value": value})
return out
def _any_nonzero(vals: object) -> bool:
if not isinstance(vals, list):
return False
for v in vals:
try:
if float(v) != 0:
return True
except (TypeError, ValueError):
continue
return False
def fetch_herodata(hero_id: int) -> dict | None:
try:
raw = http_json(HERODATA_URL.format(hero_id=hero_id), timeout=90)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
return None
heroes = (((raw or {}).get("result") or {}).get("data") or {}).get("heroes") or []
if not heroes or not isinstance(heroes[0], dict):
return None
return heroes[0]
def summarize_hero(
hero_key: str,
abilities: list[dict],
hero_tags: list[str],
) -> dict:
disp_yes = 0
strong_only = 0
has_illusion = hero_key in ILLUSION_KEYS or "幻象" in (hero_tags or [])
has_evasion = False
has_invis = False
has_passive_breakable = False
magic_nuke = False
disable_heavy = 0
for ab in abilities:
desc = (ab.get("desc_loc") or "") + " " + (ab.get("name_loc") or "")
d = ab.get("dispellable") or "none"
if d == "yes":
disp_yes += 1
elif d == "strong_only":
strong_only += 1
if ILLUSION_CREATE_RE.search(desc):
has_illusion = True
if EVASION_RE.search(desc):
has_evasion = True
if INVIS_RE.search(desc):
has_invis = True
if BREAKABLE_PASSIVE_RE.search(desc):
has_passive_breakable = True
name_key = (ab.get("key") or "").lower()
# Known breakable passives by ability key fragments
if any(
x in name_key
for x in (
"blur",
"juxtapose",
"dispersion",
"brilliance_aura",
"dragon_blood",
"bash",
"crippling_fear",
"hunter_in_the_night",
"essence_aura",
"mana_shield",
"backtrack",
"reactive_armor",
"feral_impulse",
"natural_order",
"gravekeepers_cloak",
)
):
has_passive_breakable = True
if MAGIC_NUKE_RE.search(desc):
magic_nuke = True
if DISABLE_RE.search(desc):
disable_heavy += 1
return {
"dispellable_buff_count": disp_yes,
"has_strong_only_buff": strong_only > 0,
"has_illusion": has_illusion,
"has_evasion": has_evasion,
"has_invis": has_invis,
"has_passive_breakable": has_passive_breakable,
"magic_nuke": magic_nuke,
"disable_heavy": disable_heavy >= 2,
"mana_dependent": False, # filled lightly below
}
def ability_row(raw: dict, odota: dict[str, dict]) -> dict | None:
name = (raw.get("name") or "").strip()
if not name:
return None
# Valve uses ability name without npc prefix; OD keys match
key = name
if key.startswith("item_"):
return None
od = odota.get(key) or {}
sv = raw.get("special_values") or []
if not isinstance(sv, list):
sv = []
desc_loc = format_loc(raw.get("desc_loc") or "", sv)
shard_loc = format_loc(raw.get("shard_loc") or "", sv, prefer="shard")
scepter_loc = format_loc(raw.get("scepter_loc") or "", sv, prefer="scepter")
disp = map_dispellable(raw.get("dispellable"), od.get("dispellable"))
bkb = od.get("bkbpierce")
if isinstance(bkb, str):
bkb_s = bkb
else:
bkb_s = None
# Trust Valve's explicit upgrade flags. Valve keeps residual
# scepter_loc/shard_loc text even after the upgrade is removed
# (ability_has_scepter False); the old `or bool(scepter_loc)` fallback
# turned that stale text into a phantom upgrade. Clear it instead.
has_scepter = bool(raw.get("ability_has_scepter"))
has_shard = bool(raw.get("ability_has_shard"))
if not has_scepter:
scepter_loc = ""
if not has_shard:
shard_loc = ""
behavior = _int_of(raw.get("behavior"))
is_innate = bool(raw.get("ability_is_innate"))
cast_range = fmt_num_list(raw.get("cast_ranges"))
if not _any_nonzero(raw.get("cast_ranges")):
cast_range = ""
cooldown = fmt_num_list(raw.get("cooldowns"))
mana_cost = fmt_num_list(raw.get("mana_costs"))
cast_point = (
fmt_time_value(raw.get("cast_points"))
if _any_nonzero(raw.get("cast_points"))
else ""
)
channel_time = (
fmt_time_value(raw.get("channel_times"))
if _any_nonzero(raw.get("channel_times"))
else ""
)
return {
"key": key,
"name_loc": (raw.get("name_loc") or key).strip(),
"desc_loc": truncate(desc_loc),
"shard_loc": truncate(shard_loc, UPGRADE_MAX),
"scepter_loc": truncate(scepter_loc, UPGRADE_MAX),
"has_shard": has_shard,
"has_scepter": has_scepter,
"granted_by_shard": bool(raw.get("ability_is_granted_by_shard")),
"granted_by_scepter": bool(raw.get("ability_is_granted_by_scepter")),
"dispellable": disp,
"immunity": raw.get("immunity"),
"bkbpierce": bkb_s,
"is_innate": is_innate,
# Detail pane fields (dota2.com ability panel)
"target_label": target_label(behavior, is_innate),
"affects_label": affects_label(
behavior, _int_of(raw.get("target_team")), _int_of(raw.get("target_type"))
),
"damage_label": DAMAGE_MAP.get(_int_of(raw.get("damage")), ""),
"immunity_label": IMMUNITY_MAP.get(_int_of(raw.get("immunity")), ""),
"cast_range": cast_range,
"cast_point": cast_point,
"channel_time": channel_time,
"cooldown": cooldown,
"mana_cost": mana_cost,
"specials": special_rows(sv),
"lore_loc": strip_html(raw.get("lore_loc") or "").strip(),
"tags": [],
}
def talent_rows(
raw_list: object,
odota: dict[str, dict],
abilities_raw: list | None = None,
) -> list[dict]:
"""8 talents → level 10/15/20/25 left/right (Valve order).
Talent name_loc templates use {s:bonus_<sv_name>} tokens, but the bonus
values are NOT on the talent (its special_values is empty). They live on
each ability's special_values[].bonuses. Valve's data is inconsistent:
- Normal case: bonus.name == talent key, sv_name == token's sv name.
- tinker_5: bonus.name != talent key (uses '..._rearm_channel_time'),
but token sv name == actual sv name → resolve by sv name fallback.
- invoker forged_spirit: bonus found by talent key, but sv_name
('armor_per_attack') != token sv name ('armor_removed') → pair the
single bonus to the single token regardless of name.
The +/- sign and unit (秒/%) are already in name_loc, so we fill the raw
bonus.value regardless of operation (ADD=0, SUBTRACT=2 dominate).
"""
if not isinstance(raw_list, list):
return []
# talent_key -> [(sv_name, bonus_value), ...]
bonus_by_talent: dict[str, list[tuple[str, float]]] = {}
# sv_name -> [bonus_value, ...] (fallback when bonus.name != talent key)
sv_bonus_by_name: dict[str, list[float]] = {}
for ab in abilities_raw or []:
if not isinstance(ab, dict):
continue
for sv in ab.get("special_values") or []:
if not isinstance(sv, dict):
continue
sv_name = str(sv.get("name") or "").strip()
if not sv_name:
continue
for b in sv.get("bonuses") or []:
if not isinstance(b, dict):
continue
tkey = str(b.get("name") or "").strip()
try:
bv = float(b.get("value"))
except (TypeError, ValueError):
continue
if tkey:
bonus_by_talent.setdefault(tkey, []).append((sv_name, bv))
sv_bonus_by_name.setdefault(sv_name, []).append(bv)
out: list[dict] = []
for i, raw in enumerate(raw_list[:8]):
if not isinstance(raw, dict):
continue
key = (raw.get("name") or "").strip()
if not key:
continue
name_loc_raw = raw.get("name_loc") or ""
# sv names the template expects (from {s:bonus_<X>} tokens).
expected = re.findall(r"\{s:bonus_([A-Za-z0-9_]+)\}", name_loc_raw)
keyed = bonus_by_talent.get(key, [])
# Map each expected sv name to a bonus value, with two fallbacks.
value_by_expected: dict[str, float] = {}
for exp in expected:
# 1. exact sv_name match among this talent's keyed bonuses
val = next((v for sn, v in keyed if sn == exp), None)
# 2. single token + single keyed bonus → pair regardless of name
if val is None and len(keyed) == 1 and len(expected) == 1:
val = keyed[0][1]
# 3. fallback: any bonus on a sv with this name across abilities
if val is None:
cands = sv_bonus_by_name.get(exp, [])
if cands:
val = cands[0]
if val is not None:
value_by_expected[exp] = val
pseudo_sv: list[dict] = [
{"name": f"bonus_{exp}", "values_float": [val]}
for exp, val in value_by_expected.items()
]
own_sv = raw.get("special_values") or []
if isinstance(own_sv, list):
pseudo_sv.extend(s for s in own_sv if isinstance(s, dict))
name_loc = format_loc(name_loc_raw, pseudo_sv)
if not name_loc:
od = odota.get(key) or {}
name_loc = format_loc(str(od.get("dname") or key), [])
# Drop unresolved empty placeholder leftovers like "+ %"
name_loc = re.sub(r"\+\s*%", "+", name_loc).strip()
# Manual override for talents Valve omits bonus data for (still '?').
if key in TALENT_OVERRIDES and "?" in name_loc:
name_loc = TALENT_OVERRIDES[key]
level = TALENT_LEVELS[i] if i < len(TALENT_LEVELS) else 10
side = "left" if i % 2 == 0 else "right"
out.append(
{
"key": key,
"name_loc": truncate(name_loc, 160),
"level": level,
"side": side,
}
)
return out
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--delay", type=float, default=0.2)
ap.add_argument("--out", type=Path, default=OUT)
ap.add_argument("--force", action="store_true")
ap.add_argument(
"--resummarize",
action="store_true",
help="Recompute summary from cached abilities without refetching herodata",
)
ap.add_argument(
"--icons",
action="store_true",
help="Also cache Steam CDN ability icons into assets/ability_icons/",
)
ap.add_argument(
"--icons-only",
action="store_true",
help="Only download ability icons from existing hero_abilities.json",
)
ap.add_argument(
"--icons-force",
action="store_true",
help="Re-download ability icons even when a local PNG already exists",
)
ap.add_argument(
"--tags-only",
action="store_true",
help="Recompute ability mechanic tags from cached JSON without refetching",
)
args = ap.parse_args()
heroes = hero_table()
heroes_by_key = {h["key"]: h for h in heroes}
if args.tags_only:
if not args.out.is_file():
raise SystemExit(f"missing {args.out}; run without --tags-only first")
prev = json.loads(args.out.read_text(encoding="utf-8"))
by_hero = dict(prev.get("by_hero") or {})
overrides = _load_ability_tag_overrides()
tagged = retag_abilities(by_hero, overrides)
meta = dict(prev.get("meta") or {})
meta["fetched_at"] = datetime.now(timezone.utc).isoformat()
meta["tags_only"] = True
meta["mechanic_tag_order"] = list(QUERY_MECHANIC_ORDER)
payload = {"meta": meta, "by_hero": by_hero}
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(
f"retag done: {len(by_hero)} heroes, {tagged} abilities with tags -> {args.out}",
flush=True,
)
return
if args.icons_only:
if not args.out.is_file():
raise SystemExit(f"missing {args.out}; run without --icons-only first")
prev = json.loads(args.out.read_text(encoding="utf-8"))
by_hero = dict(prev.get("by_hero") or {})
keys = ability_icon_keys(by_hero)
print(
f"downloading {len(keys)} ability icons from Steam CDN -> {ABILITY_ICONS}",
flush=True,
)
saved, skipped, fail = download_ability_icons(
keys, force=args.icons_force, delay=min(args.delay, 0.1)
)
print(
f"icons done: saved={saved} skipped={skipped} fail={fail} -> {ABILITY_ICONS}",
flush=True,
)
return
if args.resummarize:
if not args.out.is_file():
raise SystemExit(f"missing {args.out}; run without --resummarize first")
prev = json.loads(args.out.read_text(encoding="utf-8"))
by_hero = dict(prev.get("by_hero") or {})
for key, cell in by_hero.items():
if not isinstance(cell, dict):
continue
h = heroes_by_key.get(key) or {}
tags = list(h.get("tags") or [])
abilities = list(cell.get("abilities") or [])
summary = summarize_hero(key, abilities, tags)
manaish = sum(
1
for ab in abilities
if re.search(r"魔法|mana", (ab.get("desc_loc") or ""), re.I)
)
summary["mana_dependent"] = manaish >= 2 or key in {
"medusa",
"obsidian_destroyer",
"storm_spirit",
"leshrac",
}
cell["summary"] = summary
payload = {
"meta": {
"source": "valve+opendota",
"attribution": "https://www.dota2.com ; https://www.opendota.com",
"fetched_at": datetime.now(timezone.utc).isoformat(),
"heroes": len(by_hero),
"resummarized": True,
},
"by_hero": by_hero,
}
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
illus = sum(1 for c in by_hero.values() if (c.get("summary") or {}).get("has_illusion"))
print(f"resummarized {len(by_hero)} heroes (illusion={illus}) -> {args.out}", flush=True)
return
print("loading OpenDota abilities.json...", flush=True)
odota = load_odota_abilities()
print(f" {len(odota)} abilities", flush=True)
by_hero: dict[str, dict] = {}
if args.out.is_file() and not args.force:
try:
prev = json.loads(args.out.read_text(encoding="utf-8"))
for k, cell in (prev.get("by_hero") or {}).items():
# Require talents + detail-pane fields (post-upgrade schema).
abs_prev = cell.get("abilities") if isinstance(cell, dict) else None
abs_list = [a for a in (abs_prev or []) if isinstance(a, dict)]
# Require every schema generation's fields; missing any of them
# invalidates the cache so the hero is refetched with new data.
if (
isinstance(cell, dict)
and abs_prev is not None
and cell.get("talents") is not None
and any("specials" in a for a in abs_list)
and any("cast_point" in a for a in abs_list)
):
by_hero[str(k)] = cell
print(f"resuming with {len(by_hero)} heroes cached", flush=True)
except (OSError, json.JSONDecodeError):
pass
pending = [h for h in heroes if h["key"] not in by_hero]
print(f"fetching herodata for {len(pending)} / {len(heroes)} heroes...", flush=True)
for n, h in enumerate(pending, start=1):
key = h["key"]
hid = int(h["id"])
tags = list(h.get("tags") or [])
data = fetch_herodata(hid)
if data is None:
print(f" [{n}/{len(pending)}] {key} failed", flush=True)
time.sleep(max(args.delay, 0.1) * 2)
continue
abs_raw = data.get("abilities") or []
abilities: list[dict] = []
if isinstance(abs_raw, list):
for row in abs_raw:
if not isinstance(row, dict):
continue
ab = ability_row(row, odota)
if ab:
ab["tags"] = ability_mechanic_tags(ab, ABILITY_TAG_OVERRIDES_MAP)
abilities.append(ab)
talents = talent_rows(data.get("talents"), odota, abs_raw)
summary = summarize_hero(key, abilities, tags)
manaish = sum(
1
for ab in abilities
if re.search(r"魔法|mana", (ab.get("desc_loc") or ""), re.I)
)
summary["mana_dependent"] = manaish >= 2 or key in {
"medusa",
"obsidian_destroyer",
"storm_spirit",
"leshrac",
}
by_hero[key] = {
"id": hid,
"abilities": abilities,
"talents": talents,
"summary": summary,
}
n_up = sum(1 for a in abilities if a.get("shard_loc") or a.get("scepter_loc"))
print(
f" [{n}/{len(pending)}] {key}: {len(abilities)} abilities "
f"upgrades={n_up} talents={len(talents)} "
f"dispel={summary['dispellable_buff_count']}",
flush=True,
)
# Checkpoint every 10 heroes (or on the last one) so a crash only
# loses a small slice, without writing the full file on every hero.
if n % 10 == 0 or n == len(pending):
args.out.parent.mkdir(parents=True, exist_ok=True)
payload = {
"meta": {
"source": "valve+opendota",
"attribution": "https://www.dota2.com ; https://www.opendota.com",
"fetched_at": datetime.now(timezone.utc).isoformat(),
},
"by_hero": by_hero,
}
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
time.sleep(args.delay)
tagged = retag_abilities(by_hero, ABILITY_TAG_OVERRIDES_MAP)
payload = {
"meta": {
"source": "valve+opendota",
"attribution": "https://www.dota2.com ; https://www.opendota.com",
"fetched_at": datetime.now(timezone.utc).isoformat(),
"heroes": len(by_hero),
"mechanic_tag_order": list(QUERY_MECHANIC_ORDER),
"abilities_with_tags": tagged,
},
"by_hero": by_hero,
}
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(f"done: {len(by_hero)} heroes -> {args.out}", flush=True)
if args.icons:
keys = ability_icon_keys(by_hero)
print(
f"downloading {len(keys)} ability icons from Steam CDN -> {ABILITY_ICONS}",
flush=True,
)
saved, skipped, fail = download_ability_icons(
keys, force=args.icons_force, delay=min(args.delay, 0.1)
)
print(
f"icons done: saved={saved} skipped={skipped} fail={fail} -> {ABILITY_ICONS}",
flush=True,
)
if __name__ == "__main__":
main()
+318
View File
@@ -0,0 +1,318 @@
"""Fetch OpenDota hero item popularity into data/hero_items.json.
Stores a flat Top-N of core finished items per hero (no start/early/mid/late).
Item names (Chinese): Valve dota2.com datafeed (schinese).
Item icons: Steam CDN dota_react/items/{key}.png → assets/item_icons/.
Usage:
python fetch_hero_items.py
python fetch_hero_items.py --force
python fetch_hero_items.py --delay 0.3 --skip-icons
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
import time
import urllib.error
from datetime import datetime, timezone
from shared.grid import hero_table
from shared.http_utils import download_icons, http_json, load_itemlist_zh
from shared.paths import DATA, ITEM_ICONS
OPENDOTA = "https://api.opendota.com/api"
ITEMS_URL = (
"https://raw.githubusercontent.com/odota/dotaconstants/master/build/items.json"
)
ICON_URL = (
"https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/items/{key}.png"
)
OUT = DATA / "hero_items.json"
PHASE_API = (
"start_game_items",
"early_game_items",
"mid_game_items",
"late_game_items",
)
# Crafted boots / mid-game cores (phase, treads, vanguard); skip bracer/wand.
MIN_CREATED_COST = 1400
# Non-crafted shop cores that are still finished pickups (blink / 跳刀).
ALWAYS_CORE = frozenset({"blink", "aghanims_shard"})
# Aghanim's Blessing (ultimate_scepter_2, id 271) is the activated form of
# Aghanim's Scepter (ultimate_scepter, id 108). Merge blessing counts into
# scepter so heroes show "阿哈利姆神杖" once, never "阿哈利姆福佑".
BLESSING_TO_SCEPTER = {271: 108}
TOP_N = 12
# If any upgrade's popularity >= this fraction of the intermediate's, hide it
# (e.g. AM yasha→manta). If upgrades are rare (Jugg yasha only), keep it.
UPGRADE_RATIO = 0.35
def load_item_catalog() -> tuple[dict[int, dict], dict[int, list[int]]]:
"""Return (id -> meta, id -> upgrade item ids that list it as a component)."""
raw = http_json(ITEMS_URL)
zh = load_itemlist_zh()
key_to_id: dict[str, int] = {}
out: dict[int, dict] = {}
for key, row in raw.items():
if not isinstance(row, dict):
continue
iid = row.get("id")
if iid is None:
continue
key_s = str(key)
if key_s.startswith("recipe_"):
continue
dname = str(row.get("dname") or key_s)
try:
cost = int(row.get("cost") or 0)
except (TypeError, ValueError):
cost = 0
comps = [str(c) for c in (row.get("components") or []) if c]
out[int(iid)] = {
"key": key_s,
"dname": dname,
"name_loc": zh.get(int(iid)) or dname,
"created": bool(row.get("created")),
"cost": cost,
"tier": row.get("tier"),
"qual": row.get("qual"),
"components": comps,
}
key_to_id[key_s] = int(iid)
upgrades_of: dict[int, list[int]] = {iid: [] for iid in out}
for iid, meta in out.items():
for comp_key in meta["components"]:
cid = key_to_id.get(comp_key)
if cid is None or cid == iid:
continue
upgrades_of.setdefault(cid, []).append(iid)
return out, upgrades_of
def is_core_finished(meta: dict) -> bool:
"""Core finished items — skip consumables, neutrals, secret-shop parts, cheap early."""
key = meta.get("key") or ""
if meta.get("tier") is not None:
return False
if meta.get("qual") == "consumable":
return False
cost = int(meta.get("cost") or 0)
if cost <= 0:
return False
if key in ALWAYS_CORE:
return True
# Only recipe-assembled items (phase boots, bfury, bkb, …).
if not meta.get("created"):
return False
return cost >= MIN_CREATED_COST
def merge_phase_counts(raw: dict) -> dict[int, int]:
"""Max count per item id across OpenDota phases."""
merged: dict[int, int] = {}
for api_key in PHASE_API:
counts = raw.get(api_key) or {}
if not isinstance(counts, dict):
continue
for sid, cnt in counts.items():
try:
iid = int(sid)
c = int(cnt)
except (TypeError, ValueError):
continue
if c <= 0:
continue
prev = merged.get(iid, 0)
if c > prev:
merged[iid] = c
return merged
def fold_blessing_into_scepter(counts: dict[int, int]) -> dict[int, int]:
"""Merge Aghanim's Blessing (id 271) counts into Aghanim's Scepter (id 108).
Blessing is the activated/synthesized form of Scepter; OpenDota records
them under separate ids. Take max (not sum): the same game can register
both the purchase and the activation, which would double-count.
"""
out = dict(counts)
for bid, sid in BLESSING_TO_SCEPTER.items():
b = out.pop(bid, 0)
s = out.get(sid, 0)
if b or s:
out[sid] = max(s, b)
return out
def is_terminal_for_hero(
iid: int,
count: int,
all_counts: dict[int, int],
upgrades_of: dict[int, list[int]],
ratio: float = UPGRADE_RATIO,
) -> bool:
"""True if hero rarely upgrades this item further (keep yasha for Jugg, drop for AM)."""
parents = upgrades_of.get(iid) or []
if not parents:
return True
threshold = max(1, int(count * ratio))
for pid in parents:
if all_counts.get(pid, 0) >= threshold:
return False
return True
def core_from_popularity(
raw: dict,
catalog: dict[int, dict],
upgrades_of: dict[int, list[int]],
n: int = TOP_N,
) -> list[dict]:
"""Merge phases → core items → drop intermediates that this hero upgrades."""
all_counts = fold_blessing_into_scepter(merge_phase_counts(raw))
merged: dict[int, int] = {}
for iid, c in all_counts.items():
meta = catalog.get(iid)
if meta is None or not is_core_finished(meta):
continue
if not is_terminal_for_hero(iid, c, all_counts, upgrades_of):
continue
merged[iid] = c
ranked = sorted(merged.items(), key=lambda t: (-t[1], t[0]))
return [{"id": iid, "count": c} for iid, c in ranked[:n]]
def fetch_popularity(hero_id: int) -> dict:
return http_json(f"{OPENDOTA}/heroes/{hero_id}/itemPopularity") # type: ignore[return-value]
def build_payload(by_hero: dict, catalog: dict[int, dict], used_ids: set[int]) -> dict:
items_out = {
str(iid): {
"key": catalog[iid]["key"],
"dname": catalog[iid]["dname"],
"name_loc": catalog[iid]["name_loc"],
}
for iid in sorted(used_ids)
if iid in catalog
}
return {
"meta": {
"source": "opendota+valve",
"attribution": "https://www.opendota.com ; https://www.dota2.com",
"fetched_at": datetime.now(timezone.utc).isoformat(),
"top_n": TOP_N,
"mode": "core_finished_terminal",
"upgrade_ratio": UPGRADE_RATIO,
"icons": "steam_cdn",
},
"items": items_out,
"by_hero": by_hero,
}
def collect_used_ids(by_hero: dict) -> set[int]:
used: set[int] = set()
for cell in by_hero.values():
if not isinstance(cell, list):
continue
for row in cell:
if isinstance(row, dict) and "id" in row:
used.add(int(row["id"]))
return used
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--delay", type=float, default=0.3)
ap.add_argument("--out", type=Path, default=OUT)
ap.add_argument("--skip-icons", action="store_true")
ap.add_argument("--force-icons", action="store_true")
ap.add_argument(
"--force",
action="store_true",
help="Refetch all heroes (ignore existing by_hero cache)",
)
args = ap.parse_args()
print("fetching item catalog (dotaconstants + Valve schinese)...", flush=True)
catalog, upgrades_of = load_item_catalog()
print(f" {len(catalog)} items", flush=True)
heroes = hero_table()
by_key = {h["key"]: int(h["id"]) for h in heroes}
by_hero: dict[str, list] = {}
if args.out.is_file() and not args.force:
try:
prev = json.loads(args.out.read_text(encoding="utf-8"))
for k, cell in (prev.get("by_hero") or {}).items():
if isinstance(cell, list):
by_hero[str(k)] = cell
print(f"resuming with {len(by_hero)} heroes already cached", flush=True)
except (OSError, json.JSONDecodeError):
pass
elif args.force:
print("force: refetching all heroes", flush=True)
pending = [k for k in sorted(by_key) if k not in by_hero]
print(f"fetching {len(pending)} / {len(by_key)} heroes -> {args.out}", flush=True)
used_ids = collect_used_ids(by_hero)
for n, key in enumerate(pending, start=1):
hid = by_key[key]
try:
raw = fetch_popularity(hid)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, TypeError) as e:
print(f" [{n}/{len(pending)}] {key} ({hid}) failed: {e}", flush=True)
time.sleep(max(args.delay, 0.1) * 2)
continue
core = core_from_popularity(raw, catalog, upgrades_of)
by_hero[key] = core
used_ids.update(int(row["id"]) for row in core if isinstance(row, dict) and "id" in row)
print(f" [{n}/{len(pending)}] {key}: {len(core)} core items", flush=True)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(build_payload(by_hero, catalog, used_ids), ensure_ascii=False, indent=2)
+ "\n",
encoding="utf-8",
)
time.sleep(args.delay)
used_ids = collect_used_ids(by_hero)
payload = build_payload(by_hero, catalog, used_ids)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
if not args.skip_icons:
keys = {catalog[iid]["key"] for iid in used_ids if iid in catalog}
print(f"downloading {len(keys)} item icons from Steam CDN -> {ITEM_ICONS}", flush=True)
saved, skipped, fail = download_icons(keys, ICON_URL, ITEM_ICONS, force=args.force_icons)
print(f" icons saved={saved} skipped={skipped} fail={fail}", flush=True)
missing = [k for k in by_key if k not in by_hero]
print(
f"done: {len(by_hero)} heroes, {len(payload['items'])} items"
+ (f", {len(missing)} still missing" if missing else ""),
flush=True,
)
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
+111
View File
@@ -0,0 +1,111 @@
"""Download UI art used by https://www.dota2.com/heroes.
- Landscape hero cards: Steam CDN `heroes/{key}.png`
- Attribute icons: Steam CDN `icons/hero_{strength,agility,...}.png`
Separate from `templates/cdn/` face crops used for top-bar matching.
Usage:
python fetch_hero_portraits.py
python fetch_hero_portraits.py --force
Writes:
assets/hero_portraits/{key}.png
assets/attr_icons/{str,agi,int,all}.png
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
from shared.http_utils import fetch_hero_keys, http_bytes
from shared.paths import ATTR_ICONS, HERO_PORTRAITS, HEROES_JSON
# wide = face headshots; crop = waist-up 3D renders (usually worse in dense grids).
CARD_URL = (
"https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/heroes/{key}.png"
)
CROP_URL = (
"https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/heroes/crops/{key}.png"
)
ATTR_ICON_URL = (
"https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/icons/{name}.png"
)
ATTR_FILES = {
"str": "hero_strength",
"agi": "hero_agility",
"int": "hero_intelligence",
"all": "hero_universal",
}
def hero_keys() -> list[str]:
if HEROES_JSON.is_file():
rows = json.loads(HEROES_JSON.read_text(encoding="utf-8"))
return [h["key"] for h in rows if h.get("key")]
return fetch_hero_keys()
def fetch_attr_icons(*, force: bool) -> tuple[int, int, int]:
ATTR_ICONS.mkdir(parents=True, exist_ok=True)
ok = skip = fail = 0
for attr, name in ATTR_FILES.items():
out = ATTR_ICONS / f"{attr}.png"
if out.is_file() and not force:
skip += 1
continue
try:
data = http_bytes(ATTR_ICON_URL.format(name=name), timeout=30)
out.write_bytes(data)
ok += 1
print(f" attr {attr} <- {name} ({len(data)} bytes)")
except Exception as e: # noqa: BLE001
fail += 1
print(f" FAIL attr {attr}: {e}")
return ok, skip, fail
def main() -> None:
ap = argparse.ArgumentParser(description="Download dota2.com/heroes UI art")
ap.add_argument("--force", action="store_true", help="re-download existing files")
ap.add_argument(
"--style",
choices=("crop", "wide"),
default="wide",
help="wide = face headshots for draft grid (default); crop = waist-up renders",
)
args = ap.parse_args()
print("attribute icons...")
a_ok, a_skip, a_fail = fetch_attr_icons(force=args.force)
HERO_PORTRAITS.mkdir(parents=True, exist_ok=True)
keys = hero_keys()
ok = skip = fail = 0
url_t = CROP_URL if args.style == "crop" else CARD_URL
print(f"hero cards ({args.style})...")
for key in keys:
out = HERO_PORTRAITS / f"{key}.png"
if out.is_file() and not args.force:
skip += 1
continue
try:
data = http_bytes(url_t.format(key=key), timeout=30)
out.write_bytes(data)
ok += 1
print(f" ok {key} ({len(data)} bytes)")
except Exception as e: # noqa: BLE001
fail += 1
print(f" FAIL {key}: {e}")
print(f"attrs: downloaded={a_ok} skipped={a_skip} failed={a_fail} -> {ATTR_ICONS}")
print(f"heroes: downloaded={ok} skipped={skip} failed={fail} -> {HERO_PORTRAITS}")
if __name__ == "__main__":
main()
+209
View File
@@ -0,0 +1,209 @@
"""Fetch OpenDota hero pick/win stats (all brackets) into data/hero_stats.json.
One GET /api/heroStats call covers every hero. OpenDota aggregates *recent*
matches: pub_pick equals the sum of pub_pick_trend (typically 7 daily buckets),
and bracket fields share that same recent window — not all-time / full patch.
Output is for the Climperor web site only — never merge into relations.json /
heroes.json or recommend.
Usage:
python fetch_hero_stats.py
python fetch_hero_stats.py --out data/hero_stats.json
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
from datetime import datetime, timezone
from shared.grid import hero_table
from shared.http_utils import http_json
from shared.paths import DATA
OPENDOTA_HERO_STATS = "https://api.opendota.com/api/heroStats"
OUT = DATA / "hero_stats.json"
# OpenDota skill brackets: 1=Herald … 8=Immortal
BRACKET_ORDER = (
"herald",
"guardian",
"crusader",
"archon",
"legend",
"ancient",
"divine",
"immortal",
)
BRACKET_NUM = {name: i for i, name in enumerate(BRACKET_ORDER, start=1)}
# Fallback when a heroStats row has no pub_*_trend arrays.
DEFAULT_WINDOW_DAYS = 7
def _pw(pick: object, win: object) -> dict[str, int]:
try:
p = int(pick or 0)
except (TypeError, ValueError):
p = 0
try:
w = int(win or 0)
except (TypeError, ValueError):
w = 0
return {"pick": max(0, p), "win": max(0, w)}
def row_to_hero_stats(row: dict) -> dict:
brackets: dict[str, dict[str, int]] = {}
for name, num in BRACKET_NUM.items():
brackets[name] = _pw(row.get(f"{num}_pick"), row.get(f"{num}_win"))
pro = _pw(row.get("pro_pick"), row.get("pro_win"))
try:
ban = int(row.get("pro_ban") or 0)
except (TypeError, ValueError):
ban = 0
pro["ban"] = max(0, ban)
return {
"pub": _pw(row.get("pub_pick"), row.get("pub_win")),
"brackets": brackets,
"pro": pro,
"turbo": _pw(row.get("turbo_picks"), row.get("turbo_wins")),
}
def detect_window_days(rows: list) -> int:
"""Infer recent-window length from OpenDota pub_*_trend bucket counts."""
for row in rows:
if not isinstance(row, dict):
continue
for key in ("pub_pick_trend", "pub_win_trend"):
trend = row.get(key)
if isinstance(trend, list) and trend:
return len(trend)
return DEFAULT_WINDOW_DAYS
def sum_picks(by_hero: dict[str, dict], path: tuple[str, ...]) -> int:
total = 0
for cell in by_hero.values():
cur: object = cell
for key in path:
if not isinstance(cur, dict):
cur = None
break
cur = cur.get(key)
if isinstance(cur, dict):
try:
total += int(cur.get("pick") or 0)
except (TypeError, ValueError):
pass
return total
def sum_bans(by_hero: dict[str, dict], path: tuple[str, ...]) -> int:
total = 0
for cell in by_hero.values():
cur: object = cell
for key in path:
if not isinstance(cur, dict):
cur = None
break
cur = cur.get(key)
if isinstance(cur, dict):
try:
total += int(cur.get("ban") or 0)
except (TypeError, ValueError):
pass
return total
def build_totals(by_hero: dict[str, dict]) -> dict:
"""Aggregate pick (and pro ban) counts so the UI can derive pick/ban rates.
Pick rate ≈ hero_pick / (sum_picks / 10) because each match contributes 10 picks.
OpenDota exposes public ranked bans only for the pro scene (pro_ban).
"""
brackets = {
name: {"pick": sum_picks(by_hero, ("brackets", name))} for name in BRACKET_ORDER
}
return {
"pub": {"pick": sum_picks(by_hero, ("pub",))},
"brackets": brackets,
"pro": {
"pick": sum_picks(by_hero, ("pro",)),
"ban": sum_bans(by_hero, ("pro",)),
},
"turbo": {"pick": sum_picks(by_hero, ("turbo",))},
}
def build_payload(by_hero: dict[str, dict], *, window_days: int) -> dict:
days = max(1, int(window_days))
return {
"fetched_at": datetime.now(timezone.utc).isoformat(),
"source": "opendota",
"attribution": "https://www.opendota.com",
"window_days": days,
"window_note": (
f"OpenDota recent matches (~{days} days); "
"pub_pick ≈ sum(pub_pick_trend); brackets share the same window; "
"pick_rate = pick / (sum_picks/10); ranked ban rates not in heroStats"
),
"window_label_zh": f"近约 {days} 天公开对局",
"brackets": list(BRACKET_ORDER),
"totals": build_totals(by_hero),
"by_hero": by_hero,
}
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--out", type=Path, default=OUT)
args = ap.parse_args()
heroes = hero_table()
id_to_key = {int(h["id"]): h["key"] for h in heroes}
print(f"fetching {OPENDOTA_HERO_STATS} ...", flush=True)
raw = http_json(OPENDOTA_HERO_STATS)
if not isinstance(raw, list):
raise SystemExit(f"unexpected heroStats payload type: {type(raw).__name__}")
window_days = detect_window_days(raw)
by_hero: dict[str, dict] = {}
unknown = 0
for row in raw:
if not isinstance(row, dict):
continue
try:
hid = int(row.get("id"))
except (TypeError, ValueError):
continue
key = id_to_key.get(hid)
if key is None:
unknown += 1
continue
by_hero[key] = row_to_hero_stats(row)
payload = build_payload(by_hero, window_days=window_days)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
missing = [h["key"] for h in heroes if h["key"] not in by_hero]
print(
f"done: {len(by_hero)} heroes -> {args.out}"
+ (f", {unknown} unknown ids" if unknown else "")
+ (f", {len(missing)} roster keys missing" if missing else ""),
flush=True,
)
if __name__ == "__main__":
main()
+303
View File
@@ -0,0 +1,303 @@
"""Fetch adjusted enemy-item evidence for each hero from OpenDota Explorer.
For every hero, this compares enemy-team final-inventory item presence against
the same item's baseline across all teams in the same recent match window:
buy_lift = P(item | against hero) - P(item | any team)
win_delta = P(win | item, against hero) - P(win | item, any team)
These are observational signals, not causal counter claims. The output is an
optional cache consumed by item_fears.py to corroborate and gently rerank its
mechanism-based candidates.
Usage:
python fetch_item_counter_stats.py
python fetch_item_counter_stats.py --matches 20000 --min-games 100
python fetch_item_counter_stats.py --print-sql
python fetch_item_counter_stats.py --soft-fail
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
import os
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from shared.grid import hero_table
from shared.paths import DATA
API = "https://api.opendota.com/api/explorer"
ITEMS_META = DATA / "items_meta.json"
OUT = DATA / "item_counter_stats.json"
def build_sql(matches: int, min_games: int) -> str:
"""Build one bounded aggregate query; integer args are caller-validated."""
return f"""
WITH recent_ids AS (
SELECT DISTINCT match_id
FROM player_matches
ORDER BY match_id DESC
LIMIT {matches}
),
recent AS (
SELECT m.match_id, m.radiant_win
FROM matches m
JOIN recent_ids r ON r.match_id = m.match_id
),
players AS (
SELECT
p.match_id,
p.hero_id,
(p.player_slot < 128) AS is_radiant,
p.item_0, p.item_1, p.item_2,
p.item_3, p.item_4, p.item_5
FROM player_matches p
JOIN recent r ON r.match_id = p.match_id
WHERE p.hero_id IS NOT NULL
),
teams AS (
SELECT DISTINCT match_id, is_radiant
FROM players
),
team_outcomes AS (
SELECT
t.match_id,
t.is_radiant,
CASE WHEN t.is_radiant THEN r.radiant_win ELSE NOT r.radiant_win END AS won
FROM teams t
JOIN recent r ON r.match_id = t.match_id
),
team_items AS (
SELECT DISTINCT
p.match_id,
p.is_radiant,
x.item_id
FROM players p
CROSS JOIN LATERAL (
VALUES (p.item_0), (p.item_1), (p.item_2),
(p.item_3), (p.item_4), (p.item_5)
) AS x(item_id)
WHERE x.item_id IS NOT NULL AND x.item_id > 0
),
hero_totals AS (
SELECT hero_id, COUNT(*) AS target_games
FROM players
GROUP BY hero_id
),
global_total AS (
SELECT COUNT(*) AS team_games
FROM team_outcomes
),
global_items AS (
SELECT
ti.item_id,
COUNT(*) AS item_games,
SUM(CASE WHEN o.won THEN 1 ELSE 0 END) AS item_wins
FROM team_items ti
JOIN team_outcomes o
ON o.match_id = ti.match_id
AND o.is_radiant = ti.is_radiant
GROUP BY ti.item_id
),
target_items AS (
SELECT
p.hero_id,
ti.item_id,
COUNT(*) AS item_games,
SUM(CASE WHEN o.won THEN 1 ELSE 0 END) AS item_wins
FROM players p
JOIN team_items ti
ON ti.match_id = p.match_id
AND ti.is_radiant <> p.is_radiant
JOIN team_outcomes o
ON o.match_id = ti.match_id
AND o.is_radiant = ti.is_radiant
GROUP BY p.hero_id, ti.item_id
HAVING COUNT(*) >= {min_games}
)
SELECT
t.hero_id,
t.item_id,
h.target_games,
t.item_games,
t.item_wins,
g.team_games AS global_team_games,
gi.item_games AS global_item_games,
gi.item_wins AS global_item_wins
FROM target_items t
JOIN hero_totals h ON h.hero_id = t.hero_id
JOIN global_items gi ON gi.item_id = t.item_id
CROSS JOIN global_total g
ORDER BY t.hero_id, t.item_games DESC
""".strip()
def explorer(sql: str, *, timeout: int = 240) -> dict:
params = {"sql": sql}
api_key = os.environ.get("OPENDOTA_API_KEY", "").strip()
if api_key:
params["api_key"] = api_key
url = API + "?" + urllib.parse.urlencode(params)
req = urllib.request.Request(url, headers={"User-Agent": "climperor"})
with urllib.request.urlopen(req, timeout=timeout) as response:
payload = json.loads(response.read().decode())
if not isinstance(payload, dict):
raise RuntimeError("OpenDota Explorer returned a non-object payload")
if payload.get("error") or payload.get("err"):
raise RuntimeError(str(payload.get("error") or payload.get("err")))
return payload
def item_catalog() -> dict[int, dict]:
raw = json.loads(ITEMS_META.read_text(encoding="utf-8"))
out: dict[int, dict] = {}
for raw_id, row in (raw.get("items") or {}).items():
if not isinstance(row, dict) or not row.get("key"):
continue
item_id = int(row.get("id") or raw_id)
out[item_id] = {
"key": str(row["key"]),
"name_loc": str(row.get("name_loc") or row.get("dname") or row["key"]),
"cost": int(row.get("cost") or 0),
}
return out
def build_payload(
rows: list[dict],
*,
matches: int,
min_games: int,
min_cost: int,
) -> dict:
heroes = {int(row["id"]): row["key"] for row in hero_table()}
items = item_catalog()
by_hero: dict[str, list[dict]] = {key: [] for key in heroes.values()}
for row in rows:
hero_key = heroes.get(int(row["hero_id"]))
item = items.get(int(row["item_id"]))
if not hero_key or not item or item["cost"] < min_cost:
continue
target_games = int(row["target_games"])
item_games = int(row["item_games"])
item_wins = int(row["item_wins"])
global_team_games = int(row["global_team_games"])
global_item_games = int(row["global_item_games"])
global_item_wins = int(row["global_item_wins"])
if not all((target_games, item_games, global_team_games, global_item_games)):
continue
buy_rate = item_games / target_games
global_buy_rate = global_item_games / global_team_games
win_rate = item_wins / item_games
global_win_rate = global_item_wins / global_item_games
by_hero[hero_key].append(
{
"item": item["key"],
"name_loc": item["name_loc"],
"games": item_games,
"wins": item_wins,
"target_games": target_games,
"global_team_games": global_team_games,
"global_item_games": global_item_games,
"global_item_wins": global_item_wins,
"buy_rate": round(buy_rate, 6),
"global_buy_rate": round(global_buy_rate, 6),
"buy_lift": round(buy_rate - global_buy_rate, 6),
"win_rate": round(win_rate, 6),
"global_win_rate": round(global_win_rate, 6),
"win_delta": round(win_rate - global_win_rate, 6),
}
)
for entries in by_hero.values():
entries.sort(
key=lambda row: (
-float(row["buy_lift"]),
-float(row["win_delta"]),
-int(row["games"]),
str(row["item"]),
)
)
return {
"meta": {
"source": "opendota_explorer",
"attribution": "https://www.opendota.com",
"fetched_at": datetime.now(timezone.utc).isoformat(),
"window_matches": matches,
"min_games": min_games,
"min_cost": min_cost,
"method": "enemy final inventory vs same-item global team baseline",
"caveat": "observational; adjusted for item baseline, not duration, rank, role, or economy",
},
"by_hero": by_hero,
}
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--matches", type=int, default=20_000)
ap.add_argument("--min-games", type=int, default=100)
ap.add_argument("--min-cost", type=int, default=1_400)
ap.add_argument("--out", type=Path, default=OUT)
ap.add_argument("--print-sql", action="store_true")
ap.add_argument(
"--soft-fail",
action="store_true",
help="keep an existing cache and exit 0 when OpenDota is unavailable",
)
args = ap.parse_args()
if args.matches < 1 or args.min_games < 1 or args.min_cost < 0:
raise SystemExit("matches/min-games must be positive and min-cost non-negative")
sql = build_sql(args.matches, args.min_games)
if args.print_sql:
print(sql)
return
if not ITEMS_META.is_file():
raise SystemExit(f"missing {ITEMS_META}; run: python fetch_items_meta.py")
try:
payload = explorer(sql)
rows = payload.get("rows") or []
if not isinstance(rows, list) or not rows:
raise RuntimeError("OpenDota Explorer returned no rows")
output = build_payload(
rows,
matches=args.matches,
min_games=args.min_games,
min_cost=args.min_cost,
)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(output, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
nonempty = sum(bool(v) for v in output["by_hero"].values())
print(
f"done: {len(rows)} rows, {nonempty} heroes -> {args.out}",
flush=True,
)
except (OSError, TimeoutError, urllib.error.URLError, RuntimeError) as exc:
if args.soft_fail:
state = "keeping existing cache" if args.out.is_file() else "no cache available"
print(f"warn: item counter stats unavailable ({exc}); {state}", flush=True)
return
raise
if __name__ == "__main__":
main()
+345
View File
@@ -0,0 +1,345 @@
"""Build data/item_shop.json from the official CN shop layout.
Primary source: https://www.dota2.com.cn/itemscategory/json (basic / upgrade columns).
Names / cost / components: OpenDota items.json + Valve itemlist (schinese).
Icons assets/item_icons/; category icons assets/item_cat_icons/.
Usage:
python fetch_item_shop.py
python fetch_item_shop.py --skip-icons
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
import re
import time
import urllib.error
from datetime import datetime, timezone
from shared.http_utils import download_icons, http_bytes, http_json, load_itemlist_zh
from shared.paths import DATA, ITEM_CAT_ICONS, ITEM_ICONS
ITEMS_URL = (
"https://raw.githubusercontent.com/odota/dotaconstants/master/build/items.json"
)
CN_CATEGORY_URL = "https://www.dota2.com.cn/itemscategory/json"
ICON_URL = (
"https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/items/{key}.png"
)
CAT_ICON_BASE = "https://www.dota2.com.cn/items/images/"
OUT = DATA / "item_shop.json"
# Official column label → category icon filename (from items_new.js).
CAT_ICON_BY_LABEL = {
"消耗品": "itemcat_consumables.png",
"属性": "itemcat_attributes.png",
"装备": "itemcat_armaments.png",
"其它": "itemcat_arcane.png",
"其他": "itemcat_arcane.png",
"神秘商店": "itemcat_secret.png",
"配件": "itemcat_common.png",
"辅助": "itemcat_support.png",
"法器": "itemcat_caster.png",
"防具": "itemcat_armor.png",
"兵刃": "itemcat_weapons.png",
"宝物": "itemcat_artifacts.png",
"军备": "itemcat_artifacts.png",
}
# Stable section ids for UI / lookups.
SECTION_ID_BY_LABEL = {
"消耗品": "consumables",
"属性": "attributes",
"装备": "equipment",
"其它": "misc",
"其他": "misc",
"神秘商店": "secretshop",
"配件": "basics",
"辅助": "support",
"法器": "magics",
"防具": "defense",
"兵刃": "weapons",
"宝物": "artifacts",
"军备": "artifacts",
}
def slug_section(label: str, fallback: str) -> str:
sid = SECTION_ID_BY_LABEL.get(label)
if sid:
return sid
s = re.sub(r"[^a-z0-9]+", "_", fallback.lower()).strip("_")
return s or "section"
def make_item_row(key: str, row: dict, zh: dict[int, str], *, is_recipe: bool = False) -> dict:
iid = row.get("id")
try:
cost = int(row.get("cost") or 0)
except (TypeError, ValueError):
cost = 0
name_loc = zh.get(int(iid)) if iid is not None else None
if not name_loc:
name_loc = "卷轴" if is_recipe else str(row.get("dname") or key)
return {
"key": key,
"id": int(iid) if iid is not None else None,
"dname": str(row.get("dname") or key),
"name_loc": name_loc,
"cost": cost,
"created": bool(row.get("created")),
"qual": row.get("qual"),
"is_recipe": is_recipe,
}
def download_shop_icons(icon_keys: set[str], *, force: bool = False) -> tuple[int, int, int]:
"""Download item icons (recipe handled separately; recipe_* keys skipped)."""
# recipe.png is the shared icon for all recipe_* items.
recipe_dest = ITEM_ICONS / "recipe.png"
recipe_fail = 0
if force or not recipe_dest.is_file():
try:
recipe_dest.write_bytes(http_bytes(ICON_URL.format(key="recipe")))
except (urllib.error.URLError, TimeoutError, ValueError, OSError) as e:
print(f" icon fail recipe: {e}", flush=True)
recipe_fail = 1
real_keys = {k for k in icon_keys if not k.startswith("recipe_")}
saved, skipped, fail = download_icons(
real_keys, ICON_URL, ITEM_ICONS, force=force, delay=0.02
)
return saved, skipped, fail + recipe_fail
def parse_cn_sections(raw_list: list, kind: str) -> list[dict]:
"""Convert CN basic/upgrade arrays into sections; reverse items like the site JS."""
sections: list[dict] = []
for i, row in enumerate(raw_list or []):
if not isinstance(row, dict):
continue
label = str(row.get("name") or "").strip()
if not label:
continue
keys: list[str] = []
for entry in row.get("items") or []:
if isinstance(entry, dict):
name = entry.get("name")
else:
name = entry
if name:
keys.append(str(name))
# Official site reverses each column for display.
keys.reverse()
sid = slug_section(label, f"{kind}_{i}")
icon = CAT_ICON_BY_LABEL.get(label, "")
sections.append(
{
"id": sid,
"label": label,
"icon": icon,
"items": keys,
}
)
return sections
def attach_craft_graph(
items_out: dict[str, dict],
od: dict,
catalog: dict[str, dict],
zh: dict[int, str],
shop_keys: set[str],
) -> set[str]:
builds_into: dict[str, list[str]] = {}
for key, row in od.items():
if not isinstance(row, dict):
continue
key_s = str(key)
if key_s.startswith("recipe_"):
continue
if row.get("tier") is not None:
continue
for raw in row.get("components") or []:
if not raw:
continue
builds_into.setdefault(str(raw), []).append(key_s)
extra_keys: set[str] = set()
for key, row in list(items_out.items()):
od_row = od.get(key) if isinstance(od.get(key), dict) else {}
comps = [str(c) for c in (od_row.get("components") or []) if c]
# OpenDota often omits recipe_* from components; re-attach when present.
has_recipe = any(c.startswith("recipe_") for c in comps)
recipe_key = f"recipe_{key}"
if not has_recipe and bool(od_row.get("created")):
recipe_row = od.get(recipe_key)
if isinstance(recipe_row, dict):
try:
rcost = int(recipe_row.get("cost") or 0)
except (TypeError, ValueError):
rcost = 0
if rcost > 0:
comps.append(recipe_key)
else:
# Fallback: gold gap between total and plain components.
try:
total = int(od_row.get("cost") or 0)
except (TypeError, ValueError):
total = 0
part = 0
ok = True
for c in comps:
crow = od.get(c) if isinstance(od.get(c), dict) else None
if not crow:
ok = False
break
try:
part += int(crow.get("cost") or 0)
except (TypeError, ValueError):
ok = False
break
gap = total - part if ok else 0
if gap > 0:
comps.append(recipe_key)
# Synthetic stub so UI can show cost even without odota recipe row.
if recipe_key not in od:
od[recipe_key] = {
"id": None,
"dname": f"{od_row.get('dname') or key} Recipe",
"cost": gap,
"components": None,
"created": False,
}
row["components"] = comps
ups = [u for u in builds_into.get(key, []) if u in shop_keys]
ups.sort(key=lambda u: (items_out.get(u, {}).get("cost") or 0, u))
row["builds_into"] = ups
extra_keys.update(comps)
for key in sorted(extra_keys):
if key in items_out:
continue
od_row = od.get(key)
if not isinstance(od_row, dict):
continue
if key.startswith("recipe_"):
items_out[key] = make_item_row(key, od_row, zh, is_recipe=True)
elif key in catalog:
stub = dict(catalog[key])
stub.setdefault("components", [])
stub.setdefault("builds_into", [])
items_out[key] = stub
else:
stub = make_item_row(key, od_row, zh)
stub["components"] = []
stub["builds_into"] = [u for u in builds_into.get(key, []) if u in shop_keys]
items_out[key] = stub
return extra_keys
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--out", type=Path, default=OUT)
ap.add_argument("--skip-icons", action="store_true")
ap.add_argument("--force-icons", action="store_true")
args = ap.parse_args()
print("loading CN shop categories + OpenDota + Valve names...", flush=True)
cn = http_json(CN_CATEGORY_URL)
result = (cn or {}).get("result") or {}
basic_sections = parse_cn_sections(result.get("basic") or [], "basic")
up_sections = parse_cn_sections(result.get("upgrade") or [], "upgraded")
if not basic_sections and not up_sections:
raise SystemExit("CN itemscategory/json returned empty basic/upgrade")
od = http_json(ITEMS_URL)
zh = load_itemlist_zh()
catalog: dict[str, dict] = {}
for key, row in od.items():
if not isinstance(row, dict):
continue
key_s = str(key)
if key_s.startswith("recipe_"):
continue
catalog[key_s] = make_item_row(key_s, row, zh)
shop_keys: set[str] = set()
items_out: dict[str, dict] = {}
missing: list[str] = []
cat_files: set[str] = set()
for kind, sections in (("basic", basic_sections), ("upgraded", up_sections)):
for sec in sections:
if sec.get("icon"):
cat_files.add(sec["icon"])
kept: list[str] = []
for key in sec["items"]:
if key not in catalog:
missing.append(key)
continue
kept.append(key)
shop_keys.add(key)
if key not in items_out:
row = dict(catalog[key])
row["shop_kind"] = kind
row["section"] = sec["id"]
row["section_label"] = sec["label"]
items_out[key] = row
sec["items"] = kept
if missing:
print(
f" warn missing in OpenDota ({len(missing)}): "
f"{', '.join(missing[:24])}{'' if len(missing) > 24 else ''}",
flush=True,
)
craft_extra = attach_craft_graph(items_out, od, catalog, zh, shop_keys)
icon_keys = set(shop_keys) | {k for k in craft_extra if not k.startswith("recipe_")}
payload = {
"meta": {
"source": "dota2.com.cn/itemscategory+opendota+valve",
"layout": "cn_shop_columns",
"fetched_at": datetime.now(timezone.utc).isoformat(),
"basic_count": sum(len(s["items"]) for s in basic_sections),
"upgraded_count": sum(len(s["items"]) for s in up_sections),
"craft_refs": len(craft_extra),
},
"basic": {"sections": basic_sections},
"upgraded": {"sections": up_sections},
"items": items_out,
}
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(
f"wrote {args.out}: basic={payload['meta']['basic_count']} "
f"upgraded={payload['meta']['upgraded_count']} "
f"cols={len(basic_sections)+len(up_sections)} "
f"craft_refs={len(craft_extra)}",
flush=True,
)
if not args.skip_icons:
print(f"downloading {len(icon_keys)} item icons (+ recipe)...", flush=True)
saved, skipped, fail = download_shop_icons(icon_keys, force=args.force_icons)
print(f" item icons saved={saved} skipped={skipped} fail={fail}", flush=True)
print(f"downloading {len(cat_files)} category icons...", flush=True)
c_saved, c_skipped, c_fail = download_icons(
cat_files, CAT_ICON_BASE + "{key}", ITEM_CAT_ICONS, force=args.force_icons, delay=0.05
)
print(f" cat icons saved={c_saved} skipped={c_skipped} fail={c_fail}", flush=True)
if __name__ == "__main__":
main()
+441
View File
@@ -0,0 +1,441 @@
"""Fetch shop item descriptions and mechanism tags into data/items_meta.json.
Sources:
- OpenDota items.json (structure, EN ability text)
- Valve itemlist / itemdata (schinese names + descriptions)
- data/item_tag_overrides.json (manual add/remove)
Usage:
python fetch_items_meta.py
python fetch_items_meta.py --force
python fetch_items_meta.py --reformat-desc --skip-icons
python fetch_items_meta.py --skip-icons --delay 0.2
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
import re
import time
import urllib.error
from datetime import datetime, timezone
from shared.http_utils import download_icons, http_json, load_itemlist
from shared.paths import DATA, ITEM_ICONS
from loc_format import HAS_PLACEHOLDER, format_loc
from mechanic_tags import apply_mechanic_tags, merge_tag_overrides
ITEMS_URL = (
"https://raw.githubusercontent.com/odota/dotaconstants/master/build/items.json"
)
ITEMDATA_URL = (
"https://www.dota2.com/datafeed/itemdata?language={lang}&item_id={item_id}"
)
ICON_URL = (
"https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/items/{key}.png"
)
OUT = DATA / "items_meta.json"
OVERRIDES = DATA / "item_tag_overrides.json"
MIN_CREATED_COST = 1400
ALWAYS_CORE = frozenset({"blink", "aghanims_shard", "gem", "dust", "ghost"})
TAG_ORDER = [
"basic_dispel",
"strong_dispel",
"mana_burn",
"break",
"true_strike",
"illusion_clear",
"magic_immune",
"spell_block",
"spell_reflect",
"invis_detect",
"invis_break",
"heal_reduce",
"armor_reduce",
"silence",
"hex",
"root",
"stun",
"disarm",
"mute",
"invis",
"sleep",
"fear",
"taunt",
"blind",
"leash",
"ethereal",
"cyclone",
]
# key -> extra tags from curated whitelist (also covered by overrides)
ILLUSION_CLEAR_KEYS = frozenset({
"bfury",
"radiance",
"maelstrom",
"mjollnir",
"gungir",
})
H1_RE = re.compile(
r"<h1>\s*(主动|被动|使用|开关|升级|Active|Passive|Use|Toggle|Upgrade)\s*[:]?\s*([^<]*)</h1>",
re.I,
)
def is_core_finished(meta: dict) -> bool:
key = meta.get("key") or ""
if meta.get("tier") is not None:
return False
if meta.get("qual") == "consumable" and key not in ALWAYS_CORE:
return False
cost = int(meta.get("cost") or 0)
if cost <= 0 and key not in ALWAYS_CORE:
return False
if key in ALWAYS_CORE:
return True
if not meta.get("created"):
return False
return cost >= MIN_CREATED_COST
def load_odota_catalog() -> dict[int, dict]:
raw = http_json(ITEMS_URL)
zh_index = load_itemlist()
out: dict[int, dict] = {}
for key, row in raw.items():
if not isinstance(row, dict):
continue
key_s = str(key)
if key_s.startswith("recipe_"):
continue
iid = row.get("id")
if iid is None:
continue
try:
cost = int(row.get("cost") or 0)
except (TypeError, ValueError):
cost = 0
zh = zh_index.get(int(iid)) or {}
abilities = row.get("abilities") or []
if not isinstance(abilities, list):
abilities = []
en_bits = []
for ab in abilities:
if isinstance(ab, dict):
en_bits.append(str(ab.get("description") or ""))
en_bits.append(str(ab.get("title") or ""))
out[int(iid)] = {
"key": key_s,
"dname": str(row.get("dname") or key_s),
"name_loc": zh.get("name_loc") or str(row.get("dname") or key_s),
"created": bool(row.get("created")),
"cost": cost,
"tier": row.get("tier"),
"qual": row.get("qual"),
"od_abilities": abilities,
"en_text": " ".join(en_bits),
"od_dispellable": row.get("dispellable"),
}
return out
def fetch_itemdata(item_id: int, lang: str = "schinese") -> dict | None:
try:
raw = http_json(ITEMDATA_URL.format(lang=lang, item_id=item_id))
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
return None
items = (((raw or {}).get("result") or {}).get("data") or {}).get("items") or []
if not items or not isinstance(items[0], dict):
return None
return items[0]
def parse_ability_kinds(desc_loc: str, od_abilities: list) -> list[str]:
kinds: set[str] = set()
for ab in od_abilities or []:
if not isinstance(ab, dict):
continue
t = str(ab.get("type") or "").lower()
if t in ("active", "passive", "use", "toggle", "upgrade"):
kinds.add(t)
for m in H1_RE.finditer(desc_loc or ""):
label = m.group(1).lower()
mapping = {
"主动": "active",
"被动": "passive",
"使用": "use",
"开关": "toggle",
"升级": "upgrade",
"active": "active",
"passive": "passive",
"use": "use",
"toggle": "toggle",
"upgrade": "upgrade",
}
if label in mapping:
kinds.add(mapping[label])
return sorted(kinds)
def auto_tags(key: str, desc_zh: str, desc_en: str, en_od: str, od_dispellable: object) -> list[str]:
blob = " ".join([desc_zh or "", desc_en or "", en_od or "", key])
tags: set[str] = set()
# Query-facing applies-* tags (dispel / CC); shared with ability tagging.
tags |= apply_mechanic_tags(blob, key=key)
if od_dispellable and str(od_dispellable).lower() in ("yes", "both"):
# Item itself being dispellable is not "applies dispel"
pass
if re.search(
r"mana burn|burn(?:s|ed)?\s+\d*\s*mana|燃烧.{0,6}魔法|破法|mana.?burn|反馈",
blob,
re.I,
):
tags.add("mana_burn")
if key == "monkey_king_bar" or re.search(
r"true strike|无视闪避|必定命中|attacks? cannot miss",
blob,
re.I,
):
tags.add("true_strike")
if key in ILLUSION_CLEAR_KEYS or re.search(
r"(?:对|伤害).{0,8}幻象|bonus damage to illusions|cleave|分裂攻击|闪电链|chain lightning",
blob,
re.I,
):
tags.add("illusion_clear")
if key == "black_king_bar" or re.search(
r"magic immunity|spell immunity|魔法免疫",
blob,
re.I,
):
tags.add("magic_immune")
if key == "sphere" or re.search(r"spell block|法术格挡", blob, re.I):
tags.add("spell_block")
if key == "lotus_orb" or re.search(
r"re-?casts? most targeted|echo shell|反射|回到施法者|回施",
blob,
re.I,
):
tags.add("spell_reflect")
if key in ("gem", "dust") or re.search(r"true sight|真实视域", blob, re.I):
tags.add("invis_detect")
if key in ("silver_edge", "invis_sword") or re.search(
r"破隐|break(?:s)? invis",
blob,
re.I,
):
tags.add("invis_break")
if key == "spirit_vessel" or re.search(
r"heal(?:ing)? reduction|减少治疗|治疗降低|回复降低",
blob,
re.I,
):
tags.add("heal_reduce")
if re.search(r"armor reduction|reduce(?:s)? armor|减甲|降低护甲", blob, re.I):
tags.add("armor_reduce")
return [t for t in TAG_ORDER if t in tags]
def load_overrides() -> dict[str, dict]:
if not OVERRIDES.is_file():
return {}
try:
raw = json.loads(OVERRIDES.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
items = raw.get("items") or {}
return {str(k): v for k, v in items.items() if isinstance(v, dict)}
def apply_overrides(key: str, tags: list[str], overrides: dict[str, dict]) -> list[str]:
return merge_tag_overrides(tags, overrides.get(key), TAG_ORDER)
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--delay", type=float, default=0.15)
ap.add_argument("--out", type=Path, default=OUT)
ap.add_argument("--skip-icons", action="store_true")
ap.add_argument("--force-icons", action="store_true")
ap.add_argument(
"--force",
action="store_true",
help="Refetch itemdata even if cached in existing out file",
)
ap.add_argument(
"--reformat-desc",
action="store_true",
help="Refetch special_values and fill %%token%% in cached descriptions",
)
args = ap.parse_args()
print("loading OpenDota items + Valve itemlist...", flush=True)
catalog = load_odota_catalog()
candidates = {
iid: meta
for iid, meta in catalog.items()
if is_core_finished(meta) or meta["key"] in ALWAYS_CORE
}
print(f" {len(candidates)} finished shop items", flush=True)
cached: dict[str, dict] = {}
if args.out.is_file() and not args.force:
try:
prev = json.loads(args.out.read_text(encoding="utf-8"))
for k, row in (prev.get("items") or {}).items():
if isinstance(row, dict) and row.get("desc_loc") is not None:
cached[str(k)] = row
print(f"resuming with {len(cached)} cached itemdata rows", flush=True)
except (OSError, json.JSONDecodeError):
pass
overrides = load_overrides()
items_out: dict[str, dict] = {}
pending = []
for iid in sorted(candidates):
sid = str(iid)
if args.force or sid not in cached:
pending.append(iid)
continue
if args.reformat_desc:
prev = cached[sid]
if HAS_PLACEHOLDER.search(prev.get("desc_loc") or "") or HAS_PLACEHOLDER.search(
prev.get("desc_en") or ""
):
pending.append(iid)
print(f"fetching/reformatting itemdata for {len(pending)} items...", flush=True)
for n, iid in enumerate(sorted(candidates), start=1):
meta = candidates[iid]
key = meta["key"]
sid = str(iid)
need_fetch = args.force or sid not in cached
if not need_fetch and args.reformat_desc and sid in cached:
prev = cached[sid]
if HAS_PLACEHOLDER.search(prev.get("desc_loc") or "") or HAS_PLACEHOLDER.search(
prev.get("desc_en") or ""
):
need_fetch = True
if sid in cached and not need_fetch:
row = dict(cached[sid])
# refresh tags from stored text + overrides
tags = auto_tags(
key,
row.get("desc_loc") or "",
row.get("desc_en") or "",
meta.get("en_text") or "",
meta.get("od_dispellable"),
)
row["tags"] = apply_overrides(key, tags, overrides)
row["ability_kinds"] = parse_ability_kinds(
row.get("desc_loc") or "", meta.get("od_abilities") or []
)
row["name_loc"] = meta["name_loc"] or row.get("name_loc") or key
row["dname"] = meta["dname"]
row["key"] = key
row["cost"] = meta["cost"]
row["desc_loc"] = format_loc(row.get("desc_loc") or "", [])
row["desc_en"] = format_loc(row.get("desc_en") or "", [])
items_out[sid] = row
continue
zh = fetch_itemdata(iid, "schinese")
time.sleep(args.delay)
en = fetch_itemdata(iid, "english")
time.sleep(args.delay)
sv = (zh or {}).get("special_values") or []
if not isinstance(sv, list):
sv = []
desc_zh_raw = (zh or {}).get("desc_loc") or ""
desc_en_raw = (en or {}).get("desc_loc") or ""
desc_zh = format_loc(desc_zh_raw, sv)
desc_en = format_loc(desc_en_raw, sv)
notes = (zh or {}).get("notes_loc") or []
if not isinstance(notes, list):
notes = []
tags = auto_tags(
key,
desc_zh_raw,
desc_en_raw,
meta.get("en_text") or "",
meta.get("od_dispellable"),
)
tags = apply_overrides(key, tags, overrides)
items_out[sid] = {
"id": iid,
"key": key,
"dname": meta["dname"],
"name_loc": meta["name_loc"] or ((zh or {}).get("name_loc") or key),
"cost": meta["cost"],
"desc_loc": desc_zh,
"desc_en": desc_en,
"notes_loc": notes,
"dispellable": (zh or {}).get("dispellable"),
"immunity": (zh or {}).get("immunity"),
"ability_kinds": parse_ability_kinds(
desc_zh_raw, meta.get("od_abilities") or []
),
"tags": tags,
}
print(
f" [{n}/{len(candidates)}] {key}: tags={tags or '-'}",
flush=True,
)
# Incremental save
args.out.parent.mkdir(parents=True, exist_ok=True)
payload = {
"meta": {
"source": "valve+opendota",
"attribution": "https://www.dota2.com ; https://www.opendota.com",
"fetched_at": datetime.now(timezone.utc).isoformat(),
"tag_order": TAG_ORDER,
},
"items": items_out,
}
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
payload = {
"meta": {
"source": "valve+opendota",
"attribution": "https://www.dota2.com ; https://www.opendota.com",
"fetched_at": datetime.now(timezone.utc).isoformat(),
"tag_order": TAG_ORDER,
"count": len(items_out),
},
"items": items_out,
}
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
if not args.skip_icons:
keys = {row["key"] for row in items_out.values()}
print(f"downloading {len(keys)} icons -> {ITEM_ICONS}", flush=True)
saved, skipped, fail = download_icons(keys, ICON_URL, ITEM_ICONS, force=args.force_icons)
print(f" icons saved={saved} skipped={skipped} fail={fail}", flush=True)
tagged = sum(1 for r in items_out.values() if r.get("tags"))
print(f"done: {len(items_out)} items, {tagged} with tags -> {args.out}", flush=True)
if __name__ == "__main__":
main()
+117
View File
@@ -0,0 +1,117 @@
"""Fetch Valve Immortal regional leaderboards into data/leaderboards.json.
Pulls the official division boards (americas / europe / se_asia / china) and
keeps Top 100 per region for the Climperor web排行page.
Valve does not expose MMR or account_id on this endpoint only rank, name,
and optional team/country. Divisions use separate MMR scales (no global board).
Preview only do not merge into relations/heroes or recommend.
Usage:
python fetch_leaderboards.py
python fetch_leaderboards.py --out data/leaderboards.json
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
from datetime import datetime, timezone
from shared.http_utils import http_json
from shared.paths import DATA
OUT = DATA / "leaderboards.json"
API = (
"https://www.dota2.com/webapi/ILeaderboard/GetDivisionLeaderboard/v0001"
"?division={division}&leaderboard=0"
)
# Display order for the web rankings tab (China first for CN audience).
REGION_ORDER = ("china", "europe", "americas", "se_asia")
REGION_LABELS = {
"china": "中国",
"europe": "欧洲",
"americas": "美洲",
"se_asia": "东南亚",
}
TOP_N = 100
def slim_entry(row: dict) -> dict:
out: dict = {
"rank": int(row.get("rank") or 0),
"name": str(row.get("name") or ""),
}
team = row.get("team_tag")
if isinstance(team, str) and team.strip():
out["team_tag"] = team.strip()
country = row.get("country")
if isinstance(country, str) and country.strip():
out["country"] = country.strip().lower()
return out
def fetch_division(division: str) -> dict:
url = API.format(division=division)
print(f"fetching {division} ...", flush=True)
raw = http_json(url)
if not isinstance(raw, dict):
raise SystemExit(f"{division}: unexpected payload type {type(raw).__name__}")
lb = raw.get("leaderboard")
if not isinstance(lb, list):
raise SystemExit(f"{division}: missing leaderboard array")
top = [slim_entry(e) for e in lb[:TOP_N] if isinstance(e, dict)]
return {
"division": division,
"label_zh": REGION_LABELS.get(division, division),
"time_posted": raw.get("time_posted"),
"count": len(lb),
"top100": top,
}
def build_payload(regions: dict[str, dict]) -> dict:
return {
"fetched_at": datetime.now(timezone.utc).isoformat(),
"source": "valve",
"attribution": "https://www.dota2.com/leaderboards",
"note": (
"Official Immortal division boards; no MMR/account_id in payload; "
"region MMR scales are not comparable across divisions"
),
"default_region": "china",
"region_order": list(REGION_ORDER),
"regions": regions,
}
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--out", type=Path, default=OUT)
args = ap.parse_args()
regions: dict[str, dict] = {}
for div in REGION_ORDER:
regions[div] = fetch_division(div)
payload = build_payload(regions)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
parts = [
f"{div}={len((regions[div].get('top100') or []))}" for div in REGION_ORDER
]
print(f"done: {', '.join(parts)} -> {args.out}", flush=True)
if __name__ == "__main__":
main()
+497
View File
@@ -0,0 +1,497 @@
"""Fetch Dota 2 patch list + per-patch details (past year) into data/patches.json.
Sources:
- patchnoteslist (schinese): version / name / timestamp / website
- patchnotes?version={v}&language=schinese: general_notes / items / neutral_items / heroes
- itemlist (schinese): item + neutral ability_id -> {name_loc, icon key}
- odota ability_ids.json: ability_id -> ability key (e.g. 5004 -> antimage_blink)
- data/hero_abilities.json: ability key -> Chinese name_loc (fallback: odota abilities.json dname)
- herolist (schinese): hero_id -> {name_loc, portrait key}
Only patches within the past year (default 365 days from today) are kept,
descending. Per-patch detail keeps the raw ability_id / hero_id values; a
shared `lookup` resolves only the referenced ids to {key, name_loc} so the
read-only Climperor web site can render names + icons with no runtime network calls.
Referenced item / ability icons are downloaded into assets/item_icons and
assets/ability_icons so the static export stays self-contained.
Usage:
python fetch_patches.py
python fetch_patches.py --days 365 --delay 0.3
python fetch_patches.py --since 2025-07-27
python fetch_patches.py --no-icons
python fetch_patches.py --force # refetch every patch detail
python fetch_patches.py --check # list vs cache; JSON on stdout
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
import time
import urllib.error
from datetime import datetime, timedelta, timezone
from shared.http_utils import http_bytes, http_json
from shared.paths import ABILITY_ICONS, DATA, HERO_PORTRAITS, ITEM_ICONS
PATCHES_LIST_URL = "https://www.dota2.com/datafeed/patchnoteslist?language=schinese"
PATCH_NOTES_URL = "https://www.dota2.com/datafeed/patchnotes?version={version}&language=schinese"
ITEMLIST_URL = "https://www.dota2.com/datafeed/itemlist?language=schinese"
HEROLIST_URL = "https://www.dota2.com/datafeed/herolist?language=schinese"
ABILITY_IDS_URL = "https://raw.githubusercontent.com/odota/dotaconstants/master/build/ability_ids.json"
ABILITIES_URL = "https://raw.githubusercontent.com/odota/dotaconstants/master/build/abilities.json"
ITEM_ICON_URL = "https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/items/{key}.png"
ABILITY_ICON_URL = "https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/abilities/{key}.png"
# Hero-card CDN (same as fetch_hero_portraits). Non-hero units may only exist at half res.
HERO_CARD_URL = (
"https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/heroes/{key}.png"
)
# Prefer ability art when Valve's hero-card for a unit is tiny (spirit_bear is 128x72).
UNIT_PORTRAIT_FALLBACK_ABILITY = {"spirit_bear": "lone_druid_spirit_bear"}
PORTRAIT_TARGET_SIZE = (256, 144)
OUT = DATA / "patches.json"
HERO_ABILITIES_PATH = DATA / "hero_abilities.json"
DEFAULT_WINDOW_DAYS = 365
# Bundled shared badges — never fetched from CDN (many innate keys 404).
SKIP_ABILITY_ICON_KEYS = frozenset({"innate", "talent_tree"})
# Non-hero units that Valve places in patchnotes heroes[] under a pseudo hero_id
# absent from herolist. Resolve to a Chinese name + portrait key so the web site
# never shows #1961. Portrait files live in assets/hero_portraits/<key>.png.
UNIT_NAMES = {1961: {"key": "spirit_bear", "name_loc": "熊灵"}}
def _date(ts: int) -> str:
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d") if ts else ""
def fetch_patch_list(since_ts: int) -> list[dict]:
"""Patch list filtered to timestamp >= since_ts, newest first."""
raw = http_json(PATCHES_LIST_URL)
patches = raw.get("patches") or []
out: list[dict] = []
for p in patches:
if not isinstance(p, dict) or not p.get("patch_number"):
continue
ts = int(p.get("patch_timestamp") or 0)
if ts and ts < since_ts:
continue
row = {
"version": p["patch_number"],
"name": p.get("patch_name") or p["patch_number"],
"timestamp": ts,
"date": _date(ts),
}
if p.get("patch_website"):
row["website"] = p["patch_website"]
out.append(row)
out.sort(key=lambda r: r["timestamp"], reverse=True)
return out
def fetch_patch_detail(version: str) -> dict | None:
"""Raw patch notes content for one version (general/items/neutral/heroes)."""
try:
raw = http_json(PATCH_NOTES_URL.format(version=version))
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as e:
print(f" detail {version}: {e}", flush=True)
return None
if not raw or not raw.get("success", True):
print(f" detail {version}: datafeed reported failure", flush=True)
return None
ts = int(raw.get("patch_timestamp") or 0)
return {
"version": raw.get("patch_number") or version,
"name": raw.get("patch_name") or version,
"timestamp": ts,
"general_notes": list(raw.get("general_notes") or []),
"items": list(raw.get("items") or []),
"neutral_items": list(raw.get("neutral_items") or []),
"heroes": list(raw.get("heroes") or []),
}
def load_item_index() -> dict[int, dict]:
"""itemlist id -> {key, name_loc}; key = internal name minus 'item_' prefix."""
raw = http_json(ITEMLIST_URL)
rows = (((raw or {}).get("result") or {}).get("data") or {}).get("itemabilities") or []
out: dict[int, dict] = {}
for row in rows:
if not isinstance(row, dict) or row.get("id") is None:
continue
name = str(row.get("name") or "")
key = name.removeprefix("item_")
out[int(row["id"])] = {
"key": key,
"name_loc": (row.get("name_loc") or "").strip() or key,
}
return out
def load_ability_index() -> dict[int, str]:
"""ability_id -> ability key (e.g. 5004 -> antimage_blink)."""
raw = http_json(ABILITY_IDS_URL)
out: dict[int, str] = {}
for sid, key in raw.items():
if isinstance(key, str) and key:
try:
out[int(sid)] = key
except (TypeError, ValueError):
continue
return out
def load_ability_names() -> dict[str, str]:
"""ability key -> Chinese name_loc (hero_abilities.json first, odota dname fallback)."""
names: dict[str, str] = {}
if HERO_ABILITIES_PATH.is_file():
try:
raw = json.loads(HERO_ABILITIES_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
raw = {}
for cell in (raw.get("by_hero") or {}).values():
if not isinstance(cell, dict):
continue
for ab in cell.get("abilities") or []:
if isinstance(ab, dict) and ab.get("key") and ab.get("name_loc"):
names.setdefault(str(ab["key"]), str(ab["name_loc"]))
for tal in cell.get("talents") or []:
if isinstance(tal, dict) and tal.get("key") and tal.get("name_loc"):
names.setdefault(str(tal["key"]), str(tal["name_loc"]))
# Fallback: odota abilities.json English dname for keys missing a Chinese name.
try:
ab = http_json(ABILITIES_URL)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError):
ab = {}
for key, row in ab.items():
if isinstance(row, dict) and row.get("dname") and key not in names:
names[key] = str(row["dname"])
return names
def load_hero_index() -> dict[int, dict]:
"""herolist id -> {key, name_loc}; key = name minus 'npc_dota_hero_' prefix."""
raw = http_json(HEROLIST_URL)
heroes = (((raw or {}).get("result") or {}).get("data") or {}).get("heroes") or []
out: dict[int, dict] = {}
for h in heroes:
if not isinstance(h, dict) or h.get("id") is None:
continue
name = str(h.get("name") or "")
out[int(h["id"])] = {
"key": name.removeprefix("npc_dota_hero_"),
"name_loc": (h.get("name_loc") or "").strip() or name,
}
return out
def collect_referenced_ids(details: dict[str, dict]) -> tuple[set[int], set[int], set[int]]:
"""(item_ids, ability_ids, hero_ids) actually referenced across all details."""
item_ids: set[int] = set()
ability_ids: set[int] = set()
hero_ids: set[int] = set()
for det in details.values():
for entry in det.get("items") or []:
if isinstance(entry, dict):
aid = entry.get("ability_id")
if isinstance(aid, int) and aid > 0:
item_ids.add(aid)
for entry in det.get("neutral_items") or []:
if isinstance(entry, dict):
aid = entry.get("ability_id")
if isinstance(aid, int) and aid > 0:
item_ids.add(aid)
for hero in det.get("heroes") or []:
if not isinstance(hero, dict):
continue
hid = hero.get("hero_id")
if isinstance(hid, int):
hero_ids.add(hid)
for ab in hero.get("abilities") or []:
if isinstance(ab, dict):
aid = ab.get("ability_id")
if isinstance(aid, int) and aid > 0:
ability_ids.add(aid)
return item_ids, ability_ids, hero_ids
def build_lookup(
details: dict[str, dict],
item_index: dict[int, dict],
ability_index: dict[int, str],
ability_names: dict[str, str],
hero_index: dict[int, dict],
) -> dict:
"""Resolve only referenced ids to {key, name_loc}."""
item_ids, ability_ids, hero_ids = collect_referenced_ids(details)
items: dict[str, dict] = {}
for iid in sorted(item_ids):
meta = item_index.get(iid)
if meta:
items[str(iid)] = {"key": meta["key"], "name_loc": meta["name_loc"]}
abilities: dict[str, dict] = {}
for aid in sorted(ability_ids):
key = ability_index.get(aid)
if not key:
continue
abilities[str(aid)] = {"key": key, "name_loc": ability_names.get(key, key)}
heroes: dict[str, dict] = {}
for hid in sorted(hero_ids):
meta = hero_index.get(hid)
if meta:
heroes[str(hid)] = {"key": meta["key"], "name_loc": meta["name_loc"]}
elif hid in UNIT_NAMES:
# Non-hero unit (e.g. 1961 = 熊灵): bundled portrait in hero_portraits.
u = UNIT_NAMES[hid]
heroes[str(hid)] = {"key": u["key"], "name_loc": u["name_loc"]}
return {"items": items, "abilities": abilities, "heroes": heroes}
def _png_size(data: bytes) -> tuple[int, int] | None:
if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n":
return None
import struct
return struct.unpack(">II", data[16:24])
def _cover_resize_png(data: bytes, size: tuple[int, int] = PORTRAIT_TARGET_SIZE) -> bytes:
"""Center-crop / scale image bytes to a 16:9 hero-card PNG."""
import cv2
import numpy as np
arr = np.frombuffer(data, dtype=np.uint8)
img = cv2.imdecode(arr, cv2.IMREAD_UNCHANGED)
if img is None:
raise ValueError("cv2 could not decode image")
if img.ndim == 2:
img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
elif img.shape[2] == 4:
img = cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
h, w = img.shape[:2]
tw, th = size
scale = max(tw / w, th / h)
nw, nh = max(tw, int(round(w * scale))), max(th, int(round(h * scale)))
resized = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_CUBIC)
x0 = max(0, (nw - tw) // 2)
y0 = max(0, (nh - th) // 2)
crop = resized[y0 : y0 + th, x0 : x0 + tw]
ok, buf = cv2.imencode(".png", crop)
if not ok:
raise ValueError("cv2 could not encode png")
return buf.tobytes()
def download_unit_portraits(*, delay: float) -> None:
"""Fetch portraits for UNIT_NAMES into hero_portraits (not in heroes.json).
Valve ships some unit cards at half resolution (spirit_bear 128x72). When a
fallback ability icon is configured, use that art cover-cropped to 256x144
so the patch page matches normal hero cards.
"""
HERO_PORTRAITS.mkdir(parents=True, exist_ok=True)
ok = skip = fail = 0
for unit in UNIT_NAMES.values():
key = unit["key"]
out = HERO_PORTRAITS / f"{key}.png"
if out.is_file():
dims = _png_size(out.read_bytes())
if dims == PORTRAIT_TARGET_SIZE:
skip += 1
continue
try:
fb = UNIT_PORTRAIT_FALLBACK_ABILITY.get(key)
if fb:
raw = http_bytes(ABILITY_ICON_URL.format(key=fb), timeout=30)
data = _cover_resize_png(raw)
src = f"ability:{fb}"
else:
raw = http_bytes(HERO_CARD_URL.format(key=key), timeout=30)
dims = _png_size(raw)
data = (
_cover_resize_png(raw)
if dims and dims != PORTRAIT_TARGET_SIZE
else raw
)
src = "hero-card"
out.write_bytes(data)
ok += 1
print(f" unit portrait {key} <- {src} ({len(data)} bytes)", flush=True)
time.sleep(max(delay, 0.05))
except Exception as e: # noqa: BLE001
fail += 1
print(f" FAIL unit portrait {key}: {e}", flush=True)
print(
f" unit portraits saved={ok} skipped={skip} fail={fail} -> {HERO_PORTRAITS}",
flush=True,
)
def download_referenced_icons(lookup: dict, *, delay: float) -> None:
"""Pull referenced item + ability icons into assets/ (skip existing)."""
from http_utils import download_icons
item_keys = {v["key"] for v in (lookup.get("items") or {}).values() if v.get("key")}
# Recipe scrolls share one generic icon on Steam CDN.
recipe_keys = {k for k in item_keys if k.startswith("recipe_")}
item_keys -= recipe_keys
item_keys.add("recipe")
print(f"downloading {len(item_keys)} item icons -> {ITEM_ICONS}", flush=True)
saved, skipped, fail = download_icons(item_keys, ITEM_ICON_URL, ITEM_ICONS, delay=delay)
print(f" item icons saved={saved} skipped={skipped} fail={fail}", flush=True)
ability_keys = {
v["key"]
for v in (lookup.get("abilities") or {}).values()
if v.get("key") and v["key"] not in SKIP_ABILITY_ICON_KEYS
}
print(f"downloading {len(ability_keys)} ability icons -> {ABILITY_ICONS}", flush=True)
saved, skipped, fail = download_icons(
ability_keys, ABILITY_ICON_URL, ABILITY_ICONS, delay=delay, skip_keys=SKIP_ABILITY_ICON_KEYS
)
print(f" ability icons saved={saved} skipped={skipped} fail={fail}", flush=True)
# Pseudo-heroes in patchnotes (e.g. 熊灵) are absent from herolist / heroes.json.
print("downloading unit portraits for patch lookup ...", flush=True)
download_unit_portraits(delay=delay)
def load_existing_details() -> dict[str, dict]:
if not OUT.is_file():
return {}
try:
raw = json.loads(OUT.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
out: dict[str, dict] = {}
for v, det in (raw.get("details") or {}).items():
if isinstance(det, dict):
out[str(v)] = det
return out
def check_for_new_patches(*, days: int, since: str | None) -> dict:
"""Compare Valve patch list to local details cache; do not write files.
Returns a JSON-serializable dict with has_new / new_versions / latest /
"""
if since:
since_dt = datetime.strptime(since, "%Y-%m-%d").replace(tzinfo=timezone.utc)
else:
since_dt = datetime.now(tz=timezone.utc) - timedelta(days=days)
since_ts = int(since_dt.timestamp())
patches = fetch_patch_list(since_ts)
cached = load_existing_details()
new_versions = [p["version"] for p in patches if p["version"] not in cached]
latest = patches[0]["version"] if patches else None
return {
"has_new": bool(new_versions),
"new_versions": new_versions,
"latest": latest,
"window_count": len(patches),
"cached_details": len(cached),
"since": since_dt.date().isoformat(),
}
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=DEFAULT_WINDOW_DAYS, help="window in days from today")
ap.add_argument("--since", type=str, default=None, help="override cutoff (YYYY-MM-DD, UTC)")
ap.add_argument("--delay", type=float, default=0.3, help="seconds between detail/icon requests")
ap.add_argument("--no-icons", action="store_true", help="skip icon downloads")
ap.add_argument("--force", action="store_true", help="refetch every patch detail")
ap.add_argument(
"--check",
action="store_true",
help="only compare patch list to local details; print JSON to stdout",
)
args = ap.parse_args()
if args.check:
result = check_for_new_patches(days=args.days, since=args.since)
print(json.dumps(result, ensure_ascii=False), flush=True)
return
if args.since:
since_dt = datetime.strptime(args.since, "%Y-%m-%d").replace(tzinfo=timezone.utc)
else:
since_dt = datetime.now(tz=timezone.utc) - timedelta(days=args.days)
since_ts = int(since_dt.timestamp())
print(f"fetching patch list (since {since_dt.date()})...", flush=True)
patches = fetch_patch_list(since_ts)
print(f" {len(patches)} patches in window", flush=True)
if not patches:
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(
json.dumps({"meta": {}, "patches": [], "lookup": {}, "details": {}}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print("no patches in window; wrote empty file", flush=True)
return
cached = {} if args.force else load_existing_details()
details: dict[str, dict] = {}
print("fetching per-patch details...", flush=True)
for n, p in enumerate(patches, start=1):
version = p["version"]
det = cached.get(version)
if det is None:
det = fetch_patch_detail(version)
time.sleep(max(args.delay, 0.05))
if det is None:
print(f" [{n}/{len(patches)}] {version}: skipped (no detail)", flush=True)
continue
details[version] = det
print(f" [{n}/{len(patches)}] {version}: {len(det.get('heroes') or [])} heroes", flush=True)
print("building lookup (itemlist + ability_ids + hero_abilities + herolist)...", flush=True)
item_index = load_item_index()
ability_index = load_ability_index()
ability_names = load_ability_names()
hero_index = load_hero_index()
lookup = build_lookup(details, item_index, ability_index, ability_names, hero_index)
print(
f" lookup: items={len(lookup['items'])} abilities={len(lookup['abilities'])} heroes={len(lookup['heroes'])}",
flush=True,
)
if not args.no_icons:
download_referenced_icons(lookup, delay=args.delay)
payload = {
"meta": {
"source": PATCHES_LIST_URL,
"fetched_at": datetime.now(tz=timezone.utc).isoformat(),
"window_days": args.days,
"since": since_dt.date().isoformat(),
"count": len(patches),
"details_count": len(details),
"lookup_counts": {
"items": len(lookup["items"]),
"abilities": len(lookup["abilities"]),
"heroes": len(lookup["heroes"]),
},
},
"patches": patches,
"lookup": lookup,
"details": details,
}
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
print(
f"saved {len(patches)} patches, {len(details)} details -> {OUT}",
flush=True,
)
if __name__ == "__main__":
main()
+549
View File
@@ -0,0 +1,549 @@
"""Aggregate pro-player item + skill builds per hero (Climperor web only).
Uses OpenDota registered pros (/proPlayers) and league match rows from
data/hero_matches.json (run fetch_hero_matches.py --source league first).
Optionally backfill more league samples with --fetch.
Output: data/pro_builds.json not used by recommend.
Usage:
python fetch_hero_matches.py --source league --limit 12
python fetch_pro_builds.py
python fetch_pro_builds.py --fetch --heroes juggernaut --limit 20
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
import time
import urllib.error
from collections import Counter, defaultdict
from datetime import datetime, timezone
from shared.grid import hero_table
from shared.http_utils import http_json
from shared.paths import DATA
from fetch_hero_items import (
BLESSING_TO_SCEPTER,
TOP_N,
is_core_finished,
load_item_catalog,
)
from fetch_hero_matches import (
extract_player_row,
fetch_match,
league_match_ids,
load_ability_id_map,
)
OPENDOTA = "https://api.opendota.com/api"
OUT = DATA / "pro_builds.json"
MATCHES_IN = DATA / "hero_matches.json"
PRO_MATCHES_IN = DATA / "pro_matches.json"
TOP_SKILL_ORDERS = 3
def fetch_pro_index() -> dict[int, dict]:
"""account_id → slim pro profile from OpenDota."""
try:
raw = http_json(f"{OPENDOTA}/proPlayers")
except (
urllib.error.HTTPError,
urllib.error.URLError,
TimeoutError,
json.JSONDecodeError,
OSError,
) as e:
raise SystemExit(f"proPlayers fetch failed: {e}") from e
if not isinstance(raw, list):
raise SystemExit("proPlayers: unexpected payload")
out: dict[int, dict] = {}
for row in raw:
if not isinstance(row, dict):
continue
try:
aid = int(row.get("account_id") or 0)
except (TypeError, ValueError):
continue
if aid <= 0:
continue
name = row.get("name")
if isinstance(name, str):
name = name.strip() or None
else:
name = None
team_tag = row.get("team_tag")
if isinstance(team_tag, str):
team_tag = team_tag.strip() or None
else:
team_tag = None
team_name = row.get("team_name")
if isinstance(team_name, str):
team_name = team_name.strip() or None
else:
team_name = None
country = row.get("country_code") or row.get("loccountrycode")
if isinstance(country, str):
country = country.strip().lower() or None
else:
country = None
last_match = row.get("last_match_time")
if isinstance(last_match, str):
last_match = last_match.strip() or None
else:
last_match = None
out[aid] = {
"account_id": aid,
"name": name,
"team_tag": team_tag,
"team_name": team_name,
"country_code": country,
"last_match_time": last_match,
}
return out
def normalize_item_id(iid: int) -> int:
return BLESSING_TO_SCEPTER.get(iid, iid)
def match_item_ids(row: dict) -> list[int]:
ids: list[int] = []
for key in ("items", "backpack"):
part = row.get(key)
if not isinstance(part, list):
continue
for raw in part:
try:
iid = int(raw or 0)
except (TypeError, ValueError):
continue
if iid > 0:
ids.append(normalize_item_id(iid))
neut = row.get("item_neutral")
try:
ni = int(neut or 0)
except (TypeError, ValueError):
ni = 0
if ni > 0:
ids.append(ni)
return ids
def is_pro_match(row: dict, pro_ids: set[int]) -> bool:
origin = str(row.get("origin") or "")
if origin not in ("league", "pro"):
return False
aid = row.get("account_id")
try:
aid_i = int(aid) if aid is not None else 0
except (TypeError, ValueError):
aid_i = 0
if aid_i in pro_ids:
return True
# Registered pro name on league row (OpenDota match detail).
name = row.get("name")
return isinstance(name, str) and bool(name.strip())
def aggregate_items(
rows: list[dict], catalog: dict[int, dict], n: int = TOP_N
) -> list[dict]:
counts: Counter[int] = Counter()
for row in rows:
seen: set[int] = set()
for iid in match_item_ids(row):
if iid in seen:
continue
meta = catalog.get(iid)
if meta is None or not is_core_finished(meta):
continue
seen.add(iid)
counts[iid] += 1
total = len(rows)
ranked = sorted(counts.items(), key=lambda t: (-t[1], t[0]))
out: list[dict] = []
for iid, c in ranked[:n]:
cell: dict = {"id": iid, "count": c}
if total > 0:
cell["pct"] = round(c / total * 1000) / 10
out.append(cell)
return out
def aggregate_skill_orders(
rows: list[dict], *, top: int = TOP_SKILL_ORDERS
) -> list[dict]:
counts: Counter[tuple[str, ...]] = Counter()
for row in rows:
ups = row.get("ability_upgrades")
if not isinstance(ups, list) or not ups:
continue
seq = tuple(str(k) for k in ups if isinstance(k, str) and k.strip())
if not seq:
continue
counts[seq] += 1
total = sum(counts.values())
ranked = sorted(counts.items(), key=lambda t: (-t[1], t[0]))
out: list[dict] = []
for seq, c in ranked[:top]:
cell: dict = {"sequence": list(seq), "count": c}
if total > 0:
cell["pct"] = round(c / total * 1000) / 10
out.append(cell)
return out
def aggregate_pros(
rows: list[dict], pro_index: dict[int, dict]
) -> list[dict]:
by_aid: dict[int, dict] = {}
for row in rows:
aid = row.get("account_id")
try:
aid_i = int(aid) if aid is not None else 0
except (TypeError, ValueError):
aid_i = 0
prof = pro_index.get(aid_i) if aid_i else None
name = (prof or {}).get("name") or row.get("name") or row.get("display_name")
if isinstance(name, str):
name = name.strip() or None
else:
name = None
if not name and not aid_i:
continue
key = aid_i if aid_i else hash(name or "")
cell = by_aid.setdefault(
key,
{
"account_id": aid_i or None,
"name": name,
"team_tag": (prof or {}).get("team_tag"),
"games": 0,
"wins": 0,
"last_match_id": None,
"last_start_time": None,
},
)
cell["games"] += 1
if row.get("won"):
cell["wins"] += 1
mid = row.get("match_id")
st = row.get("start_time")
try:
st_i = int(st) if st is not None else 0
except (TypeError, ValueError):
st_i = 0
prev = cell.get("last_start_time") or 0
if st_i >= prev:
cell["last_start_time"] = st_i or None
cell["last_match_id"] = mid
ranked = sorted(
by_aid.values(),
key=lambda r: (
-(r.get("games") or 0),
-(r.get("last_start_time") or 0),
str(r.get("name") or ""),
),
)
for r in ranked:
r.pop("last_start_time", None)
return ranked[:12]
def load_pro_league_rows(path: Path) -> dict[str, list[dict]]:
"""League/pro rows from pro_matches.json by_hero or by_pro."""
if not path.is_file():
return {}
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
out: dict[str, list[dict]] = {}
by_hero = raw.get("by_hero")
if isinstance(by_hero, dict):
for key, cell in by_hero.items():
if not isinstance(cell, dict):
continue
matches = cell.get("matches")
if isinstance(matches, list) and matches:
out[str(key)] = [m for m in matches if isinstance(m, dict)]
by_pro = raw.get("by_pro")
if isinstance(by_pro, dict):
for cell in by_pro.values():
if not isinstance(cell, dict):
continue
for row in cell.get("matches") or []:
if not isinstance(row, dict):
continue
key = row.get("hero_key")
if not key:
continue
out.setdefault(str(key), []).append(row)
deduped: dict[str, list[dict]] = {}
for key, rows in out.items():
seen: set[int] = set()
merged: list[dict] = []
for row in rows:
try:
mid = int(row.get("match_id") or 0)
except (TypeError, ValueError):
continue
if mid in seen:
continue
seen.add(mid)
merged.append(row)
if merged:
deduped[key] = merged
return deduped
def load_league_rows(path: Path) -> dict[str, list[dict]]:
if not path.is_file():
return {}
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
by_hero = raw.get("by_hero")
if not isinstance(by_hero, dict):
return {}
out: dict[str, list[dict]] = {}
for key, cell in by_hero.items():
if not isinstance(cell, dict):
continue
matches = cell.get("matches")
if not isinstance(matches, list):
continue
league = [m for m in matches if isinstance(m, dict) and m.get("origin") == "league"]
if league:
out[str(key)] = league
return out
def fetch_league_rows_for_hero(
hero_id: int,
*,
limit: int,
id_map: dict[int, str],
catalog: dict[int, dict],
delay: float,
) -> list[dict]:
metas = league_match_ids(hero_id, limit)
rows: list[dict] = []
for meta in metas:
try:
mid = int(meta.get("match_id") or 0)
except (TypeError, ValueError):
continue
if mid <= 0:
continue
detail = fetch_match(mid)
if delay > 0:
time.sleep(delay)
if not detail:
continue
slim = extract_player_row(
detail,
hero_id,
origin="league",
id_map=id_map,
item_catalog=catalog,
list_meta=meta,
)
if slim:
rows.append(slim)
return rows
def build_payload(
*,
by_hero_rows: dict[str, list[dict]],
pro_index: dict[int, dict],
catalog: dict[int, dict],
match_source: str,
) -> dict:
pro_ids = set(pro_index)
by_hero: dict[str, dict] = {}
used_item_ids: set[int] = set()
total_matches = 0
for key, all_rows in sorted(by_hero_rows.items()):
pro_rows = [r for r in all_rows if is_pro_match(r, pro_ids)]
if not pro_rows:
continue
total_matches += len(pro_rows)
items = aggregate_items(pro_rows, catalog)
for ent in items:
used_item_ids.add(int(ent["id"]))
by_hero[key] = {
"sample_count": len(pro_rows),
"items": items,
"skill_orders": aggregate_skill_orders(pro_rows),
"pros": aggregate_pros(pro_rows, pro_index),
}
items_out = {
str(iid): {
"key": catalog[iid]["key"],
"dname": catalog[iid]["dname"],
"name_loc": catalog[iid].get("name_loc") or catalog[iid]["dname"],
}
for iid in sorted(used_item_ids)
if iid in catalog
}
pros_out = {
str(aid): prof
for aid, prof in sorted(pro_index.items(), key=lambda t: t[0])
}
return {
"meta": {
"fetched_at": datetime.now(timezone.utc).isoformat(),
"source": "opendota",
"attribution": "https://www.opendota.com",
"match_source": match_source,
"pro_count": len(pro_index),
"hero_count": len(by_hero),
"match_count": total_matches,
"note_zh": (
"职业选手样本:OpenDota 注册选手 + 联赛对局终局出装/加点;"
"出装频率 = 该装备出现在样本终局栏位的比例;"
"加点为完整升级顺序的最常见方案。"
),
},
"pros": pros_out,
"items": items_out,
"by_hero": by_hero,
}
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--out", type=Path, default=OUT)
ap.add_argument(
"--matches",
type=Path,
default=MATCHES_IN,
help="Input hero_matches.json league rows (default: data/hero_matches.json)",
)
ap.add_argument(
"--pro-matches",
type=Path,
default=PRO_MATCHES_IN,
help="Merge pro player rows from this file (default: data/pro_matches.json)",
)
ap.add_argument(
"--fetch",
action="store_true",
help="Fetch league matches from OpenDota instead of only reading --matches",
)
ap.add_argument(
"--limit",
type=int,
default=12,
help="League matches per hero when --fetch (default: 12)",
)
ap.add_argument(
"--heroes",
default="",
help="Comma-separated hero keys (default: all when --fetch; else keys in matches file)",
)
ap.add_argument("--delay", type=float, default=0.35)
args = ap.parse_args()
print("loading pro players ...", flush=True)
pro_index = fetch_pro_index()
print(f" {len(pro_index)} registered pros", flush=True)
catalog, _ = load_item_catalog()
id_map = load_ability_id_map()
heroes = hero_table()
by_key = {h["key"]: h for h in heroes}
hero_filter = [k.strip() for k in args.heroes.split(",") if k.strip()]
by_hero_rows: dict[str, list[dict]] = {}
if args.fetch:
keys = hero_filter or sorted(by_key)
limit = max(1, int(args.limit))
for i, key in enumerate(keys, 1):
hero = by_key.get(key)
if not hero:
print(f" skip unknown hero {key}", flush=True)
continue
hid = int(hero["id"])
print(f"[{i}/{len(keys)}] fetch league {key} ...", flush=True)
rows = fetch_league_rows_for_hero(
hid,
limit=limit,
id_map=id_map,
catalog=catalog,
delay=args.delay,
)
if rows:
by_hero_rows[key] = rows
else:
by_hero_rows = load_league_rows(args.matches)
pro_path = args.pro_matches
if pro_path and pro_path.is_file():
pro_rows = load_pro_league_rows(pro_path)
for key, rows in pro_rows.items():
bucket = by_hero_rows.setdefault(key, [])
seen = {int(r.get("match_id") or 0) for r in bucket if isinstance(r, dict)}
for row in rows:
try:
mid = int(row.get("match_id") or 0)
except (TypeError, ValueError):
continue
if mid in seen:
continue
seen.add(mid)
bucket.append(row)
if hero_filter:
by_hero_rows = {k: v for k, v in by_hero_rows.items() if k in hero_filter}
if not by_hero_rows:
raise SystemExit(
f"No league/pro rows in {args.matches} or {args.pro_matches}. "
"Run: python fetch_pro_matches.py or fetch_hero_matches.py --source league"
)
sources = []
if args.fetch:
sources.append("fetch")
else:
sources.append(str(args.matches.name))
if args.pro_matches and args.pro_matches.is_file():
sources.append(str(args.pro_matches.name))
match_source = "+".join(sources)
payload = build_payload(
by_hero_rows=by_hero_rows,
pro_index=pro_index,
catalog=catalog,
match_source=match_source,
)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
)
meta = payload["meta"]
print(
f"done heroes={meta['hero_count']} matches={meta['match_count']}{args.out}",
flush=True,
)
if __name__ == "__main__":
main()
+439
View File
@@ -0,0 +1,439 @@
"""Fetch recent league/tournament matches for OpenDota registered pros.
Pulls /proPlayers, then per player /players/{id}/matches (lobby practice +
tournament), enriches with /matches/{id} for final items + skill builds.
Output: data/pro_matches.json (Climperor web only; not used by recommend).
Usage:
python fetch_pro_matches.py --limit-pros 20 --limit 8
python fetch_pro_matches.py --players 1296625,117421467
python fetch_pro_matches.py --with-team --active-days 45
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
import time
import urllib.error
from datetime import datetime, timedelta, timezone
from shared.grid import hero_table
from shared.http_utils import http_json
from shared.paths import DATA
from fetch_hero_matches import (
collect_item_ids,
extract_player_row,
fetch_match,
load_ability_id_map,
)
from fetch_hero_items import load_item_catalog
from fetch_pro_builds import fetch_pro_index
OPENDOTA = "https://api.opendota.com/api"
OUT = DATA / "pro_matches.json"
DEFAULT_LIMIT = 8
DEFAULT_LIMIT_PROS = 40
# OpenDota lobby_type: 1=practice, 2=tournament (pro/league biased).
LOBBY_LEAGUE = (1, 2)
def parse_pro_filter(raw: str, pro_index: dict[int, dict]) -> list[int]:
"""Comma-separated account ids or registered pro names."""
if not raw.strip():
return []
name_to_id: dict[str, int] = {}
for aid, prof in pro_index.items():
for key in ("name",):
val = prof.get(key)
if isinstance(val, str) and val.strip():
name_to_id[val.strip().lower()] = aid
out: list[int] = []
for part in raw.split(","):
token = part.strip()
if not token:
continue
if token.isdigit():
out.append(int(token))
continue
aid = name_to_id.get(token.lower())
if aid:
out.append(aid)
else:
print(f" warn: unknown pro {token!r}", flush=True)
return out
def filter_pros(
pro_index: dict[int, dict],
*,
with_team: bool,
active_days: int | None,
limit_pros: int,
player_ids: list[int],
) -> list[tuple[int, dict]]:
if player_ids:
rows: list[tuple[int, dict]] = []
for aid in player_ids:
prof = pro_index.get(aid)
if prof:
rows.append((aid, prof))
return rows
cutoff = None
if active_days is not None and active_days > 0:
cutoff = datetime.now(timezone.utc) - timedelta(days=active_days)
candidates: list[tuple[int, dict, float]] = []
for aid, prof in pro_index.items():
if with_team and not prof.get("team_tag") and not prof.get("team_name"):
continue
last = prof.get("last_match_time")
score = 0.0
if isinstance(last, str) and last.strip():
try:
ts = datetime.fromisoformat(last.replace("Z", "+00:00"))
if cutoff and ts < cutoff:
continue
score = ts.timestamp()
except ValueError:
if cutoff:
continue
candidates.append((aid, prof, score))
candidates.sort(key=lambda t: (-t[2], str(t[1].get("name") or ""), t[0]))
picked = candidates[: max(1, limit_pros)]
return [(aid, prof) for aid, prof, _ in picked]
def player_match_metas(
account_id: int,
limit: int,
*,
lobby_types: tuple[int, ...] = LOBBY_LEAGUE,
) -> list[dict]:
"""Recent match list rows for a pro (deduped, newest first)."""
per_lt = max(limit, limit // max(1, len(lobby_types)) + 2)
by_id: dict[int, dict] = {}
for lt in lobby_types:
url = f"{OPENDOTA}/players/{account_id}/matches?limit={per_lt}&lobby_type={lt}"
try:
raw = http_json(url)
except (
urllib.error.HTTPError,
urllib.error.URLError,
TimeoutError,
json.JSONDecodeError,
OSError,
):
continue
if not isinstance(raw, list):
continue
for row in raw:
if not isinstance(row, dict):
continue
try:
mid = int(row.get("match_id") or 0)
except (TypeError, ValueError):
continue
if mid <= 0:
continue
prev = by_id.get(mid)
if prev is None:
by_id[mid] = row
continue
try:
st_new = int(row.get("start_time") or 0)
st_old = int(prev.get("start_time") or 0)
except (TypeError, ValueError):
st_new = st_old = 0
if st_new >= st_old:
by_id[mid] = row
ranked = sorted(
by_id.values(),
key=lambda r: (-int(r.get("start_time") or 0), -int(r.get("match_id") or 0)),
)
return ranked[:limit]
def fetch_player_matches(
account_id: int,
*,
limit: int,
id_map: dict[int, str],
catalog: dict[int, dict],
id_to_key: dict[int, str],
delay: float,
lobby_types: tuple[int, ...],
) -> list[dict]:
metas = player_match_metas(account_id, limit, lobby_types=lobby_types)
rows: list[dict] = []
for meta in metas:
try:
mid = int(meta.get("match_id") or 0)
hid = int(meta.get("hero_id") or 0)
except (TypeError, ValueError):
continue
if mid <= 0 or hid <= 0:
continue
detail = fetch_match(mid)
if delay > 0:
time.sleep(delay)
if not detail:
continue
slim = extract_player_row(
detail,
hid,
origin="pro",
id_map=id_map,
item_catalog=catalog,
list_meta=meta,
)
if not slim:
continue
row_aid = slim.get("account_id")
try:
row_aid_i = int(row_aid) if row_aid is not None else 0
except (TypeError, ValueError):
row_aid_i = 0
if row_aid_i and row_aid_i != account_id:
continue
slim["hero_id"] = hid
slim["hero_key"] = id_to_key.get(hid)
lt = meta.get("lobby_type")
try:
slim["lobby_type"] = int(lt) if lt is not None else None
except (TypeError, ValueError):
slim["lobby_type"] = None
rows.append(slim)
return rows
def build_indexes(
by_pro: dict[str, dict],
id_to_key: dict[int, str],
) -> dict[str, dict]:
by_hero: dict[str, list[dict]] = {}
for cell in by_pro.values():
for row in cell.get("matches") or []:
if not isinstance(row, dict):
continue
key = row.get("hero_key")
if not key:
hid = row.get("hero_id")
try:
key = id_to_key.get(int(hid)) if hid is not None else None
except (TypeError, ValueError):
key = None
if not key:
continue
by_hero.setdefault(str(key), []).append(row)
out: dict[str, dict] = {}
for key, rows in by_hero.items():
seen: set[int] = set()
deduped: list[dict] = []
for row in sorted(
rows,
key=lambda r: (-int(r.get("start_time") or 0), -int(r.get("match_id") or 0)),
):
try:
mid = int(row.get("match_id") or 0)
except (TypeError, ValueError):
continue
if mid in seen:
continue
seen.add(mid)
deduped.append(row)
out[key] = {"matches": deduped}
return out
def write_out(
path: Path,
*,
by_pro: dict[str, dict],
by_hero: dict[str, dict],
pros_meta: dict[str, dict],
item_catalog: dict[int, dict],
limit: int,
limit_pros: int,
lobby_types: tuple[int, ...],
) -> None:
used = collect_item_ids(by_hero)
items_out = {
str(iid): {
"key": item_catalog[iid]["key"],
"dname": item_catalog[iid]["dname"],
"name_loc": item_catalog[iid].get("name_loc") or item_catalog[iid]["dname"],
}
for iid in sorted(used)
if iid in item_catalog
}
match_count = sum(
len(cell.get("matches") or [])
for cell in by_pro.values()
if isinstance(cell, dict)
)
payload = {
"meta": {
"source": "opendota",
"attribution": "https://www.opendota.com",
"fetched_at": datetime.now(timezone.utc).isoformat(),
"limit_per_pro": limit,
"limit_pros": limit_pros,
"lobby_types": list(lobby_types),
"pro_count": len(by_pro),
"match_count": match_count,
"hero_count": len(by_hero),
"note_zh": (
"OpenDota 注册职业选手近期联赛/锦标赛对局;"
"lobby_type 1=训练/practice、2=tournament"
"含终局出装、加点与联赛名(若有)。"
),
},
"items": items_out,
"pros": pros_meta,
"by_pro": by_pro,
"by_hero": by_hero,
}
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
def _log(msg: str) -> None:
try:
print(msg, flush=True)
except UnicodeEncodeError:
print(msg.encode("ascii", "backslashreplace").decode("ascii"), flush=True)
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--out", type=Path, default=OUT)
ap.add_argument(
"--limit",
type=int,
default=DEFAULT_LIMIT,
help=f"Matches per pro (default: {DEFAULT_LIMIT})",
)
ap.add_argument(
"--limit-pros",
type=int,
default=DEFAULT_LIMIT_PROS,
help=f"Max pros when --players omitted (default: {DEFAULT_LIMIT_PROS})",
)
ap.add_argument(
"--players",
default="",
help="Comma-separated account_id or registered pro name (overrides --limit-pros)",
)
ap.add_argument(
"--with-team",
action="store_true",
help="Only pros with a team_tag/team_name when picking from /proPlayers",
)
ap.add_argument(
"--active-days",
type=int,
default=60,
help="Skip pros with no last_match_time within N days (0=disable; default: 60)",
)
ap.add_argument("--delay", type=float, default=0.35)
ap.add_argument(
"--include-pubs",
action="store_true",
help="Also include ranked pub lobby_type=7 (high-MMR scrims)",
)
args = ap.parse_args()
limit = max(1, int(args.limit))
limit_pros = max(1, int(args.limit_pros))
active_days = int(args.active_days) if args.active_days > 0 else None
lobby_types: tuple[int, ...] = LOBBY_LEAGUE
if args.include_pubs:
lobby_types = LOBBY_LEAGUE + (7,)
_log("loading pro players ...")
pro_index = fetch_pro_index()
_log(f" {len(pro_index)} registered pros")
player_ids = parse_pro_filter(args.players, pro_index)
picked = filter_pros(
pro_index,
with_team=args.with_team,
active_days=active_days,
limit_pros=limit_pros,
player_ids=player_ids,
)
if not picked:
raise SystemExit("No pros matched filters")
heroes = hero_table()
id_to_key = {int(h["id"]): h["key"] for h in heroes}
catalog, _ = load_item_catalog()
id_map = load_ability_id_map()
by_pro: dict[str, dict] = {}
pros_meta: dict[str, dict] = {}
total_matches = 0
for i, (aid, prof) in enumerate(picked, 1):
label = prof.get("name") or prof.get("team_tag") or str(aid)
_log(f"[{i}/{len(picked)}] {label} ({aid}) ...")
matches = fetch_player_matches(
aid,
limit=limit,
id_map=id_map,
catalog=catalog,
id_to_key=id_to_key,
delay=args.delay,
lobby_types=lobby_types,
)
sid = str(aid)
by_pro[sid] = {
"account_id": aid,
"name": prof.get("name"),
"team_tag": prof.get("team_tag"),
"team_name": prof.get("team_name"),
"country_code": prof.get("country_code"),
"match_count": len(matches),
"matches": matches,
}
pros_meta[sid] = {
"account_id": aid,
"name": prof.get("name"),
"team_tag": prof.get("team_tag"),
"team_name": prof.get("team_name"),
"country_code": prof.get("country_code"),
}
total_matches += len(matches)
_log(f" {len(matches)} matches")
by_hero = build_indexes(by_pro, id_to_key)
write_out(
args.out,
by_pro=by_pro,
by_hero=by_hero,
pros_meta=pros_meta,
item_catalog=catalog,
limit=limit,
limit_pros=len(picked),
lobby_types=lobby_types,
)
_log(
f"done pros={len(by_pro)} matches={total_matches} heroes={len(by_hero)}{args.out}"
)
if __name__ == "__main__":
main()
+504
View File
@@ -0,0 +1,504 @@
"""Fetch STRATZ hero meta (weekly WR/pick by bracket + position) and matchup tops.
Requires STRATZ_API_TOKEN (or KEYZOO_ASSET_API_KEY) in the environment / .env.
Output is for the Climperor web site only never merge into relations.json
or recommend.
Data sources (heroStats GraphQL):
- winWeek(take=N, bracketIds): per-medal weekly pick/win `weeks` / `latest`
- winWeek(take=1, bracketIds, positionIds): per-medal per-position latest week
`positions` (same time window as headline cards; exact medal, not basic merge)
- matchUp: counter / countered / synergy tops stratz_matchup_tops.json
Usage:
python fetch_stratz_meta.py
python fetch_stratz_meta.py --weeks 8 --delay 0.25
python fetch_stratz_meta.py --skip-matchups
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
import os
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from shared.grid import hero_table
from shared.paths import DATA, ROOT
API = "https://api.stratz.com/graphql"
OUT_META = DATA / "stratz_hero_meta.json"
OUT_MATCHUPS = DATA / "stratz_matchup_tops.json"
# UI medal keys ↔ STRATZ RankBracket enum
BRACKET_ORDER = (
"herald",
"guardian",
"crusader",
"archon",
"legend",
"ancient",
"divine",
"immortal",
)
BRACKET_ENUM = {
"herald": "HERALD",
"guardian": "GUARDIAN",
"crusader": "CRUSADER",
"archon": "ARCHON",
"legend": "LEGEND",
"ancient": "ANCIENT",
"divine": "DIVINE",
"immortal": "IMMORTAL",
}
# Medal → RankBracketBasicEnum (legacy; position stats now use exact medal via winWeek)
BRACKET_TO_BASIC = {
"herald": "HERALD_GUARDIAN",
"guardian": "HERALD_GUARDIAN",
"crusader": "CRUSADER_ARCHON",
"archon": "CRUSADER_ARCHON",
"legend": "LEGEND_ANCIENT",
"ancient": "LEGEND_ANCIENT",
"divine": "DIVINE_IMMORTAL",
"immortal": "DIVINE_IMMORTAL",
}
POSITION_ORDER = (
"POSITION_1",
"POSITION_2",
"POSITION_3",
"POSITION_4",
"POSITION_5",
)
ATTRIBUTION = "https://stratz.com"
def load_token() -> str:
for key in (
"STRATZ_API_TOKEN",
"KEYZOO_ASSET_API_KEY",
"KEYZOO_ASSET_SECRET_API_KEY",
"KEYZOO_ASSET_TOKEN",
):
env = os.environ.get(key, "").strip()
if env:
return env
path = ROOT / ".env"
if path.is_file():
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
if k.strip() == "STRATZ_API_TOKEN":
return v.strip().strip('"').strip("'")
raise SystemExit(
"STRATZ_API_TOKEN missing. Set env / keyzoo inject or add to .env "
"(see .env.example)."
)
def gql(token: str, query: str, variables: dict | None = None) -> dict:
body: dict = {"query": query}
if variables:
body["variables"] = variables
req = urllib.request.Request(
API,
data=json.dumps(body).encode(),
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"User-Agent": "climperor",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=120) as resp:
payload = json.loads(resp.read().decode())
if payload.get("errors"):
raise RuntimeError(str(payload["errors"][:2]))
return payload["data"]
def _pw(pick: int, win: int) -> dict:
pick = max(0, int(pick or 0))
win = max(0, int(win or 0))
wr = (win / pick) if pick > 0 else None
return {"pick": pick, "win": win, "wr": wr}
def fetch_weeks_for_bracket(
token: str, hero_ids: list[int], bracket: str, take: int
) -> list[dict]:
enum = BRACKET_ENUM[bracket]
# GraphQL list variable as inline enums (Short! list for heroIds).
ids_lit = ", ".join(str(i) for i in hero_ids)
query = f"""
query {{
heroStats {{
winWeek(heroIds: [{ids_lit}], take: {int(take)}, bracketIds: [{enum}]) {{
heroId
week
matchCount
winCount
}}
}}
}}
"""
data = gql(token, query)
return (((data or {}).get("heroStats") or {}).get("winWeek")) or []
def fetch_latest_positions_for_bracket(
token: str, hero_ids: list[int], bracket: str
) -> list[dict]:
"""Latest-week winWeek per position; same bracket + week as headline cards."""
enum = BRACKET_ENUM[bracket]
ids_lit = ", ".join(str(i) for i in hero_ids)
out: list[dict] = []
for pos in POSITION_ORDER:
query = f"""
query {{
heroStats {{
winWeek(
heroIds: [{ids_lit}]
take: 1
bracketIds: [{enum}]
positionIds: [{pos}]
) {{
heroId
week
matchCount
winCount
}}
}}
}}
"""
data = gql(token, query)
for row in (((data or {}).get("heroStats") or {}).get("winWeek")) or []:
out.append({**row, "position": pos, "bracket": bracket})
return out
MATCHUP_QUERY = """
query($id: Short!, $take: Int!, $limit: Int!) {
heroStats {
matchUp(heroId: $id, take: $take, matchLimit: $limit) {
heroId
matchCountVs
matchCountWith
vs {
heroId2
matchCount
winCount
synergy
winsAverage
}
with {
heroId2
matchCount
winCount
synergy
winsAverage
}
}
}
}
"""
def fetch_matchup_tops(
token: str, hero_id: int, *, take: int, match_limit: int
) -> dict:
data = gql(
token,
MATCHUP_QUERY,
{"id": hero_id, "take": take, "limit": match_limit},
)
rows = (((data or {}).get("heroStats") or {}).get("matchUp")) or []
row = rows[0] if rows else {}
vs_out = []
with_out = []
for pair in row.get("vs") or []:
other = pair.get("heroId2")
games = int(pair.get("matchCount") or 0)
if other is None or games <= 0:
continue
wins = int(pair.get("winCount") or 0)
vs_out.append(
{
"hero_id": int(other),
"games": games,
"wins": wins,
"advantage": float(pair.get("synergy") or 0.0),
"wr": float(pair.get("winsAverage") or (wins / games)),
}
)
for pair in row.get("with") or []:
other = pair.get("heroId2")
games = int(pair.get("matchCount") or 0)
if other is None or games <= 0:
continue
wins = int(pair.get("winCount") or 0)
with_out.append(
{
"hero_id": int(other),
"games": games,
"wins": wins,
"synergy": float(pair.get("synergy") or 0.0),
"wr": float(pair.get("winsAverage") or (wins / games)),
}
)
# vs advantage: positive = hero wins more vs other → counters other.
# Also derive "disadvantage" as others with most negative advantage for hero.
disadvantage = [
{
"hero_id": e["hero_id"],
"games": e["games"],
"wins": e["wins"],
"advantage": -float(e["advantage"]),
"wr": 1.0 - float(e["wr"]) if e["wr"] is not None else None,
}
for e in sorted(vs_out, key=lambda x: x["advantage"])[:take]
]
counters = sorted(vs_out, key=lambda x: -x["advantage"])[:take]
synergies = sorted(with_out, key=lambda x: -x["synergy"])[:take]
return {
"counters": counters,
"countered": disadvantage,
"synergies": synergies,
}
def build_meta(
weeks_rows_by_bracket: dict[str, list[dict]],
position_rows_by_bracket: dict[str, list[dict]],
id_to_key: dict[int, str],
weeks_take: int,
) -> dict:
by_hero: dict[str, dict] = {}
for hid, key in id_to_key.items():
by_hero[key] = {
"id": hid,
"weeks": {b: [] for b in BRACKET_ORDER},
"latest": {},
"positions": {b: {} for b in BRACKET_ORDER},
}
for bracket, rows in weeks_rows_by_bracket.items():
# Group by hero, sort weeks desc, keep take
by_id: dict[int, list[dict]] = {}
for row in rows:
hid = int(row.get("heroId") or 0)
if hid not in id_to_key:
continue
by_id.setdefault(hid, []).append(row)
for hid, rows_h in by_id.items():
key = id_to_key[hid]
rows_h.sort(key=lambda r: int(r.get("week") or 0), reverse=True)
weeks = []
for r in rows_h[:weeks_take]:
pick = int(r.get("matchCount") or 0)
win = int(r.get("winCount") or 0)
weeks.append(
{
"week": int(r.get("week") or 0),
"pick": pick,
"win": win,
"wr": (win / pick) if pick > 0 else None,
}
)
by_hero[key]["weeks"][bracket] = weeks
if weeks:
latest = weeks[0]
by_hero[key]["latest"][bracket] = _pw(latest["pick"], latest["win"])
for bracket, pos_rows in position_rows_by_bracket.items():
for row in pos_rows:
hid = int(row.get("heroId") or 0)
key = id_to_key.get(hid)
if not key:
continue
pos = str(row.get("position") or "")
if pos not in POSITION_ORDER:
continue
cell = _pw(int(row.get("matchCount") or 0), int(row.get("winCount") or 0))
by_hero[key]["positions"][bracket][pos] = cell
# Totals (latest week pick sum per bracket) for pick-rate denominator
totals: dict[str, dict] = {}
for bracket in BRACKET_ORDER:
total_pick = 0
for cell in by_hero.values():
latest = (cell.get("latest") or {}).get(bracket) or {}
total_pick += int(latest.get("pick") or 0)
totals[bracket] = {"pick": total_pick}
# Meta board: top heroes by pick (latest week) per bracket
meta_board: dict[str, list[dict]] = {}
for bracket in BRACKET_ORDER:
denom = max(1, int((totals.get(bracket) or {}).get("pick") or 0))
rows_board = []
for key, cell in by_hero.items():
latest = (cell.get("latest") or {}).get(bracket)
if not latest or int(latest.get("pick") or 0) <= 0:
continue
pick = int(latest["pick"])
win = int(latest["win"])
wr = win / pick if pick else None
# Same convention as OpenDota web: pick / (Σpick / 10)
pr = pick / (denom / 10.0) if denom else None
rows_board.append(
{
"key": key,
"id": cell["id"],
"pick": pick,
"win": win,
"wr": wr,
"pr": pr,
}
)
rows_board.sort(key=lambda r: (-r["pick"], -(r["wr"] or 0), r["key"]))
meta_board[bracket] = rows_board
return {
"fetched_at": datetime.now(timezone.utc).isoformat(),
"source": "stratz",
"attribution": ATTRIBUTION,
"weeks_take": weeks_take,
"window_label_zh": f"{weeks_take} 周天梯(按段位)",
"latest_window_label_zh": "最近 1 周",
"brackets": list(BRACKET_ORDER),
"positions": list(POSITION_ORDER),
"bracket_position_note": None,
"totals": totals,
"by_hero": by_hero,
"meta_board": meta_board,
}
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--weeks", type=int, default=8, help="weekly buckets to keep")
ap.add_argument("--delay", type=float, default=0.25)
ap.add_argument("--out-meta", type=Path, default=OUT_META)
ap.add_argument("--out-matchups", type=Path, default=OUT_MATCHUPS)
ap.add_argument("--skip-matchups", action="store_true")
ap.add_argument("--matchup-take", type=int, default=12)
ap.add_argument("--matchup-min-games", type=int, default=50)
args = ap.parse_args()
token = load_token()
heroes = hero_table()
id_to_key = {int(h["id"]): h["key"] for h in heroes}
hero_ids = sorted(id_to_key.keys())
print(
f"fetching STRATZ meta for {len(hero_ids)} heroes, "
f"{args.weeks} weeks × {len(BRACKET_ORDER)} brackets",
flush=True,
)
weeks_by_bracket: dict[str, list[dict]] = {}
position_rows_by_bracket: dict[str, list[dict]] = {}
for i, bracket in enumerate(BRACKET_ORDER, start=1):
try:
rows = fetch_weeks_for_bracket(token, hero_ids, bracket, args.weeks)
except (urllib.error.URLError, TimeoutError, RuntimeError, json.JSONDecodeError) as e:
print(f" [{i}/{len(BRACKET_ORDER)}] {bracket} failed: {e}", flush=True)
rows = []
weeks_by_bracket[bracket] = rows
try:
pos_rows = fetch_latest_positions_for_bracket(token, hero_ids, bracket)
except (urllib.error.URLError, TimeoutError, RuntimeError, json.JSONDecodeError) as e:
print(f" [{i}/{len(BRACKET_ORDER)}] {bracket} positions failed: {e}", flush=True)
pos_rows = []
position_rows_by_bracket[bracket] = pos_rows
print(
f" [{i}/{len(BRACKET_ORDER)}] {bracket}: {len(rows)} week rows, "
f"{len(pos_rows)} position rows",
flush=True,
)
time.sleep(args.delay)
meta = build_meta(
weeks_by_bracket, position_rows_by_bracket, id_to_key, args.weeks
)
args.out_meta.parent.mkdir(parents=True, exist_ok=True)
args.out_meta.write_text(
json.dumps(meta, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(f"wrote {args.out_meta}", flush=True)
if args.skip_matchups:
print("skip matchup tops", flush=True)
return
by_hero_mu: dict[str, dict] = {}
if args.out_matchups.is_file():
try:
prev = json.loads(args.out_matchups.read_text(encoding="utf-8"))
by_hero_mu = dict(prev.get("by_hero") or {})
print(f"resuming matchups with {len(by_hero_mu)} heroes", flush=True)
except (OSError, json.JSONDecodeError):
pass
pending = [hid for hid in hero_ids if id_to_key[hid] not in by_hero_mu]
print(
f"fetching matchup tops {len(pending)}/{len(hero_ids)} "
f"(take={args.matchup_take}, min_games={args.matchup_min_games})",
flush=True,
)
for n, hid in enumerate(pending, start=1):
key = id_to_key[hid]
try:
cell = fetch_matchup_tops(
token,
hid,
take=args.matchup_take,
match_limit=args.matchup_min_games,
)
except (urllib.error.URLError, TimeoutError, RuntimeError, json.JSONDecodeError) as e:
print(f" [{n}/{len(pending)}] {key} failed: {e}", flush=True)
time.sleep(args.delay * 2)
continue
by_hero_mu[key] = cell
print(
f" [{n}/{len(pending)}] {key}: "
f"vs={len(cell['counters'])} fear={len(cell['countered'])} "
f"with={len(cell['synergies'])}",
flush=True,
)
payload = {
"fetched_at": datetime.now(timezone.utc).isoformat(),
"source": "stratz",
"attribution": ATTRIBUTION,
"take": args.matchup_take,
"match_limit": args.matchup_min_games,
"note": (
"counters = positive vs advantage; countered = heroes this hero "
"loses to (negated advantage); synergies = with synergy. "
"Web-only; do not merge into relations.json."
),
"by_hero": by_hero_mu,
}
args.out_matchups.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
time.sleep(args.delay)
print(f"done: matchups {len(by_hero_mu)} heroes → {args.out_matchups}", flush=True)
if __name__ == "__main__":
main()
+257
View File
@@ -0,0 +1,257 @@
"""Probe real live status for streamers into data/streamers.json.
For every streamer row with ``live_url`` this script writes:
- ``is_live`` True only when the platform confirms the room is live
- ``live_probed_at`` UTC ISO timestamp of the successful probe
Approach (verified 2026-07):
- Bilibili: public API ``api.live.bilibili.com/room/v1/Room/get_info`` with the
numeric room id taken from the ``live_url`` path; ``data.live_status == 1``
means live (0 offline, 2 replay replay is treated as offline). No login.
- Douyin: one shared cookie session is warmed up (www.douyin.com +
live.douyin.com), then per room we GET ``live.douyin.com/{web_rid}`` with a
browser UA (the numeric ``live_url`` path segment is the ``web_rid``). The
SSR page embeds ``roomStore.roomInfo.room.status`` inside the streaming
pace chunks as escaped JSON: ``status == 2`` means live, ``status == 4``
offline; the embedded ``web_rid`` must match the requested one. (The
``webcast/room/web/enter`` API was considered but returns empty bodies
without request signing, so the SSR page is the source of truth.)
Everything is soft-fail: network errors, empty or non-JSON responses keep the
previous ``is_live`` value and never abort a refresh tier (exit code is
always 0). Only the two probe fields are touched; all other keys (including
``live_url``) are preserved. Note the ``daily`` tier means the badge trails
reality by up to a day truly real-time would need a higher-frequency job.
Preview only do not merge into relations/heroes or recommend.
Usage:
python fetch_streamer_live.py # probe all rows with live_url
python fetch_streamer_live.py --ids shawang,xiaowang
python fetch_streamer_live.py --dry-run # probe + print, do not write
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import http.cookiejar
import json
import re
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from shared import http_utils
from shared.paths import DATA
from fetch_streamers import BROWSER_UA
OUT = DATA / "streamers.json"
TIMEOUT = 20
# Douyin rate-limits aggressively; keep ~1s spacing between its requests.
DOUYIN_SPACING = 1.0
BILIBILI_SPACING = 0.5
DOUYIN_HOME = "https://www.douyin.com/"
DOUYIN_LIVE_HOME = "https://live.douyin.com/"
BILIBILI_INFO_URL = "https://api.live.bilibili.com/room/v1/Room/get_info?room_id={room_id}"
# Escaped JSON inside the SSR pace chunks: \"roomStore\":{\"roomInfo\":{\"room\":{
DOUYIN_ROOMSTORE_RE = re.compile(
r'\\"roomStore\\":\s*\{\\"roomInfo\\":\s*\{\\"room\\":\s*\{'
)
DOUYIN_STATUS_RE = re.compile(r'\\"status\\":\s*(\d)')
DOUYIN_WEBRID_RE = re.compile(r'\\"web_rid\\":\s*\\"(\d+)\\"')
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _douyin_opener() -> urllib.request.OpenerDirector:
jar = http.cookiejar.CookieJar()
return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
def _douyin_get(
opener: urllib.request.OpenerDirector, url: str, *, referer: str
) -> bytes:
req = urllib.request.Request(
url,
headers={
"User-Agent": BROWSER_UA,
"Accept": "*/*",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Referer": referer,
},
)
with opener.open(req, timeout=TIMEOUT) as resp:
return resp.read()
def warm_douyin(opener: urllib.request.OpenerDirector) -> None:
"""Seed cookies once so subsequent webcast calls are not blocked."""
for url in (DOUYIN_HOME, DOUYIN_LIVE_HOME):
try:
_douyin_get(opener, url, referer=DOUYIN_HOME)
except (urllib.error.URLError, TimeoutError, OSError) as e:
print(f" douyin warm-up {url} failed: {e}", flush=True)
time.sleep(DOUYIN_SPACING)
def probe_douyin(opener: urllib.request.OpenerDirector, rid: str) -> bool:
"""Parse roomStore status from the SSR live room page (2 live / 4 offline)."""
page_url = f"{DOUYIN_LIVE_HOME}{rid}"
raw = _douyin_get(opener, page_url, referer=DOUYIN_LIVE_HOME)
if not raw:
raise ValueError("empty room page")
html = raw.decode("utf-8", "replace")
store = DOUYIN_ROOMSTORE_RE.search(html)
if not store:
raise ValueError("no roomStore in page (blocked or layout changed)")
# The room object opens with id_str/status; a short window is enough.
window = html[store.end() : store.end() + 3000]
status_m = DOUYIN_STATUS_RE.search(window)
if not status_m:
raise ValueError("roomStore has no status field")
embedded = DOUYIN_WEBRID_RE.search(html)
if not embedded or embedded.group(1) != rid:
raise ValueError("page resolved to a different room (stale web_rid?)")
status = int(status_m.group(1))
if status == 2:
return True
if status == 4:
return False
raise ValueError(f"unexpected room status {status}")
def probe_bilibili(room_id: str) -> bool:
"""live_status: 0 offline, 1 live, 2 replay (replay counts as offline)."""
payload = http_utils.http_json(
BILIBILI_INFO_URL.format(room_id=room_id), timeout=TIMEOUT
)
if not isinstance(payload, dict) or payload.get("code") != 0:
raise ValueError(f"bilibili api error: code={payload.get('code')!r}")
data = payload.get("data")
if not isinstance(data, dict):
raise ValueError("bilibili api returned no data")
return data.get("live_status") == 1
def room_ref_from_url(live_url: str) -> str | None:
"""First path segment of the live room URL (douyin web_rid / bilibili room id)."""
path = urllib.parse.urlparse(live_url.strip()).path.strip("/")
if not path:
return None
return path.split("/")[0] or None
def probe_streamers(
payload: dict, *, ids: set[str] | None = None
) -> tuple[int, int, int]:
"""Probe rows with live_url in place. Returns (live, offline, fail)."""
rows = payload.get("streamers")
if not isinstance(rows, list):
raise SystemExit("streamers.json: missing streamers array")
targets = []
for row in rows:
if not isinstance(row, dict):
continue
sid = str(row.get("id") or "").strip()
if not sid or (ids is not None and sid not in ids):
continue
live_url = str(row.get("live_url") or "").strip()
if not live_url:
continue
platform = str(row.get("platform") or "").strip().lower()
ref = room_ref_from_url(live_url)
if not ref:
print(f"skip {sid}: cannot parse room ref from {live_url!r}", flush=True)
continue
targets.append((row, sid, platform, ref))
opener = None
if any(platform == "douyin" for _, _, platform, _ in targets):
opener = _douyin_opener()
warm_douyin(opener)
live = offline = fail = 0
for row, sid, platform, ref in targets:
try:
if platform == "douyin":
assert opener is not None
is_live = probe_douyin(opener, ref)
time.sleep(DOUYIN_SPACING)
elif platform == "bilibili":
is_live = probe_bilibili(ref)
time.sleep(BILIBILI_SPACING)
else:
print(f"skip {sid}: platform={platform!r} unsupported", flush=True)
continue
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
print(f" FAIL {sid}: {e} (keeping previous is_live)", flush=True)
fail += 1
continue
row["is_live"] = is_live
row["live_probed_at"] = _now_iso()
state = "LIVE" if is_live else "offline"
print(f" {state} {sid} ({platform} {ref})", flush=True)
if is_live:
live += 1
else:
offline += 1
return live, offline, fail
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--out", type=Path, default=OUT)
ap.add_argument(
"--ids",
default=None,
help="comma-separated streamer ids to probe (default: all with live_url)",
)
ap.add_argument(
"--dry-run",
action="store_true",
help="probe and print only; do not write streamers.json",
)
args = ap.parse_args()
raw = args.out.read_text(encoding="utf-8") if args.out.is_file() else None
if raw is None:
raise SystemExit(f"{args.out} not found")
payload = json.loads(raw)
if not isinstance(payload, dict):
raise SystemExit(f"{args.out}: expected object")
id_set = None
if args.ids:
id_set = {s.strip() for s in args.ids.split(",") if s.strip()}
live, offline, fail = probe_streamers(payload, ids=id_set)
summary = f"live={live} offline={offline} fail={fail}"
if args.dry_run:
print(f"dry-run: would write {args.out} ({summary})", flush=True)
return 0
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(f"wrote {args.out} {summary}", flush=True)
# Soft-fail for CI/refresh_web: always exit 0 after writing.
return 0
if __name__ == "__main__":
raise SystemExit(main())
+473
View File
@@ -0,0 +1,473 @@
"""Fetch Douyin profile fields into data/streamers.json.
Manual seed fields (id / platform / live_url / profile_url / heroes / tagline)
are preserved. Profile enrichment (nickname, signature, counts, avatar) is
best-effort via Douyin HTML RENDER_DATA + text fallback; failures keep the
previous values.
Preview only do not merge into relations/heroes or recommend.
Part of refresh_web ``daily`` (soft-fail: never aborts the tier).
Usage:
python fetch_streamers.py
python fetch_streamers.py --ids xiaowang
python fetch_streamers.py --out data/streamers.json
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import http.cookiejar
import json
import re
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from typing import Any
from shared.paths import DATA, STREAMER_AVATARS
OUT = DATA / "streamers.json"
AVATAR_DIR = STREAMER_AVATARS
BROWSER_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
)
RENDER_DATA_RE = re.compile(
r'<script[^>]+id=["\']RENDER_DATA["\'][^>]*>([^<]+)</script>',
re.IGNORECASE,
)
HTML_COUNTS_RE = re.compile(
r"关注\s*([\d.]+万?亿?)\s*粉丝\s*([\d.]+万?亿?)\s*获赞\s*([\d.]+万?亿?)",
re.DOTALL,
)
HTML_UNIQUE_RE = re.compile(r"抖音号[:]\s*([A-Za-z0-9_.-]+)")
HTML_TITLE_RE = re.compile(r"<title>\s*([^<]+?)\s*的抖音", re.IGNORECASE)
HTML_AVATAR_RE = re.compile(
r'<img[^>]+alt="[^"]*头像"[^>]+src="([^"]+)"|'
r'<img[^>]+src="([^"]+)"[^>]+alt="[^"]*头像"',
re.IGNORECASE,
)
SEC_UID_RE = re.compile(r"/user/(MS4wLjABAAAA[A-Za-z0-9_-]+)")
# Fields fetch may overwrite; manual seed keys are never removed.
PROFILE_KEYS = (
"nickname",
"unique_id",
"signature",
"following_count",
"follower_count",
"total_favorited",
"avatar",
"profile_fetched_at",
)
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _load(path: Path) -> dict:
if not path.is_file():
return {
"fetched_at": None,
"source": "manual+douyin",
"platform_meta": {
"douyin": {
"label_zh": "抖音",
"icon": "ui-icon/platform_douyin.png",
}
},
"streamers": [],
}
raw = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
raise SystemExit(f"{path}: expected object")
return raw
def _save(path: Path, payload: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
def _opener() -> urllib.request.OpenerDirector:
jar = http.cookiejar.CookieJar()
return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
def _get(
opener: urllib.request.OpenerDirector, url: str, *, timeout: int = 30
) -> tuple[str, str]:
"""Return (final_url, html)."""
req = urllib.request.Request(
url,
headers={
"User-Agent": BROWSER_UA,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Referer": "https://www.douyin.com/",
},
)
with opener.open(req, timeout=timeout) as resp:
final = resp.geturl()
html = resp.read().decode("utf-8", "replace")
return final, html
def parse_cn_count(raw: str) -> int | None:
s = (raw or "").strip().replace(",", "").replace("\n", "")
if not s:
return None
try:
if s.endswith("亿"):
return int(float(s[:-1]) * 100_000_000)
if s.endswith(""):
return int(float(s[:-1]) * 10_000)
return int(float(s))
except ValueError:
return None
def _parse_render_data(html: str) -> dict | None:
m = RENDER_DATA_RE.search(html)
if not m:
return None
try:
decoded = urllib.parse.unquote(m.group(1))
data = json.loads(decoded)
except (ValueError, json.JSONDecodeError):
return None
return data if isinstance(data, dict) else None
def _walk_user_nodes(obj: Any, found: list[dict]) -> None:
if isinstance(obj, dict):
if "nickname" in obj and (
"follower_count" in obj
or "followerCount" in obj
or "mplatform_followers_count" in obj
):
found.append(obj)
for v in obj.values():
_walk_user_nodes(v, found)
elif isinstance(obj, list):
for v in obj:
_walk_user_nodes(v, found)
def _first_int(*vals: Any) -> int | None:
for v in vals:
if isinstance(v, bool):
continue
if isinstance(v, int):
return v
if isinstance(v, float):
return int(v)
if isinstance(v, str):
n = parse_cn_count(v)
if n is not None:
return n
return None
def _first_str(*vals: Any) -> str | None:
for v in vals:
if isinstance(v, str) and v.strip():
return v.strip()
return None
def _avatar_url(user: dict) -> str | None:
for key in (
"avatar_larger",
"avatar_medium",
"avatar_thumb",
"avatarUrl",
"avatar_url",
):
cell = user.get(key)
if isinstance(cell, str) and cell.startswith("http"):
return cell
if isinstance(cell, dict):
urls = cell.get("url_list") or cell.get("urlList") or []
if isinstance(urls, list):
for u in urls:
if isinstance(u, str) and u.startswith("http"):
return u
return None
def extract_profile_from_render(render: dict) -> dict | None:
candidates: list[dict] = []
_walk_user_nodes(render, candidates)
if not candidates:
return None
def score(u: dict) -> int:
n = _first_int(
u.get("follower_count"),
u.get("followerCount"),
u.get("mplatform_followers_count"),
)
return n if n is not None else -1
user = max(candidates, key=score)
return {
"nickname": _first_str(user.get("nickname"), user.get("nickName")),
"unique_id": _first_str(
user.get("unique_id"),
user.get("uniqueId"),
user.get("short_id"),
user.get("shortId"),
),
"signature": _first_str(user.get("signature"), user.get("desc")),
"following_count": _first_int(
user.get("following_count"), user.get("followingCount")
),
"follower_count": _first_int(
user.get("follower_count"),
user.get("followerCount"),
user.get("mplatform_followers_count"),
),
"total_favorited": _first_int(
user.get("total_favorited"),
user.get("totalFavorited"),
user.get("favoriting_count"),
),
"avatar_url": _avatar_url(user),
}
def extract_profile_from_html(html: str) -> dict:
"""Best-effort parse when RENDER_DATA user blob is missing/blocked."""
out: dict[str, Any] = {}
m = HTML_COUNTS_RE.search(html)
if m:
out["following_count"] = parse_cn_count(m.group(1))
out["follower_count"] = parse_cn_count(m.group(2))
out["total_favorited"] = parse_cn_count(m.group(3))
uid = HTML_UNIQUE_RE.search(html)
if uid:
out["unique_id"] = uid.group(1)
title = HTML_TITLE_RE.search(html)
if title:
out["nickname"] = title.group(1).strip()
av = HTML_AVATAR_RE.search(html)
if av:
url = av.group(1) or av.group(2)
if url and url.startswith("http"):
out["avatar_url"] = url.replace("&amp;", "&")
return out
def merge_profile_dicts(primary: dict | None, fallback: dict) -> dict:
out = dict(fallback)
if primary:
for k, v in primary.items():
if v is None or v == "":
continue
out[k] = v
return out
def canonicalize_douyin_user_url(url: str) -> str:
"""Map share / short-link destinations to www.douyin.com/user/<sec_uid>."""
m = SEC_UID_RE.search(url)
if m:
return f"https://www.douyin.com/user/{m.group(1)}"
return url
def resolve_profile_url(
opener: urllib.request.OpenerDirector, profile_url: str
) -> tuple[str, str | None]:
"""Follow redirects; return (canonical_url, html_or_None if not yet fetched)."""
url = profile_url.strip()
host = urllib.parse.urlparse(url).netloc.lower()
if "v.douyin.com" in host or "iesdouyin.com" in host:
final, html = _get(opener, url)
canon = canonicalize_douyin_user_url(final)
if canon != final and "douyin.com/user/" in canon:
# Re-fetch canonical profile for RENDER_DATA when possible.
try:
_, html2 = _get(opener, canon)
return canon, html2
except (urllib.error.URLError, TimeoutError, OSError):
return canon, html
return canon, html
return url, None
def download_avatar(url: str, dest: Path) -> bool:
dest.parent.mkdir(parents=True, exist_ok=True)
# Prefer a larger CDN variant when the URL embeds a size token.
candidates = [url]
if "/100x100/" in url:
candidates.insert(0, url.replace("/100x100/", "/720x720/"))
if "300x300" in url:
candidates.insert(0, url.replace("300x300", "720x720"))
for candidate in candidates:
req = urllib.request.Request(
candidate,
headers={
"User-Agent": BROWSER_UA,
"Referer": "https://www.douyin.com/",
},
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
data = resp.read()
except (urllib.error.URLError, TimeoutError, OSError) as e:
print(f" avatar download failed: {e}", flush=True)
continue
if not data or len(data) < 64:
continue
dest.write_bytes(data)
return True
print(" avatar download empty", flush=True)
return False
def fetch_douyin_profile(
opener: urllib.request.OpenerDirector, profile_url: str
) -> dict:
try:
_get(opener, "https://www.douyin.com/")
except (urllib.error.URLError, TimeoutError, OSError) as e:
print(f" douyin homepage warm-up failed: {e}", flush=True)
canon, html = resolve_profile_url(opener, profile_url)
if html is None:
_, html = _get(opener, canon)
primary = None
render = _parse_render_data(html)
if render is not None:
primary = extract_profile_from_render(render)
fallback = extract_profile_from_html(html)
profile = merge_profile_dicts(primary, fallback)
if not any(
profile.get(k) is not None
for k in (
"nickname",
"follower_count",
"following_count",
"total_favorited",
"unique_id",
"avatar_url",
)
):
raise ValueError("no profile fields parsed (blocked or layout changed)")
return profile
def merge_profile(row: dict, profile: dict, *, streamer_id: str) -> None:
for key in (
"nickname",
"unique_id",
"signature",
"following_count",
"follower_count",
"total_favorited",
):
val = profile.get(key)
if val is None or val == "":
continue
row[key] = val
avatar_url = profile.get("avatar_url")
if isinstance(avatar_url, str) and avatar_url:
dest = AVATAR_DIR / f"{streamer_id}.jpg"
if download_avatar(avatar_url, dest):
row["avatar"] = f"streamer_avatars/{streamer_id}.jpg"
print(f" avatar saved {dest.relative_to(ROOT)}", flush=True)
row["profile_fetched_at"] = _now_iso()
def enrich_streamers(
payload: dict, *, ids: set[str] | None = None
) -> tuple[int, int, int]:
rows = payload.get("streamers")
if not isinstance(rows, list):
raise SystemExit("streamers.json: missing streamers array")
opener = _opener()
ok = skip = fail = 0
for row in rows:
if not isinstance(row, dict):
continue
sid = str(row.get("id") or "").strip()
if not sid:
continue
if ids is not None and sid not in ids:
continue
platform = str(row.get("platform") or "").strip().lower()
if platform != "douyin":
print(f"skip {sid}: platform={platform!r} (only douyin supported)", flush=True)
skip += 1
continue
profile_url = str(row.get("profile_url") or "").strip()
if not profile_url:
print(f"skip {sid}: missing profile_url", flush=True)
skip += 1
continue
print(f"fetching {sid} ...", flush=True)
try:
profile = fetch_douyin_profile(opener, profile_url)
merge_profile(row, profile, streamer_id=sid)
nick = row.get("nickname") or "?"
print(
f" ok {nick} followers={row.get('follower_count')} "
f"likes={row.get('total_favorited')}",
flush=True,
)
ok += 1
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
print(f" FAIL {sid}: {e} (keeping previous values)", flush=True)
fail += 1
time.sleep(0.8)
if ok > 0:
payload["fetched_at"] = _now_iso()
payload["source"] = payload.get("source") or "manual+douyin"
meta = payload.get("platform_meta")
if not isinstance(meta, dict):
meta = {}
meta.setdefault(
"douyin",
{"label_zh": "抖音", "icon": "ui-icon/platform_douyin.png"},
)
payload["platform_meta"] = meta
_ = PROFILE_KEYS
return ok, skip, fail
def main() -> int:
ap = argparse.ArgumentParser(description="Enrich streamers.json from Douyin profiles")
ap.add_argument("--out", type=Path, default=OUT)
ap.add_argument(
"--ids",
nargs="+",
default=None,
help="only refresh these streamer ids",
)
args = ap.parse_args()
payload = _load(args.out)
id_set = set(args.ids) if args.ids else None
ok, skip, fail = enrich_streamers(payload, ids=id_set)
_save(args.out, payload)
print(f"wrote {args.out} ok={ok} skip={skip} fail={fail}", flush=True)
# Soft-fail for CI/refresh_web: always exit 0 after writing (keep old values).
return 0
if __name__ == "__main__":
raise SystemExit(main())
+91 -8
View File
@@ -3738,22 +3738,27 @@ function buildStreamerCard(row) {
const head = document.createElement("div");
head.className = "streamer-card-head";
// Prefer explicit is_live when present; otherwise treat live_url as live proxy
const isLive =
typeof row.is_live === "boolean" ? row.is_live : Boolean(row.live_url);
const liveHref = isLive && row.live_url ? String(row.live_url) : "";
// Badge/animation only when the probe confirms live; the avatar still
// links to the room whenever live_url exists (offline rooms stay reachable).
const isLive = row.is_live === true;
const liveHref = row.live_url ? String(row.live_url) : "";
const avatarWrap = liveHref
? document.createElement("a")
: document.createElement("div");
avatarWrap.className = liveHref
avatarWrap.className = isLive
? "streamer-avatar-wrap is-live"
: "streamer-avatar-wrap";
if (liveHref) {
avatarWrap.href = liveHref;
avatarWrap.target = "_blank";
avatarWrap.rel = "noopener noreferrer";
avatarWrap.title = "进入直播间";
avatarWrap.setAttribute("aria-label", `${nick} 直播中,点击进入直播间`);
avatarWrap.title = isLive ? "进入直播间" : "前往直播间(未开播)";
avatarWrap.setAttribute(
"aria-label",
isLive ? `${nick} 直播中,点击进入直播间` : `${nick} 的直播间(当前未开播)`
);
}
if (isLive) {
// Douyin: static ring + pulsing ring (expand/fade) beside shrinking avatar.
const ringBase = document.createElement("span");
ringBase.className = "streamer-live-ring";
@@ -3781,7 +3786,7 @@ function buildStreamerCard(row) {
avatarInner.appendChild(ph);
}
avatarWrap.appendChild(avatarInner);
if (liveHref) {
if (isLive) {
const liveBadge = document.createElement("span");
liveBadge.className = "streamer-live-badge";
liveBadge.textContent = "直播";
@@ -3912,6 +3917,83 @@ function renderStreamers() {
}
}
/** Sync one rendered card's live ring/badge/title with row.is_live (in place). */
function syncStreamerCardLive(card, row) {
const wrap = card.querySelector(".streamer-avatar-wrap");
if (!wrap) return;
const isLive = row.is_live === true;
if (wrap.classList.contains("is-live") === isLive) return;
const nick = row.nickname || row.id || "未命名主播";
wrap.classList.toggle("is-live", isLive);
wrap
.querySelectorAll(".streamer-live-ring, .streamer-live-badge")
.forEach((el) => el.remove());
if (isLive) {
const ringBase = document.createElement("span");
ringBase.className = "streamer-live-ring";
ringBase.setAttribute("aria-hidden", "true");
const ringPulse = document.createElement("span");
ringPulse.className = "streamer-live-ring streamer-live-ring-pulse";
ringPulse.setAttribute("aria-hidden", "true");
wrap.insertBefore(ringPulse, wrap.firstChild);
wrap.insertBefore(ringBase, wrap.firstChild);
const liveBadge = document.createElement("span");
liveBadge.className = "streamer-live-badge";
liveBadge.textContent = "直播";
wrap.appendChild(liveBadge);
}
if (wrap.tagName === "A") {
wrap.title = isLive ? "进入直播间" : "前往直播间(未开播)";
wrap.setAttribute(
"aria-label",
isLive ? `${nick} 直播中,点击进入直播间` : `${nick} 的直播间(当前未开播)`
);
}
}
/**
* Visit-triggered live refresh: the Pages Function coalesces concurrent
* visitors through a 5-minute edge cache. Local dev has no /api/live-status
* (serve_relations.py returns an empty stub), so failures keep the data.json
* is_live fallback. Fires at most once per page load.
*/
let liveStatusFetched = false;
function refreshStreamerLiveStatus() {
if (liveStatusFetched) return;
liveStatusFetched = true;
fetch("/api/live-status")
.then((res) => (res.ok ? res.json() : null))
.then((payload) => {
const probed = payload && payload.streamers;
if (!probed || typeof probed !== "object") return;
const rows =
(state.data && state.data.streamers && state.data.streamers.streamers) || [];
let changed = false;
for (const row of rows) {
const cell = row && row.id ? probed[row.id] : null;
if (!cell || typeof cell.is_live !== "boolean") continue;
if (cell.stale) console.info(`live-status: ${row.id} using stale carry-over`);
if (row.is_live !== cell.is_live) {
row.is_live = cell.is_live;
changed = true;
}
}
if (!changed) return;
// Update rendered cards in place (top-level page + hero detail panel);
// rows already merged, so any later re-render picks up the new state.
document
.querySelectorAll(".streamer-card[data-streamer-id]")
.forEach((card) => {
const row = rows.find((r) => r && r.id === card.dataset.streamerId);
if (row) syncStreamerCardLive(card, row);
});
})
.catch(() => {
/* endpoint missing/unreachable: keep data.json is_live */
});
}
const TRENDS_MIN_END_PICK = 200;
const TRENDS_TOP_N = 100;
@@ -4804,6 +4886,7 @@ async function main() {
_innateAbilityKeys = null;
installRouter({ getState: () => state, applyPatch });
applyUrlToState();
refreshStreamerLiveStatus();
}
main().catch((err) => {
@@ -1,4 +1,4 @@
/* Local defaults; production export overwrites via export_relations_site.py. */
var SITE_VERSION = "0.5.59";
var SITE_VERSION = "0.5.61";
var ABILITY_VIDEO_BASE = "";
var STATIC_ASSET_BASE = "";
+323
View File
@@ -0,0 +1,323 @@
/**
* Pages Function: GET /api/live-status
*
* Visit-triggered live-status probing with request coalescing via the edge
* Cache API (no KV, no wrangler config). The first visitor after the 5-minute
* freshness window triggers a re-probe of every streamer with `live_url`;
* concurrent visitors within the window share the cached JSON.
*
* Probe logic is a JS port of fetch_streamer_live.py:
* - Bilibili: api.live.bilibili.com Room/get_info; data.live_status === 1 is
* live (0 offline, 2 replay counts as offline).
* - Douyin: warm up cookies (www.douyin.com + live.douyin.com), then GET
* live.douyin.com/<web_rid> with a browser UA and parse the escaped JSON in
* the SSR pace chunks: roomStore.roomInfo.room.status (2 live / 4 offline);
* the embedded web_rid must match the requested one.
*
* Soft-fail everywhere: douyin blocks from datacenter IPs are expected. When a
* single probe fails, the streamer carries over the last known is_live from
* the previous (stale) cache entry with `stale: true`; when every probe fails
* the stale cache entry is served wholesale (X-Live-Cache: stale-override),
* or an empty payload when nothing was ever cached (X-Live-Cache: error).
* The handler never throws a 500 for probe failures.
*
* Named exports double as the local test surface; the Pages runtime only
* routes onRequest* handlers.
*/
const CACHE_KEY = "https://live-status.internal/v1";
const FRESH_TTL_S = 300;
const FRESH_TTL_MS = FRESH_TTL_S * 1000;
// Store longer than the 5-min freshness window so expired-for-serve entries
// remain readable as carry-over material; freshness is governed by probed_at.
const CACHE_STORE_MAX_AGE_S = 6 * 60 * 60;
const CLIENT_MAX_AGE_S = 60;
const PROBE_TIMEOUT_MS = 8000;
const BATCH_SIZE = 3;
const DOUYIN_SPACING_MS = 800;
const BROWSER_UA =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
"AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/120.0.0.0 Safari/537.36";
const DOUYIN_HOME = "https://www.douyin.com/";
const DOUYIN_LIVE_HOME = "https://live.douyin.com/";
const BILIBILI_INFO_URL =
"https://api.live.bilibili.com/room/v1/Room/get_info?room_id=";
// Escaped JSON inside the SSR pace chunks: \"roomStore\":{\"roomInfo\":{\"room\":{
const DOUYIN_ROOMSTORE_RE = /\\"roomStore\\":\s*\{\\"roomInfo\\":\s*\{\\"room\\":\s*\{/;
const DOUYIN_STATUS_RE = /\\"status\\":\s*(\d)/;
const DOUYIN_WEBRID_RE = /\\"web_rid\\":\s*\\"(\d+)\\"/;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function fetchWithTimeout(url, init = {}) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT_MS);
return fetch(url, { ...init, signal: ctrl.signal }).finally(() =>
clearTimeout(timer)
);
}
/** Set-Cookie reader portable across Workers (getAll) and Node/undici. */
function setCookiesOf(res) {
const h = res && res.headers;
if (!h) return [];
if (typeof h.getSetCookie === "function") return h.getSetCookie() || [];
if (typeof h.getAll === "function") {
try {
return h.getAll("Set-Cookie") || [];
} catch {
return [];
}
}
return [];
}
function collectCookies(res, jar) {
for (const sc of setCookiesOf(res)) {
const pair = String(sc).split(";")[0];
const eq = pair.indexOf("=");
if (eq > 0) jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
}
}
function cookieHeader(jar) {
return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join("; ");
}
function douyinHeaders(referer, jar) {
const headers = {
"User-Agent": BROWSER_UA,
Accept: "*/*",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
Referer: referer,
};
const cookie = jar && jar.size ? cookieHeader(jar) : "";
if (cookie) headers.Cookie = cookie;
return headers;
}
/** Seed cookies once so subsequent room-page requests are not blocked. */
export async function warmDouyinCookies(jar = new Map()) {
for (const url of [DOUYIN_HOME, DOUYIN_LIVE_HOME]) {
try {
const res = await fetchWithTimeout(url, {
headers: douyinHeaders(DOUYIN_HOME, jar),
});
collectCookies(res, jar);
await res.arrayBuffer(); // drain the body
} catch {
// warm-up is best-effort; the room probe below is the real check
}
await sleep(DOUYIN_SPACING_MS);
}
return jar;
}
/** Parse roomStore status from the SSR live room page (2 live / 4 offline). */
export async function probeDouyinRoom(rid, jar) {
const res = await fetchWithTimeout(DOUYIN_LIVE_HOME + rid, {
headers: douyinHeaders(DOUYIN_LIVE_HOME, jar),
});
collectCookies(res, jar);
const html = await res.text();
if (!html) throw new Error("empty room page");
const store = DOUYIN_ROOMSTORE_RE.exec(html);
if (!store) throw new Error("no roomStore in page (blocked or layout changed)");
// The room object opens with id_str/status; a short window is enough.
const end = store.index + store[0].length;
const win = html.slice(end, end + 3000);
const statusM = DOUYIN_STATUS_RE.exec(win);
if (!statusM) throw new Error("roomStore has no status field");
const embedded = DOUYIN_WEBRID_RE.exec(html);
if (!embedded || embedded[1] !== rid) {
throw new Error("page resolved to a different room (stale web_rid?)");
}
const status = parseInt(statusM[1], 10);
if (status === 2) return true;
if (status === 4) return false;
throw new Error(`unexpected room status ${status}`);
}
/** live_status: 0 offline, 1 live, 2 replay (replay counts as offline). */
export async function probeBilibiliRoom(roomId) {
const res = await fetchWithTimeout(BILIBILI_INFO_URL + roomId, {
headers: { "User-Agent": BROWSER_UA, Accept: "application/json" },
});
const payload = await res.json();
if (!payload || payload.code !== 0) {
throw new Error(`bilibili api error: code=${payload && payload.code}`);
}
const data = payload.data;
if (!data || typeof data !== "object") {
throw new Error("bilibili api returned no data");
}
return data.live_status === 1;
}
/** First path segment of the live room URL (douyin web_rid / bilibili room id). */
export function roomRefFromUrl(liveUrl) {
let path = "";
try {
path = new URL(String(liveUrl).trim()).pathname;
} catch {
return null;
}
const seg = path.replace(/^\/+|\/+$/g, "").split("/")[0];
return seg || null;
}
/** Extract probe targets (id/platform/room ref) from a data.json payload. */
export function targetsFromPayload(payload) {
const rows = payload && Array.isArray(payload.streamers) ? payload.streamers : [];
const targets = [];
for (const row of rows) {
if (!row || typeof row !== "object") continue;
const sid = String(row.id || "").trim();
const liveUrl = String(row.live_url || "").trim();
if (!sid || !liveUrl) continue;
const platform = String(row.platform || "").trim().toLowerCase();
const ref = roomRefFromUrl(liveUrl);
if (!ref) continue;
targets.push({ id: sid, platform, ref });
}
return targets;
}
async function probeAll(targets) {
const jar = targets.some((t) => t.platform === "douyin")
? await warmDouyinCookies()
: new Map();
const results = new Map(); // id -> { is_live } | { error }
for (let i = 0; i < targets.length; i += BATCH_SIZE) {
const batch = targets.slice(i, i + BATCH_SIZE);
await Promise.all(
batch.map(async (t) => {
try {
let isLive;
if (t.platform === "douyin") isLive = await probeDouyinRoom(t.ref, jar);
else if (t.platform === "bilibili") isLive = await probeBilibiliRoom(t.ref);
else throw new Error(`unsupported platform ${t.platform}`);
results.set(t.id, { is_live: isLive });
} catch (err) {
results.set(t.id, { error: String((err && err.message) || err) });
}
})
);
// Douyin rate-limits aggressively; keep spacing between its requests.
if (i + BATCH_SIZE < targets.length && jar.size) await sleep(DOUYIN_SPACING_MS);
}
return results;
}
function jsonResponse(body, cacheState, extraHeaders = {}) {
return new Response(JSON.stringify(body), {
status: 200,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": `public, max-age=${CLIENT_MAX_AGE_S}`,
"X-Live-Cache": cacheState,
...extraHeaders,
},
});
}
async function readCachedPayload() {
try {
const cached = await caches.default.match(CACHE_KEY);
if (!cached) return null;
const data = await cached.json();
return data && typeof data === "object" ? data : null;
} catch {
return null;
}
}
function isFresh(data) {
const ts = Date.parse(data && data.probed_at);
return Number.isFinite(ts) && Date.now() - ts < FRESH_TTL_MS;
}
function emptyPayload() {
return { probed_at: new Date().toISOString(), ttl: FRESH_TTL_S, streamers: {} };
}
async function handle(context) {
const { request } = context;
const cachedData = await readCachedPayload();
if (cachedData && isFresh(cachedData)) {
return jsonResponse(cachedData, "hit");
}
let targets = [];
try {
const dataUrl = new URL("/data.json", request.url);
const res = await fetchWithTimeout(dataUrl.toString(), {
headers: { Accept: "application/json" },
});
if (res.ok) targets = targetsFromPayload(await res.json());
} catch {
// data.json unreachable: fall through to stale/empty below
}
if (!targets.length) {
if (cachedData) return jsonResponse(cachedData, "stale-override");
return jsonResponse(emptyPayload(), "error");
}
const staleStreamers =
cachedData && cachedData.streamers && typeof cachedData.streamers === "object"
? cachedData.streamers
: {};
const probed = await probeAll(targets);
const streamers = {};
let freshCount = 0;
for (const t of targets) {
const r = probed.get(t.id);
if (r && typeof r.is_live === "boolean") {
streamers[t.id] = { is_live: r.is_live };
freshCount += 1;
continue;
}
// Per-streamer soft-fail: carry over the last known state, marked stale.
const prev = staleStreamers[t.id];
if (prev && typeof prev.is_live === "boolean") {
streamers[t.id] = { is_live: prev.is_live, stale: true };
}
}
if (freshCount === 0) {
// Total probe failure (e.g. douyin blocking this colo): serve the stale
// snapshot if one exists, otherwise an explicitly empty payload.
if (cachedData) return jsonResponse(cachedData, "stale-override");
return jsonResponse(emptyPayload(), "error");
}
const body = { probed_at: new Date().toISOString(), ttl: FRESH_TTL_S, streamers };
const res = jsonResponse(body, "miss");
const cached = new Response(JSON.stringify(body), {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": `max-age=${CACHE_STORE_MAX_AGE_S}`,
},
});
// Cache write must not block the response.
context.waitUntil(caches.default.put(CACHE_KEY, cached));
return res;
}
export async function onRequestGet(context) {
try {
return await handle(context);
} catch {
// Never 500 because of probing: last-resort empty payload.
return jsonResponse(emptyPayload(), "error");
}
}
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>DOTA2 上分帝</title>
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
<link rel="stylesheet" href="/style.css?v=0.5.59" />
<link rel="stylesheet" href="/style.css?v=0.5.61" />
</head>
<body>
<header class="topbar">
@@ -136,8 +136,8 @@
<section class="detail" id="detail" aria-live="polite"></section>
<script src="/config.js?v=0.5.59"></script>
<script src="/router.js?v=0.5.59"></script>
<script src="/app.js?v=0.5.59"></script>
<script src="/config.js?v=0.5.61"></script>
<script src="/router.js?v=0.5.61"></script>
<script src="/app.js?v=0.5.61"></script>
</body>
</html>
@@ -3152,10 +3152,11 @@ body:has(#items-view:not(.hidden)) {
text-decoration: none;
color: inherit;
}
a.streamer-avatar-wrap.is-live {
/* Linked wraps: live rooms and offline rooms alike (offline keeps click-through). */
a.streamer-avatar-wrap {
cursor: pointer;
}
a.streamer-avatar-wrap.is-live:hover .streamer-avatar {
a.streamer-avatar-wrap:hover .streamer-avatar {
filter: brightness(1.06);
}
.streamer-avatar-inner {
+454
View File
@@ -0,0 +1,454 @@
"""Build hero → feared items map from items_meta + hero_abilities.
Usage:
python item_fears.py
python item_fears.py --top 8
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
from datetime import datetime, timezone
from shared.grid import hero_table
from shared.hero_tags import ILLUSION_KEYS
from shared.http_utils import http_json
from shared.paths import DATA
ITEMS_META = DATA / "items_meta.json"
HERO_ABILITIES = DATA / "hero_abilities.json"
HERO_FEAR_OVERRIDES = DATA / "hero_fear_overrides.json"
ITEM_COUNTER_STATS = DATA / "item_counter_stats.json"
OUT = DATA / "hero_item_fears.json"
ABILITIES_URL = (
"https://raw.githubusercontent.com/odota/dotaconstants/master/build/abilities.json"
)
# Prefer iconic counter items when scores tie
ITEM_PRIORITY = {
"lotus_orb": 12,
"diffusal_blade": 10,
"disperser": 9,
"nullifier": 9,
"silver_edge": 8,
"monkey_king_bar": 8,
"bfury": 8,
"abyssal_blade": 8,
"mjollnir": 7,
"maelstrom": 6,
"radiance": 7,
"gungir": 6,
"spirit_vessel": 7,
"black_king_bar": 5,
"sphere": 5,
"butterfly": 6,
"ethereal_blade": 6,
"ghost": 4,
"gem": 4,
"dust": 3,
"orchid": 5,
"bloodthorn": 6,
"sheepstick": 6,
}
def load_json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def is_unit_target_enemy(od: dict) -> bool:
beh = od.get("behavior")
if isinstance(beh, str):
unit = "Unit Target" in beh
elif isinstance(beh, list):
unit = any("Unit Target" in str(x) for x in beh)
else:
return False
if not unit:
return False
team = od.get("target_team")
if isinstance(team, list):
team_s = " ".join(str(x) for x in team)
else:
team_s = str(team or "")
return "Enemy" in team_s or team_s == ""
def load_odota_unit_target_keys() -> set[str]:
"""Ability keys that are enemy unit-target (Lotus / Linken relevant)."""
try:
raw = http_json(ABILITIES_URL, timeout=90)
except Exception as e: # noqa: BLE001
print(f"warn: could not load OpenDota abilities.json ({e})", flush=True)
return set()
if not isinstance(raw, dict):
return set()
out: set[str] = set()
for key, row in raw.items():
if not isinstance(row, dict) or str(key).startswith("special_bonus"):
continue
if is_unit_target_enemy(row):
out.add(str(key))
return out
def index_items_by_key(items: dict) -> dict[str, dict]:
out: dict[str, dict] = {}
for row in items.values():
if not isinstance(row, dict) or not row.get("key"):
continue
key = row["key"]
out[key] = {
"key": key,
"name_loc": row.get("name_loc") or row.get("dname") or key,
"tags": [t for t in (row.get("tags") or []) if isinstance(t, str)],
"cost": int(row.get("cost") or 0),
}
return out
def enrich_override_items(items_by_key: dict[str, dict], overrides: dict[str, dict]) -> None:
"""Ensure override-only item keys have name_loc (from OpenDota items.json)."""
needed = set()
for ov in overrides.values():
for row in ov.get("add") or []:
if isinstance(row, dict) and row.get("item"):
needed.add(str(row["item"]))
missing = [k for k in needed if k not in items_by_key]
if not missing:
return
try:
raw = http_json(
"https://raw.githubusercontent.com/odota/dotaconstants/master/build/items.json",
timeout=90,
)
except Exception as e: # noqa: BLE001
print(f"warn: could not enrich override items ({e})", flush=True)
return
if not isinstance(raw, dict):
return
for key in missing:
row = raw.get(key)
if not isinstance(row, dict):
items_by_key[key] = {
"key": key,
"name_loc": key,
"tags": [],
"cost": 0,
}
continue
items_by_key[key] = {
"key": key,
"name_loc": str(row.get("dname") or key),
"tags": [],
"cost": int(row.get("cost") or 0),
}
def index_items_by_tag(items_by_key: dict[str, dict]) -> dict[str, list[dict]]:
by_tag: dict[str, list[dict]] = {}
for entry in items_by_key.values():
for t in entry["tags"]:
by_tag.setdefault(t, []).append(entry)
return by_tag
def hero_summary(
hero_key: str,
abilities_db: dict,
hero_tags: list[str],
unit_target_keys: set[str],
) -> dict:
cell = (abilities_db.get("by_hero") or {}).get(hero_key) or {}
summary = dict(cell.get("summary") or {})
if hero_key in ILLUSION_KEYS or "幻象" in (hero_tags or []):
summary["has_illusion"] = True
# Unit-target enemy skills → Lotus / Linken
has_unit = False
for ab in cell.get("abilities") or []:
if not isinstance(ab, dict):
continue
key = ab.get("key") or ""
if ab.get("is_innate"):
continue
if key in unit_target_keys:
has_unit = True
break
summary["has_unit_target"] = has_unit
return summary
def needed_tags(summary: dict) -> list[tuple[str, str]]:
"""Return (item_tag, reason) pairs this hero fears."""
out: list[tuple[str, str]] = []
n_disp = int(summary.get("dispellable_buff_count") or 0)
if n_disp > 0:
out.append(("basic_dispel", "可驱散技能增益"))
out.append(("strong_dispel", "可驱散技能增益"))
if summary.get("has_strong_only_buff"):
out.append(("strong_dispel", "需强驱散才能驱散的增益"))
# Few shop items apply strong dispel offensively; still surface Linken/BKB.
out.append(("spell_block", "法术格挡关键技能"))
out.append(("magic_immune", "魔免削弱技能"))
if summary.get("has_illusion"):
out.append(("illusion_clear", "克制幻象"))
if summary.get("has_evasion"):
out.append(("true_strike", "无视闪避"))
if summary.get("has_invis"):
out.append(("invis_detect", "显影"))
out.append(("invis_break", "破隐"))
if summary.get("has_passive_breakable"):
out.append(("break", "破坏关键被动"))
if summary.get("mana_dependent"):
out.append(("mana_burn", "烧蓝克制"))
if summary.get("has_unit_target"):
out.append(("spell_reflect", "反射点目标技能"))
out.append(("spell_block", "格挡点目标技能"))
if summary.get("disable_heavy"):
out.append(("magic_immune", "魔免抵消控制"))
out.append(("spell_block", "法术格挡"))
elif summary.get("magic_nuke"):
out.append(("magic_immune", "魔免削弱魔法输出"))
return out
def score_item(
item: dict,
matched_tags: list[str],
reasons: list[str],
bonus: int = 0,
) -> tuple[int, dict]:
pri = ITEM_PRIORITY.get(item["key"], 0)
score = len(matched_tags) * 10 + pri + bonus
if item["cost"] >= 2000:
score += 2
return score, {
"item": item["key"],
"name_loc": item["name_loc"],
"tags": matched_tags,
"reason": "".join(dict.fromkeys(reasons)),
"_score": score,
}
def load_hero_overrides() -> dict[str, dict]:
if not HERO_FEAR_OVERRIDES.is_file():
return {}
try:
raw = load_json(HERO_FEAR_OVERRIDES)
except (OSError, json.JSONDecodeError):
return {}
heroes = raw.get("heroes") or {}
return {str(k): v for k, v in heroes.items() if isinstance(v, dict)}
def load_counter_stats() -> tuple[dict, dict[str, dict[str, dict]]]:
"""Load optional adjusted OpenDota evidence indexed by hero and item."""
if not ITEM_COUNTER_STATS.is_file():
return {}, {}
try:
raw = load_json(ITEM_COUNTER_STATS)
except (OSError, json.JSONDecodeError):
return {}, {}
indexed: dict[str, dict[str, dict]] = {}
for hero_key, rows in (raw.get("by_hero") or {}).items():
if not isinstance(rows, list):
continue
indexed[str(hero_key)] = {
str(row["item"]): row
for row in rows
if isinstance(row, dict) and row.get("item")
}
return dict(raw.get("meta") or {}), indexed
def evidence_adjustment(evidence: dict | None) -> int:
"""Return a deliberately small corroboration bonus/penalty."""
if not evidence:
return 0
games = int(evidence.get("games") or 0)
if games < 100:
return 0
buy_lift_pp = float(evidence.get("buy_lift") or 0) * 100
win_delta_pp = float(evidence.get("win_delta") or 0) * 100
# Mixed signs are inconclusive. Requiring agreement avoids promoting generic
# expensive winner items based on conditional win rate alone.
if buy_lift_pp * win_delta_pp <= 0:
return 0
raw = 0.6 * buy_lift_pp + 0.4 * win_delta_pp
reliability = min(1.0, games / 500)
return round(max(-6.0, min(6.0, raw)) * reliability)
def fears_for_hero(
hero_key: str,
summary: dict,
by_tag: dict[str, list[dict]],
items_by_key: dict[str, dict],
overrides: dict[str, dict],
counter_stats: dict[str, dict[str, dict]],
top_n: int,
) -> list[dict]:
need = needed_tags(summary)
acc: dict[str, dict] = {}
for tag, reason in need:
for item in by_tag.get(tag) or []:
key = item["key"]
if key not in acc:
acc[key] = {"item": item, "matched": [], "reasons": [], "bonus": 0}
if tag not in acc[key]["matched"]:
acc[key]["matched"].append(tag)
if reason not in acc[key]["reasons"]:
acc[key]["reasons"].append(reason)
ov = overrides.get(hero_key) or {}
for row in ov.get("add") or []:
if not isinstance(row, dict):
continue
key = row.get("item")
if not key or key not in items_by_key:
# Allow overrides for items not tagged (butterfly, ghost, …)
if not key:
continue
meta = items_by_key.get(key)
if meta is None:
# Synthesize from key alone if missing from finished catalog
meta = {
"key": key,
"name_loc": key,
"tags": list(row.get("tags") or []),
"cost": 0,
}
items_by_key[key] = meta
item = items_by_key[key]
if key not in acc:
acc[key] = {"item": item, "matched": [], "reasons": [], "bonus": 20}
else:
acc[key]["bonus"] = max(int(acc[key].get("bonus") or 0), 20)
for t in row.get("tags") or []:
if isinstance(t, str) and t not in acc[key]["matched"]:
acc[key]["matched"].append(t)
reason = (row.get("reason") or "").strip()
if reason and reason not in acc[key]["reasons"]:
acc[key]["reasons"].insert(0, reason)
# Refresh name_loc if we only had key
if item.get("name_loc") == key and row.get("name_loc"):
item["name_loc"] = row["name_loc"]
for key in ov.get("remove") or []:
if isinstance(key, str):
acc.pop(key, None)
ranked = []
for row in acc.values():
evidence = (counter_stats.get(hero_key) or {}).get(row["item"]["key"])
sc, entry = score_item(
row["item"],
row["matched"],
row["reasons"],
bonus=int(row.get("bonus") or 0) + evidence_adjustment(evidence),
)
if evidence:
entry["stats"] = {
"games": int(evidence.get("games") or 0),
"purchase_rate": float(evidence.get("buy_rate") or 0),
"win_rate": float(evidence.get("win_rate") or 0),
"purchase_lift": float(evidence.get("buy_lift") or 0),
"win_delta": float(evidence.get("win_delta") or 0),
}
ranked.append((sc, entry))
ranked.sort(key=lambda t: (-t[0], t[1]["item"]))
out = []
for _, entry in ranked[:top_n]:
entry.pop("_score", None)
out.append(entry)
return out
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--top", type=int, default=8)
ap.add_argument("--out", type=Path, default=OUT)
args = ap.parse_args()
if not ITEMS_META.is_file():
raise SystemExit(f"missing {ITEMS_META}; run: python fetch_items_meta.py")
if not HERO_ABILITIES.is_file():
raise SystemExit(f"missing {HERO_ABILITIES}; run: python fetch_hero_abilities.py")
items_meta = load_json(ITEMS_META)
abilities_db = load_json(HERO_ABILITIES)
counter_meta, counter_stats = load_counter_stats()
items = items_meta.get("items") or {}
items_by_key = index_items_by_key(items)
overrides = load_hero_overrides()
enrich_override_items(items_by_key, overrides)
by_tag = index_items_by_tag(items_by_key)
print("loading OpenDota abilities for unit-target detection...", flush=True)
unit_target_keys = load_odota_unit_target_keys()
print(f" {len(unit_target_keys)} unit-target enemy abilities", flush=True)
heroes = hero_table()
by_hero: dict[str, list] = {}
for h in heroes:
key = h["key"]
tags = list(h.get("tags") or [])
summary = hero_summary(key, abilities_db, tags, unit_target_keys)
by_hero[key] = fears_for_hero(
key, summary, by_tag, items_by_key, overrides, counter_stats, args.top
)
items_out = {
k: {
"key": v["key"],
"name_loc": v["name_loc"],
"tags": v["tags"],
}
for k, v in items_by_key.items()
}
payload = {
"meta": {
"source": "rules+valve+opendota",
"attribution": "derived from items_meta.json + hero_abilities.json",
"fetched_at": datetime.now(timezone.utc).isoformat(),
"top_n": args.top,
"heroes": len(by_hero),
"overrides": str(HERO_FEAR_OVERRIDES.relative_to(DATA.parent)).replace("\\", "/"),
"counter_stats": (
{
"path": str(ITEM_COUNTER_STATS.relative_to(DATA.parent)).replace("\\", "/"),
"source": counter_meta.get("source"),
"fetched_at": counter_meta.get("fetched_at"),
"method": counter_meta.get("method"),
"caveat": counter_meta.get("caveat"),
}
if counter_meta
else None
),
},
"by_hero": by_hero,
"items": items_out,
}
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
nonempty = sum(1 for v in by_hero.values() if v)
jugg = [x["item"] for x in by_hero.get("juggernaut") or []]
print(f"done: {nonempty}/{len(by_hero)} heroes have feared items -> {args.out}", flush=True)
print(f" juggernaut: {jugg}", flush=True)
if __name__ == "__main__":
main()
+98
View File
@@ -0,0 +1,98 @@
"""Shared Valve loc formatting: strip HTML and fill %token% / {s:token}."""
from __future__ import annotations
import re
from html import unescape
def fmt_num(v: float) -> str:
if abs(v - round(v)) < 1e-6:
return str(int(round(v)))
return f"{v:g}"
def sv_lookup(
special_values: list | None, prefer: str | None = None
) -> dict[str, list[float]]:
"""prefer: None | 'scepter' | 'shard' — choose values_* channel when present."""
out: dict[str, list[float]] = {}
for sv in special_values or []:
if not isinstance(sv, dict):
continue
name = str(sv.get("name") or "").strip()
if not name:
continue
base = sv.get("values_float") or []
if not isinstance(base, list):
base = []
sc = sv.get("values_scepter") or []
sh = sv.get("values_shard") or []
if not isinstance(sc, list):
sc = []
if not isinstance(sh, list):
sh = []
chosen = base
if prefer == "scepter" and sc:
chosen = sc
elif prefer == "shard" and sh:
chosen = sh
out[name] = [float(x) for x in chosen if isinstance(x, (int, float))]
if sc:
out["scepter_" + name] = [
float(x) for x in sc if isinstance(x, (int, float))
]
if sh:
out["shard_" + name] = [
float(x) for x in sh if isinstance(x, (int, float))
]
return out
def strip_html(text: str) -> str:
"""Remove HTML tags and collapse whitespace (no token filling)."""
if not text:
return ""
t = unescape(text)
t = re.sub(r"<br\s*/?>", "\n", t, flags=re.I)
t = re.sub(r"</?h1[^>]*>", "\n", t, flags=re.I)
t = re.sub(r"</?font[^>]*>", "", t, flags=re.I)
t = re.sub(r"<[^>]+>", " ", t)
return re.sub(r"[ \t]+", " ", t).strip()
def format_loc(
text: str,
special_values: list | None = None,
prefer: str | None = None,
) -> str:
"""Strip HTML and fill %token% / {s:token} from special_values when possible."""
if not text:
return ""
t = strip_html(text)
t = re.sub(r"[ \t]+\n", "\n", t)
lookup = sv_lookup(special_values or [], prefer=prefer)
def repl_pct(m: re.Match) -> str:
key = m.group(1)
vals = lookup.get(key)
if not vals and prefer:
vals = lookup.get(f"{prefer}_{key}")
if not vals:
return "?"
if len(vals) == 1:
return fmt_num(vals[0])
if len(vals) <= 4:
return " / ".join(fmt_num(v) for v in vals)
return fmt_num(vals[0])
t = re.sub(r"%([A-Za-z0-9_]+)%", repl_pct, t)
t = re.sub(r"\{s:([A-Za-z0-9_]+)\}", repl_pct, t)
t = t.replace("%%", "%")
t = re.sub(r"[ \t]+\n", "\n", t)
t = re.sub(r"\n{3,}", "\n\n", t)
t = re.sub(r"[ \t]{2,}", " ", t).strip()
return t
HAS_PLACEHOLDER = re.compile(r"%[A-Za-z0-9_]+%|\{s:[A-Za-z0-9_]+\}")
+284
View File
@@ -0,0 +1,284 @@
"""Shared "applies effect" mechanic tags for items and abilities (Web query page).
These tags mean the skill/item *applies* the effect (e.g. applies silence),
NOT that the effect itself is dispellable (see ability ``dispellable`` field).
"""
from __future__ import annotations
import re
# Sidebar / query page order (驱散 first, then control, then other).
QUERY_MECHANIC_ORDER: list[str] = [
"basic_dispel",
"strong_dispel",
"root",
"disarm",
"silence",
"mute",
"stun",
"hex",
"break",
"sleep",
"fear",
"taunt",
"blind",
"leash",
"invis",
"ethereal",
"cyclone",
]
QUERY_MECHANIC_LABELS: dict[str, str] = {
"basic_dispel": "弱驱散",
"strong_dispel": "强驱散",
"root": "缠绕",
"disarm": "缴械",
"silence": "沉默",
"mute": "锁闭",
"stun": "眩晕",
"hex": "妖术",
"break": "破坏",
"sleep": "睡眠",
"fear": "恐惧",
"taunt": "嘲讽",
"blind": "致盲",
"leash": "束缚",
"invis": "隐身",
"ethereal": "虚无",
"cyclone": "吹风",
}
# Short gameplay blurbs for the mechanics query page (ZH).
QUERY_MECHANIC_BLURBS: dict[str, str] = {
"basic_dispel": "基础驱散(弱驱散)。可移除多数可用弱驱散清除的增益/减益;无法驱散标注为「仅强驱散」的效果。",
"strong_dispel": "强驱散。可移除弱驱散清不掉、但可被强驱散清除的效果(如部分强控、特殊增益)。",
"root": "缠绕 / 定身。目标无法移动,通常仍可攻击与施法;多数情况下也无法使用位移技能。",
"disarm": "缴械。目标无法进行普通攻击,仍可移动与施法。",
"silence": "沉默。目标无法施放技能,仍可移动与普通攻击;物品主动一般不受影响。",
"mute": "锁闭。目标无法使用物品主动技能;通常仍可移动、攻击与施放英雄技能。",
"stun": "眩晕。目标完全无法行动(不能移动、攻击或施法),是最直接的硬控之一。",
"hex": "妖术(变羊等)。强制变形并大幅限制行动,通常伴随沉默、锁闭与缴械等组合限制。",
"break": "破坏。使目标的被动技能暂时失效(不移除身上已有 buff,但被动效果不再生效)。",
"sleep": "睡眠。目标陷入沉睡,无法行动;受到伤害时通常会醒来(具体以技能为准)。",
"fear": "恐惧。目标被迫失控移动(通常远离来源),期间难以自主操作。",
"taunt": "嘲讽。强制目标攻击施法者(或指定单位),暂时无法按自己的意愿选目标。",
"blind": "致盲。普通攻击有几率(或必定)落空,不直接禁止攻击指令本身。",
"leash": "束缚。限制目标使用位移/传送类技能(如闪烁、强制位移道具),与缠绕的「不能走路」不同。",
"invis": "隐身。对无真实视域的敌人不可见;攻击或部分行为常会显形(以技能/物品为准)。",
"ethereal": "虚无(灵体)。通常大幅降低魔法抗性、无法普通攻击,且自身常变为对物理攻击免疫或特殊交互。",
"cyclone": "吹风 / 驱逐。目标被卷入气旋或移出战场一段时间,期间通常无法行动,落地后恢复。",
}
QUERY_MECHANIC_GROUPS: list[tuple[str, list[str]]] = [
("驱散", ["basic_dispel", "strong_dispel"]),
(
"控制",
[
"root",
"disarm",
"silence",
"mute",
"stun",
"hex",
"break",
"sleep",
"fear",
"taunt",
"blind",
"leash",
],
),
("其他", ["invis", "ethereal", "cyclone"]),
]
DISPEL_TYPE_RE = re.compile(
r"(?:驱散类型|Dispel\s*Type)\s*[:]\s*([^<\n]+)",
re.I,
)
# Phrases that describe "this buff/debuff can be dispelled" — not "applies dispel".
DISPELLABLE_NOISE_RE = re.compile(
r"可被.{0,8}驱散|可以被.{0,8}驱散|无法被驱散|不可驱散|"
r"dispellable|undispellable|cannot be dispelled|can be dispelled|"
r"strong dispels? only|仅强驱散|只有强驱散",
re.I,
)
APPLIES_BASIC_DISPEL_RE = re.compile(
r"(?:施加|应用|造成|提供|进行|立即).{0,12}(?:基础)?驱散|"
r"applies?\s+(?:a\s+)?(?:basic\s+)?dispel|"
r"\bpurge\b|阻止敌方|Inhibit",
re.I,
)
APPLIES_STRONG_DISPEL_RE = re.compile(
r"(?:施加|应用|造成|提供|进行|立即).{0,12}强驱散|"
r"applies?\s+(?:a\s+)?strong\s+dispel",
re.I,
)
# Phrases that mention an effect without applying it to enemies.
SILENCE_INTERACTION_RE = re.compile(
r"沉默术士|"
r"目标被沉默时|被沉默的目标|被沉默单位|被沉默时|"
r"while silenced|when silenced|silenced target|silenced unit",
re.I,
)
STUN_INTERACTION_RE = re.compile(
r"只要眩晕敌人|自身被眩晕|多个眩晕的效果|when you stun an enemy",
re.I,
)
DISARM_SELF_RE = re.compile(
r"他被缴械|自身被缴械|被缴械,但可以",
re.I,
)
ALLY_SELF_DEBUFF_RE = re.compile(
r"被锁闭、沉默和缴械|无法成为目标,被锁闭",
re.I,
)
ABILITY_CATALOG_RE = re.compile(
r"拥有[^。]{0,120}技能",
re.I,
)
def _strip_interaction_noise(text: str) -> str:
if not text:
return ""
t = text
t = SILENCE_INTERACTION_RE.sub(" ", t)
t = STUN_INTERACTION_RE.sub(" ", t)
t = DISARM_SELF_RE.sub(" ", t)
t = ALLY_SELF_DEBUFF_RE.sub(" ", t)
return t
def _strip_dispellable_noise(text: str) -> str:
"""Remove 'is dispellable' clauses and dispel-type metadata lines."""
if not text:
return ""
t = DISPEL_TYPE_RE.sub(" ", text)
return DISPELLABLE_NOISE_RE.sub(" ", t)
def extract_dispel_tags(text: str) -> set[str]:
"""Tags for abilities/items that *apply* basic or strong dispel."""
tags: set[str] = set()
if not text:
return tags
cleaned = _strip_dispellable_noise(text)
if APPLIES_STRONG_DISPEL_RE.search(cleaned):
tags.add("strong_dispel")
if APPLIES_BASIC_DISPEL_RE.search(cleaned):
# Strong takes precedence when both fire on the same "强驱散" phrase.
if "strong_dispel" not in tags and "强驱散" not in cleaned and "strong dispel" not in cleaned.lower():
tags.add("basic_dispel")
elif "strong_dispel" not in tags:
# "施加驱散" without 强 → basic; with 强 already handled above.
if not re.search(r"强驱散|strong\s+dispel", cleaned, re.I):
tags.add("basic_dispel")
return tags
def apply_mechanic_tags(blob: str, *, key: str = "") -> set[str]:
"""Extract QUERY mechanic tags from Chinese/English description text."""
del key # reserved for key-specific whitelist callers
tags: set[str] = set()
if not blob:
return tags
cleaned = _strip_interaction_noise(blob)
tags |= extract_dispel_tags(cleaned)
root_blob = ABILITY_CATALOG_RE.sub(" ", cleaned)
if re.search(r"\broot(?:s|ed)?\b|缠绕|定身", root_blob, re.I):
tags.add("root")
if re.search(r"\bdisarm(?:s|ed)?\b|缴械", cleaned, re.I):
tags.add("disarm")
if re.search(r"\bsilence[sd]?\b|沉默", cleaned, re.I):
tags.add("silence")
if re.search(r"\bmute[sd]?\b|锁闭", cleaned, re.I):
tags.add("mute")
if re.search(r"\bstun(?:s|ned)?\b|眩晕", cleaned, re.I):
tags.add("stun")
if re.search(r"\bhex(?:es|ed)?\b|变羊|妖术", cleaned, re.I):
tags.add("hex")
if re.search(
r"(?:施加|造成|应用).{0,6}破坏|"
r"\bbreaks?\s+passives?\b|"
r"(?<![A-Za-z_])Break(?![A-Za-z_])|"
r"破坏会禁用|禁用被动",
cleaned,
):
tags.add("break")
if re.search(r"\bsleep(?:s|ing|t)?\b|睡眠|催眠|梦魇", cleaned, re.I):
tags.add("sleep")
if re.search(r"\bfear(?:s|ed|ing)?\b|恐惧|恐慌", cleaned, re.I):
tags.add("fear")
if re.search(r"\btaunt(?:s|ed|ing)?\b|嘲讽", cleaned, re.I):
tags.add("taunt")
if re.search(r"\bblind(?:s|ed|ing)?\b|致盲|失明", cleaned, re.I):
tags.add("blind")
if re.search(r"\bleash(?:es|ed|ing)?\b|束缚|拴住|无法使用移动类", cleaned, re.I):
tags.add("leash")
if re.search(
r"\bethereal\b|虚无|灵体化|变为虚无|幽魂形态|幽灵形态",
cleaned,
re.I,
):
tags.add("ethereal")
if re.search(
r"\bcyclone[sd]?\b|\bbanish(?:es|ed|ing)?\b|吹起|吹风|卷入气流|"
r"龙卷风|气旋|驱逐出",
cleaned,
re.I,
):
tags.add("cyclone")
# Applies invisibility (not detect/break invis).
invis_blob = re.sub(
r"破隐|反隐|真实视域|看见隐身|对隐身|true\s*sight|"
r"break(?:s|ing)?\s+invis|detect(?:s|ing)?\s+invis|"
r"reveal(?:s|ed)?\s+invis",
" ",
cleaned,
flags=re.I,
)
if re.search(
r"隐身|invisible|invisibility|渐隐|shadow\s*walk|fade\s*time",
invis_blob,
re.I,
):
tags.add("invis")
return tags
def merge_tag_overrides(
auto: list[str] | set[str],
override_row: dict | None,
order: list[str],
) -> list[str]:
"""Merge add/remove override onto auto tags; keep ``order`` then extras."""
s = set(auto)
row = override_row or {}
for t in row.get("add") or []:
if isinstance(t, str):
s.add(t)
for t in row.get("remove") or []:
if isinstance(t, str):
s.discard(t)
ordered = [t for t in order if t in s]
extras = sorted(s - set(order))
return ordered + extras
def mechanic_query_payload() -> dict:
"""Slim dict for serve_relations / static data.json."""
return {
"order": list(QUERY_MECHANIC_ORDER),
"labels": dict(QUERY_MECHANIC_LABELS),
"blurbs": dict(QUERY_MECHANIC_BLURBS),
"groups": [
{"label": label, "keys": list(keys)}
for label, keys in QUERY_MECHANIC_GROUPS
],
}
+717
View File
@@ -0,0 +1,717 @@
"""Daily site digest -> Feishu webhook.
1) Cloudflare edge analytics for dota2.refining.dev (human homepage estimate)
2) Auto-refresh status: Gitea Actions (web-daily / web-weekly / web-patch)
+ Cloudflare Pages production deploys that day
Credentials (env, prefer keyzoo inject names):
CLOUDFLARE_EMAIL / KEYZOO_ASSET_META_USERNAME
CLOUDFLARE_API_KEY / KEYZOO_ASSET_SECRET_GLOBAL_API_KEY
FEISHU_WEBHOOK_URL / KEYZOO_ASSET_SECRET_FEISHU_WEBHOOK_URL
GITEA_TOKEN / KEYZOO_ASSET_SECRET_PERSONAL_ACCESS_TOKEN_GITEA_1 (optional;
without it the refresh section shows \"未配置 token\")
Usage:
python notify_site_traffic.py # yesterday CST
python notify_site_traffic.py --day 2026-07-27
python notify_site_traffic.py --dry-run
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Any
HOST = "dota2.refining.dev"
ZONE_NAME = "refining.dev"
SITE_URL = "https://dota2.refining.dev"
PAGES_PROJECT = "climperor-relations"
GITEA_URL_DEFAULT = "https://gitea.refining.dev"
GITEA_OWNER = "refining"
GITEA_REPO = "climperor"
CST = timezone(timedelta(hours=8))
# (workflow file basename, short label)
REFRESH_WORKFLOWS: tuple[tuple[str, str], ...] = (
("web-daily.yml", "每日数据"),
("web-weekly.yml", "每周 Meta"),
("web-patch.yml", "版本检测"),
)
HUMAN_BROWSERS = frozenset(
{
"Chrome",
"Firefox",
"Safari",
"Edge",
"Opera",
"MobileSafari",
"ChromeMobile",
"ChromeMobileWebview",
"FirefoxMobile",
"SamsungInternet",
"EdgeMobile",
}
)
def _env(*names: str) -> str | None:
for n in names:
v = os.environ.get(n)
if v:
return v
return None
def cf_headers() -> dict[str, str]:
email = _env("CLOUDFLARE_EMAIL", "KEYZOO_ASSET_META_USERNAME")
key = _env("CLOUDFLARE_API_KEY", "KEYZOO_ASSET_SECRET_GLOBAL_API_KEY")
if not email or not key:
raise SystemExit(
"missing Cloudflare credentials "
"(CLOUDFLARE_EMAIL + CLOUDFLARE_API_KEY, or keyzoo inject)"
)
return {
"X-Auth-Email": email,
"X-Auth-Key": key,
"Content-Type": "application/json",
}
def feishu_url() -> str:
url = _env("FEISHU_WEBHOOK_URL", "KEYZOO_ASSET_SECRET_FEISHU_WEBHOOK_URL")
if not url:
raise SystemExit(
"missing Feishu webhook "
"(FEISHU_WEBHOOK_URL or KEYZOO_ASSET_SECRET_FEISHU_WEBHOOK_URL)"
)
return url
def gitea_token() -> str | None:
return _env(
"GITEA_TOKEN",
"KEYZOO_ASSET_SECRET_PERSONAL_ACCESS_TOKEN_GITEA_1",
"KEYZOO_ASSET_SECRET_PERSONAL_ACCESS_TOKEN__GITEA_1",
"KEYZOO_ASSET_TOKEN",
)
def gitea_base() -> str:
return (
_env("GITEA_URL", "KEYZOO_ASSET_META_URL") or GITEA_URL_DEFAULT
).rstrip("/")
def http_json(
url: str,
*,
method: str = "GET",
body: dict | None = None,
headers: dict | None = None,
) -> dict | list:
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(url, data=data, method=method, headers=headers or {})
try:
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read().decode()
if not raw:
return {}
return json.loads(raw)
except urllib.error.HTTPError as e:
raw = e.read().decode(errors="replace")
raise SystemExit(f"HTTP {e.code} {url}: {raw[:800]}") from e
def http_json_soft(
url: str,
*,
method: str = "GET",
body: dict | None = None,
headers: dict | None = None,
) -> tuple[int, Any]:
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(url, data=data, method=method, headers=headers or {})
try:
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read().decode()
if not raw:
return r.status, {}
return r.status, json.loads(raw)
except urllib.error.HTTPError as e:
raw = e.read().decode(errors="replace")
try:
return e.code, json.loads(raw)
except json.JSONDecodeError:
return e.code, raw
def gql(query: str, variables: dict) -> dict:
data = http_json(
"https://api.cloudflare.com/client/v4/graphql",
method="POST",
body={"query": query, "variables": variables},
headers=cf_headers(),
)
if isinstance(data, dict) and data.get("errors"):
raise SystemExit(
"GraphQL errors: " + json.dumps(data["errors"], ensure_ascii=False)[:1500]
)
assert isinstance(data, dict)
return data
def read_site_version() -> str:
path = Path(__file__).resolve().parent / "frontend" / "config.js"
try:
text = path.read_text(encoding="utf-8")
except OSError:
return "?"
m = re.search(r'SITE_VERSION\s*=\s*"([^"]+)"', text)
return m.group(1) if m else "?"
@dataclass
class DayStats:
day: date
all_req: int = 0
all_vis: int = 0
human_home_req: int = 0
human_home_vis: int = 0
other_home_vis: int = 0
rum_human: int = 0
rum_other: int = 0
bytes: int = 0
top_countries: list[tuple[str, int, int]] = field(default_factory=list)
status: list[tuple[Any, int]] = field(default_factory=list)
@dataclass
class WorkflowDayStatus:
file: str
label: str
runs: list[dict] = field(default_factory=list) # status/conclusion/started_at
note: str = "" # e.g. missing token / none
@property
def ok_count(self) -> int:
return sum(1 for r in self.runs if r.get("conclusion") == "success")
@property
def fail_count(self) -> int:
return sum(
1
for r in self.runs
if r.get("conclusion") not in (None, "success")
and r.get("status") == "completed"
)
@property
def running_count(self) -> int:
return sum(1 for r in self.runs if r.get("status") in ("queued", "in_progress", "waiting"))
@dataclass
class PagesDeploy:
created_on: datetime
status: str
commit: str
url: str
@dataclass
class DigestExtras:
site_version: str
workflows: list[WorkflowDayStatus] = field(default_factory=list)
deploys: list[PagesDeploy] = field(default_factory=list)
gitea_note: str = ""
def zone_id() -> str:
d = http_json(
f"https://api.cloudflare.com/client/v4/zones?name={ZONE_NAME}",
headers=cf_headers(),
)
assert isinstance(d, dict)
rows = d.get("result") or []
if not rows:
raise SystemExit(f"zone not found: {ZONE_NAME}")
return rows[0]["id"]
def fetch_day(zid: str, day: date) -> DayStats:
q = """
query($zoneTag: string!, $day: Date!, $host: string!) {
viewer {
zones(filter: { zoneTag: $zoneTag }) {
all: httpRequestsAdaptiveGroups(
limit: 1
filter: { date: $day, clientRequestHTTPHost: $host }
) { count sum { visits edgeResponseBytes } }
homeByUA: httpRequestsAdaptiveGroups(
limit: 40
filter: {
date: $day
clientRequestHTTPHost: $host
clientRequestPath: "/"
}
orderBy: [count_DESC]
) {
dimensions { userAgentBrowser }
count
sum { visits }
}
rumByUA: httpRequestsAdaptiveGroups(
limit: 20
filter: {
date: $day
clientRequestHTTPHost: $host
clientRequestPath: "/cdn-cgi/rum"
}
orderBy: [count_DESC]
) {
dimensions { userAgentBrowser }
count
}
homeByCountry: httpRequestsAdaptiveGroups(
limit: 8
filter: {
date: $day
clientRequestHTTPHost: $host
clientRequestPath: "/"
}
orderBy: [count_DESC]
) {
dimensions { clientCountryName }
count
sum { visits }
}
byStatus: httpRequestsAdaptiveGroups(
limit: 8
filter: { date: $day, clientRequestHTTPHost: $host }
orderBy: [count_DESC]
) {
dimensions { edgeResponseStatus }
count
}
}
}
}
"""
data = gql(q, {"zoneTag": zid, "day": day.isoformat(), "host": HOST})
z = data["data"]["viewer"]["zones"][0]
st = DayStats(day=day)
all_rows = z.get("all") or []
if all_rows:
r = all_rows[0]
st.all_req = r.get("count") or 0
st.all_vis = (r.get("sum") or {}).get("visits") or 0
st.bytes = (r.get("sum") or {}).get("edgeResponseBytes") or 0
for r in z.get("homeByUA") or []:
ua = (r.get("dimensions") or {}).get("userAgentBrowser")
c = r.get("count") or 0
v = (r.get("sum") or {}).get("visits") or 0
if ua in HUMAN_BROWSERS:
st.human_home_req += c
st.human_home_vis += v
else:
st.other_home_vis += v
for r in z.get("rumByUA") or []:
ua = (r.get("dimensions") or {}).get("userAgentBrowser")
c = r.get("count") or 0
if ua in HUMAN_BROWSERS:
st.rum_human += c
else:
st.rum_other += c
for r in z.get("homeByCountry") or []:
name = (r.get("dimensions") or {}).get("clientCountryName") or "?"
v = (r.get("sum") or {}).get("visits") or 0
st.top_countries.append((name, r.get("count") or 0, v))
for r in z.get("byStatus") or []:
code = (r.get("dimensions") or {}).get("edgeResponseStatus")
st.status.append((code, r.get("count") or 0))
return st
def day_window_utc(day: date) -> tuple[datetime, datetime]:
"""Inclusive start / exclusive end of CST calendar day, as aware UTC datetimes."""
start_cst = datetime(day.year, day.month, day.day, tzinfo=CST)
end_cst = start_cst + timedelta(days=1)
return start_cst.astimezone(timezone.utc), end_cst.astimezone(timezone.utc)
def parse_gitea_time(s: str | None) -> datetime | None:
if not s:
return None
try:
# Gitea may return +08:00 or Z
if s.endswith("Z"):
return datetime.fromisoformat(s.replace("Z", "+00:00"))
return datetime.fromisoformat(s)
except ValueError:
return None
def fetch_workflow_statuses(day: date) -> tuple[list[WorkflowDayStatus], str]:
tok = gitea_token()
if not tok:
return (
[
WorkflowDayStatus(file=f, label=lab, note="未配置 GITEA_TOKEN")
for f, lab in REFRESH_WORKFLOWS
],
"未配置 GITEA_TOKEN,跳过 Actions 查询",
)
headers = {
"Authorization": f"token {tok}",
"Accept": "application/json",
"Content-Type": "application/json",
}
start_utc, end_utc = day_window_utc(day)
code, payload = http_json_soft(
f"{gitea_base()}/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}/actions/runs?limit=50",
headers=headers,
)
if code != 200:
return (
[
WorkflowDayStatus(file=f, label=lab, note=f"API {code}")
for f, lab in REFRESH_WORKFLOWS
],
f"Gitea runs API HTTP {code}",
)
if isinstance(payload, dict):
items = payload.get("workflow_runs") or payload.get("runs") or []
elif isinstance(payload, list):
items = payload
else:
items = []
by_file: dict[str, list[dict]] = {f: [] for f, _ in REFRESH_WORKFLOWS}
for x in items:
path = str(x.get("path") or "")
# path like "web-daily.yml@refs/heads/main"
base = path.split("@", 1)[0]
if base not in by_file:
continue
ts = parse_gitea_time(x.get("started_at") or x.get("completed_at"))
if ts is None:
continue
ts_utc = ts.astimezone(timezone.utc)
if not (start_utc <= ts_utc < end_utc):
continue
by_file[base].append(
{
"id": x.get("id"),
"status": x.get("status"),
"conclusion": x.get("conclusion"),
"event": x.get("event"),
"started_at": x.get("started_at"),
"completed_at": x.get("completed_at"),
"html_url": x.get("html_url"),
}
)
out: list[WorkflowDayStatus] = []
for f, lab in REFRESH_WORKFLOWS:
runs = by_file.get(f) or []
note = "" if runs else "当日无运行"
out.append(WorkflowDayStatus(file=f, label=lab, runs=runs, note=note))
return out, ""
def cf_account_id() -> str:
d = http_json(
"https://api.cloudflare.com/client/v4/accounts",
headers=cf_headers(),
)
assert isinstance(d, dict)
rows = d.get("result") or []
if not rows:
raise SystemExit("no Cloudflare accounts")
return rows[0]["id"]
def fetch_pages_deploys(day: date) -> list[PagesDeploy]:
aid = cf_account_id()
d = http_json(
f"https://api.cloudflare.com/client/v4/accounts/{aid}/pages/projects/"
f"{PAGES_PROJECT}/deployments",
headers=cf_headers(),
)
assert isinstance(d, dict)
start_utc, end_utc = day_window_utc(day)
out: list[PagesDeploy] = []
for x in d.get("result") or []:
created = parse_gitea_time(x.get("created_on"))
if created is None:
continue
created_utc = created.astimezone(timezone.utc)
if not (start_utc <= created_utc < end_utc):
continue
if (x.get("environment") or "") != "production":
continue
stage = x.get("latest_stage") or {}
trig = x.get("deployment_trigger") or {}
meta = trig.get("metadata") or {}
commit = str(meta.get("commit_hash") or "")[:10]
out.append(
PagesDeploy(
created_on=created.astimezone(CST),
status=str(stage.get("status") or "?"),
commit=commit,
url=str(x.get("url") or ""),
)
)
out.sort(key=lambda p: p.created_on, reverse=True)
return out
def fmt_bytes(n: int) -> str:
x = float(n)
for unit in ("B", "KB", "MB", "GB"):
if abs(x) < 1024:
return f"{x:.1f} {unit}"
x /= 1024
return f"{x:.1f} TB"
def _fmt_workflow_line(w: WorkflowDayStatus) -> str:
if w.note and not w.runs:
return f"- **{w.label}**`{w.file}`):{w.note}"
parts: list[str] = []
if w.ok_count:
parts.append(f"成功 {w.ok_count}")
if w.fail_count:
parts.append(f"失败 {w.fail_count}")
if w.running_count:
parts.append(f"进行中 {w.running_count}")
if not parts:
parts.append(w.note or "无结论")
# Show latest conclusion time if any
latest = ""
if w.runs:
r0 = w.runs[0]
t = r0.get("completed_at") or r0.get("started_at") or ""
if t:
dt = parse_gitea_time(t)
if dt:
latest = " · 最近 " + dt.astimezone(CST).strftime("%H:%M")
return f"- **{w.label}**{' / '.join(parts)}{latest}"
def build_card(st: DayStats, extras: DigestExtras) -> dict:
"""Feishu interactive card (msg_type=interactive)."""
countries = "".join(
f"{name} {vis}访/{req}" for name, req, vis in st.top_countries[:5]
) or ""
status_line = "".join(f"{code}×{n}" for code, n in st.status[:6]) or ""
now = datetime.now(CST).strftime("%Y-%m-%d %H:%M CST")
estimate = st.human_home_vis
rum = st.rum_human
traffic_md = (
f"**真实访问(估)**:约 **{estimate}** 次首页打开\n"
f"(已知浏览器 UA 打开 `/`;SPA Hash 路由不计路径;不含爬虫)\n\n"
f"**RUM 页面浏览**{rum}"
+ (f" + {st.rum_other} 其他" if st.rum_other else "")
+ "\n"
f"**边缘总量**{st.all_req} 请求 / {st.all_vis} visits / {fmt_bytes(st.bytes)}\n"
f"**非人类首页命中**{st.other_home_vis} visits"
)
wf_lines = [_fmt_workflow_line(w) for w in extras.workflows]
if extras.gitea_note:
wf_lines.append(f"- _{extras.gitea_note}_")
ok_deploys = [p for p in extras.deploys if p.status == "success"]
fail_deploys = [p for p in extras.deploys if p.status != "success"]
if extras.deploys:
deploy_md = (
f"**Pages 生产部署**:成功 **{len(ok_deploys)}**"
+ (f" / 异常 {len(fail_deploys)}" if fail_deploys else "")
+ "\n"
)
for p in extras.deploys[:5]:
mark = "[ok]" if p.status == "success" else "[!]"
deploy_md += (
f"- {mark} {p.created_on.strftime('%H:%M')} "
f"`{p.commit or '?'}` {p.status}\n"
)
else:
deploy_md = "**Pages 生产部署**:当日无新部署(数据未变则 refresh 会跳过 deploy\n"
refresh_md = (
f"**站点版本**`v{extras.site_version}`\n\n"
f"**自动刷新(Gitea Actions**\n"
+ "\n".join(wf_lines)
+ "\n\n"
+ deploy_md.strip()
)
any_refresh_ok = any(w.ok_count > 0 for w in extras.workflows) or bool(ok_deploys)
any_refresh_fail = any(w.fail_count > 0 for w in extras.workflows) or bool(
fail_deploys
)
if any_refresh_fail:
header_color = "red"
elif estimate > 0 or any_refresh_ok:
header_color = "blue"
else:
header_color = "grey"
elements: list[dict] = [
{
"tag": "div",
"text": {"tag": "lark_md", "content": "**访问**\n" + traffic_md},
},
{
"tag": "div",
"text": {"tag": "lark_md", "content": "**自动更新**\n" + refresh_md},
},
{
"tag": "div",
"text": {
"tag": "lark_md",
"content": (
f"**首页地区 Top**{countries}\n"
f"**状态码**{status_line}\n"
f"_模块:英雄 / 排行 / 主播 / 走势 / 物品 / 版本 / 机制_"
),
},
},
{
"tag": "note",
"elements": [
{
"tag": "plain_text",
"content": (
f"{HOST} · Free 档无 BotScore · "
f"refresh=web-daily/weekly/patch · {now}"
),
}
],
},
{
"tag": "action",
"actions": [
{
"tag": "button",
"text": {"tag": "plain_text", "content": "打开站点"},
"type": "primary",
"url": SITE_URL,
},
{
"tag": "button",
"text": {"tag": "plain_text", "content": "Gitea Actions"},
"type": "default",
"url": f"{gitea_base()}/{GITEA_OWNER}/{GITEA_REPO}/actions",
},
],
},
]
return {
"msg_type": "interactive",
"card": {
"header": {
"title": {
"tag": "plain_text",
"content": f"上分帝 Web 日报 · {st.day.isoformat()}",
},
"template": header_color,
},
"elements": elements,
},
}
def post_feishu(payload: dict) -> dict:
url = feishu_url()
resp = http_json(
url,
method="POST",
body=payload,
headers={"Content-Type": "application/json"},
)
assert isinstance(resp, dict)
return resp
def parse_day(s: str | None) -> date:
if not s:
return (datetime.now(CST) - timedelta(days=1)).date()
return date.fromisoformat(s)
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--day", help="YYYY-MM-DD (default: yesterday CST)")
ap.add_argument(
"--dry-run",
action="store_true",
help="print JSON card only, do not call Feishu",
)
args = ap.parse_args(argv)
day = parse_day(args.day)
zid = zone_id()
st = fetch_day(zid, day)
workflows, gitea_note = fetch_workflow_statuses(day)
deploys = fetch_pages_deploys(day)
extras = DigestExtras(
site_version=read_site_version(),
workflows=workflows,
deploys=deploys,
gitea_note=gitea_note,
)
payload = build_card(st, extras)
print(
f"{HOST} {day}: human_home_vis={st.human_home_vis} "
f"rum_human={st.rum_human} all_req={st.all_req} "
f"deploys={len(deploys)} "
f"wf_ok={sum(w.ok_count for w in workflows)} "
f"wf_fail={sum(w.fail_count for w in workflows)} "
f"v={extras.site_version}",
flush=True,
)
if args.dry_run:
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0
resp = post_feishu(payload)
code = resp.get("code", resp.get("StatusCode"))
if code not in (0, None) and code != 200:
print(
"Feishu error:",
json.dumps(resp, ensure_ascii=False)[:800],
file=sys.stderr,
)
return 1
print("feishu ok:", json.dumps(resp, ensure_ascii=False)[:200])
return 0
if __name__ == "__main__":
raise SystemExit(main())
+258
View File
@@ -0,0 +1,258 @@
"""Orchestrate Climperor web data refresh tiers, then optional OSS + deploy.
Tiers (see AGENTS.md / Gitea Actions workflows):
daily OpenDota stats, leaderboards, matches, pro matches, streamers,
streamer live probe; patch check
(live badge still trails reality by up to a day truly real-time
would need a dedicated higher-frequency workflow, e.g. 15-min)
weekly STRATZ meta, hero items, items_meta, item_fears
patch patch list check; on has_new fetch details + version-linked scripts
all weekly then daily (patch check included in daily/patch)
Usage:
python refresh_web.py --tier patch --skip-deploy --skip-oss
python refresh_web.py --tier daily --skip-deploy
python refresh_web.py --tier weekly
python refresh_web.py --tier all --dry-run
"""
from __future__ import annotations
import argparse
import hashlib
import json
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
# Paths whose content change should trigger deploy / OSS.
DATA_WATCH = [
ROOT / "data" / "hero_stats.json",
ROOT / "data" / "leaderboards.json",
ROOT / "data" / "hero_matches.json",
ROOT / "data" / "pro_matches.json",
ROOT / "data" / "streamers.json",
ROOT / "data" / "stratz_hero_meta.json",
ROOT / "data" / "stratz_matchup_tops.json",
ROOT / "data" / "hero_items.json",
ROOT / "data" / "items_meta.json",
ROOT / "data" / "hero_item_fears.json",
ROOT / "data" / "patches.json",
ROOT / "data" / "hero_abilities.json",
ROOT / "data" / "item_shop.json",
]
ASSET_DIRS = [
ROOT / "assets" / "item_icons",
ROOT / "assets" / "ability_icons",
ROOT / "assets" / "item_cat_icons",
ROOT / "assets" / "hero_portraits",
ROOT / "assets" / "streamer_avatars",
ROOT / "assets" / "streamer_videos",
]
def _file_digest(path: Path) -> str | None:
if not path.is_file():
return None
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def _dir_digest(path: Path) -> str | None:
if not path.is_dir():
return None
h = hashlib.sha256()
for p in sorted(path.rglob("*")):
if not p.is_file():
continue
rel = p.relative_to(path).as_posix().encode()
h.update(rel)
h.update(b"\0")
digest = _file_digest(p)
if digest:
h.update(digest.encode())
return h.hexdigest()
def snapshot() -> dict[str, str | None]:
out: dict[str, str | None] = {}
for p in DATA_WATCH:
out[str(p.relative_to(ROOT))] = _file_digest(p)
for d in ASSET_DIRS:
out[str(d.relative_to(ROOT)) + "/"] = _dir_digest(d)
return out
def diff_snapshots(before: dict[str, str | None], after: dict[str, str | None]) -> tuple[bool, bool]:
"""Return (data_changed, assets_changed)."""
data_changed = False
assets_changed = False
keys = set(before) | set(after)
for k in keys:
if before.get(k) == after.get(k):
continue
if k.endswith("/"):
assets_changed = True
else:
data_changed = True
return data_changed, assets_changed
def run_script(script: str, *args: str, dry_run: bool = False) -> None:
cmd = [sys.executable, str(ROOT / script), *args]
print(f"+ {' '.join(cmd)}", flush=True)
if dry_run:
return
subprocess.run(cmd, cwd=str(ROOT), check=True)
def patch_check() -> dict:
"""Always hits the network (read-only list); safe under --dry-run."""
cmd = [sys.executable, str(ROOT / "fetch_patches.py"), "--check"]
print(f"+ {' '.join(cmd)}", flush=True)
proc = subprocess.run(
cmd,
cwd=str(ROOT),
check=True,
capture_output=True,
text=True,
encoding="utf-8",
)
line = (proc.stdout or "").strip().splitlines()[-1] if (proc.stdout or "").strip() else ""
if not line:
raise RuntimeError("fetch_patches.py --check produced empty stdout")
result = json.loads(line)
print(json.dumps(result, ensure_ascii=False), flush=True)
return result
def run_patch_linked(*, dry_run: bool = False) -> None:
run_script("fetch_patches.py", dry_run=dry_run)
run_script("fetch_hero_abilities.py", "--icons", dry_run=dry_run)
run_script("fetch_item_shop.py", dry_run=dry_run)
run_script("fetch_items_meta.py", dry_run=dry_run)
run_script("item_fears.py", dry_run=dry_run)
def run_daily(*, dry_run: bool = False) -> bool:
"""Return True if patch-linked fetch ran."""
run_script("fetch_hero_stats.py", dry_run=dry_run)
run_script("fetch_leaderboards.py", dry_run=dry_run)
run_script("fetch_hero_matches.py", "--source", "league", dry_run=dry_run)
run_script("fetch_pro_matches.py", dry_run=dry_run)
# Soft-fail Douyin enrichment (script itself exits 0; keep previous values on miss).
run_script("fetch_streamers.py", dry_run=dry_run)
# Soft-fail live probe (exits 0; probe failures keep previous is_live).
run_script("fetch_streamer_live.py", dry_run=dry_run)
check = patch_check()
if check.get("has_new"):
print(
f"new patches detected: {check.get('new_versions')}; running version-linked fetch",
flush=True,
)
run_patch_linked(dry_run=dry_run)
return True
print("no new patches", flush=True)
return False
def run_weekly(*, dry_run: bool = False) -> None:
run_script("fetch_stratz_meta.py", dry_run=dry_run)
run_script("fetch_hero_items.py", dry_run=dry_run)
run_script("fetch_items_meta.py", dry_run=dry_run)
run_script("fetch_item_counter_stats.py", "--soft-fail", dry_run=dry_run)
run_script("item_fears.py", dry_run=dry_run)
def run_patch_tier(*, dry_run: bool = False) -> bool:
"""Return True if version-linked fetch ran (or would run under dry-run)."""
check = patch_check()
if not check.get("has_new"):
print("no new patches; skip detail fetch", flush=True)
return False
print(
f"new patches detected: {check.get('new_versions')}; running version-linked fetch",
flush=True,
)
run_patch_linked(dry_run=dry_run)
return True
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument(
"--tier",
choices=("daily", "weekly", "patch", "all"),
required=True,
help="which refresh tier to run",
)
ap.add_argument("--dry-run", action="store_true", help="print commands only")
ap.add_argument("--skip-deploy", action="store_true", help="do not call deploy_relations.py")
ap.add_argument("--skip-oss", action="store_true", help="do not upload static assets to OSS")
ap.add_argument(
"--force-deploy",
action="store_true",
help="deploy even when snapshot digests are unchanged",
)
args = ap.parse_args()
before = snapshot() if not args.dry_run else {}
did_work = False
if args.tier in ("weekly", "all"):
run_weekly(dry_run=args.dry_run)
did_work = True
if args.tier in ("daily", "all"):
if run_daily(dry_run=args.dry_run):
did_work = True
else:
did_work = True # daily fetch scripts still ran
if args.tier == "patch":
if run_patch_tier(dry_run=args.dry_run):
did_work = True
if args.dry_run:
print("dry-run complete (no deploy/oss)", flush=True)
return
after = snapshot()
data_changed, assets_changed = diff_snapshots(before, after)
print(
f"changes: data={data_changed} assets={assets_changed} "
f"force_deploy={args.force_deploy} did_work={did_work}",
flush=True,
)
if assets_changed and not args.skip_oss:
run_script("_oss_static_assets.py", "upload")
elif assets_changed and args.skip_oss:
print("assets changed but --skip-oss set; skipping OSS upload", flush=True)
should_deploy = args.force_deploy or data_changed or assets_changed
deployed = False
if should_deploy and not args.skip_deploy:
run_script("deploy_relations.py")
deployed = True
elif should_deploy and args.skip_deploy:
print("deploy needed but --skip-deploy set; skipping", flush=True)
else:
print("no data/asset changes; skip deploy", flush=True)
summary = {
"ok": True,
"tier": args.tier,
"data_changed": data_changed,
"assets_changed": assets_changed,
"deployed": deployed,
"force_deploy": args.force_deploy,
}
print("REFRESH_SUMMARY " + json.dumps(summary, ensure_ascii=False), flush=True)
if __name__ == "__main__":
main()
+4
View File
@@ -0,0 +1,4 @@
# Extra deps for Web refresh / OSS upload (Gitea Actions + local refresh_web.py).
# CI also installs requirements.txt — fetch scripts import common (opencv/numpy)
# and fetch_patches portrait resize needs cv2.
oss2>=2.18
+38
View File
@@ -0,0 +1,38 @@
# Local / scheduled runner: inject both keyzoo secrets then post Feishu digest.
#
# Requires Keyzoo desktop daemon (MCP asset_exec). From an agent:
# run this script is not enough alone — use notify_site_traffic.py via
# dual asset_exec, or set env manually:
#
# $env:CLOUDFLARE_EMAIL = "..."
# $env:CLOUDFLARE_API_KEY = "..."
# $env:FEISHU_WEBHOOK_URL = "..."
# python notify_site_traffic.py
#
# Windows Task Scheduler example (after env is available in the task):
# Program: python
# Arguments: notify_site_traffic.py
# Start in: <repo>
# Trigger: Daily 09:00
param(
[string]$Day = "",
[switch]$DryRun
)
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $Root
$argsList = @()
if ($Day) { $argsList += @("--day", $Day) }
if ($DryRun) { $argsList += "--dry-run" }
if (-not $env:CLOUDFLARE_API_KEY -and -not $env:KEYZOO_ASSET_SECRET_GLOBAL_API_KEY) {
Write-Error "Missing Cloudflare API key in env (set CLOUDFLARE_* or run via keyzoo)."
}
if (-not $env:FEISHU_WEBHOOK_URL -and -not $env:KEYZOO_ASSET_SECRET_FEISHU_WEBHOOK_URL) {
Write-Error "Missing Feishu webhook in env (set FEISHU_WEBHOOK_URL or run via keyzoo)."
}
python notify_site_traffic.py @argsList
+991
View File
@@ -0,0 +1,991 @@
"""Local dev server for the Climperor web site (web/relations/).
Usage:
python serve_relations.py
python serve_relations.py --port 8765
Hero relations, rankings, streamers, mechanics, items, patches read-only browser UI.
Edit data/*.json directly, then refresh the page.
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
import mimetypes
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
import urllib.error
from shared.grid import ATTR_ORDER, hero_table
from shared.hero_tags import TAG_ORDER, tags_for_hero
from shared.http_utils import http_bytes
from shared.paths import (
ABILITY_ICONS,
ABILITY_VIDEOS,
ATTR_ICONS,
DATA,
HERO_PORTRAITS,
ITEM_CAT_ICONS,
ITEM_ICONS,
RANK_ICONS,
ROOT,
STREAMER_AVATARS,
STREAMER_VIDEOS,
TEMPLATES_CDN,
UI_ICONS,
WEB_FRONTEND,
)
from shared.relations import DEFAULT_RELATIONS, load_relations
from mechanic_tags import QUERY_MECHANIC_ORDER, mechanic_query_payload
WEB_DIR = WEB_FRONTEND
GRID_ORDER_PATH = DATA / "hero_grid_order.json"
HERO_ITEMS_PATH = DATA / "hero_items.json"
HERO_STATS_PATH = DATA / "hero_stats.json"
HERO_MATCHES_PATH = DATA / "hero_matches.json"
HERO_ITEM_FEARS_PATH = DATA / "hero_item_fears.json"
HERO_ABILITIES_PATH = DATA / "hero_abilities.json"
ITEM_SHOP_PATH = DATA / "item_shop.json"
ITEMS_META_PATH = DATA / "items_meta.json"
PATCHES_PATH = DATA / "patches.json"
LEADERBOARDS_PATH = DATA / "leaderboards.json"
PRO_MATCHES_PATH = DATA / "pro_matches.json"
STREAMERS_PATH = DATA / "streamers.json"
STRATZ_HERO_META_PATH = DATA / "stratz_hero_meta.json"
STRATZ_MATCHUP_TOPS_PATH = DATA / "stratz_matchup_tops.json"
ATTR_COLS = {"str": 6, "agi": 6, "int": 6, "all": 4}
ATTR_LABELS = {"str": "力量", "agi": "敏捷", "int": "智力", "all": "全才"}
ABILITY_ICON_URL = (
"https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/abilities/{key}.png"
)
# Shared innate badge (official-style gold droplet); UI falls back here when CDN 404s.
INNATE_ICON_NAME = "innate"
# Bundled talent-tree trigger icon (not fetched from CDN).
TALENT_TREE_ICON_NAME = "talent_tree"
def load_hero_items() -> dict:
"""Cached OpenDota item popularity (see fetch_hero_items.py)."""
empty = {"meta": {}, "items": {}, "by_hero": {}}
if not HERO_ITEMS_PATH.is_file():
return empty
try:
raw = json.loads(HERO_ITEMS_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return empty
return {
"meta": dict(raw.get("meta") or {}),
"items": dict(raw.get("items") or {}),
"by_hero": dict(raw.get("by_hero") or {}),
}
def load_hero_matches() -> dict:
"""Cached recent matches + builds (see fetch_hero_matches.py). Preview only."""
empty: dict = {"meta": {}, "items": {}, "by_hero": {}}
if not HERO_MATCHES_PATH.is_file():
return empty
try:
raw = json.loads(HERO_MATCHES_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return empty
by_hero = raw.get("by_hero")
if not isinstance(by_hero, dict):
by_hero = {}
return {
"meta": dict(raw.get("meta") or {}),
"items": dict(raw.get("items") or {}),
"by_hero": by_hero,
}
def load_hero_stats() -> dict:
"""Cached OpenDota bracket pick/win (see fetch_hero_stats.py). Preview only."""
empty: dict = {
"fetched_at": None,
"source": "opendota",
"attribution": "https://www.opendota.com",
"window_days": 7,
"window_note": None,
"window_label_zh": "近约 7 天公开对局",
"brackets": [],
"totals": {},
"by_hero": {},
}
if not HERO_STATS_PATH.is_file():
return empty
try:
raw = json.loads(HERO_STATS_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return empty
try:
window_days = int(raw.get("window_days") or 7)
except (TypeError, ValueError):
window_days = 7
window_days = max(1, window_days)
label = raw.get("window_label_zh") or f"近约 {window_days} 天公开对局"
return {
"fetched_at": raw.get("fetched_at"),
"source": raw.get("source") or "opendota",
"attribution": raw.get("attribution") or "https://www.opendota.com",
"window_days": window_days,
"window_note": raw.get("window_note"),
"window_label_zh": label,
"brackets": list(raw.get("brackets") or []),
"totals": dict(raw.get("totals") or {}),
"by_hero": dict(raw.get("by_hero") or {}),
}
def load_hero_item_fears() -> dict:
"""Cached rule-based items that counter each hero (see item_fears.py)."""
empty = {"meta": {}, "items": {}, "by_hero": {}}
if not HERO_ITEM_FEARS_PATH.is_file():
return empty
try:
raw = json.loads(HERO_ITEM_FEARS_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return empty
return {
"meta": dict(raw.get("meta") or {}),
"items": dict(raw.get("items") or {}),
"by_hero": dict(raw.get("by_hero") or {}),
}
def load_items_meta_index() -> dict:
"""key → slim item row (desc/tags/cost) for hero-page inspect lookups."""
out: dict[str, dict] = {}
if not ITEMS_META_PATH.is_file():
return out
try:
raw = json.loads(ITEMS_META_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return out
for row in (raw.get("items") or {}).values():
if not isinstance(row, dict) or not row.get("key"):
continue
key = str(row["key"])
out[key] = {
"key": key,
"name_loc": row.get("name_loc") or row.get("dname") or key,
"cost": row.get("cost"),
"desc_loc": row.get("desc_loc") or "",
"tags": list(row.get("tags") or []),
}
return out
def load_item_shop() -> dict:
"""Full shop catalog for the Items page (see fetch_item_shop.py)."""
empty = {
"meta": {},
"basic": {"sections": []},
"upgraded": {"sections": []},
"items": {},
}
if not ITEM_SHOP_PATH.is_file():
return empty
try:
raw = json.loads(ITEM_SHOP_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return empty
items = dict(raw.get("items") or {})
# Merge mechanism tags / short desc from items_meta when present.
if ITEMS_META_PATH.is_file():
try:
meta = json.loads(ITEMS_META_PATH.read_text(encoding="utf-8"))
by_key = {}
for row in (meta.get("items") or {}).values():
if isinstance(row, dict) and row.get("key"):
by_key[row["key"]] = row
for key, row in items.items():
m = by_key.get(key)
if not m:
continue
row = dict(row)
row["tags"] = list(m.get("tags") or [])
row["desc_loc"] = m.get("desc_loc") or ""
items[key] = row
except (OSError, json.JSONDecodeError):
pass
return {
"meta": dict(raw.get("meta") or {}),
"basic": dict(raw.get("basic") or {"sections": []}),
"upgraded": dict(raw.get("upgraded") or {"sections": []}),
"items": items,
}
def load_patches() -> dict:
"""Patch list + per-patch details + id lookup (see fetch_patches.py).
Returns {patches, lookup, details}; empty-shaped when the file is missing
so the web UI degrades to "no data" instead of crashing.
"""
empty = {"patches": [], "lookup": {}, "details": {}}
if not PATCHES_PATH.is_file():
return empty
try:
raw = json.loads(PATCHES_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return empty
return {
"patches": list(raw.get("patches") or []),
"lookup": dict(raw.get("lookup") or {}),
"details": dict(raw.get("details") or {}),
}
def load_leaderboards() -> dict:
"""Valve Immortal division Top 100 (see fetch_leaderboards.py). Preview only."""
empty: dict = {
"fetched_at": None,
"source": "valve",
"attribution": "https://www.dota2.com/leaderboards",
"note": None,
"default_region": "china",
"region_order": ["china", "europe", "americas", "se_asia"],
"regions": {},
}
if not LEADERBOARDS_PATH.is_file():
return empty
try:
raw = json.loads(LEADERBOARDS_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return empty
if not isinstance(raw, dict):
return empty
regions = raw.get("regions") if isinstance(raw.get("regions"), dict) else {}
order = raw.get("region_order")
if not isinstance(order, list) or not order:
order = list(empty["region_order"])
return {
"fetched_at": raw.get("fetched_at"),
"source": raw.get("source") or "valve",
"attribution": raw.get("attribution") or empty["attribution"],
"note": raw.get("note"),
"default_region": raw.get("default_region") or "china",
"region_order": [str(x) for x in order],
"regions": regions,
}
def load_pro_matches() -> dict:
"""Pro-player recent matches (see fetch_pro_matches.py). Preview only."""
empty: dict = {
"meta": {},
"items": {},
"pros": {},
"by_pro": {},
"by_hero": {},
}
if not PRO_MATCHES_PATH.is_file():
return empty
try:
raw = json.loads(PRO_MATCHES_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return empty
if not isinstance(raw, dict):
return empty
return {
"meta": dict(raw.get("meta") or {}),
"items": dict(raw.get("items") or {}),
"pros": dict(raw.get("pros") or {}),
"by_pro": dict(raw.get("by_pro") or {}),
"by_hero": dict(raw.get("by_hero") or {}),
}
def load_streamers() -> dict:
"""Manual streamer directory + Douyin profile enrichment (fetch_streamers.py).
Web主播only do not merge into relations/heroes or recommend.
"""
empty: dict = {
"fetched_at": None,
"source": "manual+douyin",
"platform_meta": {
"douyin": {
"label_zh": "抖音",
"icon": "ui-icon/platform_douyin.png",
}
},
"streamers": [],
}
if not STREAMERS_PATH.is_file():
return empty
try:
raw = json.loads(STREAMERS_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return empty
if not isinstance(raw, dict):
return empty
rows = raw.get("streamers")
if not isinstance(rows, list):
rows = []
platform_meta = raw.get("platform_meta")
if not isinstance(platform_meta, dict):
platform_meta = dict(empty["platform_meta"])
else:
platform_meta = {
**empty["platform_meta"],
**{k: v for k, v in platform_meta.items() if isinstance(v, dict)},
}
return {
"fetched_at": raw.get("fetched_at"),
"source": raw.get("source") or "manual+douyin",
"platform_meta": platform_meta,
"streamers": [r for r in rows if isinstance(r, dict)],
}
def load_stratz_hero_meta() -> dict:
"""STRATZ weekly WR/pick by bracket + positions (see fetch_stratz_meta.py). Web only."""
empty: dict = {
"fetched_at": None,
"source": "stratz",
"attribution": "https://stratz.com",
"weeks_take": 0,
"window_label_zh": None,
"brackets": [],
"positions": [],
"bracket_position_note": None,
"totals": {},
"by_hero": {},
"meta_board": {},
}
if not STRATZ_HERO_META_PATH.is_file():
return empty
try:
raw = json.loads(STRATZ_HERO_META_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return empty
if not isinstance(raw, dict):
return empty
return {
"fetched_at": raw.get("fetched_at"),
"source": raw.get("source") or "stratz",
"attribution": raw.get("attribution") or empty["attribution"],
"weeks_take": raw.get("weeks_take") or 0,
"window_label_zh": raw.get("window_label_zh"),
"brackets": list(raw.get("brackets") or []),
"positions": list(raw.get("positions") or []),
"bracket_position_note": raw.get("bracket_position_note"),
"totals": dict(raw.get("totals") or {}),
"by_hero": dict(raw.get("by_hero") or {}),
"meta_board": dict(raw.get("meta_board") or {}),
}
def load_stratz_matchup_tops() -> dict:
"""STRATZ vs/with top lists per hero (see fetch_stratz_meta.py). Web only."""
empty: dict = {
"fetched_at": None,
"source": "stratz",
"attribution": "https://stratz.com",
"take": 0,
"match_limit": 0,
"note": None,
"by_hero": {},
}
if not STRATZ_MATCHUP_TOPS_PATH.is_file():
return empty
try:
raw = json.loads(STRATZ_MATCHUP_TOPS_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return empty
if not isinstance(raw, dict):
return empty
return {
"fetched_at": raw.get("fetched_at"),
"source": raw.get("source") or "stratz",
"attribution": raw.get("attribution") or empty["attribution"],
"take": raw.get("take") or 0,
"match_limit": raw.get("match_limit") or 0,
"note": raw.get("note"),
"by_hero": dict(raw.get("by_hero") or {}),
}
def load_hero_abilities() -> dict:
"""Slim per-hero abilities / Aghs upgrades / talents for the web hero pane."""
empty = {"meta": {}, "by_hero": {}}
if not HERO_ABILITIES_PATH.is_file():
return empty
try:
raw = json.loads(HERO_ABILITIES_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return empty
by_hero: dict[str, dict] = {}
for key, cell in (raw.get("by_hero") or {}).items():
if not isinstance(cell, dict):
continue
abs_out = []
for ab in cell.get("abilities") or []:
if not isinstance(ab, dict) or not ab.get("key"):
continue
specials = [
{"label": str(s["label"]), "value": str(s["value"])}
for s in (ab.get("specials") or [])
if isinstance(s, dict) and s.get("label") and s.get("value")
]
abs_out.append(
{
"key": ab["key"],
"name_loc": ab.get("name_loc") or ab["key"],
"desc_loc": ab.get("desc_loc") or "",
"shard_loc": ab.get("shard_loc") or "",
"scepter_loc": ab.get("scepter_loc") or "",
"has_shard": bool(ab.get("has_shard")),
"has_scepter": bool(ab.get("has_scepter")),
"granted_by_shard": bool(ab.get("granted_by_shard")),
"granted_by_scepter": bool(ab.get("granted_by_scepter")),
"dispellable": ab.get("dispellable") or "none",
"is_innate": bool(ab.get("is_innate")),
"target_label": ab.get("target_label") or "",
"affects_label": ab.get("affects_label") or "",
"damage_label": ab.get("damage_label") or "",
"immunity_label": ab.get("immunity_label") or "",
"cast_range": ab.get("cast_range") or "",
"cast_point": ab.get("cast_point") or "",
"channel_time": ab.get("channel_time") or "",
"cooldown": ab.get("cooldown") or "",
"mana_cost": ab.get("mana_cost") or "",
"specials": specials,
"lore_loc": ab.get("lore_loc") or "",
"tags": [
str(t)
for t in (ab.get("tags") or [])
if isinstance(t, str) and t in QUERY_MECHANIC_ORDER
],
}
)
talents_out = []
for tal in cell.get("talents") or []:
if not isinstance(tal, dict) or not tal.get("key"):
continue
talents_out.append(
{
"key": tal["key"],
"name_loc": tal.get("name_loc") or tal["key"],
"level": int(tal.get("level") or 0),
"side": tal.get("side") or "left",
}
)
by_hero[str(key)] = {
"abilities": abs_out,
"talents": talents_out,
}
return {
"meta": dict(raw.get("meta") or {}),
"by_hero": by_hero,
}
def ensure_ability_icon(key: str) -> Path | None:
"""Return local ability icon path, downloading from Steam CDN on miss.
``innate.png`` / ``talent_tree.png`` are bundled shared badges (not fetched
from CDN). Many innate ability keys 404 on Steam ``dota_react/abilities``;
the web UI falls back to ``innate.png`` when a per-ability icon is missing.
"""
if not key or "/" in key or "\\" in key or ".." in key:
return None
ABILITY_ICONS.mkdir(parents=True, exist_ok=True)
dest = ABILITY_ICONS / f"{key}.png"
if dest.is_file() and dest.stat().st_size >= 32:
return dest
if key in (INNATE_ICON_NAME, TALENT_TREE_ICON_NAME):
return None
try:
data = http_bytes(ABILITY_ICON_URL.format(key=key), timeout=30)
if not data or len(data) < 32:
return None
dest.write_bytes(data)
return dest
except (urllib.error.URLError, TimeoutError, OSError):
return None
def load_grid_order() -> dict[str, list[str]]:
"""Column membership + order for the pick grid (see data/hero_grid_order.json)."""
if not GRID_ORDER_PATH.is_file():
return {}
raw = json.loads(GRID_ORDER_PATH.read_text(encoding="utf-8"))
out: dict[str, list[str]] = {}
for attr in ATTR_ORDER:
keys = raw.get(attr) or []
if isinstance(keys, list):
out[attr] = [str(k) for k in keys if isinstance(k, str) and not k.startswith("_")]
return out
def build_payload() -> dict:
heroes = hero_table()
by_key = {}
slim = []
for h in heroes:
roles = list(h.get("roles") or [])
tags = list(h.get("tags") or []) or tags_for_hero(h["key"], roles)
row = {
"id": int(h["id"]),
"key": h["key"],
"name": h.get("name") or h["key"],
"name_loc": h.get("name_loc") or h["key"],
"aliases": list(h.get("aliases") or []),
"abbr": [str(a).lower() for a in (h.get("abbr") or []) if str(a).strip()],
"attr": h.get("attr") or "all",
"roles": roles,
"tags": tags,
}
# Level-1 strip + combat stats (from OpenDota via fetch_cdn_templates.py).
for key in (
"base_str",
"str_gain",
"base_agi",
"agi_gain",
"base_int",
"int_gain",
"health",
"mana",
"health_regen",
"mana_regen",
"armor",
"damage_min",
"damage_max",
"move_speed",
"attack_range",
"attack_rate",
"projectile_speed",
"magic_resist",
"turn_rate",
"vision_day",
"vision_night",
):
if key in h and h[key] is not None:
row[key] = h[key]
slim.append(row)
by_key[row["key"]] = row
grid = load_grid_order()
by_attr: dict[str, list[dict]] = {a: [] for a in ATTR_ORDER}
placed: set[str] = set()
for attr in ATTR_ORDER:
for key in grid.get(attr) or []:
row = by_key.get(key)
if row is None or key in placed:
continue
by_attr[attr].append(row)
placed.add(key)
# Heroes missing from the order file → append under their primary attr.
rest = [r for r in slim if r["key"] not in placed]
rest.sort(key=lambda r: r["id"])
for row in rest:
attr = row["attr"] if row["attr"] in by_attr else "all"
by_attr[attr].append(row)
slim_ordered: list[dict] = []
for attr in ATTR_ORDER:
slim_ordered.extend(by_attr[attr])
rel = load_relations()
items = load_hero_items()
hero_stats = load_hero_stats()
hero_matches = load_hero_matches()
fears = load_hero_item_fears()
abilities = load_hero_abilities()
shop = load_item_shop()
items_meta = load_items_meta_index()
patches_data = load_patches()
leaderboards = load_leaderboards()
pro_matches = load_pro_matches()
streamers = load_streamers()
stratz_meta = load_stratz_hero_meta()
stratz_matchups = load_stratz_matchup_tops()
# Attach craft graph from shop catalog when available.
for key, row in (shop.get("items") or {}).items():
if not isinstance(row, dict):
continue
cell = items_meta.setdefault(key, {"key": key})
cell.setdefault("name_loc", row.get("name_loc") or key)
if row.get("cost") is not None:
cell["cost"] = row.get("cost")
if row.get("desc_loc"):
cell["desc_loc"] = row.get("desc_loc")
cell["components"] = list(row.get("components") or [])
cell["builds_into"] = list(row.get("builds_into") or [])
return {
"heroes": slim_ordered,
"by_attr": by_attr,
"attr_order": list(ATTR_ORDER),
"attr_cols": ATTR_COLS,
"attr_labels": ATTR_LABELS,
"tag_order": list(TAG_ORDER),
"relations": rel,
"hero_items": items,
"hero_stats": hero_stats,
"hero_matches": hero_matches,
"hero_item_fears": fears,
"hero_abilities": abilities,
"item_shop": shop,
"items_meta": items_meta,
"patches": patches_data.get("patches") or [],
"patch_lookup": patches_data.get("lookup") or {},
"patch_details": patches_data.get("details") or {},
"leaderboards": leaderboards,
"pro_matches": pro_matches,
"streamers": streamers,
"stratz_hero_meta": stratz_meta,
"stratz_matchup_tops": stratz_matchups,
"mechanic_query": mechanic_query_payload(),
"meta": {
"relations_path": str(DEFAULT_RELATIONS.relative_to(ROOT)).replace("\\", "/"),
"grid_order_path": str(GRID_ORDER_PATH.relative_to(ROOT)).replace("\\", "/"),
"hero_items_path": str(HERO_ITEMS_PATH.relative_to(ROOT)).replace("\\", "/"),
"hero_stats_path": str(HERO_STATS_PATH.relative_to(ROOT)).replace("\\", "/"),
"hero_matches_path": str(HERO_MATCHES_PATH.relative_to(ROOT)).replace(
"\\", "/"
),
"hero_item_fears_path": str(HERO_ITEM_FEARS_PATH.relative_to(ROOT)).replace(
"\\", "/"
),
"hero_abilities_path": str(HERO_ABILITIES_PATH.relative_to(ROOT)).replace(
"\\", "/"
),
"item_shop_path": str(ITEM_SHOP_PATH.relative_to(ROOT)).replace("\\", "/"),
"leaderboards_path": str(LEADERBOARDS_PATH.relative_to(ROOT)).replace(
"\\", "/"
),
"streamers_path": str(STREAMERS_PATH.relative_to(ROOT)).replace("\\", "/"),
"stratz_hero_meta_path": str(STRATZ_HERO_META_PATH.relative_to(ROOT)).replace(
"\\", "/"
),
"stratz_matchup_tops_path": str(
STRATZ_MATCHUP_TOPS_PATH.relative_to(ROOT)
).replace("\\", "/"),
"source": (rel.get("meta") or {}).get("source"),
"counters": len(rel.get("counters") or []),
"synergies": len(rel.get("synergies") or []),
"item_heroes": len(items.get("by_hero") or {}),
"stats_heroes": len(hero_stats.get("by_hero") or {}),
"match_heroes": len(hero_matches.get("by_hero") or {}),
"fear_heroes": len(fears.get("by_hero") or {}),
"ability_heroes": len(abilities.get("by_hero") or {}),
"shop_items": len(shop.get("items") or {}),
"patches": len(patches_data.get("patches") or []),
"patch_details": len(patches_data.get("details") or {}),
"leaderboard_regions": len(leaderboards.get("regions") or {}),
"pro_match_players": len(pro_matches.get("by_pro") or {}),
"pro_match_heroes": len(pro_matches.get("by_hero") or {}),
"streamers": len(streamers.get("streamers") or []),
"stratz_meta_heroes": len(stratz_meta.get("by_hero") or {}),
"stratz_matchup_heroes": len(stratz_matchups.get("by_hero") or {}),
},
}
class Handler(BaseHTTPRequestHandler):
server_version = "ClimperorRelations/2.0"
def log_message(self, fmt: str, *args) -> None:
print(f"[relations] {self.address_string()} {fmt % args}")
def _send(self, code: int, body: bytes, content_type: str) -> None:
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.send_header(
"Content-Security-Policy",
"default-src 'self'; "
"img-src 'self' data: https://climperor.oss-cn-shanghai.aliyuncs.com; "
"style-src 'self' 'unsafe-inline'; "
"script-src 'self'; "
"media-src 'self' https://climperor.oss-cn-shanghai.aliyuncs.com",
)
self.end_headers()
self.wfile.write(body)
def _send_file(self, fpath: Path, content_type: str) -> None:
"""Stream a file with optional HTTP Range (needed for HTML5 video seek)."""
size = fpath.stat().st_size
range_hdr = self.headers.get("Range") or self.headers.get("range")
start, end = 0, size - 1
code = 200
if range_hdr and range_hdr.startswith("bytes=") and size > 0:
spec = range_hdr[len("bytes=") :].strip()
if "," not in spec:
left, _, right = spec.partition("-")
try:
if left == "" and right:
# suffix bytes: bytes=-N
suffix = int(right)
start = max(0, size - suffix)
else:
start = int(left) if left else 0
end = int(right) if right else size - 1
if start < 0 or end >= size or start > end:
self.send_response(416)
self.send_header("Content-Range", f"bytes */{size}")
self.end_headers()
return
code = 206
except ValueError:
start, end = 0, size - 1
code = 200
length = end - start + 1
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Accept-Ranges", "bytes")
self.send_header("Content-Length", str(length))
if code == 206:
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
self.send_header("Cache-Control", "no-store")
self.send_header(
"Content-Security-Policy",
"default-src 'self'; "
"img-src 'self' data: https://climperor.oss-cn-shanghai.aliyuncs.com; "
"style-src 'self' 'unsafe-inline'; "
"script-src 'self'; "
"media-src 'self' https://climperor.oss-cn-shanghai.aliyuncs.com",
)
self.end_headers()
with fpath.open("rb") as fh:
fh.seek(start)
remaining = length
while remaining > 0:
chunk = fh.read(min(1 << 20, remaining))
if not chunk:
break
self.wfile.write(chunk)
remaining -= len(chunk)
def _json(self, code: int, obj: object) -> None:
self._send(code, json.dumps(obj, ensure_ascii=False).encode("utf-8"),
"application/json; charset=utf-8")
def do_GET(self) -> None: # noqa: N802
path = urlparse(self.path).path
if path in ("/", "/index.html"):
index = WEB_DIR / "index.html"
if not index.is_file():
self._json(500, {"error": "web/relations/index.html missing"})
return
self._send(200, index.read_bytes(), "text/html; charset=utf-8")
return
if path in ("/api/data", "/data.json"):
self._json(200, build_payload())
return
if path == "/api/live-status":
# Local-dev stub for the Pages Function (edge probing runs in
# production only); the empty map keeps the UI on data.json is_live.
self._json(200, {"probed_at": None, "ttl": 300, "streamers": {}})
return
if path.startswith("/attr/"):
key = path[len("/attr/") :]
if key not in ("str.png", "agi.png", "int.png", "all.png"):
self._json(400, {"error": "bad attr icon"})
return
fpath = ATTR_ICONS / key
if not fpath.is_file():
self.send_error(404)
return
self._send(200, fpath.read_bytes(), "image/png")
return
if path.startswith("/rank/"):
key = path[len("/rank/") :]
allowed = {f"rank_icon_{i}.png" for i in range(1, 9)}
if key not in allowed:
self._json(400, {"error": "bad rank icon"})
return
fpath = RANK_ICONS / key
if not fpath.is_file():
self.send_error(404)
return
self._send(200, fpath.read_bytes(), "image/png")
return
if path.startswith("/ui-icon/"):
key = path[len("/ui-icon/") :]
allowed_ui = {
"cooldown.png",
"dota2_logo.png",
"dota2_logo_wordmark.png",
"platform_douyin.png",
}
if key not in allowed_ui:
self._json(400, {"error": "bad ui icon"})
return
fpath = UI_ICONS / key
if not fpath.is_file():
self.send_error(404)
return
self._send(200, fpath.read_bytes(), "image/png")
return
if path.startswith("/streamer-avatar/"):
key = path[len("/streamer-avatar/") :]
if "/" in key or "\\" in key or ".." in key:
self._json(400, {"error": "bad path"})
return
if not (key.endswith(".jpg") or key.endswith(".jpeg") or key.endswith(".png") or key.endswith(".webp")):
self._json(400, {"error": "bad path"})
return
fpath = STREAMER_AVATARS / key
if not fpath.is_file():
self.send_error(404)
return
ctype = mimetypes.guess_type(key)[0] or "image/jpeg"
self._send(200, fpath.read_bytes(), ctype)
return
if path.startswith("/streamer-video/"):
key = path[len("/streamer-video/") :]
if "/" in key or "\\" in key or ".." in key:
self._json(400, {"error": "bad path"})
return
if not (key.endswith(".mp4") or key.endswith(".webm")):
self._json(400, {"error": "bad path"})
return
if key.startswith("_"):
self.send_error(404)
return
fpath = STREAMER_VIDEOS / key
if not fpath.is_file():
self.send_error(404)
return
ctype = (
"video/webm" if key.endswith(".webm") else "video/mp4"
)
self._send_file(fpath, ctype)
return
if path.startswith("/portrait/") or path.startswith("/cdn/"):
prefix = "/portrait/" if path.startswith("/portrait/") else "/cdn/"
key = path[len(prefix) :]
if "/" in key or "\\" in key or not key.endswith(".png"):
self._json(400, {"error": "bad path"})
return
# Prefer official Heroes-page cards; fall back to match templates.
fpath = HERO_PORTRAITS / key
if not fpath.is_file():
fpath = TEMPLATES_CDN / key
if not fpath.is_file():
self.send_error(404)
return
self._send(200, fpath.read_bytes(), "image/png")
return
if path.startswith("/item-cat/"):
key = path[len("/item-cat/") :]
if "/" in key or "\\" in key or not key.endswith(".png"):
self._json(400, {"error": "bad path"})
return
if not key.startswith("itemcat_"):
self._json(400, {"error": "bad path"})
return
fpath = ITEM_CAT_ICONS / key
if not fpath.is_file():
self.send_error(404)
return
self._send(200, fpath.read_bytes(), "image/png")
return
if path.startswith("/item/"):
key = path[len("/item/") :]
if "/" in key or "\\" in key or not key.endswith(".png"):
self._json(400, {"error": "bad path"})
return
fpath = ITEM_ICONS / key
# All recipe scrolls share recipe.png on Steam CDN.
if not fpath.is_file() and key.startswith("recipe_"):
fpath = ITEM_ICONS / "recipe.png"
if not fpath.is_file():
self.send_error(404)
return
self._send(200, fpath.read_bytes(), "image/png")
return
if path.startswith("/ability/"):
key = path[len("/ability/") :]
if "/" in key or "\\" in key or not key.endswith(".png"):
self._json(400, {"error": "bad path"})
return
stem = key[: -len(".png")]
fpath = ensure_ability_icon(stem)
if fpath is None:
self.send_error(404)
return
self._send(200, fpath.read_bytes(), "image/png")
return
if path.startswith("/ability-video/"):
rel_path = path[len("/ability-video/") :]
parts = rel_path.split("/")
if len(parts) != 2:
self._json(400, {"error": "bad path"})
return
hero, fname = parts
if (
not hero
or ".." in hero
or "\\" in hero
or ".." in fname
or "\\" in fname
):
self._json(400, {"error": "bad path"})
return
if fname.endswith(".webm"):
ctype = "video/webm"
elif fname.endswith(".mp4"):
ctype = "video/mp4"
else:
self._json(400, {"error": "bad path"})
return
fpath = ABILITY_VIDEOS / hero / fname
if not fpath.is_file():
self.send_error(404)
return
self._send(200, fpath.read_bytes(), ctype)
return
rel = path.lstrip("/")
candidate = (WEB_DIR / rel).resolve()
if not str(candidate).startswith(str(WEB_DIR.resolve())) or not candidate.is_file():
self.send_error(404)
return
ctype = mimetypes.guess_type(str(candidate))[0] or "application/octet-stream"
self._send(200, candidate.read_bytes(), ctype)
def main() -> None:
ap = argparse.ArgumentParser(description="Preview qualitative hero relations (read-only)")
ap.add_argument("--port", type=int, default=8765)
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--no-browser", action="store_true")
args = ap.parse_args()
if not (WEB_DIR / "index.html").is_file():
raise SystemExit(f"missing UI: {WEB_DIR / 'index.html'}")
if not TEMPLATES_CDN.is_dir():
raise SystemExit("templates/cdn missing — run python fetch_cdn_templates.py")
httpd = ThreadingHTTPServer((args.host, args.port), Handler)
url = f"http://{args.host}:{args.port}/"
print(f"Climperor web: {url}")
print(f"edit JSON then refresh: {DEFAULT_RELATIONS}")
if not args.no_browser:
try:
webbrowser.open(url)
except Exception: # noqa: BLE001
pass
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nstopped")
httpd.server_close()
if __name__ == "__main__":
main()