· 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 준비
- Hapi 체험을 시작하세요(15일 $5).
- Accounts에서 계정을 연결하세요. X와 LinkedIn은 클릭 한 번이고, Bluesky는 앱 비밀번호입니다.
- API 키를 만드세요.
2. 라이브러리 없이 피드 읽기
RSS와 Atom은 둘 다 “제목, 링크, ID가 있는 항목의 목록”입니다. 정규식 몇 개면 실제로 만나는 피드를 처리할 수 있습니다. WordPress, Ghost, Substack, Medium, 팟캐스트, 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()는 CDATA를 풀고, 엔티티를 디코딩하고, HTML을 걷어 냅니다. <p> 태그로 가득한 요약이 깨끗한 문장이 됩니다.
3. 요청 한 번, 네트워크마다 맞는 길이
X는 280자, Bluesky는 300자, LinkedIn은 3,000자입니다. 요청을 세 번 보내는 대신, 긴 글을 기본으로 두고, 글자 수 제한이 짧은 네트워크에는 짧은 버전을 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는 게시 전에 각 네트워크용 문구를 그 네트워크의 제한과 대조합니다. 그래서 중간에 실패하는 일이 없습니다.
4. 같은 글을 두 번 올리지 않기
스크립트는 공유한 ID를 seen.json에 남깁니다. 그런데 파일은 사라질 수 있고, “올렸다”와 “저장했다” 사이에 프로세스가 죽을 수도 있습니다. 그래서 게시마다 피드 항목 ID로 만든 Idempotency-Key를 함께 보냅니다. 같은 항목이 다시 오면 Hapi는 새로 게시하지 않고 원래 게시물을 돌려줍니다.
그리고 맨 처음 실행에서는 피드에 이미 있는 글을 기억만 합니다. 지난 글을 계정에 한꺼번에 쏟아 내지 않습니다.
5. 스크립트 전체
// 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);
}실행합니다.
HAPI_API_KEY=hapi_live_... FEED_URL=https://blog.example.com/feed node dlvrit-clone.mjs첫 실행은 이미 올라간 글을 기억합니다. 새 글을 발행하면 10분 안에 X, LinkedIn, Bluesky에 올라갑니다. 커뮤니티 채널도 포함하려면 이렇게 합니다.
PLATFORMS=x,linkedin,bluesky,discord,slack,telegram node dlvrit-clone.mjsHapi가 대신 처리한 것
- 모든 네트워크의 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는 아직 그 기능을 제공하지 않습니다.
dlvr.it은 해당 소유자의 상표입니다. 이 튜토리얼은 dlvr.it과 제휴하거나 dlvr.it의 승인을 받은 것이 아니며, “dlvr.it 클론”은 재현하는 피드-소셜 흐름을 가리킵니다.