Indeed est l'une des plus grandes plateformes d'offres d'emploi sur le web, agrégeant des millions d'annonces publiques dans de nombreux secteurs, entreprises et régions. Chaque annonce porte des données structurées utiles pour la recherche sur le marché du travail, l'analyse concurrentielle des recruteurs et les outils de recherche d'emploi personnalisés : un intitulé de poste, l'entreprise qui recrute, une localisation, une fourchette salariale lorsqu'elle est publique, ainsi qu'un lien vers l'annonce complète. Le problème, c'est qu'Indeed construit ses résultats de recherche en JavaScript et dissimule les données des annonces au plus profond de la page, si bien qu'une simple requête HTTP vous renvoie une coquille presque vide plutôt que les offres d'emploi recherchées.

Ce guide vous explique comment scraper des offres d'emploi Indeed avec Python de manière fiable. Vous construisez un petit scraper fonctionnel qui récupère une page de résultats rendue via la Crawling API, extrait les données d'emploi intégrées dans la page, analyse chaque champ, gère la pagination et exporte les résultats en JSON et CSV. L'ensemble du tutoriel se limite aux données d'annonces d'emploi publiques, et la section sur la légalité en fin d'article n'est pas là par formalité, lisez-la avant de lancer ce script à grande échelle.

Ce que vous allez construire

Un script Python qui prend une URL de recherche Indeed publique, récupère le HTML rendu via la Crawling API et extrait un enregistrement structuré pour chaque annonce de la page. Nous utiliserons une recherche d'emplois de développeur comme exemple fil rouge et extrairons les champs suivants par annonce :

  • Intitulé du poste le rôle proposé, lu depuis le champ title de chaque carte.
  • Entreprise l'employeur derrière l'annonce.
  • Lieu où se situe le poste, depuis formattedLocation.
  • Fourchette salariale le salaire minimum et maximum lorsqu'Indeed l'affiche, depuis extractedSalary.
  • Clé et lien du poste l'identifiant jobkey et l'URL de l'annonce, pour pouvoir revenir sur chaque offre.

Pourquoi une simple requête échoue sur Indeed

Si vous interrogez une URL de recherche Indeed avec un client HTTP basique, vous obtenez une réponse avec le statut 200 mais presque aucune donnée d'annonce dans le corps. Deux obstacles se dressent face à vous. D'abord, Indeed affiche ses résultats en JavaScript, donc le HTML initial est une coquille qui ne se remplit qu'après l'exécution des scripts de la page. Ensuite, Indeed se défend activement contre les accès automatisés avec des challenges CAPTCHA et des limites de débit par adresse IP, si bien que même une requête rendue depuis une adresse signalée renvoie une page de challenge plutôt que des offres d'emploi.

Un scraper Indeed fonctionnel nécessite donc deux choses dans une seule requête : un navigateur qui rende réellement la page, et une adresse IP que la plateforme perçoit comme un visiteur réel. Vous pouvez assembler cela vous-même avec un navigateur headless et un pool de proxies résidentiels rotatifs, mais maintenir cet ensemble en bon état représente l'essentiel du travail. La Crawling API réunit les deux en un seul appel : vous lui envoyez l'URL avec un token JavaScript, elle rend la page derrière une adresse IP de confiance, et vous retourne le HTML complet prêt à être analysé.

Pourquoi le token JS

Crawlbase propose deux types de tokens. Le token normal récupère le HTML statique ; le token JavaScript (JS) rend d'abord la page dans un vrai navigateur. Indeed est rendu côté client, vous avez donc besoin du token JS ici. Utiliser le token normal renvoie la même coquille vide qu'une simple requête, sans rien à analyser.

Prérequis

Quelques éléments doivent être en place avant d'écrire du code. Aucun ne prend longtemps.

Python de base. Vous devez être à l'aise pour écrire et exécuter un script Python et installer des paquets avec pip. L'analyse ici s'appuie sur les expressions régulières et JSON plutôt que sur une bibliothèque DOM, mais si vous souhaitez aussi un primer basé sur les sélecteurs, notre guide sur comment utiliser BeautifulSoup en Python couvre les bases.

Python 3.8 ou version ultérieure. Vérifiez votre version avec python --version. Si vous ne l'avez pas, installez-le depuis python.org ou via une distribution comme Anaconda.

