Node.jsでNotion APIを使ってFileUploadを試す - データベースにアップロード


前回の記事でページに画像をアップロードするサンプルを試しましたがNotionのDatabaseにアップロードもできるので試してみました。

https://x.com/n0bisuke/status/1930934207636717751


前回の記事はこちらです。

https://qiita.com/n0bisuke/items/e3a814ec9686ba7e0e4c

## Databaseを用意

シンプルに名前とファイルのフィールドだけのDBを用意します。

`写真アルバム`という名前のDBを作りました。

- Name: デフォルトで`名前`というフィールド名だけあると思いますがこれを`Name`に変更
- Files: プロパティ追加でフィールド名を`Files`に、種類は`ファイル&メディア`を指定

> ![CleanShot 2025-06-06 at 19.30.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35387/cffcd13b-e1aa-4c9a-bd7f-3ae1767c2080.png)

> ![CleanShot 2025-06-06 at 19.29.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35387/ffb0ee6f-57dd-4850-9d6a-a195aa5ac1d9.png)

というシンプルな二つのプロパティだけあるDBを作りました。

### DatabaseIDはどれだ

またDatabaseのIDはURLから取得できます。

`https://www.notion.so/hogehoge/xxxxxxxxxxxxxxxxxxxxx?v=yyyyyyyyyyyyyyyyyyyyy0&source=copy_link`みたいなURLになりますが、

`hogehoge/`と`?v=`の間のxxxxxxxxの部分がdatabaseIDになります。

`?v=`以降のyyyyyyyyyyyyyの部分はビューのIDらしいです。

## Databaseにアップロードするコードサンプル

```ts
// fileupload2db.ts
import { Client } from '@notionhq/client';
import { openAsBlob } from 'node:fs';
import { basename } from 'node:path';

const NOTION_TOKEN = `Notionのアクセストークン`;
const notion = new Client({ auth: NOTION_TOKEN });

function getMimeType(filename: string): string {
  const ext = filename.toLowerCase().split('.').pop();
  const types: Record<string, string> = {
    'jpg': 'image/jpeg',
    'jpeg': 'image/jpeg',
    'png': 'image/png',
    'gif': 'image/gif',
    'webp': 'image/webp',
    'pdf': 'application/pdf',
    'txt': 'text/plain',
    'mp4': 'video/mp4',
    'mp3': 'audio/mpeg'
  };
  return types[ext || ''] || 'application/octet-stream';
}

async function uploadToDatabase(filePath: string, databaseId: string) {
  const filename = basename(filePath);
  const mimeType = getMimeType(filename);
  console.log(`📤 ${filename} をアップロード中... (${mimeType})`);

  // ファイルアップロード
  const fileUpload = await notion.fileUploads.create({ mode: "single_part" });
  const uploadedFile = await notion.fileUploads.send({
    file_upload_id: fileUpload.id,
    file: {
      filename: filename,
      data: new Blob([await openAsBlob(filePath)], { type: mimeType }),
    },
  });

  // データベースにページ作成
  await notion.pages.create({
    parent: { type: 'database_id', database_id: databaseId },
    properties: {
      Name: { title: [{ text: { content: filename } }] },
      Files: {
        files: [{
          type: 'file_upload',
          file_upload: { id: uploadedFile.id },
          name: filename
        }]
      }
    }
  });

  console.log('✅ 完了!');
}

const [,, filePath, databaseId] = process.argv;
await uploadToDatabase(filePath, databaseId);
```

実行

```bash
$ node fileupload2db.ts ./image3.png xxxxxxxxxxxxxxxxx

📤 image3.png をアップロード中... (image/png)
✅ 完了
```

こんな感じでアップロードできました。

https://x.com/n0bisuke/status/1930934207636717751


## 重たいファイルをアップロードする

20MB以上のファイルは処理の仕方を変えないと行けない模様です。

タイムアウトされちゃいます。

 ```bash
@notionhq/client warn: request fail {
  code: 'notionhq_client_request_timeout',
  message: 'Request to Notion API has timed out'
}
/Users/n0bisuke/ds/2_playground/tldv-upload-test/node_modules/@notionhq/client/build/src/errors.js:69
                reject(new RequestTimeoutError());
                       ^

RequestTimeoutError: Request to Notion API has timed out
    at Timeout._onTimeout (/Users/n0bisuke/ds/2_playground/tldv-upload-test/node_modules/@notionhq/client/build/src/errors.js:69:24)
    at listOnTimeout (node:internal/timers:608:17)
    at process.processTimers (node:internal/timers:543:7) {
  code: 'notionhq_client_request_timeout'
}
```

