Automatiser la publication de son blog vers dev.to et LinkedIn : le code complet

La friction de republier manuellement sur chaque plateforme finit par tuer la régularité. Écrire l'article, le poster, copier-coller sur dev.to, reformater, poster sur LinkedIn, chercher l'image — à un moment on arrête, parce que c'est chiant. Voici le système complet : 6 scripts Node.js, zéro dépendance externe hormis turndown, une commande pour tout publier.

Prérequis

Node.js 18+ (ES modules natifs, fetch built-in). Un blog dont les articles sont dans des fichiers .en.php avec une <div class="article-content">. Un posts.json avec cette structure par article :

{
  "slug": "mon-article",
  "date": "2026-03-21",
  "fr": {
    "title": "Titre FR",
    "category": "Golang",
    "tags": ["tag1", "tag2"],
    "excerpt": "Description courte."
  },
  "en": {
    "title": "EN title",
    "category": "Golang",
    "tags": ["tag1", "tag2"],
    "excerpt": "Short description."
  }
}

Installer la seule dépendance :

npm install turndown

Et dans package.json, s'assurer d'avoir "type": "module" pour les imports ES.

Structure des fichiers

scripts/
├── devto-helpers.js        # Extraction Markdown + utilitaires
├── devto-draft-all.js      # Drafter tous les articles pending sur dev.to
├── devto-publish-next.js   # Publier le prochain draft (avec cadence)
├── devto-cron.sh           # Shell script pour le cron
├── linkedin-auth.js        # OAuth flow LinkedIn (à faire une fois)
├── linkedin-publish.js     # Publier sur LinkedIn
├── publish-article.js      # Script unifié : tout en une commande
├── devto-schedule.json     # État et queue dev.to
├── linkedin-schedule.json  # État et queue LinkedIn
├── .devto-env              # DEVTO_API_KEY (gitignore)
└── .linkedin-env           # Credentials LinkedIn (gitignore)

Ajouter au .gitignore :

scripts/.devto-env
scripts/.linkedin-env

Part 1 — Dev.to

Obtenir l'API key

Aller dans dev.to/settings/extensions, section "DEV API Keys", générer une clé.

Créer scripts/.devto-env :

export DEVTO_API_KEY=votre_cle_ici

devto-helpers.js — extraction HTML → Markdown

Le contenu des articles est du HTML — c'est ce que génère le blog PHP. Dev.to ingère du Markdown. La bibliothèque turndown fait la conversion, mais le comportement par défaut produit deux problèmes qu'il faut corriger avec des règles custom.

Règle 1 — fenced-code-blocks : Turndown convertit les blocs <pre><code> en blocs indentés par 4 espaces (convention Markdown classique). Sur dev.to, ça fonctionne, mais la classe CSS language-go portée par la balise <code> serait silencieusement perdue — plus de coloration syntaxique. La règle custom lit node.className, strip le préfixe language-, et génère des fenced code blocks avec le langage : ```go.

Règle 2 — absolute-links : les liens internes du blog sont relatifs : /blog/mon-article. Sur dev.to, ces liens sont cassés — ils pointent vers dev.to/blog/mon-article. La règle intercepte tous les <a>, détecte si le href commence par /, et le préfixe avec SITE_URL.

La fonction devtoTags normalise les tags pour dev.to : la plateforme accepte maximum 4 tags, en minuscules, alphanumériques uniquement. Les tags avec tirets ou caractères spéciaux (php-fpm, vue.js) seraient refusés — on les strip.

import { readFileSync } from 'fs';
import { resolve } from 'path';
import TurndownService from 'turndown';

const SITE_URL = 'https://votre-site.com';

function makeTurndown() {
    const td = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced', fence: '```' });

    td.addRule('fenced-code-blocks', {
        filter: node => node.nodeName === 'CODE' && node.parentNode.nodeName === 'PRE',
        replacement: (content, node) => {
            const lang = (node.className || '').replace('language-', '').trim();
            return `\n\`\`\`${lang}\n${node.textContent}\n\`\`\`\n`;
        },
    });

    td.addRule('absolute-links', {
        filter: 'a',
        replacement: (content, node) => {
            let href = node.getAttribute('href') || '';
            if (href.startsWith('/')) href = SITE_URL + href;
            const title = node.title ? ` "${node.title}"` : '';
            return `[${content}](${href}${title})`;
        },
    });

    return td;
}

