3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GitHub と Notion の同期システム実装ガイド

Last updated at Posted at 2025-09-09

1. はじめに - システムの概要説明

技術ドキュメントは開発者にとって重要な資産ですが、「GitHub のマークダウンファイルで管理したい」という開発者の要望と「Notion で閲覧・編集したい」という非エンジニアの要望を両立させることが難しい場合があります。

本記事では、GitHub リポジトリのマークダウンファイルを Notion データベースと自動同期するシステムの実装方法を紹介します。このシステムを導入することで、以下のメリットが得られます:

  • 開発者は Git ワークフローの中でドキュメントを管理できる
  • 非開発者は Notion の使いやすいインターフェースでドキュメントを閲覧できる
  • 画像やPDFなどの添付ファイルも自動的に同期される
    • 以前は画像のアップロードはNotionAPIでできなかったができる様になった様子、検証後本ドキュメントを更新予定
  • マスターデータは常に GitHub 側にあり、一元管理が可能

2. システム構成図

このシステムでは次のような流れでドキュメントを同期します:

  1. 開発者が GitHub リポジトリのマークダウンファイルを更新してプッシュ
  2. GitHub Actions が変更を検出して同期ワークフローを起動
  3. Node.js スクリプトが変更内容を解析
  4. 画像やPDFファイルは AWS S3 にアップロード
  5. マークダウンファイルは Notion のブロック形式に変換
  6. Notion API を使用してデータベースにページを作成または更新

3. GitHub Actions ワークフローの設定

同期のトリガーとなる GitHub Actions ワークフローを .github/workflows/notion-sync.yml として設定します:

name: Sync Markdown Documents to Notion

on:
  push:
    branches: [ main ]
    paths:
      - 'docs/**/*.md'   # Markdownファイル
      - 'images/**'      # 画像ファイル
      - 'pdfs/**'        # PDFファイル

