Aller au contenu

Détecter les signaux d'expansion d'entreprises avec WTTJ, LinkedIn et n8n

Le problème que j'essayais de résoudre n'était pas technique au départ. C'était un problème de timing.

En B2B, le moment où tu contactes une entreprise change presque tout. La même entreprise, le même profil, le même discours : si tu arrives au bon moment, c'est une conversation. Sinon, c'est une relance ignorée.

Les bases de données classiques décrivent bien les entreprises. Elles ne disent presque rien de leur moment.

C'est ce que ce projet a cherché à corriger : construire un pipeline capable de détecter automatiquement qu'une entreprise est probablement en phase d'expansion, à partir de sources publiques.

Le problème de départ

Une équipe commerciale a généralement accès à des données précises sur ses cibles : taille, secteur, stack, chiffre d'affaires estimé. Ce profil dit ce qu'une entreprise est. Il ne dit pas ce qu'elle fait en ce moment.

Or les signaux d'expansion sont publics. Éparpillés, bruités, difficiles à agréger, mais publics.

Une entreprise qui ouvre 15 postes en six semaines, publie des posts LinkedIn sur une expansion géographique et annonce un nouveau partenariat sur son blog n'est pas dans le même état qu'une entreprise apparemment similaire mais immobile depuis 18 mois.

Le vrai sujet

La question n'était pas "comment scraper des données". C'était "comment transformer des données publiques désordonnées en signal exploitable commercialement, sans produire principalement des faux positifs".

Pourquoi les outils existants ne convenaient pas

Problème Ce que ça produit
Données statiques Peu de contexte sur le moment
Signaux génériques Volume élevé, ratio signal/bruit mauvais
Opacité du scoring Impossible de comprendre le raisonnement
Coût à l'échelle Difficile d'expérimenter sans budget conséquent

La plupart des outils de sales intelligence ne montrent jamais le raisonnement derrière le signal. Je voulais voir la source, le contenu, la logique, la temporalité. Presque comme un notebook d'investigation.

Sources utilisées

Le pipeline repose sur trois types de sources complémentaires :

Ce que ça révèle : les recrutements actifs, les types de contrats, les localisations, le volume d'offres ouvertes.

L'approche : pas de scraping HTML. WTTJ expose son index Algolia dans le frontend : une requête POST structurée suffit.

Ce que ça ne dit pas seul : si les postes sont réels ou s'ils traînent depuis six mois republics automatiquement.

Ce que ça révèle : les posts d'entreprise, les annonces de lancement, les prises de parole sur la croissance, les recrutements en vitrine.

L'approche : via Apify pour éviter de maintenir un scraper maison fragile. Même logique que dans l'article sur la veille concurrentielle multi-source.

Ce que ça ne dit pas : la majorité des posts sont du contenu marque employeur générique. Le ratio signal/bruit est le plus élevé des trois sources.

Ce que ça révèle : les levées de fonds, les partenariats, les lancements produit, les ouvertures de marchés, les annonces intentionnelles.

L'approche : crawl HTTP des chemins /blog, /actualites, /news de chaque entreprise, même logique heuristique que dans le projet Scrapy de détection de technologies.

Ce que ça ne dit pas : certains blogs sont abandonnés depuis deux ans. Le crawler détecte l'absence de contenu récent et passe.

Architecture globale

flowchart TD
    A[(Supabase\nentreprises)] --> B[n8n Scheduler]
    B --> C{Loop Over Items}

    C --> D[HTTP Algolia\nWTTJ]
    C --> E[Apify\nLinkedIn]
    C --> F[HTTP\nPages actualités]

    D --> G[Code\nParse Jobs]
    E --> H[Code\nParse Posts]
    F --> I[Code\nParse Contenu]

    G --> J[Assemblage\ncontexte]
    H --> J
    I --> J

    J --> K[AI Agent\nOpenRouter]
    K --> L[Parsing\nJSON]
    L --> M[(Supabase\nsignaux)]

Redis intervient en amont de la boucle : un sémaphore borne le nombre d'exécutions parallèles pour éviter de saturer les APIs externes sur une grande liste d'entreprises.

La vraie découverte : WTTJ utilise Algolia

Au départ, je partais sur un scraping HTML classique de Welcome to the Jungle. Puis j'ai ouvert l'onglet réseau des DevTools.

WTTJ charge ses offres via des requêtes Algolia, le moteur de recherche qu'ils utilisent en interne. Et leur clé de recherche est présente dans le frontend, accessible à n'importe qui qui inspecte les requêtes réseau.

Ce n'est pas un exploit. C'est le comportement normal d'Algolia : les Search-Only API Keys sont conçues pour être exposées côté client. Elles permettent de lire des données, pas de les modifier. WTTJ en a parfaitement conscience.

