Skip to content

Commit af8c44a

Browse files
authored
Fix/transfers null response erases history (#378)
1 parent 59fa295 commit af8c44a

5 files changed

Lines changed: 334 additions & 67 deletions

File tree

data/raw/transfermarkt-api.dvc

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
outs:
2-
- md5: c829abceffc9d979752b46b5e1c947bc.dir
3-
size: 581192599
2+
- md5: 3e2a95f72dfccad1d3f2f3677e661102.dir
3+
size: 833083085
44
nfiles: 17
55
hash: md5
66
path: transfermarkt-api

dbt/models/base/transfermarkt_api/base_transfers.sql

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ with
77
row_number() over (partition by player_id order by season desc) as n,
88
filename
99
from {{ source("transfermarkt_api", "transfers") }}
10+
11+
-- filter out null API responses before ranking so that
12+
-- the latest season with actual data is selected. Without this, a
13+
-- failed acquisition run (which still persists {"response": null}
14+
-- rows) shadows every earlier season and erases the player's entire
15+
-- transfer history from the dataset.
16+
where json_extract(json(value), '$.response') is not null
17+
and json_extract_string(json(value), '$.response') != 'null'
1018
),
1119
unnested as (
1220
select
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
{#
2+
A player with usable transfer history in ANY season must appear in
3+
base_transfers.
4+
5+
base_transfers keeps only the latest season per player
6+
(row_number() ... order by season desc, where n = 1). A failed acquisition
7+
run still persists one {"response": null} row per player, and because those
8+
rows carry the latest season they win that ranking, shadow every earlier
9+
season, and erase the player's entire transfer history from the dataset.
10+
11+
This is not hypothetical: the 2026-07-11 run was blocked by the API and
12+
wrote 22,324 null responses for season 2025, dropping transfers from
13+
175,120 rows to 35,139 and removing players such as Alexander Isak and
14+
Florian Wirtz completely, even though seasons 2023/2024 held their full
15+
history.
16+
17+
base_market_value_development already filters null responses before
18+
ranking, which is why player_valuations survived the same wipe intact.
19+
#}
20+
21+
with players_with_history as (
22+
23+
select distinct
24+
json_extract_string(json(value), '$.player_id')::integer as player_id
25+
26+
from {{ source("transfermarkt_api", "transfers") }},
27+
unnest(
28+
json_transform(
29+
json_extract(json(value), '$.response.transfers'), '["JSON"]'
30+
)
31+
) as u(transfer)
32+
33+
-- only responses that actually carry data
34+
where json_extract(json(value), '$.response') is not null
35+
and json_extract_string(json(value), '$.response') != 'null'
36+
-- mirror the date filter in base_transfers so that transfers the model
37+
-- legitimately discards are never counted as history
38+
and (transfer ->> 'dateUnformatted') is not null
39+
and (transfer ->> 'dateUnformatted') != '0000-00-00'
40+
and (transfer ->> 'dateUnformatted') != ''
41+
42+
)
43+
44+
select
45+
players_with_history.player_id
46+
47+
from players_with_history
48+
49+
left join {{ ref('base_transfers') }} as base_transfers
50+
on players_with_history.player_id = base_transfers.player_id
51+
52+
where base_transfers.player_id is null

scripts/acquiring/transfermarkt-api.py

Lines changed: 148 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@
3636
TRANSFERS_API = "https://www.transfermarkt.co.uk/ceapi/transferHistory/list/"
3737
USER_AGENT = "transfermarkt-datasets/1.0 (https://github.com/dcaribou/transfermarkt-datasets)"
3838

39+
# how many times to re-request the players that came back with a null response
40+
MAX_BATCH_RETRIES = 2
41+
42+
# a run with a higher share of null responses than this is treated as failed,
43+
# and is never written over the raw data already on disk
44+
MAX_NULL_RESPONSE_RATE = 0.2
45+
3946

4047
# get the player ids from the players asset from transfermarkt-scraper source
4148
def get_player_ids(season: int, player_filter=None, club_filter=None, competition_filter=None) -> List[int]:
@@ -176,13 +183,96 @@ async def get_transfers(player_ids: List[int]) -> List[dict]:
176183

177184
return responses
178185

179-
def persist_data(data: List[dict], path: str) -> None:
180-
"""Persist the data to a file.
186+
def fetch_with_retries(fetch_all, player_ids: List, label: str) -> List[dict]:
187+
"""Fetch responses for `player_ids`, re-requesting the ones that come back null.
188+
189+
The API intermittently returns null for individual players, and rejects
190+
whole runs when it decides to block us. Retrying only the failed players
191+
keeps a partial failure from turning into a lost season.
192+
193+
Args:
194+
fetch_all: Coroutine function taking a list of player ids
195+
player_ids (List): The players to request
196+
label (str): Name of the asset, used for logging
197+
198+
Returns:
199+
List[dict]: One response per requested player, in the original order
200+
"""
201+
results = asyncio.run(fetch_all(player_ids))
202+
203+
for attempt in range(MAX_BATCH_RETRIES):
204+
null_ids = [item["player_id"] for item in results if item["response"] is None]
205+
if not null_ids:
206+
break
207+
logging.warning(
208+
f"Batch retry {attempt + 1}/{MAX_BATCH_RETRIES}: "
209+
f"{len(null_ids)} players with null {label} responses"
210+
)
211+
retry_lookup = {item["player_id"]: item for item in asyncio.run(fetch_all(null_ids))}
212+
results = [
213+
retry_lookup.get(item["player_id"], item) if item["response"] is None else item
214+
for item in results
215+
]
216+
217+
null_count = sum(1 for item in results if item["response"] is None)
218+
logging.info(
219+
f"{label} complete: {len(results)} total, {null_count} null responses remaining"
220+
)
221+
222+
return results
223+
224+
def validate_responses(data: List[dict], path: str, label: str) -> None:
225+
"""Check that an acquisition result is good enough to overwrite raw data.
226+
227+
A blocked run still returns a well-formed response per player, just with a
228+
null payload, so without this check it overwrites good raw data with
229+
nothing. That is what happened on 2026-07-11: 22,324 null responses were
230+
written over season 2025, erasing 242MB of transfer history and removing
231+
players from the published dataset.
232+
233+
Args:
234+
data (List[dict]): List of dicts with data to persist
235+
path (str): Path the data would be written to, used in error messages
236+
label (str): Name of the asset, used in log and error messages
237+
238+
Raises:
239+
RuntimeError: If the result is empty or too many responses are null.
240+
"""
241+
if not data:
242+
raise RuntimeError(
243+
f"{label} acquisition returned no records; refusing to overwrite {path}"
244+
)
245+
246+
null_count = sum(1 for item in data if item["response"] is None)
247+
null_rate = null_count / len(data)
248+
249+
if null_rate > MAX_NULL_RESPONSE_RATE:
250+
raise RuntimeError(
251+
f"{label} acquisition returned {null_count}/{len(data)} "
252+
f"({null_rate:.1%}) null responses, above the "
253+
f"{MAX_NULL_RESPONSE_RATE:.0%} threshold; refusing to overwrite {path}. "
254+
"This usually means the API blocked the run."
255+
)
256+
257+
if null_count:
258+
logging.warning(
259+
f"Persisting {label} with {null_count}/{len(data)} null responses "
260+
f"({null_rate:.1%})"
261+
)
262+
263+
def persist_data(data: List[dict], path: str, label: str) -> None:
264+
"""Persist the data to a file, unless the run looks like it failed.
181265
182266
Args:
183267
data (List[dict]): List of dicts with data to persist
184268
path (str): Path where to store the data
269+
label (str): Name of the asset, used in log and error messages
270+
271+
Raises:
272+
RuntimeError: If the result would not pass validate_responses.
185273
"""
274+
validate_responses(data, path, label)
275+
186276
with open(path, "w") as f:
187277
f.writelines(json.dumps(item) + "\n" for item in data)
188278

@@ -209,74 +299,67 @@ def run_for_season(season: int, player_filter=None, club_filter=None, competitio
209299
club_filter=club_filter, competition_filter=competition_filter)
210300

211301
# collect market values and transfers for players in SEASON
212-
market_values = asyncio.run(get_market_values(player_ids))
213-
214-
# batch-level retry for null market value responses
215-
max_batch_retries = 2
216-
for batch_attempt in range(max_batch_retries):
217-
null_ids = [item["player_id"] for item in market_values if item["response"] is None]
218-
if not null_ids:
219-
break
220-
logging.warning(f"Batch retry {batch_attempt + 1}/{max_batch_retries}: {len(null_ids)} players with null market value responses")
221-
retry_results = asyncio.run(get_market_values(null_ids))
222-
# build lookup of retry results
223-
retry_lookup = {item["player_id"]: item for item in retry_results}
224-
# replace null responses with retry results
225-
market_values = [
226-
retry_lookup.get(item["player_id"], item) if item["response"] is None else item
227-
for item in market_values
228-
]
229-
230-
final_null_count = sum(1 for item in market_values if item["response"] is None)
231-
logging.info(f"Market values complete for season {season}: {len(market_values)} total, {final_null_count} null responses remaining")
302+
market_values = fetch_with_retries(get_market_values, player_ids, "market values")
232303

233-
transfers = asyncio.run(get_transfers(player_ids))
304+
transfers = fetch_with_retries(get_transfers, player_ids, "transfers")
234305

235306
# filter out player ids in responses that are not in the original list
236307
transfers = [item for item in transfers if item["player_id"] in player_ids]
237308

238309
logging.info(f"Persisting market values and transfers for season {season}")
239310

240-
# persist market values and transfers to files
241-
persist_data(market_values, target_market_values_path)
242-
persist_data(transfers, target_transfers_path)
243-
244-
parser = argparse.ArgumentParser()
245-
parser.add_argument(
246-
'--seasons',
247-
help="Season to be acquired. This is passed to the scraper as the SEASON argument",
248-
default="2024",
249-
type=str
250-
)
251-
parser.add_argument(
252-
'--competitions',
253-
help="Comma-separated competition IDs to filter (e.g., GB1,ES1). Only fetches data for players in these competitions.",
254-
default=None
255-
)
256-
parser.add_argument(
257-
'--clubs',
258-
help="Comma-separated club IDs to filter (e.g., 131,583). Only fetches data for players in these clubs.",
259-
default=None
260-
)
261-
parser.add_argument(
262-
'--players',
263-
help="Comma-separated player IDs to filter (e.g., 28003,1122196). Only fetches data for these players.",
264-
default=None
265-
)
311+
# check both before writing either, so a failed run cannot leave one file
312+
# updated and the other stale
313+
validate_responses(market_values, target_market_values_path, "market values")
314+
validate_responses(transfers, target_transfers_path, "transfers")
266315

267-
parsed = parser.parse_args()
268-
269-
# Validate mutual exclusivity
270-
active_filters = sum(1 for f in [parsed.competitions, parsed.clubs, parsed.players] if f is not None)
271-
if active_filters > 1:
272-
parser.error("Only one filter (--competitions, --clubs, or --players) can be used at a time")
273-
274-
player_filter = set(parsed.players.split(',')) if parsed.players else None
275-
club_filter = set(parsed.clubs.split(',')) if parsed.clubs else None
276-
competition_filter = set(parsed.competitions.split(',')) if parsed.competitions else None
277-
278-
expanded_seasons = seasons_list(parsed.seasons)
279-
280-
for season in expanded_seasons:
281-
run_for_season(season, player_filter=player_filter, club_filter=club_filter,
282-
competition_filter=competition_filter)
316+
# persist market values and transfers to files
317+
persist_data(market_values, target_market_values_path, "market values")
318+
persist_data(transfers, target_transfers_path, "transfers")
319+
320+
def main():
321+
"""Parse arguments and run the acquisition for every requested season."""
322+
323+
parser = argparse.ArgumentParser()
324+
parser.add_argument(
325+
'--seasons',
326+
help="Season to be acquired. This is passed to the scraper as the SEASON argument",
327+
default="2024",
328+
type=str
329+
)
330+
parser.add_argument(
331+
'--competitions',
332+
help="Comma-separated competition IDs to filter (e.g., GB1,ES1). Only fetches data for players in these competitions.",
333+
default=None
334+
)
335+
parser.add_argument(
336+
'--clubs',
337+
help="Comma-separated club IDs to filter (e.g., 131,583). Only fetches data for players in these clubs.",
338+
default=None
339+
)
340+
parser.add_argument(
341+
'--players',
342+
help="Comma-separated player IDs to filter (e.g., 28003,1122196). Only fetches data for these players.",
343+
default=None
344+
)
345+
346+
parsed = parser.parse_args()
347+
348+
# Validate mutual exclusivity
349+
active_filters = sum(1 for f in [parsed.competitions, parsed.clubs, parsed.players] if f is not None)
350+
if active_filters > 1:
351+
parser.error("Only one filter (--competitions, --clubs, or --players) can be used at a time")
352+
353+
player_filter = set(parsed.players.split(',')) if parsed.players else None
354+
club_filter = set(parsed.clubs.split(',')) if parsed.clubs else None
355+
competition_filter = set(parsed.competitions.split(',')) if parsed.competitions else None
356+
357+
expanded_seasons = seasons_list(parsed.seasons)
358+
359+
for season in expanded_seasons:
360+
run_for_season(season, player_filter=player_filter, club_filter=club_filter,
361+
competition_filter=competition_filter)
362+
363+
364+
if __name__ == "__main__":
365+
main()

0 commit comments

Comments
 (0)