블로그

· 5분 분량

dlvr.it 클론 만들기: 새 블로그 글을 X, LinkedIn, Bluesky에 자동으로 공유하기

RSS를 소셜로 올리는 자동 게시기를 직접 만드세요. 블로그나 팟캐스트 피드를 지켜보다가 새 글을 X, LinkedIn, Bluesky, Discord, Slack, Telegram에 올리고, 네트워크마다 길이를 맞춥니다. Node 약 90줄, 의존성 없음.

dlvr.it이 잘하는 일은 하나입니다. 블로그 글을 올리면, 몇 분 뒤 소셜 계정에도 올라갑니다. 복사해서 붙여 넣을 일도, 깜빡할 일도 없습니다.

일은 두 부분입니다.

  • 피드를 지켜보기. RSS를 가져오고, 새 글을 찾고, 이미 공유한 글을 기억합니다. 대략 60줄입니다.
  • 모든 곳에 게시하기. 네트워크마다 OAuth, 토큰 갱신, 서로 다른 글자 수 제한, 같은 글을 두 번 올리지 않기. 이게 인프라이고, 그 인프라가 Hapi입니다.

끝나면 이런 작은 스크립트가 남습니다.

  • 아무 RSS나 Atom 피드나 지켜봅니다. 블로그, 팟캐스트, YouTube 채널, 체인지로그
  • 새 항목을 X, LinkedIn, Bluesky에 공유합니다. 원하면 Discord, Slack, Telegram도
  • 요청 한 번으로 X와 Bluesky용 짧은 글, LinkedIn용 요약이 붙은 긴 글을 씁니다
  • 프로세스가 죽거나, 다시 시작하거나, 기억 파일을 잃어도 같은 글이 두 번 나가지 않습니다

파일 하나, Node 22 이상, 의존성도 데이터베이스도 없습니다.

1. Hapi 준비

  1. Hapi 체험을 시작하세요(15일 $5).
  2. Accounts에서 계정을 연결하세요. X와 LinkedIn은 클릭 한 번이고, Bluesky는 앱 비밀번호입니다.
  3. API 키를 만드세요.

2. 라이브러리 없이 피드 읽기

RSS와 Atom은 둘 다 “제목, 링크, ID가 있는 항목의 목록”입니다. 정규식 몇 개면 실제로 만나는 피드를 처리할 수 있습니다. WordPress, Ghost, Substack, Medium, 팟캐스트, 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()는 CDATA를 풀고, 엔티티를 디코딩하고, HTML을 걷어 냅니다. <p> 태그로 가득한 요약이 깨끗한 문장이 됩니다.

3. 요청 한 번, 네트워크마다 맞는 길이

X는 280자, Bluesky는 300자, LinkedIn은 3,000자입니다. 요청을 세 번 보내는 대신, 긴 글을 기본으로 두고, 글자 수 제한이 짧은 네트워크에는 짧은 버전을 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는 게시 전에 각 네트워크용 문구를 그 네트워크의 제한과 대조합니다. 그래서 중간에 실패하는 일이 없습니다.

4. 같은 글을 두 번 올리지 않기

스크립트는 공유한 ID를 seen.json에 남깁니다. 그런데 파일은 사라질 수 있고, “올렸다”와 “저장했다” 사이에 프로세스가 죽을 수도 있습니다. 그래서 게시마다 피드 항목 ID로 만든 Idempotency-Key를 함께 보냅니다. 같은 항목이 다시 오면 Hapi는 새로 게시하지 않고 원래 게시물을 돌려줍니다.

그리고 맨 처음 실행에서는 피드에 이미 있는 글을 기억만 합니다. 지난 글을 계정에 한꺼번에 쏟아 내지 않습니다.

5. 스크립트 전체

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

실행합니다.

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

첫 실행은 이미 올라간 글을 기억합니다. 새 글을 발행하면 10분 안에 X, LinkedIn, Bluesky에 올라갑니다. 커뮤니티 채널도 포함하려면 이렇게 합니다.

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

Hapi가 대신 처리한 것

  • 모든 네트워크의 OAuth와 토큰 갱신
  • 각 네트워크의 글자 수 제한. 무엇이든 게시되기 전에 확인합니다
  • 같은 항목을 두 번 게시하지 않기(Idempotency-Key)
  • 재시도, 전달, 그리고 네트워크가 글을 거절하면 오는 post.failed 웹훅

여러분이 쓴 90줄은 제품입니다. 피드, 기억, 그리고 글이 어떻게 읽히는지.

진짜 제품으로 만들기

  • 피드 여러 개. FEED_URL 목록을 돌리고, 피드마다 PLATFORMS를 따로 두세요.
  • 더 나은 타이밍. 바로 올리지 말고, 다음 좋은 슬롯의 scheduledAt을 보내세요. 큐는 Buffer 클론을 보세요.
  • 이미지. 글의 og:image를 가져와 POST /v1/media로 올리고 mediaIds를 붙이세요.
  • AI 캡션. 제목을 그대로 쓰지 말고, Claude가 네트워크마다 첫 문장을 쓰게 하세요. Taplio 클론을 참고하세요.
  • 클라이언트. Hapi Pro에서는 클라이언트마다 피드 하나와, 그 클라이언트 프로필로 제한된 API 키를 쓰세요.

누가 쓸 수 있나

여기서 만든 클론은 여러분의 Hapi 워크스페이스에 연결된 계정에 게시합니다. 내 브랜드, 우리 팀, 또는 내가 관리하는 클라이언트입니다. 누구나 자기 계정을 연결하는 공개 SaaS로 제공하려면 API로 계정을 연결할 수 있어야 하는데, Hapi는 아직 그 기능을 제공하지 않습니다.

직접 만들어 보세요. 15일 $5 →

dlvr.it은 해당 소유자의 상표입니다. 이 튜토리얼은 dlvr.it과 제휴하거나 dlvr.it의 승인을 받은 것이 아니며, “dlvr.it 클론”은 재현하는 피드-소셜 흐름을 가리킵니다.