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>
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 444 B |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 558 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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主播,万分小鱼王! 上车+v:375874361",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 捕捞速度"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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("&", "&")
|
||||
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())
|
||||
@@ -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 = "";
|
||||
@@ -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 {
|
||||
@@ -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()
|
||||
@@ -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_]+\}")
|
||||
@@ -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
|
||||
],
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||