Aller au contenu

L'ingénierie d'acquisition

Un modèle de prédiction est un programme de quelques centaines de lignes. La collecte qui l'alimente en fait plusieurs milliers, tourne pendant des heures, échoue de façon silencieuse et se répare à la main.

Ce chapitre décrit cette moitié-là : les pages, les protocoles, les blocages, les contournements, et les quatre ou cinq fois où l'infrastructure a menti sans prévenir.

Le cache HLTV

Tout part de là. HLTV n'expose aucune API JSON : le site est du HTML rendu côté serveur, et la seule façon d'en tirer quoi que ce soit est de télécharger des pages et de les lire.

Ce que le cache contient

Au moment de la clôture de la campagne, l'index du cache compte 16 178 lignes pour 13 160 URLs distinctes — l'écart correspond aux pages re-téléchargées volontairement. La composition :

type de page URLs distinctes ce qu'on y prend
/matches/<id>/… 11 526 tables de stats par joueur, veto, scores par map, mi-temps CT/T, bloc H2H, rang affiché
/team/<id>/… 657 historique de rang mondial daté, stats par map, ratings joueurs
/events/<id>/… 656 dotation, poids VRS, LAN/online, dates de début et de fin
/valve-ranking/… 41 classements officiels rejoués, détail par équipe et par jour
/ranking/teams/… 31 archives hebdomadaires du classement HLTV
/results?offset=… ~60 les listings paginés de résultats

Ces 11 526 pages de match sont le gisement principal du projet. Chacune pèse de l'ordre de 200 Ko de HTML et contient, dans un seul fichier, tout ce que trois familles de features entières exploitent.

Un détail de chronologie mérite d'être noté, parce qu'il explique des écarts entre les rapports : le cache a grandi pendant la campagne. L'approche maps a miné 7 148 pages, l'approche stats en a miné 9 112, et l'approche finale en parse environ 11 500. Le rapport d'assault le dit explicitement : build_dataset.py a dû ré-extraire tout le cache parce que « les .pkl des familles dataient d'avant la collecte étendue ». Comparer des chiffres issus de deux approches différentes suppose de vérifier qu'ils portent sur la même matière.

Le mécanisme du cache

Trois lignes suffisent à le décrire.

Indexation par hachage. Une URL HLTV contient des slugs et parfois des points d'interrogation ; elle ne fait pas un nom de fichier. Le chemin de cache est donc les seize premiers caractères hexadécimaux du SHA-1 de l'URL, suivis de .html. Un fichier index.txt en mode ajout seul conserve la correspondance nom de fichier → URL, tabulée.

Pas d'empoisonnement. Une réponse en erreur n'est jamais écrite : raise_for_status() est appelé avant l'écriture disque. Un 503 passager ne laisse pas derrière lui un fichier vide qui serait servi éternellement comme s'il était valide.

Mode hors ligne strict. Une variable de module, OFFLINE, bascule tout le système : une page absente du cache devient une LookupError au lieu d'un téléchargement silencieux. Tous les scripts d'analyse la mettent à True dès l'import. C'est ce qui garantit qu'un entraînement rejoué deux fois voit exactement les mêmes données.

Politesse : 2,5 secondes

Le module maintient l'horodatage de la dernière requête réseau et attend si nécessaire :

wait = MIN_DELAY - (time.time() - _last_request[0])
if wait > 0:
    time.sleep(wait)

MIN_DELAY vaut 2,5 secondes par défaut, réglable par variable d'environnement. Le cache n'est pas concerné : relire une page déjà téléchargée est instantané.

Cette cadence a été relevée quand il le fallait. Le script de réparation de frontière du cache fixe explicitement hltv.MIN_DELAY = 4.0, avec le commentaire « consigne du jour : jamais moins de 4 s entre requêtes ». Et les explorations de nouvelles sources travaillent sous budget de requêtes déclaré : le rapport d'exploration ouvre sur la ligne « budget_hltv : 10 requêtes / 150 autorisées ».

À retenir