jobs:
  sync-to-notion:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 0  # 全履歴を取得して変更ファイルを特定

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '18'
          cache: 'npm'
          cache-dependency-path: 'notion-sync/package-lock.json'
      
      - name: Install dependencies
        run: |
          cd notion-sync
          npm ci
      
      - name: Get changed files
        id: changed-files
        run: |
          # 前のコミットと比較して変更されたファイルを検出
          if git rev-parse --verify HEAD^ >/dev/null 2>&1; then
            # 変更状態を取得
            git diff --name-status HEAD^ HEAD > /tmp/git_changes.txt
            
            # Markdownファイルの変更・追加・削除を検出
            CHANGED_MD_FILES=$(grep -E "^A|^M|^R" /tmp/git_changes.txt | awk '{if ($1 == "A" || $1 == "M") print $2; else if (substr($1, 0, 1) == "R") print $3}' | grep -E "^docs/.*\.md$" || echo "")
            DELETED_MD_FILES=$(grep -E "^D|^R" /tmp/git_changes.txt | awk '{if ($1 == "D") print $2; else if (substr($1, 0, 1) == "R") print $2}' | grep -E "^docs/.*\.md$" || echo "")
            
            # 画像ファイルの変更・追加・削除を検出
            CHANGED_IMG_FILES=$(grep -E "^A|^M|^R" /tmp/git_changes.txt | awk '{if ($1 == "A" || $1 == "M") print $2; else if (substr($1, 0, 1) == "R") print $3}' | grep -E "^images/.*\.(png|jpg|jpeg|gif|svg|webp)$" || echo "")
            DELETED_IMG_FILES=$(grep -E "^D|^R" /tmp/git_changes.txt | awk '{if ($1 == "D") print $2; else if (substr($1, 0, 1) == "R") print $2}' | grep -E "^images/.*\.(png|jpg|jpeg|gif|svg|webp)$" || echo "")
            
            # PDFファイルの変更・追加・削除を検出
            CHANGED_PDF_FILES=$(grep -E "^A|^M|^R" /tmp/git_changes.txt | awk '{if ($1 == "A" || $1 == "M") print $2; else if (substr($1, 0, 1) == "R") print $3}' | grep -E "^pdfs/.*\.pdf$" || echo "")
            DELETED_PDF_FILES=$(grep -E "^D|^R" /tmp/git_changes.txt | awk '{if ($1 == "D") print $2; else if (substr($1, 0, 1) == "R") print $2}' | grep -E "^pdfs/.*\.pdf$" || echo "")
            
            # 結果を環境変数に設定
            echo "md_files<<EOF" >> $GITHUB_OUTPUT
            echo "$CHANGED_MD_FILES" >> $GITHUB_OUTPUT
            echo "EOF" >> $GITHUB_OUTPUT
      
            echo "deleted_md_files<<EOF" >> $GITHUB_OUTPUT
            echo "$DELETED_MD_FILES" >> $GITHUB_OUTPUT
            echo "EOF" >> $GITHUB_OUTPUT
            
            # 他のファイル情報も同様に設定
            # (省略)
          else
            # 最初のコミットの場合、すべてのファイルを「追加」として扱う
            # (省略)
          fi
      
      - name: Sync to Notion
        if: steps.check-files.outputs.files_to_process == 'true'
        env:
          # Notion API関連の環境変数
          NOTION_API_KEY: ${{ secrets.NOTION_API_KEY }}
          NOTION_DATABASE_ID: ${{ secrets.NOTION_DATABASE_ID }}
          GITHUB_REPOSITORY_URL: https://github.com/${{ github.repository }}
          
          # AWS S3関連の環境変数(画像・PDF保存用)
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          AWS_REGION: ${{ secrets.AWS_REGION || 'ap-northeast-1' }}
          AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }}
          AWS_S3_CUSTOM_DOMAIN: ${{ secrets.AWS_S3_CUSTOM_DOMAIN }}
        run: |
          cd notion-sync
          
          # Node.jsスクリプトに変更ファイル情報を渡して実行
          node sync-to-notion.js \
            --md-files ${{ steps.changed-files.outputs.md_files }} \
            --deleted-md-files ${{ steps.changed-files.outputs.deleted_md_files }} \
            --image-files ${{ steps.changed-files.outputs.image_files }} \
            --deleted-image-files ${{ steps.changed-files.outputs.deleted_image_files }} \
            --pdf-files ${{ steps.changed-files.outputs.pdf_files }} \
            --deleted-pdf-files ${{ steps.changed-files.outputs.deleted_pdf_files }}

主要なポイント

  • fetch-depth: 0 で完全な Git 履歴を取得し、変更ファイルの特定を容易にしています
  • 追加・変更・削除・リネームなど、様々なファイル操作をトラッキングしています
  • 初回実行時(最初のコミット)とそれ以降で処理を分けています
  • ファイル変更がない場合は同期処理をスキップする最適化を行っています

4. Notion API との連携方法

Notion API を使用してデータベースにページを作成・更新する核となる Node.js スクリプトについて解説します。

必要なパッケージのインストール

npm install @notionhq/client front-matter @tryfabric/martian

sync-to-notion.js(概要)

const { Client } = require('@notionhq/client');
const fs = require('fs');
const path = require('path');
const matter = require('front-matter');
const { markdownToBlocks } = require('@tryfabric/martian');
const s3Integration = require('./s3-integration');

// Notion APIの設定
const notion = new Client({
  auth: process.env.NOTION_API_KEY,
});
const databaseId = process.env.NOTION_DATABASE_ID;

// メイン処理
async function main() {
  try {
    // S3クライアントの初期化
    s3Integration.initClient();
    
    // 1. 画像とPDFをS3にアップロード
    await processMediaFiles(imageFiles, pdfFiles);
    
    // 2. Markdownファイルを処理してNotionに同期
    await processMarkdownFiles(mdFiles);
    
    // 3. 削除されたファイルを処理
    await processDeletedFiles(deletedMdFiles, deletedImageFiles, deletedPdfFiles);
    
    console.log('同期処理が完了しました');
  } catch (error) {
    console.error('同期処理中にエラーが発生しました:', error);
    process.exit(1);
  }
}

