なぜSlackをコンテンツ投稿の入口にしたか
CMSを構築・運用するコストは、小規模なメディアにとって見合わないことが多い。ContentfulやSanityは強力だが、導入・設定・権限管理・APIの維持……それだけでエンジニアの工数が消える。WordPressは論外で、プラグイン管理とセキュリティアップデートが終わらない。
そもそもこのサイトはAstro SSGで動いており、コンテンツはMDXファイルとしてsrc/content/journal/に置く設計になっている。コードとコンテンツを同じGitリポジトリで管理することで、ローカルプレビューもバージョン管理も全部gitで完結する。この設計は変えたくない。
問題は投稿体験だった。MDXを書いてGitHubにPRを出して……という手順は、エンジニアでない著者には難しいし、エンジニアでもちょっと面倒だ。Slackはすでに常に開いている。だったら、SlackにMDXファイルを投稿するだけで公開まで完結させればいい。CMSを構築するより、Botを一本書くほうがずっと速い。
Bot を導入してから、記事の公開頻度が上がった。 仕組みが変わると行動が変わるという、単純だが実感を持って言える話だ。
Before / After
| Bot導入前 | Bot導入後 | |
|---|---|---|
| 投稿手順 | MDX作成→GitHubにPR→マージ→デプロイ待ち | SlackにMDXを貼るだけ |
| 著者の操作時間 | 10〜15分 | 1分以内 |
| 公開まで | 手動操作が複数回必要 | 平均5〜8分で自動完了 |
| X告知 | 手動 | 自動(新規記事のみ) |
| ランニングコスト | CMS費用(数千〜数万円/月) | 月0円 |
アーキテクチャ全体像
flowchart TD
A["著者がMDXファイルをSlackに投稿"] --> B["Slack Bot\nNode.js / Bolt SDK"]
B --> C["フロントマター検証"]
C -->|"❌ エラー"| D["Slackにエラー通知\n著者が修正して再投稿"]
C -->|"✅ OK"| E["GitHub API\nOctokit"]
E --> F["コミット\n記事MDX + 画像・動画"]
F --> G["GitHub Actions\npnpm build + CF Pages デプロイ"]
G --> H["デプロイ完了を検知\nポーリング 15秒間隔"]
H --> I["SlackにURL通知"]
H --> J["X API v2\n自動ツイート(新規のみ)"]
J --> I
style G fill:#1a6b35,stroke:#27ae60,color:#fff
style I fill:#1a6b35,stroke:#27ae60,color:#fff
Botはファイルの受け取りとGitHubへの橋渡しだけを担い、ビルド・デプロイはGitHub Actionsに委ねている。Slackへの投稿から公開URLが届くまで、平均5〜8分。 著者がその間にやることは何もない。
Slack Bot の実装
Bolt SDK(Node.js)をSocket Modeで動かしている。Socket ModeはWebhookと違いサーバーにパブリックURLが不要で、pm2による常時起動と相性がいい。
ファイル受信とバリデーション
file_shareイベントでMDXファイルを受け取り、添付ファイルの一覧からMDXを特定する。
app.message(async ({ message: event, client, logger }) => {
const msg = event as unknown as Record<string, unknown>;
// ボット自身のメッセージは無視
if (msg['bot_id']) return;
// file_share 以外のsubtypeは無視
const subtype = msg['subtype'] as string | undefined;
if (subtype && subtype !== 'file_share') return;
const rawFiles = msg['files'] as Array<Record<string, unknown>> | undefined;
if (!rawFiles?.length) return;
// MDXファイルを探す
const mdFile = fileInfoList.find(
(f) => typeof f['name'] === 'string' && (f['name'] as string).match(/\.mdx?$/),
);
if (!mdFile) return;
});
Slackはfile_access: "check_file_info"フラグが立っているファイルを直接ダウンロードできないことがある。その場合はfiles.info APIで詳細を取得してからurl_private_downloadを使う。これに気づくまで30分ほどハマった。
const fileInfoList = await Promise.all(
rawFiles.map(async (f) => {
const fileId = f['id'] as string | undefined;
if (!fileId) return f;
try {
const info = await client.files.info({ file: fileId });
return (info.file as Record<string, unknown>) ?? f;
} catch {
return f;
}
}),
);
フロントマター検証で「投稿ミス」を防ぐ
MDXファイルのフロントマターに必須項目が揃っているか検証する。不足があればSlackにエラーを返し、著者が修正して再投稿する。この仕組みのおかげで、タイトルや公開日が抜けたまま記事が公開されるという事故がなくなった。

