Comment scraper Indeed en 2026 ? Un des sites les mieux protégés au monde

Indeed déploie un arsenal complet contre le scraping : Cloudflare, fingerprinting TLS, tokens dynamiques, détection comportementale. Découvrez comment le scraper avec un script léger, sans navigateur headless.
Disclaimer : Cet article est publié à des fins éducatives et de recherche uniquement. Veillez à respecter les Conditions Générales d'Utilisation (CGU) ainsi que les règles de chaque site web avant toute extraction de données.
Indeed est l'un des sites les mieux protégés contre le scraping. Au menu :
- Cloudflare
- Fingerprinting TLS
- Tokens de pagination dynamiques
- Détection comportementale
Indeed.fr déploie un arsenal complet. Je vous apprend à scraper ce site, avec un script light, sans utiliser des navigateurs coûteux et peu fiables. Vous voulez découvrir comme ça marche ? Alors c'est parti !
Le code complet est disponible sur GitHub.
L'architecture globale
Le scraper est un script unique (scrape_indeed.py) qui repose sur deux dépendances :
pip install curl_cffi lxml
- curl_cffi -- un binding Python de curl qui permet l'impersonation TLS de vrais navigateurs
- lxml -- parsing HTML pour extraire les données structurées des pages
Le flux est simple : on charge la page de résultats, on extrait les offres en JSON, puis on visite chaque offre individuellement via une URL "embedded" pour enrichir les données.
Session curl_cffi (Chrome TLS)
|
v
GET listing (SERP) --> parse Mosaic/Legacy --> JSON offres du listing
|
v
Pour chaque offre:
GET viewjob?viewtype=embedded --> parse JSON/JSON-LD --> enrichir l'offre
|
v
Affichage JSON complet + export optionnel
curl_cffi : l'arme principale
Pourquoi pas requests ?
Le problème fondamental du scraping en 2026, c'est le TLS fingerprinting. Quand un navigateur établit une connexion HTTPS, le ClientHello TLS contient une signature unique :
- L'ordre des cipher suites
- Les extensions TLS supportées
- Les courbes elliptiques
- Le support ALPN (HTTP/2)
- L'ordre de tout ça
Cette signature est appelée JA3 fingerprint (ou JA4 dans sa version plus récente). Python requests utilise la stack TLS d'OpenSSL par défaut, qui a une signature reconnaissable instantanément comme "pas un vrai navigateur".
Comment curl_cffi résout le problème
curl_cffi (4900+ stars sur GitHub) est un binding de curl-impersonate, un fork de curl qui reproduit fidèlement le handshake TLS de vrais navigateurs. En une ligne :
from curl_cffi import requests
session = requests.Session(impersonate="chrome")
Dans le script, c'est exactement ce qu'on fait :
IMPERSONATE = "chrome"
def create_session(proxy_url: str | None = None) -> requests.Session:
session = requests.Session(impersonate=IMPERSONATE)
if proxy_url:
session.proxies = {"http": proxy_url, "https": proxy_url}
return session
Le profil "chrome" sans numéro de version utilise automatiquement la dernière version de Chrome disponible dans curl_cffi (actuellement Chrome 142). On peut aussi épingler une version spécifique avec "chrome124" ou "chrome131" par exemple. Chaque requête produit un handshake TLS identique à celui de Chrome : mêmes cipher suites, même ordre, mêmes extensions, même ALPN.
Les profils disponibles
curl_cffi supporte des dizaines de profils :
| Navigateur | Versions disponibles |
|---|---|
| Chrome | 99, 100, 101, 104, 107, 110, 116, 119, 120, 123, 124, 131, 133a, 136, 142 |
| Firefox | 133, 135, 144 |
| Safari | 15.3, 15.5, 17.0, 18.0, 18.4, 26.0 |
| Edge | 99, 101 |
| Tor | 145 |
Chaque profil reproduit fidèlement les spécificités TLS du navigateur cible à la version près.
Les headers HTTP : cohérence avec l'impersonation
L'impersonation TLS ne suffit pas. Les headers HTTP doivent être cohérents avec le navigateur simulé. Indeed vérifie notamment les Sec-Fetch headers.
Headers pour la page de listing (SERP)
LISTING_HEADERS = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) "
"Gecko/20100101 Firefox/148.0",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "fr,fr-FR;q=0.9,en-US;q=0.8,en;q=0.7",
"Alt-Used": "fr.indeed.com",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "same-origin",
"Sec-Fetch-User": "?1",
"Priority": "u=0, i",
"Pragma": "no-cache",
"Cache-Control": "no-cache",
}
Pourquoi ces headers importent
Sec-Fetch-* : Ce sont des headers que les vrais navigateurs envoient automatiquement. Ils indiquent le contexte de la requête :
Sec-Fetch-Site: same-origin= navigation intra-site (pas un appel API externe)Sec-Fetch-Mode: navigate= navigation utilisateur (pas un fetch JS)Sec-Fetch-Dest: document= on demande un document HTMLSec-Fetch-User: ?1= la requête est initiée par l'utilisateur (clic)
Un script sans ces headers est immédiatement identifiable.
Accept-Language : fr,fr-FR;q=0.9,en-US;q=0.8,en;q=0.7 est cohérent avec un utilisateur français sur fr.indeed.com. Un en-US tout seul serait suspect.
Referer : Le referer est calculé dynamiquement à partir de l'URL de recherche :
headers = dict(LISTING_HEADERS)
parsed = urlparse(url)
headers["Referer"] = f"{parsed.scheme}://{parsed.netloc}/jobs"
Headers pour les pages de détail (offres)
DETAIL_HEADERS = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) "
"Gecko/20100101 Firefox/148.0",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "fr,fr-FR;q=0.9,en-US;q=0.8,en;q=0.7",
}
Les headers sont plus légers pour les pages de détail -- conformément à ce que fait réellement le navigateur. Le referer pointe vers l'URL de l'offre dans le listing :
headers = dict(DETAIL_HEADERS)
headers["Referer"] = job.get("url", "https://fr.indeed.com/jobs")
Le proxy
Le script accepte un proxy via la variable d'environnement PROXY_URL :
PROXY_URL="http://user:pass@host:port" python scrape_indeed.py
C'est configuré à la création de la session :
if proxy_url:
session.proxies = {"http": proxy_url, "https": proxy_url}
Sans proxy, le script fonctionne mais Indeed bloquera probablement après quelques requêtes. Les fournisseurs de proxies résidentiels (Decodo/Smartproxy, Bright Data, etc.) sont recommandés -- les proxies datacenter sont détectés plus facilement.
La détection de bot
Indeed utilise Cloudflare pour la protection. Sans pagination (première page uniquement), le blocage se manifeste typiquement par un 403 avec un captcha Cloudflare. Dans ce cas, deux options :
- Changer de proxy -- une nouvelle IP résidentielle suffit souvent à passer
- Utiliser un service de résolution de captcha comme CapSolver avec leur tâche
AntiCloudflareTask, qui résout le challenge et retourne les cookiescf_clearanceà injecter dans la session
On ne gèrera pas cette partie ici.
La pagination : La total désactivation depuis février 2026
Indeed a désormais totalement bloqué la pagination pour les utilisateurs non connectés. Les tokens de pagination (pp) sont toujours présents dans la réponse (dans pageLinks), mais ils ne fonctionnent plus sans session authentifiée : toute tentative de les utiliser ou d'incrémenter le paramètre start manuellement est redirigée vers la page de login.
C'est un changement majeur par rapport à 2024 où ces mêmes tokens pp fonctionnaient en mode anonyme. Aujourd'hui, Indeed force le login comme prérequis à toute navigation au-delà de la première page de résultats.
Le script standalone scrape donc la première page de résultats (typiquement 15 offres), puis enrichit chacune via sa page de détail.
Chez crawlergrid.ai, nous gérons pour vous tous types d'extractions de données et d'automatisations, sans limite de pagination ni blocage par captchas.
Le parsing
Page de listing : le format Mosaic
Indeed utilise une architecture "Mosaic" (2026) qui expose les données via des assignations JavaScript :
window.mosaic.providerData["mosaic-provider-jobcards"] = {...};
Le scraper localise ces blocs par regex, puis extrait le JSON par comptage d'accolades (les blocs peuvent faire des centaines de Ko sur une seule ligne, un json.loads naïf échouerait). Les offres sont dans le provider mosaic-provider-jobcards, le total dans MosaicProviderRichSearchDaemon.
Un fallback legacy est aussi supporté pour les anciennes pages (<script id="comp-initialData">).
Page de détail : le format embedded
Chaque offre est chargée via viewtype=embedded, qui retourne du JSON pur au lieu d'HTML :
detail_url = (
f"https://fr.indeed.com/viewjob?viewtype=embedded"
f"&jk={jk}&from=shareddesktop_copy&adid=0&spa=1&hidecmpheader=1"
)
Le JSON est profondément imbriqué -- chaque champ (entreprise, salaire, contrat, description) est extrait avec plusieurs chemins de fallback. Si le JSON embedded échoue, le script tombe sur le JSON-LD JobPosting (schema.org) présent dans le HTML.
Fusion listing + détail
Les données du listing (titre, entreprise, lieu) sont enrichies par celles du détail (description, contrat, salaire complet).
Le rythme des requêtes
Le timing est crucial pour ne pas se faire détecter, et se comporter comme un utilisateur classique :
# Entre les pages de détail (offres)
delay = random.uniform(2, 4)
time.sleep(delay)
Utilisation et sortie
Commandes
# Sans proxy (risque de blocage)
python scrape_indeed.py
# Avec proxy (recommandé)
PROXY_URL="http://user:pass@host:port" python scrape_indeed.py
# Recherche custom, limiter à 5 offres
python scrape_indeed.py --url "https://fr.indeed.com/jobs?q=python&l=Paris" --max 5
# Listing seulement (pas de chargement des pages de détail)
python scrape_indeed.py --no-detail
# Exporter les résultats en JSON
python scrape_indeed.py --json-output
Ce que le script affiche
Le script affiche chaque étape du pipeline avec des logs structurés et colorés. Chaque phase est horodatée :
======================================================================
Indeed Scraper -- curl_cffi + Chrome TLS Impersonation
======================================================================
[14:32:01] Création de la session curl_cffi
> TLS impersonation: chrome
!! Aucun proxy configuré -- risque élevé de blocage
[14:32:01] Chargement de la page de résultats (SERP)
> URL: https://fr.indeed.com/jobs?q=alternance&l=France&sort=date...
> HTTP 200 -- 847,231 bytes
OK Page reçue, parsing en cours...
OK Format détecté: Mosaic (2026+)
> Providers trouvés: mosaic-provider-jobcards, MosaicProviderRichSearchDaemon, ...
OK 15 offres extraites sur cette page (total Indeed: 2847)
Immédiatement après le parsing du listing, le JSON extrait de chaque offre est affiché :
[14:32:02] JSON extrait du listing (15 offres)
----------------------------------------------------------------------
--- Offre #1 -- Alternance Développeur Web ---
{
"job_key": "abc123def456",
"title": "Alternance Développeur Web",
"company": "TechCorp",
"location": "Paris (75)",
"salary": "1 200 EUR par mois",
"published_at": "2026-02-10T14:00:00+00:00",
"url": "https://fr.indeed.com/viewjob?jk=abc123def456"
}
Puis pour chaque offre détaillée, le script affiche la progression et les champs enrichis :
[14:32:03] Chargement des détails (5 offres)
> [1/5] Détail de: Alternance Développeur Web (jk=abc123def456)
OK Champs enrichis: company, location, description, contract_type, published_at
> Pause 2.7s...
> [2/5] Détail de: Assistant Commercial H/F (jk=xyz789...)
OK Champs enrichis: company, location, salary, description, published_at
À la fin, un résumé compact puis le JSON complet enrichi (listing + détail fusionnés) :
[14:32:18] Résultats finaux (5 offres)
#1 Alternance Développeur Web @ TechCorp -- Paris (75) | 1 200 EUR par mois
#2 Assistant Commercial H/F @ SARL Dupont -- Lyon 69001
...
[14:32:18] JSON complet des offres enrichies
----------------------------------------------------------------------
--- Offre #1 -- Alternance Développeur Web ---
{
"job_key": "abc123def456",
"title": "Alternance Développeur Web",
"company": "TechCorp",
"location": "Paris 75001",
"salary": "1 200 EUR par mois",
"contract_type": "Alternance",
"published_at": "2026-02-10T14:00:00+00:00",
"url": "https://fr.indeed.com/viewjob?jk=abc123def456",
"description": "Nous recherchons un développeur web en alternance... (1847 chars)"
}
Les descriptions longues sont tronquées dans l'affichage (200 caractères) mais conservées intégralement dans l'export --json-output.
Résumé technique
| Couche | Implémentation | Rôle |
|---|---|---|
| TLS | curl_cffi + impersonate="chrome" | Fingerprint TLS identique à Chrome |
| HTTP | Headers Sec-Fetch-*, Accept-Language, Referer dynamique | Cohérence applicative |
| Réseau | Proxy unique via PROXY_URL | Rotation d'IP (manuelle) |
| Anti-bot | Détection de redirection secure.indeed.com/auth | Diagnostic de blocage |
| Pagination | Première page seulement (login obligatoire depuis 2026) | Limitation connue |
| Parsing listing | Mosaic providers + fallback legacy comp-initialData | Support multi-format |
| Parsing détail | JSON embedded + fallback JSON-LD JobPosting | Support multi-format |
| Extraction | dict_get multi-chemin + fallbacks par champ | Résilience aux variations |
| Timing | Délais aléatoires 2-4s entre les détails | Comportement humain |
| Sortie | Logs colorés + JSON pretty-print + export --json-output | Traçabilité et debug |
La clé, c'est la cohérence entre toutes ces couches. Le fingerprint TLS seul ne suffit pas si les headers HTTP sont incohérents. Les bons headers ne suffisent pas si l'IP est blacklistée. Et depuis 2026, même avec tout ça, la pagination reste un défi -- crawlergrid.ai gère toutes ces contraintes en production, de manière fiable et durable.