· 6 min de leitura
Como criar um clone do dlvr.it: compartilhe cada post novo do blog no X, no LinkedIn e no Bluesky
Monte seu próprio autopublicador de RSS para redes sociais: acompanhe qualquer feed de blog ou podcast e compartilhe cada item novo no X, no LinkedIn, no Bluesky, no Discord, no Slack e no Telegram, no tamanho certo para cada rede. Cerca de 90 linhas de Node, sem dependências.
O dlvr.it faz um trabalho muito bem: você publica um post no blog e, minutos depois, ele aparece nas suas redes. Sem copiar e colar, sem esquecer.
O trabalho tem duas metades:
- Acompanhar um feed. Buscar o RSS, ver o que é novo, lembrar o que já foi compartilhado. Isso dá umas 60 linhas.
- Publicar em todo lugar. OAuth de cada rede, renovação de token, um limite de tamanho diferente em cada uma, nunca postar a mesma coisa duas vezes. Essa é a infraestrutura, e essa é a Hapi.
No fim você vai ter um script pequeno que:
- Acompanha qualquer feed RSS ou Atom: seu blog, um podcast, um canal do YouTube, um changelog
- Compartilha cada item novo no X, no LinkedIn e no Bluesky (e no Discord, no Slack e no Telegram, se quiser)
- Escreve uma versão curta para o X e o Bluesky e uma mais longa, com resumo, para o LinkedIn, em uma única requisição
- Nunca publica em dobro, mesmo se travar, reiniciar ou perder o arquivo de memória
É um arquivo só, Node 22 ou mais recente, sem dependências e sem banco.
1. Configure a Hapi
- Comece um teste da Hapi (15 dias por US$ 5).
- Conecte suas contas em Accounts: X e LinkedIn em um clique, Bluesky com uma senha de app.
- Crie uma chave de API.
2. Ler um feed sem biblioteca
RSS e Atom são, os dois, “uma lista de entradas com título, link e ID”. Algumas expressões regulares cobrem os feeds que você encontra na prática (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() desembrulha CDATA, decodifica entidades e tira o HTML, então um resumo cheio de tags <p> vira texto limpo.
3. Uma requisição, o tamanho certo em cada rede
O X permite 280 caracteres, o Bluesky 300, o LinkedIn 3.000. Em vez de três requisições, envie um post com um padrão longo e versões curtas para as redes mais rígidas, usando 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" }
}
}A Hapi confere a versão de cada rede contra o limite daquela rede antes de publicar, então nada falha no meio do caminho.
4. Nunca publicar em dobro
O script guarda em seen.json os IDs que já compartilhou. Mas arquivos se perdem, e o processo pode cair entre “publiquei” e “salvei”. Por isso cada post também envia um Idempotency-Key feito a partir do ID do item do feed: se o mesmo item for enviado de novo, a Hapi devolve o post original em vez de publicar um duplicado.
E, na primeira execução, o script só lembra o que já está no feed. Ele não despeja o acervo inteiro nas suas contas.
5. O script inteiro
// 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);
}Para rodar:
HAPI_API_KEY=hapi_live_... FEED_URL=https://blog.example.com/feed node dlvrit-clone.mjsA primeira execução memoriza o que já foi publicado. Publique um post novo e, em até dez minutos, ele está no X, no LinkedIn e no Bluesky. Para incluir também os canais da comunidade:
PLATFORMS=x,linkedin,bluesky,discord,slack,telegram node dlvrit-clone.mjsO que a Hapi resolveu por você
- OAuth e renovação de token de cada rede
- O limite de caracteres de cada rede, conferido antes de qualquer publicação
- Nunca publicar o mesmo item duas vezes (
Idempotency-Key) - Novas tentativas, entrega e um webhook
post.failedse uma rede rejeitar o post
Suas 90 linhas são só o produto: o feed, a memória e como o post fica escrito.
Transforme em um produto de verdade
- Vários feeds: percorra uma lista de
FEED_URL, cada um com os própriosPLATFORMS. - Horário melhor: em vez de postar na hora, envie
scheduledAtpara o próximo horário bom (veja a fila no nosso clone do Buffer). - Imagens: pegue o
og:imagedo post, envie comPOST /v1/mediae anexemediaIds. - Legendas com IA: peça ao Claude um gancho para cada rede, em vez de reaproveitar o título (veja o nosso clone do Taplio).
- Clientes: no Hapi Pro, rode um feed por cliente com uma chave de API restrita ao perfil daquele cliente.
Quem pode usar
Do jeito que está aqui, o clone posta nas contas conectadas no seu workspace da Hapi: a sua marca, a sua equipe ou clientes que você gerencia. Oferecer isso como um SaaS público, em que qualquer pessoa conecta as próprias contas, exige conexão de contas pela API, e a Hapi ainda não oferece isso.
Crie o seu: 15 dias por US$ 5 →
dlvr.it é marca registrada do seu dono. Este tutorial não é afiliado nem endossado pelo dlvr.it; “clone do dlvr.it” descreve o fluxo de feed para redes sociais que ele recria.