Blog

· 6 min de lecture

Créer un clone de dlvr.it : partager chaque nouvel article sur X, LinkedIn et Bluesky

Construisez votre propre relais RSS vers les réseaux : surveillez un blog ou un podcast et partagez chaque nouvel article sur X, LinkedIn, Bluesky, Discord, Slack et Telegram, à la bonne longueur pour chaque réseau. Environ 90 lignes de Node, sans dépendance.

dlvr.it ne fait qu’une chose, et il la fait bien : vous publiez un article, et il apparaît sur vos comptes quelques minutes plus tard. Pas de copier-coller, pas d’oubli.

Le travail a deux moitiés :

  • Surveiller un flux. Récupérer le RSS, repérer ce qui est nouveau, se souvenir de ce qui a déjà été partagé. Environ 60 lignes.
  • Le publier partout. OAuth pour chaque réseau, rafraîchissement des tokens, une limite de longueur différente à chaque fois, et ne jamais publier deux fois la même chose. C’est l’infrastructure, et c’est Hapi.

À la fin, vous aurez un petit script qui :

  • Surveille n’importe quel flux RSS ou Atom : votre blog, un podcast, une chaîne YouTube, un changelog
  • Partage chaque nouvel élément sur X, LinkedIn et Bluesky (et Discord, Slack et Telegram si vous voulez)
  • Écrit une version courte pour X et Bluesky, et une version plus longue avec un résumé pour LinkedIn, en une seule requête
  • Ne publie jamais en double, même s’il plante, redémarre, ou perd son fichier de mémoire

Un seul fichier, Node 22 ou plus récent, aucune dépendance, aucune base de données.

1. Préparer Hapi

  1. Démarrez un essai Hapi (15 jours pour 5 $).
  2. Connectez vos comptes dans Accounts : X et LinkedIn en un clic, Bluesky avec un mot de passe d’application.
  3. Créez une clé API.

2. Lire un flux sans bibliothèque

RSS et Atom, c’est « une liste d’entrées avec un titre, un lien et un identifiant ». Quelques expressions régulières couvrent les flux que vous croiserez en pratique (WordPress, Ghost, Substack, Medium, podcasts, YouTube) :

js
const tag = (xml, name) => xml.match(new RegExp(`<${name}[^>]*>([\\s\\S]*?)</${name}>`))?.[1];

function parseFeed(xml) {
  return (xml.match(/<(item|entry)[\s>][\s\S]*?<\/\1>/g) ?? []).map((entry) => {
    const link = text(tag(entry, "link")) || entry.match(/<link[^>]*href="([^"]+)"/)?.[1] || "";
    return { id: text(tag(entry, "guid") ?? tag(entry, "id")) || link, title: text(tag(entry, "title")), link, summary: text(tag(entry, "description") ?? tag(entry, "summary")) };
  });
}

text() défait le CDATA, décode les entités et retire le HTML : un résumé plein de balises <p> devient du texte propre.

3. Une requête, la bonne longueur partout

X autorise 280 caractères, Bluesky 300, LinkedIn 3 000. Au lieu de trois requêtes, envoyez un seul post avec un texte long par défaut et des versions courtes pour les réseaux stricts, via platformOptions :

json
{
  "content": "Title\n\nFirst 500 characters of the summary…\n\nhttps://blog.example.com/post",
  "platforms": ["x", "linkedin", "bluesky"],
  "platformOptions": {
    "x": { "content": "Title\n\nhttps://blog.example.com/post" },
    "bluesky": { "content": "Title\n\nhttps://blog.example.com/post" }
  }
}

Hapi compare la version de chaque réseau à la limite de ce réseau avant de publier. Rien n’échoue à mi-chemin.

4. Ne jamais publier en double

Le script garde dans seen.json les identifiants déjà partagés. Mais un fichier se perd, et un processus peut planter entre « publié » et « enregistré ». Chaque post envoie donc aussi une Idempotency-Key dérivée de l’identifiant de l’élément : si le même élément repart, Hapi renvoie le post d’origine au lieu de publier un doublon.

Et au tout premier passage, le script se contente de mémoriser ce qui est déjà dans le flux. Il n’inonde pas vos comptes avec tout l’historique.

5. Le script entier

js
// dlvrit-clone.mjs: share every new feed item to your social accounts, via Hapi.
// Keep it running:  HAPI_API_KEY=hapi_live_... FEED_URL=https://blog.example.com/feed node dlvrit-clone.mjs
// Or from cron:     ... node dlvrit-clone.mjs --once
import crypto from "node:crypto";
import fs from "node:fs";

const HAPI = process.env.HAPI_API_BASE ?? "https://hapi.tools/v1";
const { HAPI_API_KEY, FEED_URL } = process.env;
const PLATFORMS = (process.env.PLATFORMS ?? "x,linkedin,bluesky").split(",");
const EVERY_MINUTES = Number(process.env.EVERY_MINUTES ?? 10);
const SEEN_FILE = process.env.SEEN_FILE ?? "seen.json";

async function hapi(path, options = {}) {
  const res = await fetch(HAPI + path, {
    ...options,
    headers: { Authorization: `Bearer ${HAPI_API_KEY}`, "Content-Type": "application/json", ...options.headers },
  });
  const body = await res.json();
  if (!res.ok) throw new Error(body.error?.message ?? `Hapi returned ${res.status}`);
  return body;
}

