216 000 €. En dessous de ce seuil, une municipalité européenne qui achète des services n'est pas tenue de publier son appel d'offres sur TED, le registre central de l'UE.
Ce qui signifie que les contrats qu'un petit soumissionnaire peut raisonnablement remporter se trouvent ailleurs : sur le portail de la commune, de la région, du service public ou du ministère acheteur.
Le défi
Plus de 250 000 autorités publiques dans l'UE dépensent environ 2,6 billions d'euros par an en travaux, fournitures et services, selon les chiffres de la Commission européenne. Les directives européennes ne régissent que les contrats supérieurs aux seuils, qui à compter du 1er janvier 2026 s'élèvent à 140 000 € pour les fournitures et services de l'administration centrale, 216 000 € pour les autorités locales et régionales, et 5 404 000 € pour les travaux. En dessous de ces montants, les règles nationales s'appliquent et la publication sur TED est facultative.
C'est pourquoi la veille des appels d'offres relève avant tout du scraping. Si vous développez un outil pour un cabinet de conseil en soumissions ou une entreprise de construction, la partie au-dessus des seuils est la plus simple. Le reste constitue une longue traîne de portails que personne n'a conçus pour être lus par une machine.
BidPrime, qui vend des données sur les marchés publics en Amérique du Nord, a qualifié la version américaine de ce problème de "milliers de flux de travail" en mai 2026 : des portails qui limitent l'accès automatisé, des systèmes de passation de marchés qui exigent un compte ou une session, des pièces jointes stockées séparément de l'avis, des URL qui changent sans préavis. Rien dans cette liste n'est propre aux États-Unis.
Mais l'échec le plus coûteux dans la veille d'appels d'offres n'est pas le portail qui vous rejette. Celui-ci apparaît dans vos logs. C'est l'exécution qui revient au vert mais vide.
Le portail d'une collectivité peut répondre à une session expirée par une page standard avec un code HTTP 200. Un avis de maintenance peut s'afficher à l'emplacement prévu pour le tableau des résultats. Une recherche soumise hier peut vous renvoyer la première page, car la page deux n'existait que pour le cookie qui l'a demandée. Votre parseur ne trouve aucune ligne et enregistre "aucun nouvel appel d'offres aujourd'hui". Et contrairement à un prix manqué, un appel d'offres manqué ne se rattrape pas : le temps que quelqu'un remarque le manque, la date limite de soumission est dépassée et l'avis ne présente plus aucun intérêt pour un candidat.
L'approche : forcer chaque extraction à prouver ce qu'elle a vu
Commencez par ce qui ne nécessite pas de scraping. L'API de TED offre un accès anonyme aux avis publiés, sans clé requise, extrayez donc la partie au-dessus des seuils depuis cette source. Au Royaume-Uni, Find a Tender est la plateforme centrale où tous les pouvoirs adjudicateurs publient leurs informations de passation de marchés depuis le 24 février 2025, selon GOV.UK. Vérifiez ce qu'un registre central couvre déjà avant d'écrire un parser pour un portail qu'il duplique.
Pour tout le reste, trois règles font l'essentiel du travail.
1. Définissez une vraie réponse, y compris la réponse vide. Une page de résultats contient son en-tête de colonne ("Date de clôture", selon les termes propres au portail). Une journée réellement vide affiche le texte d'état vide du portail. Tout autre cas représente un trou dans les données, pas un zéro. Placez les deux chaînes dans la liste validate.data.accept de la request, et une page de session expirée fera échouer la request au lieu de passer pour une journée sans activité. Nos moteurs refusent déjà de considérer une page de vérification reconnue comme un succès, mais aucun moteur ne sait comment chaque administration formule sa page de timeout (cette partie doit venir de vous). La correspondance est une sous-chaîne stricte et sensible à la casse, copiez donc le texte depuis la page plutôt que de le retaper. Nous avons détaillé le fonctionnement lorsque les règles de validation ont commencé à déterminer le succès, ainsi que le même problème sous l'angle des séries temporelles dans When a Block Looks Like a Data Point.
2. Vérifiez le total affiché par le portail. La plupart des listes de résultats indiquent le nombre d'avis correspondants ("1-20 sur 143"). Extrayez ce nombre et comparez-le aux lignes que vous avez enregistrées. S'ils diffèrent, l'exécution n'est pas terminée, quels que soient les codes d'état renvoyés.
3. Identifiez l'itinéraire une fois, puis rejouez-le. Les portails paginés et liés à une session s'attendent généralement à ce que les pages suivantes proviennent de la même adresse et du même navigateur. Avec une plateforme comme FourA, Auto trouve un itinéraire qui fonctionne pour la première page et renvoie sa session : l'identifiant du proxy, le User-Agent et les cookies de la tentative réussie. Chaque page suivante passe par Single sur cette sortie, ce qui constitue le chemin de production. Auto n'est que l'outil de découverte du chemin.
import math, re, requests
API = "https://api.foura.ai/api"
H = {"X-API-Key": "pk_live_..."}
LIST = "https://tenders.example-council.gov/open?page={}"
PER_PAGE = 20
# A real answer is the results table or the portal's own empty-state line.
# An expired session or a maintenance notice served at 200 is neither.
CHECK = {"data": {"accept": ["Closing date", "No open tenders"]}}
def fetch(endpoint, body):
r = requests.post(f"{API}/{endpoint}", headers=H, json=body).json()
if r.get("error"):
raise RuntimeError(r["error"]) # a gap to refill, not a quiet day
return r
# Auto finds a route that works and hands back its session...
first = fetch("auto", {"url": LIST.format(1), "validate": CHECK})
proxy, ua = first["session"]["proxy"], first["session"]["userAgent"]
pages = [first["data"]]
# ...and Single replays it on the same exit for every other page.
m = re.search(r"\d+-\d+ of (\d+)", pages[0])
total = int(m.group(1)) if m else 0
for n in range(2, math.ceil(total / PER_PAGE) + 1):
pages.append(fetch("single", {
"method": "GET", "url": LIST.format(n), "proxy": proxy,
"headers": [["User-Agent", ua]], "validate": CHECK,
})["data"])
Si le portail conserve l'état de votre recherche sur son serveur, envoyez son cookie de session dans la même liste headers. Single renvoie chaque header Set-Cookie pour chaque saut de redirection. Les documents d'appel d'offres se trouvent généralement derrière un lien de téléchargement sous forme de PDF, et le contrôle des redirections ainsi que le mode raw buffer de Single les récupèrent sous forme d'octets plutôt que de texte corrompu. Une habitude supplémentaire : interrogez chaque portail selon son propre calendrier. Il s'agit de petits serveurs gérés par de petites collectivités, et vingt requests en une seule rafale sont bien plus suspectes à leurs yeux que vingt réparties sur un après-midi.
Résultats
- 25 % d'appels d'offres supplémentaires capturés. GroupBWT, une société de services de scraping, rapporte ce résultat pour un client surveillant plus de 50 portails de marchés publics par rapport à une référence manuelle, « non pas parce qu'il y avait plus d'appels d'offres, mais parce qu'ils ont cessé de les manquer. »
- Aucun scraper pour la partie au-dessus des seuils. Les avis de TED proviennent d'une API qui ne nécessite aucune clé, de sorte que chaque heure de travail sur le parser est consacrée aux portails sans API.
- Les anomalies apparaissent tant que l'avis est encore ouvert. Une page qui échoue à la règle de validation déclenche une erreur le jour même, et le job la réessaie au lieu d'enregistrer une journée vide.
- Une seule découverte de route par portail, pas par page. La recherche d'Auto s'exécute une fois ; chaque page suivante est un appel Single sur la sortie identifiée.
Seul le premier point constitue un résultat mesuré ; les trois autres décrivent les changements apportés par cette configuration.
À retenir
Dans la veille des marchés publics, la ligne la plus coûteuse à enregistrer est « rien de nouveau aujourd'hui », car c'est la seule que vous ne pourrez pas corriger plus tard. Les prix reviennent demain. Un appel d'offres clôturé pendant que votre scraper lisait une page de session expirée ne revient pas.
Faites en sorte que le job mérite ses zéros. Une page de liste qui ne peut pas vous afficher le tableau, le message d'état vide du portail ou un décompte concordant ne vous a encore rien appris, et considérer cela comme une réponse valide est précisément la raison pour laquelle un soumissionnaire finit par découvrir un contrat lors de la publication de son avis d'attribution.