J'ai sept annonces actives sur Leboncoin : dépannage, dev web, hébergement WordPress, retrogaming, ramassage de vieux PC. Toutes pertinentes pour ma zone, toutes invisibles passé le troisième jour. Sur Leboncoin, sans abonnement payant, le seul moyen de remonter en tête des résultats c'est supprimer puis republier chaque annonce. À la main. Une par une. Chaque semaine. Avec re-upload des photos.
Au bout de trois semaines à le faire le dimanche matin entre deux cafés, j'ai compris que je préférais coder une extension que je ne lancerais jamais qu'effectuer la corvée encore une fois.
Le repo : ohugonnot/leboncoin-bumper — Manifest V3, zéro dépendance, zéro serveur, MIT.
Pourquoi une extension Chrome et pas un script Node
Première option testée : un script Node avec Puppeteer qui se logge avec mon compte. J'ai abandonné en deux soirées. Trois raisons concrètes :
- DataDome. L'anti-bot de Leboncoin flag un navigateur headless en quelques requêtes. Un vrai navigateur, avec ma vraie session, mes vraies cookies, mon vrai user-agent — il laisse passer.
- L'authentification. Je me logge avec Google. Reproduire ce flow dans Puppeteer demande du JWT scraping qui casse à chaque rotation. Réutiliser la session du navigateur, c'est
chrome.scripting.executeScript, gratuit. - Le déploiement. Une extension chargée non empaquetée, ça démarre quand j'ouvre Chrome. Pas de serveur à maintenir. Pas de cron. Pas de docker. Le jour où je change de machine, je clone le repo et j'active le mode développeur.
Le verdict était évident après deux heures de prototypage : l'extension gagne sur tous les axes pour un usage strictement personnel.
Manifest V3 — le service worker qui meurt tous les 30 secondes
MV3 a un piège que personne ne raconte avant que tu te le prennes : le service worker est tué quand il est idle. Pas de daemon long-running. Pas de setInterval qui survit. Pour planifier, il faut chrome.alarms, qui réveille le worker au bon moment.
chrome.runtime.onInstalled.addListener(() => {
chrome.alarms.create('bump-weekly', {
when: nextBumpSlot(), // timestamp ms
periodInMinutes: 60 * 24 * 7
});
});
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name === 'bump-weekly') {
await runBumpCycle(); // ouvre un onglet, scrape, delete, repost
}
});
Conséquence pratique : aucun état en mémoire ne survit entre deux réveils. Tout passe par chrome.storage.local (les profils de veille, les annonces déjà vues, l'historique des cycles, le template de réponse). C'est l'équivalent IndexedDB pour les paresseux — tu écris du JSON, tu relis du JSON, c'est tout.
La leçon que j'ai mis trois jours à intégrer : ne jamais supposer que le service worker tourne. Toute logique synchrone doit pouvoir être reprise depuis le storage. C'est éprouvant les premières heures, et plus du tout après.
Le bumper — piloter un vrai onglet en arrière-plan
Le cycle : récupérer les annonces actives → en sélectionner une → la scraper en détail (titre, description, prix, localité, photos, préférences contact) → la supprimer → ouvrir le wizard de dépôt → remplir → re-uploader les photos → valider. Sept étapes par annonce, sur cinq pages différentes du back-office Leboncoin.
Tout repose sur deux primitives MV3 :
// 1. Ouvrir un onglet et y exécuter du code arbitraire
const tab = await chrome.tabs.create({ url: AD_LIST_URL, active: false });
const [{ result: ads }] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => {
return [...document.querySelectorAll('[data-test-id="ad-card"]')]
.map(card => ({
id: card.dataset.adId,
title: card.querySelector('h3').textContent.trim(),
status: card.dataset.status
}));
}
});
// 2. Naviguer et ré-injecter à chaque étape
await chrome.tabs.update(tab.id, { url: EDIT_URL(ad.id) });
await waitForLoad(tab.id);
await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: fillFormStep1, args: [ad] });
Les sélecteurs DOM sont la zone fragile. À chaque redesign Leboncoin, il faut les rebrancher. J'ai pris la convention de centraliser tous les sélecteurs dans un seul fichier selectors.js, avec un commentaire sur la date du dernier audit. Quand quelqu'un ouvre une issue parce que ça casse, je sais quoi grep.
Le détail qui m'a fait perdre une heure : l'upload des photos. Leboncoin attend un File object dans l'<input type="file">. Sauf qu'on ne peut pas créer un File à partir d'une URL d'image en JS pur — il faut fetch() l'URL CDN, récupérer le Blob, le wrapper en File, puis utiliser DataTransfer pour le poser dans l'input :
const blob = await fetch(photoUrl).then(r => r.blob());
const file = new File([blob], 'photo.jpg', { type: 'image/jpeg' });
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
input.dispatchEvent(new Event('change', { bubbles: true }));
Le coup de DataTransfer, je l'ai trouvé dans un Stack Overflow de 2019 avec 23 upvotes. Sans ça, l'input reste vide et Leboncoin refuse la publication. Le genre de truc qu'aucun LLM ne te suggère du premier coup parce que la doc officielle ne le couvre pas.
Prospect Watch — l'API privée que tout le monde fait semblant d'ignorer
La partie veille, c'est celle qui m'a fait écrire l'extension en vrai. Le bumper, c'est utile mais ennuyeux. Le prospect watch, c'est ce qui transforme l'outil en générateur d'opportunités.
Leboncoin a une API JSON privée derrière son front : POST /finder/search. C'est celle que le front appelle quand tu tapes une recherche. JSON propre, schéma stable, pagination par limit/page. Je l'appelle en réutilisant la session du navigateur, depuis un onglet Leboncoin actif pour ne pas déclencher DataDome :
async function searchAds(keywords, filters) {
const body = {
filters: {
keywords: { text: keywords.join(' ') },
category: { id: '8' }, // Services
location: { departments: filters.depts },
ranges: { price: { min: filters.priceMin, max: filters.priceMax } }
},
limit: 100,
listing_source: 'direct-search'
};
return chrome.scripting.executeScript({
target: { tabId: leboncoinTab.id },
func: async (b) => {
const r = await fetch('/finder/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(b),
credentials: 'include'
});
return r.json();
},
args: [body]
}).then(([{ result }]) => result.ads);
}
Une fois les annonces récupérées, le scoring. Trois règles simples qui marchent étonnamment bien :
+2 × poidssi le keyword match dans le titre+1 × poidssi le keyword match dans la description+1si on détecte une intention de demande : cherche, recherche, besoin, aide, conseil
Les poids viennent d'une syntaxe lisible dans les keywords : wordpress:3 prestashop symfony:2. WordPress vaut 3, PrestaShop vaut 1 par défaut, Symfony vaut 2. Au survol de l'étoile dans le popup, le breakdown du score s'affiche — « wordpress (titre, ×3) : +6 | demande détectée : +1 ». Pas de boîte noire. Quand un faux positif passe, je vois pourquoi en une seconde.
La détection de demande vs. offre se fait par regex sur les premiers 200 caractères. Imparfait mais cheap. « Cherche dev WordPress » matche. « Je propose mes services WordPress » ne matche pas. Sur 800 annonces scannées sur trois semaines, le taux de précision tourne autour de 90 %.
Le filtre anti-scam — neuf patterns qui couvrent 95 % des arnaques
Pas prévu au départ. Je l'ai ajouté après avoir reçu trois messages la même semaine avec exactement la même formulation Western Union. La messagerie Leboncoin est un aimant à scammers, et le filtre natif est très bas-niveau.
J'ai listé toutes les arnaques que j'avais reçues sur deux ans, j'en ai sorti neuf patterns regex :
| Pattern | Exemple détecté |
|---|---|
| Mandat-cash / Western Union | « je paie par mandat cash, donnez-moi votre nom complet » |
| QR code de paiement | « flashez ce QR pour libérer le paiement » |
| Faux transporteur | « mon livreur passera demain, prévoyez 35 € de frais » |
| Hors plateforme | « contactez-moi sur WhatsApp +33 6... » |
| Numéro étranger | +44, +234, +1 dans le corps |
| Lien externe court | bit.ly, tinyurl, t.co |
| PayPal Friends & Family | « envoyez en option amis et famille » |
| Code SMS demandé | « je vous envoie un code de confirmation, transmettez-le » |
| Urgence + déplacement | « je suis en voyage, urgent, mon mari/femme passera » |
Chaque message reçu est classé : 🚨 Scam · 💬 Lead · ❓ Question · 🗑 Spam. La boîte de réception affiche les vrais leads en premier et planque les scams sous un filtre. Trois semaines après l'avoir activé, je n'ai plus jamais lu un message Western Union — alors qu'il en arrive deux par semaine.
Tests Node natifs — zéro dépendance, 120 ms, 35 tests
Une extension qui fait du DOM scraping, ça paraît intestable. Et c'est vrai pour la couche de scraping : chrome.scripting.executeScript ne se mock pas. Mais toute la logique utile est en JS pur, séparée du chrome.* — et celle-là, je la teste avec le test runner Node natif, sans Jest, sans Vitest, sans Mocha.
// tests/scoring.test.js
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { scoreAd, parseKeywords } from '../lib/scoring.js';
test('keyword in title beats keyword in description', () => {
const ad = { title: 'cherche dev wordpress', description: '...' };
const kw = parseKeywords(['wordpress']);
assert.equal(scoreAd(ad, kw).total, 3); // 2 (titre) + 1 (cherche)
});
test('weighted keyword multiplies score', () => {
const kw = parseKeywords(['wordpress:3']);
const ad = { title: 'wordpress pro', description: '' };
assert.equal(scoreAd(ad, kw).total, 6); // 2 × 3
});
npm test tourne en 120 ms. 35 tests qui couvrent les regex de keyword (mots accentués, C++, .NET, parenthèses), le scoring v2, le parsing des poids, le tri d'affichage, les post-filtres, la sérialisation des profils.
Pas de framework. Pas de transpileur. Pas de config. node --test tests/. C'est le confort que je voulais d'un side-project : si demain je reprends le repo, je ne dois rien réinstaller pour lancer les tests. git pull && npm test, point.
Pourquoi ce n'est pas sur le Chrome Web Store
Les CGU de Leboncoin interdisent explicitement l'automatisation (article 8 : « usage de robot, script, ou tout dispositif permettant d'accéder au site de manière automatisée »). Soumettre une extension publique qui fait exactement ça, c'est demander à se faire rejeter en review, puis à se faire signaler par Leboncoin avec le risque de fermeture définitive du compte développeur.
Donc : installation manuelle, repo public sur GitHub, MIT, à tes risques et périls. C'est explicite dans le README et c'est une condition à laquelle je tiens — un dev qui clone et installe sait ce qu'il fait. Un utilisateur lambda qui télécharge depuis le Web Store, non. La barrière à l'entrée fait partie de la responsabilisation.
Côté risque pour mon compte : trois mois d'usage quotidien, zéro flag. Je reste dans des fréquences humaines (un bump par jour max, un scan toutes les heures), j'utilise un vrai onglet, je ne descends pas sous des seuils suspects. DataDome semble plus regarder le pattern que le fait d'automatiser — si tu te comportes comme un humain pressé plutôt que comme un bot, ça passe.
Conclusion
Le truc surprenant en relisant mon repo après trois mois, c'est ce que je n'ai pas écrit. Pas de framework. Pas de TypeScript. Pas de build step. Pas de bundler. Vanilla JS, vanilla CSS, MV3, tests Node natifs. Le tout tient en ~2 000 lignes de code. Quand je dois ajouter une feature, je relis le fichier concerné en une minute et je code dedans.
La vraie productivité d'un side-project, c'est l'absence d'outillage. Chaque dépendance que j'aurais ajoutée le premier jour serait devenue une corvée de maintenance la semaine d'après. Là, l'extension tourne, je l'oublie, elle me réveille quand un prospect WordPress poste dans le Doubs un mardi à 14 h. Le ROI, pour un outil que j'ai construit en weekend, c'est un client par mois en moyenne. Largement payé.