Export Tennis Abstract ATP and WTA player match logs with scores, opponents, surfaces and serve/return counts. Filter by season, tournament or head-to-head opponent.
2026-10-08: Trim the public README to its buyer facts in a docs-only rebuild of the deployed 0.1.4 file set; preserve runtime, schemas, prefills, pricing and listing.
2026-10-07: Refresh public listing and README copy in a docs-only rebuild; preserve runtime, schemas, prefills and pricing.
0.2
Adds a tournaments mode: every result table of a Tennis Abstract tournament
page as flat dataset rows, beside the unchanged players mode.
WTA biography rows cite the fetched shortlist JS file supplying their bio;
match rows and career-only null-biography rows keep their classic-page links
(item 20, #5).
Empty players input and invalid run-level tour make no source request,
including no warm-up, with cap/span validation precedence preserved
(item 21, #6).
Transport exhaustion performs no final sleep, rotation callback or warm-up.
A later call on the reused client opens and warms a fresh session before
its first attempt; attempt budgets and terminal errors are unchanged (item 24).
The buyer README starts from the reviewed 0.1.5 text and adds the measured
tournament rows, caps and errors, with the #5/#6 claims updated (item 23).
What it does
A mode input, default players. An input without mode runs exactly
as in 0.1: same rows, same defaults, same inputs read. tournaments switches
the run to tournament pages, and the players-mode inputs are then not read.
One tournament page per entry in tournaments, written as the page's own
t= value (2024-540/Wimbledon) or as a pasted
https://www.tennisabstract.com/cgi-bin/tourney.cgi?t=… URL. The value is
kept exactly as given — M25-Ajaccio+H keeps its + — and a value without
its /Name part is refused rather than guessed. Repeated entries are fetched
and billed once.
Every result table of the page, in page order. The main draw and the
qualifying draw become recordType: "tournamentMatch" rows — round, winner
and loser with their site player ids, seeds, entry codes, countries and
ranks, score, and each side's printed serve rates and break points saved and
faced. The other six tables (seed outcomes, this event's finals, surface
speed, stat summaries, winners and errors, other events' finals that week)
become recordType: "tournamentTable" rows, each carrying its table's own
header beside its cell values. On the measured 2024 Wimbledon page that is
127 main-draw rows first and 467 rows in all.
A maxRowsPerTarget cap, default 500, 0 for every row, applied per
page in page order. The buyer's own spending limit stops the run between
tournaments and writes a charge-limit entry naming the tournaments not
fetched, as it does between players.
Correctness choices worth knowing
Rates ship as the page prints them. The singles-results and
qual-results match tables print percentages and dominance ratio as text
and publish none of the counts behind them except break points saved and
faced. Match rates stay strings ("5.3%") and the break-point pair is split
into two integers; no rate is derived. Generic stat-summaries rows retain
published counts (Pts, A, DF, BPFaced, BPEarned) as printed text.
Tournament years are checked before billing. The requested year is
compared with the published title and visible event heading when present.
A contradiction or unreadable title year is unreadable-page, with zero
rows from that target. An absent site title, including a commented-only
title, is blocked. No event-name or event-id derivation is claimed.
Table selection uses actual HTML attributes. The table's id selects
it regardless of quote style, attribute order or whitespace; data-id does
not. Classification also uses the actual title and main-table id.
Comments contribute no identity, table, row or cell text, and cannot consume
the row cap. Quoted > in attributes does not leak into values or split cells.
Malformed pasted URLs are per-entry errors. URL parsing failures are
recorded as unsupported-target; later valid tournament entries still run.
Drift is refused, not guessed. A main-draw or qualifying header that
differs from the measured one, a row whose width differs from its header's,
a player cell or rank or break-point cell outside its measured shape: the
page is reported as unreadable-page and no row is taken from it.
A live source fault is one error, not a retry. A current event can answer
with the site's own Python traceback instead of the page; that is recorded
once as source-error in ERRORS, with no row and no second request.
No tournaments is an empty result. A tournaments run with no entries
fetches nothing and is not back-filled. An unknown mode or an unusable
maxRowsPerTarget ends as an empty result with one unsupported-target
entry in ERRORS, like the 0.1 input errors.
Not part of this release
The page's "Extreme Performances" block (prose, not a table) and its event
header (name, date, surface and draw size) are not exported.
The WTA tournament page is not read: it was never measured as a complete
link. The per-player charting tables and the rankings and Elo reports remain
planned.
0.1 — 2026-09-11
First release: ATP and WTA career match logs from Tennis Abstract as flat typed
dataset rows.
What it does
One row per match, with date, tournament, surface, level, round, score,
opponent, and the raw per-match serve and return counts the source stores —
51 columns on an ATP row, 48 on a WTA row. Absent values are null, never
0, and the serve/return block blanks as a whole when the source recorded no
statistics for that match.
A whole career in one request. The ATP career page embeds the full match
array, so a career is a single HTTPS GET — 1,516 matches for Novak Djokovic,
2003-01-06 to 2026-08-31, on the measured capture. A WTA career is assembled
the way the source's own page assembles it, from the shortlist file plus the
older-history file: 130 + 418 = 548 rows for Iga Swiatek.
Many players per run, addressed as bare slugs, as pasted player-page URLs
in either p= form, or through the discovery tokens @elo-top:N,
@leaders:1-50 and @wta-leaders:1-50, which resolve names through the
slug rule the source's own JavaScript publishes.
Filters over rows already fetched — season, surface, level, round,
tournament, result and head-to-head opponent — so a head-to-head costs the
same as the career it came from, and no filter value is ever sent to the
source as a request parameter.
An opt-in player record row (includePlayerRecord) carrying current and
peak rank, birth date, hand, backhand, country, active flag, Twitter handle,
and the match total the source served for that player.
Two run ceilings: maxMatchesPerPlayer (default 100, 0 for all) and
maxPlayers (default 50, maximum 200, applied after discovery so a token
cannot fan out past it; a higher value is clamped with a warning, not
refused). A run also stops when the buyer's own Apify spending limit is
reached, writing a charge-limit entry to ERRORS naming the targets it did
not fetch.
Correctness choices worth knowing
Column names come from the page, not from a hard-coded list: rows are
mapped through the header array the source publishes, so an inserted column
cannot shift the data.
Identity is checked against the page's own published name. The source
answers 200 for a slug it does not have and serves a different player's
page; such a page is reported as wrong-player and no row is taken from it.
Where a surface publishes no name of its own, the rows are still delivered
and the target carries an identity-uncheckednote — a note beside
delivered rows, never a failure.
Failures never enter the dataset. Every per-target failure goes to the
run's ERRORS key-value record with four keys (target, slug,
errorClass, message); the class vocabulary is ten values, eight
page-level and two run-level.
Empty results exit cleanly. No players, filters matching nothing, or an
input value the Actor cannot act on: the run succeeds with an empty dataset,
one ERRORS entry where there is something to say, and guidance in the log —
never a traceback and never a failed run.
No derived statistic is emitted. The source's displayed rates are
computed at render time; the README gives the formulas so they can be derived
from the raw counts instead of guessed.
Raw counts only, no doubles. The measured doubles history file answers
404 and the career page never fills its doubles array, so no doubles switch
is offered rather than one that silently does nothing.
Not part of this release
Store listing craft — icon, published example tasks and Store SEO fields — is
the operator's pre-publication step, not something this release performs.
Tournament draws, the per-player charting tables and the rankings/Elo reports
are planned for 0.2; charted point logs and draw forecasts for 0.3. Both row
families ship into the same dataset, which is why every row already carries
recordType.
The leaders files are read for names only. Their bulk match arrays are
deferred to 0.3 as a mode of their own, so no match datum is lost — the same
matches ship per player through @leaders: and the career route.
The site's recent-results fragment, /jsfrags/, is excluded as a route
rather than as data: every row it carries is already on the allowed career
page, and the hrefs it adds are on allowed pages too.