Coverage for src/pyrf_data/client.py: 49%

293 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-09-06 21:54 +0000

1"""Client for accessing Ringfinity match data sources. 

2 

3Wraps the FootyIQ watcher data API and the bet3 ratings API. 

4""" 

5 

6from __future__ import annotations 

7 

8import json 

9import os 

10import re 

11from dataclasses import dataclass 

12from pathlib import Path 

13from typing import Any, cast 

14 

15import pandas as pd 

16import requests 

17from dotenv import load_dotenv 

18 

19# Default date range constants 

20DEFAULT_FROM_DATE = pd.Timestamp("2000-07-07") 

21DEFAULT_TO_DATE = pd.Timestamp("2029-08-07") 

22 

23# Filenames within the local data directory 

24WCPLAYER_EXPORT_FILENAME = "wcplayerexport.json" 

25WC2026_PLAYERS_FILENAME = "wc2026_players_flashscore.parquet" 

26 

27# FlashScore 2026 World Cup squads article 

28WC2026_SQUADS_URL = "https://www.flashscore.com/news/soccer-world-cup-official-full-squads-fifa-world-cup/UsBRzJS9/" 

29 

30_FS_POSITION_LABEL_TO_CODE = { 

31 "Goalkeepers": "GK", 

32 "Defenders": "DF", 

33 "Midfielders": "MF", 

34 "Forwards": "FW", 

35 "Attackers": "FW", 

36 "Strikers": "FW", 

37 "Reserves": "RES", 

38} 

39_FS_POSITION_LABELS_RE = re.compile( 

40 r"^\s*(" + "|".join(_FS_POSITION_LABEL_TO_CODE) + r")\b\s*:?\s*", 

41 re.IGNORECASE, 

42) 

43_FS_TEAM_HREF_RE = re.compile(r"/team/(?P<slug>[^/]+)/(?P<fs_id>[^/]+)/?") 

44_FS_PLAYER_HREF_RE = re.compile(r"/player/(?P<slug>[^/]+)/(?P<fs_id>[^/]+)/?") 

45_FS_INVISIBLE_RE = re.compile(r"[\u200b\u200c\u200d\u202c\u202d\u00ad]") 

46_FS_GROUP_HEADING_RE = re.compile(r"^Group\s+[A-L]$", re.IGNORECASE) 

47 

48 

49def _timestamp_to_str(ts: pd.Timestamp) -> str: 

50 """Convert a pandas Timestamp to YYYY-MM-DD string format.""" 

51 return ts.strftime("%Y-%m-%d") 

52 

53 

54def _flatten_scores_response(data: list[dict[str, Any]]) -> pd.DataFrame: 

55 """Flatten nested scores response into a DataFrame with one row per fixture. 

56 

57 The API returns data nested as: country -> comps -> seasons -> rounds -> fixtures. 

58 This function flattens it to a tabular format. 

59 """ 

60 rows: list[dict[str, Any]] = [] 

61 

62 for country_data in data: 

63 country = country_data.get("country", "") 

64 

65 for comp in country_data.get("comps", []): 

66 comp_id = comp.get("id") 

67 comp_name = comp.get("name", "") 

68 comp_short = comp.get("shortName", "") 

69 

70 for season in comp.get("seasons", []): 

71 season_name = season.get("name", "") 

72 

73 for round_data in season.get("rounds", []): 

74 round_name = round_data.get("name", "") 

75 

76 for fixture in round_data.get("fixtures", []): 

77 row = { 

78 "fixture_id": fixture.get("id"), 

79 "start_time": pd.to_datetime(fixture.get("startTime")), 

80 "country": country, 

81 "comp_id": comp_id, 

82 "comp_name": comp_name, 

83 "comp_short": comp_short, 

84 "season": season_name, 

85 "round": round_name, 

86 "team1_id": (fixture.get("team1") or {}).get("_id"), 

87 "team1_name": (fixture.get("team1") or {}).get("name", ""), 

88 "team2_id": (fixture.get("team2") or {}).get("_id"), 

89 "team2_name": (fixture.get("team2") or {}).get("name", ""), 

90 "score_ft": fixture.get("scoreFT", ""), 

91 "score_ht": fixture.get("scoreHT", ""), 

92 "team1_goals": (fixture.get("bestScore") or {}).get("team1"), 

93 "team2_goals": (fixture.get("bestScore") or {}).get("team2"), 

94 "scores_agree": (fixture.get("bestScore") or {}).get("scoresAgree"), 

95 "pitch": ((fixture.get("watcher") or {}).get("conditions") or {}).get("pitch"), 

96 "weather": ((fixture.get("watcher") or {}).get("conditions") or {}).get("weather"), 

97 "temp": ((fixture.get("watcher") or {}).get("conditions") or {}).get("temp"), 

98 } 

