Node.jsでNotion API待望のFileUploadを試す


20025年5月のアップデートでNotionのファイルアップロードAPIが解禁されたので、こちらの写真をページにアップロードしてみたいと思います。

まじで待望でし

https://x.com/NotionAPI/status/1924860802369995246

ローカルのこんなファイルをNotion上にアップロードします。

> ![CleanShot 2025-06-05 at 18.16.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35387/b9e6f5fa-d153-4416-89aa-ac74c931c987.png)

こういう状態へ。

> ![CleanShot 2025-06-05 at 18.07.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35387/69606ebb-64d1-415f-b81b-87144baefb20.png)

## @notionhq/client v3.1系〜を使ってアップロード

```bash
$ npm i @notionhq/client   
```

@notionqh/clinetはv3.1系を利用してますが、このバージョンからファイルアップロードに対応した模様です。

https://github.com/makenotion/notion-sdk-js/releases/tag/v3.1.0 

```json
{
  "name": "tldv-upload-test",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@notionhq/client": "^3.1.3"
  }
}

```

公式

https://github.com/makenotion/notion-sdk-js/commit/833880d44d35bbd3f65c7b007d70dbaa1a016730#diff-b8b0ede94052aa48c307d86c47a5e717eaa1ffca30a9aceba19a856ee2d10eddR1-R80

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

Gensparkに書いてもらいました。

Node.js v24系で試してるので.tsファイルにしてみてます。

アップロードは`notion.fileUploads.create()`と`notion.fileUploads.send()`を使う模様です。

createでアップロードオプジェクトを作り、そこにsendでアップロードするといった雰囲気。