export function extractMarkdown(root, slug) {
    const phpFile = resolve(root, `blog/posts/${slug}.en.php`);
    const phpContent = readFileSync(phpFile, 'utf8');
    const match = phpContent.match(/<div class="article-content">([\s\S]*?)<\/div>\s*\n*\s*<\/article>/);
    if (!match) throw new Error(`Could not extract content from ${slug}.en.php`);
    const td = makeTurndown();
    return td.turndown(match[1]).replace(/\n{3,}/g, '\n\n').trim();
}

export function devtoTags(meta) {
    return (meta.tags || [])
        .map(t => t.toLowerCase().replace(/[^a-z0-9]/g, ''))
        .filter(Boolean)
        .slice(0, 4);
}

devto-schedule.json — le fichier de queue

L'idée centrale du système : séparer le moment où on drafe un article du moment où on le publie. Ça permet de préparer 10 articles en avance — tous en draft sur dev.to, invisibles — et de les sortir au compte-gouttes selon une cadence définie.

Le flux complet d'un article est : pendingdrafted (créé en draft via devto-draft-all.js) → published (publié via devto-publish-next.js appelé par le cron). Cette séparation en deux phases est délibérée : drafter se fait manuellement depuis WSL (offline, avec accès aux fichiers locaux), publier se fait automatiquement par le cron.

{
  "cadence_days": 4,
  "articles": [
    { "slug": "mon-premier-article", "status": "pending" }
  ]
}

Les statuts possibles : pendingdraftedpublished (ou skipped).

devto-draft-all.js — créer les drafts

Ce script ne publie rien. Il prend tous les articles en statut pending et crée un draft sur dev.to pour chacun — published: false. La publication viendra plus tard, volontairement et séparément. L'objectif : ne jamais publier accidentellement un article pas fini, et garder la main sur le calendrier.

Le champ canonical_url dans le payload est critique pour le SEO. Il indique à Google que la source originale de l'article est votre blog, pas dev.to. Sans lui, Google peut traiter dev.to comme la source principale et considérer votre blog comme du contenu dupliqué — ce qui pénalise votre référencement au profit de la plateforme.

#!/usr/bin/env node
import { readFileSync, writeFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { extractMarkdown, devtoTags } from './devto-helpers.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');
const SCHEDULE_FILE = resolve(__dirname, 'devto-schedule.json');

const API_KEY = process.env.DEVTO_API_KEY;
if (!API_KEY) { console.error('Missing DEVTO_API_KEY'); process.exit(1); }

const schedule = JSON.parse(readFileSync(SCHEDULE_FILE, 'utf8'));
const posts = JSON.parse(readFileSync(resolve(ROOT, 'blog/posts.json'), 'utf8'));

const pending = schedule.articles.filter(a => a.status === 'pending');
console.log(`Found ${pending.length} pending articles to draft.\n`);

let ok = 0, fail = 0;