99 rows.append(row) 

100 

101 return pd.DataFrame(rows) 

102 

103 

104def _flatten_full_export(data: dict[str, Any]) -> pd.DataFrame: 

105 """Flatten full export response into a DataFrame with one row per fixture. 

106 

107 Includes detailed match statistics for each team. 

108 """ 

109 rows: list[dict[str, Any]] = [] 

110 

111 for fixture_data in data.get("fixtures", []): 

112 fixture = fixture_data.get("fixture") or {} 

113 match_data = fixture_data.get("matchData") or {} 

114 conditions = fixture_data.get("conditions") or {} 

115 league = fixture.get("league") or {} 

116 

117 team1_match = match_data.get("team1") or {} 

118 team2_match = match_data.get("team2") or {} 

119 team1_stats = team1_match.get("stats") or {} 

120 team2_stats = team2_match.get("stats") or {} 

121 

122 start_time_str = fixture.get("startTime") 

123 row = { 

124 # Fixture info 

125 "fixture_id": fixture.get("_id"), 

126 "start_time": pd.to_datetime(start_time_str) if start_time_str else pd.NaT, 

127 "status": fixture_data.get("status"), 

128 # League info 

129 "country": league.get("country", ""), 

130 "comp_id": league.get("_id"), 

131 "comp_name": league.get("name", ""), 

132 "comp_short": league.get("shortName", ""), 

133 "season": league.get("season", ""), 

134 "round_num": fixture.get("roundNum"), 

135 "is_cup": fixture.get("isCup", False), 

136 # Team info 

137 "team1_id": (fixture.get("team1") or {}).get("_id"), 

138 "team1_name": (fixture.get("team1") or {}).get("name", ""), 

139 "team2_id": (fixture.get("team2") or {}).get("_id"), 

140 "team2_name": (fixture.get("team2") or {}).get("name", ""), 

141 # Scores 

142 "score_ft": fixture.get("scoreFT", ""), 

143 "score_ht": fixture.get("scoreHT", ""), 

144 # Conditions 

145 "pitch": conditions.get("pitch"), 

146 "weather": conditions.get("weather"), 

147 "temp": conditions.get("temp"), 

148 # Team 1 stats 

149 "team1_goals": (team1_stats.get("goals") or {}).get("total"), 

150 "team1_goals_h1": (team1_stats.get("goals") or {}).get("firstHalf"), 

151 "team1_goals_h2": (team1_stats.get("goals") or {}).get("secondHalf"), 

152 "team1_chances": (team1_stats.get("chances") or {}).get("total"), 

153 "team1_chances_h1": (team1_stats.get("chances") or {}).get("firstHalf"), 

154 "team1_chances_h2": (team1_stats.get("chances") or {}).get("secondHalf"), 

155 "team1_half_chances": (team1_stats.get("halfChances") or {}).get("total"), 

156 "team1_half_chances_h1": (team1_stats.get("halfChances") or {}).get("firstHalf"), 

157 "team1_half_chances_h2": (team1_stats.get("halfChances") or {}).get("secondHalf"), 

158 "team1_deliveries": (team1_stats.get("deliveries") or {}).get("total"), 

159 "team1_deliveries_h1": (team1_stats.get("deliveries") or {}).get("firstHalf"), 

160 "team1_deliveries_h2": (team1_stats.get("deliveries") or {}).get("secondHalf"), 

161 "team1_ooohs": (team1_stats.get("ooohs") or {}).get("total"), 

162 "team1_choos": (team1_stats.get("choos") or {}).get("total"), 

163 # Team 2 stats 

164 "team2_goals": (team2_stats.get("goals") or {}).get("total"), 

165 "team2_goals_h1": (team2_stats.get("goals") or {}).get("firstHalf"), 

166 "team2_goals_h2": (team2_stats.get("goals") or {}).get("secondHalf"), 

167 "team2_chances": (team2_stats.get("chances") or {}).get("total"), 

168 "team2_chances_h1": (team2_stats.get("chances") or {}).get("firstHalf"), 

169 "team2_chances_h2": (team2_stats.get("chances") or {}).get("secondHalf"), 

170 "team2_half_chances": (team2_stats.get("halfChances") or {}).get("total"), 

171 "team2_half_chances_h1": (team2_stats.get("halfChances") or {}).get("firstHalf"), 

172 "team2_half_chances_h2": (team2_stats.get("halfChances") or {}).get("secondHalf"), 

173 "team2_deliveries": (team2_stats.get("deliveries") or {}).get("total"), 

174 "team2_deliveries_h1": (team2_stats.get("deliveries") or {}).get("firstHalf"), 

175 "team2_deliveries_h2": (team2_stats.get("deliveries") or {}).get("secondHalf"), 

176 "team2_ooohs": (team2_stats.get("ooohs") or {}).get("total"), 

177 "team2_choos": (team2_stats.get("choos") or {}).get("total"), 

178 } 

