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:
voson
2026-07-29 16:03:45 +08:00
co-authored by Cursor
parent 09bd5aabb3
commit d9c7b4b7b1
11 changed files with 1073 additions and 130 deletions
+154 -27
View File
@@ -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}"