かつ、現状のSDK(v3.1系)だと大きいファイルを送信すると`part_number parameter is missing.`といったエラーがでました。

```json
{
  "code": "validation_error",
  "message": "File upload is in multipart mode, but the `part_number` parameter is missing."
}
```

SDK内部でのFormData構築時にpart_numberパラメータが正しく送信されていない雰囲気です。
Fetchで直接実装することでうまくいきました。

```ts
// fileupload2db-fetch.ts
import { openAsBlob } from 'node:fs';
import { basename } from 'node:path';
import { stat } from 'node:fs/promises';

const NOTION_TOKEN = `Notionのトークン`;
const NOTION_VERSION = '2022-06-28';
const API_BASE = 'https://api.notion.com/v1';

function getMimeType(filename: string): string {
  const ext = filename.toLowerCase().split('.').pop();
  const types: Record<string, string> = {
    'jpg': 'image/jpeg',
    'jpeg': 'image/jpeg',
    'png': 'image/png',
    'gif': 'image/gif',
    'webp': 'image/webp',
    'pdf': 'application/pdf',
    'txt': 'text/plain',
    'mp4': 'video/mp4',
    'mov': 'video/quicktime',
    'mp3': 'audio/mpeg',
    'avi': 'video/x-msvideo',
    'mkv': 'video/x-matroska',
    'webm': 'video/webm',
    'heic': 'image/heic',
    'heif': 'image/heif'
  };
  return types[ext || ''] || 'application/octet-stream';
}

async function sleep(ms: number) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

/**
 * ファイルアップロードオブジェクトを作成
 */
async function createFileUpload(mode: 'single_part' | 'multi_part', options: any = {}) {
  const response = await fetch(`${API_BASE}/file_uploads`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${NOTION_TOKEN}`,
      'Notion-Version': NOTION_VERSION,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      mode,
      ...options
    })
  });

  if (!response.ok) {
    const error = await response.text();
    throw new Error(`ファイルアップロード作成失敗: ${error}`);
  }

  return await response.json();
}

/**
 * ファイルパートを送信（fetch直接実装）
 */
async function sendFilePart(fileUploadId: string, fileData: Blob, filename: string, partNumber?: number) {
  const formData = new FormData();
  formData.append('file', fileData, filename);
  
  // GitHubのテストコードを参考に、part_numberをFormDataに追加
  if (partNumber) {
    formData.append('part_number', partNumber.toString());
  }

  const response = await fetch(`${API_BASE}/file_uploads/${fileUploadId}/send`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${NOTION_TOKEN}`,
      'Notion-Version': NOTION_VERSION
      // Content-Typeは自動設定（FormDataの場合）
    },
    body: formData
  });

  if (!response.ok) {
    const error = await response.text();
    throw new Error(`ファイル送信失敗: ${error}`);
  }

  return await response.json();
}

/**
 * マルチパートアップロードを完了
 */
async function completeFileUpload(fileUploadId: string) {
  const response = await fetch(`${API_BASE}/file_uploads/${fileUploadId}/complete`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${NOTION_TOKEN}`,
      'Notion-Version': NOTION_VERSION,
      'Content-Type': 'application/json'
    }
  });

  if (!response.ok) {
    const error = await response.text();
    throw new Error(`アップロード完了失敗: ${error}`);
  }

  return await response.json();
}

/**
 * データベースにページを作成
 */