const validation = validateFrontmatter(rawMarkdown);
if (!validation.ok) {
const lines: string[] = ['❌ フロントマターに不足・不正な項目があります\n'];
if (validation.missingKeys.length > 0) {
lines.push('不足項目:');
for (const key of validation.missingKeys) lines.push(`- ${key}`);
}
await client.chat.postMessage({
channel: channelId,
thread_ts: threadTs,
text: lines.join('\n'),
});
return;
}
画像・動画ファイルの処理順序が重要
MDXと一緒に画像・動画をSlackに添付できる。ここで順序が重要になる。画像を先にGitHubへコミットしてから記事MDXをコミットしないと、AstroのSSGビルド時に画像パスが解決できない場合がある。
Botは必ずメディアファイルを先にコミットしてから、MDXをコミットする順序を守っている。
for (const mediaFile of mediaFiles) {
const mediaName = mediaFile['name'] as string;
const mediaUrl = mediaFile['url_private'] as string | undefined;
const res = await fetch(mediaUrl, {
headers: { Authorization: `Bearer ${config.slack.botToken}` },
});
const buf = Buffer.from(await res.arrayBuffer());
await github.commitImage(slug, mediaName, buf);
}
// 画像コミット完了後にMDXをコミット
await articleService.publishArticle(rawMarkdown, filename);
GitHub API でファイルをコミット
Octokitを使い、GitHub Contents APIでファイルを直接コミットする。新規記事と更新記事を同じコードで扱えるのがポイントで、既存ファイルのSHAを取得できれば上書き、なければ新規作成になる。
async commitFile(path: string, content: string, message: string): Promise<string> {
let sha: string | undefined;
try {
const { data } = await this.octokit.repos.getContent({
owner: this.owner,
repo: this.repo,
path,
});
if (!Array.isArray(data)) sha = data.sha;
} catch {
// 新規ファイルはSHAなしでOK
}
const { data } = await this.octokit.repos.createOrUpdateFileContents({
owner: this.owner,
repo: this.repo,
path,
message,
content: Buffer.from(content).toString('base64'),
sha,
});
return data.commit.sha;
}
コミットが作成されると、GitHub ActionsのPushトリガーが自動的に発火してビルドが始まる。
デプロイ完了をポーリングで検知する
コミット直後、BotはSlackに「⏳ デプロイ完了を待っています…」と通知したうえで、GitHub Actions APIを15秒間隔でポーリングする。最大10分待って、完了・失敗・タイムアウトのいずれかをSlackに報告する。
export async function waitForDeploy(
github: GitHubClient,
commitSha: string,
timeoutMs = 10 * 60 * 1000,
): Promise<DeployResult> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
await sleep(15_000);
const runs = await github.getWorkflowRuns(commitSha);
const run = runs[0];
if (!run) continue;
if (run.status === 'completed') {
return {
status: run.conclusion === 'success' ? 'success' : 'failure',
runUrl: run.html_url,
};
}
}
return { status: 'timeout' };
}
WebhookでGitHub Actionsの完了を受け取る方法もある。ただしそのためにはサーバーにパブリックURLが必要になり、Bot全体の構成が複雑になる。今の規模ではポーリングで十分だと判断した。
X(Twitter)自動投稿——上書き更新では投稿しない
新規記事の公開が確認できたときだけXにツイートする。同じ記事を修正・再投稿した場合(上書き)はツイートしない。result.isUpdateフラグで判定している。
export async function postArticleToX(
title: string,
slug: string,
tags: string[] = [],
): Promise<string | null> {
const url = `https://quratedlab.com/journal/${slug}?ref=twitter`;
// タグからハッシュタグを生成(最大3件)
const articleHashtags = tags
.map(t => '#' + t.replace(/\s+/g, ''))
.slice(0, 3)
.join(' ');
const hashtags = `#QuratedLab${articleHashtags ? ' ' + articleHashtags : ''}`;
const text = `📝 新着記事を公開しました!\n\n${title}\n\n${url}\n\n${hashtags}`;
try {
const tweet = await getClient().v2.tweet(text);
return `https://x.com/QuratedLab/status/${tweet.data.id}`;
} catch (err) {
console.error('X投稿エラー:', err);
return null; // 失敗してもSlack通知は届ける
}
}
?ref=twitterを付けることでGA4でX経由の流入を追跡できる。ツイートが成功するとSlackの完了通知にもXのURLが含まれる。
著者が受け取る最終通知
投稿から平均5〜8分後、著者のSlackスレッドにこんな通知が届く。slug・公開URL・XポストのURLまで一括で確認できる。

