Add star-player matches tab and watchlist-driven fetch.
Ship top-level #/matches with pro watchlist defaults, bump site to 0.5.71, and document the flow in AGENTS/README. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+154
-27
@@ -1,14 +1,18 @@
|
||||
"""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.
|
||||
Default source: web/data/pro_player_watchlist.json (hand-curated star IDs).
|
||||
Falls back to /proPlayers auto-pick only with --all-pros.
|
||||
|
||||
Output: data/pro_matches.json (Climperor web only; not used by recommend).
|
||||
Per player: /players/{id}/matches (lobby practice + tournament), then
|
||||
/matches/{id} for final items + skill builds.
|
||||
|
||||
Output: web/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
|
||||
python web/fetch_pro_matches.py
|
||||
python web/fetch_pro_matches.py --limit 12
|
||||
python web/fetch_pro_matches.py --players 898754153,Ame
|
||||
python web/fetch_pro_matches.py --all-pros --limit-pros 20 --with-team
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -39,22 +43,84 @@ from fetch_pro_builds import fetch_pro_index
|
||||
|
||||
OPENDOTA = "https://api.opendota.com/api"
|
||||
OUT = DATA / "pro_matches.json"
|
||||
WATCHLIST = DATA / "pro_player_watchlist.json"
|
||||
DEFAULT_LIMIT = 8
|
||||
DEFAULT_LIMIT_PROS = 40
|
||||
# OpenDota lobby_type: 1=practice, 2=tournament (pro/league biased).
|
||||
LOBBY_LEAGUE = (1, 2)
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
try:
|
||||
print(msg, flush=True)
|
||||
except UnicodeEncodeError:
|
||||
print(msg.encode("ascii", "backslashreplace").decode("ascii"), flush=True)
|
||||
|
||||
|
||||
def load_watchlist(path: Path) -> list[dict]:
|
||||
"""Load hand-curated star players from pro_player_watchlist.json."""
|
||||
if not path.is_file():
|
||||
return []
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
raise SystemExit(f"watchlist read failed: {path}: {e}") from e
|
||||
players = raw.get("players") if isinstance(raw, dict) else None
|
||||
if not isinstance(players, list):
|
||||
raise SystemExit(f"watchlist missing players[]: {path}")
|
||||
out: list[dict] = []
|
||||
seen: set[int] = set()
|
||||
for row in players:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
try:
|
||||
aid = int(row.get("account_id") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if aid <= 0 or aid in seen:
|
||||
continue
|
||||
seen.add(aid)
|
||||
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
|
||||
region = row.get("region")
|
||||
if isinstance(region, str):
|
||||
region = region.strip().lower() or None
|
||||
else:
|
||||
region = None
|
||||
out.append(
|
||||
{
|
||||
"account_id": aid,
|
||||
"name": name,
|
||||
"team_tag": team_tag,
|
||||
"team_name": team_name,
|
||||
"region": region,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
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
|
||||
val = prof.get("name")
|
||||
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()
|
||||
@@ -67,7 +133,36 @@ def parse_pro_filter(raw: str, pro_index: dict[int, dict]) -> list[int]:
|
||||
if aid:
|
||||
out.append(aid)
|
||||
else:
|
||||
print(f" warn: unknown pro {token!r}", flush=True)
|
||||
_log(f" warn: unknown pro {token!r}")
|
||||
return out
|
||||
|
||||
|
||||
def merge_prof(aid: int, base: dict | None, override: dict | None = None) -> dict:
|
||||
"""Prefer OpenDota live fields, keep watchlist name/team as fallback."""
|
||||
out: dict = {
|
||||
"account_id": aid,
|
||||
"name": None,
|
||||
"team_tag": None,
|
||||
"team_name": None,
|
||||
"country_code": None,
|
||||
"last_match_time": None,
|
||||
}
|
||||
if isinstance(base, dict):
|
||||
for key in out:
|
||||
if key == "account_id":
|
||||
continue
|
||||
val = base.get(key)
|
||||
if val is not None and val != "":
|
||||
out[key] = val
|
||||
if isinstance(override, dict):
|
||||
for key in ("name", "team_tag", "team_name"):
|
||||
if out.get(key):
|
||||
continue
|
||||
val = override.get(key)
|
||||
if isinstance(val, str) and val.strip():
|
||||
out[key] = val.strip()
|
||||
elif val:
|
||||
out[key] = val
|
||||
return out
|
||||
|
||||
|
||||
@@ -78,13 +173,19 @@ def filter_pros(
|
||||
active_days: int | None,
|
||||
limit_pros: int,
|
||||
player_ids: list[int],
|
||||
watchlist: list[dict] | None = None,
|
||||
) -> 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))
|
||||
rows.append((aid, merge_prof(aid, pro_index.get(aid))))
|
||||
return rows
|
||||
|
||||
if watchlist:
|
||||
rows = []
|
||||
for entry in watchlist:
|
||||
aid = int(entry["account_id"])
|
||||
rows.append((aid, merge_prof(aid, pro_index.get(aid), entry)))
|
||||
return rows
|
||||
|
||||
cutoff = None
|
||||
@@ -110,7 +211,7 @@ def filter_pros(
|
||||
|
||||
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]
|
||||
return [(aid, merge_prof(aid, prof)) for aid, prof, _ in picked]
|
||||
|
||||
|
||||
def player_match_metas(
|
||||
@@ -266,6 +367,7 @@ def write_out(
|
||||
limit: int,
|
||||
limit_pros: int,
|
||||
lobby_types: tuple[int, ...],
|
||||
player_source: str,
|
||||
) -> None:
|
||||
used = collect_item_ids(by_hero)
|
||||
items_out = {
|
||||
@@ -287,6 +389,7 @@ def write_out(
|
||||
"source": "opendota",
|
||||
"attribution": "https://www.opendota.com",
|
||||
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
||||
"player_source": player_source,
|
||||
"limit_per_pro": limit,
|
||||
"limit_pros": limit_pros,
|
||||
"lobby_types": list(lobby_types),
|
||||
@@ -294,7 +397,8 @@ def write_out(
|
||||
"match_count": match_count,
|
||||
"hero_count": len(by_hero),
|
||||
"note_zh": (
|
||||
"OpenDota 注册职业选手近期联赛/锦标赛对局;"
|
||||
"OpenDota 明星/职业选手近期联赛/锦标赛对局;"
|
||||
"默认名单见 web/data/pro_player_watchlist.json;"
|
||||
"lobby_type 1=训练/practice、2=tournament;"
|
||||
"含终局出装、加点与联赛名(若有)。"
|
||||
),
|
||||
@@ -310,16 +414,15 @@ def write_out(
|
||||
)
|
||||
|
||||
|
||||
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(
|
||||
"--watchlist",
|
||||
type=Path,
|
||||
default=WATCHLIST,
|
||||
help=f"Star player list JSON (default: {WATCHLIST})",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--limit",
|
||||
type=int,
|
||||
@@ -330,23 +433,28 @@ def main() -> None:
|
||||
"--limit-pros",
|
||||
type=int,
|
||||
default=DEFAULT_LIMIT_PROS,
|
||||
help=f"Max pros when --players omitted (default: {DEFAULT_LIMIT_PROS})",
|
||||
help=f"Max pros when --all-pros (default: {DEFAULT_LIMIT_PROS})",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--players",
|
||||
default="",
|
||||
help="Comma-separated account_id or registered pro name (overrides --limit-pros)",
|
||||
help="Comma-separated account_id or registered pro name (overrides watchlist)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--all-pros",
|
||||
action="store_true",
|
||||
help="Ignore watchlist; auto-pick from /proPlayers (see --limit-pros/--with-team)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--with-team",
|
||||
action="store_true",
|
||||
help="Only pros with a team_tag/team_name when picking from /proPlayers",
|
||||
help="With --all-pros: only pros with a team_tag/team_name",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--active-days",
|
||||
type=int,
|
||||
default=60,
|
||||
help="Skip pros with no last_match_time within N days (0=disable; default: 60)",
|
||||
help="With --all-pros: skip pros idle > N days (0=disable; default: 60)",
|
||||
)
|
||||
ap.add_argument("--delay", type=float, default=0.35)
|
||||
ap.add_argument(
|
||||
@@ -368,12 +476,30 @@ def main() -> None:
|
||||
_log(f" {len(pro_index)} registered pros")
|
||||
|
||||
player_ids = parse_pro_filter(args.players, pro_index)
|
||||
watchlist: list[dict] | None = None
|
||||
player_source = "cli"
|
||||
|
||||
if player_ids:
|
||||
player_source = "cli"
|
||||
elif not args.all_pros:
|
||||
watchlist = load_watchlist(args.watchlist)
|
||||
if not watchlist:
|
||||
raise SystemExit(
|
||||
f"Empty/missing watchlist {args.watchlist}. "
|
||||
"Add players or pass --players / --all-pros."
|
||||
)
|
||||
player_source = f"watchlist:{args.watchlist.name}"
|
||||
_log(f" watchlist {len(watchlist)} players ← {args.watchlist}")
|
||||
else:
|
||||
player_source = "proPlayers"
|
||||
|
||||
picked = filter_pros(
|
||||
pro_index,
|
||||
with_team=args.with_team,
|
||||
active_days=active_days,
|
||||
limit_pros=limit_pros,
|
||||
player_ids=player_ids,
|
||||
watchlist=watchlist,
|
||||
)
|
||||
if not picked:
|
||||
raise SystemExit("No pros matched filters")
|
||||
@@ -429,6 +555,7 @@ def main() -> None:
|
||||
limit=limit,
|
||||
limit_pros=len(picked),
|
||||
lobby_types=lobby_types,
|
||||
player_source=player_source,
|
||||
)
|
||||
_log(
|
||||
f"done pros={len(by_pro)} matches={total_matches} heroes={len(by_hero)} → {args.out}"
|
||||
|
||||
Reference in New Issue
Block a user