Un scraper qui se comporte bien n'est pas seulement une question d'éthique : c'est ce qui permet à un projet de durer six mois sur la même source. Trois pratiques y suffisent — un délai minimum appliqué au niveau du transport, un cache agressif pour ne jamais redemander deux fois la même chose, et un budget explicite pour l'exploration.

Le parsing par expressions régulières ancrées

Ni bs4 ni lxml ne sont installés dans l'image. Le parsing se fait donc par expressions régulières sur le HTML brut, et la docstring du module prend les devants sur l'objection évidente :

Ce n'est pas un choix esthétique : chaque motif est ancré sur un attribut ou une classe stable, et le test casse bruyamment si HLTV change son gabarit, ce qui vaut mieux qu'un parseur tolérant qui renverrait silencieusement des listes vides.

L'argument est plus solide qu'il n'y paraît. Un sélecteur CSS qui ne trouve rien renvoie une liste vide, et une liste vide se propage jusqu'à un jeu de données amputé sans qu'aucune alarme ne sonne. Une regex ancrée sur <div class="time" data-unix="…"> échoue à zéro résultat de la même façon — mais les tests du projet vérifient un nombre de résultats attendu, pas seulement l'absence d'exception.

Un exemple concret, celui qui extrait le rang mondial affiché sur une page de match :

_PAGE_RANK = re.compile(r'/ranking/teams/(\d+)/(\w+)/(\d+)/(\d+)"[^>]*>'
                        r'<span>World rank: </span>#(\d+)')

Cinq groupes : année, mois, jour, identifiant d'équipe, rang. Le motif est ancré sur le libellé littéral « World rank: » et sur la forme de l'URL d'archive du classement. La date vient de l'URL, pas d'un attribut séparé — c'est ce qui rend cette valeur datable, donc utilisable comme feature.

Les 403 de Cloudflare sur /stats

Voici l'obstacle le plus coûteux de la campagne d'acquisition, et son histoire complète.

Le symptôme

