Claudilon : une IA qui répond à mes commentaires LinkedIn en temps réel

J'ai un bot qui répond à mes commentaires LinkedIn. En 30 secondes. Avec un ton pro, en français, et en commençant par "Claudilon :" pour que tout le monde sache que c'est une IA. Pas un chatbot générique — un truc branché sur Claude qui comprend le contexte de mes articles techniques. Voici comment c'est construit.

Le projet s'appelle Claudilon — contraction de Claude et Odilon. L'idée de base : je publie des articles techniques sur LinkedIn. Les commentaires arrivent, parfois la nuit, parfois un dimanche. Je veux qu'ils reçoivent une réponse rapide, contextuelle, pas un "merci pour votre commentaire !" généré par un script de 2018. Et je veux que le lecteur sache qu'il parle à une IA. Transparence totale.

Le problème — LinkedIn verrouille ses propres données

Avant de coder quoi que ce soit, j'ai vérifié l'API LinkedIn. Résultat : c'est une embuscade.

L'API LinkedIn permet de poster des commentaires avec le scope w_member_social. Ce scope est disponible pour les applications tierces sans processus de validation particulier. Jusqu'ici, ça ressemble à une vraie API.

Le problème : lire les commentaires sur son propre profil nécessite r_member_social. Ce scope est restreint aux partenaires approuvés — Hootsuite, Buffer, les gros outils de gestion de réseaux sociaux. Pour un développeur indépendant, la demande d'accès prend des semaines et n'aboutit jamais.

Quant aux webhooks : ils existent, mais uniquement pour les pages entreprise, pas pour les profils personnels. Tu peux recevoir une notification en temps réel si ta page LinkedIn reçoit un commentaire. Ton profil perso, lui, reste muet.

Résultat : l'API officielle te permet de parler mais pas d'écouter. Il faut contourner.

La solution — Playwright + Claude CLI

L'architecture se décompose en trois couches indépendantes :

  1. Playwright headless : se connecte à LinkedIn, navigue sur l'URL du post, et scrape les commentaires du DOM.
  2. Claude CLI (claude --print) : reçoit le contexte du post et du commentaire, génère une réponse en 5 secondes.
  3. API LinkedIn : poste la réponse comme commentaire via POST /ugcPosts/{postId}/comments.

Le tout tient en moins de 100 lignes de Node.js utiles. Un service systemd tourne en daemon et relance le script toutes les 30 secondes. Schéma global :

Daemon systemd (30s) → Script Node.js
  ├── Mode hybride :
  │   ├── Page notifications LinkedIn (filtre "Mes posts") → nouveaux commentaires sur TOUS les posts
  │   └── WATCHED_POSTS → scrape direct des posts configurés
  ├── Nouveau commentaire ?
  │   ├── Non → exit
  │   └── Oui → Reconstruit contexte (thread parent + article de blog)
  │             └── claude --print "contexte + commentaire" (~5s)
  │                 └── POST API LinkedIn (réponse en thread, ~2s)
  └── Total: < 15 secondes par nouveau commentaire

Un point important sur les coûts : claude --print utilise Claude Code installé en local, branché sur un abonnement flat. Pas d'appel API au token. Coût supplémentaire : 0€.

Mode hybride — notifications + posts watchés

Scraper chaque post individuellement à chaque cycle, c'est N page loads pour N posts surveillés. Pas scalable, et LinkedIn repère plus facilement le pattern. La solution : une seule page de notifications.

LinkedIn a une page de notifications filtrable sur "Mes posts" — elle agrège en une seule vue tous les nouveaux commentaires sur l'ensemble de tes publications. Le bot charge cette page une fois par cycle, extrait les nouveaux commentaires, et ne revient sur le post individuel que si nécessaire pour récupérer le contexte du thread.

En parallèle, WATCHED_POSTS permet de déclarer des posts importants à scraper directement — utile pour les posts récents ou ceux qui reçoivent du volume et dont on veut s'assurer de ne rien rater. Le merge des deux sources déduplique sur l'URN du commentaire.