Un compte Crawlbase et un token JS. Inscrivez-vous, ouvrez votre tableau de bord et copiez votre token JavaScript (JS) depuis la page de documentation du compte. Traitez le token comme un mot de passe : il authentifie vos requêtes, gardez-le hors du contrôle de version. Le niveau gratuit inclut jusqu'à 5 000 requêtes, suffisant pour suivre ce guide de bout en bout, et vous n'êtes facturé que pour les requêtes réussies.

Configurer le projet

Créez un environnement virtuel pour isoler les dépendances du projet, puis installez la seule bibliothèque dont le scraper a besoin.

bash
python --version

python -m venv indeed_env
source indeed_env/bin/activate

pip install crawlbase

Sur Windows, activez l'environnement avec indeed_env\Scripts\activate au lieu de la ligne source. Le paquet crawlbase est le client officiel de la Crawling API. Comme Indeed intègre ses données d'annonces en JSON dans la page, les modules de la bibliothèque standard re et json effectuent l'analyse, sans bibliothèque HTML à installer pour les pages de recherche.

Étape 1 : Récupérer la page de recherche rendue

Commencez par obtenir la page complète. Lorsque vous effectuez une recherche sur la page d'accueil d'Indeed, elle vous redirige vers une URL comme https://www.indeed.com/jobs?q=Web+Developer&l=Virginia, où q est la requête et l est la localisation. Importez la classe CrawlingAPI, initialisez-la avec votre token JS et demandez cette URL. Passez country pour que la requête provienne d'une adresse IP américaine, car Indeed sert des résultats localisés. Vérifier le code de statut avant d'analyser permet de rendre les échecs visibles plutôt que silencieux.

python
from crawlbase import CrawlingAPI

api = CrawlingAPI({"token": "YOUR_CRAWLBASE_JS_TOKEN"})

def crawl(page_url):
    response = api.get(page_url, {"country": "US"})
    if response["status_code"] == 200:
        return response["body"].decode("latin1")
    print(f"Request failed: {response['status_code']}")
    return None

if __name__ == "__main__":
    page_url = "https://www.indeed.com/jobs?q=Web+Developer&l=Virginia"
    html = crawl(page_url)
    print(html[:500] if html else "No HTML returned")

Exécutez le script avec python scraper.py et vous devriez voir le vrai balisage de la page, et non la coquille vide qu'une simple requête renverrait. Nous décodons avec latin1 plutôt qu'avec utf-8 car les pages d'Indeed contiennent des séquences d'octets mixtes, et latin1 mappe chaque octet proprement pour que le JSON intégré survive au décodage intact. Cela confirme que le rendu fonctionne avant d'écrire le moindre analyseur.

Crawlbase Crawling API

Indeed exige une page rendue en JavaScript derrière une adresse IP de confiance, en un seul appel, faute de quoi son CAPTCHA et ses limites de débit vous bloquent net. La Crawling API prend un token JS, exécute la page dans un vrai navigateur et fait tourner des adresses IP résidentielles côté serveur, vous évitant ainsi de gérer vous-même une flotte headless et un pool de proxies. Pointez-la vers une page de recherche publique sur le niveau gratuit en premier.

Étape 2 : Localiser les données d'emploi intégrées

Vous pourriez analyser les cartes d'emploi rendues avec des sélecteurs CSS ou XPath, mais Indeed vous offre un point d'ancrage plus facile et bien plus stable. Chaque page de recherche intègre l'ensemble des annonces sous forme de document JSON dans une balise script, assignée à une variable JavaScript nommée window.mosaic.providerData["mosaic-provider-jobcards"]. Lire ce seul blob vous donne tous les champs affichés sur les cartes, déjà structurés, sans la volatilité des sélecteurs de noms de classe.

Une courte expression régulière extrait ce JSON du HTML. Une fois analysé, les annonces se trouvent sous un chemin prévisible, et un bloc voisin contient les comptages de résultats nécessaires pour la pagination :

python
import re
import json

def parse_search_page_html(html):
    data = re.findall(r'window.mosaic.providerData\["mosaic-provider-jobcards"\]=(\{.+?\});', html)
    data = json.loads(data[0])
    model = data["metaData"]["mosaicProviderJobCardsModel"]
    return {
        "results": model["results"],
        "meta": model["tierSummaries"],
    }