179 rows.append(row) 

180 

181 return pd.DataFrame(rows) 

182 

183 

184def _flatten_wcplayer_export(raw: dict[str, Any]) -> pd.DataFrame: 

185 """Flatten a wcplayerexport payload into a one-row-per-player DataFrame.""" 

186 df = pd.json_normalize(raw.get("wcteamplayers", [])) 

187 for col in ("fs", "ng", "tm"): 

188 if col in df.columns and any(c.startswith(f"{col}.") for c in df.columns): 

189 df = df.drop(columns=[col]) 

190 for col in ("createdAt", "updatedAt", "v1StarRatingChangedAt", "v2StarRatingChangedAt"): 

191 if col in df.columns: 

192 df[col] = pd.to_datetime(df[col]) 

193 return df 

194 

195 

196@dataclass 

197class PyRfDataClient: 

198 """Client for the FootyIQ watcher data API. 

199 

200 Returns DataFrames with flattened data by default. Use `*_raw` methods 

201 for the original nested JSON response. 

202 """ 

203 

204 watcher_token: str | None = None 

205 ratings_token: str | None = None 

206 watcher_url: str = "https://portal.footyiq.co.uk/api/watcher" 

207 ratings_url: str = "https://g.bet3.co.uk/api" 

208 rf_local_path: Path | str | None = None 

209 

210 def __post_init__(self) -> None: 

211 """Initialize tokens and paths from environment if not provided.""" 

212 if self.watcher_token is None or self.ratings_token is None or self.rf_local_path is None: 

213 load_dotenv() 

214 if self.watcher_token is None: 

215 self.watcher_token = os.environ.get("FOOTYIQ_TOKEN") 

216 if self.ratings_token is None: 

217 self.ratings_token = os.environ.get("BET3_TOKEN") 

218 if self.rf_local_path is None: 

219 env_path = os.environ.get("RF_LOCAL_PATH") 

220 self.rf_local_path = Path(env_path) if env_path else None 

221 if self.rf_local_path is not None: 

222 self.rf_local_path = Path(self.rf_local_path) 

223 

224 @property 

225 def _watcher_headers(self) -> dict[str, str]: 

226 """Build authorization headers for watcher API requests.""" 

227 return {"Authorization": f"Bearer {self.watcher_token}"} 

228 

229 @property 

230 def _ratings_headers(self) -> dict[str, str]: 

231 """Build authorization headers for ratings API requests.""" 

232 return {"Authorization": f"Bearer {self.ratings_token}"} 

233 

234 # ------------------------------------------------------------------------- 

235 # Raw JSON methods 

236 # ------------------------------------------------------------------------- 

237 

238 def fetch_scores_raw( 

239 self, 

240 limit: int = 1000, 

241 from_date: pd.Timestamp | None = None, 

242 to_date: pd.Timestamp | None = None, 

243 scores_agree: bool | None = None, 

244 ) -> list[dict[str, Any]]: 

