3636TRANSFERS_API = "https://www.transfermarkt.co.uk/ceapi/transferHistory/list/"
3737USER_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
4148def 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