この記事は Movable Type Advent Calendar 2025 14日目の記事です。
社内での情報共有、どうせ書くならなら外部公開してみたら?
私たちの会社では、Slack でスタッフ間での情報公開はしていますが、外部発信までは行っていない状況でした。
そこで今回、Notion に書くだけで、Movable Type に記事が公開される仕組みを構築し、社内ナレッジ → 外部発信 をやってみようと思い立ちました。
この記事では、その仕組みと考え方を紹介します。
全体構成:今回作った仕組み
システム構成
- Notion データベースに記事を作成
- ステータスを「ready」に変更
- GitHub Actions が定期的にチェック
- Movable Type に自動投稿
- 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 |
|---|---|
| 気軽に投稿できる | 構造化できる |
| 流れる | 書き直せる |
| 検索しづらい | 公開向き |
- Slack に投げられた情報を
- 「記事にできそうなものだけ」Notion に転記
- 整ったら公開フラグを立てる
という運用にしました。

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 で情報整理している方の何かヒントになれば幸いです。