Blog

· 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

  1. Comece um teste da Hapi (15 dias por US$ 5).
  2. Conecte suas contas em Accounts: X e LinkedIn em um clique, Bluesky com uma senha de app.
  3. 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):

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() 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:

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" }
  }
}

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

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

Para rodar:

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

A 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:

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

O 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.failed se 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óprios PLATFORMS.
  • Horário melhor: em vez de postar na hora, envie scheduledAt para o próximo horário bom (veja a fila no nosso clone do Buffer).
  • Imagens: pegue o og:image do post, envie com POST /v1/media e anexe mediaIds.
  • 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.