La fonction localise la variable mosaic-provider-jobcards, analyse le JSON et retourne deux éléments : results, la liste des annonces d'emploi, et meta, les résumés de tier qui indiquent le nombre d'offres correspondant aux critères par catégorie. Chaque entrée dans results est un objet riche, mais vous n'avez besoin que d'une poignée de ses champs pour constituer un enregistrement propre.

Étape 3 : Extraire les champs souhaités

Un objet d'annonce brut contient des dizaines de clés internes. Réduisez chaque carte aux champs pertinents, en protégeant les champs optionnels pour qu'un salaire manquant ne fasse jamais planter l'exécution. Le salaire se trouve sous extractedSalary avec un minimum, un maximum et un type lorsqu'Indeed le fournit, et le lien vers l'annonce est construit à partir du jobkey.

python
def format_salary(card):
    salary = card.get("extractedSalary")
    if not salary:
        return ""
    low, high = salary.get("min"), salary.get("max")
    unit = salary.get("type", "")
    if low == high:
        return f"{low} {unit}".strip()
    return f"{low}-{high} {unit}".strip()

def extract_jobs(results):
    jobs = []
    for card in results:
        job_key = card.get("jobkey", "")
        jobs.append({
            "title": card.get("title", ""),
            "company": card.get("company", ""),
            "location": card.get("formattedLocation", ""),
            "salary": format_salary(card),
            "posted": card.get("formattedRelativeTime", ""),
            "job_key": job_key,
            "link": f"https://www.indeed.com/viewjob?jk={job_key}",
        })
    return jobs

Chaque lecture de champ passe par .get() avec une valeur par défaut, de sorte qu'une annonce sans salaire ou sans nom d'entreprise renvoie une chaîne vide plutôt qu'une erreur KeyError. Le jobkey vaut la peine d'être conservé séparément : il identifie de manière unique une annonce et vous permet de construire un lien viewjob direct, pratique si vous souhaitez ensuite récupérer la page complète de description du poste pour n'importe quel rôle.

Les sélecteurs évoluent

Le nom de variable mosaic-provider-jobcards et les clés de champs ci-dessus reflètent la structure actuelle de la page d'Indeed et peuvent changer lors d'une refonte. Traitez-les comme un modèle de départ, pas comme un contrat. Si l'expression régulière cesse de correspondre ou qu'un champ revient vide sur toutes les cartes, inspectez à nouveau une page de recherche en direct dans les outils de développement de votre navigateur, localisez de nouveau le JSON intégré et mettez à jour le motif. Une maintenance périodique est normale pour tout scraper en production.

Étape 4 : Gérer la pagination

Une page de recherche n'affiche que le premier lot d'offres. Indeed pagine avec un paramètre de requête start qui décale les résultats par pas de 10, donc vous avancez dans les résultats en l'incrémentant. Les résumés de tier de l'étape 2 vous indiquent combien d'offres correspondent au total, ce qui vous permet de plafonner le crawl à un max_results raisonnable au lieu de récupérer toutes les pages. Construisez chaque URL de page avec urlencode, récupérez-la et accumulez les enregistrements extraits.

python
import time
from urllib.parse import urlencode

def make_search_url(query, location, offset):
    params = {"q": query, "l": location, "filter": 0, "start": offset}
    return f"https://www.indeed.com/jobs?{urlencode(params)}"

def scrape_indeed_search(query, location, max_results=50):
    print(f"Scraping first page: query={query}, location={location}")
    html = crawl(make_search_url(query, location, 0))
    if not html:
        return []

    first = parse_search_page_html(html)
    jobs = extract_jobs(first["results"])
    total = sum(c["jobCount"] for c in first["meta"])
    total = min(total, max_results)

    for offset in range(10, total, 10):
        print(f"Scraping page at offset {offset}")
        page_html = crawl(make_search_url(query, location, offset))
        if page_html:
            page = parse_search_page_html(page_html)
            jobs.extend(extract_jobs(page["results"]))
        time.sleep(2)
    return jobs

