社内ナレッジを“自然に外部発信”へつなげる仕組みをAIで作ってみる話

社内ナレッジを“自然に外部発信”へつなげる仕組みをAIで作ってみる話

この記事は Movable Type Advent Calendar 2025 14日目の記事です。

社内での情報共有、どうせ書くならなら外部公開してみたら?

私たちの会社では、Slack でスタッフ間での情報公開はしていますが、外部発信までは行っていない状況でした。
そこで今回、Notion に書くだけで、Movable Type に記事が公開される仕組みを構築し、社内ナレッジ → 外部発信 をやってみようと思い立ちました。
この記事では、その仕組みと考え方を紹介します。


全体構成:今回作った仕組み

システム構成

  1. Notion データベースに記事を作成
  2. ステータスを「ready」に変更
  3. GitHub Actions が定期的にチェック
  4. Movable Type に自動投稿
  5. Notion のステータスを「published」に更新

ポイント

  • Zapier / Make 等の有料ツールは使っていません
  • GitHub Actions を「無料の中継サーバ」として利用
  • MT Data API を正規ルートで使用
  • 双方向更新:MT投稿後にNotionのステータスも自動更新
  • 主にClaude Codeを利用して実装。記事はnotion AIで作成も

なぜ Notion を起点にしたのか?

Slack はフロー型、Notion はストック型。
notionは情報管理からプロジェクト・タスク管理まで社内で導入済み。

notion AIも導入しているため、たとえメモだけのきっかけでも、それを拡げてAI で記事に出来る時代です。
公開記事にするフローも含めてAI活用出来るのでは?と考えました。

Slack Notion
気軽に投稿できる 構造化できる
流れる 書き直せる
検索しづらい 公開向き
  1. Slack に投げられた情報を
  2. 「記事にできそうなものだけ」Notion に転記
  3. 整ったら公開フラグを立てる

という運用にしました。

 


Notion 側の設計

DB は 1つだけ。必須項目は最小限に。

データベースのプロパティ設計

プロパティ名 用途
Title Title 記事タイトル
Category Select MTのカテゴリ名と一致させる
Author Select MT投稿者名と一致させる
PublishDate Date 投稿日時
Status Select draft / ready / published / error
MTEntryId Number MT記事ID(同期済みフラグ)
MTSyncedAt Date 最終同期日時
ErrorMessage Rich Text エラー時の詳細

Status の運用ルール

  • draft → 下書き中(同期対象外)
  • ready → 公開対象!(同期スクリプトが検知)
  • published → 投稿完了(自動で変更される)
  • error → 投稿失敗(ErrorMessageを確認)

「ちゃんと書こう」と思わせないことが継続のコツです。
書く人は ready に変えるだけ。あとは自動。


Notion API:記事の取得

status = ready の記事だけを取得:

// src/notion/client.ts 
import { Client } from '@notionhq/client';
const notion = new Client({ auth: process.env.NOTION_API_KEY });
export async function getReadyArticles() { 
const response = await notion.databases.query({ 
  database_id: process.env.NOTION_DATABASE_ID!, 
  filter: { property: 'Status', select: { equals: 'ready' } }, 
  sorts: [ { property: 'PublishDate', direction: 'ascending' } ] });
 return response.results; }

本文の取得とMarkdown変換

Notionのページ本文はブロック単位で取得し、Markdownに変換します:

// src/notion/markdown.ts 
import { NotionToMarkdown } from 'notion-to-md'; import { marked } from 'marked';

const n2m = new NotionToMarkdown({ notionClient: notion });
export async function getPageContentAsHtml(pageId: string): Promise<string> { 
  // Notionブロック → Markdown 
  const mdBlocks = await n2m.pageToMarkdown(pageId); 
  const markdown = n2m.toMarkdownString(mdBlocks).parent;
 // Markdown → HTML
  const html = await marked(markdown);
  return html; }

変換の流れ: Notion ブロック → notion-to-md → Markdown → marked → HTML → MT 本文

▲ Notionで普通に記事を書くだけ。見出し、リスト、コードブロックなどがそのまま変換される


Movable Type Data API での投稿

Movable Type の Data API を使用しています。
せっかくなのでMT9を。

認証フロー

// src/movabletype/client.ts 
interface AuthResponse { accessToken: string; sessionId: string; }
export async function authenticate(): Promise<AuthResponse> { 
  const endpoint = ${MT_BASE_URL}${MT_CGI_PATH}/mt-data-api.cgi/v5/authentication;   
 const response = await fetch(endpoint, {
   method: 'POST',
   headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
   body: new URLSearchParams({
     username: process.env.MT_USERNAME!,
     password: process.env.MT_PASSWORD!, // Webサービスパスワード
     clientId: 'notion-mt-sync'
    })
  });

  const data = await response.json();
  return {
    accessToken: data.accessToken,
    sessionId: data.sessionId
  }; }

重要: MTのユーザー設定から「Webサービスパスワード」を発行。通常のログインパスワードとは別

記事の投稿

 

export async function createEntry( accessToken: string, entry: { 
    title: string; body: string; categoryId: number; authorId: number; date: string; } ): Promise<{ id: number }> { 
    const endpoint = ${MT_BASE_URL}${MT_CGI_PATH}/mt-data-api.cgi/v5/sites/${MT_BLOG_ID}/entries;   
  const response = await fetch(endpoint, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-MT-Authorization': `MTAuth accessToken=${accessToken}`
    },
    body: JSON.stringify({
      entry: {
        title: entry.title,
        body: entry.body,
        status: 'Publish',
        categories: [{ id: entry.categoryId }],
        author: { id: entry.authorId },
        date: entry.date
      }
    })
  });

  const data = await response.json();
  return { id: data.id }; }

 

 