245 """Fetch game scores from the API as raw nested JSON. 

246 

247 Args: 

248 limit: Maximum number of records to fetch. 

249 from_date: Start date as pd.Timestamp (defaults to 2000-07-07). 

250 to_date: End date as pd.Timestamp (defaults to 2029-08-07). 

251 scores_agree: If True, only return games where watcher and NowGoal agree. 

252 If False, only return games where they disagree. 

253 If None, return all games regardless. 

254 

255 Returns: 

256 Raw nested JSON response (country -> comps -> seasons -> rounds -> fixtures). 

257 """ 

258 from_date = from_date or DEFAULT_FROM_DATE 

259 to_date = to_date or DEFAULT_TO_DATE 

260 

261 url = f"{self.watcher_url}/export/scoresonly" 

262 params: dict[str, Any] = { 

263 "limit": limit, 

264 "from": _timestamp_to_str(from_date), 

265 "to": _timestamp_to_str(to_date), 

266 } 

267 if scores_agree is not None: 

268 params["scoresAgree"] = str(scores_agree).lower() 

269 

270 response = requests.get(url, params=params, headers=self._watcher_headers, timeout=60) 

271 response.raise_for_status() 

272 return cast(list[dict[str, Any]], response.json()) 

273 

274 def fetch_full_export_raw( 

275 self, 

276 limit: int = 500000, 

277 from_date: pd.Timestamp | None = None, 

278 to_date: pd.Timestamp | None = None, 

279 ) -> dict[str, Any]: 

280 """Fetch full export data as raw JSON. 

281 

282 Returns: 

283 Raw JSON with 'fixtures' list and metadata. 

284 """ 

285 from_date = from_date or DEFAULT_FROM_DATE 

286 to_date = to_date or DEFAULT_TO_DATE 

287 

288 url = f"{self.watcher_url}/export" 

289 params: dict[str, Any] = { 

290 "limit": limit, 

291 "from": _timestamp_to_str(from_date), 

292 "to": _timestamp_to_str(to_date), 

293 } 

294 response = requests.get(url, params=params, headers=self._watcher_headers, timeout=120) 

295 response.raise_for_status() 

296 return cast(dict[str, Any], response.json()) 

297 

298 # ------------------------------------------------------------------------- 

299 # DataFrame methods 

300 # ------------------------------------------------------------------------- 

301 

302 def fetch_scores( 

303 self, 

304 limit: int = 1000, 

305 from_date: pd.Timestamp | None = None, 

306 to_date: pd.Timestamp | None = None, 

307 scores_agree: bool | None = None, 

308 ) -> pd.DataFrame: 

309 """Fetch game scores as a flattened DataFrame. 

310 

311 Args: 

312 limit: Maximum number of records to fetch. 

313 from_date: Start date as pd.Timestamp (defaults to 2000-07-07). 

314 to_date: End date as pd.Timestamp (defaults to 2029-08-07). 

315 scores_agree: If True, only return games where watcher and NowGoal agree. 

316 If False, only return games where they disagree. 

317 If None, return all games regardless. 

318 

319 Returns: 

320 DataFrame with columns: fixture_id, start_time, country, comp_id, comp_name, 

321 comp_short, season, round, team1_id, team1_name, team2_id, team2_name, 

322 score_ft, score_ht, team1_goals, team2_goals, scores_agree, pitch, 

323 weather, temp. 

324 """ 

325 raw_data = self.fetch_scores_raw( 

326 limit=limit, 

327 from_date=from_date, 

328 to_date=to_date, 

329 scores_agree=scores_agree, 

330 ) 

331 return _flatten_scores_response(raw_data) 

332 

333 def fetch_scores_agreed( 

334 self, 

335 limit: int = 500000, 

336 from_date: pd.Timestamp | None = None, 

337 to_date: pd.Timestamp | None = None, 

338 ) -> pd.DataFrame: 

339 """Fetch all trusted data where watcher and NowGoal scores agree. 

340 

341 This is the recommended method for fetching reliable historical data. 

342 

343 Returns: 

344 DataFrame with one row per fixture. 

345 """ 

346 return self.fetch_scores( 

347 limit=limit, 

348 from_date=from_date, 

349 to_date=to_date, 

350 scores_agree=True, 

351 ) 

352 

