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>
1466 lines
45 KiB
Python
1466 lines
45 KiB
Python
"""Fetch recent OpenDota matches per hero with final items + skill builds.
|
|
|
|
Sources (switch with --source):
|
|
league GET /heroes/{id}/matches (pro/league-biased; fewer requests)
|
|
public Scan GET /publicMatches for the hero, then match details
|
|
both Merge both (dedupe by match_id); each row tagged origin
|
|
|
|
Kept rows (after merge, newest first, up to --limit):
|
|
- wins only
|
|
- public/ladder: Legend+ only (avg_rank_tier / rank_tier >= 50)
|
|
- league/pro: wins only (no medal filter)
|
|
|
|
Public region (--public-region, default china):
|
|
china Prefer Perfect World / 国服 clusters when scanning publicMatches;
|
|
fill --limit with China pubs first, then backfill other regions if short
|
|
any No cluster filter (global pubs)
|
|
|
|
China clusters: classic PW ids from odota cluster→region map (regions
|
|
12/13/17/18/20/25) plus modern CJK-heavy clusters observed in publicMatches
|
|
(413/414/415/417). Each row stores `cluster` when available.
|
|
|
|
Per match: final inventory + ability_upgrades_arr resolved to ability keys.
|
|
When OpenDota has parsed the match (`purchase_log`), also store per-slot
|
|
purchase times (seconds from game start; may be negative pre-game):
|
|
item_times / backpack_times / item_neutral_time.
|
|
Unparsed matches omit the log → times stay null (icons still shown).
|
|
|
|
Speed: --workers N concurrent match-detail fetches; stop once enough
|
|
accepted wins are collected. Optional OPENDOTA_API_KEY raises rate limits.
|
|
Heroes are still written sequentially (one JSON writer).
|
|
|
|
Incremental (default): refetch heroes missing from by_hero, or with fewer
|
|
than --limit kept matches. Use --force to refetch all.
|
|
|
|
Output: data/hero_matches.json (Climperor web site only; not used by recommend).
|
|
|
|
Usage:
|
|
python fetch_hero_matches.py --heroes antimage
|
|
python fetch_hero_matches.py --source both --public-region china --limit 10 --workers 6 --delay 0.05
|
|
python fetch_hero_matches.py --force
|
|
python fetch_hero_matches.py --enrich-item-times --heroes juggernaut
|
|
"""
|
|
|
|
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
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from datetime import datetime, timezone
|
|
|
|
from shared.grid import hero_table
|
|
from shared.http_utils import download_icons, http_json
|
|
from shared.paths import DATA, ITEM_ICONS
|
|
|
|
OPENDOTA = "https://api.opendota.com/api"
|
|
ABILITY_IDS_URL = (
|
|
"https://raw.githubusercontent.com/odota/dotaconstants/master/build/ability_ids.json"
|
|
)
|
|
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_matches.json"
|
|
# Max recent matches kept per hero after merge (league + public), newest first.
|
|
DEFAULT_LIMIT = 10
|
|
DEFAULT_WORKERS = 6
|
|
PUBLIC_PAGES_CAP = 80
|
|
# Prefer deeper scan when filtering to China (hero+cluster coincidence is rarer).
|
|
PUBLIC_PAGES_CAP_CHINA = 160
|
|
|
|
# Perfect World region ids (odota region.json / Valve regions.txt).
|
|
CHINA_REGIONS = frozenset({12, 13, 17, 18, 20, 25})
|
|
# Classic PW clusters (odota cluster.json → CHINA_REGIONS) + modern observed
|
|
# 国服 clusters in publicMatches (CJK-heavy; absent from outdated cluster.json).
|
|
# Exclude 410/412 (still mapped to US East) and other non-CJK 41x samples.
|
|
CHINA_CLUSTERS = frozenset(
|
|
{
|
|
221,
|
|
222,
|
|
223,
|
|
224,
|
|
225,
|
|
227,
|
|
231,
|
|
232,
|
|
235,
|
|
236,
|
|
413,
|
|
414,
|
|
415,
|
|
417,
|
|
}
|
|
)
|
|
|
|
# OpenDota lobby_type / game_mode filters for public ranked pubs (skip bots / turbo).
|
|
BOT_LOBBY_TYPES = frozenset({3, 4, 10}) # tutorial, coop_bots, local_bots
|
|
RANKED_LOBBY_TYPES = frozenset({5, 6, 7}) # ranked solo / team / legacy ranked
|
|
EXCLUDED_GAME_MODES = frozenset({23}) # Turbo
|
|
MIN_HUMAN_PLAYERS = 10
|
|
# Legend+ (传奇及以上): rank_tier = medal*10 + stars → legend floor is 50.
|
|
MIN_RANK_TIER = 50
|
|
# Over-fetch list candidates so detail+win/rank filters can still fill --limit.
|
|
CANDIDATE_MULTIPLIER = 8
|
|
|
|
|
|
def _int_field(row: dict, key: str) -> int | None:
|
|
raw = row.get(key)
|
|
if raw is None:
|
|
return None
|
|
try:
|
|
return int(raw)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def is_legend_plus_tier(tier: int | None) -> bool:
|
|
"""True if OpenDota rank_tier / avg_rank_tier is Legend medal or higher."""
|
|
if tier is None:
|
|
return False
|
|
try:
|
|
t = int(tier)
|
|
except (TypeError, ValueError):
|
|
return False
|
|
return t >= MIN_RANK_TIER
|
|
|
|
|
|
def hero_won_on_list_row(row: dict, hero_id: int | None = None) -> bool | None:
|
|
"""Infer win from list meta when radiant_win (+ slot or team) is present."""
|
|
if "radiant_win" not in row:
|
|
return None
|
|
radiant_win = bool(row.get("radiant_win"))
|
|
slot = _int_field(row, "player_slot")
|
|
if slot is not None:
|
|
radiant = slot < 128
|
|
return radiant_win if radiant else (not radiant_win)
|
|
if hero_id is None:
|
|
return None
|
|
radiant_team = row.get("radiant_team") or []
|
|
dire_team = row.get("dire_team") or []
|
|
on_radiant = False
|
|
on_dire = False
|
|
if isinstance(radiant_team, list):
|
|
for hid in radiant_team:
|
|
try:
|
|
if int(hid) == hero_id:
|
|
on_radiant = True
|
|
break
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if isinstance(dire_team, list):
|
|
for hid in dire_team:
|
|
try:
|
|
if int(hid) == hero_id:
|
|
on_dire = True
|
|
break
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if on_radiant and not on_dire:
|
|
return radiant_win
|
|
if on_dire and not on_radiant:
|
|
return not radiant_win
|
|
return None
|
|
|
|
|
|
def public_list_row_ok(row: dict, hero_id: int) -> bool:
|
|
"""True if a publicMatches list row is ranked Legend+ win for hero_id."""
|
|
lobby = _int_field(row, "lobby_type")
|
|
game_mode = _int_field(row, "game_mode")
|
|
if lobby is not None and lobby in BOT_LOBBY_TYPES:
|
|
return False
|
|
if game_mode is not None and game_mode in EXCLUDED_GAME_MODES:
|
|
return False
|
|
if lobby is not None and lobby not in RANKED_LOBBY_TYPES:
|
|
return False
|
|
avg_rank = _int_field(row, "avg_rank_tier")
|
|
if not is_legend_plus_tier(avg_rank):
|
|
return False
|
|
won = hero_won_on_list_row(row, hero_id)
|
|
if won is False:
|
|
return False
|
|
return True
|
|
|
|
|
|
def match_detail_ok(match: dict, list_meta: dict | None = None) -> bool:
|
|
"""Drop bot / partial-human matches after detail fetch."""
|
|
lobby = _int_field(match, "lobby_type")
|
|
if lobby is None and list_meta:
|
|
lobby = _int_field(list_meta, "lobby_type")
|
|
game_mode = _int_field(match, "game_mode")
|
|
if game_mode is None and list_meta:
|
|
game_mode = _int_field(list_meta, "game_mode")
|
|
if lobby is not None and lobby in BOT_LOBBY_TYPES:
|
|
return False
|
|
if game_mode is not None and game_mode in EXCLUDED_GAME_MODES:
|
|
return False
|
|
human = _int_field(match, "human_players")
|
|
if human is not None and human < MIN_HUMAN_PLAYERS:
|
|
return False
|
|
return True
|
|
|
|
|
|
def slim_match_ok(slim: dict) -> bool:
|
|
"""Keep wins only; public/ladder also requires Legend+ rank."""
|
|
if not isinstance(slim, dict):
|
|
return False
|
|
if not slim.get("won"):
|
|
return False
|
|
origin = str(slim.get("origin") or "")
|
|
if origin == "public":
|
|
tier = slim.get("rank_tier")
|
|
if tier is None:
|
|
tier = slim.get("avg_rank_tier")
|
|
try:
|
|
t_i = int(tier) if tier is not None else None
|
|
except (TypeError, ValueError):
|
|
t_i = None
|
|
if not is_legend_plus_tier(t_i):
|
|
return False
|
|
return True
|
|
|
|
|
|
def is_china_cluster(cluster: int | None, region: int | None = None) -> bool:
|
|
"""True if cluster/region looks like Perfect World 国服."""
|
|
if region is not None and region in CHINA_REGIONS:
|
|
return True
|
|
if cluster is not None and cluster in CHINA_CLUSTERS:
|
|
return True
|
|
return False
|
|
|
|
|
|
def load_ability_id_map() -> dict[int, str]:
|
|
raw = http_json(ABILITY_IDS_URL)
|
|
out: dict[int, str] = {}
|
|
if not isinstance(raw, dict):
|
|
return out
|
|
for sid, key in raw.items():
|
|
try:
|
|
aid = int(sid)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if isinstance(key, str) and key.strip():
|
|
out[aid] = key.strip()
|
|
return out
|
|
|
|
|
|
def load_item_id_catalog() -> dict[int, dict]:
|
|
"""id → {key, dname} from dotaconstants (English dname; UI may localize via shop)."""
|
|
raw = http_json(ITEMS_URL)
|
|
out: dict[int, dict] = {}
|
|
if not isinstance(raw, dict):
|
|
return out
|
|
for key, row in raw.items():
|
|
if not isinstance(row, dict) or row.get("id") is None:
|
|
continue
|
|
key_s = str(key)
|
|
if key_s.startswith("recipe_"):
|
|
continue
|
|
try:
|
|
iid = int(row["id"])
|
|
except (TypeError, ValueError):
|
|
continue
|
|
out[iid] = {"key": key_s, "dname": str(row.get("dname") or key_s)}
|
|
return out
|
|
|
|
|
|
def resolve_upgrades(arr: list | None, id_map: dict[int, str]) -> list[str]:
|
|
if not isinstance(arr, list):
|
|
return []
|
|
keys: list[str] = []
|
|
for raw in arr:
|
|
try:
|
|
aid = int(raw)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
key = id_map.get(aid)
|
|
if key:
|
|
keys.append(key)
|
|
return keys
|
|
|
|
|
|
def item_slot_ids(player: dict, prefix: str, n: int) -> list[int]:
|
|
out: list[int] = []
|
|
for i in range(n):
|
|
raw = player.get(f"{prefix}_{i}")
|
|
try:
|
|
iid = int(raw or 0)
|
|
except (TypeError, ValueError):
|
|
iid = 0
|
|
if iid > 0:
|
|
out.append(iid)
|
|
return out
|
|
|
|
|
|
def _purchase_log_entries(raw) -> list[tuple[str, int]]:
|
|
"""Chronological (key, time_sec) from OpenDota purchase_log."""
|
|
if not isinstance(raw, list):
|
|
return []
|
|
out: list[tuple[str, int]] = []
|
|
for row in raw:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
key = row.get("key")
|
|
if not isinstance(key, str) or not key.strip():
|
|
continue
|
|
try:
|
|
t = int(row["time"])
|
|
except (TypeError, ValueError, KeyError):
|
|
continue
|
|
out.append((key.strip(), t))
|
|
return out
|
|
|
|
|
|
def slot_purchase_times(
|
|
item_ids: list[int],
|
|
purchase_log,
|
|
item_catalog: dict[int, dict],
|
|
) -> list[int | None]:
|
|
"""Map each final inventory id → last matching purchase time (sec).
|
|
|
|
Consumes from the end of the log so duplicate keys get distinct times.
|
|
Falls back to recipe_<key> when the finished item key is absent.
|
|
Missing purchase_log → all nulls.
|
|
"""
|
|
n = len(item_ids)
|
|
if n == 0:
|
|
return []
|
|
entries = _purchase_log_entries(purchase_log)
|
|
if not entries:
|
|
return [None] * n
|
|
used = [False] * len(entries)
|
|
times: list[int | None] = []
|
|
for iid in item_ids:
|
|
meta = item_catalog.get(int(iid)) if iid else None
|
|
if not meta:
|
|
times.append(None)
|
|
continue
|
|
key = str(meta["key"])
|
|
recipe_key = f"recipe_{key}"
|
|
found: int | None = None
|
|
for i in range(len(entries) - 1, -1, -1):
|
|
if used[i]:
|
|
continue
|
|
if entries[i][0] == key:
|
|
found = i
|
|
break
|
|
if found is None:
|
|
for i in range(len(entries) - 1, -1, -1):
|
|
if used[i]:
|
|
continue
|
|
if entries[i][0] == recipe_key:
|
|
found = i
|
|
break
|
|
if found is None:
|
|
times.append(None)
|
|
else:
|
|
used[found] = True
|
|
times.append(entries[found][1])
|
|
return times
|
|
|
|
|
|
def neutral_purchase_time(
|
|
neutral_id: int | None,
|
|
player: dict,
|
|
item_catalog: dict[int, dict],
|
|
) -> int | None:
|
|
"""Seconds when the final neutral was acquired, if parse data exists."""
|
|
if not neutral_id:
|
|
return None
|
|
meta = item_catalog.get(int(neutral_id))
|
|
if not meta:
|
|
return None
|
|
key = str(meta["key"])
|
|
history = player.get("neutral_item_history")
|
|
if isinstance(history, list):
|
|
last: int | None = None
|
|
for row in history:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
if row.get("item_neutral") != key:
|
|
continue
|
|
try:
|
|
last = int(row["time"])
|
|
except (TypeError, ValueError, KeyError):
|
|
continue
|
|
if last is not None:
|
|
return last
|
|
# Rare fallback: some neutrals also appear in purchase_log.
|
|
for k, t in reversed(_purchase_log_entries(player.get("purchase_log"))):
|
|
if k == key:
|
|
return t
|
|
return None
|
|
|
|
|
|
def extract_player_row(
|
|
match: dict,
|
|
hero_id: int,
|
|
*,
|
|
origin: str,
|
|
id_map: dict[int, str],
|
|
item_catalog: dict[int, dict] | None = None,
|
|
list_meta: dict | None = None,
|
|
) -> dict | None:
|
|
"""Build one slim match row for the player on hero_id, or None."""
|
|
players = match.get("players")
|
|
if not isinstance(players, list):
|
|
return None
|
|
player = None
|
|
for p in players:
|
|
if not isinstance(p, dict):
|
|
continue
|
|
try:
|
|
if int(p.get("hero_id") or 0) == hero_id:
|
|
player = p
|
|
break
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if player is None:
|
|
return None
|
|
|
|
try:
|
|
match_id = int(match.get("match_id") or 0)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if match_id <= 0:
|
|
return None
|
|
|
|
slot = int(player.get("player_slot") or 0)
|
|
radiant = slot < 128
|
|
radiant_win = bool(match.get("radiant_win"))
|
|
won = radiant_win if radiant else not radiant_win
|
|
|
|
start_time = match.get("start_time")
|
|
if start_time is None and list_meta:
|
|
start_time = list_meta.get("start_time")
|
|
try:
|
|
start_time_i = int(start_time) if start_time is not None else None
|
|
except (TypeError, ValueError):
|
|
start_time_i = None
|
|
|
|
try:
|
|
duration = int(match.get("duration") or 0)
|
|
except (TypeError, ValueError):
|
|
duration = 0
|
|
|
|
league_name = None
|
|
if list_meta and list_meta.get("league_name"):
|
|
league_name = str(list_meta["league_name"])
|
|
league = match.get("league")
|
|
if not league_name and isinstance(league, dict) and league.get("name"):
|
|
league_name = str(league["name"])
|
|
|
|
avg_rank = None
|
|
if list_meta and list_meta.get("avg_rank_tier") is not None:
|
|
try:
|
|
avg_rank = int(list_meta["avg_rank_tier"])
|
|
except (TypeError, ValueError):
|
|
avg_rank = None
|
|
|
|
account_id = player.get("account_id")
|
|
try:
|
|
account_id_i = int(account_id) if account_id is not None else None
|
|
except (TypeError, ValueError):
|
|
account_id_i = None
|
|
|
|
personaname = player.get("personaname")
|
|
if isinstance(personaname, str):
|
|
personaname = personaname.strip() or None
|
|
else:
|
|
personaname = None
|
|
# Pro/registered name when present (league matches).
|
|
pro_name = player.get("name")
|
|
if isinstance(pro_name, str):
|
|
pro_name = pro_name.strip() or None
|
|
else:
|
|
pro_name = None
|
|
|
|
rank_tier = player.get("rank_tier")
|
|
try:
|
|
rank_tier_i = int(rank_tier) if rank_tier is not None else None
|
|
except (TypeError, ValueError):
|
|
rank_tier_i = None
|
|
if rank_tier_i is not None and rank_tier_i <= 0:
|
|
rank_tier_i = None
|
|
|
|
leaderboard_rank = player.get("leaderboard_rank")
|
|
try:
|
|
leaderboard_rank_i = (
|
|
int(leaderboard_rank) if leaderboard_rank is not None else None
|
|
)
|
|
except (TypeError, ValueError):
|
|
leaderboard_rank_i = None
|
|
if leaderboard_rank_i is not None and leaderboard_rank_i <= 0:
|
|
leaderboard_rank_i = None
|
|
|
|
neutral = player.get("item_neutral")
|
|
try:
|
|
neutral_i = int(neutral) if neutral else None
|
|
except (TypeError, ValueError):
|
|
neutral_i = None
|
|
if neutral_i is not None and neutral_i <= 0:
|
|
neutral_i = None
|
|
|
|
def _stat(key: str) -> int:
|
|
try:
|
|
return int(player.get(key) or 0)
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
cluster = match.get("cluster")
|
|
if cluster is None and list_meta:
|
|
cluster = list_meta.get("cluster")
|
|
try:
|
|
cluster_i = int(cluster) if cluster is not None else None
|
|
except (TypeError, ValueError):
|
|
cluster_i = None
|
|
|
|
region = match.get("region")
|
|
try:
|
|
region_i = int(region) if region is not None else None
|
|
except (TypeError, ValueError):
|
|
region_i = None
|
|
|
|
lobby_type = _int_field(match, "lobby_type")
|
|
if lobby_type is None and list_meta:
|
|
lobby_type = _int_field(list_meta, "lobby_type")
|
|
game_mode = _int_field(match, "game_mode")
|
|
if game_mode is None and list_meta:
|
|
game_mode = _int_field(list_meta, "game_mode")
|
|
human_players = _int_field(match, "human_players")
|
|
|
|
items = item_slot_ids(player, "item", 6)
|
|
backpack = item_slot_ids(player, "backpack", 3)
|
|
catalog = item_catalog or {}
|
|
# Shared consume so a duplicated key across inv+backpack does not
|
|
# double-claim the same purchase_log entry.
|
|
shared_times = slot_purchase_times(
|
|
items + backpack, player.get("purchase_log"), catalog
|
|
)
|
|
item_times = shared_times[: len(items)]
|
|
backpack_times = shared_times[len(items) :]
|
|
neut_time = neutral_purchase_time(neutral_i, player, catalog)
|
|
|
|
out = {
|
|
"match_id": match_id,
|
|
"origin": origin,
|
|
"start_time": start_time_i,
|
|
"duration": duration,
|
|
"won": won,
|
|
"kills": _stat("kills"),
|
|
"deaths": _stat("deaths"),
|
|
"assists": _stat("assists"),
|
|
"account_id": account_id_i,
|
|
"personaname": personaname,
|
|
"name": pro_name,
|
|
"rank_tier": rank_tier_i,
|
|
"leaderboard_rank": leaderboard_rank_i,
|
|
"league_name": league_name,
|
|
"avg_rank_tier": avg_rank,
|
|
"cluster": cluster_i,
|
|
"region": region_i,
|
|
"lobby_type": lobby_type,
|
|
"game_mode": game_mode,
|
|
"human_players": human_players,
|
|
"items": items,
|
|
"backpack": backpack,
|
|
"item_neutral": neutral_i,
|
|
"item_times": item_times,
|
|
"backpack_times": backpack_times,
|
|
"item_neutral_time": neut_time,
|
|
"ability_upgrades": resolve_upgrades(
|
|
player.get("ability_upgrades_arr"), id_map
|
|
),
|
|
}
|
|
# Convenience label for UI (pro name > persona); never an account id.
|
|
display = pro_name or personaname
|
|
if display:
|
|
out["display_name"] = display
|
|
return out
|
|
|
|
|
|
def opendota_api_key() -> str:
|
|
"""Optional key raises OpenDota rate limits (env OPENDOTA_API_KEY)."""
|
|
return (os.environ.get("OPENDOTA_API_KEY") or "").strip()
|
|
|
|
|
|
def opendota_url(path: str) -> str:
|
|
"""Build api.opendota.com URL; append api_key query when configured."""
|
|
base = f"{OPENDOTA}{path}"
|
|
key = opendota_api_key()
|
|
if not key:
|
|
return base
|
|
sep = "&" if "?" in base else "?"
|
|
return f"{base}{sep}api_key={key}"
|
|
|
|
|
|
def fetch_match(match_id: int) -> dict | None:
|
|
try:
|
|
raw = http_json(opendota_url(f"/matches/{match_id}"))
|
|
except (
|
|
urllib.error.HTTPError,
|
|
urllib.error.URLError,
|
|
TimeoutError,
|
|
json.JSONDecodeError,
|
|
OSError,
|
|
):
|
|
return None
|
|
return raw if isinstance(raw, dict) else None
|
|
|
|
|
|
def league_match_ids(hero_id: int, limit: int) -> list[dict]:
|
|
"""Return win list metas from /heroes/{id}/matches (up to candidate cap)."""
|
|
try:
|
|
raw = http_json(opendota_url(f"/heroes/{hero_id}/matches"))
|
|
except (
|
|
urllib.error.HTTPError,
|
|
urllib.error.URLError,
|
|
TimeoutError,
|
|
json.JSONDecodeError,
|
|
OSError,
|
|
):
|
|
return []
|
|
if not isinstance(raw, list):
|
|
return []
|
|
want = max(limit * CANDIDATE_MULTIPLIER, limit)
|
|
out: list[dict] = []
|
|
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
|
|
won = hero_won_on_list_row(row, hero_id)
|
|
if won is False:
|
|
continue
|
|
out.append(row)
|
|
if len(out) >= want:
|
|
break
|
|
return out
|
|
|
|
|
|
def _hero_in_public_row(row: dict, hero_id: int) -> bool:
|
|
radiant = row.get("radiant_team") or []
|
|
dire = row.get("dire_team") or []
|
|
for side in (radiant, dire):
|
|
if not isinstance(side, list):
|
|
continue
|
|
for hid in side:
|
|
try:
|
|
if int(hid) == hero_id:
|
|
return True
|
|
except (TypeError, ValueError):
|
|
continue
|
|
return False
|
|
|
|
|
|
def public_match_ids(
|
|
hero_id: int,
|
|
limit: int,
|
|
*,
|
|
delay: float,
|
|
public_region: str = "china",
|
|
) -> list[dict]:
|
|
"""Scan publicMatches for Legend+ ranked wins containing hero_id.
|
|
|
|
When public_region=china, collect China-cluster hits first; if fewer than
|
|
the candidate cap, backfill with other-region pubs from the same scan.
|
|
"""
|
|
prefer_china = public_region == "china"
|
|
pages_cap = PUBLIC_PAGES_CAP_CHINA if prefer_china else PUBLIC_PAGES_CAP
|
|
want = max(limit * CANDIDATE_MULTIPLIER, limit)
|
|
china_out: list[dict] = []
|
|
other_out: list[dict] = []
|
|
seen: set[int] = set()
|
|
less_than: int | None = None
|
|
|
|
def _enough() -> bool:
|
|
if prefer_china:
|
|
return len(china_out) >= want
|
|
return len(china_out) + len(other_out) >= want
|
|
|
|
for _page in range(pages_cap):
|
|
if _enough():
|
|
break
|
|
url = opendota_url("/publicMatches")
|
|
if less_than is not None:
|
|
# opendota_url may already have ?api_key=
|
|
sep = "&" if "?" in url else "?"
|
|
url = f"{url}{sep}less_than_match_id={less_than}"
|
|
try:
|
|
raw = http_json(url)
|
|
except (
|
|
urllib.error.HTTPError,
|
|
urllib.error.URLError,
|
|
TimeoutError,
|
|
json.JSONDecodeError,
|
|
OSError,
|
|
):
|
|
break
|
|
if not isinstance(raw, list) or not raw:
|
|
break
|
|
page_min: int | None = None
|
|
for row in raw:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
try:
|
|
mid = int(row.get("match_id") or 0)
|
|
duration = int(row.get("duration") or 0)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if mid <= 0:
|
|
continue
|
|
if page_min is None or mid < page_min:
|
|
page_min = mid
|
|
if duration <= 0:
|
|
continue
|
|
if mid in seen:
|
|
continue
|
|
if not _hero_in_public_row(row, hero_id):
|
|
continue
|
|
if not public_list_row_ok(row, hero_id):
|
|
continue
|
|
try:
|
|
cluster_i = int(row["cluster"]) if row.get("cluster") is not None else None
|
|
except (TypeError, ValueError):
|
|
cluster_i = None
|
|
seen.add(mid)
|
|
if is_china_cluster(cluster_i):
|
|
china_out.append(row)
|
|
else:
|
|
other_out.append(row)
|
|
if _enough():
|
|
break
|
|
if page_min is None:
|
|
break
|
|
if less_than is not None and page_min >= less_than:
|
|
break
|
|
less_than = page_min
|
|
if delay > 0:
|
|
time.sleep(delay)
|
|
|
|
if prefer_china:
|
|
out = china_out[:want]
|
|
if len(out) < want:
|
|
need = want - len(out)
|
|
out.extend(other_out[:need])
|
|
return out
|
|
merged = china_out + other_out
|
|
return merged[:want]
|
|
|
|
|
|
def fill_matches(
|
|
hero_id: int,
|
|
list_rows: list[dict],
|
|
*,
|
|
origin: str,
|
|
id_map: dict[int, str],
|
|
item_catalog: dict[int, dict],
|
|
delay: float,
|
|
workers: int = 1,
|
|
need: int | None = None,
|
|
) -> list[dict]:
|
|
"""Fetch details; keep only slim_match_ok rows; stop once `need` accepted."""
|
|
candidates: list[tuple[int, dict]] = []
|
|
for row in list_rows:
|
|
try:
|
|
mid = int(row.get("match_id") or 0)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if mid <= 0:
|
|
continue
|
|
candidates.append((mid, row))
|
|
|
|
if not candidates:
|
|
return []
|
|
|
|
target = need if need is not None and need > 0 else len(candidates)
|
|
workers_n = max(1, int(workers))
|
|
matches: list[dict] = []
|
|
|
|
def _process(mid: int, row: dict) -> dict | None:
|
|
detail = fetch_match(mid)
|
|
if not detail:
|
|
return None
|
|
if origin == "public" and not match_detail_ok(detail, row):
|
|
return None
|
|
slim = extract_player_row(
|
|
detail,
|
|
hero_id,
|
|
origin=origin,
|
|
id_map=id_map,
|
|
item_catalog=item_catalog,
|
|
list_meta=row,
|
|
)
|
|
if not slim or not slim_match_ok(slim):
|
|
return None
|
|
return slim
|
|
|
|
if workers_n <= 1:
|
|
for mid, row in candidates:
|
|
if len(matches) >= target:
|
|
break
|
|
slim = _process(mid, row)
|
|
if slim:
|
|
matches.append(slim)
|
|
if delay > 0:
|
|
time.sleep(delay)
|
|
return matches
|
|
|
|
chunk = max(workers_n * 2, workers_n)
|
|
idx = 0
|
|
while idx < len(candidates) and len(matches) < target:
|
|
batch = candidates[idx : idx + chunk]
|
|
idx += len(batch)
|
|
with ThreadPoolExecutor(max_workers=workers_n) as pool:
|
|
futs = [pool.submit(_process, mid, row) for mid, row in batch]
|
|
for fut in as_completed(futs):
|
|
try:
|
|
slim = fut.result()
|
|
except Exception:
|
|
slim = None
|
|
if slim:
|
|
matches.append(slim)
|
|
if delay > 0:
|
|
time.sleep(delay)
|
|
return matches
|
|
|
|
|
|
def fetch_hero_matches(
|
|
hero_id: int,
|
|
*,
|
|
source: str,
|
|
limit: int,
|
|
id_map: dict[int, str],
|
|
item_catalog: dict[int, dict],
|
|
delay: float,
|
|
public_region: str = "china",
|
|
workers: int = 1,
|
|
) -> list[dict]:
|
|
by_id: dict[int, dict] = {}
|
|
workers_n = max(1, int(workers))
|
|
|
|
if source in ("league", "both"):
|
|
league_rows = league_match_ids(hero_id, limit)
|
|
if delay > 0:
|
|
time.sleep(delay)
|
|
for slim in fill_matches(
|
|
hero_id,
|
|
league_rows,
|
|
origin="league",
|
|
id_map=id_map,
|
|
item_catalog=item_catalog,
|
|
delay=delay,
|
|
workers=workers_n,
|
|
need=limit,
|
|
):
|
|
by_id[slim["match_id"]] = slim
|
|
|
|
if source in ("public", "both") and len(by_id) < limit:
|
|
pub_rows = public_match_ids(
|
|
hero_id, limit, delay=delay, public_region=public_region
|
|
)
|
|
need_more = limit - len(by_id)
|
|
for slim in fill_matches(
|
|
hero_id,
|
|
pub_rows,
|
|
origin="public",
|
|
id_map=id_map,
|
|
item_catalog=item_catalog,
|
|
delay=delay,
|
|
workers=workers_n,
|
|
need=need_more,
|
|
):
|
|
mid = slim["match_id"]
|
|
if mid not in by_id:
|
|
by_id[mid] = slim
|
|
|
|
rows = list(by_id.values())
|
|
rows.sort(key=lambda r: int(r.get("start_time") or 0), reverse=True)
|
|
return rows[:limit]
|
|
|
|
|
|
def hero_cell_needs_fetch(cell, limit: int) -> bool:
|
|
"""True if hero is missing, empty, or has fewer than limit kept matches."""
|
|
if cell is None:
|
|
return True
|
|
if isinstance(cell, list):
|
|
matches = cell
|
|
elif isinstance(cell, dict):
|
|
matches = cell.get("matches")
|
|
if not isinstance(matches, list):
|
|
return True
|
|
else:
|
|
return True
|
|
return len(matches) < limit
|
|
|
|
|
|
def load_existing(path: Path) -> dict:
|
|
empty = {"meta": {}, "by_hero": {}}
|
|
if not path.is_file():
|
|
return empty
|
|
try:
|
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
return empty
|
|
if not isinstance(raw, dict):
|
|
return empty
|
|
by_hero = raw.get("by_hero")
|
|
if not isinstance(by_hero, dict):
|
|
by_hero = {}
|
|
return {"meta": dict(raw.get("meta") or {}), "by_hero": by_hero}
|
|
|
|
|
|
def collect_item_ids(by_hero: dict) -> set[int]:
|
|
used: set[int] = set()
|
|
for cell in by_hero.values():
|
|
matches = []
|
|
if isinstance(cell, dict):
|
|
matches = cell.get("matches") or []
|
|
elif isinstance(cell, list):
|
|
matches = cell
|
|
if not isinstance(matches, list):
|
|
continue
|
|
for row in matches:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
for key in ("items", "backpack"):
|
|
for raw in row.get(key) or []:
|
|
try:
|
|
iid = int(raw)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if iid > 0:
|
|
used.add(iid)
|
|
neut = row.get("item_neutral")
|
|
if neut is not None:
|
|
try:
|
|
nid = int(neut)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if nid > 0:
|
|
used.add(nid)
|
|
return used
|
|
|
|
|
|
def write_out(
|
|
path: Path,
|
|
by_hero: dict,
|
|
*,
|
|
source: str,
|
|
limit: int,
|
|
item_catalog: dict[int, dict],
|
|
public_region: str = "china",
|
|
) -> 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]["dname"],
|
|
}
|
|
for iid in sorted(used)
|
|
if iid in item_catalog
|
|
}
|
|
payload = {
|
|
"meta": {
|
|
"source": "opendota",
|
|
"attribution": "https://www.opendota.com",
|
|
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
|
"match_source": source,
|
|
"public_region": public_region,
|
|
"limit": limit,
|
|
"wins_only": True,
|
|
"min_rank_tier": MIN_RANK_TIER,
|
|
"min_bracket": "legend",
|
|
},
|
|
"items": items_out,
|
|
"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 needs_player_enrich(row: dict) -> bool:
|
|
"""True if row is missing nickname / rank fields from newer schema."""
|
|
if not isinstance(row, dict):
|
|
return False
|
|
if "personaname" not in row and "rank_tier" not in row:
|
|
return True
|
|
return False
|
|
|
|
|
|
def needs_item_times_enrich(row: dict) -> bool:
|
|
"""True if row lacks item_times schema (nulls still count as enriched)."""
|
|
if not isinstance(row, dict):
|
|
return False
|
|
return "item_times" not in row
|
|
|
|
|
|
def enrich_player_fields(
|
|
path: Path,
|
|
*,
|
|
hero_keys: list[str],
|
|
by_key: dict,
|
|
id_map: dict[int, str],
|
|
item_catalog: dict[int, dict],
|
|
delay: float,
|
|
source: str,
|
|
limit: int,
|
|
) -> None:
|
|
"""Re-fetch match details to fill personaname / rank_tier on existing rows."""
|
|
existing = load_existing(path)
|
|
by_hero: dict = dict(existing.get("by_hero") or {})
|
|
meta = existing.get("meta") or {}
|
|
match_source = str(meta.get("match_source") or source)
|
|
try:
|
|
limit_i = int(meta.get("limit") or limit)
|
|
except (TypeError, ValueError):
|
|
limit_i = limit
|
|
|
|
total = 0
|
|
updated = 0
|
|
for hi, key in enumerate(hero_keys, 1):
|
|
cell = by_hero.get(key)
|
|
if not isinstance(cell, dict):
|
|
continue
|
|
matches = cell.get("matches")
|
|
if not isinstance(matches, list) or not matches:
|
|
continue
|
|
hero = by_key.get(key)
|
|
if not hero:
|
|
continue
|
|
hid = int(hero["id"])
|
|
dirty = False
|
|
for i, row in enumerate(matches):
|
|
if not isinstance(row, dict):
|
|
continue
|
|
if not needs_player_enrich(row):
|
|
continue
|
|
mid = row.get("match_id")
|
|
try:
|
|
mid_i = int(mid)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
total += 1
|
|
detail = fetch_match(mid_i)
|
|
if delay > 0:
|
|
time.sleep(delay)
|
|
if not detail:
|
|
# Still mark schema keys so we do not retry forever.
|
|
row["personaname"] = row.get("personaname")
|
|
row["name"] = row.get("name")
|
|
row["rank_tier"] = row.get("rank_tier")
|
|
row["leaderboard_rank"] = row.get("leaderboard_rank")
|
|
dirty = True
|
|
continue
|
|
slim = extract_player_row(
|
|
detail,
|
|
hid,
|
|
origin=str(row.get("origin") or "league"),
|
|
id_map=id_map,
|
|
item_catalog=item_catalog,
|
|
list_meta=row,
|
|
)
|
|
if not slim:
|
|
row["personaname"] = None
|
|
row["name"] = None
|
|
row["rank_tier"] = None
|
|
row["leaderboard_rank"] = None
|
|
dirty = True
|
|
continue
|
|
for field in (
|
|
"personaname",
|
|
"name",
|
|
"rank_tier",
|
|
"leaderboard_rank",
|
|
"account_id",
|
|
):
|
|
row[field] = slim.get(field)
|
|
display = slim.get("display_name") or slim.get("name") or slim.get(
|
|
"personaname"
|
|
)
|
|
if display:
|
|
row["display_name"] = display
|
|
else:
|
|
row.pop("display_name", None)
|
|
matches[i] = row
|
|
updated += 1
|
|
dirty = True
|
|
if dirty:
|
|
by_hero[key] = {"matches": matches}
|
|
write_out(
|
|
path,
|
|
by_hero,
|
|
source=match_source,
|
|
limit=limit_i,
|
|
item_catalog=item_catalog,
|
|
public_region=str(meta.get("public_region") or "china"),
|
|
)
|
|
print(f"[{hi}/{len(hero_keys)}] {key} enriched (saved)", flush=True)
|
|
else:
|
|
print(f"[{hi}/{len(hero_keys)}] {key} ok", flush=True)
|
|
print(f"enrich done checked={total} updated={updated} → {path}")
|
|
|
|
|
|
def enrich_item_times(
|
|
path: Path,
|
|
*,
|
|
hero_keys: list[str],
|
|
by_key: dict,
|
|
id_map: dict[int, str],
|
|
item_catalog: dict[int, dict],
|
|
delay: float,
|
|
source: str,
|
|
limit: int,
|
|
) -> None:
|
|
"""Re-fetch match details to fill item_times / backpack_times / item_neutral_time."""
|
|
existing = load_existing(path)
|
|
by_hero: dict = dict(existing.get("by_hero") or {})
|
|
meta = existing.get("meta") or {}
|
|
match_source = str(meta.get("match_source") or source)
|
|
try:
|
|
limit_i = int(meta.get("limit") or limit)
|
|
except (TypeError, ValueError):
|
|
limit_i = limit
|
|
public_region = str(meta.get("public_region") or "china")
|
|
|
|
total = 0
|
|
updated = 0
|
|
with_times = 0
|
|
for hi, key in enumerate(hero_keys, 1):
|
|
cell = by_hero.get(key)
|
|
if not isinstance(cell, dict):
|
|
continue
|
|
matches = cell.get("matches")
|
|
if not isinstance(matches, list) or not matches:
|
|
continue
|
|
hero = by_key.get(key)
|
|
if not hero:
|
|
continue
|
|
hid = int(hero["id"])
|
|
dirty = False
|
|
for i, row in enumerate(matches):
|
|
if not isinstance(row, dict):
|
|
continue
|
|
if not needs_item_times_enrich(row):
|
|
continue
|
|
mid = row.get("match_id")
|
|
try:
|
|
mid_i = int(mid)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
total += 1
|
|
detail = fetch_match(mid_i)
|
|
if delay > 0:
|
|
time.sleep(delay)
|
|
|
|
def _null_times(target: dict) -> None:
|
|
n_items = (
|
|
len(target.get("items") or [])
|
|
if isinstance(target.get("items"), list)
|
|
else 0
|
|
)
|
|
n_bp = (
|
|
len(target.get("backpack") or [])
|
|
if isinstance(target.get("backpack"), list)
|
|
else 0
|
|
)
|
|
target["item_times"] = [None] * n_items
|
|
target["backpack_times"] = [None] * n_bp
|
|
target["item_neutral_time"] = None
|
|
|
|
if not detail:
|
|
_null_times(row)
|
|
dirty = True
|
|
continue
|
|
slim = extract_player_row(
|
|
detail,
|
|
hid,
|
|
origin=str(row.get("origin") or "league"),
|
|
id_map=id_map,
|
|
item_catalog=item_catalog,
|
|
list_meta=row,
|
|
)
|
|
if not slim:
|
|
_null_times(row)
|
|
dirty = True
|
|
continue
|
|
for field in (
|
|
"items",
|
|
"backpack",
|
|
"item_neutral",
|
|
"item_times",
|
|
"backpack_times",
|
|
"item_neutral_time",
|
|
):
|
|
row[field] = slim.get(field)
|
|
times = slim.get("item_times") or []
|
|
bp_times = slim.get("backpack_times") or []
|
|
if (
|
|
any(t is not None for t in times)
|
|
or any(t is not None for t in bp_times)
|
|
or slim.get("item_neutral_time") is not None
|
|
):
|
|
with_times += 1
|
|
matches[i] = row
|
|
updated += 1
|
|
dirty = True
|
|
if dirty:
|
|
by_hero[key] = {"matches": matches}
|
|
write_out(
|
|
path,
|
|
by_hero,
|
|
source=match_source,
|
|
limit=limit_i,
|
|
item_catalog=item_catalog,
|
|
public_region=public_region,
|
|
)
|
|
print(
|
|
f"[{hi}/{len(hero_keys)}] {key} item-times enriched (saved)",
|
|
flush=True,
|
|
)
|
|
else:
|
|
print(f"[{hi}/{len(hero_keys)}] {key} ok", flush=True)
|
|
print(
|
|
f"enrich-item-times done checked={total} updated={updated} "
|
|
f"with_times={with_times} → {path}"
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument(
|
|
"--source",
|
|
choices=("league", "public", "both"),
|
|
default="league",
|
|
help="Match list source (default: league)",
|
|
)
|
|
ap.add_argument(
|
|
"--limit",
|
|
type=int,
|
|
default=DEFAULT_LIMIT,
|
|
help=f"Recent matches kept per hero after merge, newest first (default: {DEFAULT_LIMIT})",
|
|
)
|
|
ap.add_argument(
|
|
"--heroes",
|
|
default="",
|
|
help="Comma-separated hero keys to fetch (default: all)",
|
|
)
|
|
ap.add_argument(
|
|
"--delay",
|
|
type=float,
|
|
default=None,
|
|
help="Sleep between list pages / detail batches (default: 0.05 with "
|
|
"--workers>1, else 0.35)",
|
|
)
|
|
ap.add_argument(
|
|
"--workers",
|
|
type=int,
|
|
default=DEFAULT_WORKERS,
|
|
help=f"Concurrent match-detail fetches per hero (default: {DEFAULT_WORKERS})",
|
|
)
|
|
ap.add_argument("--out", type=Path, default=OUT)
|
|
ap.add_argument(
|
|
"--force",
|
|
action="store_true",
|
|
help="Refetch heroes even if already present with enough matches",
|
|
)
|
|
ap.add_argument(
|
|
"--enrich-players",
|
|
action="store_true",
|
|
help="Re-fetch match details to fill personaname/rank_tier on existing rows",
|
|
)
|
|
ap.add_argument(
|
|
"--enrich-item-times",
|
|
action="store_true",
|
|
help="Re-fetch match details to fill item_times/backpack_times on existing rows",
|
|
)
|
|
ap.add_argument(
|
|
"--public-region",
|
|
choices=("china", "any"),
|
|
default="china",
|
|
help=(
|
|
"Public match region preference (default: china). "
|
|
"china = Perfect World clusters first, backfill others if short; "
|
|
"any = no cluster filter"
|
|
),
|
|
)
|
|
ap.add_argument("--skip-icons", action="store_true")
|
|
ap.add_argument("--force-icons", action="store_true")
|
|
args = ap.parse_args()
|
|
limit = max(1, int(args.limit))
|
|
public_region = str(args.public_region)
|
|
workers = max(1, int(args.workers))
|
|
if args.delay is None:
|
|
delay = 0.05 if workers > 1 else 0.35
|
|
else:
|
|
delay = max(0.0, float(args.delay))
|
|
|
|
heroes = hero_table()
|
|
by_key = {h["key"]: h for h in heroes}
|
|
want_keys: list[str]
|
|
if args.heroes.strip():
|
|
want_keys = []
|
|
for part in args.heroes.split(","):
|
|
key = part.strip()
|
|
if not key:
|
|
continue
|
|
if key not in by_key:
|
|
raise SystemExit(f"unknown hero key: {key}")
|
|
want_keys.append(key)
|
|
else:
|
|
want_keys = sorted(by_key.keys())
|
|
|
|
if args.enrich_players or args.enrich_item_times:
|
|
print("loading ability_ids + items catalog…")
|
|
id_map = load_ability_id_map()
|
|
item_catalog = load_item_id_catalog()
|
|
print(f"ability_ids={len(id_map)} items={len(item_catalog)}")
|
|
if args.enrich_item_times:
|
|
enrich_item_times(
|
|
args.out,
|
|
hero_keys=want_keys,
|
|
by_key=by_key,
|
|
id_map=id_map,
|
|
item_catalog=item_catalog,
|
|
delay=delay,
|
|
source=args.source,
|
|
limit=limit,
|
|
)
|
|
if args.enrich_players:
|
|
enrich_player_fields(
|
|
args.out,
|
|
hero_keys=want_keys,
|
|
by_key=by_key,
|
|
id_map=id_map,
|
|
item_catalog=item_catalog,
|
|
delay=delay,
|
|
source=args.source,
|
|
limit=limit,
|
|
)
|
|
return
|
|
|
|
existing = load_existing(args.out)
|
|
by_hero: dict = dict(existing.get("by_hero") or {})
|
|
if args.force:
|
|
for key in want_keys:
|
|
by_hero.pop(key, None)
|
|
|
|
pending = [
|
|
k
|
|
for k in want_keys
|
|
if args.force or hero_cell_needs_fetch(by_hero.get(k), limit)
|
|
]
|
|
# Drop stale short cells so refetch replaces them.
|
|
for key in pending:
|
|
by_hero.pop(key, None)
|
|
|
|
api_key_note = "yes" if opendota_api_key() else "no"
|
|
print(
|
|
f"heroes={len(want_keys)} pending={len(pending)} "
|
|
f"source={args.source} public_region={public_region} "
|
|
f"limit={limit} workers={workers} delay={delay} "
|
|
f"api_key={api_key_note} out={args.out}"
|
|
)
|
|
if not pending:
|
|
print(
|
|
"nothing to do (use --force to refetch, "
|
|
"--enrich-players, or --enrich-item-times)"
|
|
)
|
|
return
|
|
|
|
print("loading ability_ids + items catalog…")
|
|
id_map = load_ability_id_map()
|
|
item_catalog = load_item_id_catalog()
|
|
print(f"ability_ids={len(id_map)} items={len(item_catalog)}")
|
|
|
|
for i, key in enumerate(pending, 1):
|
|
hero = by_key[key]
|
|
hid = int(hero["id"])
|
|
print(f"[{i}/{len(pending)}] {key} id={hid} …", flush=True)
|
|
try:
|
|
matches = fetch_hero_matches(
|
|
hid,
|
|
source=args.source,
|
|
limit=limit,
|
|
id_map=id_map,
|
|
item_catalog=item_catalog,
|
|
delay=delay,
|
|
public_region=public_region,
|
|
workers=workers,
|
|
)
|
|
except (
|
|
urllib.error.HTTPError,
|
|
urllib.error.URLError,
|
|
TimeoutError,
|
|
OSError,
|
|
json.JSONDecodeError,
|
|
) as exc:
|
|
print(f" FAIL {exc}")
|
|
continue
|
|
by_hero[key] = {"matches": matches}
|
|
write_out(
|
|
args.out,
|
|
by_hero,
|
|
source=args.source,
|
|
limit=limit,
|
|
item_catalog=item_catalog,
|
|
public_region=public_region,
|
|
)
|
|
n_pub = sum(1 for m in matches if m.get("origin") == "public")
|
|
n_china = sum(
|
|
1
|
|
for m in matches
|
|
if m.get("origin") == "public"
|
|
and is_china_cluster(m.get("cluster"), m.get("region"))
|
|
)
|
|
print(
|
|
f" matches={len(matches)} public={n_pub} china={n_china} (saved)",
|
|
flush=True,
|
|
)
|
|
|
|
if not args.skip_icons:
|
|
keys: list[str] = []
|
|
try:
|
|
written = json.loads(args.out.read_text(encoding="utf-8"))
|
|
keys = sorted(
|
|
{
|
|
str(r["key"])
|
|
for r in (written.get("items") or {}).values()
|
|
if isinstance(r, dict) and r.get("key")
|
|
}
|
|
)
|
|
except (OSError, json.JSONDecodeError):
|
|
keys = []
|
|
if keys:
|
|
print(f"downloading item icons ({len(keys)})…")
|
|
saved, skipped, fail = download_icons(
|
|
keys,
|
|
ICON_URL,
|
|
ITEM_ICONS,
|
|
force=args.force_icons,
|
|
delay=min(0.15, max(0.0, delay)),
|
|
)
|
|
print(f"icons saved={saved} skipped={skipped} fail={fail}")
|
|
|
|
print(f"done → {args.out} heroes={len(by_hero)}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|