const WATCHED_POSTS = [
    { urn: 'urn:li:share:7301234567890123456', slug: 'goroutine-leaks-golang' },
    { urn: 'urn:li:share:7309876543210987654', slug: 'cqrs-go-postgresql-event-store' },
];

// Cycle principal
async function runCycle() {
    // 1 page load pour tous les posts
    const fromNotifs = await scrapeNotifications(page);

    // Scrape direct des posts prioritaires
    const fromWatched = [];
    for (const post of WATCHED_POSTS) {
        const comments = await scrapePostComments(page, post.urn);
        fromWatched.push(...comments.map(c => ({ ...c, slug: post.slug })));
    }

    // Merge + dédup sur l'URN du commentaire
    const all = mergeByUrn(fromNotifs, fromWatched);
    return all.filter(c => !seenComments.has(c.urn));
}

Le scraping — contourner l'API verrouillée

Playwright ouvre un navigateur Chromium headless, charge les cookies de session LinkedIn sauvegardés lors de la première connexion manuelle, et navigue directement sur l'URL du post.

const { chromium } = require('playwright');
const path = require('path');
const fs = require('fs');

async function scrapeComments(postUrl) {
    const browser = await chromium.launch({ headless: true });
    const context = await browser.newContext({
        storageState: path.resolve(__dirname, 'linkedin-cookies.json'),
    });

    const page = await context.newPage();
    await page.goto(postUrl, { waitUntil: 'networkidle' });

    // Charger tous les commentaires si le post est populaire
    let loadMore = true;
    while (loadMore) {
        const btn = await page.$('button[aria-label*="Load more comments"]');
        if (btn) {
            await btn.click();
            await page.waitForTimeout(1500);
        } else {
            loadMore = false;
        }
    }

    const comments = await page.evaluate(() => {
        const items = document.querySelectorAll('article[class*="comments-comment"]');
        return Array.from(items).map(item => {
            const authorEl = item.querySelector('[class*="comments-post-meta__name"]');
            const textEl = item.querySelector('[class*="comments-comment__main-content"]');
            return {
                author: authorEl ? authorEl.innerText.trim() : '',
                text: textEl ? textEl.innerText.trim() : '',
            };
        });
    });

    await browser.close();
    return comments;
}

Les cookies sont sauvegardés avec context.storageState({ path: 'linkedin-cookies.json' }) lors de la première session manuelle. Ça évite de passer par la 2FA à chaque exécution du cron. La session LinkedIn reste valide plusieurs semaines.

Le sélecteur CSS article[class*="comments-comment"] cible les articles de commentaires indépendamment des noms de classes générés dynamiquement par LinkedIn. Ce pattern est plus robuste qu'un sélecteur exact — LinkedIn change ses class names régulièrement, mais la structure sémantique du DOM bouge moins souvent.

Réponses en thread — pas en commentaire de niveau 1

Répondre au niveau 1 sous un post quand quelqu'un a commenté dans un thread existant, c'est hors-sujet. L'API LinkedIn permet de répondre directement dans le thread via le champ parentComment.

Le problème : l'URN retourné par le scraping Playwright a le format urn:li:comment:(activity:X,Y), mais l'API LinkedIn attend urn:li:comment:(urn:li:activity:X,Y). Une transformation est nécessaire avant de passer l'URN à l'API.

// URN scraped : urn:li:comment:(activity:7301234567890123456,7309876543210987654)
// URN attendu : urn:li:comment:(urn:li:activity:7301234567890123456,7309876543210987654)

function normalizeCommentUrn(urn) {
    return urn.replace(
        /urn:li:comment:\(activity:(\d+),(\d+)\)/,
        'urn:li:comment:(urn:li:activity:$1,$2)'
    );
}