353 def fetch_scores_disagreed( 

354 self, 

355 limit: int = 1000, 

356 from_date: pd.Timestamp | None = None, 

357 to_date: pd.Timestamp | None = None, 

358 ) -> pd.DataFrame: 

359 """Fetch games where watcher score differs from NowGoal score. 

360 

361 Useful for investigating data quality or discrepancies. 

362 

363 Returns: 

364 DataFrame with one row per fixture. 

365 """ 

366 return self.fetch_scores( 

367 limit=limit, 

368 from_date=from_date, 

369 to_date=to_date, 

370 scores_agree=False, 

371 ) 

372 

373 def fetch_full_export( 

374 self, 

375 limit: int = 500000, 

376 from_date: pd.Timestamp | None = None, 

377 to_date: pd.Timestamp | None = None, 

378 ) -> pd.DataFrame: 

379 """Fetch full export with detailed match statistics as DataFrame. 

380 

381 Includes per-team stats: goals, chances, half-chances, deliveries, 

382 ooohs, choos - broken down by half. 

383 

384 Returns: 

385 DataFrame with detailed match statistics. 

386 """ 

387 raw_data = self.fetch_full_export_raw( 

388 limit=limit, 

389 from_date=from_date, 

390 to_date=to_date, 

391 ) 

392 return _flatten_full_export(raw_data) 

393 

394 # ------------------------------------------------------------------------- 

395 # Ratings 

396 # ------------------------------------------------------------------------- 

397 

398 def fetch_ratings_club_raw(self) -> list[dict[str, Any]]: 

399 """Fetch club team ratings as raw JSON. 

400 

401 Returns: 

402 List of rating objects, one per home/away club matchup, each 

403 containing alpha/beta/lambda parameters, supremacy (sup), total 

404 goals (tg), and team/league metadata. 

405 """ 

406 url = f"{self.ratings_url}/numbers/ratings" 

407 response = requests.get(url, headers=self._ratings_headers, timeout=60) 

408 response.raise_for_status() 

409 return cast(list[dict[str, Any]], response.json()) 

410 

411 def fetch_ratings_club(self) -> pd.DataFrame: 

412 """Fetch club team ratings as a DataFrame. 

413 

414 Returns: 

415 DataFrame with one row per club matchup, including alpha/beta/lambda 

416 parameters, supremacy, total goals, and team/league metadata. 

417 The ``updated_at`` column is parsed to datetime. 

418 """ 

419 raw_data = self.fetch_ratings_club_raw() 

420 df = pd.DataFrame(raw_data) 

421 if "updated_at" in df.columns: 

422 df["updated_at"] = pd.to_datetime(df["updated_at"]) 

423 return df 

424 

425 def fetch_ratings_international_raw(self) -> list[dict[str, Any]]: 

426 """Fetch international (national-team) ratings as raw JSON. 

427 

428 Returns: 

429 List of rating objects, one per nation, each containing supremacy 

430 (sup), total goals (tg, goals), per-side goal expectations, 

431 normalised alpha/beta, and team/NowGoal metadata. 

432 """ 

433 url = f"{self.ratings_url}/numbers/ratings_mark" 

434 response = requests.get(url, headers=self._ratings_headers, timeout=60) 

435 response.raise_for_status() 

436 return cast(list[dict[str, Any]], response.json()) 

437 

438 def fetch_ratings_international(self) -> pd.DataFrame: 

439 """Fetch international (national-team) ratings as a DataFrame. 

440 

441 Returns: 

442 DataFrame with one row per nation, including supremacy, total goals, 

443 per-side goal expectations, normalised alpha/beta, and team/NowGoal 

444 metadata. The ``updated_at`` column is parsed to datetime. 

445 """ 

446 raw_data = self.fetch_ratings_international_raw() 

447 df = pd.DataFrame(raw_data) 

448 if "updated_at" in df.columns: 

449 df["updated_at"] = pd.to_datetime(df["updated_at"]) 

450 return df 

451 

452 # ------------------------------------------------------------------------- 

453 # Local wcplayer export 

454 # ------------------------------------------------------------------------- 

455 

456 def load_wcplayer_export_raw(self) -> dict[str, Any]: 

457 """Load the local ``wcplayerexport.json`` file as raw JSON. 

458 

459 Returns: 

460 Dict with keys ``wcteamplayers`` (list of player records), 

461 ``exportedAt``, and ``count``. 

462 """ 

