job2apply
Veille quotidienne d'offres d'emploi, tri selon un profil, et preparation de candidatures adaptees (lettre de motivation ciblee + CV) soumises a validation avant tout envoi.
Fonctionnement
job2apply fetchinterroge l'API France Travail et les sources d'appoint activees (LinkedIn, Adzuna, Google Jobs), deduplique viadata/seen.json, applique un pre-filtre par mots-cles, et ecritdata/shortlist.json.- La commande Claude Code
/veille-emploireprend cette shortlist, fait le vrai tri de fond, redige une lettre par offre danscandidatures/, puis demande la validation offre par offre. - L'envoi reste manuel pour l'instant (pas d'auth email configuree).
Tous ces chemins sont relatifs a un espace de travail : un dossier par recherche d'emploi, distinct du code (voir plus bas).
Installation
uv tool install job2apply # la commande job2apply, disponible partout
job2apply install-command # la commande Claude Code /veille-emploi
job2apply install-command copie /veille-emploi dans ~/.claude/commands/, ou Claude
Code la trouve depuis n'importe quel dossier. Une commande deja presente et modifiee
n'est pas ecrasee sans --forcer, et --projet l'installe dans l'espace de travail
courant plutot que chez l'utilisateur.
uv tool install --with-executables-from playwright "job2apply[google-jobs]" ajoute la
source Google Jobs, qui pilote un navigateur via Playwright ; sans cet extra, job2apply fetch la saute simplement. Il faut ensuite lancer une fois playwright install chromium.
Depuis les sources
Pour developper sur le depot lui-meme :
git clone <url-du-depot> job2apply
uv tool install -e ./job2apply # garde le lien avec les sources, commande disponible partout
uv sync # ou : travailler avec `uv run job2apply`, sans rien installer globalement
Puis un espace de travail :
mkdir ~/recherche-data-engineer && cd ~/recherche-data-engineer
job2apply init
init y depose config/profile.yaml et .env a remplir, et les dossiers data/,
candidatures/ et cv/. Relance sur un espace existant, il ne remplace rien.
Identifiants France Travail : creer un compte gratuit sur https://francetravail.io,
puis une application abonnee a l'API « Offres d'emploi v2 ». Le client_id et le
client_secret vont dans le .env de l'espace (jamais commite).
Espaces de travail
Un espace de travail est un dossier contenant config/profile.yaml — c'est tout ce qui
le definit. Il porte le profil, les identifiants d'API, la memoire des offres deja vues
(data/seen.json), le CV et les candidatures preparees. Deux recherches d'emploi
menees en parallele, ou deux personnes sur la meme machine, vivent dans deux espaces et
ne se marchent pas dessus.
L'outil determine a quel espace il s'applique, dans cet ordre :
- l'option
--espace <chemin>; - la variable d'environnement
JOB2APPLY_HOME; - le premier dossier parent, en partant du dossier courant, qui contient
config/profile.yaml— donc lancer la commande depuis un sous-dossier de l'espace fonctionne ; - a defaut, le dossier courant.
Le depot lui-meme peut servir d'espace : job2apply init a sa racine, et le profil ainsi
que data/ y sont deja ignores par git.
L'option globale --verbeux (comme --espace, avant la sous-commande) descend le
journal en DEBUG, y compris sur la console — voir Journal.
Configuration
Tout le filtrage et la redaction s'appuient sur config/profile.yaml : mots-cles de
recherche, localisation, types de contrat, termes bonus/eliminatoires, points forts
utilises dans les lettres. Le CV de base va dans cv/.
Aucune donnee personnelle n'est codee en dur dans le code : changer de profil (autre
metier, autre region, autre candidat) se fait entierement dans ce fichier. Les modeles
copies par job2apply init — profil, .env et commande Claude Code — vivent dans le
paquet, sous src/job2apply/modeles/, pour rester disponibles une fois l'outil installe
loin du depot.
Plusieurs villes
recherche.villes accepte une liste : chaque entree porte nom (la localite en clair,
pour Adzuna, LinkedIn et Google Jobs) et commune (son code INSEE, pour France Travail).
recherche:
villes:
- nom: "Nantes"
commune: "44109"
- nom: "Paris"
commune: "75056"
distance_km: 10
Toutes les sources interrogent chaque ville, une requete par ville et par mot-cle. Une
offre remontee par plusieurs villes n'est conservee qu'une fois, attribuee a la premiere
ville de la liste qui l'a trouvee — c'est ce que lit son champ ville_recherche, affiche
dans une colonne supplementaire de la table des qu'au moins deux villes sont
configurees. distance_km, pays, departements, types_contrat et publiee_depuis
restent des reglages globaux, communs a toutes les villes.
L'ancienne ecriture a une seule ville (commune: et lieu: scalaires) reste acceptee
et vaut une ville unique.
Multiplier les villes multiplie le nombre de requetes, LinkedIn en premier lieu : voir « Le nombre de requetes est la ressource rare » plus bas.
Adzuna (source d'appoint)
Adzuna est un agregateur d'offres avec une API publique et une inscription libre sur
https://developer.adzuna.com : l'app_id et l'app_key sont delivres immediatement et
vont dans .env. La source est optionnelle — sans identifiants, job2apply fetch la
saute sans rien signaler.
Elle se pilote par les memes cles de config/profile.yaml que France Travail :
mots_cles, villes, distance_km, types_contrat, publiee_depuis. Une cle lui est
propre, car Adzuna ne connait pas le code INSEE : pays (fr par defaut). Chaque ville
lui est passee par son nom en clair (par defaut identite.ville), pas son commune.
Adzuna ne publie pas d'email de contact : la candidature passe par l'URL de l'annonce.
LinkedIn (source d'appoint)
LinkedIn n'ouvre plus son API d'offres aux particuliers, mais sa recherche reste
consultable sans compte : c'est l'espace « invite », que la source lit directement en
HTTP. Ni identifiants ni navigateur — contrairement a Google Jobs, ces pages sont
servies sans JavaScript — mais c'est du scraping tout de meme : le robots.txt de
LinkedIn l'interdit explicitement (voir plus bas), d'ou l'activation explicite.
Elle est donc opt-in, dans config/profile.yaml :
linkedin:
actif: true
max_offres: 50 # par mot-cle ; jusqu'a 60, une seule requete suffit
lire_descriptions: true
delai_s: 1.0
langue: "fr-FR"
Elle reprend mots_cles, villes, distance_km et publiee_depuis de la section
recherche. types_contrat ne lui est pas transmis : le filtre de LinkedIn porte sur
le rythme de travail (temps plein, stage...) et non sur le CDI / CDD francais, et
l'appliquer ecarterait des offres valables. Le champ contrat reprend donc ce rythme
tel quel, sauf pour les quelques cas qui se recoupent (stage, alternance, interim).
Le nombre de requetes est la ressource rare
LinkedIn limite les visiteurs sur deux plans a la fois, mesures sur des fiches reelles :
| Delai entre requetes | Cadence | Resultat |
|---|---|---|
| 0.5 s | ~86 req/min | bloque des la 12e requete |
| 1.0 s | ~48 req/min | une veille entiere passe |
| 2.0 s | ~27 req/min | 150 requetes sans incident |
Le blocage (429) se leve en une vingtaine de secondes, mais le volume compte aussi :
apres environ 280 requetes en un quart d'heure, meme 1 s ne passe plus. Aucun delai ne
rend donc une veille trop bavarde sure — c'est le nombre de requetes qu'il faut tenir
bas. Deux mecanismes s'en chargent :
- La liste passe par la page de resultats, qui rend une soixantaine d'offres en une requete, la ou le fragment de defilement en donne dix. Le fragment ne sert plus qu'a depasser cette soixantaine, ou a prendre le relais si la page cesse d'etre lisible.
- Les fiches des offres deja vues ne sont pas relues. Elles seraient de toute facon
ecartees par
data/seen.jsonjuste apres : leur description couterait une requete pour un resultat jete.
Sur un profil a cinq mots-cles, cela donne 85 requetes le premier jour (77 offres, environ deux minutes) puis 8 requetes les jours suivants (une vingtaine de secondes), la ou lire chaque liste par dix et relire chaque fiche en demandait pres de trois cents. En cas de blocage malgre tout, la source patiente puis reessaie ; si les fiches restent refusees, elle finit la recolte sans descriptif plutot que de s'arreter (les offres remontent alors en revue manuelle) et le signale sur stderr.
Chaque offre inconnue est ouverte pour en lire le descriptif complet, ce qui permet au
scoring de trancher. lire_descriptions: false supprime ces requetes, au prix du tri
automatique. Les cartes de resultat, elles, ne portent aucun extrait de description :
la fiche est le seul moyen d'obtenir le texte de l'annonce.
Comme Google Jobs, c'est du scraping : aucune CGU n'a ete signee faute de compte, mais
le robots.txt de LinkedIn interdit explicitement /jobs-guest/, son bloc
User-agent: * interdit le site entier, et ses conditions proscrivent l'acces
automatise. Le risque pratique se limite a un blocage temporaire par adresse IP.
La source depend aussi de la mise en page de LinkedIn : uv run pytest -m reseau le
detecte, et les selecteurs a mettre a jour sont en tete de src/job2apply/linkedin.py.
LinkedIn ne publie pas d'email de contact : la candidature part de la page de l'offre.
Google Jobs (source d'appoint)
L'onglet « Emplois » de la recherche Google agrege les offres de la plupart des jobboards francais, sans compte ni cle d'API. Il n'existe en revanche aucune API pour l'interroger — la Cloud Talent Solution sert aux employeurs qui indexent leurs propres postes — donc la source lit la page de resultats avec un vrai navigateur, pilote par Playwright. Deux consequences a connaitre avant de l'activer :
- Une fenetre de navigateur s'ouvre. Google exige JavaScript depuis 2025, et
reconnait un navigateur headless : en mode invisible, il sert son CAPTCHA des la
premiere requete. Le profil navigateur est conserve dans
data/google-profile/pour ne repondre qu'une fois a la banniere de consentement. - C'est du scraping, contrairement aux autres sources. L'absence de compte veut dire
qu'aucune CGU n'a ete signee, mais le
robots.txtde Google interdit/searchet ses conditions d'utilisation proscrivent l'acces automatise aux resultats. Le risque pratique se limite a un blocage temporaire par CAPTCHA, d'ou l'activation explicite.
Elle est donc opt-in, dans config/profile.yaml :
google_jobs:
actif: true
max_offres: 30
headless: false
langue: "fr"
Installation : voir la section Installation plus haut (--with-executables-from playwright), ou uv sync --extra google-jobs dans le depot, puis playwright install chromium une fois. Sans Playwright, ou avec actif: false, job2apply fetch saute
simplement la source.
Elle reprend les cles mots_cles, villes, pays et publiee_depuis de la section
recherche. Chaque offre est ouverte pour en lire le descriptif complet, ce qui permet
au scoring de trancher comme sur les autres sources ; le champ via retient le
jobboard d'origine, et url_postulation pointe directement vers lui. Google ne decrit
pas le contrat en CDI / CDD mais en rythme de travail (« A plein temps »,
« Prestataire ») : le champ contrat reprend donc son libelle tel quel.
Cette source depend de la mise en page de Google, qui change sans preavis. Le test
uv run pytest -m reseau le detecte (voir « Tests »), et les selecteurs a mettre a
jour sont regroupes en tete de src/job2apply/google_jobs.py.
Pourquoi pas Indeed
Indeed n'a plus d'API de recherche d'offres accessible. L'API Publisher a ete fermee en 2023, et ce qui reste est reserve aux partenaires valides :
- Le connecteur MCP officiel (
https://mcp.indeed.com/claude/mcp) fonctionne dans l'application Claude, mais refuse Claude Code : le flux OAuth aboutit, puis le serveur repondinvalid_client/ « Client not allowed » car l'identite du CLI n'est pas sur sa liste blanche. - L'API GraphQL sous-jacente (
https://apis.indeed.com/graphql) se comporte pareil. N'importe qui peut y enregistrer un client OAuth et obtenir un jeton portant le scopejob_seeker.jobs.search, mais l'appel repond alors403 Client is not authorized.L'enregistrement et le consentement sont ouverts, l'acces aux donnees ne l'est pas.
Les revendeurs tiers qui exposent des offres Indeed en JSON sont des scrapers, dont on
ne se sert pas. Si Indeed rouvre un jour, la source se rebranche via job2apply ingest --source indeed sans toucher au reste du code.
Ajouter une source
job2apply ingest --source <nom> lit une liste d'offres JSON sur stdin et les injecte
dans le pipeline. Les champs sont tolerants aux alias courants (job_id/id,
title/titre, company_name/entreprise, apply_url/url...), seuls un
identifiant et un titre sont requis. C'est le point d'entree pour toute nouvelle source
— connecteur MCP, export CSV converti, saisie manuelle — sans toucher au reste du code.
Une source interrogee a chaque veille merite en revanche son propre module, sur le
modele de france_travail.py, adzuna.py, linkedin.py et google_jobs.py : une
fonction search() qui lit config/profile.yaml (et recoit des Credentials si la
source en demande), une fonction normalize() vers le format commun, et un branchement dans la commande
fetch de cli.py.
Usage
job2apply init [chemin] # cree un espace de travail
job2apply install-command # installe /veille-emploi dans Claude Code
job2apply fetch # veille complete, dans l'espace courant
job2apply --espace ~/autre-recherche fetch # veille d'un autre espace
Puis, dans Claude Code lance depuis l'espace : /veille-emploi.
Dans le depot sans installation globale, prefixer par uv run : uv run job2apply fetch.
Journal
Le tableau et les resumes affiches par job2apply ne changent pas ; en parallele, un
journal de diagnostic s'ecrit dans data/job2apply.log (rotation a 1 Mo, 3 archives) :
requetes envoyees a chaque source, limitations de debit de LinkedIn, tracebacks des
sources en echec. Les anomalies (niveau WARNING et au-dessus) sont aussi rappelees sur
la console.
job2apply --verbeux <commande> descend les deux canaux en DEBUG. La section logs de
config/profile.yaml permet un reglage plus fin (niveau du fichier, niveau de la
console, chemin du fichier, ou desactivation complete) ; voir les commentaires du
modele. --verbeux l'emporte toujours sur le profil.
Tests
uv run pytest # suite rapide, hors reseau
uv run pytest -m reseau # interroge vraiment LinkedIn (~30 s) et Google Jobs
# (ouvre une fenetre, ~15 s)
La suite par defaut ne touche a rien d'exterieur et couvre le tri, la deduplication et
la lecture de chaque source. Les tests marques reseau sont a relancer regulierement :
ils sont le seul filet contre un changement de mise en page chez LinkedIn ou Google, que
les tests de lecture pure ne peuvent pas voir. Leur echec n'implique pas toujours une
regression — Google peut opposer son CAPTCHA, LinkedIn limiter le debit — et les
messages d'assertion distinguent les deux cas.
Verification
uv run ruff format . # formatage
uv run ruff check . # lint
uv run ty check # types
uv run pytest # tests
Le lint reprend six familles de regles, declarees dans pyproject.toml : pycodestyle
(E), Pyflakes (F), pyupgrade (UP), flake8-bugbear (B), flake8-simplify (SIM) et isort
(I). Le formateur est celui de ruff, configure pour ecrire en LF afin de ne pas
reintroduire les CRLF que .gitattributes bannit. Le controle de types passe par ty,
encore jeune : ses diagnostics valent d'etre lus, pas forcement suivis a la lettre.
Desinstallation
uv tool uninstall job2apply # l'executable et son environnement
rm ~/.claude/commands/veille-emploi.md # la commande Claude Code
L'installation ne pose que ces deux elements. En mode editable (uv tool install -e),
elle ne contient aucune copie du code : le depot reste intact et uv run job2apply y
fonctionne toujours.
Les espaces de travail survivent volontairement — ce sont des donnees, pas du logiciel : profil, identifiants, memoire des offres vues et candidatures preparees. Les supprimer est une decision separee, dossier par dossier.
Release files for job2apply 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| job2apply-1.0.0.tar.gz | 87.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| job2apply-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 131.5 kB
Release files / job2apply-1.0.0.tar.gz
| Download URL | job2apply-1.0.0.tar.gz |
|---|---|
| Size | 87.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
0bcc0e85a20b66c8035f84fd416faca6690876770dda23326cc7280d2b633dcf
|
|
BLAKE2b-256 checksum How to use checksums |
7068955be311bd60ed05dcecf10b3712fc8e6b7da2c326fb1ebdab4dd97e4877
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.12.12
|
Release files / job2apply-1.0.0-py3-none-any.whl
| Download URL | job2apply-1.0.0-py3-none-any.whl |
|---|---|
| Size | 44.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
8bfd239ab6852ac3e7350c3b3318ee25ae1edf4df9a87ebde65eba477ee59ded
|
|
BLAKE2b-256 checksum How to use checksums |
59278d0162149da715a5aa37f3cc191cede7e301ef98d6f672ac446763163a30
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.12.12
|