// Markdownファイル処理関数
async function processMarkdownFiles(files) {
  for (const file of files) {
    try {
      // ファイル読み込み
      const content = fs.readFileSync(file, 'utf8');
      
      // frontmatterからメタデータ取得
      const { attributes, body } = matter(content);
      
      // Notionデータベース内の既存ページを検索
      const existingPage = await findNotionPage(file);
      
      // 画像参照のURLをS3/CloudFrontのURLに変換
      const processedBody = replaceImagePathsWithUrls(body);
      
      // MarkdownをNotionブロックに変換
      const blocks = markdownToBlocks(processedBody);
      
      if (existingPage) {
        // 既存ページの更新
        await updateNotionPage(existingPage.id, attributes, blocks);
      } else {
        // 新規ページ作成
        await createNotionPage(attributes, blocks, file);
      }
    } catch (error) {
      console.error(`ファイル ${file} の処理中にエラーが発生:`, error);
    }
  }
}

Notionページの作成と更新

// Notionページ作成関数
async function createNotionPage(metadata, blocks, filePath) {
  // ページのプロパティを設定
  const properties = {
    'Title': {
      title: [{ text: { content: metadata.title || path.basename(filePath) } }]
    },
    'Category': {
      select: { name: metadata.category || 'その他' }
    },
    // その他必要なプロパティ
  };

  // APIリクエスト制限対策のための再試行機能
  const response = await retryOperation(() => 
    notion.pages.create({
      parent: { database_id: databaseId },
      properties: properties,
      children: blocks.slice(0, 100) // 最初の100ブロックのみ初期登録
    })
  );

  // 100ブロック以上ある場合は追加ブロックを分割して登録
  if (blocks.length > 100) {
    await appendBlocksInChunks(response.id, blocks.slice(100));
  }
  
  return response;
}

// Notionページ更新関数
async function updateNotionPage(pageId, metadata, blocks) {
  // ページのプロパティを更新
  await notion.pages.update({
    page_id: pageId,
    properties: {
      'Title': {
        title: [{ text: { content: metadata.title || '無題' } }]
      },
      // その他必要なプロパティ
    }
  });
  
  // 既存のブロックを削除
  const existingBlocks = await notion.blocks.children.list({ block_id: pageId });
  for (const block of existingBlocks.results) {
    await notion.blocks.delete({ block_id: block.id });
  }
  
  // 新しいブロックを分割して追加
  await appendBlocksInChunks(pageId, blocks);
}

// API制限を考慮したブロック追加関数
async function appendBlocksInChunks(blockId, blocks) {
  const MAX_BLOCKS_PER_REQUEST = 100;
  
  for (let i = 0; i < blocks.length; i += MAX_BLOCKS_PER_REQUEST) {
    const chunk = blocks.slice(i, i + MAX_BLOCKS_PER_REQUEST);
    await new Promise(resolve => setTimeout(resolve, 300)); // API負荷軽減
    await notion.blocks.children.append({
      block_id: blockId,
      children: chunk
    });
  }
}

API制限とエラーハンドリング

// APIリクエストの再試行関数
async function retryOperation(operation, maxRetries = 3, delay = 1000) {
  let lastError;
  
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await operation();
    } catch (error) {
      console.warn(`操作失敗 (試行 ${attempt}/${maxRetries}): ${error.message}`);
      lastError = error;
      
      // 429エラー(レート制限)の場合は長めに待機
      if (error.status === 429) {
        const waitTime = (error.headers?.['retry-after'] * 1000) || delay * 3;
        await new Promise(resolve => setTimeout(resolve, waitTime));
      } else {
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }
  }
  
  throw lastError;
}

5. S3を使った画像・PDF管理

マークダウン内で参照される画像やPDF添付ファイルをAWS S3で管理する方法について解説します。
※画像はWeb上に公開可能でないとダメなので、機密情報を画像に含めないよう注意してください。
(github のレビューで確認する等)

s3-integration.js

