Files
climperor/web/fetch_item_counter_stats.py
vosonandCursor b01552ee6e v0.5.84: matches origin filter, mobile gate, refresh reliability.
Ship Web refresh cache/lock, mobile demand gate, matches 职业/国服 filter, and related site updates through 0.5.84.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 18:31:55 +08:00

301 lines
9.3 KiB
Python

"""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.http_utils import write_json_atomic
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,
)
write_json_atomic(args.out, output)
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()