463 if self.rf_local_path is None: 

464 msg = ( 

465 "rf_local_path is not set. Provide it via the constructor " 

466 "or set the RF_LOCAL_PATH environment variable." 

467 ) 

468 raise ValueError(msg) 

469 path = Path(self.rf_local_path) / WCPLAYER_EXPORT_FILENAME 

470 with path.open("r", encoding="utf-8") as f: 

471 return cast(dict[str, Any], json.load(f)) 

472 

473 def load_wcplayer_export(self) -> pd.DataFrame: 

474 """Load the local ``wcplayerexport.json`` as a flat DataFrame. 

475 

476 Nested ``fs`` / ``ng`` / ``tm`` sub-objects are flattened with 

477 dot-separated column names (e.g. ``tm.cpm``, ``ng._id``). 

478 Date columns (``createdAt``, ``updatedAt``, ``v1StarRatingChangedAt``, 

479 ``v2StarRatingChangedAt``) are parsed to datetime. 

480 

481 Returns: 

482 DataFrame with one row per player. 

483 """ 

484 return _flatten_wcplayer_export(self.load_wcplayer_export_raw()) 

485 

486 def fetch_wcplayer_export_raw(self, limit: int = 5000) -> dict[str, Any]: 

487 """Fetch the wcteamplayers export from the API as raw JSON. 

488 

489 Returns: 

490 Dict with keys ``wcteamplayers`` (list of player records), 

491 ``exportedAt``, and ``count``. 

492 """ 

493 url = f"{self.watcher_url}/export/wcteamplayers" 

494 params: dict[str, Any] = {"limit": limit} 

495 response = requests.get(url, params=params, headers=self._watcher_headers, timeout=600) 

496 response.raise_for_status() 

497 return cast(dict[str, Any], response.json()) 

498 

499 def fetch_wcplayer_export(self, limit: int = 5000, *, cache: bool = True) -> pd.DataFrame: 

500 """Fetch the wcteamplayers export from the API as a flat DataFrame. 

501 

502 When ``cache`` is True (default) and ``rf_local_path`` is set, the raw 

503 JSON response is written to ``<rf_local_path>/wcplayerexport.json``, 

504 overwriting any existing file. 

505 

506 Returns: 

507 DataFrame with one row per player, matching ``load_wcplayer_export``. 

508 """ 

509 raw = self.fetch_wcplayer_export_raw(limit=limit) 

510 if cache and self.rf_local_path is not None: 

511 path = Path(self.rf_local_path) / WCPLAYER_EXPORT_FILENAME 

512 path.parent.mkdir(parents=True, exist_ok=True) 

513 with path.open("w", encoding="utf-8") as f: 

514 json.dump(raw, f) 

515 return _flatten_wcplayer_export(raw) 

516 

517 # ------------------------------------------------------------------------- 

518 # FlashScore 2026 World Cup squads 

519 # ------------------------------------------------------------------------- 

520 

521 def _wc2026_cache_path(self, cache_path: Path | str | None) -> Path: 

522 """Resolve the parquet cache location for the WC2026 squads.""" 

523 if cache_path is not None: 

524 return Path(cache_path) 

525 if self.rf_local_path is not None: 

526 return Path(self.rf_local_path) / WC2026_PLAYERS_FILENAME 

527 return Path("data") / WC2026_PLAYERS_FILENAME 

528 

529 def fetch_wc2026_squads( 

530 self, 

531 *, 

532 update: bool = False, 

533 cache_path: Path | str | None = None, 

534 url: str = WC2026_SQUADS_URL, 

535 ) -> pd.DataFrame: 

536 """Return the 2026 World Cup squads as one row per (nation, player). 

537 

538 Reads ``data/wc2026_players_flashscore.parquet`` (or ``rf_local_path`` equivalent) 

539 by default. Pass ``update=True`` to re-scrape the FlashScore article 

540 at ``url`` and overwrite the cache. 

541 

542 See ``docs/specs/wc2026_v2.md`` for the column contract. 

543 """ 

544 path = self._wc2026_cache_path(cache_path) 

545 if not update: 

546 if not path.exists(): 