```ts
// app.ts
import { Client } from '@notionhq/client';
import { openAsBlob } from 'node:fs';
import { basename } from 'node:path';
import { stat, access } from 'node:fs/promises';

const NOTION_TOKEN = `Notionのトークン`;

if (!NOTION_TOKEN) {
  console.error('❌ NOTION_API_TOKEN環境変数が設定されていません');
  process.exit(1);
}

class NotionFileUploader {
  private notion: Client;

  constructor(token: string) {
    this.notion = new Client({ auth: token });
  }

  /**
   * ページIDを正規化
   */
  private normalizePageId(pageIdOrUrl: string): string {
    // 既にUUID形式の場合
    if (pageIdOrUrl.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/)) {
      return pageIdOrUrl;
    }
    
    // 32文字のIDをUUIDフォーマットに変換
    if (pageIdOrUrl.match(/^[a-f0-9]{32}$/)) {
      return `${pageIdOrUrl.slice(0, 8)}-${pageIdOrUrl.slice(8, 12)}-${pageIdOrUrl.slice(12, 16)}-${pageIdOrUrl.slice(16, 20)}-${pageIdOrUrl.slice(20, 32)}`;
    }
    
    // URLから抽出
    const match = pageIdOrUrl.match(/([a-f0-9]{32})/);
    if (!match) {
      throw new Error(`無効なページID/URL: ${pageIdOrUrl}`);
    }
    
    const rawId = match[1];
    return `${rawId.slice(0, 8)}-${rawId.slice(8, 12)}-${rawId.slice(12, 16)}-${rawId.slice(16, 20)}-${rawId.slice(20, 32)}`;
  }

  /**
   * ファイルのMIMEタイプを推測
   */
  private 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',
      'svg': 'image/svg+xml',
      'pdf': 'application/pdf',
      'txt': 'text/plain',
      'mp4': 'video/mp4',
      'mov': 'video/quicktime',
      'mp3': 'audio/mpeg',
      'wav': 'audio/wav',
      'doc': 'application/msword',
      'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
    };
    return types[ext || ''] || 'application/octet-stream';
  }

  /**
   * ファイルアップロードオブジェクトを作成
   */
  private async createFileUpload() {
    return await this.notion.fileUploads.create({
      mode: "single_part",
    });
  }

  /**
   * ファイルをアップロード
   */
  private async sendFileUpload(fileUploadId: string, filePath: string) {
    const filename = basename(filePath);
    const mimeType = this.getMimeType(filename);
    
    return await this.notion.fileUploads.send({
      file_upload_id: fileUploadId,
      file: {
        filename: filename,
        data: new Blob([await openAsBlob(filePath)], {
          type: mimeType,
        }),
      },
    });
  }

  /**
   * ファイルをアップロードしてページに添付
   */
  async uploadAndAttach(
    filePath: string,
    pageIdOrUrl: string,
    attachType: 'cover' | 'icon' | 'image' | 'file' = 'image'
  ) {
    try {
      // ファイル存在チェック
      try {
        await access(filePath);
      } catch {
        throw new Error(`ファイルが見つかりません: ${filePath}`);
      }

      // ファイル情報取得
      const filename = basename(filePath);
      const fileSize = (await stat(filePath)).size;

      console.log(`📤 ファイルアップロード開始: ${filename}`);
      console.log(`📊 サイズ: ${(fileSize / 1024 / 1024).toFixed(2)}MB`);

      // ページID正規化
      const pageId = this.normalizePageId(pageIdOrUrl);
      console.log(`🔍 ページID: ${pageId}`);

      // 1. ファイルアップロードオブジェクトを作成
      console.log('🔄 ファイルアップロードオブジェクト作成中...');
      let fileUpload = await this.createFileUpload();
      console.log(`✅ ファイルアップロードID: ${fileUpload.id}`);

      // 2. ファイルをアップロード
      console.log('🔄 ファイルデータアップロード中...');
      fileUpload = await this.sendFileUpload(fileUpload.id, filePath);
      console.log(`✅ ファイルアップロード完了! ステータス: ${fileUpload.status}`);

      // 3. ページに添付
      console.log(`🔄 ページに${attachType}として添付中...`);
      
      switch (attachType) {
        case 'cover':
          await this.notion.pages.update({
            page_id: pageId,
            cover: {
              type: 'file_upload',
              file_upload: { id: fileUpload.id }
            }
          });
          console.log('✅ ページカバーに設定完了');
          break;

        case 'icon':
          await this.notion.pages.update({
            page_id: pageId,
            icon: {
              type: 'file_upload',
              file_upload: { id: fileUpload.id }
            }
          });
          console.log('✅ ページアイコンに設定完了');
          break;

        case 'image':
          await this.notion.blocks.children.append({
            block_id: pageId,
            children: [{
              type: 'image',
              image: {
                type: 'file_upload',
                file_upload: { id: fileUpload.id }
              }
            }]
          });
          console.log('✅ 画像ブロック追加完了');
          break;

        case 'file':
          await this.notion.blocks.children.append({
            block_id: pageId,
            children: [{
              type: 'file',
              file: {
                type: 'file_upload',
                file_upload: { id: fileUpload.id }
              }
            }]
          });
          console.log('✅ ファイルブロック追加完了');
          break;
      }

      console.log('\n🎉 処理完了!');
      return fileUpload;

    } catch (error) {
      console.error('❌ エラー:', error);
      throw error;
    }
  }

  /**
   * 複数ファイルを一度にアップロード
   */
  async uploadMultipleFiles(
    filePaths: string[],
    pageIdOrUrl: string,
    attachType: 'image' | 'file' = 'image'
  ) {
    console.log(`📁 ${filePaths.length}個のファイルをアップロード開始`);
    
    const results = [];
    for (const [index, filePath] of filePaths.entries()) {
      console.log(`\n📄 ファイル ${index + 1}/${filePaths.length}`);
      try {
        const result = await this.uploadAndAttach(filePath, pageIdOrUrl, attachType);
        results.push({ success: true, filePath, result });
      } catch (error) {
        console.error(`❌ ${filePath} のアップロードに失敗:`, error);
        results.push({ success: false, filePath, error });
      }
    }

    const successful = results.filter(r => r.success).length;
    console.log(`\n🎉 ${successful}/${filePaths.length} ファイルのアップロード完了`);
    
    return results;
  }
}

/**
 * メイン実行関数
 */
async function main() {
  try {
    const [,, filePath, pageIdOrUrl, attachType] = process.argv;

    if (!filePath || !pageIdOrUrl) {
      console.log('使用方法:');
      console.log('node --experimental-strip-types app.ts <ファイルパス> <ページIDまたはURL> [cover|icon|image|file]');
      console.log('');
      console.log('例:');
      console.log('node app.ts ./image.png xxxxxxxxxxxx image');
      process.exit(1);
    }

    const uploader = new NotionFileUploader(NOTION_TOKEN);

    // ワイルドカード対応（複数ファイル）
    if (filePath.includes('*')) {
      const { glob } = await import('glob');
      const files = await glob(filePath);
      
      if (files.length === 0) {
        console.error('❌ 該当するファイルが見つかりません');
        process.exit(1);
      }

      await uploader.uploadMultipleFiles(
        files, 
        pageIdOrUrl, 
        attachType as 'image' | 'file' || 'image'
      );
    } else {
      // 単一ファイル
      await uploader.uploadAndAttach(
        filePath, 
        pageIdOrUrl, 
        attachType as 'cover' | 'icon' | 'image' | 'file' || 'image'
      );
    }

  } catch (error) {
    console.error('\n💥 実行エラー:', error);
    process.exit(1);
  }
}

await main();
```

### 使い方

アップロードしたいページのURLからページIDを取得します。

`https://www.notion.so/hogehoge/<この辺の文字列がページID>?source=copy_link`

こんな雰囲気で使います。

```bash
$ node app.ts <アップロードするファイルパス> <アップロード先のページID>
```

使うとこんな感じ

```
$ node app.ts ./image2.png HOGEHOGE-xxxxxxxxxxxxxxxxxxxxxxxxx

📊 サイズ: 1.84MB
🔍 ページID: HOGEHOGE-xxxxxxxxxxxxxxxxxxxxxxxxx
🔄 ファイルアップロードオブジェクト作成中...
✅ ファイルアップロードID: HOGEHOGE-xxxxxxxxxxxxxxxxxxxxxxxxx
🔄 ファイルデータアップロード中...
✅ ファイルアップロード完了! ステータス: uploaded
🔄 ページにimageとして添付中...
✅ 画像ブロック追加完了

```

## 所感

Notionoファイルアップロード機能はまだ実装例が少ないので何回かコケました。

NotionにAPI経由でアップロードできるとやれることが広がるので色々試したいですね。