Retour au blog

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

11 février 2026
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.

Voir le code complet sur GitHub

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
  1. curl_cffi -- un binding Python de curl qui permet l'impersonation TLS de vrais navigateurs
  2. 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 :

NavigateurVersions disponibles
Chrome99, 100, 101, 104, 107, 110, 116, 119, 120, 123, 124, 131, 133a, 136, 142
Firefox133, 135, 144
Safari15.3, 15.5, 17.0, 18.0, 18.4, 26.0
Edge99, 101
Tor145

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 HTML
  • Sec-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 :

  1. Changer de proxy -- une nouvelle IP résidentielle suffit souvent à passer
  2. Utiliser un service de résolution de captcha comme CapSolver avec leur tâche AntiCloudflareTask, qui résout le challenge et retourne les cookies cf_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

CoucheImplémentationRôle
TLScurl_cffi + impersonate="chrome"Fingerprint TLS identique à Chrome
HTTPHeaders Sec-Fetch-*, Accept-Language, Referer dynamiqueCohérence applicative
RéseauProxy unique via PROXY_URLRotation d'IP (manuelle)
Anti-botDétection de redirection secure.indeed.com/authDiagnostic de blocage
PaginationPremière page seulement (login obligatoire depuis 2026)Limitation connue
Parsing listingMosaic providers + fallback legacy comp-initialDataSupport multi-format
Parsing détailJSON embedded + fallback JSON-LD JobPostingSupport multi-format
Extractiondict_get multi-chemin + fallbacks par champRésilience aux variations
TimingDélais aléatoires 2-4s entre les détailsComportement humain
SortieLogs colorés + JSON pretty-print + export --json-outputTraç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.

Besoin d'aide pour votre projet de scraping ?

Contactez-nous pour un devis gratuit.

Demander un devis