547 msg = ( 

548 f"WC2026 squad cache not found at {path}. " 

549 "Call fetch_wc2026_squads(update=True) once to populate it." 

550 ) 

551 raise FileNotFoundError(msg) 

552 return pd.read_parquet(path) 

553 

554 response = requests.get( 

555 url, 

556 headers={"User-Agent": "pyrf-notebook/2.0"}, 

557 timeout=60, 

558 ) 

559 response.raise_for_status() 

560 df = _parse_wc2026_squads_html(response.text) 

561 path.parent.mkdir(parents=True, exist_ok=True) 

562 df.to_parquet(path, index=False) 

563 return df 

564 

565 

566def _clean_fs_text(text: str) -> str: 

567 """Strip zero-width / bidi control chars and surrounding whitespace.""" 

568 return _FS_INVISIBLE_RE.sub("", text).strip() 

569 

570 

571def _extract_first_paren(tail: str) -> str | None: 

572 """Return the first parenthesised club name from a FlashScore tail.""" 

573 match = re.search(r"\(([^)]+)\)", tail) 

574 if not match: 

575 return None 

576 inner = match.group(1).strip() 

577 # Drop "on loan from ...", trailing notes, etc. 

578 inner = inner.split(",")[0].strip() 

579 return inner or None 

580 

581 

582def _parse_wc2026_squads_html(html: str) -> pd.DataFrame: 

583 """Parse the FlashScore squads article into a per-player DataFrame. 

584 

585 See ``docs/specs/wc2026_v2.md`` § 3 for the algorithm and column contract. 

586 """ 

587 from bs4 import BeautifulSoup, NavigableString, Tag 

588 

589 soup = BeautifulSoup(html, "html.parser") 

590 scraped_at = pd.Timestamp.now("UTC").tz_localize(None) 

591 

592 # Walk every heading + paragraph in document order. Anchoring to a 

593 # specific article container is fragile; the heading text itself 

594 # (`Group A` ... `Related Articles`) gives us a clean window. 

595 elements = soup.find_all(["h1", "h2", "h3", "h4", "p"]) 

596 

597 rows: list[dict[str, Any]] = [] 

598 nations_seen: dict[str, dict[str, Any]] = {} 

599 

600 current_group: str | None = None 

601 current_nation: str | None = None 

602 current_fs_country: str | None = None 

603 current_fs_team_id: str | None = None 

604 current_position_label: str | None = None 

605 nation_has_players: dict[str, bool] = {} 

606 nation_status_hint: dict[str, str] = {} 

607 

608 in_window = False 

609 

610 def _emit_status_for(nation: str) -> str: 

611 """Classify a nation's squad status from observed players and hints.""" 

612 if nation_has_players.get(nation): 

613 return "final" 

614 return nation_status_hint.get(nation, "pending") 

615 

616 for el in elements: 

617 text = _clean_fs_text(el.get_text(" ", strip=True)) 

618 if not text: 

619 continue 

620 

621 # Window control: start at "Group A", stop at "Related Articles". 

622 if not in_window: 

623 if _FS_GROUP_HEADING_RE.match(text): 

624 in_window = True 

625 else: 

626 continue 

627 elif text.lower().startswith("related articles"): 

628 break 

629 

630 tag_name = el.name 

631 

632 if tag_name in {"h1", "h2", "h3", "h4"}: 

633 if _FS_GROUP_HEADING_RE.match(text): 

634 current_group = text 

635 continue 

636 # Nation headings carry a /team/<slug>/<id>/ anchor. 

637 team_anchor: Tag | None = None 

638 team_match: re.Match[str] | None = None 

639 for a in el.find_all("a"): 

640 if not isinstance(a, Tag): 

641 continue 

642 href = a.get("href") or "" 

643 m = _FS_TEAM_HREF_RE.search(str(href)) 

644 if m is not None: 

645 team_anchor = a 

646 team_match = m 

647 break 

648 if team_anchor is None or team_match is None: 

649 # Non-nation heading inside the window (e.g. "MENTIONS"). 

650 current_position_label = None 

651 continue 

652 current_nation = _clean_fs_text(team_anchor.get_text(" ", strip=True)) 

653 current_fs_country = team_match.group("slug") 

654 current_fs_team_id = team_match.group("fs_id") 

655 current_position_label = None 

