diff --git a/CHANGELOG.md b/CHANGELOG.md index 115090c..7788fe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ ### Added +- `Match(match_id)` works without a series ID: the match page is found from + the match ID and `series_id` is read from it (#50). - `Series.season(key)` finds a season by year or by ESPNcricinfo's two-year label (`"2024/25"`), and `Season.label` gives that label. Multi-year seasons live under their own series ID, now reachable as `season.series_id` (#76). diff --git a/README.md b/README.md index ac3ca65..7815bd9 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A Python client for ESPNcricinfo match, series, season, team and player data. ## Description -Match pages are loaded with Playwright (WebKit), because ESPN's CDN refuses plain HTTP clients; player, series, season and team records come from ESPN's core JSON API. A `Match` needs both a `match_id` and a `series_id`, which `Match.get_recent_matches()` and `Series(...).matches` provide. +Match pages are loaded with Playwright (WebKit), because ESPN's CDN refuses plain HTTP clients; player, series, season and team records come from ESPN's core JSON API. A `Match` needs only a `match_id`; `Match.get_recent_matches()` and `Series(...).matches` list them. Disclaimer: This library is not intended for commercial use and neither it nor its creator has any affiliation with ESPNCricInfo. The [LICENSE](https://github.com/outside-edge/python-espncricinfo/blob/master/LICENSE.txt) for this library applies only to the code, not to the data. @@ -54,10 +54,10 @@ MatchRef(series_id=1523975, match_id=1523981) ## A single match -A match needs both its match ID and series ID: the two numbers in a match page URL, or a `MatchRef`. Loading a match starts a headless WebKit browser. +A match needs only its match ID, the number in a match page URL. Passing the series ID too (`Match(1478914, 1478874)`, or `MatchRef.to_match()`) skips a redirect. Loading a match starts a headless WebKit browser. ```python ->>> m = Match(1478914, 1478874) # same as MatchRef(1478874, 1478914).to_match() +>>> m = Match(1478914) >>> m.description 'IND Women v AUS Women' >>> m.match_class diff --git a/espncricinfo/match.py b/espncricinfo/match.py index 105aa61..7ddbfc1 100644 --- a/espncricinfo/match.py +++ b/espncricinfo/match.py @@ -256,7 +256,9 @@ def build_team(t): "series": [ { "series_name": series_data.get("name", ""), - "core_recreation_id": str(series_data.get("objectId", series_id)), + "core_recreation_id": str( + series_data.get("objectId") or series_id or "" + ), "slug": series_data.get("slug", ""), } ], @@ -424,21 +426,32 @@ class Match: #: The parsed page, reshaped by ``_normalize``. Prefer the attributes. json: dict[str, Any] - def __init__(self, match_id: int | str, series_id: int | str) -> None: + def __init__(self, match_id: int | str, series_id: int | str | None = None) -> None: """Fetch and parse the match. Args: match_id: ESPNcricinfo match (event) id. - series_id: ESPNcricinfo series (league) id the match belongs to. + series_id: ESPNcricinfo series id the match belongs to. Optional: + without it the match page is found from the match id alone, + and ``series_id`` is read from the page. + + Raises: + MatchNotFoundError: If ESPNcricinfo has no such match. """ self.match_id = int(match_id) - self.series_id = int(series_id) - self.json = self._get_json() + given_series = int(series_id) if series_id is not None else None + self.json = self._get_json(given_series) data = self.json match = data["match"] series = data["series"][-1] - self.series_id = int(series["core_recreation_id"]) + page_series = series.get("core_recreation_id") + if page_series: + self.series_id = int(page_series) + elif given_series is not None: + self.series_id = given_series + else: + raise MatchNotFoundError(f"No series found for match {self.match_id}") self.description = data["description"] self.match_title = match["cms_match_title"] self.match_class = ( @@ -506,13 +519,18 @@ def __repr__(self) -> str: """Return ``Match(match_id, series_id)``.""" return f"{self.__class__.__name__}({self.match_id!r}, {self.series_id!r})" - def _get_json(self) -> dict[str, Any]: + def _get_json(self, series_id: int | None) -> dict[str, Any]: """Fetch the match page and reshape its __NEXT_DATA__ blob.""" - url = ( - f"https://www.espncricinfo.com/series/" - f"x-{self.series_id}/x-{self.match_id}/full-scorecard" - ) - return _normalize(_playwright_fetch(url), self.match_id, self.series_id) + if series_id is None: + # ESPNcricinfo redirects its legacy match URL to the full match + # page, whatever series the match belongs to. + url = f"https://www.espncricinfo.com/matches/engine/match/{self.match_id}.html" + else: + url = ( + f"https://www.espncricinfo.com/series/" + f"x-{series_id}/x-{self.match_id}/full-scorecard" + ) + return _normalize(_playwright_fetch(url), self.match_id, series_id) def _first_innings_of(self, team_id: str) -> dict[str, Any] | None: return next( diff --git a/tests/test_match.py b/tests/test_match.py index ced411e..7cf7a56 100644 --- a/tests/test_match.py +++ b/tests/test_match.py @@ -450,3 +450,21 @@ async def notebook_cell(): with patch.object(match_module, "_async_playwright_fetch", fake_fetch): assert asyncio.run(notebook_cell()) == {"url": "https://example.invalid/page"} + + +def test_match_without_series_id_uses_legacy_url_and_reads_series_from_page(): + next_data = json.loads((FIXTURE_DIR / "match_1478914_next_data.json").read_text()) + with patch("espncricinfo.match._playwright_fetch", return_value=next_data) as fetch: + match = Match(1478914) + fetch.assert_called_once_with( + "https://www.espncricinfo.com/matches/engine/match/1478914.html" + ) + assert match.series_id == 1478874 + + +@pytest.mark.integration +def test_live_match_from_id_alone(): + """#50: match 729303 (IPL 2014) loads without knowing its series.""" + match = Match(729303) + assert match.series_id == 695871 + assert match.batting_scorecard[0]