La première requête remplit un double rôle : elle vous fournit la première page d'offres et les résumés de tier qui révèlent le nombre total de correspondances. Ensuite, la boucle génère des URLs de page à des décalages de 10 jusqu'à votre plafond et récupère chacune d'elles, en réutilisant la même paire analyser-extraire. Le time.sleep(2) entre les pages est délibéré. Envoyer des requêtes en rafale est le moyen le plus rapide d'être limité, même avec le rendu et la rotation pris en charge pour vous.

Étape 5 : Assembler le tout et exporter en JSON et CSV

Maintenant, reliez la récupération, l'analyseur, l'extracteur de champs et la boucle de pagination dans un script exécutable unique, puis écrivez les enregistrements en JSON et en CSV. JSON conserve la structure intacte pour le code en aval ; CSV s'ouvre directement dans un tableur.

python
import re
import json
import csv
import time
from urllib.parse import urlencode
from crawlbase import CrawlingAPI

api = CrawlingAPI({"token": "YOUR_CRAWLBASE_JS_TOKEN"})

def crawl(page_url):
    response = api.get(page_url, {"country": "US"})
    if response["status_code"] == 200:
        return response["body"].decode("latin1")
    print(f"Request failed: {response['status_code']}")
    return None

def parse_search_page_html(html):
    data = re.findall(r'window.mosaic.providerData\["mosaic-provider-jobcards"\]=(\{.+?\});', html)
    data = json.loads(data[0])
    model = data["metaData"]["mosaicProviderJobCardsModel"]
    return {"results": model["results"], "meta": model["tierSummaries"]}

def format_salary(card):
    salary = card.get("extractedSalary")
    if not salary:
        return ""
    low, high = salary.get("min"), salary.get("max")
    unit = salary.get("type", "")
    if low == high:
        return f"{low} {unit}".strip()
    return f"{low}-{high} {unit}".strip()

def extract_jobs(results):
    jobs = []
    for card in results:
        job_key = card.get("jobkey", "")
        jobs.append({
            "title": card.get("title", ""),
            "company": card.get("company", ""),
            "location": card.get("formattedLocation", ""),
            "salary": format_salary(card),
            "posted": card.get("formattedRelativeTime", ""),
            "job_key": job_key,
            "link": f"https://www.indeed.com/viewjob?jk={job_key}",
        })
    return jobs

def make_search_url(query, location, offset):
    params = {"q": query, "l": location, "filter": 0, "start": offset}
    return f"https://www.indeed.com/jobs?{urlencode(params)}"

def scrape_indeed_search(query, location, max_results=50):
    html = crawl(make_search_url(query, location, 0))
    if not html:
        return []
    first = parse_search_page_html(html)
    jobs = extract_jobs(first["results"])
    total = min(sum(c["jobCount"] for c in first["meta"]), max_results)
    for offset in range(10, total, 10):
        page_html = crawl(make_search_url(query, location, offset))
        if page_html:
            jobs.extend(extract_jobs(parse_search_page_html(page_html)["results"]))
        time.sleep(2)
    return jobs

def save_results(jobs):
    with open("indeed_jobs.json", "w") as f:
        json.dump(jobs, f, indent=2)
    if jobs:
        with open("indeed_jobs.csv", "w", newline="") as f:
            writer = csv.DictWriter(f, fieldnames=jobs[0].keys())
            writer.writeheader()
            writer.writerows(jobs)

def main():
    jobs = scrape_indeed_search("Web Developer", "Virginia")
    save_results(jobs)
    print(f"Saved {len(jobs)} jobs to indeed_jobs.json and indeed_jobs.csv")

if __name__ == "__main__":
    main()

À quoi ressemble le résultat

Exécutez le script complet avec python scraper.py et vous obtenez une liste d'enregistrements structurés propres, un par annonce, écrits dans indeed_jobs.json et indeed_jobs.csv.

json
[
  {
    "title": "Front Desk Agent",
    "company": "The Inn at Little Washington",
    "location": "Washington, VA 22747",
    "salary": "22 hourly",
    "posted": "20 days ago",
    "job_key": "72ed373141879fd4",
    "link": "https://www.indeed.com/viewjob?jk=72ed373141879fd4"
  },
  {
    "title": "Web Developer",
    "company": "Acme Digital",
    "location": "Richmond, VA",
    "salary": "75000-95000 yearly",
    "posted": "3 days ago",
    "job_key": "6a45faa5d8d817fa",
    "link": "https://www.indeed.com/viewjob?jk=6a45faa5d8d817fa"
  }
]

