{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Compter un marché du travail — API France Travail « Offres d'emploi v2 »\n", "\n", "**M2 MOD · Séminaires métiers · IAE Clermont Auvergne (V. Favarin)**\n", "\n", "Ce notebook compte les offres d'emploi d'un métier en France, en extrait les salaires affichés et\n", "repère les mots-clés *IA / data / automation* dans les annonces. Il produit un CSV et trois chiffres\n", "sourcés pour votre site.\n", "\n", "Règles :\n", "1. **Pas de scraping** : l'APEC l'interdit dans ses CGU, et ce n'est pas nécessaire — France Travail expose ses offres via une API gratuite.\n", "2. La clé est **personnelle et gratuite** : créez un compte sur https://francetravail.io, puis *Mes applications → Créer une application*, cochez l'API **Offres d'emploi v2**. Vous obtenez un *identifiant client* et une *clé secrète*.\n", "3. Ne collez jamais votre clé dans un document rendu : elle reste dans la cellule ci-dessous (ou dans les *Secrets* de Colab).\n", "\n", "Documentation officielle : https://francetravail.io/data/api/offres-emploi\n" ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "# 1) Vos identifiants (jamais dans un rendu !)\n", "CLIENT_ID = \"PAR_votre_application_xxxxxxxx\" # <- remplacez\n", "CLIENT_SECRET = \"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\" # <- remplacez\n", "\n", "# 2) Le métier que vous ciblez : des mots-clés (comme dans la barre de recherche France Travail)\n", "MOTS_CLES = \"chef de projet marketing digital\"\n", "# Facultatif : un code ROME pour cadrer (ex. M1705 = Marketing, M1703 = Management et gestion de produit,\n", "# E1101 = Animation de site multimédia, M1707 = Stratégie commerciale). Laissez \"\" pour ne pas filtrer.\n", "CODE_ROME = \"\"\n", "# Facultatif : un département (ex. \"63\") ou \"\" pour la France entière\n", "DEPARTEMENT = \"\"\n" ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "import requests, time, re, json\n", "import pandas as pd\n", "\n", "TOKEN_URL = \"https://entreprise.francetravail.fr/connexion/oauth2/access_token?realm=/partenaire\"\n", "SEARCH_URL = \"https://api.francetravail.io/partenaire/offresdemploi/v2/offres/search\"\n", "\n", "def obtenir_token():\n", " r = requests.post(TOKEN_URL, data={\n", " \"grant_type\": \"client_credentials\",\n", " \"client_id\": CLIENT_ID,\n", " \"client_secret\": CLIENT_SECRET,\n", " \"scope\": \"api_offresdemploiv2 o2dsoffre\",\n", " }, headers={\"Content-Type\": \"application/x-www-form-urlencoded\"}, timeout=30)\n", " r.raise_for_status()\n", " return r.json()[\"access_token\"]\n", "\n", "token = obtenir_token()\n", "print(\"Token obtenu :\", token[:12] + \"…\")\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Collecte\n", "\n", "L'API renvoie au plus **150 offres par appel** (paramètre `range`, ex. `0-149`) et ne permet pas de dépasser\n", "l'offre n° 1149 pour une même requête. On pagine donc, et si votre métier compte plus de 1 150 offres,\n", "on découpe par département (boucle facultative plus bas). Le total réel est lu dans l'en-tête `Content-Range`.\n" ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "def chercher(params, pas=150, maximum=1150):\n", " \"\"\"Pagine la recherche ; renvoie (liste d'offres, total annoncé par l'API).\"\"\"\n", " offres, total, debut = [], None, 0\n", " while debut < maximum:\n", " fin = min(debut + pas - 1, maximum - 1)\n", " p = dict(params, range=f\"{debut}-{fin}\")\n", " r = requests.get(SEARCH_URL, params=p, headers={\"Authorization\": f\"Bearer {token}\"}, timeout=30)\n", " if r.status_code == 204: # aucune offre\n", " break\n", " if r.status_code not in (200, 206):\n", " raise RuntimeError(f\"{r.status_code} : {r.text[:200]}\")\n", " cr = r.headers.get(\"Content-Range\", \"\") # ex. \"offres 0-149/1234\"\n", " m = re.search(r\"/(\\d+)\", cr)\n", " if m:\n", " total = int(m.group(1))\n", " lot = r.json().get(\"resultats\", [])\n", " offres.extend(lot)\n", " if len(lot) < pas or (total is not None and len(offres) >= total):\n", " break\n", " debut += pas\n", " time.sleep(0.3) # on reste poli avec l'API\n", " return offres, total\n", "\n", "params = {\"motsCles\": MOTS_CLES}\n", "if CODE_ROME:\n", " params[\"codeROME\"] = CODE_ROME\n", "if DEPARTEMENT:\n", " params[\"departement\"] = DEPARTEMENT\n", "\n", "offres, total = chercher(params)\n", "print(f\"{len(offres)} offres récupérées — total annoncé par l'API : {total}\")\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Mise en tableau\n", "\n", "Chaque offre devient une ligne : intitulé, entreprise, lieu, contrat, salaire affiché, date, et trois drapeaux\n", "*IA / data / automation* calculés sur l'intitulé + la description. Adaptez les listes de mots-clés à votre métier\n", "(ajoutez par exemple `CRM`, `SEO`, `Meta Ads`…) : c'est votre grille, elle doit être écrite sur votre site.\n" ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "# Chaque mot-clé est cherché comme un mot entier (frontières de mot) : « ia » ne matche pas « media », « make » ne matche pas « maker ».\n", "MOTS_IA = [\"intelligence artificielle\", \"ia\", \"genai\", \"ia générative\", \"ia generative\", \"chatgpt\", \"llm\", \"machine learning\", \"copilot\"]\n", "MOTS_DATA = [\"data\", \"données\", \"analytics\", \"sql\", \"power bi\", \"looker\", \"tableau de bord\", \"tableaux de bord\", \"kpi\", \"python\"]\n", "MOTS_AUTO = [\"automation\", \"automatisation\", \"hubspot\", \"marketing automation\", \"workflow\", \"workflows\", \"zapier\", \"make\", \"n8n\"]\n", "\n", "def drapeau(texte, mots):\n", " t = (texte or \"\").lower()\n", " return int(any(re.search(r\"(? 5000] # écarte les '13 mois', '35 h'…\n", " if not vals:\n", " return (None, None)\n", " return (min(vals), max(vals))\n", "\n", "lignes = []\n", "for o in offres:\n", " texte = (o.get(\"intitule\") or \"\") + \" \" + (o.get(\"description\") or \"\")\n", " smin, smax = salaire_min_max((o.get(\"salaire\") or {}).get(\"libelle\"))\n", " lignes.append({\n", " \"id\": o.get(\"id\"),\n", " \"intitule\": o.get(\"intitule\"),\n", " \"entreprise\": (o.get(\"entreprise\") or {}).get(\"nom\"),\n", " \"lieu\": (o.get(\"lieuTravail\") or {}).get(\"libelle\"),\n", " \"contrat\": o.get(\"typeContrat\"),\n", " \"experience\": o.get(\"experienceLibelle\"),\n", " \"rome\": o.get(\"romeCode\"),\n", " \"date\": (o.get(\"dateCreation\") or \"\")[:10],\n", " \"salaire_libelle\": (o.get(\"salaire\") or {}).get(\"libelle\"),\n", " \"salaire_min_annuel\": smin,\n", " \"salaire_max_annuel\": smax,\n", " \"ia\": drapeau(texte, MOTS_IA),\n", " \"data\": drapeau(texte, MOTS_DATA),\n", " \"automation\": drapeau(texte, MOTS_AUTO),\n", " \"url\": (o.get(\"origineOffre\") or {}).get(\"urlOrigine\"),\n", " })\n", "df = pd.DataFrame(lignes)\n", "df.head()\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Les trois chiffres pour la note\n", "\n", "Ce bloc sort ce que votre site doit afficher **avec la date d'extraction et la requête exacte** : le nombre d'offres,\n", "la fourchette de salaire affichée (et la part d'offres qui l'affichent), la part d'offres qui mentionnent IA / data / automation.\n", "Un chiffre sans sa requête ni sa date n'est pas un chiffre sourcé.\n" ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "from datetime import date\n", "n = len(df)\n", "avec_salaire = df[\"salaire_min_annuel\"].notna().sum()\n", "print(f\"Extraction du {date.today():%d/%m/%Y} — requête : motsCles='{MOTS_CLES}' codeROME='{CODE_ROME}' departement='{DEPARTEMENT}'\")\n", "print(f\"1) Offres actives : {total if total is not None else n} (dont {n} récupérées)\")\n", "if avec_salaire:\n", " print(f\"2) Salaire annuel affiché (médiane des minima → médiane des maxima) : \"\n", " f\"{df['salaire_min_annuel'].median():,.0f} € → {df['salaire_max_annuel'].median():,.0f} € \"\n", " f\"({avec_salaire} offres sur {n} l'affichent, soit {100*avec_salaire/n:.0f} %)\")\n", "else:\n", " print(\"2) Aucune offre n'affiche de salaire : dites-le, c'est une information en soi.\")\n", "print(f\"3) Mentions dans l'annonce : IA {100*df['ia'].mean():.0f} % · data {100*df['data'].mean():.0f} % · automation {100*df['automation'].mean():.0f} %\")\n", "print(\" Contrats :\", df[\"contrat\"].value_counts().to_dict())\n", "print(\" Expérience demandée :\", df[\"experience\"].value_counts().head(4).to_dict())\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Export\n", "\n", "Le CSV est votre trace : il va dans le dossier `data/` de votre site ou de votre dépôt, avec sa date dans le nom.\n" ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "nom = \"offres_\" + re.sub(r\"[^a-z0-9]+\", \"_\", MOTS_CLES.lower()).strip(\"_\") + f\"_{date.today():%Y%m%d}.csv\"\n", "df.to_csv(nom, index=False, encoding=\"utf-8-sig\")\n", "print(\"Écrit :\", nom, \"—\", len(df), \"lignes\")\n", "# Dans Colab : panneau de gauche > Fichiers > clic droit > Télécharger.\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Facultatif — un métier à plus de 1 150 offres : découper par département\n", "\n", "L'API refuse de dépasser l'offre n° 1149 pour une même requête. On relance donc la même recherche\n", "département par département et on additionne. C'est plus long (≈ 100 appels), gardez-le pour un métier\n", "très courant (« commercial », « chargé de communication »…).\n" ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "DEPARTEMENTS = [f\"{i:02d}\" for i in range(1, 96) if i != 20] + [\"2A\", \"2B\", \"971\", \"972\", \"973\", \"974\", \"976\"]\n", "compte = {}\n", "for d in DEPARTEMENTS:\n", " _, t = chercher(dict(params, departement=d), maximum=150)\n", " compte[d] = t or 0\n", " time.sleep(0.2)\n", "serie = pd.Series(compte).sort_values(ascending=False)\n", "print(\"Total France :\", int(serie.sum()))\n", "serie.head(10)\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Ce que ce notebook ne dit pas\n", "\n", "- France Travail n'héberge pas toutes les offres cadres : l'APEC, LinkedIn et Welcome to the Jungle en publient d'autres. Vous comptez **un** canal, dites-le.\n", "- Les salaires affichés sont ceux des offres qui les affichent (souvent moins de la moitié) : biais probable vers le bas ou vers les PME.\n", "- Une mention « IA » dans une annonce n'est pas une compétence exigée : lisez dix annonces marquées `ia = 1` pour dire ce que le mot recouvre.\n", "\n", "Ces trois limites sont attendues sur votre site (« ce que ces chiffres n'autorisent pas à conclure »).\n" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "name": "python" }, "colab": { "name": "france-travail-compter-un-marche.ipynb" } }, "nbformat": 4, "nbformat_minor": 5 }