Blog

· 6 min de lectura

Cómo crear un clon de dlvr.it: comparte cada artículo nuevo en X, LinkedIn y Bluesky

Crea tu propio autopublicador de RSS a redes: vigila el feed de un blog o un podcast y comparte cada artículo nuevo en X, LinkedIn, Bluesky, Discord, Slack y Telegram, con la longitud justa para cada red. Unas 90 líneas de Node, sin dependencias.

dlvr.it hace una sola cosa, y la hace muy bien: publicas un artículo y, unos minutos después, aparece en tus redes. Sin copiar y pegar, y sin olvidarte.

El trabajo tiene dos mitades:

  • Vigilar un feed. Descargar el RSS, ver qué hay de nuevo y recordar lo que ya compartiste. Eso son unas 60 líneas.
  • Publicarlo en todas partes. OAuth en cada red, renovación de tokens, un límite de longitud distinto en cada una y no publicar nunca lo mismo dos veces. Eso es la infraestructura, y de eso se encarga Hapi.

Al terminar tendrás un script pequeño que:

  • Vigila cualquier feed RSS o Atom: tu blog, un podcast, un canal de YouTube, un changelog
  • Comparte cada elemento nuevo en X, LinkedIn y Bluesky (y en Discord, Slack y Telegram, si quieres)
  • Escribe una versión corta para X y Bluesky y otra más larga, con resumen, para LinkedIn, en una sola petición
  • Nunca publica dos veces, aunque se caiga, se reinicie o pierda el archivo donde guarda la memoria

Es un solo archivo, Node 22 o posterior, sin dependencias y sin base de datos.

1. Configura Hapi

  1. Empieza una prueba de Hapi (15 días por 5 USD).
  2. Conecta tus cuentas en Accounts: X y LinkedIn con un clic, Bluesky con una contraseña de aplicación.
  3. Crea una clave de API.

2. Leer un feed sin librerías

RSS y Atom son, en el fondo, «una lista de entradas con título, enlace e identificador». Unas pocas expresiones regulares cubren los feeds que vas a encontrar en la práctica (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() desenvuelve el CDATA, decodifica las entidades y quita el HTML, así que un resumen lleno de etiquetas <p> queda en texto limpio.

3. Una petición, la longitud correcta en cada red

X admite 280 caracteres, Bluesky 300 y LinkedIn 3000. En lugar de tres peticiones, envía una publicación con un texto largo por defecto y versiones cortas para las redes estrictas, mediante 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 compara la versión de cada red con el límite de esa red antes de publicar, así que nada se queda a medias.

4. Nunca publicar dos veces

El script guarda en seen.json los identificadores que ya compartió. Pero los archivos se pierden, y un proceso puede caerse entre «publicado» y «guardado». Por eso cada publicación envía también una Idempotency-Key construida con el identificador del elemento: si ese mismo elemento vuelve a enviarse, Hapi devuelve la publicación original en lugar de publicar un duplicado.

Y en la primera ejecución el script solo recuerda lo que ya está en el feed. No va a llenarte las cuentas con todo el histórico.

5. El script completo

js
// dlvrit-clone.mjs: comparte cada elemento nuevo del feed en tus redes, a través de Hapi.
// Déjalo en marcha:  HAPI_API_KEY=hapi_live_... FEED_URL=https://blog.example.com/feed node dlvrit-clone.mjs
// O desde 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 → entidades → quitar HTML → limpiar espacios.
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) {
    // Primera ejecución: recuerda el histórico en lugar de publicarlo todo.
    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);
}

Ejecútalo:

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

La primera ejecución recuerda lo ya publicado. Publica un artículo nuevo y, en menos de diez minutos, está en X, LinkedIn y Bluesky. Para incluir también los canales de tu comunidad:

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

Lo que Hapi resolvió por ti

  • El OAuth y la renovación de tokens de cada red
  • El límite de caracteres de cada red, comprobado antes de publicar nada
  • No publicar el mismo elemento dos veces (Idempotency-Key)
  • Los reintentos, la entrega y un webhook post.failed si una red rechaza la publicación

Tus 90 líneas son solo el producto: el feed, la memoria y cómo se lee la publicación.

Conviértelo en un producto de verdad

  • Varios feeds: recorre una lista de FEED_URL, cada una con sus propios PLATFORMS.
  • Mejor momento: en lugar de publicar al instante, envía scheduledAt con la siguiente franja que te interese (la cola está en nuestro clon de Buffer).
  • Imágenes: toma el og:image del artículo, súbelo con POST /v1/media y adjunta mediaIds.
  • Textos con IA: pide a Claude un gancho distinto para cada red, en lugar de repetir el título (mira nuestro clon de Taplio).
  • Clientes: en Hapi Pro, ejecuta un feed por cliente con una clave de API limitada al perfil de ese cliente.

Quién puede usarlo

Tal como está montado aquí, el clon publica en las cuentas conectadas a tu espacio de trabajo de Hapi: tu marca, tu equipo o los clientes que gestionas. Ofrecerlo como un SaaS público, donde cualquiera conecta sus propias cuentas, exige conectar cuentas a través de la API, y eso Hapi todavía no lo ofrece.

Crea el tuyo: 15 días por 5 USD →

dlvr.it es una marca registrada de su propietario. Este tutorial no está afiliado a dlvr.it ni cuenta con su respaldo; «clon de dlvr.it» describe el paso del feed a las redes que reproduce.