async function postThreadedReply(commentUrn, activityUrn, replyText) {
    const body = {
        actor: `urn:li:person:${LINKEDIN_PERSON_ID}`,
        message: { text: replyText },
        parentComment: normalizeCommentUrn(commentUrn),  // Répond dans le thread
        object: activityUrn,
    };

    const res = await fetch(
        `https://api.linkedin.com/v2/socialActions/${encodeURIComponent(activityUrn)}/comments`,
        {
            method: 'POST',
            headers: {
                'Authorization': `Bearer ${LINKEDIN_ACCESS_TOKEN}`,
                'Content-Type': 'application/json',
                'X-Restli-Protocol-Version': '2.0.0',
            },
            body: JSON.stringify(body),
        }
    );

    if (!res.ok) throw new Error(`LinkedIn API error: ${res.status}`);
}

Contexte de thread et contexte d'article

Quand quelqu'un répond dans un thread, la réponse n'a de sens que si Claude connaît ce qui a été dit au-dessus. Le bot remonte la chaîne : commentaire parent, grand-parent si disponible, et passe cette conversation reconstituée dans le prompt.

En parallèle, si le post est dans WATCHED_POSTS avec un slug, le bot charge le contenu de l'article de blog associé — les H2 et les premiers paragraphes de chaque section. Claude répond alors avec la connaissance précise du sujet traité, pas juste du contexte générique "développeur Go".

async function buildPromptContext(comment, post) {
    const parts = [];

    // Contexte de l'article de blog (si slug fourni)
    if (post.slug) {
        const articleContent = await loadArticleContext(post.slug);
        if (articleContent) {
            parts.push(`Contenu de l'article de blog associé :\n${articleContent}`);
        }
    }

    // Contexte du thread (parent + grand-parent)
    if (comment.parentUrn) {
        const parent = await fetchComment(comment.parentUrn);
        parts.push(`Commentaire parent de ${parent.author} :\n${parent.text}`);

        if (parent.parentUrn) {
            const grandParent = await fetchComment(parent.parentUrn);
            parts.push(`Contexte plus haut dans le thread (${grandParent.author}) :\n${grandParent.text}`);
        }
    }

    return parts.join('\n\n');
}

async function loadArticleContext(slug) {
    // Charge le fichier PHP de l'article, extrait H2 + premiers paragraphes
    const filePath = path.join(__dirname, '..', 'blog', 'posts', `${slug}.php`);
    if (!fs.existsSync(filePath)) return null;

    const content = fs.readFileSync(filePath, 'utf-8');
    // Extrait le texte brut des balises h2 et p (regex simple, suffisant pour du PHP statique)
    const sections = [...content.matchAll(/<h2>(.+?)<\/h2>|<p>(.+?)<\/p>/gs)]
        .slice(0, 15)
        .map(m => m[1] || m[2])
        .join('\n');
    return sections;
}

La réponse IA — Claude CLI en one-shot

claude --print est le mode non-interactif de Claude Code. Tu passes un prompt, tu récupères la réponse sur stdout, le processus se termine. Aucun état persistant, aucune session à gérer.

const { execFileSync } = require('child_process');

function generateReply(postText, commentAuthor, commentText, threadContext) {
    const prompt = `
Tu es Claudilon, un assistant IA qui répond aux commentaires LinkedIn au nom d'Odilon Hugonnot,
développeur full-stack spécialisé en Go, PHP et architecture backend.

Contexte du post LinkedIn :
${postText}

${threadContext ? `Contexte du thread :\n${threadContext}\n` : ''}
Commentaire de ${commentAuthor} :
${commentText}

Rédige une réponse en respectant ces règles strictement :
- Commence TOUJOURS par "🤖 Claudilon :"
- 2 à 3 phrases maximum
- Ton professionnel mais décontracté, pas de formules creuses
- En français si le commentaire est en français, en anglais sinon
- Ne promets pas de faire des choses qu'Odilon ne peut pas faire
- Si c'est une question technique précise, donne une vraie réponse

Réponds uniquement avec le texte du commentaire, sans guillemets, sans introduction.
`.trim();

    const result = execFileSync('claude', ['--print', prompt], {
        encoding: 'utf-8',
        timeout: 30000,
    });

    return result.trim();
}

