# TennisExplorer Scraper & New-Result Monitor - Odds & Scores (`neverempty/tennisexplorer-scraper`) Actor

Turn monitoring on and every later run returns only the matches it has not returned before, so you are never charged for the same match twice. Every TennisExplorer match as clean JSON: set-by-set scores with tiebreaks, decimal odds for both sides, seeds, singles and doubles, ATP and WTA. Unofficial.

- **URL**: https://apify.com/neverempty/tennisexplorer-scraper.md
- **Developed by:** [NeverEmpty](https://apify.com/neverempty) (community)
- **Categories:** Sports, Developer tools, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $5.48 / 1,000 match returneds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## TennisExplorer Scraper & New-Result Monitor — Odds & Scores

**Turn monitoring on and every later run returns only the matches it has not returned before, so you are never charged for the same match twice.**

Every match TennisExplorer publishes for a day, as one flat row: set-by-set scores with tiebreak points, decimal odds for both sides, seeds, tournament, and singles or doubles — ATP, WTA, Challenger and ITF together.

> **Unofficial.** This Actor is not affiliated with or endorsed by TennisExplorer. It reads public pages only, and nothing that `robots.txt` disallows.

### What it does

- 🎾 **A whole day in one request.** TennisExplorer has no pagination: one day is one page. A busy day carries roughly 200–350 matches across all tours (2026-09-05: **224**).
- 📊 **Odds on about 95% of matches.** Decimal odds for both sides, exactly as printed (2026-09-05: **212 of 224**, ranging 1.03–7.07).
- 🔁 **Monitoring mode.** Remembers every match id it has returned and, on later runs, returns only the new ones. Run it on a schedule and pay only for matches you have not received yet.
- 📅 **Ten years of history.** Any past day is available (2016-09-05 still returns **356** matches).
- 👥 **Singles and doubles.** Doubles are teams on TennisExplorer, with their own team page — they are returned and marked, not skipped (2026-09-05: 161 singles, 63 doubles).
- 🗓 **Scheduled matches too.** The `matches` section returns the day's card, with head-to-head records for most of it (2026-09-06: **574 of 609**).

### Best for

- **Betting and odds research** — every finished match comes with the closing decimal price for both players, so you can rebuild a season's line history.
- **Model training and backtesting** — set-by-set scores with tiebreak points, and a flag saying which side is printed first, so a model is never fed a winner it was supposed to predict.
- **Watching a tournament** — turn monitoring on and each later run returns only matches it has not returned before.

### How fast, and what it costs to run

Measured on this Actor on 2026-09-06 (`section: results`, `tour: all`, `maxMatches: 120`):

| | |
|---|---|
| Matches returned | **120 across 9 tournaments** |
| Run time | **4 seconds** |
| Platform cost of that run | **$0.0007** |
| Matches carrying both closing odds | **112 of 120 (93%)** |
| Matches with a seed printed | 23 of 120 (19%) |
| Matches with tiebreak points broken out | 6 of 120 (5%) |

One list page carries many matches, so a run of this size costs a fraction of a cent to execute.

### What you get in every row

| Field | What it is |
|---|---|
| `matchId`, `matchUrl` | TennisExplorer's own match id and its detail page |
| `date`, `startTimeLocal` | The day you asked for, and the start time **exactly as the site prints it** |
| `tournament`, `tournamentUrl` | The event this match belongs to |
| `matchType` | `single` or `double` |
| `player1Name` / `player2Name` | The full name as the site spells it in the link title |
| `player1Display` / `player2Display` | The shortened name the site shows in the table |
| `player1Slug`, `player1Url` | The player page, or the doubles-team page for a pair |
| `player1Seed`, `player2Seed` | Seed where one is shown (2026-09-05: 107 of 448 sides) |
| `player1SetsWon`, `player2SetsWon` | Sets won |
| `sets` | Per set: games for both sides and the tiebreak points |
| `scoreText` | The score as one line, e.g. `3-6 7-6(3) 4-6 7-6(6) 6-3` |
| `winner`, `winnerName` | Which side won |
| `playerOrder` | `winner-first` or `as-listed` — see below, this matters |
| `player1Odds`, `player2Odds` | Decimal odds, `null` when the page shows none |
| `headToHead` | Head-to-head record (scheduled matches only) |
| `isCompleted` | Whether the match has a score yet |

### 🔴 Read this before you model on the data: the winner comes first

**On the results pages TennisExplorer prints the winner on the first line of every match.** This was checked on 580 matches across two fixture days and there is not one exception.

So on a finished match:

- `player1` is **the player who won**, not "the home side" or "the one who served first".
- `player1Odds` is therefore **the odds that were on the winner**, and `player2Odds` the odds on the loser.

Treating `player1` as a neutral side will quietly bias anything you build from it. Every row says which arrangement it is in, in `playerOrder`: `winner-first` for finished matches, `as-listed` for matches that have not started.

### What you can do with it

**📈 Build a form table.** Pull `dateFrom` to `dateTo` for a player and get every match with the score, the surface's tournament, and the odds at the time.

**🎯 Study closing odds against results.** Because the winner is first, `player1Odds` is the winning price on every finished match — favourite-versus-underdog splits come out of one column.

**🔔 Follow results without paying twice.** Turn monitoring on, run it hourly during a tournament, and each run returns only the matches that finished since the last one.

**🗓 Preview a card.** Set the section to `matches` for the day's schedule with head-to-head records before play starts.

### Configure the run

A first run with the defaults reads yesterday's results across all tours:

```json
{
  "maxMatches": 50
}
```

One tournament, one week, singles only:

```json
{
  "tour": "atp-single",
  "dateFrom": "2026-08-25",
  "dateTo": "2026-09-05",
  "tournaments": ["US Open"],
  "maxMatches": 300
}
```

Monitoring, on a schedule:

```json
{
  "monitoringMode": true,
  "maxDays": 3,
  "maxMatches": 200
}
```

Or paste a day page and it wins over every field above:

```json
{
  "searchUrl": "https://www.tennisexplorer.com/results/?type=wta-single&year=2026&month=09&day=05"
}
```

### Input reference

Every field is optional; the defaults read yesterday's results across all tours.

| Field | Type | Default | What it does |
|---|---|---|---|
| `searchUrl` | string | `(empty)` | Paste a TennisExplorer day page, e.g. https://www.tennisexplorer.com/results/?type=atp-single\&year=2026\&month=09\&day=05 . When set, it wins: the section, tour and day in the… |
| `section` | string | `results` | results = finished matches with scores and odds. matches = the day's card, which mixes matches not started yet with ones already finished. |
| `tour` | string | `all` | Which table to read. On 2026-09-05 all = 224 matches = atp-single 89 + atp-double 39 + wta-single 72 + wta-double 24, so all also includes Challenger and ITF events.… |
| `dateFrom` | string | `(empty)` | First day to read. Empty = yesterday for results, today for scheduled matches. There is no pagination on TennisExplorer: one day is one page, so a range is read one day at a… |
| `dateTo` | string | `(empty)` | Last day to read. Empty = same as the first day. Days in the future have no results yet and come back as an empty-day row that is not charged. |
| `maxDays` | integer | `0` | Hard cap on how many days are requested, counted from the newest. 0 = read the whole range. Monitoring runs with no dates set use 3. Reading a day is free; only returned… |
| `maxMatches` | integer | `50` | How many matches to return. You are charged for the rows you actually receive. A busy day carries roughly 200-350 matches across all tours. |
| `monitoringMode` | boolean | `false` | Off = return the matches of the days you asked for. On = remember every match id already returned and, on later runs, return only the ones that are new, so the same match is… |
| `resetMonitoringState` | boolean | `false` | Clear the remembered match ids before this run, so everything counts as new again. Use it after changing what you follow. |
| `players` | array | `[]` | Keep a match only if one of these appears in either side's name or slug (case-insensitive, partial). Example: djokovic, swiatek. Doubles teams are matched on the team name too. |
| `tournaments` | array | `[]` | Keep a match only if the tournament name contains one of these (case-insensitive, partial). Example: US Open, Challenger. |
| `matchTypes` | array | `[]` | Keep only singles or only doubles. Empty = both. Doubles are teams on TennisExplorer and have a team page rather than a player page. |
| `keywords` | array | `[]` | Keep a match only if one (Any) or all (All) of these appear in the tournament name, either player's name or the score. |
| `keywordMatch` | string | `any` | Any = at least one keyword. All = every keyword. |
| `excludeKeywords` | array | `[]` | Drop a match if any of these appear in the same fields. |
| `minOdds` | integer | `0` | Keep a match only if at least one side is quoted at or above this. 0 = no limit. Odds are decimal (1.03 to 7.07 on 2026-09-05, median 1.56) and are present on about 95% of… |
| `maxOdds` | integer | `0` | Keep a match only if at least one side is quoted at or below this. 0 = no limit. |
| `requireOdds` | boolean | `false` | Drop matches with no odds shown on the page. About 5% of matches have none. |
| `onlyCompleted` | boolean | `false` | Keep only matches that have a score. The scheduled-matches section mixes finished and not-yet-started matches in one table. |
| `onlyUpcoming` | boolean | `false` | Keep only matches with no score yet. |
| `useProxy` | boolean | `false` | Off by default: TennisExplorer answered 200 from a plain Apify datacentre address when this Actor was built (2026-09-06). If the site starts answering 403, 429 or 503, the… |

### What this Actor refuses to guess

TennisExplorer has two habits that quietly produce wrong data, and this Actor checks for both **before** it asks:

- **An impossible day answers HTTP 200.** Ask for `2026-02-30` and the site redirects to today and serves today's matches. Nothing tells you the date was dropped. The day is validated before the request, and the address the site actually landed on is checked afterwards; if the date was dropped you get a `no-such-search` row, never today's matches labelled as yours.
- **An unknown tour is not rejected — it is ignored.** Ask for `type=whatever` and you get every match of the day, which looks exactly like a filter that matched everything. Only the five tours the site really has are accepted.

A day with no matches, a day the site would not answer for, filters that matched nothing, and a page that could not be read each come back as **their own row with the reason written out**, and none of them are charged.

### Pricing

Pay per result: you are charged for the match rows you actually receive. Rows that only explain why there is nothing (`no-results`, `no-such-search`, `unreadable`, `no-filter-match`, `no-new-matches`) are never charged.

Reading pages is free — only returned matches cost anything, so a wide date range with a small `maxMatches` is cheap.

### Questions

**Are the odds the opening or the closing price?**
They are the prices TennisExplorer prints next to a finished match, which are the closing prices. `oddsText` keeps the site's own text so you can check. 112 of the 120 matches in the run above carried both.

**Why is `headToHead` empty?**
Because it is only printed on the schedule, not on the results pages. Run `section: upcoming` to get it. This Actor does not copy a value across sections to make a column look full.

**How do I avoid training a model on the answer?**
Finished matches are printed winner-first on the site. Every row carries `playerOrder`, and it was `winner-first` for 120 of 120 matches in the run above. Use it, or use `winner` and `winnerName` directly — do not assume player 1 is the home side.

**Do I get charged for days with no matches?**
No. A day with no play, a filter that matched nothing, and a page that could not be read each come back as a row with the reason, and none of them are charged.

**Can I follow a single player or tournament?**
Yes — `players` and `tournaments` filter locally after the page is read, so the filter is applied to the rows you can see rather than to a search box that might ignore it.

### How this differs from other tennis scrapers

- **It says which side is printed first.** Finished matches on this site are winner-first; a scraper that maps them to home/away hands you the answer inside the features.
- **It keeps the site's own text** next to the parsed values (`oddsText`, `resultRaw`, `scoreText`), so any number can be checked against what was printed.
- **It does not fill columns across sections.** `headToHead` stays empty on results pages instead of being borrowed from elsewhere.
- **Empty days are explained, not billed.**

### Notes on the data

- **Start times are not converted.** The page carries no time zone and no epoch, so the time is returned as the site prints it (`23:40`). Converting it would invent a fact.
- **Odds are decimal** and are returned only when a number is actually printed. About 5% of matches have none; those rows have `null`, not `0`, and filters on odds never drop them.
- **`all` includes more than ATP and WTA.** On 2026-09-05, `all` = 224 = ATP singles 89 + ATP doubles 39 + WTA singles 72 + WTA doubles 24, which on that day accounted for all of it; on other days `all` also carries Challenger and ITF events.
- **No personal contact data.** The pages carry match records only; no e-mail address or phone number appears in any row, and a test fails if one ever does.

### Every number in this README is checked

The fixtures in `test/fixtures/` are the real pages this Actor was built from (2026-09-06). The test suite re-counts every figure quoted above — 224, 89, 39, 356, 212, 107, 161, 63, 574, 609 — so if TennisExplorer changes its markup, the tests fail rather than the data going quietly wrong.

# Actor input Schema

## `searchUrl` (type: `string`):

Paste a TennisExplorer day page, e.g. https://www.tennisexplorer.com/results/?type=atp-single\&year=2026\&month=09\&day=05 . When set, it wins: the section, tour and day in the URL are used as they are and the fields below are ignored. TennisExplorer answers HTTP 200 and quietly redirects to today when the day does not exist, so the day is checked before it is requested.

## `section` (type: `string`):

results = finished matches with scores and odds. matches = the day's card, which mixes matches not started yet with ones already finished.

## `tour` (type: `string`):

Which table to read. On 2026-09-05 all = 224 matches = atp-single 89 + atp-double 39 + wta-single 72 + wta-double 24, so all also includes Challenger and ITF events. TennisExplorer does not reject an unknown value - it silently returns every match of the day - so only these five are accepted.

## `dateFrom` (type: `string`):

First day to read. Empty = yesterday for results, today for scheduled matches. There is no pagination on TennisExplorer: one day is one page, so a range is read one day at a time, newest first.

## `dateTo` (type: `string`):

Last day to read. Empty = same as the first day. Days in the future have no results yet and come back as an empty-day row that is not charged.

## `maxDays` (type: `integer`):

Hard cap on how many days are requested, counted from the newest. 0 = read the whole range. Monitoring runs with no dates set use 3. Reading a day is free; only returned matches are charged.

## `maxMatches` (type: `integer`):

How many matches to return. You are charged for the rows you actually receive. A busy day carries roughly 200-350 matches across all tours.

## `monitoringMode` (type: `boolean`):

Off = return the matches of the days you asked for. On = remember every match id already returned and, on later runs, return only the ones that are new, so the same match is never charged twice. The first run returns everything and remembers it. Ids are kept per section and tour.

## `resetMonitoringState` (type: `boolean`):

Clear the remembered match ids before this run, so everything counts as new again. Use it after changing what you follow.

## `players` (type: `array`):

Keep a match only if one of these appears in either side's name or slug (case-insensitive, partial). Example: djokovic, swiatek. Doubles teams are matched on the team name too.

## `tournaments` (type: `array`):

Keep a match only if the tournament name contains one of these (case-insensitive, partial). Example: US Open, Challenger.

## `matchTypes` (type: `array`):

Keep only singles or only doubles. Empty = both. Doubles are teams on TennisExplorer and have a team page rather than a player page.

## `keywords` (type: `array`):

Keep a match only if one (Any) or all (All) of these appear in the tournament name, either player's name or the score.

## `keywordMatch` (type: `string`):

Any = at least one keyword. All = every keyword.

## `excludeKeywords` (type: `array`):

Drop a match if any of these appear in the same fields.

## `minOdds` (type: `integer`):

Keep a match only if at least one side is quoted at or above this. 0 = no limit. Odds are decimal (1.03 to 7.07 on 2026-09-05, median 1.56) and are present on about 95% of matches; a match with no odds is never dropped by this filter because the value is unknown, not low.

## `maxOdds` (type: `integer`):

Keep a match only if at least one side is quoted at or below this. 0 = no limit.

## `requireOdds` (type: `boolean`):

Drop matches with no odds shown on the page. About 5% of matches have none.

## `onlyCompleted` (type: `boolean`):

Keep only matches that have a score. The scheduled-matches section mixes finished and not-yet-started matches in one table.

## `onlyUpcoming` (type: `boolean`):

Keep only matches with no score yet.

## `useProxy` (type: `boolean`):

Off by default: TennisExplorer answered 200 from a plain Apify datacentre address when this Actor was built (2026-09-06). If the site starts answering 403, 429 or 503, the Actor switches to a proxy session on its own and tries again.

## Actor input object example

```json
{
  "section": "results",
  "tour": "all",
  "maxDays": 0,
  "maxMatches": 50,
  "monitoringMode": false,
  "resetMonitoringState": false,
  "players": [],
  "tournaments": [],
  "matchTypes": [],
  "keywords": [],
  "keywordMatch": "any",
  "excludeKeywords": [],
  "minOdds": 0,
  "maxOdds": 0,
  "requireOdds": false,
  "onlyCompleted": false,
  "onlyUpcoming": false,
  "useProxy": false
}
```

# Actor output Schema

## `results` (type: `string`):

One row per match: the tournament and its TennisExplorer page, the day and the start time exactly as the site prints it (the page carries no time zone, so it is not converted), both sides with their full name as the site spells it in the link title, the shortened name it displays, the player or doubles-team slug and page, and the seed where one is shown; sets won by each side, a set-by-set breakdown with tiebreak points, a plain score line, the winner, and whether the match has finished; decimal odds for both sides where the page shows them. Singles and doubles are both returned and are marked as such. Days with no matches, days TennisExplorer would not answer for, filters that matched nothing and pages that could not be read come back as their own rows and are not charged.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "searchUrl": "",
    "dateFrom": "",
    "dateTo": ""
};

// Run the Actor and wait for it to finish
const run = await client.actor("neverempty/tennisexplorer-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "searchUrl": "",
    "dateFrom": "",
    "dateTo": "",
}

# Run the Actor and wait for it to finish
run = client.actor("neverempty/tennisexplorer-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "searchUrl": "",
  "dateFrom": "",
  "dateTo": ""
}' |
apify call neverempty/tennisexplorer-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,neverempty/tennisexplorer-scraper"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/cbNGhACpY1BwqxmYu/builds/S0n0wXHXSeNL9u01S/openapi.json
