0
0

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 の同期システム実装ガイド: 更新版

Posted at

【Notion API】ファイル直接アップロード機能でAWS環境が不要に

はじめに

以前、「GitHub と Notion の同期システム実装ガイド」という記事で、Notion API では画像を直接アップロードできないため AWS S3 を経由する方法を紹介しました。

しかし、2025年9月にリリースされた Notion API v5 により、Notion API で直接ファイルアップロードが可能になりました。これにより AWS 環境の構築が不要となり、Notion 連携がより簡単に実装できるようになりました。

本記事では、Notion API v5.0.0 を使った新しいファイルアップロード機能の実装方法を紹介します。

重要: 5MB制限は「1ファイルあたり」の制限です。無料プランでも、各ファイルが5MB以下であれば複数の画像をアップロード可能です。ただし、大きな画像(5MB超)や20MB超のマルチパートアップロードには有料プラン(5GBまで対応)が必要です。

何が変わったのか?

旧バージョン(〜v4.x)の課題

  • Notion APIでは画像やPDFなどのファイルを直接アップロードできない
  • GitHubマークダウンから画像付きページを同期する場合、別途AWS S3などの外部ストレージが必要
  • AWS S3、CloudFront、Lambda@Edgeなどのインフラ構築が必要
  • インフラ構築コストと運用コストが発生(月額数千円〜)