Les annonces sans salaire public renvoient une chaîne salary vide, ce qui est attendu puisque tous les employeurs n'affichent pas leur rémunération. Les mêmes enregistrements se trouvent dans le CSV avec une ligne par offre, prêts à être ouverts dans un tableur ou chargés dans une base de données pour analyse.

Rester débloqué

Même avec le rendu et la rotation pris en charge, Indeed surveille de près le trafic ayant l'apparence d'un scraper et est l'une des plateformes d'offres d'emploi les plus agressives à ce sujet. Quelques habitudes permettent de maintenir un run en bonne santé, et elles s'appliquent à toute cible commerciale difficile.

  • Cadencez vos requêtes. Conservez le time.sleep entre les pages et variez vos requêtes plutôt que de crawler un seul chemin de recherche à pleine vitesse.
  • Misez sur la rotation. Un pool d'adresses IP résidentielles répartit les requêtes sur de nombreuses adresses d'utilisateurs réels, de sorte qu'aucune ne déclenche une limite de débit. La Crawling API gère cela pour vous ; si vous montez votre propre infrastructure, c'est la partie à soigner.
  • Lisez les codes de statut. Un run qui commence à renvoyer des challenges CAPTCHA ou des codes non-200 vous indique que le débit ou le niveau d'adresses IP actuel n'est plus suffisant. Traitez cela comme un signal pour ralentir, pas comme du bruit à ignorer.

Pour un guide plus complet, consultez comment scraper des sites web sans se faire bloquer et notre analyse approfondie sur comment crawler des sites JavaScript, qui couvre le problème de rendu qui bloque la plupart des scrapers de plateformes d'emploi. Si vous construisez un ensemble de données d'emploi plus large, le même motif d'extraction JSON fonctionne sur d'autres plateformes : consultez comment scraper Monster avec Python et comment scraper Glassdoor.

Est-il légal de scraper Indeed ?

La légalité du scraping d'Indeed dépend des conditions d'utilisation d'Indeed, de votre juridiction et de l'usage que vous faites des données. Les conditions d'Indeed restreignent l'accès automatisé, et le site déploie activement des CAPTCHA et des limites de débit par adresse IP pour le décourager, de sorte que le scraping peut aller à l'encontre de ces conditions, quelle que soit la prudence de vos outils. Aucun code présenté ici ne change cela ; il se contente de faire fonctionner la partie technique. Lisez les conditions d'utilisation d'Indeed et son robots.txt, et traitez les deux comme la limite de ce que vous collectez.

Quelques règles à respecter. Ne collectez que des données publiques d'annonces d'emploi : l'intitulé du poste, l'entreprise, la localisation, la fourchette salariale publique et le lien vers l'annonce que tout le monde peut voir sur une page de recherche publique sans se connecter. Respectez les attentes de débit indiquées par Indeed et maintenez votre volume de requêtes suffisamment bas pour ne pas surcharger ses serveurs. Lorsque des données personnelles sont impliquées, les lois sur la vie privée telles que le RGPD et le CCPA s'appliquent, alors limitez strictement votre travail à l'annonce publique elle-même.

Ce guide est délibérément limité aux annonces d'emploi publiques parce que c'est la ligne qui rend le travail défendable. Il ne couvre pas les données des candidats, les CV, les profils de candidats, les coordonnées des recruteurs, ni quoi que ce soit derrière une connexion ou un niveau payant, et il ne tente pas de contourner l'authentification. Les informations personnelles des demandeurs d'emploi et des recruteurs sont exactement le type de données à laisser de côté. Indeed gère également une API Publisher officielle et un programme partenaire pour l'accès sous licence à ses données d'emploi, et si votre projet nécessite plus que des annonces publiques à grande échelle, ce programme est la bonne voie, pas un scraper plus sophistiqué.

Récapitulatif