Le prompt système est volontairement minimaliste. Claude Code comprend le contexte technique sans avoir besoin d'un roman d'instructions. Le paramètre clé est timeout: 30000 — Claude CLI met en général 3 à 8 secondes, mais un timeout de 30 secondes évite qu'un délai réseau bloque le daemon indéfiniment.

Exemple de sortie pour un commentaire "Super article sur les goroutines, tu as une ressource pour aller plus loin ?" :

🤖 Claudilon : Pour aller plus loin sur les goroutines, je recommande "Concurrency in Go" de Katherine Cox-Buday — c'est la référence. Le blog officiel Go a aussi quelques articles solides sur le scheduler. Bonne lecture !

Garde-fous anti-spam et préfixe robot

Sans protection, le bot lirait ses propres réponses comme de nouveaux commentaires et entrerait dans une boucle infinie. Sans rate limiting, il pourrait poster 50 réponses en rafale si un post explose soudainement. Les deux mécanismes sont critiques.

Premier filtre : tout commentaire commençant par "🤖 Claudilon :" ou "Claudilon :" (legacy) est ignoré. Le bot ne répond jamais à ses propres messages, même aux anciens postés avant l'ajout de l'emoji.

function isOwnReply(text) {
    return text.startsWith('🤖 Claudilon :') || text.startsWith('Claudilon :');
}

// Rate limiting : 3/cycle, 15/heure, 50/jour
const rateLimits = {
    perCycle: 3,
    perHour:  15,
    perDay:   50,
};

function checkRateLimit(counters) {
    const now = Date.now();

    // Reset heure/jour si la fenêtre est passée
    if (now - counters.hourStart > 3600_000) {
        counters.hour = 0;
        counters.hourStart = now;
    }
    if (now - counters.dayStart > 86_400_000) {
        counters.day = 0;
        counters.dayStart = now;
    }

    if (counters.cycle >= rateLimits.perCycle) return false;
    if (counters.hour  >= rateLimits.perHour)  return false;
    if (counters.day   >= rateLimits.perDay)   return false;

    return true;
}

function isAlreadySeen(comment, seenComments) {
    const id = `${comment.author}::${comment.text.slice(0, 60)}`;
    return seenComments.has(id);
}

function markAsSeen(comment, seenComments, seenPath) {
    const id = `${comment.author}::${comment.text.slice(0, 60)}`;
    seenComments.add(id);
    fs.writeFileSync(seenPath, JSON.stringify([...seenComments]));
}

Deuxième filtre : un fichier JSON persiste les identifiants des commentaires déjà traités entre les redémarrages du daemon. L'identifiant est construit à partir de l'auteur et des 60 premiers caractères du texte — suffisant pour éviter les doublons, résistant aux modifications mineures.

Au démarrage, le script charge ce JSON. À chaque nouveau commentaire traité, il l'écrit immédiatement — si le service redémarre entre deux cycles, l'état est préservé.

Service systemd — daemon persistant

Un script lancé manuellement s'arrête quand le terminal se ferme. Pour que le bot tourne en continu, il faut un daemon. systemd user mode est la solution la plus propre sur Linux : restart auto, logs centralisés, pas besoin de root.

# ~/.config/systemd/user/claudilon.service
[Unit]
Description=Claudilon LinkedIn auto-reply bot
After=network-online.target

[Service]
Type=simple
WorkingDirectory=/home/odilon/work/cv
ExecStart=/usr/bin/node scripts/linkedin-auto-reply.js --loop
Restart=on-failure
RestartSec=10s
StandardOutput=journal
StandardError=journal

# Shutdown propre : SIGTERM → attendre 15s → SIGKILL
KillMode=mixed
TimeoutStopSec=15

[Install]
WantedBy=default.target
# Activer et démarrer
systemctl --user enable claudilon
systemctl --user start claudilon

# Logs en temps réel
journalctl --user -u claudilon -f

