Blog

· 6 min di lettura

Come costruire un clone di dlvr.it: condividi in automatico ogni nuovo articolo su X, LinkedIn e Bluesky

Costruisci uno strumento che pubblica da solo i feed sui social: tieni d’occhio il feed di un blog o di un podcast e condividi ogni nuovo post su X, LinkedIn, Bluesky, Discord, Slack e Telegram, con la lunghezza giusta per ogni network. Circa 90 righe di Node, nessuna dipendenza.

dlvr.it fa una cosa sola, e la fa bene: pubblichi un articolo e qualche minuto dopo è sui tuoi account social. Niente copia e incolla, niente “me ne sono dimenticato”.

Il lavoro ha due metà:

  • Tenere d’occhio un feed. Scaricare l’RSS, capire cosa è nuovo, ricordare cosa hai già condiviso. Sono una sessantina di righe.
  • Pubblicarlo ovunque. OAuth per ogni network, refresh dei token, un limite di lunghezza diverso per ciascuno, e non pubblicare mai due volte la stessa cosa. Quella è l’infrastruttura, e quella è Hapi.

Alla fine avrai un piccolo script che:

  • Tiene d’occhio qualsiasi feed RSS o Atom: il tuo blog, un podcast, un canale YouTube, un changelog
  • Condivide ogni elemento nuovo su X, LinkedIn e Bluesky (e su Discord, Slack e Telegram, se ti serve)
  • Scrive una versione corta per X e Bluesky e una più lunga, con un riassunto, per LinkedIn, in una sola richiesta
  • Non pubblica mai due volte, anche se va in crash, riparte o perde il file in cui ricorda cosa ha già visto

È un solo file, Node 22 o successivo, nessuna dipendenza, nessun database.

1. Configura Hapi

  1. Inizia una prova di Hapi (15 giorni a 5 $).
  2. Collega gli account sotto Accounts: X e LinkedIn in un clic, Bluesky con una password per app.
  3. Crea una chiave API.

2. Leggere un feed senza librerie

RSS e Atom sono entrambi “un elenco di voci con titolo, link e ID”. Poche espressioni regolari coprono i feed che incontri davvero (WordPress, Ghost, Substack, Medium, podcast, 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() scarta il CDATA, decodifica le entità e toglie l’HTML, così un riassunto pieno di tag <p> diventa testo pulito.

3. Una richiesta, la lunghezza giusta ovunque

X consente 280 caratteri, Bluesky 300, LinkedIn 3.000. Invece di tre richieste, invia un solo post con un testo lungo di default e versioni corte per i network più stretti, usando 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 confronta la versione di ogni network con il limite di quel network prima di pubblicare, così niente fallisce a metà.

4. Mai un post doppio

Lo script tiene gli ID già condivisi in seen.json. I file però si perdono, e i processi vanno in crash fra “pubblicato” e “salvato”. Ogni post invia quindi anche un Idempotency-Key ricavato dall’ID dell’elemento nel feed: se lo stesso elemento viene inviato di nuovo, Hapi restituisce il post originale invece di pubblicare un duplicato.

E alla primissima esecuzione lo script si limita a ricordare ciò che è già nel feed. Non inonda gli account con tutto l’archivio.

5. Lo script completo

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);
}

Avvialo:

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

La prima esecuzione ricorda ciò che è già pubblicato. Pubblica un nuovo articolo e, entro dieci minuti, è su X, LinkedIn e Bluesky. Per includere anche i canali della community:

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

Cosa ha gestito Hapi al posto tuo

  • OAuth e refresh dei token per ogni network
  • Il limite di caratteri di ogni network, controllato prima che esca qualsiasi cosa
  • Non pubblicare mai due volte lo stesso elemento (Idempotency-Key)
  • Retry, consegna, e un webhook post.failed se un network rifiuta un post

Le tue 90 righe sono solo il prodotto: il feed, la memoria e come si legge il post.

Farne un prodotto vero

  • Tanti feed: fai un ciclo su un elenco di FEED_URL, ognuno con i suoi PLATFORMS.
  • Orari migliori: invece di pubblicare all’istante, invia scheduledAt per il prossimo slot buono (vedi la coda nel nostro clone di Buffer).
  • Immagini: prendi l’og:image dell’articolo, caricala con POST /v1/media e allega mediaIds.
  • Didascalie con l’AI: fai scrivere a Claude un gancio per ogni network, invece di riusare il titolo (vedi il nostro clone di Taplio).
  • Clienti: con Hapi Pro fai girare un feed per cliente, con una chiave API limitata al profilo di quel cliente.

Chi può usarlo

Così com’è, il clone pubblica sugli account collegati nel tuo workspace Hapi: il tuo brand, il team o i clienti che gestisci. Offrirlo come SaaS pubblico, dove chiunque collega i propri account, richiede la connessione degli account tramite API, che Hapi non offre ancora.

Costruisci il tuo: 15 giorni a 5 $ →

dlvr.it è un marchio del suo proprietario. Questo tutorial non è affiliato a dlvr.it né approvato da dlvr.it; “clone di dlvr.it” descrive il flusso da feed a social che ricostruisce.