Écrire un article, c'est déjà du boulot. Le republier manuellement sur dev.to — copier-coller le markdown,
reformater les blocs de code, corriger les liens relatifs — puis créer le post LinkedIn avec l'image et le
texte d'accroche... c'est exactement le genre de friction qui fait qu'on finit par poster une fois tous
les deux mois. L'objectif était simple : node scripts/publish-article.js mon-slug et c'est
fini. Voilà ce que ça donne en pratique.
L'architecture en 3 scripts
Trois scripts Node.js, chacun responsable d'une plateforme ou d'une étape :
devto-draft-all.js+devto-publish-next.js— pipeline dev.to avec cadence (4 jours entre chaque article)linkedin-publish.js— publication LinkedIn avec grande imagepublish-article.js— script unifié qui orchestre tout
Chaque pipeline a son propre fichier de schedule JSON, qui suit le même pattern que posts.json :
une liste d'objets avec le slug, la date de publication planifiée, et le statut (draft, published).
Simple, versionnnable, lisible.
Dev.to : l'API simple
Dev.to expose une API REST propre. Une API key dans l'en-tête, un POST sur
/api/articles, c'est tout. Le contenu des articles est du HTML (fichiers
.en.php) — Turndown se charge de la conversion HTML → Markdown.
La canonical URL pointe vers le blog original : essentiel pour que Google ne considère
pas dev.to comme la source primaire.
const payload = {
article: {
title: meta.title,
published: false, // draft d'abord, publish séparé
body_markdown: markdown,
tags: ['golang', 'api'],
canonical_url: `https://www.web-developpeur.com/en/blog/${slug}`,
description: meta.excerpt,
},
};
const res = await fetch('https://dev.to/api/articles', {
method: 'POST',
headers: { 'api-key': DEVTO_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
Deux étapes séparées : d'abord créer le draft, ensuite le publier via un second appel
(PUT /api/articles/:id avec published: true). La cadence de 4 jours
entre chaque publication évite de spammer les followers — le script devto-publish-next.js
vérifie la date du dernier article publié avant d'agir.
La conversion HTML → Markdown avec Turndown
Les articles sont écrits en HTML dans les fichiers .en.php. Turndown convertit ça
en Markdown propre, mais deux points nécessitent des règles custom : les blocs de code
(Prism utilise class="language-go" qu'il faut traduire en triple backtick),
et les liens relatifs qui cassent sur dev.to si on ne les rend pas absolus.
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 = 'https://www.web-developpeur.com' + href;
return `[${content}](${href})`;
},
});
Sans la règle absolute-links, tous les liens internes du blog
(/blog/goroutine-leaks-golang) pointent vers dev.to/blog/...
côté lecteur. Classique.
LinkedIn : l'API qui pique
C'est là que ça devient intéressant. Pas de simple API key — LinkedIn exige OAuth 2.0. La procédure complète pour obtenir un token utilisable :
- Créer une app sur le portail développeur LinkedIn
- Activer les produits "Share on LinkedIn" (
w_member_social) et "Sign In with LinkedIn using OpenID Connect" (openid,profile) - Ajouter
http://localhost:8989/callbackcomme redirect URL autorisée - Lancer un serveur local qui reçoit le callback et échange le code contre un token (valable 60 jours)
// Serveur local OAuth
const server = createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost:8989');
if (url.pathname !== '/callback') return;
const code = url.searchParams.get('code');
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: 'http://localhost:8989/callback',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
});
const token = await tokenRes.json();
// sauvegarder token.access_token dans .linkedin-env
server.close();
});
server.listen(8989);
Les pièges LinkedIn
Deux galères non documentées qui font perdre du temps :
L'URL OAuth depuis WSL. Ouvrir l'URL d'autorisation depuis cmd.exe sur WSL coupe l'URL
au premier & — le navigateur reçoit une URL tronquée, l'autorisation échoue sans
message d'erreur utile. Solution : afficher l'URL complète dans le terminal et copier-coller manuellement
dans le navigateur.
L'erreur unauthorized_scope_error. Elle ne signifie pas que les scopes
sont mal configurés dans le code — elle signifie que les produits ne sont pas activés dans le portail
développeur. L'activation "Share on LinkedIn" peut prendre quelques minutes et nécessite parfois un
rechargement de page dans le portail.
Pour récupérer le person ID (nécessaire dans tous les appels API), utiliser l'endpoint OpenID Connect :
GET /v2/userinfo, champ sub.
Deux modes de post LinkedIn
Après quelques essais, deux comportements distincts selon comment on structure le post. Le mode par défaut uploade l'OG image manuellement pour avoir la grande image pleine largeur. Le mode article laisse LinkedIn fetcher l'image lui-même via l'URL — rendu en card cliquable.
| Mode | Rendu | Lien vers l'article |
|---|---|---|
--mode image (défaut) |
Grande image pleine largeur dans le feed | URL dans le texte |
--mode article |
Card OG cliquable automatique | Card entière cliquable |
Piège du mode image : l'URL doit être en toute dernière position dans le texte, sans
\n après. Sinon LinkedIn encode le saut de ligne en %20 et l'URL
devient https://mon-blog.com/article%20https://mon-blog.com/article côté lecteur.
Trouvé après deux posts supprimés et republiés.
// Mode image (défaut) — upload OG image + URL en dernier, rien après
shareContent = {
shareCommentary: { text: `${excerpt}\n\n${tags}\n\n${articleUrl}` },
shareMediaCategory: 'IMAGE',
media: [{
status: 'READY',
media: assetUrn, // asset URN obtenu après upload
title: { text: title },
}],
};
// Mode article — LinkedIn fetche l'OG image, card entière cliquable
shareContent = {
shareCommentary: { text: `${excerpt}\n\n${tags}` },
shareMediaCategory: 'ARTICLE',
media: [{ status: 'READY', originalUrl: articleUrl }],
};
node scripts/linkedin-publish.js --force # mode image (défaut)
node scripts/linkedin-publish.js --force --mode article # mode article
Les étapes pour que ça marche
Dev.to : 2 minutes
- Générer une API key dans les settings dev.to (section "DEV API Keys")
- La stocker dans
scripts/.devto-env:export DEVTO_API_KEY=votre_cle - Lancer
node scripts/devto-draft-all.jspour drafter tous les articles existants en une fois - Le cron publie ensuite à cadence de 4 jours —
node scripts/devto-publish-next.js --forcepour forcer
LinkedIn : 15 minutes (et quelques galères)
- Créer une app sur linkedin.com/developers/apps
- Onglet Products : activer "Share on LinkedIn" (
w_member_social) et "Sign In with LinkedIn using OpenID Connect" (openid,profile) — les deux sont nécessaires - Onglet Auth : ajouter
http://localhost:8989/callbackdans "Authorized redirect URLs" - Copier le Client ID et Client Secret dans
scripts/.linkedin-env - Lancer
node scripts/linkedin-auth.js— copier l'URL affichée dans le terminal et l'ouvrir manuellement dans le browser. Ne pas laisser le script l'ouvrir depuis WSL : cmd.exe coupe l'URL au premier&, LinkedIn reçoit une URL tronquée et répond "client_id manquant" - Autoriser l'app dans LinkedIn → token et person ID sauvegardés automatiquement dans
.linkedin-env - Le token dure 60 jours — penser à relancer
linkedin-auth.jsavant expiration
Le script unifié
publish-article.js orchestre tout dans l'ordre :
node scripts/publish-article.js mon-slug
Ce que ça fait :
- Vérifie que les fichiers FR et EN existent et que
posts.jsonest à jour - Génère l'OG image (1200×628)
- Crée le draft sur dev.to (version EN, avec canonical URL)
- Publie sur LinkedIn en mode image par défaut —
--mode articlepour la card auto - Déploie le site sur OVH via FTP
La séparation draft/publish dev.to est intentionnelle : le draft est créé immédiatement, la publication effective est gérée par le cron selon la cadence configurée.
Le cron pour dev.to
Dev.to a une cadence de publication gérée par un cron sur WSL. Le script vérifie la date du
dernier article publié avant d'agir — si la cadence de 4 jours n'est pas respectée, il ne fait rien.
--force pour bypasser.
# crontab -e
17 3,15 * * * /home/user/work/cv/scripts/devto-cron.sh
Le script shell active nvm, charge les variables d'environnement, lance
devto-publish-next.js et log le résultat dans un fichier de log rotatif.
Deux passages par jour (3h17 et 15h17) pour ne pas manquer la fenêtre si le PC est éteint
le matin.
Conclusion
Dev.to est trivial : une API key, un POST, c'est fait. LinkedIn est deux ordres de magnitude
plus compliqué pour un résultat fonctionnellement identique. L'OAuth local avec un serveur sur
localhost:8989 est la solution la plus simple sans avoir à déployer un serveur de callback
dédié. Le token dure 60 jours — penser à le renouveler (un script linkedin-refresh.js
gère ça).
Le vrai gain n'est pas dans le gain de temps brut — créer le post à la main prenait 10 minutes. C'est dans la suppression de la friction mentale. Quand poster est une commande, on poste plus souvent. La régularité, c'est ça le vrai objectif.