DEV Community

Cover image for One tennis rivalry, three different stories: build a surface-aware H2H card
PlantCrawl
PlantCrawl

Posted on Originally published at crawlplant.com AI-assisted

One tennis rivalry, three different stories: build a surface-aware H2H card

“Player A leads the head-to-head” sounds like a complete preview. It is usually the beginning of one.

Which surface? How many meetings? How recently? Were those normal completed matches, or did a retirement decide the result? A single career count compresses all of that into one confident-looking number.

Let's build a better rivalry card: wins by surface, the number of meetings alongside each comparison, and a separate place for current match context. We will use Sinner and Alcaraz as the example, but the workflow works for a WTA rivalry too.

Disclosure: I maintain CrawlPlant's tennis Actors. The examples use our independent data service, not an official ATP or WTA feed.

Run this project: Get the code, sample data and finished outputs on GitHub. To fetch your own data, use the live recipe and follow the .env setup guide to add your APIFY_TOKEN. Actor calls can incur charges; the setup explains per-run limits.

The visual we are aiming for

Sinner and Alcaraz head-to-head split by surface, with meeting counts

This is the completed-only CrawlPlant database sample retrieved on October 23, 2026: 17 completed meetings, split into nine on hard, six on clay and two on grass. It is a snapshot of this source's coverage, not an independently audited official head-to-head. The two grass meetings are especially important to label: two wins are interesting, but two matches do not establish a dependable forecasting rule.

The point of the chart is the change in context. A career total and a surface-specific total answer different questions.

Retrieve the rivalry as individual matches

Install apify-client, set APIFY_TOKEN, and use this helper. It retrieves every dataset page and refuses to treat a capped result as a complete history. Actor calls are paid according to the Store listing; set a spending limit in Apify before a large request.

import os
import json
import math
from pathlib import Path

def call_actor(slug, run_input):
    from apify_client import ApifyClient

    client = ApifyClient(os.environ["APIFY_TOKEN"])
    run = client.actor(f"crawlplant/{slug}").call(run_input=run_input)
    if not run or run["status"] != "SUCCEEDED":
        raise RuntimeError(f"Actor did not succeed: {run and run['status']}")
    rows = list(client.dataset(run["defaultDatasetId"]).iterate_items())
    if len(rows) >= run_input["maxItems"]:
        raise RuntimeError("Result reached maxItems; narrow the range or increase the cap before claiming completeness.")
    return rows
Enter fullscreen mode Exit fullscreen mode
rows = call_actor("tennis-match-history", {
    "playerUrls": ["https://www.flashscore.com/player/sinner-jannik/6HdC3z4H/"],
    "players": ["Alcaraz"],
    "status": "finished",
    "tours": ["atp", "challenger-men", "itf-men"],
    "maxItems": 200,
})
Enter fullscreen mode Exit fullscreen mode

The player URL fixes the first identity. The opponent filter is a name-contains search, so inspect the returned opponents before publishing a named rivalry. For a production report, verify the second player's playerId too. A short surname is convenient input, not a globally unique key.

Avoid the home-player trap

Tennis does not have a home side. player1 is the first player in that source record; the same person may be player2 in the next meeting.

This aggregation finds Sinner by ID for every match, drops duplicate match IDs, and counts only completed matches with a recorded winner:

def rivalry_by_surface(rows, player_id):
    totals = {}
    seen = set()
    for row in rows:
        if row.get("status") != "finished" or row.get("winner") not in ("player1", "player2"):
            continue
        if row["matchId"] in seen:
            continue
        seen.add(row["matchId"])
        side = next((s for s in ("player1", "player2") if (row.get(s) or {}).get("playerId") == player_id), None)
        if side is None:
            raise ValueError("The chosen player is absent from a returned match")
        surface = row.get("surface") or "unknown"
        total = totals.setdefault(surface, {"wins": 0, "losses": 0})
        total["wins" if row["winner"] == side else "losses"] += 1
    return totals
Enter fullscreen mode Exit fullscreen mode
card = rivalry_by_surface(rows, "6HdC3z4H")
for surface, record in card.items():
    n = record["wins"] + record["losses"]
    print(surface, f"{record['wins']}-{record['losses']}", f"n={n}")
Enter fullscreen mode Exit fullscreen mode

An unknown surface remains an explicit group. It does not silently become hard court. Retirements and walkovers are excluded from this particular analysis; an official head-to-head may use a different inclusion rule. State the rule near the chart rather than expecting readers to infer it.

Add today's context through the main Actor

A rivalry table describes their meetings. A preview also needs the conditions of the next match: scheduled time, surface, form, rest days and ratings.

context = call_actor("flashscore-tennis", {
    "mode": "matches",
    "dateFrom": "tomorrow",
    "players": ["Sinner"],
    "tours": ["atp"],
    "status": "scheduled",
    "includeOdds": False,
    "includeH2H": True,
    "maxItems": 50,
})

for match in context:
    print(match["matchId"], match["player1Name"], match["player2Name"],
          match.get("surface"), match.get("form"))
Enter fullscreen mode Exit fullscreen mode

There may be no scheduled match tomorrow, or it may be against someone else. Check both player IDs before attaching this context to a rivalry card. “No scheduled match returned” is a valid report outcome.

For a historical preview, filter the rivalry rows to dates before the target match, and use that match's stored pre-match fields. Today's career record would include results the historical preview could not have known.

Make the card useful to a reader

Show the surface record, its sample size and the last few actual meetings. Put a timestamp on current form. Leave an unavailable field blank with a clear label. Avoid a single “confidence score” that mixes all of those quantities without a model behind it.

The two Actors have distinct jobs here: Tennis Match History supplies the auditable list of meetings, while Flashscore Tennis supplies the match context. If one row from that list deserves a closer look, its matchId is the handoff to the point-by-point Actor.

That gives you a rivalry card a reader can question and inspect, instead of a number they simply have to trust.

Put the meetings behind the card

The completed-only calculation gives Sinner a 3–6 hard-court record, 2–4 on clay and 2–0 on grass in the saved coverage. Here are the latest three eligible meetings; scores below are in the winner's order:

Date Event Winner Score Source
2026-04-12 Monte Carlo Sinner 7–6(5) 6–3 6ye8F0kT
2025-11-16 Finals – Turin Sinner 7–6(4) 7–5 SGeB5San
2025-09-07 US Open Alcaraz 6–2 3–6 6–1 6–4 4EAVYPi5

Scheduled context: not fetched in the offline example. The live lookup above is a separate step, and may return no match. We do not attach today's form to a historical meeting.

The companion generates the surface chart from the individual records, and exports reports/rivalry.md and .json with the recent meetings and their links. Its completed-only filter excludes the retirement that was present in the earlier 18-match aggregate, leaving 17 meetings: nine hard, six clay, two grass. Exhibitions are excluded too.

Download and reproduce the example

Download the offline companion ZIP. Extract it, open a terminal in that folder and run:

python -m pip install -r requirements.txt
python render.py
Enter fullscreen mode Exit fullscreen mode

The package includes the dated sample data, calculation functions, complete chart renderers and finished reports. It generates the series' charts and reports together; no API token or paid Actor/model call is required. The live-fetch code earlier in this article is a separate option and can produce different counts.

Inspect the sources: chart renderer, AI charts, report generation, and data notes. Both PNG and SVG outputs are written to assets/; reports go to reports/.

Keep building: Browse all 48 runnable examples, including Python, JavaScript and AI workflows across the CrawlPlant Actors.

Top comments (0)