# Vérifier le statut
systemctl --user status claudilon

Le mode --loop du script fait tourner le cycle en boucle avec un setTimeout(runCycle, 30_000). La gestion du SIGTERM est explicite : le handler attend que le cycle en cours se termine proprement avant d'exit — pas d'interruption en pleine requête API LinkedIn.

process.on('SIGTERM', async () => {
    console.log('SIGTERM reçu — attente fin du cycle en cours...');
    running = false;  // Stoppe la prochaine itération
    await currentCyclePromise;  // Attend la fin du cycle actuel
    process.exit(0);
});

Le résultat en production

Premier test en conditions réelles. Je publie un article, j'attends. Un commentaire arrive : "Bonjour claudilon, est ce que tu arrives a voir ce message". Quelqu'un avait visiblement lu la description du bot dans mon post.

13 secondes plus tard, la réponse est postée dans le thread :

🤖 Claudilon : Oui, message bien reçu ! Le mode notifications fonctionne — ton commentaire est arrivé dans le flux en 8 secondes. Si tu as une vraie question sur l'architecture ou le code, je suis là.

Le daemon systemd prend le relais dès le boot. Les cookies sont persistants entre les redémarrages — la session LinkedIn reste valide plusieurs semaines. Les compteurs de rate limiting sont en mémoire mais se réinitialisent au redémarrage, ce qui est acceptable.

Les limites honnêtes

Ce projet fonctionne mais reste fragile sur plusieurs points. Les nommer clairement est plus utile que de les minimiser.

Le scraping peut casser. LinkedIn change régulièrement ses class names et parfois sa structure DOM. Le sélecteur article[class*="comments-comment"] est plus robuste qu'un sélecteur exact, mais un refactor majeur du rendu LinkedIn casserait le scraper. Il faudrait maintenir le sélecteur manuellement.

La 2FA est manuelle. La première connexion requiert une validation humaine. Si les cookies expirent ou si LinkedIn détecte un comportement suspect et force une reconnexion, il faut intervenir manuellement. Ce n'est pas automatisable de façon fiable.

LinkedIn peut bloquer. Le scraping automatisé est techniquement contre les conditions d'utilisation de LinkedIn. Un compte scripté de façon agressive peut se faire bannir. Ici, le bot fait une requête toutes les 30 secondes sur une seule URL — un rythme raisonnable — mais le risque zéro n'existe pas.

Le contexte de thread reste partiel. Le bot remonte jusqu'au grand-parent, mais une conversation longue en plusieurs échanges lui échappe encore. Remonter toute la chaîne alourdirait le prompt — le compromis actuel est acceptable pour des threads réels de 2-3 niveaux.

Pas de filtre de pertinence. Le bot répond à tous les nouveaux commentaires sans exception. Un spam, un commentaire agressif, une publicité — il répondrait quand même. Un filtre de modération serait à ajouter pour un usage à grande échelle.

Conclusion

Le plus intéressant dans ce projet, c'est pas le bot lui-même — c'est ce que l'exercice révèle sur LinkedIn. Un développeur qui veut automatiser ses propres interactions est obligé de contourner l'API officielle. Lire ses propres commentaires est une fonctionnalité réservée aux partenaires payants. Le scraping est la seule porte d'entrée réaliste pour un usage indépendant.

Sur le plan technique, claude --print en mode one-shot est étonnamment efficace pour ce cas d'usage. Pas d'état, pas de session à maintenir, une latence prévisible. Le résultat est contextuel sans être générique — parce que le prompt contient le texte réel du post, pas juste un topic abstrait.

Claudilon tourne depuis quelques jours. Si vous commentez sous mes posts LinkedIn, vous verrez la réponse. Testez-le — l'expérience est plus intéressante que la description.

Pour aller plus loin : automatiser la publication de son blog vers dev.to et LinkedIn pour la couche amont, et construire une landing page avec Claude Code pour voir comment Claude CLI s'intègre dans un workflow de développement.

Commentaires (0)