ブログ

· 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を用意する

  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. リクエストは1回。長さはネットワークごとに

Xは280文字、Blueskyは300文字、LinkedInは3,000文字です。リクエストを3回に分けず、長い既定と、制限の厳しいネットワーク向けの短い版を、platformOptions で1つの投稿にします。

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は複製を公開せず、最初の投稿を返します。

そして最初の1回は、フィードにすでにあるものを 覚える だけです。過去の記事をまとめてアカウントへ流すことはありません。

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 Webhook

あなたの90行は、プロダクトだけです。フィードと、記録と、投稿の文面です。

本物のプロダクトにする

  • 複数のフィード: FEED_URL の一覧を回し、それぞれに独自の PLATFORMS を持たせます。
  • よりよいタイミング: すぐに投稿せず、次のよい枠へ scheduledAt を送ります(Bufferクローンのキューを参照)。
  • 画像: 記事の og:image を取り、POST /v1/media でアップロードし、mediaIds を付けます。
  • AIのキャプション: タイトルを使い回さず、ネットワークごとにフックをClaudeに書かせます(Taplioクローンを参照)。
  • クライアント: Hapi Proでは、クライアントごとにフィードを1本、そのクライアントのプロフィールに限定したAPIキーで走らせます。

誰が使えるか

ここで作ったクローンが投稿するのは、あなた のHapiワークスペースに連携したアカウントです。自社のブランド、チーム、運用しているクライアントです。誰でも自分のアカウントを連携できる公開SaaSにするには、API経由のアカウント連携が必要で、Hapiはまだそれを提供していません。

作ってみる。15日間 $5 →

dlvr.itは権利者の商標です。このチュートリアルはdlvr.itとは無関係で、dlvr.itによる推奨もありません。「dlvr.itクローン」は、再現しているフィードからSNSへのワークフローを指します。