著者がやることはMDXファイルを書いてSlackに貼るだけ。それ以降は何もしなくていい。
実装でハマった3つのポイント
1. BOM付きUTF-8でフロントマターが壊れる
Windowsで作成したファイルはBOM(\uFEFF)が先頭に付くことがある。フロントマターのパースに失敗するため、取得直後に除去する処理を入れた。
rawMarkdown = (await res.text()).replace(/^\uFEFF/, '');
2. CRLF改行コードで正規表現がマッチしない
同じくWindowsファイルはCRLFになっていることがある。行単位の正規表現がマッチしなくなるため、正規化する。
const normalized = rawMarkdown.replace(/\r\n/g, '\n');
3. CIでTypeCheckが落ちる
twitter-api-v2をローカルでインストールしたが、package.jsonへの追加を忘れてコミットした。CI環境ではインストールされずTypeCheckが落ちた。ローカルでは動いているのにCIで落ちるパターンは原因特定に時間がかかる。
外部パッケージを追加したら必ずpackage.jsonとpnpm-lock.yamlを一緒にコミットすること。pnpm addではなく、直接package.jsonを編集してしまうとpnpm-lock.yamlが更新されないので要注意。
技術スタックまとめ
| 役割 | 技術 | 選んだ理由 |
|---|---|---|
| Bot ランタイム | Node.js + Bolt SDK | Socket ModeでパブリックURL不要 |
| プロセス管理 | pm2 | クラッシュ時の自動再起動 |
| Git操作 | Octokit(GitHub REST API) | 公式SDK、型定義が充実 |
| ビルド | Astro SSG + pnpm | 既存サイトの構成をそのまま活用 |
| デプロイ | GitHub Actions + Cloudflare Pages | Pushトリガーで自動化 |
| X投稿 | twitter-api-v2(OAuth 1.0a) | v2 APIに対応した実績あるライブラリ |
運用して気づいたこと
このBotを作って最初に気づいたのは、「ツールが変わると行動が変わる」ということだ。CMSの管理画面があった頃は、記事を書き終えた後に「後で投稿しよう」とタブを閉じることがあった。今はSlackに貼るだけなので、書き終わった瞬間に公開まで完了する。
もう一つは、バリデーションの重要性だ。フロントマターの検証を入れたことで、公開後に「タイトルが抜けていた」「日付が間違っていた」といった修正コミットがなくなった。入口でエラーを返してくれるので、著者も安心して投稿できる。
疎結合な設計にしたことで、将来の拡張も容易だ。ZennやnoteへのクロスポストもDeployResult通知の後に処理を追加するだけで対応できる。
この記事で紹介したBotはQuratedの実際の運用で使っており、この記事自体もこのBotで公開している。