Les pages /matches/ se téléchargent sans problème avec impersonate="chrome" de curl_cffi — pas de cookie, pas de session, juste une empreinte TLS de navigateur. Les pages /stats/*, elles, renvoient systématiquement un 403 accompagné d'une page de challenge Cloudflare d'environ 6 Ko, reconnaissable à son <title>Just a moment...</title>.

Or /stats/* est exactement là où se trouvent les données les plus désirables : les duels d'ouverture, les clutchs, les multi-kills par match, les ratings de carrière par joueur, les splits LAN/online, les winrates par map longue durée. Et /betting/money, c'est-à-dire les cotes des bookmakers — la cible même que le projet essaie d'atteindre.

Les contournements ratés

Six impersonations récentes. curl_cffi 0.16.1 propose des empreintes TLS de navigateurs modernes. Toutes ont été essayées sur /stats/teams : chrome131, chrome145, chrome150, firefox147, safari260, chrome131_android. Les six renvoient 403 avec la page « Just a moment ». Verdict : le blocage n'est pas une question d'empreinte TLS.

La session réchauffée. Deuxième hypothèse : peut-être manque-t-il des cookies. Une session curl_cffi fait d'abord un GET /matches, obtient un 200 et récupère les cookies __cflb, __cf_bm, _cfuvid, puis attaque /stats avec ces cookies et un Referer crédible. Résultat : 403 quand même, avec la même session qui obtenait 200 une seconde plus tôt.

Le site mobile. Une résolution DNS suffit : m.hltv.org pointe sur la même IP que www. HLTV est responsive, il n'y a pas de variante mobile. Zéro requête dépensée pour éliminer cette piste — la bonne façon de tester une hypothèse.

Le diagnostic

Le challenge Cloudflare est scopé au chemin /stats/*. Il faudrait exécuter le JavaScript du challenge pour obtenir un vrai cookie cf_clearance, ce qui suppose un navigateur sans interface graphique — un outil que le projet n'a pas et n'a pas voulu ajouter.

Le rapport conclut : « Verdict HLTV /stats en direct : mort, définitivement. »

Intuition

Quand une porte est fermée, il y a trois familles de réponses : forcer la porte (les impersonations), chercher une autre porte du même bâtiment (le site mobile), ou trouver quelqu'un qui a déjà photographié l'intérieur. La troisième a marché, deux fois.

Les contournements réussis

Contournement n°1 — miner ce qu'on a déjà. Les pages /matches/ téléchargées depuis des mois contiennent, dans une section id="all-content", les tables table.totalstats par joueur : K-D, eK-eD, Swing, ADR, eADR, KAST, eKAST et le rating 3.0. Elles contiennent aussi le veto complet, les scores par map avec les mi-temps CT/T, le format Bo1/Bo3 et le drapeau Online/LAN.

Rien de tout cela n'avait jamais été parsé. Le gisement était sur le disque depuis le début. Coût réseau du contournement : zéro. Gain : les deux familles stats et maps, soit 0,0042 et 0,0038 de Brier.

Contournement n°2 — les tooltips fusioncharts des pages d'équipe. Les pages /team/<id> embarquent un graphique d'évolution du rang mondial. Ce graphique est alimenté par un blob JSON fusioncharts inclus dans le HTML, et ce blob contient l'historique complet et daté point par point, de 2016 à 2026. Sur une page témoin : 822 points. Sur l'ensemble des 656 équipes : 66 860 points, dont 494 équipes avec un historique exploitable, 234 avec des statistiques par map et 305 avec des ratings de joueurs. 162 pages sont vides côté HLTV.

Coût réseau : zéro, les pages étaient en cache. C'est cette extraction qui a produit hr_rank_diff et hr_trend_diff, soit 0,0018 de Brier.

Contournement n°3 — la Wayback Machine. L'Internet Archive a crawlé HLTV massivement. L'API CDX permet de lister les URLs archivées ; le préfixe /web/<timestamp>id_/ sert le HTML original, sans la couche d'habillage de l'archive — et sans Cloudflare, puisque la requête va à archive.org.

Le recensement : au moins 20 000 URLs distinctes hltv.org/stats/players/* archivées en 200 depuis 2025, autant de stats/matches/*, et 4 256 stats/teams/*. Sur les dix équipes du haut de notre pool, dix sont couvertes, avec 11 à 76 snapshots chacune.

Deux propriétés rendent cette source précieuse. La jointure est exacte par construction : l'identifiant HLTV est dans l'URL, c'est notre clé native. Et le timestamp du snapshot date les statistiques, ce qui donne l'intégrité temporelle gratuitement — plusieurs snapshots d'une même page forment même une série temporelle.

La contrepartie est le débit. La politesse envers archive.org impose environ une requête toutes les deux secondes. La collecte des carrières a ramené 966 snapshots en 54,8 minutes, pour 629 joueurs sur 3 038 et 131 équipes sur 656. Le rapport d'exploration recommandait d'ailleurs de cibler « les ~50 équipes / 200 joueurs du pool VRS, pas l'exhaustivité ».

Comme vu au chapitre précédent, cette source n'a finalement rien produit — non par manque de qualité, mais par manque de couverture après filtrage temporel : 1 145 matchs sur 6 056.

bo3.gg : l'API qui existait

Le meilleur gisement de la campagne n'était pas chez HLTV.

Ce que c'est

bo3.gg expose une API REST ouverte, sans clé, sur https://bo3.gg/api/v1/. Le recensement initial : 8 378 équipes, 20 308 joueurs, 73 486 matchs terminés répartis sur les tiers s, a, b et c, et 143 029 manches.

Et surtout, l'endpoint /games/<id> renvoie chaque round, avec pour chaque équipe : equipment_value, economy_level et son écart, money_spent et money_save, loss_bonus_streak, pistol_round, first_kills et first_death, trade_kills, clutch_attempts et clutches, utility_value, flash_assists, poses et désamorçages de bombe, et la raison de fin de round.

C'est précisément l'économie par round que le cache HLTV ne contient pas.

Deux bonus figurent au passage : un champ ps_id qui est l'identifiant PandaScore de l'équipe ou du joueur — un pont gratuit vers une autre API si une clé arrive un jour — et un champ demo_url pointant vers les démos GOTV.

Le piège d'API silencieux

Il mérite sa propre section, parce que c'est le mode de défaillance le plus vicieux qu'on puisse rencontrer sur une API.

Sur l'endpoint /players, le filtre par pseudo s'écrit :

filter[nickname][like]=<pseudo>

La forme intuitive, préfixée par le nom de table :

filter[players.nickname][like]=<pseudo>

est ignorée silencieusement. Pas d'erreur 400. Pas de message. L'API renvoie un 200 avec la liste complète, non filtrée.

Un client naïf reçoit donc des données parfaitement bien formées, dans le bon schéma, avec le bon code HTTP — et complètement fausses. Si le code d'appel prend le premier résultat, il apparie chaque joueur au même joueur arbitraire, et la corruption se propage jusqu'aux features sans qu'aucune exception ne soit levée.

Limite importante

Un code 200 ne prouve rien. La seule défense contre ce genre de défaut est un contrôle de cohérence explicite : on demande dix joueurs qu'on connaît, et on vérifie qu'on obtient bien ces dix-là. C'est ce que le projet a fait — jointure d'équipes 15/15 exacte par nom, jointure de joueurs 9/10 exacte, y compris sur un joueur de tier obscur, ce dernier point étant le vrai test.

L'appariement par noms et dates

bo3.gg et HLTV n'ont aucun identifiant commun. L'appariement se fait donc sur les noms d'équipes et les dates, en deux passes :

  1. Passe exacte. Noms d'équipes normalisés (accents retirés, minuscules, alphanumérique seulement) identiques des deux côtés, et date à \(\pm 1\) jour. Rendement : 5 085 appariements.
  2. Passe floue. Un côté exact, date à \(\pm 12\) heures, et similarité difflib \(\geq 0{,}75\) sur l'autre nom. Rendement : 64 appariements supplémentaires, chacun marqué method="fuzzy" avec son name_ratio conservé — pour que la modélisation puisse les filtrer si elle le souhaite.

Total : 5 149 matchs appariés sur 6 056, soit 85 %. Le résidu non apparié est « essentiellement absent de bo3.gg » : de petits événements que le site ne couvre pas.

Les contrôles qui rendent l'appariement crédible

Un appariement par noms est une hypothèse. Trois vérifications l'ont transformée en fait mesuré.

Le vainqueur. Sur les 4 304 matchs pour lesquels bo3.gg fournit des rounds, le vainqueur selon bo3.gg est le nôtre dans 4 302 cas. Les deux écarts sont listés nommément dans le rapport et exclus de la modélisation — leurs clés apparaissent en dur dans le code sous le nom ECO_EXCLUDE.

Le score exact. Concordance sur 3 940 matchs. L'écart avec les 4 302 s'explique par les formats et les manches non jouées.

La reconstruction. Sur les Bo1, le score reconstruit en comptant les rounds gagnés égale le score HLTV. C'est le contrôle le plus fort des trois : il vérifie non pas l'appariement mais la cohérence interne de la donnée round par round.

Enfin, le taux de rounds effectivement parsés a été mesuré par tier, parce que le champ parsed_status peut rester à « waiting » sur les petits matchs :

tier manches avec rounds taux
s 758 / 758 100 %
a 354 / 358 99 %
b 4 559 / 4 826 94 %
c 4 102 / 4 238 97 %

Le volume et le coût

collecte volume durée échecs
rounds (rounds.pkl) 209 482 rounds sur 10 180 manches, 4 304 de nos matchs (71 %) 209,2 min 0
historique (bo3_history.pkl) 40 023 matchs et 77 890 manches, 2024-01 → 2026-08 27,4 min —
carrières Wayback 966 snapshots 54,8 min 2

Environ une requête par seconde, cinq heures de collecte cumulées, zéro échec de téléchargement sur la partie bo3.gg. Les collecteurs sont résumables : l'état est sur disque, avec des clés de reprise par fichier et par ligne, et une interruption se relance telle quelle. Pendant toute cette phase, hltv.OFFLINE reste à True — la moisson externe ne touche jamais HLTV.

La collecte historique 2025

L'historique bo3.gg a été collecté en deux temps. Le socle couvrait la fenêtre native de six mois ; une extension dédiée (bo3_ext.py) a remonté du 25 août 2025 au 4 février 2026, avec les mêmes fonctions et le même mécanisme de reprise, puis une troisième passe (bo3_history.py) a ramassé les listings depuis janvier 2024.

C'est cette collecte historique qui a permis les deux gains les plus importants de la campagne : la fenêtre d'entraînement portée à douze mois (10 287 matchs d'entraînement au lieu de 4 916) et les horloges réchauffées depuis août 2025. Rappel du chiffre : −0,0072 de Brier pour les états chauds, à modèle rigoureusement identique.

Les sources mortes

Pour être complet, voici ce qui a été testé et éliminé, avec la preuve à chaque fois.

source statut preuve
HLTV /stats direct mort 403 + « Just a moment » sur 6 impersonations et sur session réchauffée
m.hltv.org mort même IP que www, résolution DNS
esports-charts mort 403, et l'audience est peu prédictive
Kaggle ESTA mort données ère CS:GO, démos ≤ 2022, aucun recouvrement avec notre fenêtre
Liquipedia viable, faible valeur rosters fiables, peu de statistiques neuves face à bo3.gg
PandaScore non testé clé requise ; les cotes sont en offre payante
cotes dans notre cache mort grep sur 30 pages : « odds » n'apparaît que dans le forum

Ce dernier point a une conséquence stratégique. HLTV injecte les cotes en JavaScript géolocalisé : elles ne sont pas dans le HTML. Aucune source de cotes gratuite n'a été trouvée. Le palier bookmakers — Brier 0,198 — reste donc sans données d'entraînement : on peut s'en approcher, on ne peut pas apprendre à l'imiter.

Les leçons d'infrastructure durement payées

Trois défauts ont chacun coûté plusieurs jours. Ils partagent une propriété : ils ne provoquent aucune erreur, seulement des chiffres faux.

Un cache sans expiration sur du contenu vivant

Déjà rencontré au chapitre 1 du point de vue de l'intégrité temporelle ; voyons-le du point de vue de l'infrastructure.

Le cache disque n'expire jamais. C'est un choix correct pour une page de match — le résultat d'un match joué ne change plus. C'est un choix faux pour trois autres classes de pages :

  • les pages d'événement en cours, qui figent une dotation partielle définitivement ;
  • les listings triés par date, dont la première page change à chaque match terminé ;
  • les classements du jour, re-rendus au présent.

Le critère unificateur retenu au terme de la campagne : une ressource est acquise quand son élément le plus récent a plus d'un jour. Trois correctifs distincts implémentent ce même critère sur trois types de pages — hltv.event() refuse une page mise en cache avant la fin annoncée du tournoi, results_settled refuse une page de listing trop fraîche, et ranking_settled rafraîchit toujours la page du jour courant.

Ordre de grandeur du dommage évité : le suivi quotidien du moteur passait de 0,4 point d'écart moyen à 51 points en une dizaine de jours, uniquement à cause d'une page d'événement figée pendant qu'un tournoi majeur courait.

La pagination par offset qui glisse

C'est le plus beau bug de la campagne, et il mérite d'être raconté en entier.

Le symptôme. Le 25 août au matin, le test de reproduction quotidienne du classement donne, pour les journées du 19 au 22 août, des écarts de 6,75 / 6,32 / 6,09 / 4,83 points. La veille au soir, le même test donnait 0,63 / 0,69 / 1,11 / 1,10. Rien n'avait changé dans le code. Et re-télécharger les pages de classement n'y changeait rien non plus.

Le mécanisme. Le listing /results est paginé par offset : page 0 = matchs 0 à 99, page 1 = matchs 100 à 199, et ainsi de suite. Cette pagination est relative au présent. Quand un nouveau match se termine, tout glisse d'un cran.

Conséquence : si la page 6 a été mise en cache lundi et la page 7 mercredi, elles ne se raccordent plus. Entre le dernier match de la page 6 (version lundi) et le premier match de la page 7 (version mercredi), il y a une bande de matchs qui n'apparaît sur aucune page en cache.

       collecte du 23/08 18:23              collecte du 25/08 06:02
                 │                                     │
                 ▼                                     ▼
   page 7   ┌──────────────┐              page 6  ┌──────────────┐
   (offset  │ … 30/07 09:04│              (offset │31/07 09:51 … │
    700)    └──────────────┘               600)   └──────────────┘
                     └─────────── TROU ───────────┘
                          35 matchs, 24,8 heures
                     invisibles pour tout replay OFFLINE

L'état réel du cache ce matin-là, daté par les mtime des fichiers et l'index en mode ajout seul : les pages 0 à 600 refetchées vers 06:02 par des marches courtes de vérification, les pages 700 à 6000 datant du 23 août à 18:23. Entre les deux générations, 35 matchs joués les 30 et 31 juillet — donc en plein cœur de la fenêtre de six mois des journées rejouées, avec un poids d'âge d'environ 0,88.

La preuve. Trois éléments, dans l'ordre où ils ont été obtenus.

Reproduction : à 06:03, le test rejoué sur le 21 août redonne 6,09, identique à la mesure du matin.

Auto-guérison observée : le démon de production redémarre à 06:06:37 et lance une marche longue de six mois en ligne. Le contrôle de continuité en ligne refetch en pratique toute page en cache d'une marche en ligne, ce qui réécrit les pages 0 à 6000 en une génération cohérente entre 06:07:00 et 06:09:30. À 06:11, le même test donne 1,18. Aucune page de match ni d'événement n'a changé entre les deux mesures : seul le listing a bougé.

Simulation : on prend le listing sain du jour, on l'ampute exactement des 35 matchs de la bande, et on rejoue.

jour mesuré le matin simulé (listing amputé) sain
19/08 6,75 6,78 0,63
21/08 6,09 6,09 1,12
22/08 4,83 4,84 1,12

La bande explique l'intégralité du résidu, au centième près sur le 21 août.

La suite. Un second trou du même type subsistait à la frontière des offsets 6000/6100 : une bande allant du 24 février 18:52 au 25 février 18:30, soit 23,6 heures et environ 35 matchs de plus. Hors de portée de la marche de six mois du démon, mais dans les fenêtres des journées de début août, avec un poids d'âge d'environ 13 %.

Au total, sur les deux frontières, près de deux jours entiers de matchs étaient invisibles pour tout calcul hors ligne, sans qu'aucune alarme ne se déclenche.

Le correctif. Une sentinelle dans le lecteur de listings, active en mode hors ligne : si deux pages consécutives viennent de générations différentes — détecté par une régression de mtime — et laissent un trou temporel de plus de 12 heures à plus de 14 jours du début de fenêtre, alors on lève une LookupError. Le jour est ignoré bruyamment, avec le message « cache incomplet », au lieu d'être mesuré faux.

Les deux seuils sont justifiés par la mesure et non choisis au hasard : la plus longue accalmie réelle observée dans le calendrier CS2 est de 9,9 heures, ce qui place le seuil de 12 heures au-dessus du bruit légitime ; et à 14 jours du bord de fenêtre, le poids d'âge borne l'impact d'une bande manquante à environ 0,5 point.

Le coût réseau total de l'enquête, du diagnostic au recollage des 13 pages de frontière : 13 requêtes.

À retenir

Un chiffre de test n'est interprétable qu'accompagné de l'état de son entrée. Les générations des pages de listing font partie des données du problème, au même titre que les matchs qu'elles contiennent. La sentinelle rend désormais cet état vérifié à chaque construction de jeu de données.

Les archives re-rendues au moment de la consultation

Le troisième défaut est le plus difficile à admettre, parce qu'il ne vient pas de nous.

La page HLTV du classement d'un jour passé n'est pas un document figé : elle est re-générée à chaque consultation, avec la connaissance disponible au moment du rendu — y compris les placements des tournois qui étaient en cours à la date affichée.

Conséquence : pendant qu'un tournoi majeur se déroulait, nos écarts sur les équipes qui y participaient atteignaient 22 à 36 points, alors même que nos cinq facteurs de calcul collaient à \(\pm 0{,}5\) point près à la page de détail de HLTV pour les mêmes équipes. Les deux mesures étaient justes ; c'est la cible qui bougeait.

Correctif : la page du jour courant est toujours rafraîchie, et les onze pages de la période concernée ont été re-capturées après la fin du tournoi. Écart moyen sur la journée du 21 août : 2,44 → 1,11 point.

Deux épisodes entiers étiquetés « dégradation du moteur » se sont ainsi révélés être des artefacts de mesure.

Récapitulatif : la carte des sources

  ┌─────────────────────────────────────────────────────────────────────┐
  │  CACHE HLTV LOCAL — 13 160 URLs, 2,5 s/requête, zéro réseau à l'usage│
  │                                                                     │
  │  /matches/  11 526 ──► stats joueurs (KAST, rating 3.0, ADR)        │
  │                    ──► veto, scores par map, mi-temps CT/T          │
  │                    ──► bloc Head to head daté                       │
  │                    ──► rang mondial affiché (lien daté)             │
  │  /team/       657 ──► historique de rang (66 860 pts, fusioncharts) │
  │  /events/     656 ──► dotation, poids VRS, LAN, dates               │
  │  /results/    ~60 ──► le calendrier lui-même  ⚠ pagination glissante│
  └─────────────────────────────────────────────────────────────────────┘
                                    │
  ┌─────────────────────────────────┴───────────────────────────────────┐
  │  SOURCES EXTERNES                                                   │
  │                                                                     │
  │  bo3.gg API      ──► 209 482 rounds / 10 180 manches   (209 min)    │
  │  (ouverte)       ──► 40 023 matchs 2024→ pour réchauffer (27 min)   │
  │                  ⚠ filtre silencieusement ignoré si mal préfixé     │
  │                                                                     │
  │  Wayback CDX     ──► 966 snapshots de carrières datés   (55 min)    │
  │                  ⚠ couverture 1 145/6 056 après filtre temporel     │
  └─────────────────────────────────────────────────────────────────────┘
                                    │
  ┌─────────────────────────────────┴───────────────────────────────────┐
  │  MORT : HLTV /stats direct · m.hltv.org · esports-charts · ESTA     │
  │         cotes bookmakers (aucune source gratuite trouvée)           │
  └─────────────────────────────────────────────────────────────────────┘

Pour aller plus loin

Pourquoi ne pas avoir utilisé un navigateur sans interface pour passer Cloudflare ?

C'était techniquement possible et cela aurait ouvert /stats, donc les duels d'ouverture, les clutchs par match et les ratings de carrière. Le projet ne l'a pas fait pour deux raisons : cela ajoutait une dépendance lourde à une image volontairement minimale, et cela franchissait une ligne — contourner activement une protection explicitement mise en place par le site. Les trois contournements retenus ne forcent rien : ils lisent ce qui est déjà sur notre disque, ou ce qu'un tiers a archivé publiquement.

L'autre question porte sur la durée de la collecte bo3.gg, qui surprend souvent.

Pourquoi 209 minutes pour 4 304 matchs ?

Une requête par manche, environ une par seconde, et 10 180 manches. La contrainte n'est pas la bande passante mais la politesse : à raison d'une requête par seconde, 10 180 requêtes font 170 minutes de plancher incompressible, auxquelles s'ajoutent les listings et les reprises. C'est le prix normal d'une collecte respectueuse, et c'est pourquoi les collecteurs sont résumables.

Le chapitre suivant prend la plus exotique des données ramenées par cette collecte — les 209 482 rounds — et montre exactement ce qu'on en a fait, pour un gain d'un à deux millièmes de Brier.