GitHub Actions ワークフロー

指定した時間、間隔で実行する。

cron: '0 0 * * *' # 09:00 JST
cron: '0 12 * * *' # 21:00 JST workflow_dispatch: # 手動実行も可能
  steps:
    - uses: actions/checkout@v4

    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        node-version: '20'
        cache: 'npm'

    - name: Install dependencies
      run: npm ci

    - name: Run sync
      env:
        NOTION_API_KEY: ${{ secrets.NOTION_API_KEY }}
        NOTION_DATABASE_ID: ${{ secrets.NOTION_DATABASE_ID }}
        MT_BASE_URL: ${{ secrets.MT_BASE_URL }}
        MT_CGI_PATH: ${{ secrets.MT_CGI_PATH }}
        MT_BLOG_ID: ${{ secrets.MT_BLOG_ID }}
        MT_USERNAME: ${{ secrets.MT_USERNAME }}
        MT_PASSWORD: ${{ secrets.MT_PASSWORD }}
      run: npm run sync

GitHub Secrets に登録する値

Secret名 値の例
NOTION_API_KEY secret_xxxxx…
NOTION_DATABASE_ID xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
MT_BASE_URL https://example.com
MT_CGI_PATH /cgi-bin/mt
MT_BLOG_ID 1
MT_USERNAME admin
MT_PASSWORD Webサービスパスワード

 

同期処理のメインフロー

// src/sync/processor.ts
export async function syncArticles(): Promise<void> { 
  // 1. MT認証 
  const { accessToken } = await authenticate(); logger.info('MT認証成功');

  // 2. カテゴリ・ユーザーのマッピング取得
  const categoryMap = await getCategoryMap(accessToken);
  const userMap = await getUserMap(accessToken);

  // 3. Notionから ready 記事を取得
  const articles = await getReadyArticles();
  logger.info(`同期対象: ${articles.length}件`);

  // 4. 各記事を処理
  for (const article of articles) {
    try {
      // プロパティ取得
      const title = getTitle(article);
      const category = getSelectValue(article, 'Category');
      const author = getSelectValue(article, 'Author');
      // カテゴリID解決
      const categoryId = categoryMap.get(category);
      if (!categoryId) {
        throw new Error(`カテゴリ "${category}" がMTに存在しません`);
      }
      // 本文取得・変換
      const body = await getPageContentAsHtml(article.id);
      // MT投稿
      const { id: entryId } = await createEntry(accessToken, {
        title,
        body,
        categoryId,
        authorId: userMap.get(author)!,
        date: new Date().toISOString()
      });
      // Notionステータス更新
      await updateNotionStatus(article.id, 'published', entryId);
      logger.info(`✅ 投稿成功: ${title} (MT ID: ${entryId})`);
    } catch (error) {
      // エラー時はNotionに記録
      await updateNotionStatus(article.id, 'error', null, error.message);
      logger.error(`❌ 投稿失敗: ${error.message}`);
    }
  }
}

エラーハンドリングの工夫

リトライ機能

ネットワークエラーに備えて、指数バックオフでリトライ:

async function fetchWithRetry( url: string, options: RequestInit, maxRetries = 3 ): Promise<Response> {
  for (let i = 0; i < maxRetries; i++) {
  try { const response = await fetch(url, options);
  if (response.ok) return response;
  // 4xx エラーはリトライしない
 if (response.status >= 400 && response.status < 500) {
    throw new Error(`Client error: ${response.status}`);
 }
} catch (error) {
  if (i === maxRetries - 1) throw error;
    // 指数バックオフ: 1秒 → 2秒 → 4秒
    const delay = Math.pow(2, i) * 1000;
      await new Promise(r => setTimeout(r, delay));
    }
  }
  throw new Error('Max retries exceeded'); }

Notion へのエラー記録

失敗した記事は status = error にして、原因を ErrorMessage に記録:

await notion.pages.update({ page_id: pageId, properties: { Status: { select: { name: 'error' } }, ErrorMessage: { rich_text: [{ text: { content: errorMessage } }] } } });

▲ エラー時はNotionのErrorMessageに原因が記録される。修正して再度 ready にすればOK

これで、Notion上で「どの記事が失敗したか」「なぜ失敗したか」が一目でわかります。


⚠️ セキュリティ上の注意

  • Notion API Key や MT パスワードは絶対にコードに直書きしない
  • GitHub Secrets は Organization レベルで管理することを推奨
  • Webサービスパスワードは定期的に更新する
  • GitHub Actions のログには機密情報が出力されないよう注意

今後の拡張アイデア

機能 実装方法
画像の自動転送 Notion画像URL → MTにアップロード → 本文のURL差し替え
記事の更新対応 MTEntryId がある記事は PUT /entries/{id}
リアルタイム同期 Notion Webhook → GitHub Actions workflow_dispatch
複数ブログ対応 Notionに BlogId プロパティ追加

 


おわりに

社内ナレッジは、本来とても価値のある資産です。
それが Slack の奥底に沈んでしまうのは、少しもったいない。
今回の仕組みは、Notion を書ける人が、そのまま発信者になるための「最短距離」だと思っています。
現在は実際の運用前ですが、上手くAIを使いつつ、外部発信のために分かりやすくまとめることは重要な学習だと思いました。

Movable Type を使っている方、Notion で情報整理している方の何かヒントになれば幸いです。

システム開発・サーバー管理のご相談なら12Gridへ

システムでお困りのことはございませんか?
プランニングから構築までの一括サポートまで、
お客さまのご要望にあわせて柔軟に請け負っています。

STAFF BLOGカテゴリの最新記事