const { S3Client, PutObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
const fs = require('fs');
const path = require('path');

let s3Client = null;

// S3クライアント初期化
function initClient() {
  s3Client = new S3Client({
    region: process.env.AWS_REGION || 'ap-northeast-1',
    credentials: {
      accessKeyId: process.env.AWS_ACCESS_KEY_ID,
      secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
    }
  });
  return s3Client;
}

// ファイルをS3にアップロード
async function uploadFile(filePath, contentType) {
  try {
    const fileContent = fs.readFileSync(filePath);
    const key = filePath.replace(/^(images|pdfs)\//, ''); // ルートパス除去
    
    await s3Client.send(new PutObjectCommand({
      Bucket: process.env.AWS_S3_BUCKET,
      Key: key,
      Body: fileContent,
      ContentType: contentType,
      ACL: 'public-read', // 公開設定
    }));
    
    // CloudFrontドメインがある場合はそれを使用、なければS3のURLを返す
    const domain = process.env.AWS_S3_CUSTOM_DOMAIN || 
      `${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com`;
    return `https://${domain}/${key}`;
  } catch (error) {
    console.error(`${filePath} のアップロード中にエラー:`, error);
    throw error;
  }
}

// マークダウン内の画像パスをS3 URLに置換
function replaceImagePathsWithUrls(markdownContent) {
  // 画像参照の正規表現パターン
  const imageRegex = /!\[(.+?)\]\(\/images\/(.+?)\)/g;
  
  // S3 URLに置換
  return markdownContent.replace(imageRegex, (match, alt, imagePath) => {
    const domain = process.env.AWS_S3_CUSTOM_DOMAIN || 
      `${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com`;
    return `![${alt}](https://${domain}/${imagePath})`;
  });
}

// 不要になったファイルの削除
async function deleteFile(filePath) {
  try {
    const key = filePath.replace(/^(images|pdfs)\//, '');
    
    await s3Client.send(new DeleteObjectCommand({
      Bucket: process.env.AWS_S3_BUCKET,
      Key: key,
    }));
    
    console.log(`${key} を削除しました`);
  } catch (error) {
    console.error(`${filePath} の削除中にエラー:`, error);
  }
}

6. 実装のポイントと注意点

Frontmatter の活用

マークダウンファイルの Frontmatter(先頭のYAML部分)を使ってメタデータを定義し、Notionのデータベースプロパティにマッピングします。

---
title: "ドキュメントタイトル"
category: "カテゴリー名"
tags: ["タグ1", "タグ2"]
author: "作成者"
---

# 本文...

これによりNotionデータベースでの検索や絞り込みが容易になります。

画像参照の変換

マークダウン内の画像参照パスは、GitHubとNotionでは形式が異なるため適切に変換する必要があります:

  1. GitHub側: ![代替テキスト](/images/example.png)
  2. Notion側: ![代替テキスト](https://s3-bucket.example.com/example.png)

APIレート制限への対応

Notion APIにはレート制限があります:

  • 3秒あたり3リクエスト
  • 1分あたり90リクエスト
  • ブロック操作は100ブロックまで

対策として:

  • 再試行メカニズムの実装
  • ブロックの分割送信
  • スリープ処理によるリクエスト調整

エラー処理

様々なエラーケースを考慮する必要があります:

  • ネットワーク接続の問題
  • API制限の超過
  • ファイルパースエラー
  • S3アップロード失敗

適切なロギングとエラーハンドリングで運用をスムーズにします。

7. まとめ

このシステムにより、以下のワークフローが実現できます:

  1. 開発者はGitHubでマークダウンファイルを編集
  2. コミット&プッシュでGitHub Actionsが起動
  3. 変更されたファイルが自動的にNotionと同期
  4. 非開発者はNotionからドキュメントを閲覧・共有

GitHubをマスターデータとしながらNotionの優れたドキュメント閲覧機能を活用できる本システムは、開発チームと非開発チームの間のコラボレーションを大きく改善します。

参考資料


免責事項: この記事のコードはサンプルであり、実際に使用する場合は環境に合わせた調整やセキュリティ対策を適切に行ってください。また、実装にあたってはNotionとAWSのAPIの使用条件を確認してください。

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?