Pipeline IA de recherche d'emploi — Conversation de conception¶
Export de conversation Claude.ai — à reprendre avec Claude Code pour l'implémentation. Contexte : développeur fullstack TypeScript/React/Node.js/Prisma/DDD/Inversify.
1. Proposition initiale (architecture globale)¶
Vision¶
Transformer ce flux manuel :
CV → Recherche manuelle → Lecture des annonces → Adaptation du CV → Rédaction de lettre → Candidature
En ce flux assisté par IA :
CV maître → Agent IA → Collecte automatique des offres → Scoring → CV ciblé → Lettre ciblée → Validation → Envoi → Suivi
Architecture fonctionnelle¶
┌──────────────────────────┐
│ Frontend React │
└────────────┬─────────────┘
▼
┌──────────────────────────┐
│ API Backend │
│ Node + Express │
└────────────┬─────────────┘
▼
┌──────────────────────────┐
│ Orchestrateur │
│ d'agents │
└────────────┬─────────────┘
┌───────────┼───────────┬─────────────┬─────────────┐
▼ ▼ ▼ ▼
Agent Agent Agent Agent
Recherche Scoring CV Candidature
▼
Base PostgreSQL
Stack technique proposée¶
Frontend : React, TypeScript, Tailwind, React Query, React Hook Form, Zod
Backend : Node.js, Express, TypeScript, Prisma, PostgreSQL, Inversify
IA : - Raisonnement : OpenAI API ou Anthropic API - Embeddings : OpenAI Embeddings (comparaison CV/offres) - Recherche sémantique : PostgreSQL + pgvector (pas besoin de base vectorielle séparée au départ)
Agents envisagés (version initiale)¶
- Agent Collecteur d'offres — sources : LinkedIn Jobs, Indeed, Welcome to the Jungle, Apec, sites carrières
- Agent Analyseur d'offres — description brute → JSON structuré (
poste,seniorite,competences[],softskills[],langues[]) - Agent Scoring — CV vs offre →
{ score, forces[], manques[] } - Moteur de personnalisation — CV maître → CV ciblés (React, Node, Architecte, DDD, Consultant)
- Générateur de lettre — CV + offre → lettre personnalisée
- Agent de candidature (2 niveaux) :
- Niveau 1 : pré-remplissage + validation humaine
- Niveau 2 : agent navigateur (Browser Use, OpenHands, Playwright)
- Agent de suivi — table
Applicationavec statuts (À analyser, Validée, Envoyée, Entretien, Refusée, Offre reçue, Acceptée)
Modèle Prisma initial proposé¶
model Application {
id String @id
company String
title String
status String
appliedAt DateTime
followUpDate DateTime?
notes String?
}
Tables principales envisagées¶
Candidate
ResumeMaster
ResumeVersion
JobOffer
JobAnalysis
MatchingScore
CoverLetter
Application
Interview
Company
Évolution en phases (vision originale, 6 phases)¶
- Recherche, Scoring, CV personnalisé
- Lettres automatiques
- Remplissage automatique
- Suivi des réponses
- Relances automatiques
- Préparation aux entretiens
2. Analyse critique et recommandations¶
Points forts de la proposition initiale¶
- Séparation en agents spécialisés cohérente avec une approche DDD/Clean Architecture
- pgvector plutôt qu'une base vectorielle dédiée : bon choix pour un projet solo, pas de nouvelle techno à apprendre
- Modèle
Applicationavec statuts etfollowUpDate: essentiel pour le suivi, souvent négligé
Points problématiques identifiés¶
-
Le scraping LinkedIn/Indeed est le risque principal. LinkedIn bannit agressivement les comptes qui scrapent ou automatisent (même via Browser Use / Playwright), et ses ToS l'interdisent explicitement. Indeed et Welcome to the Jungle ont des protections similaires (rate limiting, détection de bot, captchas). C'est le composant le plus susceptible de casser, et le plus souvent.
-
L'auto-remplissage de candidature (Agent niveau 2) est risqué pour le compte personnel. Un agent navigateur qui soumet des candidatures sur des plateformes qui détectent l'automatisation peut faire flaguer ou bannir le profil du candidat — effet inverse de l'objectif recherché.
-
Le scope initial est trop large pour un démarrage. 6 phases + 10 tables + 4 agents + navigateur automatisé = plusieurs semaines avant la première candidature envoyée. Pour un usage personnel, il vaut mieux démarrer beaucoup plus petit.
MVP réaliste recommandé¶
Phase 1 (1-2 semaines, faisable solo) :
- CV maître structuré en JSON (Prisma)
- Agent de scoring : offre collée manuellement → score + CV ciblé généré
- Agent de lettre : génère la lettre à partir du CV ciblé + offre
- Validation et envoi manuels
Phase 2 (si Phase 1 prouve sa valeur) :
- Ajout d'une source de collecte stable (RSS, API officielle, ou sites carrières directs)
- Automatisation du tri quotidien
L'humain reste dans la boucle pour la collecte et l'envoi (où le risque légal/technique est concentré) ; l'IA fait ce qu'elle fait bien : analyser, scorer, rédiger.
3. Stratégie de collecte alternative — RSS / sites carrières directs¶
Pourquoi c'est plus stable que le scraping des gros agrégateurs¶
Les gros agrégateurs (LinkedIn, Indeed) ont une équipe anti-bot dédiée parce que leur modèle économique dépend du contrôle de l'accès aux données. Un site carrière d'entreprise individuelle n'a généralement aucune protection — l'entreprise veut au contraire être vue.
Sources concrètes à exploiter¶
1. Flux RSS / APIs natives des ATS (Applicant Tracking Systems)
- Greenhouse : https://boards.greenhouse.io/[company].json ou /embed/job_board?for=[company]
- Lever : https://api.lever.co/v0/postings/[company]?mode=json
- Workable : flux JSON public similaire
- SmartRecruiters : API publique également
→ Identifier l'ATS utilisé par une entreprise via l'URL de sa page carrière (ex : jobs.lever.co/nomEntreprise, boards.greenhouse.io/nomEntreprise).
2. APIs officielles - Adzuna : API gratuite (avec quota), agrège plusieurs pays, couverture Afrique de l'Ouest à vérifier - Jobicy, Remotive, Arbeitnow : APIs gratuites orientées remote/tech, JSON propre - France Travail (ex-Pôle Emploi) : API officielle gratuite et bien documentée, utile si ciblage du marché français
3. Sites carrières d'entreprises ciblées Liste de 30-50 entreprises (Cotonou, Bénin, Afrique de l'Ouest, ou remote) → scraping direct via Playwright/Cheerio. Plus de configuration initiale (un scraper par structure de site) mais casse rarement, contrairement à un scraper LinkedIn qui peut être bloqué du jour au lendemain.
Architecture des connecteurs¶
Agent Collecteur
├── Connecteur ATS (Greenhouse, Lever, Workable) — JSON direct, pas de scraping
├── Connecteur API (Adzuna, Remotive, France Travail) — JSON direct
└── Connecteur custom (Cheerio/Playwright) — par entreprise ciblée, liste maintenue à la main
Interface commune (DDD-friendly) :
interface JobSourceConnector {
fetchOffers(): Promise<RawJobOffer[]>;
}
→ Ajout de sources une par une sans toucher au reste du pipeline.
Limite à anticiper¶
Volume d'offres plus faible que LinkedIn en brut, mais fiabilité largement supérieure pour un usage personnel. Compensation possible par élargissement progressif de la liste d'entreprises ciblées et des APIs, plutôt que dépendance à une source fragile.
4. Workflow général intégré (avec couche collecte)¶
Vue d'ensemble du flux¶
┌─────────────────────────────────────────────────────────┐
│ COUCHE COLLECTE │
│ │
│ Connecteur ATS Connecteur API Connecteur │
│ (Greenhouse, (Adzuna, Custom │
│ Lever, Workable) Remotive, (Cheerio/ │
│ France Travail) Playwright) │
│ │ │ │ │
│ └────────────────────┴──────────────────┘ │
│ ▼ │
│ RawJobOffer[] (format unifié) │
└────────────────────────┬──────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ COUCHE NORMALISATION │
│ │
│ - Déduplication (hash titre+entreprise+url) │
│ - Nettoyage HTML/texte │
│ - Détection langue │
│ - Mapping vers JobOffer (entité domaine) │
└────────────────────────┬──────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ AGENT ANALYSEUR (IA) │
│ │
│ JobOffer (brut) → JobAnalysis (structuré) │
│ { poste, séniorité, compétences[], langues[], type } │
└────────────────────────┬──────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ AGENT SCORING (IA) │
│ │
│ JobAnalysis VS ResumeMaster → MatchingScore │
│ { score, forces[], manques[] } │
└────────────────────────┬──────────────────────────────────┘
▼
score >= seuil (ex: 75) ?
│ │
NON OUI
│ ▼
Archivé ┌─────────────────────────┐
(consultable │ GÉNÉRATION CIBLÉE │
manuellement) │ - ResumeVersion (IA) │
│ - CoverLetter (IA) │
└────────────┬─────────────┘
▼
┌─────────────────────────┐
│ VALIDATION HUMAINE │
│ (notification + UI) │
└────────────┬─────────────┘
▼
┌─────────────────────────┐
│ Application créée │
│ status: "À envoyer" │
└─────────────────────────┘
1. Couche Collecte — interface unifiée¶
interface RawJobOffer {
sourceId: string; // ex: "greenhouse", "lever", "custom:acme-corp"
externalId: string; // id chez la source, pour dédup
title: string;
company: string;
description: string; // HTML ou markdown brut
location: string | null;
url: string;
publishedAt: Date | null;
raw: unknown; // payload brut conservé pour debug
}
interface JobSourceConnector {
readonly sourceId: string;
fetchOffers(): Promise<RawJobOffer[]>;
}
Orchestrateur de collecte :
class CollectOffersUseCase {
constructor(private connectors: JobSourceConnector[]) {}
async execute(): Promise<RawJobOffer[]> {
const results = await Promise.allSettled(
this.connectors.map(c => c.fetchOffers())
);
// Logger les échecs sans bloquer les autres connecteurs
return results
.filter(r => r.status === "fulfilled")
.flatMap(r => (r as PromiseFulfilledResult<RawJobOffer[]>).value);
}
}
Point important :
Promise.allSettledplutôt quePromise.all— un connecteur qui plante un jour ne doit jamais bloquer la collecte des autres sources.
2. Couche Normalisation¶
class NormalizeOfferService {
dedupe(offers: RawJobOffer[], existing: JobOffer[]): RawJobOffer[] {
const seen = new Set(existing.map(o => this.hashKey(o)));
return offers.filter(o => !seen.has(this.hashKey(o)));
}
private hashKey(o: RawJobOffer | JobOffer): string {
return `${o.company.toLowerCase()}::${o.title.toLowerCase()}`;
}
toDomain(raw: RawJobOffer): JobOffer {
return JobOffer.create({
title: raw.title,
company: raw.company,
descriptionHtml: sanitizeHtml(raw.description),
sourceId: raw.sourceId,
externalId: raw.externalId,
url: raw.url,
});
}
}
3. Orchestration quotidienne complète¶
class DailyJobPipeline {
constructor(
private collectUseCase: CollectOffersUseCase,
private normalizeService: NormalizeOfferService,
private analyzeAgent: JobAnalyzerAgent,
private scoringAgent: ScoringAgent,
private resumeGenerator: ResumeGeneratorAgent,
private letterGenerator: CoverLetterAgent,
private notifier: NotificationService,
private jobOfferRepo: JobOfferRepository,
) {}
async run(): Promise<DailyReport> {
// 1. Collecte
const rawOffers = await this.collectUseCase.execute();
// 2. Normalisation + dédup
const existing = await this.jobOfferRepo.findRecent();
const newOffers = this.normalizeService.dedupe(rawOffers, existing);
const domainOffers = newOffers.map(o => this.normalizeService.toDomain(o));
await this.jobOfferRepo.saveMany(domainOffers);
// 3. Analyse + scoring (en parallèle, limité pour éviter rate-limit IA)
const analyzed = await this.analyzeAgent.analyzeAll(domainOffers);
const scored = await this.scoringAgent.scoreAll(analyzed);
// 4. Filtre par seuil
const matches = scored.filter(s => s.score >= 75);
// 5. Génération ciblée (uniquement pour les bons matchs)
const candidatures = await Promise.all(
matches.map(async m => ({
offer: m.offer,
score: m.score,
resume: await this.resumeGenerator.generate(m.offer, m.analysis),
letter: await this.letterGenerator.generate(m.offer, m.analysis),
}))
);
// 6. Notification récapitulative
await this.notifier.sendDailySummary({
totalCollected: domainOffers.length,
totalMatches: matches.length,
candidatures,
});
return { totalCollected: domainOffers.length, matches: candidatures.length };
}
}
4. Configuration des connecteurs (extensible)¶
// config/connectors.ts
export const connectors: JobSourceConnector[] = [
new GreenhouseConnector(["acme", "startupx", "techco"]),
new LeverConnector(["companyA", "companyB"]),
new RemotiveConnector({ category: "software-dev" }),
new AdzunaConnector({ country: "fr", keywords: ["react", "typescript"] }),
new CustomScraperConnector("entreprise-locale-cotonou", scraperConfigA),
];
→ Ajouter une nouvelle entreprise ou source = une ligne de config, zéro modification du pipeline existant.
Cadence et déclenchement¶
Cron quotidien (ex: 6h du matin, via VPS + Caddy/Docker existant)
↓
DailyJobPipeline.run()
↓
Notification (email ou webhook vers une UI React)
↓
Consultation des candidatures pré-générées
↓
Validation manuelle → envoi manuel (Phase 1)
Avantages de ce découpage¶
- Résilience : un connecteur cassé (ex : une entreprise change son ATS) n'affecte que cette source, pas le pipeline
- Évolutivité : démarrage avec 2-3 connecteurs (Remotive + Greenhouse de 5 boîtes ciblées), élargissement progressif
- Testabilité : chaque connecteur et chaque agent IA testable isolément, cohérent avec l'approche DDD habituelle
- Coût IA maîtrisé : analyse/scoring sur toutes les offres (peu coûteux, petit modèle), génération CV/lettre (plus coûteuse) uniquement sur les matchs filtrés
5. Prochaines étapes possibles (non encore traitées)¶
À discuter/coder avec Claude Code :
- [ ] Schéma Prisma complet (
JobOffer,JobAnalysis,MatchingScore,ResumeVersion,CoverLetter,Application) avec relations - [ ] Implémentation d'un connecteur réel (Greenhouse ou Remotive) pour valider le format de données
- [ ] Structure DDD/Clean Architecture du module (entités, repositories, use cases, services applicatifs)
- [ ] Agent d'analyse et agent de scoring (prompts + intégration Anthropic API)
- [ ] Génération de CV ciblé et lettre de motivation (prompts + format de sortie)
- [ ] Mise en place du cron sur le VPS Hetzner existant (Docker + Caddy)