Développer un ATS maison en Python : trier 4000+ CVs en quelques secondes
En 2023, Royal Broker publie une offre d'emploi pour des conseillers commerciaux. En 48h : 4 200 candidatures dans la boîte mail.
L'équipe RH avait littéralement abandonné. Trier ça manuellement aurait pris des semaines.
Ma réponse : un ATS (Applicant Tracking System) maison en Python. Voici comment ça marche.
Le problème concret
Le processus de recrutement chez Royal Broker ressemblait à ça :
- Candidat envoie CV par email à
recrutement@royalbroker.ca - Une personne RH ouvre chaque email, télécharge le PDF, le lit
- Elle décide : Entretien / Rejet / Liste d'attente
Avec 200 CVs par jour, c'est impossible à tenir. Et les bons profils passaient à travers les mailles.
L'architecture du système
Le système est composé de 4 modules indépendants :
Boîte Email (IMAP)
↓
[Module 1] Extraction des CVs (PDF → Texte)
↓
[Module 2] Analyse & Scoring NLP
↓
[Module 3] Classification & Ranking
↓
[Module 4] Dashboard + Actions automatiques
Module 1 : Extraction des CVs
import imaplib
import email
import pdfplumber
from pathlib import Path
def fetch_cvs_from_email(host, user, password, folder="INBOX"):
"""Récupère tous les CVs PDF depuis la boîte mail."""
mail = imaplib.IMAP4_SSL(host)
mail.login(user, password)
mail.select(folder)
_, message_ids = mail.search(None, 'UNSEEN')
cvs = []
for msg_id in message_ids[0].split():
_, msg_data = mail.fetch(msg_id, '(RFC822)')
msg = email.message_from_bytes(msg_data[0][1])
for part in msg.walk():
if part.get_content_type() == 'application/pdf':
pdf_bytes = part.get_payload(decode=True)
text = extract_text_from_pdf(pdf_bytes)
cvs.append({
'sender': msg['From'],
'subject': msg['Subject'],
'text': text,
'raw_pdf': pdf_bytes,
})
return cvs
def extract_text_from_pdf(pdf_bytes: bytes) -> str:
"""Extrait le texte d'un PDF en bytes."""
import io
with pdfplumber.open(io.BytesIO(pdf_bytes)) as pdf:
return "\n".join(
page.extract_text() or ""
for page in pdf.pages
)Pourquoi pdfplumber plutôt que PyPDF2 ? Meilleure gestion des tableaux et colonnes, fréquents dans les CVs modernes.
Module 2 : Scoring NLP
Le coeur du système. J'ai opté pour une approche TF-IDF + correspondance de critères plutôt que des LLMs — plus rapide, plus prévisible, et gratuit.
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
import spacy
import re
nlp = spacy.load("fr_core_news_sm")
CRITERIA = {
"experience_years": {
"pattern": r"(\d+)\s*(an|ans|année|années)\s*(d'expérience|experience)",
"weight": 3.0,
},
"hard_skills": {
"keywords": ["excel", "crm", "salesforce", "communication", "vente", "négociation"],
"weight": 2.0,
},
"education": {
"keywords": ["bac", "baccalauréat", "dec", "diplôme", "université", "college"],
"weight": 1.5,
},
"languages": {
"keywords": ["français", "anglais", "bilingue", "bilingual"],
"weight": 1.0,
},
}
def score_cv(cv_text: str, job_description: str) -> dict:
"""Calcule un score global pour un CV par rapport à l'offre d'emploi."""
cv_text_lower = cv_text.lower()
scores = {}
total_weight = 0
# Score par critère
for criterion, config in CRITERIA.items():
weight = config["weight"]
total_weight += weight
if "pattern" in config:
match = re.search(config["pattern"], cv_text_lower)
if match:
years = int(match.group(1))
scores[criterion] = min(years / 5, 1.0) * weight # cap à 5 ans = 100%
else:
scores[criterion] = 0
elif "keywords" in config:
found = sum(1 for kw in config["keywords"] if kw in cv_text_lower)
scores[criterion] = (found / len(config["keywords"])) * weight
# Score de similarité sémantique avec la description du poste
vectorizer = TfidfVectorizer(stop_words=None)
try:
tfidf_matrix = vectorizer.fit_transform([cv_text, job_description])
similarity = cosine_similarity(tfidf_matrix[0:1], tfidf_matrix[1:2])[0][0]
scores["semantic_similarity"] = similarity * 2.0 # poids 2x
total_weight += 2.0
except Exception:
scores["semantic_similarity"] = 0
# Score final normalisé sur 100
raw_score = sum(scores.values())
final_score = (raw_score / total_weight) * 100
return {
"total": round(final_score, 1),
"breakdown": scores,
}Module 3 : Classification automatique
THRESHOLDS = {
"entretien": 65, # Score > 65 → Entretien
"liste_attente": 45, # Score 45-65 → Liste d'attente
"rejet": 0, # Score < 45 → Rejet automatique
}
def classify_candidate(score: float) -> str:
if score >= THRESHOLDS["entretien"]:
return "entretien"
elif score >= THRESHOLDS["liste_attente"]:
return "liste_attente"
return "rejet"
def process_batch(cvs: list, job_description: str) -> list:
"""Traite un batch de CVs et retourne les résultats triés."""
results = []
for cv in cvs:
score_data = score_cv(cv["text"], job_description)
results.append({
**cv,
"score": score_data["total"],
"breakdown": score_data["breakdown"],
"classification": classify_candidate(score_data["total"]),
})
return sorted(results, key=lambda x: x["score"], reverse=True)Module 4 : Actions automatiques
Pour les rejets clairs (score < 30), envoi automatique d'un email de rejet poli :
import smtplib
from email.mime.text import MIMEText
def send_rejection_email(to_email: str, candidate_name: str):
template = f"""
Bonjour {candidate_name},
Nous vous remercions de l'intérêt que vous portez à Royal Broker Solutions
et du temps consacré à votre candidature.
Après étude attentive de votre dossier, nous n'avons pas retenu
votre candidature pour ce poste.
Nous vous souhaitons bonne chance dans vos recherches.
L'équipe RH Royal Broker
"""
msg = MIMEText(template)
msg["Subject"] = "Votre candidature chez Royal Broker"
msg["From"] = "recrutement@royalbroker.ca"
msg["To"] = to_email
with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
server.login(SMTP_USER, SMTP_PASS)
server.send_message(msg)Les résultats
Sur les 4 200 CVs reçus :
| Classification | Nombre | % | |---|---|---| | Entretien (score > 65) | 187 | 4.5% | | Liste d'attente (45-65) | 630 | 15% | | Rejet auto (< 45) | 3 383 | 80.5% | | Total traité | 4 200 | 100% |
Temps de traitement : ~8 minutes pour les 4 200 CVs sur un laptop standard.
L'équipe RH a ensuite eu à traiter seulement 817 dossiers au lieu de 4 200 — soit 5x moins de travail.
Validation de la précision
J'ai validé le système sur un échantillon de 200 CVs évalués manuellement au préalable. Résultats :
- Précision sur "entretien" : 88% (peu de faux positifs)
- Rappel sur "entretien" : 82% (quelques bons profils dans "liste d'attente")
- Erreurs critiques (bon profil dans "rejet") : < 3%
Le seuil de 3% d'erreurs critiques était acceptable pour ce contexte (volume très élevé, poste commercial junior).
Ce que j'aurais fait différemment
1. Gestion des CVs en formats variés
Les CVs modernes viennent en PDF, Word, parfois même en images. J'ai géré le PDF, mais des python-docx + pytesseract (OCR) auraient évité quelques rejets injustes.
2. Feedback loop
Je n'ai pas implémenté de mécanisme pour que les RH signalent les erreurs du système, ce qui aurait permis d'améliorer les seuils dans le temps.
3. Biais potentiels
Le système TF-IDF favorise les CVs avec beaucoup de texte et les candidats qui "parlent le même langage" que la description de poste. Un candidat atypique (reconversion, parcours non-linéaire) peut être pénalisé injustement.
Conclusion
Un ATS maison n'est pas aussi complexe qu'on le croit. Avec Python et quelques bibliothèques open source, vous pouvez construire quelque chose de fonctionnel en moins d'une semaine.
La vraie valeur n'est pas dans la sophistication technique, mais dans la fiabilité et la transparence du système — l'équipe RH doit pouvoir comprendre pourquoi un candidat a été classé d'une certaine façon.
Le code complet est disponible dans le projet InstaHR — la partie ATS est le composant RecruitmentEngine.
Une question sur l'implémentation ? Contactez-moi directement, je suis ouvert à en discuter.