for (const item of pending) {
    const post = posts.find(p => p.slug === item.slug);
    if (!post?.en) {
        console.log(`  SKIP  ${item.slug} (no EN version)`);
        item.status = 'skipped'; fail++; continue;
    }

    let markdown;
    try { markdown = extractMarkdown(ROOT, item.slug); }
    catch (e) {
        console.log(`  SKIP  ${item.slug} (${e.message})`);
        item.status = 'skipped'; fail++; continue;
    }

    const payload = {
        article: {
            title: post.en.title,
            published: false,
            body_markdown: markdown,
            tags: devtoTags(post.en),
            canonical_url: `${SITE_URL}/en/blog/${item.slug}`,
            description: post.en.excerpt,
        },
    };

    const res = await fetch('https://dev.to/api/articles', {
        method: 'POST',
        headers: { 'api-key': API_KEY, 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
    });
    const result = await res.json();

    if (!res.ok) {
        console.log(`  FAIL  ${item.slug} — ${result.error || JSON.stringify(result)}`);
        fail++; continue;
    }

    item.status = 'drafted';
    item.drafted_at = new Date().toISOString();
    item.devto_id = result.id;
    item.devto_url = result.url;
    writeFileSync(SCHEDULE_FILE, JSON.stringify(schedule, null, 2));
    console.log(`  OK    ${item.slug}`);
    ok++;

    await new Promise(r => setTimeout(r, 800)); // éviter le rate limiting
}

console.log(`\nDone: ${ok} drafted, ${fail} skipped/failed.`);

Usage : . scripts/.devto-env && node scripts/devto-draft-all.js

devto-publish-next.js — publier avec cadence

Ce script publie un article — au plus un par appel. La mécanique de cadence est simple : on cherche la date de la dernière publication dans le schedule, on calcule le delta en jours, on compare à cadence_days. Si c'est trop tôt, on sort proprement sans rien faire. Sinon, on prend le premier article en statut drafted et on le publie.

Le flag --force bypass le calcul de cadence. Utile pour les urgences : un article important qui ne peut pas attendre, ou une correction critique à pousser immédiatement.

Pourquoi un PUT sur l'id existant plutôt qu'un nouveau POST ? L'article est déjà sur dev.to en état draft — il a un id stocké dans le schedule. Il suffit d'envoyer published: true sur cet id. Re-POSTer créerait un doublon.

#!/usr/bin/env node
import { readFileSync, writeFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const SCHEDULE_FILE = resolve(__dirname, 'devto-schedule.json');

const force = process.argv.includes('--force');
const API_KEY = process.env.DEVTO_API_KEY;
if (!API_KEY) { console.error('Missing DEVTO_API_KEY'); process.exit(1); }

const schedule = JSON.parse(readFileSync(SCHEDULE_FILE, 'utf8'));

if (!force) {
    const lastPublished = schedule.articles
        .filter(a => a.published_at)
        .map(a => new Date(a.published_at))
        .sort((a, b) => b - a)[0];

    if (lastPublished) {
        const daysSince = (Date.now() - lastPublished) / (1000 * 60 * 60 * 24);
        if (daysSince < schedule.cadence_days) {
            const wait = Math.ceil(schedule.cadence_days - daysSince);
            const next = schedule.articles.find(a => a.status === 'drafted');
            console.log(`Next publish in ${wait} day(s).`);
            if (next) console.log(`   Queued: "${next.slug}"`);
            console.log('   Use --force to publish now.');
            process.exit(0);
        }
    }
}

const next = schedule.articles.find(a => a.status === 'drafted');
if (!next) { console.log('All articles published.'); process.exit(0); }

const res = await fetch(`https://dev.to/api/articles/${next.devto_id}`, {
    method: 'PUT',
    headers: { 'api-key': API_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({ article: { published: true } }),
});

const result = await res.json();
if (!res.ok) { console.error('Dev.to API error:', JSON.stringify(result)); process.exit(1); }

next.status = 'published';
next.published_at = new Date().toISOString();
writeFileSync(SCHEDULE_FILE, JSON.stringify(schedule, null, 2));

const remaining = schedule.articles.filter(a => a.status === 'drafted').length;
console.log(`Publié : ${next.devto_url}`);
console.log(`  ${remaining} drafts restants.`);

Le cron

Créer scripts/devto-cron.sh :

#!/bin/bash
cd /home/user/work/mon-blog
. scripts/.devto-env
/usr/bin/node scripts/devto-publish-next.js >> logs/devto-cron.log 2>&1

L'ajouter au crontab (crontab -e) :

17 3,15 * * * /home/user/work/mon-blog/scripts/devto-cron.sh

Pourquoi deux passages par jour (3h et 15h) ? Si la machine est éteinte à 3h, le passage de 15h prend le relais. Et passer deux fois ne publie pas deux fois : le script vérifie la cadence lui-même. Si l'article du matin a été publié, celui de 15h voit que la cadence n'est pas écoulée et sort sans rien faire.

Part 2 — LinkedIn

LinkedIn est deux ordres de magnitude plus compliqué que dev.to. Pas de simple API key — OAuth 2.0 obligatoire.

Setup de l'app LinkedIn (une seule fois)

  1. Aller sur linkedin.com/developers/apps et créer une app
  2. Onglet Products : activer "Share on LinkedIn" (w_member_social) et "Sign In with LinkedIn using OpenID Connect" (openid, profile) — les deux sont obligatoires
  3. Onglet Auth : ajouter http://localhost:8989/callback dans "Authorized redirect URLs"
  4. Copier Client ID et Client Secret

Créer scripts/.linkedin-env :

LINKEDIN_CLIENT_ID=votre_client_id
LINKEDIN_CLIENT_SECRET=votre_client_secret
LINKEDIN_ACCESS_TOKEN=
LINKEDIN_PERSON_ID=

linkedin-auth.js — obtenir le token

Pourquoi un serveur local ? L'OAuth LinkedIn exige une redirect_uri enregistrée — une URL que LinkedIn va appeler après l'autorisation pour retourner le code. En ligne de commande pure, il n'y a pas d'URL à exposer. La solution : lancer un mini serveur HTTP sur le port 8989, qui reçoit le callback, échange le code contre un token, et s'arrête. Aucune dépendance externe, aucun tunnel ngrok, aucun service tiers.

Les deux scopes nécessaires sont w_member_social (poster des publications) et openid profile (récupérer le person ID de l'utilisateur). Ces scopes correspondent à deux products distincts dans le portail LinkedIn Developer. Si l'un des deux n'est pas activé, LinkedIn renverra unauthorized_scope_error même si le code est parfaitement correct.

Le token LinkedIn expire après 60 jours. Mettre un rappel calendrier ou ajouter une vérification de date dans le script — sinon c'est le cron qui tombe en silence un matin.

#!/usr/bin/env node
import { createServer } from 'http';
import { readFileSync, writeFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const ENV_FILE = resolve(__dirname, '.linkedin-env');

function readEnv() {
    const env = {};
    for (const line of readFileSync(ENV_FILE, 'utf8').split('\n')) {
        const [k, ...v] = line.split('=');
        if (k && v.length) env[k.trim()] = v.join('=').trim();
    }
    return env;
}

function writeEnv(updates) {
    let content = readFileSync(ENV_FILE, 'utf8');
    for (const [key, value] of Object.entries(updates)) {
        const regex = new RegExp(`^${key}=.*$`, 'm');
        content = regex.test(content)
            ? content.replace(regex, `${key}=${value}`)
            : content + `\n${key}=${value}`;
    }
    writeFileSync(ENV_FILE, content);
}

const env = readEnv();
const CLIENT_ID = env.LINKEDIN_CLIENT_ID;
const CLIENT_SECRET = env.LINKEDIN_CLIENT_SECRET;
const REDIRECT_URI = 'http://localhost:8989/callback';
const SCOPES = 'openid profile w_member_social';

const authUrl = `https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=${CLIENT_ID}&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&scope=${encodeURIComponent(SCOPES)}`;

console.log('\n→ Ouvre cette URL dans ton browser :\n');
console.log(authUrl + '\n');
// ⚠️ Ne pas ouvrir depuis WSL/cmd.exe : & est interprété comme séparateur de commandes,
//    l'URL est tronquée et LinkedIn répond "client_id manquant"

const server = createServer(async (req, res) => {
    const url = new URL(req.url, 'http://localhost:8989');
    if (url.pathname !== '/callback') { res.end('Not found'); return; }

    const code = url.searchParams.get('code');
    const error = url.searchParams.get('error');

    if (error || !code) {
        res.end(`<h1>Error: ${error || 'no code'}</h1>`);
        console.error('Auth error:', error);
        server.close();
        return;
    }

    const tokenRes = await fetch('https://www.linkedin.com/oauth/v2/accessToken', {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: new URLSearchParams({
            grant_type: 'authorization_code',
            code, redirect_uri: REDIRECT_URI,
            client_id: CLIENT_ID, client_secret: CLIENT_SECRET,
        }),
    });

    const token = await tokenRes.json();
    if (!tokenRes.ok || !token.access_token) {
        res.end('<h1>Token exchange failed</h1><pre>' + JSON.stringify(token, null, 2) + '</pre>');
        server.close(); return;
    }

    // Récupérer le person ID via OpenID Connect
    const profile = await (await fetch('https://api.linkedin.com/v2/userinfo', {
        headers: { Authorization: `Bearer ${token.access_token}` },
    })).json();

    writeEnv({ LINKEDIN_ACCESS_TOKEN: token.access_token, LINKEDIN_PERSON_ID: profile.sub || '' });

    console.log('Token saved to .linkedin-env');
    console.log(`  Person ID: ${profile.sub}`);
    console.log(`  Expires in: ${Math.round(token.expires_in / 86400)} days`);

    res.end('<h1>Done! You can close this tab.</h1>');
    server.close();
    process.exit(0);
});

server.listen(8989, () => console.log('Listening on http://localhost:8989/callback...\n'));

Piège WSL : le & dans l'URL OAuth est interprété comme séparateur de commandes par cmd.exe. Si vous tentez d'ouvrir l'URL avec start depuis WSL, l'URL sera tronquée au premier & et LinkedIn répondra "client_id manquant". Toujours copier-coller l'URL manuellement dans le browser.

Usage :

node scripts/linkedin-auth.js
# Copier l'URL affichée → coller dans le browser → autoriser → token sauvegardé

linkedin-publish.js — deux modes de post

LinkedIn a déprécié l'ancienne Share API au profit de l'UGC Posts API (/v2/ugcPosts). C'est cette dernière qu'on utilise ici. Elle accepte deux modes de publication pour un article, avec des trade-offs différents.

Mode image : on uploade manuellement le JPEG de l'OG image en trois étapes (registerUpload → PUT du fichier → création du post avec l'assetUrn). Résultat : une grande image pleine largeur dans le feed LinkedIn. L'inconvénient : l'image n'est pas cliquable — l'URL de l'article est dans le texte du post.

Mode article : on passe juste l'URL et LinkedIn fetche lui-même l'OG image via les meta tags. Résultat : une card entière cliquable, image + titre + description. L'inconvénient : LinkedIn peut mettre plusieurs heures à fetcher l'image, voire l'ignorer si le site est lent à répondre.

Le flow image en détail : registerUpload retourne un uploadUrl temporaire et un assetUrn permanent. On PUT le JPEG sur l'uploadUrl (avec Bearer token). Une fois uploadé, on crée le post en référençant l'assetUrn — LinkedIn sait quelle image afficher.

Piège %20 : en mode image, si le texte du post se termine par l'URL suivie d'un retour à la ligne (\n), LinkedIn encode ce retour en %20 et l'URL devient https://votre-site.com/blog/slug%20. Le lien est cassé. Solution : l'URL doit toujours être en dernière position, rien après.

#!/usr/bin/env node
// Usage: node scripts/linkedin-publish.js [--force] [--mode image|article]
import { readFileSync, writeFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');
const SCHEDULE_FILE = resolve(__dirname, 'linkedin-schedule.json');

function readEnv() {
    const env = {};
    for (const line of readFileSync(resolve(__dirname, '.linkedin-env'), 'utf8').split('\n')) {
        const [k, ...v] = line.split('=');
        if (k && v.length) env[k.trim()] = v.join('=').trim();
    }
    return env;
}

const force = process.argv.includes('--force');
const modeIdx = process.argv.indexOf('--mode');
const mode = modeIdx !== -1 ? process.argv[modeIdx + 1] : 'image';

const env = readEnv();
const ACCESS_TOKEN = env.LINKEDIN_ACCESS_TOKEN;
const PERSON_ID = env.LINKEDIN_PERSON_ID;
if (!ACCESS_TOKEN || !PERSON_ID) {
    console.error('Missing token. Run linkedin-auth.js first.');
    process.exit(1);
}

const schedule = JSON.parse(readFileSync(SCHEDULE_FILE, 'utf8'));
const posts = JSON.parse(readFileSync(resolve(ROOT, 'blog/posts.json'), 'utf8'));

if (!force) {
    const lastPublished = schedule.articles
        .filter(a => a.published_at).map(a => new Date(a.published_at))
        .sort((a, b) => b - a)[0];
    if (lastPublished) {
        const daysSince = (Date.now() - lastPublished) / 86400000;
        if (daysSince < schedule.cadence_days) {
            const wait = Math.ceil(schedule.cadence_days - daysSince);
            console.log(`Next publish in ${wait} day(s). Use --force to bypass.`);
            process.exit(0);
        }
    }
}

const next = schedule.articles.find(a => a.status === 'pending');
if (!next) { console.log('No pending articles.'); process.exit(0); }

const post = posts.find(p => p.slug === next.slug);
if (!post?.en) { console.error(`No EN metadata for ${next.slug}`); process.exit(1); }

const meta = post.en;
const articleUrl = `https://votre-site.com/en/blog/${next.slug}`;
const tags = (meta.tags || []).slice(0, 5).map(t => '#' + t.replace(/-/g, '')).join(' ');
const headers = { Authorization: `Bearer ${ACCESS_TOKEN}`, 'Content-Type': 'application/json' };

console.log(`→ Publication LinkedIn [${mode}] : "${next.slug}"...`);

let shareContent;

if (mode === 'image') {
    // Étape 1 : enregistrer l'upload
    const regData = await (await fetch('https://api.linkedin.com/v2/assets?action=registerUpload', {
        method: 'POST', headers,
        body: JSON.stringify({
            registerUploadRequest: {
                recipes: ['urn:li:digitalmediaRecipe:feedshare-image'],
                owner: `urn:li:person:${PERSON_ID}`,
                serviceRelationships: [{ relationshipType: 'OWNER', identifier: 'urn:li:userGeneratedContent' }],
            },
        }),
    })).json();

    const uploadUrl = regData.value.uploadMechanism['com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest'].uploadUrl;
    const assetUrn = regData.value.asset;

    // Étape 2 : uploader le JPEG
    await fetch(uploadUrl, {
        method: 'PUT',
        headers: { Authorization: `Bearer ${ACCESS_TOKEN}`, 'Content-Type': 'image/jpeg' },
        body: readFileSync(resolve(ROOT, `assets/images/og/${next.slug}.jpg`)),
    });
    console.log('  Image uploadée.');

    // URL en dernier, sans \n après — évite le bug %20 de LinkedIn
    shareContent = {
        shareCommentary: { text: `${meta.excerpt}\n\n${tags}\n\n${articleUrl}` },
        shareMediaCategory: 'IMAGE',
        media: [{ status: 'READY', media: assetUrn, title: { text: meta.title } }],
    };
} else {
    // Mode article : LinkedIn fetche l'OG image, card entière cliquable
    shareContent = {
        shareCommentary: { text: `${meta.excerpt}\n\n${tags}` },
        shareMediaCategory: 'ARTICLE',
        media: [{ status: 'READY', originalUrl: articleUrl }],
    };
}

// Étape 3 (ou 1 en mode article) : créer le post
const postData = await (await fetch('https://api.linkedin.com/v2/ugcPosts', {
    method: 'POST', headers,
    body: JSON.stringify({
        author: `urn:li:person:${PERSON_ID}`,
        lifecycleState: 'PUBLISHED',
        specificContent: { 'com.linkedin.ugc.ShareContent': shareContent },
        visibility: { 'com.linkedin.ugc.MemberNetworkVisibility': 'PUBLIC' },
    }),
})).json();

const postUrl = `https://www.linkedin.com/feed/update/${postData.id}/`;
next.status = 'published';
next.published_at = new Date().toISOString();
next.linkedin_url = postUrl;
writeFileSync(SCHEDULE_FILE, JSON.stringify(schedule, null, 2));

console.log(`Publié : ${postUrl}`);

Le linkedin-schedule.json a le même format que devto-schedule.json, avec status: "pending" au lieu de passer par un état "drafted" : LinkedIn n'a pas de concept de draft accessible via l'API, donc on publie directement.

{
  "cadence_days": 3,
  "articles": [
    { "slug": "mon-premier-article", "status": "pending" }
  ]
}

Le script unifié

publish-article.js est le chef d'orchestre. Il appelle les 4 étapes dans l'ordre — vérifications, OG image, draft dev.to, publication LinkedIn, déploiement — avec gestion d'erreur entre chaque. Une seule commande pour tout faire.

spawnSync plutôt qu'execSync : spawnSync passe les arguments directement au processus sans passer par un shell intermédiaire. Pas d'interpolation, pas de risque d'injection avec des slugs qui contiendraient des caractères bizarres. Petit détail, mais c'est le genre de chose qui mord quand on l'ignore.

La fonction readEnv supporte deux formats de fichiers d'environnement : .devto-env utilise export KEY=VALUE (format shell sourceable), .linkedin-env utilise KEY=VALUE. La fonction strip le préfixe export initial pour normaliser les deux.

#!/usr/bin/env node
// Usage: node scripts/publish-article.js <slug> [--mode image|article]
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { spawnSync } from 'child_process';
import { extractMarkdown, devtoTags } from './devto-helpers.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');

// spawnSync évite l'injection shell (pas d'interpolation dans sh -c)
function run(cmd, args, opts = {}) {
    const r = spawnSync(cmd, args, { stdio: 'inherit', cwd: ROOT, ...opts });
    if (r.status !== 0) process.exit(r.status ?? 1);
}

function readEnv(file) {
    const env = {};
    for (let line of readFileSync(resolve(__dirname, file), 'utf8').split('\n')) {
        line = line.replace(/^export\s+/, '');
        const [k, ...v] = line.split('=');
        if (k?.trim() && v.length) env[k.trim()] = v.join('=').trim();
    }
    return env;
}

const slug = process.argv[2];
if (!slug) { console.error('Usage: node publish-article.js <slug>'); process.exit(1); }

// 0. Vérifications
console.log(`\n[0/4] Vérifications pour "${slug}"...`);
if (!existsSync(resolve(ROOT, `blog/posts/${slug}.php`))) { console.error('Missing FR file'); process.exit(1); }
if (!existsSync(resolve(ROOT, `blog/posts/${slug}.en.php`))) { console.error('Missing EN file'); process.exit(1); }

const posts = JSON.parse(readFileSync(resolve(ROOT, 'blog/posts.json'), 'utf8'));
const post = posts.find(p => p.slug === slug);
if (!post?.fr || !post?.en) { console.error(`Missing posts.json entry for "${slug}"`); process.exit(1); }
console.log('  OK');

// 1. OG image
console.log('\n[1/4] OG image...');
run('npm', ['run', 'og', slug]);
const ogImagePath = resolve(ROOT, `assets/images/og/${slug}.jpg`);

// 2. Dev.to draft
console.log('\n[2/4] Draft dev.to (EN)...');
const { DEVTO_API_KEY } = readEnv('.devto-env');
const devtoScheduleFile = resolve(__dirname, 'devto-schedule.json');
const devtoSchedule = JSON.parse(readFileSync(devtoScheduleFile, 'utf8'));
const alreadyDrafted = devtoSchedule.articles.find(a => a.slug === slug);
let devtoUrl = alreadyDrafted?.devto_url || null;

if (alreadyDrafted) {
    console.log(`  Déjà dans devto-schedule (${alreadyDrafted.status}), skip.`);
} else {
    const res = await fetch('https://dev.to/api/articles', {
        method: 'POST',
        headers: { 'api-key': DEVTO_API_KEY, 'Content-Type': 'application/json' },
        body: JSON.stringify({
            article: {
                title: post.en.title, published: false,
                body_markdown: extractMarkdown(ROOT, slug),
                tags: devtoTags(post.en),
                canonical_url: `https://votre-site.com/en/blog/${slug}`,
                description: post.en.excerpt,
            },
        }),
    });
    const result = await res.json();
    if (res.ok) {
        devtoUrl = result.url;
        devtoSchedule.articles.push({ slug, status: 'drafted', drafted_at: new Date().toISOString(), devto_id: result.id, devto_url: devtoUrl });
        writeFileSync(devtoScheduleFile, JSON.stringify(devtoSchedule, null, 2));
        console.log(`  Drafté : ${devtoUrl}`);
    } else {
        console.error('  Dev.to error:', result.error);
    }
}

// 3. LinkedIn
console.log('\n[3/4] LinkedIn...');
const liEnv = readEnv('.linkedin-env');
const { LINKEDIN_ACCESS_TOKEN: TOKEN, LINKEDIN_PERSON_ID: PERSON_ID } = liEnv;
const liHeaders = { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' };
const liMode = (() => { const i = process.argv.indexOf('--mode'); return i !== -1 ? process.argv[i + 1] : 'image'; })();
const frMeta = post.fr;
const articleUrl = `https://votre-site.com/blog/${slug}`;
const hashTags = (frMeta.tags || []).slice(0, 4).map(t => '#' + t.replace(/-/g, '')).join(' ');

let liShareContent;
if (liMode === 'image') {
    const regData = await (await fetch('https://api.linkedin.com/v2/assets?action=registerUpload', {
        method: 'POST', headers: liHeaders,
        body: JSON.stringify({ registerUploadRequest: { recipes: ['urn:li:digitalmediaRecipe:feedshare-image'], owner: `urn:li:person:${PERSON_ID}`, serviceRelationships: [{ relationshipType: 'OWNER', identifier: 'urn:li:userGeneratedContent' }] } }),
    })).json();
    const uploadUrl = regData.value.uploadMechanism['com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest'].uploadUrl;
    const assetUrn = regData.value.asset;
    await fetch(uploadUrl, { method: 'PUT', headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'image/jpeg' }, body: readFileSync(ogImagePath) });
    console.log('  Image uploadée.');
    liShareContent = { shareCommentary: { text: `${frMeta.excerpt}\n\n${hashTags}\n\n${articleUrl}` }, shareMediaCategory: 'IMAGE', media: [{ status: 'READY', media: assetUrn, title: { text: frMeta.title } }] };
} else {
    liShareContent = { shareCommentary: { text: `${frMeta.excerpt}\n\n${hashTags}` }, shareMediaCategory: 'ARTICLE', media: [{ status: 'READY', originalUrl: articleUrl }] };
}

const postData = await (await fetch('https://api.linkedin.com/v2/ugcPosts', {
    method: 'POST', headers: liHeaders,
    body: JSON.stringify({ author: `urn:li:person:${PERSON_ID}`, lifecycleState: 'PUBLISHED', specificContent: { 'com.linkedin.ugc.ShareContent': liShareContent }, visibility: { 'com.linkedin.ugc.MemberNetworkVisibility': 'PUBLIC' } }),
})).json();

const liPostUrl = `https://www.linkedin.com/feed/update/${postData.id}/`;
console.log(`  Publié : ${liPostUrl}`);

const liScheduleFile = resolve(__dirname, 'linkedin-schedule.json');
const liSchedule = JSON.parse(readFileSync(liScheduleFile, 'utf8'));
const ex = liSchedule.articles.find(a => a.slug === slug);
if (ex) { ex.status = 'published'; ex.published_at = new Date().toISOString(); ex.linkedin_url = liPostUrl; }
else liSchedule.articles.push({ slug, status: 'published', published_at: new Date().toISOString(), linkedin_url: liPostUrl });
writeFileSync(liScheduleFile, JSON.stringify(liSchedule, null, 2));

// 4. Deploy
console.log('\n[4/4] Déploiement...');
run('bash', ['scripts/deploy.sh']);

console.log(`\n"${slug}" publié partout !`);
if (devtoUrl) console.log(`  Dev.to   : ${devtoUrl}`);
console.log(`  LinkedIn : ${liPostUrl}`);

Adapter à votre blog

Remplacer votre-site.com dans devto-helpers.js et publish-article.js. Le script suppose que les articles EN sont dans blog/posts/${slug}.en.php avec une <div class="article-content">. Adapter le regex dans extractMarkdown si votre structure est différente.

L'OG image suppose un script npm run og <slug> — adapter ou retirer l'étape si vous n'en avez pas.

Conclusion

Dev.to prend 2 minutes. LinkedIn en prend 15, dont 10 de débogage sur l'OAuth. Le script linkedin-auth.js est à relancer tous les 60 jours — mettre un rappel calendrier.

Une fois en place, node scripts/publish-article.js mon-article fait tout : OG image, draft dev.to, post LinkedIn, deploy. Le cron s'occupe du reste.

Le vrai gain n'est pas le temps économisé à la publication — c'est la suppression de la friction qui fait qu'on finit par ne plus publier du tout. Quand la commande est une ligne, on la lance. Quand c'est 20 minutes de copier-coller, on remet à demain.

Commentaires (0)