Ça change complètement l'approche : au lieu de parser du HTML fragile, on fait une requête POST structurée à leur index.

POST https://<APP_ID>-dsn.algolia.net/1/indexes/wttj_jobs_production_fr/query
X-Algolia-Application-Id: <APP_ID>
X-Algolia-API-Key: <search-only key visible dans les DevTools WTTJ>
Content-Type: application/json
{
  "query": "",
  "hitsPerPage": 20,
  "filters": "organization.slug:doctolib"
}

La réponse est propre, structurée, sans ambiguïté. Pas de sélecteur CSS à maintenir, pas de rendu JavaScript à gérer.

Le slug WTTJ

Le champ organization.slug n'est pas toujours identique au domaine ou au nom de l'entreprise. Il faut le stocker explicitement dans la base. Dans ce pipeline, j'ai ajouté un champ wttj_slug_computed dans la table companies de Supabase, renseigné manuellement ou via une étape d'enrichissement préalable.

C'est un point de friction réel : sur les premières exécutions, environ 15% des entreprises n'avaient pas de slug renseigné. L'API retournait 0 hits, techniquement correct mais silencieux.

Parsing des offres

Après la requête Algolia, un nœud Code extrait les champs utiles depuis les hits :

const resp   = $input.first().json;
const domain = ($('Loop Over Items').item.json.domain || '').trim();
const slug   = ($('Loop Over Items').item.json.wttj_slug_computed || '').trim();

const hits   = Array.isArray(resp?.hits) ? resp.hits : [];
const nbJobs = resp?.nbHits ?? hits.length;

const jobs = hits.slice(0, 20).map(j => ({
  title:        j.name          || null,
  contract:     j.contract_type || null,
  remote:       j.remote        || null,
  location:     j.offices?.[0]?.city || null,
  published_at: j.published_at  || null,
  url: j.organization?.slug && j.slug
    ? `https://www.welcometothejungle.com/fr/companies/${j.organization.slug}/jobs/${j.slug}`
    : null,
}));

return [{ json: { domain, wttj_slug: slug, total_jobs: nbJobs, jobs } }];

Ce que ça produit pour une entreprise active :

{
  "domain": "example.fr",
  "total_jobs": 23,
  "jobs": [
    {
      "title": "Senior Backend Engineer",
      "contract": "CDI",
      "remote": "hybrid",
      "location": "Paris",
      "published_at": "2026-05-12T08:00:00Z",
      "url": "https://www.welcometothejungle.com/fr/companies/example/jobs/senior-backend-engineer"
    }
  ]
}

Le prompt d'analyse

Une fois les offres parsées et assemblées avec les autres sources, un Agent IA analyse le contexte.

Le choix du persona dans le system prompt est intentionnel. "Analyse ces offres d'emploi" produit des résumés bien écrits, inutilisables dans un pipeline. "Tu es un account manager B2B senior qui cherche des opportunités d'upsell" contraint le modèle vers ce qu'on veut réellement.

Tu es un account manager B2B senior spécialisé en expansion de revenu (upsell et cross-sell).
Ton rôle : analyser les offres d'emploi d'un client sur Welcome to the Jungle pour identifier
des opportunités commerciales.
Chaque insight doit être basé sur un signal précis (rôle recruté, volume, type de contrat).
Tu identifies uniquement des opportunités exploitables, pas des résumés.
Tu réponds uniquement en JSON valide, sans markdown, sans texte hors JSON.

La contrainte JSON strict est indispensable. Sans elle, le modèle intercale du texte entre les blocs de code, et le parsing en aval casse.

Le modèle : openai/gpt-4o-mini via OpenRouter. Bon rapport coût/qualité pour une tâche de classification et d'extraction structurée à volume.

Redis : borner la concurrence

Avec une base d'entreprises de taille raisonnable, n8n lance naturellement plusieurs branches en parallèle dans la boucle. Sans contrôle, ça devient rapidement des dizaines de requêtes simultanées vers Algolia, Apify et les APIs externes.

J'utilise Redis comme sémaphore simple : avant d'entrer dans le traitement, chaque itération acquiert un slot via INCR sur une clé de compteur. Si la limite est atteinte, elle attend. Après traitement, DECR.

Pas de lib externe, pas de Celery, pas de queue complète. Un nœud Redis natif dans n8n suffit pour ça.

Ce qui a cassé

Les annonces republiques

Le problème le plus fréquent sur WTTJ : une entreprise reposte la même offre toutes les quelques semaines. published_at se renouvelle, le total monte, mais rien de particulier ne se passe réellement.

