· 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
- Empieza una prueba de Hapi (15 días por 5 USD).
- Conecta tus cuentas en Accounts: X y LinkedIn con un clic, Bluesky con una contraseña de aplicación.
- 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):
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:
{
"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
// 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:
HAPI_API_KEY=hapi_live_... FEED_URL=https://blog.example.com/feed node dlvrit-clone.mjsLa 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:
PLATFORMS=x,linkedin,bluesky,discord,slack,telegram node dlvrit-clone.mjsLo 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.failedsi 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 propiosPLATFORMS. - Mejor momento: en lugar de publicar al instante, envía
scheduledAtcon la siguiente franja que te interese (la cola está en nuestro clon de Buffer). - Imágenes: toma el
og:imagedel artículo, súbelo conPOST /v1/mediay adjuntamediaIds. - 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.