Points clés

  • Indeed est rendu en JavaScript et défendu. Une simple requête renvoie une coquille vide et une adresse IP signalée reçoit un CAPTCHA, vous devez donc rendre la page derrière une adresse IP de confiance avant de l'analyser.
  • Les données sont intégrées, pas dans le balisage. Chaque page de recherche dissimule ses annonces en JSON dans la variable mosaic-provider-jobcards ; une courte expression régulière et json.loads sont plus stables que les sélecteurs CSS.
  • Ne mappez que les champs dont vous avez besoin. Extrayez le titre, l'entreprise, formattedLocation, extractedSalary et le lien jobkey, en protégeant les champs optionnels pour qu'un salaire manquant ne fasse jamais planter l'exécution.
  • Paginez avec start et exportez dans les deux formats. Parcourez le paramètre start par pas de 10 jusqu'à un plafond tiré des résumés de tier, puis écrivez en JSON et CSV.
  • Restez sur les annonces publiques. Respectez les conditions d'utilisation et le robots.txt d'Indeed, préférez l'API Publisher officielle pour la mise à l'échelle et ne touchez jamais aux données personnelles des candidats ou des recruteurs.

Foire aux questions

Est-il possible de scraper Indeed ?

Techniquement oui, mais Indeed s'en défend avec des challenges CAPTCHA et des limites de débit par adresse IP, et ses conditions restreignent l'accès automatisé. Pour récupérer une page de recherche publique, vous devez rendre la page dans un vrai navigateur derrière une adresse IP de confiance, ce que gère le token JS de la Crawling API avant que vous n'analysiez quoi que ce soit. Pour un accès sous licence à grande échelle, l'API Publisher officielle et le programme partenaire d'Indeed sont la voie sanctionnée.

Ai-je besoin du token normal ou du token JS pour Indeed ?

Le token JS. Indeed rend ses résultats en JavaScript, donc le token normal renvoie la même coquille vide qu'une simple requête. Le token JS rend d'abord la page dans un vrai navigateur, ce qui rend le JSON mosaic-provider-jobcards intégré présent dans le HTML que vous analysez.

Pourquoi analyser le JSON intégré plutôt que les sélecteurs CSS ?

Indeed envoie l'intégralité des données d'annonces sous forme de blob JSON dans la page, assigné à window.mosaic.providerData["mosaic-provider-jobcards"]. Lire cet objet unique vous donne tous les champs déjà structurés, et il résiste aux noms de classe hachés générés à la compilation qui cassent les scrapers basés sur des sélecteurs à chaque déploiement. Une seule expression régulière l'extrait, puis json.loads le transforme en dictionnaire Python.

Comment gérer la pagination d'Indeed ?

Indeed décale les résultats avec un paramètre de requête start par pas de 10. Lisez le nombre total de correspondances depuis les résumés de tier sur la première page, plafonnez-le à un max_results raisonnable, puis générez des URLs de page à des décalages de 10, 20, 30, etc., en récupérant et analysant chacune. Cadencez la boucle avec un court délai pour ne pas être limité.

Puis-je scraper les données des candidats ou les CV complets sur Indeed ?

Non, et ce guide ne le couvre pas. Les données des candidats, les CV et les profils de candidats ou de recruteurs sont des informations personnelles ou se trouvent derrière une connexion, et non des données d'annonces d'emploi publiques. Le scraping de contenu protégé par une connexion ou le contournement de l'authentification pour y accéder est hors de portée ici et va à l'encontre des conditions d'Indeed. Limitez votre périmètre aux annonces publiques sur les pages de recherche.

Comment éviter d'être bloqué lors du scraping d'Indeed ?

Maintenez votre taux de requêtes par adresse IP bas, variez vos requêtes au lieu de boucler sur un seul chemin de recherche et routez via des adresses IP résidentielles rotatives pour qu'aucune adresse individuelle ne déclenche une limite de débit. La Crawling API gère la rotation et un pool d'adresses IP de confiance pour vous ; si vous construisez votre propre infrastructure, c'est la partie dans laquelle investir. Surveillez les codes de statut et ralentissez dès que vous commencez à voir des challenges CAPTCHA.

Commencer à construire

Crawlez n'importe quel site à grande échelle, sans combattre l'infrastructure.

Crawlbase gère les proxies, les empreintes et les CAPTCHA afin que votre équipe livre des pipelines de données au lieu de maintenir la plomberie de crawl. 1 000 requêtes gratuites, sans carte requise.

En libre-service · Sans appel commercial requis · Volumes de crawl entreprise disponibles