whoscored-event-data v2.0.0
A polite, typed Python SDK for scraping football match event data from Whoscored's chalkboard. Built for Python ≥ 3.9.
Overview
A rewrite of the original notebook-based scraper (v1.0.0) into a proper package. The event feed is decoded as real JSON, match pages are fetched over HTTP with rate limiting and caching, and a browser is only used when the site throws a bot challenge.
- One request per match — the whole event feed is embedded in a single page.
- Polite by default — 7 s delay + jitter between requests, on-disk caching.
- Auto browser fallback — a 403 challenge is retried through a real browser automatically.
- Backwards compatible — the
v1notebook functions still work viamain.py.
Install
pip install -r requirements.txt # or: pip install -e .
# extras: pip install -r requirements-visuals.txt (plots)
Dependencies: pandas, numpy, requests, beautifulsoup4, lxml, tqdm. selenium is optional and only needed for Cloudflare-protected listing pages.
Quick start
from whoscored import WhoscoredClient
with WhoscoredClient() as client:
match = client.get_match(1650630) # id or full match URL
print(f"{match.home.name} {match.score} {match.away.name}")
# -> Barcelona 0 : 0 Rayo Vallecano
events = match.events # DataFrame, one row per event (258 cols)
events = match.add_epv() # + Expected Possession Value column
events.to_csv("events.csv", index=False)
WhoscoredClient
Main entry point. Use it as a context manager.
| Method | Returns | Description |
|---|---|---|
get_match(id_or_url) | Match | Fetch and parse one match. |
get_matches(list) | list[Match] | Fetch many matches (progress bar, order preserved). |
list_leagues(refresh=False) | dict[str, str] | Leagues as {slug: url}. Offline via bundled snapshot. |
list_fixtures(league, season) | list[Fixture] | Fixtures for a competition/season (browser backend). |
team_fixtures(team, fixtures) | list[Fixture] | Filter a fixture list to one team. |
close() | — | Release browser/session resources. |
Constructor options
| Option | Default | Description |
|---|---|---|
backend | "http" | "http" for match pages, "browser" for Selenium. |
request_delay / jitter | 7 / 2 | Politeness pacing between requests. |
timeout / retries | 30 / 3 | Per-request timeout and retry count. |
cache_dir | None | On-disk cache for raw match payloads. |
headless | False | Headless browser (not recommended — easily flagged). |
browser / binary_location | "firefox" / auto | Browser engine and executable path. |
fallback_to_browser | True | Retry a 403 challenge through a real browser. |
proxy / proxy_pool / free_proxies | off | Proxy support (see Proxies). |
Match · Fixture · Team
| Member | Type | Description |
|---|---|---|
match.match_id | int | Whoscored match id. |
match.home / .away | Team | .name, .team_id, .venue. |
match.score | str | e.g. "0 : 0". |
match.league / .season / .date / .venue | str | Metadata. |
match.events | DataFrame | One row per event, 258 columns. |
match.matches_df | DataFrame | Match-level summary frame. |
match.add_epv() | DataFrame | Events with an EPV column. |
match.raw | dict | The full parsed payload. |
Fixture.date/.home/.away/.score/.url, .match_id | — | A listed fixture. |
League & fixture discovery
Listing pages are Cloudflare-protected and need a browser. A snapshot of every league is bundled, so list_leagues() works with no network.
with WhoscoredClient(backend="browser") as client:
fixtures = client.list_fixtures("spain-laliga", "2022/2023")
lfc = client.team_fixtures("Barcelona", fixtures)
EPV analysis
Expected Possession Value (a model by Laurie Shaw) is bundled with the SDK.
| Function | Description |
|---|---|
add_epv_to_dataframe(df) | Append the EPV column (non-NaN for successful passes). |
load_epv_grid() | Load the bundled 32×50 EPV surface. |
get_epv_at_location(x, y) | EPV for a point on the metric pitch. |
to_metric_coordinates_from_whoscored(x, y) | Whoscored → metric pitch coordinates. |
Utilities
| Function / class | Description |
|---|---|
save_dataframe(df, path) / load_dataframe(path) | CSV / Parquet round-trip, creates parent dirs. |
save_json(data, path) / load_json(path) | JSON round-trip. |
RateLimiter(delay, jitter) | Minimum gap between requests. |
retry(func, retries, backoff) | Exponential-backoff retry wrapper. |
create_events_dataframe(data) / create_matches_dataframe(data) | Build frames from raw payloads. |
Proxies
from whoscored import WhoscoredClient, ProxyRotator
# Static
with WhoscoredClient(proxy="host:port") as client:
client.get_match(1650630)
# Rotating pool, validated against a neutral endpoint
pool = ProxyRotator(proxies=["p1:8080", "p2:3128"], validate=True)
with WhoscoredClient(proxy_pool=pool) as client:
client.get_matches(urls)
With a pool, a blocked or dead proxy is skipped automatically; the request only fails once every proxy has been tried. Proxies are validated against gstatic.com, never against Whoscored.
CLI
whoscored --version
whoscored leagues --out leagues.json
whoscored match 1650630 --out data/ --epv
whoscored fixtures spain-laliga 2023/2024 --out fixtures.json
whoscored team "Real Madrid" --fixtures fixtures.json
whoscored scrape --fixtures fixtures.json --out scraped/ --delay 8
| Global flag | Description |
|---|---|
--delay N / --jitter N | Politeness pacing. |
--cache DIR | On-disk caching. |
--proxy URL / --free-proxies | Proxy support. |
python -m whoscored is equivalent to whoscored.
Errors
| Exception | Raised when |
|---|---|
WhoscoredError | Base class for all SDK errors. |
TransportError | A network request or browser navigation fails. |
BlockedError | Anti-bot / Cloudflare challenge (403/404). |
ParseError | Match-centre JSON can't be located or decoded. |
MatchNotFoundError / SeasonNotFoundError | No match page / no fixtures. |
BackendError / ProxyError | Misconfigured backend / empty proxy pool. |
Politeness & avoiding bans
- Rate limiting on by default (7 s + jitter); tune with
request_delay=/jitter=or--delay. - Set
cache_dirso re-runs never touch the site. - Realistic browser headers are sent by default.
- Challenged pages go through a real headed browser instead of hammering HTTP.
BlockedError. If you keep seeing it, stop scraping for a while; continuing prolongs the block. Respect Whoscored's terms of service.Browser setup
- Ubuntu/Debian snap Firefox:
/usr/bin/firefoxis a shell wrapper, not a real binary, so geckodriver fails withbinary is not a Firefox executable. The SDK auto-detects the real binary (override viabinary_location=orFIREFOX_BIN). - Driver shutdown noise (the
PermissionError/Error terminating service processtracebacks and themight not be compatiblewarning) is silenced automatically. SetSE_DEBUG=1to restore diagnostics. - Driver path: found via
PATH, or setSE_GECKODRIVER/SE_CHROMEDRIVER.
Examples
Fetch many matches, add EPV, save
from whoscored import WhoscoredClient, save_dataframe
urls = ["https://www.whoscored.com/Matches/1650630/Live/...",
"https://www.whoscored.com/Matches/1650634/Live/..."]
with WhoscoredClient(cache_dir=".whoscored_cache") as client:
matches = client.get_matches(urls)
for match in matches:
save_dataframe(match.add_epv(), f"data/events_{match.match_id}.csv")
A whole season for one team
with WhoscoredClient(backend="browser") as client:
fixtures = client.list_fixtures("spain-laliga", "2022/2023")
barca = client.team_fixtures("Barcelona", fixtures)
with WhoscoredClient() as client:
matches = client.get_matches([f.url for f in barca])
Testing
The suite runs entirely offline against a captured match page — no network, no ban risk.
python test.py # offline suite (47 tests)
python test.py --live # + one real match fetch
v1 notebook API
The original notebook functions still work via main.py (with a DeprecationWarning): getLeagueUrls, getMatchUrls, getTeamUrls, getMatchesData, getMatchData, getFixtureData, createEventsDF, createMatchesDF, translateDate, getSortedData, load_EPV_grid, get_EPV_at_location, to_metric_coordinates_from_whoscored, addEpvToDataFrame, main_url.
Repo layout
whoscored/ the SDK package
client.py WhoscoredClient (main entry point)
parser.py match-centre JSON extraction
dataframe.py event / match DataFrame builders
epv.py EPV helpers
models.py Match / Fixture / League / Team
transports.py HTTP + Selenium browser backends
discovery.py league / fixture listing
proxies.py rotating proxy pool
cache.py on-disk cache
utils.py RateLimiter, retry, save/load helpers
cli.py the `whoscored` command
main.py v1 compatibility shims
tests/ offline test suite + captured fixture
docs/index.html this documentation site