async function createDatabasePage(databaseId: string, filename: string, fileUploadId: string) {
  const response = await fetch(`${API_BASE}/pages`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${NOTION_TOKEN}`,
      'Notion-Version': NOTION_VERSION,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      parent: { type: 'database_id', database_id: databaseId },
      properties: {
        Name: { title: [{ text: { content: filename } }] },
        Files: {
          files: [{
            type: 'file_upload',
            file_upload: { id: fileUploadId },
            name: filename
          }]
        }
      }
    })
  });

  if (!response.ok) {
    const error = await response.text();
    throw new Error(`ページ作成失敗: ${error}`);
  }

  return await response.json();
}

/**
 * 大きなファイルのマルチパートアップロード
 */
async function uploadLargeFile(filePath: string) {
  const filename = basename(filePath);
  const mimeType = getMimeType(filename);
  const fileSize = (await stat(filePath)).size;
  const chunkSize = 10 * 1024 * 1024; // 10MB chunks
  const totalParts = Math.ceil(fileSize / chunkSize);
  
  console.log(`📤 大きなファイル ${filename} をアップロード中...`);
  console.log(`📊 サイズ: ${(fileSize / 1024 / 1024).toFixed(2)}MB, ${totalParts}パートに分割`);
  console.log(`🎬 ファイル形式: ${mimeType}`);

  // マルチパートアップロード作成
  const fileUpload = await createFileUpload('multi_part', {
    number_of_parts: totalParts,
    filename: filename,
    content_type: mimeType
  });

  console.log(`🔄 ${totalParts}個のパートをアップロード中...`);

  // 各パートをアップロード
  const blob = await openAsBlob(filePath);
  for (let i = 1; i <= totalParts; i++) {
    const start = (i - 1) * chunkSize;
    const end = Math.min(start + chunkSize, fileSize);
    const chunk = blob.slice(start, end);
    
    console.log(`  パート ${i}/${totalParts} (${((end - start) / 1024 / 1024).toFixed(2)}MB)`);
    
    await sendFilePart(fileUpload.id, chunk, filename, i);
    await sleep(500);
  }

  // マルチパートアップロード完了
  console.log('🔄 アップロード完了処理中...');
  const completedUpload = await completeFileUpload(fileUpload.id);

  return completedUpload;
}

/**
 * 小さなファイルのシングルパートアップロード
 */
async function uploadSmallFile(filePath: string) {
  const filename = basename(filePath);
  const mimeType = getMimeType(filename);
  
  console.log(`🎬 ファイル形式: ${mimeType}`);
  
  const fileUpload = await createFileUpload('single_part');
  const fileBlob = new Blob([await openAsBlob(filePath)], { type: mimeType });
  
  const uploadedFile = await sendFilePart(fileUpload.id, fileBlob, filename);
  return uploadedFile;
}

/**
 * メインアップロード関数
 */
async function uploadToDatabase(filePath: string, databaseId: string) {
  const filename = basename(filePath);
  const fileSize = (await stat(filePath)).size;
  const fileSizeMB = fileSize / 1024 / 1024;
  const isLargeFile = fileSizeMB > 20;

  console.log(`📤 ${filename} をアップロード中... (${fileSizeMB.toFixed(2)}MB)`);

  let retries = 3;
  while (retries > 0) {
    try {
      // ファイルサイズに応じてアップロード方法を選択
      const uploadedFile = isLargeFile 
        ? await uploadLargeFile(filePath)
        : await uploadSmallFile(filePath);

      await sleep(1000);

      // データベースにページ作成
      await createDatabasePage(databaseId, filename, uploadedFile.id);

      console.log('✅ 完了!');
      return;

    } catch (error) {
      retries--;
      console.log(`❌ エラー発生。リトライ残り: ${retries}回`);
      console.error('エラー詳細:', error);
      
      if (retries === 0) {
        console.error('💥 最終エラー:', error);
        throw error;
      }
      
      await sleep(3000);
    }
  }
}

const [,, filePath, databaseId] = process.argv;

if (!filePath || !databaseId) {
  console.log('使用方法: node fileupload2db-fetch.ts <ファイル名> <データベースID>');
  console.log('🎉 fetch直接実装: 大きなファイルもサポート!');
  process.exit(1);
}

await uploadToDatabase(filePath, databaseId);

```

`IMG_0588.MOV`という60MBくらいある動画ファイルをアップロードしてみます。

```
$ node movieupload2db.ts ./IMG_0588.MOV <databaseID>

📤 IMG_0588.MOV をアップロード中... (58.15MB)
📤 大きなファイル IMG_0588.MOV をアップロード中...
📊 サイズ: 58.15MB, 6パートに分割
🎬 ファイル形式: video/quicktime
🔄 6個のパートをアップロード中...
  パート 1/6 (10.00MB)
  パート 2/6 (10.00MB)
  パート 3/6 (10.00MB)
  パート 4/6 (10.00MB)
  パート 5/6 (10.00MB)
  パート 6/6 (8.15MB)
🔄 アップロード完了処理中...
✅ 完了!
```

> ![CleanShot 2025-06-06 at 19.58.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35387/f123d666-d4fc-4fb7-9259-6221dc5d884e.png)

無事にアップロードできました。

## まとめ


SDKの更新やAPI自体も最近なのでまだ安定しないところもありそうですが使える感じになってます。

色々ファイルアップロードしてNotionに情報集約してみたいですn