技術記事 2026年4月19日 読了 約11分

Slack Bot で記事公開を全自動化した——CMS不要・月0円・投稿から5分で公開される仕組みと実装全公開

「記事を書いたのに投稿するのが面倒で後回しにした」——そんな経験がチーム内で続いたとき、私はCMSの管理画面をやめることにした。代わりに作ったのは、SlackにMDXファイルを投稿するだけで記事公開・X投稿まで完結するBotだ。構築にかかった時間は約1日、ランニングコストは月0円。この記事では、設計の判断理由から実装の詳細、運用して気づいたことまでを書く。

SH
鈴木 保乃香
DesignLink

なぜ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;
});
INFO

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にエラーを返し、著者が修正して再投稿する。この仕組みのおかげで、タイトルや公開日が抜けたまま記事が公開されるという事故がなくなった。

バリデーションエラー時のSlack通知。author名が登録外の場合、即座にエラーと修正方法が返ってくる

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' };
}
INFO

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まで一括で確認できる。

公開完了時の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で落ちるパターンは原因特定に時間がかかる。

WARN

外部パッケージを追加したら必ずpackage.jsonpnpm-lock.yamlを一緒にコミットすること。pnpm addではなく、直接package.jsonを編集してしまうとpnpm-lock.yamlが更新されないので要注意。

技術スタックまとめ

役割技術選んだ理由
Bot ランタイムNode.js + Bolt SDKSocket ModeでパブリックURL不要
プロセス管理pm2クラッシュ時の自動再起動
Git操作Octokit(GitHub REST API)公式SDK、型定義が充実
ビルドAstro SSG + pnpm既存サイトの構成をそのまま活用
デプロイGitHub Actions + Cloudflare PagesPushトリガーで自動化
X投稿twitter-api-v2(OAuth 1.0a)v2 APIに対応した実績あるライブラリ

運用して気づいたこと

このBotを作って最初に気づいたのは、「ツールが変わると行動が変わる」ということだ。CMSの管理画面があった頃は、記事を書き終えた後に「後で投稿しよう」とタブを閉じることがあった。今はSlackに貼るだけなので、書き終わった瞬間に公開まで完了する。

もう一つは、バリデーションの重要性だ。フロントマターの検証を入れたことで、公開後に「タイトルが抜けていた」「日付が間違っていた」といった修正コミットがなくなった。入口でエラーを返してくれるので、著者も安心して投稿できる。

疎結合な設計にしたことで、将来の拡張も容易だ。ZennやnoteへのクロスポストもDeployResult通知の後に処理を追加するだけで対応できる。


この記事で紹介したBotはQuratedの実際の運用で使っており、この記事自体もこのBotで公開している。


Slack × GitHub で業務を自動化したい。まず話を聞かせてください。

AI開発の相談、品質保証の仕組みづくり、要件定義のサポートなど。 まずは30分の無料相談で、方向性を一緒に整理しましょう。