// CDATA → entities → strip HTML → tidy whitespace.
const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", "#39": "'", nbsp: " " };
const text = (s = "") =>
  s
    .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1")
    .replace(/&(amp|lt|gt|quot|apos|#39|nbsp);/g, (_, e) => ENTITIES[e])
    .replace(/<[^>]+>/g, " ")
    .replace(/\s+/g, " ")
    .trim();

const tag = (xml, name) => xml.match(new RegExp(`<${name}[^>]*>([\\s\\S]*?)</${name}>`))?.[1];

function parseFeed(xml) {
  return (xml.match(/<(item|entry)[\s>][\s\S]*?<\/\1>/g) ?? []).map((entry) => {
    const link = text(tag(entry, "link")) || entry.match(/<link[^>]*href="([^"]+)"/)?.[1] || "";
    return { id: text(tag(entry, "guid") ?? tag(entry, "id")) || link, title: text(tag(entry, "title")), link, summary: text(tag(entry, "description") ?? tag(entry, "summary")) };
  });
}

function compose(item) {
  const short = (limit) => {
    const room = limit - item.link.length - 2;
    const title = item.title.length > room ? `${item.title.slice(0, room - 1)}…` : item.title;
    return `${title}\n\n${item.link}`;
  };
  const summary = item.summary.length > 500 ? `${item.summary.slice(0, 499)}…` : item.summary;
  return {
    content: [item.title, summary, item.link].filter(Boolean).join("\n\n"),
    platforms: PLATFORMS,
    platformOptions: { x: { content: short(280) }, bluesky: { content: short(300) } },
  };
}

async function check() {
  const seen = new Set(fs.existsSync(SEEN_FILE) ? JSON.parse(fs.readFileSync(SEEN_FILE, "utf8")) : []);
  const save = () => fs.writeFileSync(SEEN_FILE, JSON.stringify([...seen]));
  const res = await fetch(FEED_URL);
  if (!res.ok) throw new Error(`Feed returned ${res.status}`);
  const items = parseFeed(await res.text()).filter((item) => item.id && item.link);

  if (seen.size === 0) {
    // First run: remember the back catalog instead of posting all of it.
    items.forEach((item) => seen.add(item.id));
    save();
    console.log(`Watching ${FEED_URL}: ${items.length} existing posts remembered. New ones will be shared.`);
    return;
  }
  for (const item of items.filter((i) => !seen.has(i.id)).reverse()) {
    const key = `feed-${crypto.createHash("sha256").update(item.id).digest("hex").slice(0, 40)}`;
    await hapi("/posts", { method: "POST", headers: { "Idempotency-Key": key }, body: JSON.stringify(compose(item)) });
    seen.add(item.id);
    save();
    console.log(`Shared: ${item.title}`);
  }
}

if (process.argv.includes("--once")) {
  await check();
} else {
  const run = () => check().catch((err) => console.error(`Check failed: ${err.message}`));
  await run();
  setInterval(run, EVERY_MINUTES * 60_000);
}

Lancez-le :

bash
HAPI_API_KEY=hapi_live_... FEED_URL=https://blog.example.com/feed node dlvrit-clone.mjs

Le premier passage mémorise ce qui est déjà publié. Publiez un nouvel article : dans les dix minutes, il est sur X, LinkedIn et Bluesky. Pour inclure aussi vos canaux communautaires :

bash
PLATFORMS=x,linkedin,bluesky,discord,slack,telegram node dlvrit-clone.mjs

Ce que Hapi a pris en charge

  • OAuth et rafraîchissement des tokens pour chaque réseau
  • La limite de caractères de chaque réseau, vérifiée avant toute publication
  • L’absence de doublon pour un même élément (Idempotency-Key)
  • Les relances, la livraison, et un webhook post.failed si un réseau refuse un post

Vos 90 lignes, c’est le produit : le flux, la mémoire, et la façon dont le post est rédigé.

En faire un vrai produit

  • Plusieurs flux : bouclez sur une liste d’URL, chacune avec ses propres PLATFORMS.
  • Un meilleur horaire : au lieu de publier tout de suite, envoyez scheduledAt vers le prochain bon créneau (voir la file du clone de Buffer).
  • Des images : récupérez l’og:image de l’article, envoyez-la avec POST /v1/media et joignez mediaIds.
  • Des accroches générées : faites écrire à Claude une accroche par réseau au lieu de réutiliser le titre (voir le clone de Taplio).
  • Des clients : sur Hapi Pro, faites tourner un flux par client, avec une clé API limitée au profil de ce client.

Qui peut s’en servir

Tel qu’il est construit ici, le clone publie sur les comptes connectés dans votre espace de travail Hapi : votre marque, votre équipe, ou les clients que vous gérez. Le proposer comme SaaS public, où chacun connecte ses propres comptes, demande une connexion de comptes via l’API, que Hapi ne propose pas encore.

Construisez le vôtre : 15 jours pour 5 $ →

dlvr.it est une marque de son propriétaire. Ce tutoriel n’est ni affilié à dlvr.it ni approuvé par dlvr.it. « Clone de dlvr.it » décrit le flux « du fil RSS vers les réseaux » que ce guide recrée.