· 4分で読めます
週末でBufferクローンを作る(バックエンドはHapi)
Buffer風のキュースケジューラーを自作します。投稿枠、空いた枠へ自動で入るキュー、X・LinkedIn・Blueskyへの公開。Nodeは約120行。SNSの基盤はHapiが担います。
Bufferの核は キュー です。投稿枠を一度だけ決めて(「平日の9:00、12:30、17:00」)、あとは書くだけ。新しい投稿は、次の空いている枠に落ちます。
その考え方自体は、50行ほどです。スケジューラーで難しいのはその下です。各ネットワークのOAuth、トークン更新、メディアのルール、時刻どおりの公開、リトライ。そこをHapiがやります。プロダクトを作るのはあなたで、基盤は hapi.tools です。
最後には、次のものが手元にあります。
- X、LinkedIn、Bluesky(加えてDiscord、Slack、Telegram)へ一度に投稿するコンポーザー
- Add to queue。各投稿を、あなたのタイムゾーンの次の空き枠へ入れます。夏時間も含みます
- 取り消しボタン付きのキュー一覧
- チームやクライアントと共有するためのパスワード保護
ファイルは1つ。Node 22以降。依存パッケージはありません。
1. Hapiを用意する
- Hapiのトライアルを始める(15日間 $5)。
- Accounts でアカウントを連携します。XとLinkedInはワンクリック(OAuth)、Blueskyはアプリパスワードです。
- API keys でAPIキーを作り、手元に置いておきます。
2. キューのロジック
投稿スケジュールは、ただのデータです。
const SLOTS = { days: [1, 2, 3, 4, 5], times: ["09:00", "12:30", "17:00"] }; // Mon–Fri投稿を置くには2つ必要です。タイムゾーンにおけるこれからの枠と、そのうちすでに埋まっている枠です。埋まっている枠はHapiからそのまま取れます。予約済みの投稿には、すべて scheduledAt があるからです。クローン側にデータベースは要りません。
微妙なのは、「ニューヨークの09:00」を正確な瞬間にすることです。オフセットは夏時間で変わります。ライブラリなしで、Intl.DateTimeFormat がそれをやります。
function zonedTime(y, m, d, hh, mm) {
const guess = Date.UTC(y, m - 1, d, hh, mm);
const p = Object.fromEntries(
new Intl.DateTimeFormat("en-US", { timeZone: TZ, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })
.formatToParts(new Date(guess))
.map((x) => [x.type, x.value]),
);
const shownAs = Date.UTC(+p.year, p.month - 1, +p.day, +p.hour, +p.minute);
return new Date(guess - (shownAs - guess)); // shift by the zone's offset on that date
}3. アプリ全体
画面の文言はサンプルのまま英語です。Add to queue がキューへの追加、Post now が今すぐ投稿、Remove が取り消しです。
// buffer-clone.mjs: a Buffer-style queue scheduler built on Hapi.
// Run: HAPI_API_KEY=hapi_live_... APP_PASSWORD=pick-one node buffer-clone.mjs
import http from "node:http";
const API = process.env.HAPI_API_BASE ?? "https://hapi.tools/v1";
const { HAPI_API_KEY, APP_PASSWORD, PORT = 3000 } = process.env;
const TZ = process.env.QUEUE_TZ ?? "America/New_York";
const SLOTS = { days: [1, 2, 3, 4, 5], times: ["09:00", "12:30", "17:00"] }; // 1 = Monday … 7 = Sunday
async function hapi(path, options = {}) {
const res = await fetch(API + path, {
...options,
headers: { Authorization: `Bearer ${HAPI_API_KEY}`, "Content-Type": "application/json" },
});
if (res.status === 204) return null;
const body = await res.json();
if (!res.ok) throw new Error(body.error?.message ?? `Hapi returned ${res.status}`);
return body;
}
// "09:00 on 2026-11-20 in TZ" → the exact moment, daylight saving included.
function zonedTime(y, m, d, hh, mm) {
const guess = Date.UTC(y, m - 1, d, hh, mm);
const p = Object.fromEntries(
new Intl.DateTimeFormat("en-US", { timeZone: TZ, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })
.formatToParts(new Date(guess))
.map((x) => [x.type, x.value]),
);
const shownAs = Date.UTC(+p.year, p.month - 1, +p.day, +p.hour, +p.minute);
return new Date(guess - (shownAs - guess));
}
function* upcomingSlots(after) {
const [y, m, d] = new Intl.DateTimeFormat("en-CA", { timeZone: TZ }).format(after).split("-").map(Number);
for (let i = 0; i < 60; i++) {
const day = new Date(Date.UTC(y, m - 1, d + i));
if (!SLOTS.days.includes(day.getUTCDay() || 7)) continue;
for (const time of SLOTS.times) {
const [hh, mm] = time.split(":").map(Number);
const at = zonedTime(day.getUTCFullYear(), day.getUTCMonth() + 1, day.getUTCDate(), hh, mm);
if (at > after) yield at;
}
}
}
async function nextFreeSlot() {
const { data } = await hapi("/posts?status=scheduled&limit=100");
const taken = new Set(data.map((p) => new Date(p.scheduledAt).getTime()));
for (const at of upcomingSlots(new Date(Date.now() + 60_000))) {
if (!taken.has(at.getTime())) return at;
}
throw new Error("The queue is full for the next 60 days");
}
const esc = (s = "") => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
const when = (iso) => new Date(iso).toLocaleString("en-US", { timeZone: TZ, weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
async function page(message = "") {
const [{ data: accounts }, { data: queued }] = await Promise.all([hapi("/accounts"), hapi("/posts?status=scheduled&limit=100")]);
queued.sort((a, b) => new Date(a.scheduledAt) - new Date(b.scheduledAt));
return `<!doctype html><meta charset="utf-8"><title>Queue</title>
<style>body{font:16px system-ui;max-width:640px;margin:40px auto;padding:0 16px}textarea{width:100%;height:110px}li{margin:10px 0}small{color:#666}</style>
<h1>Queue</h1>${message ? `<p><b>${esc(message)}</b></p>` : ""}
<form method="post" action="/post">
<textarea name="content" required placeholder="What do you want to share?"></textarea>
<p>${accounts.map((a) => `<label><input type="checkbox" name="accountId" value="${esc(a.id)}" checked> ${esc(a.platform)} · ${esc(a.username ?? a.displayName)}</label>`).join("<br>")}</p>
<button name="action" value="queue">Add to queue</button> <button name="action" value="now">Post now</button>
</form>
<h2>Up next <small>(${esc(TZ)})</small></h2>
<ul>${queued.map((p) => `<li><small>${esc(when(p.scheduledAt))}</small><br>${esc(p.content)}
<form method="post" action="/cancel/${esc(p.id)}" style="display:inline"><button>Remove</button></form></li>`).join("") || "<li>Your queue is empty.</li>"}</ul>`;
}
function readBody(req) {
return new Promise((resolve) => {
let data = "";
req.on("data", (chunk) => (data += chunk));
req.on("end", () => resolve(data));
});
}
http
.createServer(async (req, res) => {
// One shared password keeps the app, and your API key, private.
const encoded = (req.headers.authorization ?? "").split(" ")[1] ?? "";
const password = Buffer.from(encoded, "base64").toString().split(":").slice(1).join(":");
if (!APP_PASSWORD || password !== APP_PASSWORD) {
res.writeHead(401, { "WWW-Authenticate": 'Basic realm="queue"' }).end("Password required");
return;
}
let message = "";
try {
if (req.method === "POST" && req.url === "/post") {
const form = new URLSearchParams(await readBody(req));
const scheduledAt = form.get("action") === "now" ? null : (await nextFreeSlot()).toISOString();
await hapi("/posts", {
method: "POST",
body: JSON.stringify({ content: form.get("content"), accountIds: form.getAll("accountId"), scheduledAt }),
});
message = scheduledAt ? `Queued for ${when(scheduledAt)} ✓` : "Posted ✓";
} else if (req.method === "POST" && req.url.startsWith("/cancel/")) {
await hapi(`/posts/${encodeURIComponent(req.url.slice("/cancel/".length))}`, { method: "DELETE" });
message = "Removed from the queue.";
}
} catch (err) {
message = `Error: ${err.message}`;
}
try {
const html = await page(message);
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }).end(html);
} catch (err) {
res.writeHead(502, { "Content-Type": "text/plain; charset=utf-8" }).end(`Couldn't reach Hapi: ${err.message}`);
}
})
.listen(PORT, () => console.log(`Your Buffer clone is on http://localhost:${PORT}`));起動します。
HAPI_API_KEY=hapi_live_... APP_PASSWORD=pick-one QUEUE_TZ=Europe/Berlin node buffer-clone.mjs投稿を3つ書き、Add to queue を3回押すと、次の3つの枠に並びます。1つ外せば、次に追加した投稿がその隙間を埋めます。
Hapiが代わりにやったこと
この120行に 入っていない ものを見てください。
- XとLinkedInのOAuthとトークン更新、Blueskyのセッション
- 文字数、メディアのルール、ネットワークごとの検証(長すぎる投稿は、キューに入る 前 にHapiが拒否します)
- 指定した分ぴったりに公開するジョブの実行
- リトライと、再送しても二重投稿しないこと(
Idempotency-Keyヘッダーを送ります) - 認証情報の暗号化保存
これが基盤です。「自分でBufferを作る週末」が何か月にもなるのは、たいていここが原因です。
本物のプロダクトにする
- ネットワークごとの文面:
platformOptionsで、LinkedInは長く、Blueskyは300文字にします。 - 画像:
POST /v1/mediaでアップロードし、投稿にmediaIdsを付けます。 - 失敗の通知:
post.failedのWebhookを登録し、キューに赤いバッジを出します。 - 下書きと承認:
"draft": trueで作成し、PATCH /v1/posts/{id}で承認します。 - AIで書く: HapiのMCPサーバー(
https://hapi.tools/mcp)経由でClaudeに下書きさせ、よいものをキューへ入れます。 - クライアント: Hapi Proでは、クライアントごとにプロフィールと、そのプロフィールに限定したキーを渡し、キューを1つずつ走らせます。
誰が使えるか
ここで作ったクローンは、あなた のHapiワークスペースで動きます。アカウントは、Hapiのダッシュボードで連携したものです。自社のブランド、チーム、クライアントのアカウントを運用する代理店には、これで足ります。知らない人が登録し、自分のアカウントを連携する公開SaaSにするには、API経由のアカウント連携が必要で、Hapiはまだそれを提供していません。
作ってみる。15日間 $5 → アカウント5件と投稿300件。クローンを作り、実際に回すには十分です。
Bufferは権利者の商標です。このチュートリアルはBufferとは無関係で、Bufferによる推奨もありません。「Bufferクローン」は、再現しているキュー予約のワークフローを指します。