La correction : hash sur (title, contract, location) par entreprise. Si le même triplet existe déjà dans Supabase avec une date récente, on ignore. Le total_jobs brut reste suivi séparément pour détecter les vraies variations de volume dans le temps.

Le slug manquant silencieux

Un retour Algolia à 0 hits peut signifier deux choses très différentes : l'entreprise n'a effectivement pas de poste ouvert, ou le slug est manquant dans la base. Traités pareil au départ, les deux cas masquaient des trous de couverture.

Ajout d'un log explicite dans le Code Parse pour distinguer les deux situations. Simple, mais pas fait au départ.

Les prompts trop ouverts

Première version du system prompt : "identifie les signaux d'expansion". Résultat : du texte bien rédigé, sans structure, impossible à parser en aval. Le modèle extrapole et généralise quand on ne le contraint pas.

Le JSON strict + la taxonomie explicite des outputs attendus a tout changé.

Le piège classique

Le modèle ne "comprend" pas l'entreprise. Il complète des patterns. Si le prompt est vague, la sortie sera plausible mais pas fiable. Contraindre le format de sortie et le persona n'est pas un détail : c'est la variable d'ajustement principale.

Ce que j'ai appris

  • Inspecter le réseau avant de scraper. WTTJ aurait demandé plusieurs jours de développement en parsing HTML. Algolia l'a réduit à moins d'une heure. Ouvrir les DevTools en premier est devenu un réflexe systématique.
  • Redis pour le throttling, pas pour le cache. Utilisation très ciblée : borner la concurrence, rien de plus. Ne pas sur-engineerer.
  • Le hash anti-doublon est indispensable. Sans lui, les annonces republiques polluent les signaux indéfiniment. À implémenter dès le départ, pas en rattrapage.
  • Logger "0 résultats" différemment des erreurs. Un retour vide peut être correct ou symptomatique. Les traiter pareil masque des bugs.
  • Un signal seul ne veut presque rien dire. 20 offres WTTJ peut signifier une croissance forte ou une rotation massive. C'est la combinaison avec les autres sources qui produit du contexte.
  • Le total_jobs brut est un leurre. La variation dans le temps est plus utile que le volume instantané. Ce qu'il faudrait, c'est un historique sur 4 semaines glissantes.
  • Le persona dans le prompt change la qualité de sortie. "Analyse" produit des résumés. "Tu cherches des opportunités upsell concrètes" produit des actions.
  • Stocker les slugs dès le départ. Tout enrichissement non fait en amont coûte cher à rattraper sur une grande base.
  • Modulariser par source. Chaque source (WTTJ, LinkedIn, pages actualités) dans un sous-workflow indépendant, testable sans relancer l'ensemble.
  • Prévoir les signaux contradictoires. Une entreprise peut recruter fortement tout en perdant des clients. Les agréger sans les croiser produit du bruit.

Limites actuelles

Sujet État
wttj_slug À enrichir manuellement ou via étape dédiée
Déduplication Hash basique — ne couvre pas les reformulations de titre
LinkedIn Dépendance Apify — fragile si l'acteur change
Historique de tendance Pas encore implémenté sur le volume WTTJ
Scoring Encore empirique, pas de benchmark terrain
Index Algolia Configuré sur wttj_jobs_production_fr uniquement

Conclusion

Le projet a commencé comme un système de scraping. Ce qu'il est devenu, c'est surtout un système de lecture des organisations.

La découverte Algolia illustre bien l'approche générale : avant d'écrire du code, regarder ce qui existe déjà. La meilleure solution technique est souvent celle qui n'invente rien.

Le scraping en lui-même était la partie la plus simple. La difficulté réelle était ailleurs : distinguer un signal réel d'une annonce qui traîne, éviter les faux positifs sur les entreprises qui communiquent sans bouger, rendre la sortie exploitable par quelqu'un qui ne lit pas de données brutes.

Les signaux intéressants sont rarement spectaculaires. Ce sont souvent des répétitions, des changements de rythme, des corrélations faibles et des mouvements progressifs.

Ce qu'il reste à construire : la pondération temporelle des signaux, un score de tendance basé sur l'évolution du volume WTTJ sur 4 semaines glissantes, et la corrélation (vrai test) avec les résultats commerciaux réels.

Ce que je veux explorer ensuite

  • Score de tendance basé sur l'évolution du total_jobs sur 4 semaines
  • Détection de cycles : rythmes de recrutement, variations de communication
  • Corrélation avec les taux de conversion commerciaux : est-ce que les entreprises détectées "en expansion" convertissent mieux ?
  • Pondération temporelle : un signal vieux de 3 mois ne pèse pas autant qu'une offre publiée hier