新バージョン(v5.0.0〜)の改善点

  • Notion API で直接ファイルアップロードが可能に
  • AWS 環境が不要(月額数千円のインフラコスト削減)
  • 小さなファイル(20MB以下)の直接アップロード対応
  • 大きなファイル(最大5GB)のマルチパートアップロード対応(有料プランのみ
  • シンプルな実装で管理が容易

制限の詳細:

  • 無料プラン: 1ファイルあたり5MBまで(複数ファイルOK)
  • 有料プラン: 1ファイルあたり5GBまで、20MB超のマルチパート対応
  • 一般的なスクリーンショット(1〜3MB)であれば、無料プランでも十分実用的

Notion API v5.0.0のファイルアップロードの仕組み

Notion API v5.0.0では、ファイルアップロードに専用のエンドポイントが追加されました。

公式ドキュメント:

基本的なフロー

1. 小さなファイル(20MB以下)の場合

1. File Upload Object作成
   POST /v1/file_uploads
   
2. ファイルコンテンツのアップロード
   POST /v1/file_uploads/{upload_id}/send
   
3. アップロード完了通知(pendingの場合のみ)
   POST /v1/file_uploads/{upload_id}/complete

2. 大きなファイル(20MB〜5GB)の場合(有料プランのみ

1. Multipart Upload開始
   POST /v1/file_uploads
   body: { mode: 'multi_part', number_of_parts: N, ... }
   
2. パートのアップロード(複数回、FormData形式)
   POST /v1/file_uploads/{upload_id}/send
   body: FormData with part_number and file
   
3. アップロード完了
   POST /v1/file_uploads/{upload_id}/complete
   body: {}  # 空のボディ

実装例

環境設定

const { Client } = require('@notionhq/client');
const fs = require('fs');
const path = require('path');
const fetch = require('node-fetch');
const FormData = require('form-data');

const NOTION_API_VERSION = '2025-09-03'; // v5.0.0対応バージョン

const notion = new Client({
  auth: process.env.NOTION_API_KEY,
  notionVersion: NOTION_API_VERSION,
});

// 設定定数
const MAX_FILE_SIZE_FREE = 5 * 1024 * 1024; // 5MB(無料プラン)
const MAX_FILE_SIZE_PAID = 5 * 1024 * 1024 * 1024; // 5GB(有料プラン)
const DIRECT_UPLOAD_LIMIT = 20 * 1024 * 1024; // 20MB(直接アップロードの上限)

小さなファイルの直接アップロード

/**
 * 小さなファイル(20MB以下)を直接アップロード
 */
async function uploadSmallFileToNotion(localFilePath, fileName, contentType) {
  // Step 1: File Upload Objectを作成
  const createResponse = await fetch('https://api.notion.com/v1/file_uploads', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.NOTION_API_KEY}`,
      'Notion-Version': NOTION_API_VERSION,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      filename: fileName,
      content_type: contentType
    })
  });
  
  const uploadInfo = await createResponse.json();
  const fileUploadId = uploadInfo.id;
  const uploadUrl = uploadInfo.upload_url;
  
  // Step 2: ファイル内容をアップロード
  // ⚠️ 注意: multipart/form-dataを使用。他のNotion APIと異なりJSONではない
  const fileBuffer = fs.readFileSync(localFilePath);
  const formData = new FormData();
  formData.append('file', fileBuffer, {
    filename: fileName,
    contentType: contentType
  });
  
  const uploadResponse = await fetch(uploadUrl, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.NOTION_API_KEY}`,
      'Notion-Version': NOTION_API_VERSION,
      // Content-Typeヘッダーは自動設定される(boundaryを含む)
      ...formData.getHeaders()
    },
    body: formData
  });
  
  const uploadResult = await uploadResponse.json();
  
  // Step 3: アップロード完了通知(statusがpendingの場合のみ)
  // 注: single_partモードでは通常、このステップは不要の場合が多い
  if (uploadResult.status === 'pending') {
    const completeResponse = await fetch(
      `https://api.notion.com/v1/file_uploads/${fileUploadId}/complete`,
      {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${process.env.NOTION_API_KEY}`,
          'Notion-Version': NOTION_API_VERSION,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({})
      }
    );
    
    return await completeResponse.json();
  }
  
  return uploadResult;
}

Content-Typeの判定

/**
 * ファイル拡張子からContent-Typeを取得
 */
function getContentTypeFromExtension(extension) {
  const contentTypes = {
    '.jpg': 'image/jpeg',
    '.jpeg': 'image/jpeg',
    '.png': 'image/png',
    '.gif': 'image/gif',
    '.svg': 'image/svg+xml',
    '.webp': 'image/webp',
    '.pdf': 'application/pdf',
    '.txt': 'text/plain',
    '.doc': 'application/msword',
    '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
    '.csv': 'text/csv',
  };

  return contentTypes[extension.toLowerCase()] || 'text/plain';
}

ファイルサイズによるアップロード方法の切り替え

/**
 * ファイルサイズに応じて適切なアップロード方法を選択
 */
async function uploadFileToNotion(localFilePath, fileName, isPaidWorkspace = false) {
  const stats = fs.statSync(localFilePath);
  const fileSize = stats.size;
  const maxSize = isPaidWorkspace ? MAX_FILE_SIZE_PAID : MAX_FILE_SIZE_FREE;
  
  // サイズチェック
  if (fileSize > maxSize) {
    throw new Error(`ファイルサイズが制限を超えています: ${fileSize} bytes (制限: ${maxSize} bytes)`);
  }

  const extension = path.extname(localFilePath);
  const contentType = getContentTypeFromExtension(extension);

  // 20MB以下なら直接アップロード、それ以上ならマルチパートアップロード
  if (fileSize <= DIRECT_UPLOAD_LIMIT) {
    return await uploadSmallFileToNotion(localFilePath, fileName, contentType);
  } else {
    return await uploadLargeFileToNotion(localFilePath, fileName, contentType);
  }
}

大きなファイルのマルチパートアップロード

⚠️ 有料プランのみ対応(無料プランは5MB制限のため使用不可)

/**
 * 大きなファイル(20MB〜5GB)をマルチパートでアップロード
 * 注意: 有料プランのみ利用可能
 */
async function uploadLargeFileToNotion(localFilePath, fileName, contentType) {
  const fileSize = fs.statSync(localFilePath).size;
  const chunkSize = 10 * 1024 * 1024; // 10MBずつ
  const numberOfParts = Math.ceil(fileSize / chunkSize);
  
  // Step 1: マルチパートアップロード開始(通常のエンドポイントにmodeパラメータを追加)
  const initResponse = await fetch('https://api.notion.com/v1/file_uploads', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.NOTION_API_KEY}`,
      'Notion-Version': NOTION_API_VERSION,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      filename: fileName,
      content_type: contentType,
      file_size: fileSize,
      mode: 'multi_part',  // マルチパートモードを指定
      number_of_parts: numberOfParts
    })
  });
  
  const uploadInfo = await initResponse.json();
  const uploadId = uploadInfo.id;
  
  // Step 2: ファイルを分割してアップロード
  for (let partNumber = 1; partNumber <= numberOfParts; partNumber++) {
    const start = (partNumber - 1) * chunkSize;
    const end = Math.min(start + chunkSize, fileSize);
    
    // ファイルの一部を読み込み
    const buffer = Buffer.alloc(end - start);
    const fd = fs.openSync(localFilePath, 'r');
    fs.readSync(fd, buffer, 0, end - start, start);
    fs.closeSync(fd);
    
    // FormDataを使ってパートをアップロード
    // ⚠️ 重要: mode=multi_partの場合、part_numberフィールドが必須
    const formData = new FormData();
    formData.append('part_number', partNumber.toString());
    formData.append('file', buffer, {
      filename: `${fileName}-part-${partNumber}`,
      contentType: 'application/octet-stream'
    });
    
    const partResponse = await fetch(
      `https://api.notion.com/v1/file_uploads/${uploadId}/send`,
      {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${process.env.NOTION_API_KEY}`,
          'Notion-Version': NOTION_API_VERSION,
          ...formData.getHeaders()
        },
        body: formData
      }
    );
    
    const partResult = await partResponse.json();
    console.log(`パート ${partNumber}/${numberOfParts} アップロード完了`);
  }
  
  // Step 3: アップロード完了(空のボディで通知)
  // 注: このステップはmode=multi_partの場合のみ必要
  const completeResponse = await fetch(
    `https://api.notion.com/v1/file_uploads/${uploadId}/complete`,
    {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${process.env.NOTION_API_KEY}`,
        'Notion-Version': NOTION_API_VERSION,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({})  // 空のボディを送信
    }
  );
  
  return await completeResponse.json();
}

GitHubマークダウンをNotionページに同期する実装例

マークダウンから画像参照を抽出

/**
 * Markdownから画像参照を抽出
 */
function extractImageReferences(markdownContent) {
  const imageRegex = /!\[.*?\]\((\/images\/.*?\.(png|jpg|jpeg|gif|svg|webp))\)/gi;
  const matches = [...markdownContent.matchAll(imageRegex)];
  return matches.map(match => match[1]);
}

/**
 * Markdownから画像をアップロードしてURLを置換
 */
async function uploadImagesFromMarkdown(markdownContent, repoRoot) {
  const imageRefs = extractImageReferences(markdownContent);
  let updatedContent = markdownContent;
  
  for (const imageRef of imageRefs) {
    const localPath = path.join(repoRoot, imageRef);
    
    if (fs.existsSync(localPath)) {
      const fileName = path.basename(imageRef);
      
      // Notionにアップロード
      const uploadResult = await uploadFileToNotion(localPath, fileName);
      
      // マークダウン内のパスを置換
      updatedContent = updatedContent.replace(imageRef, uploadResult.id);
      
      console.log(`画像アップロード完了: ${fileName} -> ${uploadResult.id}`);
    }
  }
  
  return updatedContent;
}

パッケージ依存関係

{
  "name": "notion-sync",
  "version": "0.1.0",
  "dependencies": {
    "@notionhq/client": "^5.0.0",
    "@tryfabric/martian": "^1.2.4",
    "form-data": "^4.0.4",
    "front-matter": "^4.0.2",
    "node-fetch": "^2.7.0"
  }
}

GitHub Actionsでの自動同期

name: Sync to Notion

on:
  push:
    branches:
      - main
    paths:
      - 'docs/**'
      - 'images/**'

jobs:
  sync:
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
      
      - name: Install dependencies
        run: |
          cd notion-sync
          npm install
      
      - name: Sync to Notion
        env:
          NOTION_API_KEY: ${{ secrets.NOTION_API_KEY }}
          NOTION_DATASOURCE_ID: ${{ secrets.NOTION_DATASOURCE_ID }}
          FILE_UPLOAD_MODE: notion
          NOTION_PAID_WORKSPACE: true
        run: |
          cd notion-sync
          node sync-to-notion.js

ファイルサイズ制限とプラン

プラン 1ファイルあたりの最大サイズ 直接アップロード マルチパートアップロード 実用性
無料プラン 5MB 可能(5MB以下) 不可 ⚠️ 条件付き
有料プラン 5GB 可能(20MB以下) 可能(20MB〜5GB) 推奨

実用性の判断基準

無料プランで十分なケース

  • スクリーンショット中心(通常1〜3MB/枚)
  • 最適化された画像ファイル(各5MB以下)
  • ドキュメント数が少ない

有料プランが必要なケース

  • 高解像度の画像(5MB超/枚)
  • 動画ファイルやPDF(20MB超)
  • 大量のファイルを頻繁にアップロード(レート制限回避)

結論: 一般的なドキュメント管理(スクリーンショット中心)であれば、無料プランでも実用可能です。ただし、画像サイズが大きい場合や、大容量ファイルを扱う場合は有料プラン推奨です。

実装時の注意点

1. multipart/form-dataの扱い

NotionのファイルアップロードAPI(Send a file upload)は、他のNotion APIと異なりmultipart/form-dataを使用します。

// NG: JSON で送信(他の Notion API はこれで OK)
const response = await fetch(uploadUrl, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'  // ファイルアップロードではエラー
  },
  body: JSON.stringify({ file: base64Data })
});

// OK: multipart/form-data で送信
const formData = new FormData();
formData.append('file', fileBuffer, { filename: 'image.png' });

const response = await fetch(uploadUrl, {
  method: 'POST',
  headers: {
    ...formData.getHeaders()  // boundary が自動設定される
  },
  body: formData
});

重要: Content-Typeヘッダーにboundaryパラメータが必要ですが、ほとんどのライブラリ(fetchnode-fetchFormData)は自動的に設定します。明示的にContent-Typeを上書きしないことが重要です。

2. リトライ処理の実装

Notion APIは時々タイムアウトやレート制限エラーを返すため、リトライ処理を実装することをお勧めします。

async function retryOperation(operation, maxRetries = 3, delay = 1000) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await operation();
    } catch (error) {
      if (i === maxRetries - 1) throw error;
      
      console.log(`リトライ ${i + 1}/${maxRetries}...`);
      await new Promise(resolve => setTimeout(resolve, delay * (i + 1)));
    }
  }
}

2. API呼び出しのレート制限

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

  • 平均: 3リクエスト/秒
  • バースト: 短時間で多数のリクエストを送ると制限される
  • マルチパートアップロードの各パートも標準のレート制限に従う

複数ファイルをアップロードする場合は、適切な待機時間を設けましょう。

公式ドキュメント: Request limits

// ファイルアップロード間に待機時間を設ける
await new Promise(resolve => setTimeout(resolve, 400)); // 0.4秒待機

サポートされているContent-Type:

  • 画像: image/jpeg, image/png, image/gif, image/svg+xml, image/webp
  • ドキュメント: application/pdf, text/plain, text/csv
  • Microsoft Office: application/msword, application/vnd.openxmlformats-officedocument.*

Content-Typeが不明な場合やファイル名に拡張子が含まれている場合、NotionのAPIが自動的に推論します。

3. Content-Typeの正確性

ファイルの拡張子から正しいContent-Typeを設定することが重要です。不正確なContent-Typeを設定すると、アップロードが失敗する場合があります。

4. エラーハンドリング

アップロード失敗時の処理を適切に実装しましょう。

try {
  const result = await uploadFileToNotion(filePath, fileName);
  console.log('アップロード成功:', result.id);
} catch (error) {
  console.error('アップロード失敗:', error.message);
  // フォールバック処理やログ記録など
}

まとめ

Notion API v5.0.0により、ついに念願のファイル直接アップロード機能が実装されました!

メリット

  • インフラコスト削減: AWS環境(S3、CloudFront、Lambda@Edge)が不要になり、月々数千円のコスト削減
  • シンプルな実装: 外部ストレージの構築・管理が不要
  • 高速化: 外部ストレージを経由しないため、アップロード処理が高速
  • セキュリティ: ファイルがNotion内で完結し、アクセス制御が容易
  • 運用負荷軽減: AWSのインフラ監視やメンテナンスが不要

デメリット

  • 1ファイルあたりのサイズ制限: 無料プランは5MB/ファイル、有料プランでも5GB/ファイル
  • 大容量ファイルは有料プラン必須: 5MB超のファイルや20MB超のマルチパートアップロードには有料プラン(月額$8〜$10/ユーザー)が必要
  • レート制限: 平均3リクエスト/秒のため、大量ファイルの一括アップロードには時間がかかる
  • ベンダーロックイン: Notion依存度が高まるため、将来的な移行が困難になる可能性

コスト比較

項目 AWS S3連携 Notion直接アップロード
初期構築 必要(S3/CloudFront/Lambda@Edge) 不要
月額コスト 数千円〜(通信量次第) Notion有料プランに含まれる
実装難易度 高(AWSインフラ構築必要) 低(API呼び出しのみ)
運用負荷 高(インフラ監視必要) 低(Notionに委任)

結論:

  • 無料プランでも実用可能(通常のスクリーンショット中心のドキュメント管理)
  • 既にNotionの有料プランを利用している場合、追加コストなしで実装できるため最適な選択
  • 画像サイズが大きい場合(5MB超)や大容量ファイルを扱う場合は有料プラン推奨

参考リンク


以上、お疲れ様でした!

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?