This article was originally published on the Zenrows blog. Read the original here: https://www.zenrows.com/blog/scrape-2026-fifa-world-cup-data-python
We built a Python pipeline over three sources (Sofascore, FIFA, and Wikipedia) to pull the full 2026 World Cup dataset. Each source delivered its data differently. Each needed a different extraction strategy. This is how we handled all three, and what the data revealed when we had it.
Full source on GitHub.
Before you start
- Python 3.10 or later
- Zenrows account for your API key
-
playwright,requests,beautifulsoup4,pandas,python-dotenv
pip install requests playwright beautifulsoup4 pandas python-dotenv
Three sources, three access patterns
No single source had everything. FIFA had team-level metrics. Sofascore had player-level statistics. Wikipedia had tournament metadata.
The access patterns were all different across the three sources.
- Sofascore delivered data through a JavaScript-rendered JSON endpoint
- FIFA delivered it through a backend API behind a per-session JWT
- Wikipedia delivered server-rendered HTML
Standard requests handles the last one. It fails silently on the first two, returning an empty shell or a challenge page instead of data.
Retrieving Sofascore data from a JavaScript endpoint
Sofascore is a single-page application. The front end calls an internal API and JavaScript renders the result into the browser. requests alone returns the application shell with no data.
We used Zenrows Fetch with mode=auto. Because the endpoint returns JSON, there was no HTML parsing step. The response was already structured data.
import os
import time
from urllib.parse import urlencode
import requests
ZENROWS_API_KEY = os.getenv("ZENROWS_API_KEY")
class SofascoreClient:
BASE_URL = "https://www.sofascore.com/api/v1"
def _get(self, endpoint: str, params: dict | None = None):
base_url = f"{self.BASE_URL}/{endpoint.lstrip('/')}"
target_url = f"{base_url}?{urlencode(params)}" if params else base_url
retries = 3
for attempt in range(retries):
try:
response = requests.get(
"https://api.zenrows.com/v1/",
params={
"apikey": ZENROWS_API_KEY,
"url": target_url,
"mode": "auto", # handles JS rendering and proxy escalation per request
},
timeout=120,
)
response.raise_for_status()
return response.json()
except (
requests.exceptions.ReadTimeout,
requests.exceptions.ConnectionError,
requests.exceptions.HTTPError,
) as e:
print(f"Attempt {attempt + 1}/{retries} failed: {e}")
if attempt == retries - 1:
raise
wait = 2 ** attempt
print(f"Retrying in {wait}s...")
time.sleep(wait)
def fetch_statistics(self, group, order, page=1, limit=20, accumulation="total"):
endpoint = f"unique-tournament/{TOURNAMENT_ID}/season/{SEASON_ID}/statistics"
params = {
"group": group,
"order": order,
"page": page,
"limit": limit,
"accumulation": accumulation,
}
return self._get(endpoint, params=params)
Retrieving FIFA data from an authenticated API
FIFA's data came from a backend API called with a JWT bearer token issued for the current browsing session. Every visitor gets a temporary token; the front end attaches it to subsequent API calls. The endpoint couldn't be queried as a public URL.
We used Zenrows Browser Sessions with Playwright to follow the same authenticated flow a normal browser follows and intercepted the API responses.
import os
from playwright.async_api import async_playwright
class FIFAClient:
def __init__(self):
self.api_key = os.getenv("ZENROWS_API_KEY")
self.connection_url = f"wss://browser.zenrows.com?apikey={self.api_key}"
self.responses = {}
async def _handle_response(self, response):
if "gameday-prod.fifa.mangodev.co.uk" not in response.url:
return
self.responses[response.url] = await response.json()
async def fetch_all(self):
async with async_playwright() as p:
browser = await p.chromium.connect_over_cdp(self.connection_url)
context = browser.contexts[0]
page = await context.new_page()
page.on("response", self._handle_response) # intercept all API responses
await page.goto(self.URL)
await self._remove_cookie_banner(page)
for tab in self.TABS:
await self._click_tab(page, tab)
self._save_files()
Retrieving Wikipedia tournament metadata
Wikipedia is server-rendered. Fetch retrieved it with no extra capabilities. BeautifulSoup located the stadium summary table by its headers.
from bs4 import BeautifulSoup
import re
def normalize(self):
with open(self.INPUT_FILE, encoding="utf-8") as f:
soup = BeautifulSoup(f, "html.parser")
target_table = None
for table in soup.find_all("table", class_="wikitable"):
headers = [th.get_text(" ", strip=True) for th in table.find_all("th")]
if "Year" in headers and "Host" in headers and "Stadiums" in headers:
target_table = table
break
records = []
for row in target_table.find("tbody").find_all("tr")[1:]:
cells = row.find_all(["th", "td"])
if len(cells) < 4:
continue
year_match = re.search(r"\d{4}", cells[0].get_text())
if not year_match:
continue
records.append({
"year": int(year_match.group()),
"hosts": [t.strip() for t in cells[1].stripped_strings if t.strip()],
"cities": int(re.search(r"\d+", cells[2].get_text()).group()),
"stadiums": int(re.search(r"\d+", cells[3].get_text()).group()),
})
Normalizing three sources into one format
Three sources fetched three ways produce three shapes of data. Each had its own normalization layer. Sofascore returned duplicate players across statistic groups, so we keyed on player IDs to deduplicate.
def normalize_player(self, record):
player = record.get("player", {})
team = record.get("team", {})
stats = {k: v for k, v in record.items() if k not in {"player", "team", "rating"}}
return {
"player_id": player.get("id"),
"player": player.get("name"),
"team": team.get("name"),
"rating": record.get("rating"),
"stats": stats,
}
def normalize_dataset(self, dataset):
with open(f"data/raw/sofascore/{dataset}.json") as f:
data = json.load(f)
players = {}
for record in data:
normalized = self.normalize_player(record)
if normalized["player_id"]:
players[normalized["player_id"]] = normalized # keep one record per player
return list(players.values())
What the data revealed
Ten findings from the dataset that challenged what we expected.
- Dembélé scored six goals from just over two expected goals, close to three times his xG
- The Golden Glove winner (Unai Simón) didn't appear in our top ten goalkeepers by rating
- Bellingham, Rodri, and Tchouaméni dominated the defensive action rankings ahead of centre-backs
- Japan recorded the highest shot conversion rate while taking the lowest share of shots from inside the box
- Brian Brobbey recorded a 75% conversion rate without missing a single big chance
- Michael Olise set a new World Cup record with seven assists and led the tournament in big chances created
- More pressing volume didn't produce quicker ball recovery. Türkiye applied fewer pressures and recovered faster than France, England, and Argentina
- Argentina reached the final with the most bookings and no VAR interventions against them
- Spain led the tournament in defensive line breaks while holding around 90% passing accuracy
- The 2026 three-country World Cup used 16 stadiums, fewer than Spain used alone in 1982
The full findings with charts are in the original article.
What's next
- Zenrows Batch for large-scale collection across thousands of URLs
- Zenrows Browser Sessions for any authenticated scraping workflow
- Web data for LLM fine-tuning for feeding scraped data into training pipelines
Top comments (0)