656 nations_seen.setdefault( 

657 current_nation, 

658 { 

659 "nation": current_nation, 

660 "fs_country": current_fs_country, 

661 "fs_team_id": current_fs_team_id, 

662 "group": current_group, 

663 }, 

664 ) 

665 nation_has_players.setdefault(current_nation, False) 

666 continue 

667 

668 if tag_name != "p" or current_nation is None: 

669 continue 

670 

671 # Strip and record a leading "Goalkeepers:" / "Defenders:" / ... label. 

672 label_match = _FS_POSITION_LABELS_RE.match(text) 

673 body_html_parts: list[Any] = list(el.children) 

674 if label_match: 

675 current_position_label = label_match.group(1).title() 

676 

677 # Look for status hints when the paragraph has no player anchors. 

678 anchors = [ 

679 a for a in el.find_all("a") if isinstance(a, Tag) and _FS_PLAYER_HREF_RE.search(str(a.get("href") or "")) 

680 ] 

681 if not anchors: 

682 low = text.lower() 

683 if "preliminary" in low and current_nation not in nation_status_hint: 

684 nation_status_hint[current_nation] = "preliminary" 

685 elif "will announce" in low and current_nation not in nation_status_hint: 

686 nation_status_hint[current_nation] = "pending" 

687 continue 

688 

689 # Walk children to keep player → following-text association intact. 

690 # We collect (anchor, trailing_text_until_next_anchor) pairs. 

691 pairs: list[tuple[Tag, str]] = [] 

692 pending: Tag | None = None 

693 trailing_buf: list[str] = [] 

694 for child in body_html_parts: 

695 if isinstance(child, Tag) and child.name == "a" and _FS_PLAYER_HREF_RE.search(str(child.get("href") or "")): 

696 if pending is not None: 

697 pairs.append((pending, "".join(trailing_buf))) 

698 pending = child 

699 trailing_buf = [] 

700 else: 

701 if isinstance(child, NavigableString): 

702 trailing_buf.append(str(child)) 

703 elif isinstance(child, Tag): 

704 trailing_buf.append(child.get_text(" ")) 

705 if pending is not None: 

706 pairs.append((pending, "".join(trailing_buf))) 

707 

708 for anchor, tail in pairs: 

709 match = _FS_PLAYER_HREF_RE.search(str(anchor.get("href") or "")) 

710 if match is None: 

711 continue 

712 fs_id = match.group("fs_id") 

713 fs_slug = match.group("slug") 

714 fs_name = _clean_fs_text(anchor.get_text(" ", strip=True)) 

715 fs_club = _extract_first_paren(tail) 

716 rows.append( 

717 { 

718 "nation": current_nation, 

719 "fs_country": current_fs_country, 

720 "fs_team_id": current_fs_team_id, 

721 "fs_position": _FS_POSITION_LABEL_TO_CODE.get(current_position_label or ""), 

722 "fs_position_label": current_position_label, 

723 "fs_name": fs_name, 

724 "fs_slug": fs_slug, 

725 "fs_id": fs_id, 

726 "fs_club": fs_club, 

727 "fs_squad_status": "final", 

728 "scraped_at": scraped_at, 

729 } 

730 ) 

731 nation_has_players[current_nation] = True 

732 

733 # Placeholder rows for nations declared but without any players parsed. 

734 for nation, meta in nations_seen.items(): 

735 if nation_has_players.get(nation): 

736 continue 

737 status = _emit_status_for(nation) 

738 rows.append( 

739 { 

740 "nation": nation, 

741 "fs_country": meta["fs_country"], 

742 "fs_team_id": meta["fs_team_id"], 

743 "fs_position": None, 

744 "fs_position_label": None, 

745 "fs_name": None, 

746 "fs_slug": None, 

747 "fs_id": None, 

748 "fs_club": None, 

749 "fs_squad_status": status, 

750 "scraped_at": scraped_at, 

751 } 

752 ) 

753 

754 df = pd.DataFrame(rows) 

755 if not df.empty: 

756 # Stamp the final status onto every row of each nation. 

757 status_per_nation = {nation: _emit_status_for(nation) for nation in nations_seen} 

758 df["fs_squad_status"] = df["nation"].map(status_per_nation).fillna(df["fs_squad_status"]) 

759 return df