· 3分で読めます
dlvr.itクローンの作り方。新しいブログ記事をX、LinkedIn、Blueskyへ自動でシェアする
RSSからSNSへの自動投稿を自作します。ブログやポッドキャストのフィードを監視し、新しい記事をX、LinkedIn、Bluesky、Discord、Slack、Telegramへ、ネットワークに合った長さでシェア。Nodeは約90行。依存パッケージはありません。
dlvr.itがうまくやる仕事は一つです。ブログを公開すると、数分後にSNSアカウントへ出ます。コピペも、出し忘れもありません。
仕事は半分ずつです。
- フィードを見る。 RSSを取り、新しいものを見つけ、すでにシェアしたものを覚えておく。これは60行ほどです。
- あらゆる場所へ投稿する。 各ネットワークのOAuth、トークン更新、ネットワークごとの文字数、同じものを二度投稿しないこと。これが基盤で、それが Hapi です。
最後には、小さなスクリプトが手元にあります。
- 任意のRSSまたはAtomフィードを監視します。ブログ、ポッドキャスト、YouTubeチャンネル、変更履歴
- 新しい項目をX、LinkedIn、Blueskyへシェアします(希望ならDiscord、Slack、Telegramも)
- XとBlueskyには 短い版、LinkedInには要約付きの 長い版 を、1回のリクエストで書きます
- クラッシュしても、再起動しても、記録ファイルを失っても、二重投稿しません
ファイルは1つ。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. リクエストは1回。長さはネットワークごとに
Xは280文字、Blueskyは300文字、LinkedInは3,000文字です。リクエストを3回に分けず、長い既定と、制限の厳しいネットワーク向けの短い版を、platformOptions で1つの投稿にします。
{
"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は複製を公開せず、最初の投稿を返します。
そして最初の1回は、フィードにすでにあるものを 覚える だけです。過去の記事をまとめてアカウントへ流すことはありません。
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.failedWebhook
あなたの90行は、プロダクトだけです。フィードと、記録と、投稿の文面です。
本物のプロダクトにする
- 複数のフィード:
FEED_URLの一覧を回し、それぞれに独自のPLATFORMSを持たせます。 - よりよいタイミング: すぐに投稿せず、次のよい枠へ
scheduledAtを送ります(Bufferクローンのキューを参照)。 - 画像: 記事の
og:imageを取り、POST /v1/mediaでアップロードし、mediaIdsを付けます。 - AIのキャプション: タイトルを使い回さず、ネットワークごとにフックをClaudeに書かせます(Taplioクローンを参照)。
- クライアント: Hapi Proでは、クライアントごとにフィードを1本、そのクライアントのプロフィールに限定したAPIキーで走らせます。
誰が使えるか
ここで作ったクローンが投稿するのは、あなた のHapiワークスペースに連携したアカウントです。自社のブランド、チーム、運用しているクライアントです。誰でも自分のアカウントを連携できる公開SaaSにするには、API経由のアカウント連携が必要で、Hapiはまだそれを提供していません。
dlvr.itは権利者の商標です。このチュートリアルはdlvr.itとは無関係で、dlvr.itによる推奨もありません。「dlvr.itクローン」は、再現しているフィードからSNSへのワークフローを指します。