🗒️

NotionのデータベースにAPI経由でレコードを一括インポートしたメモ

2023/10/16に公開2

備忘録です。

やりたかったこと

  • Notion内のデータベースにCSVでレコードを一括投入したかった
  • が、Notion標準の 「CSV取り込み」機能 だと少なくともマルチセレクト、リレーション、本文をCSVで取り込むことができなかった(実験結果。公式見解は見当たらず)
  • しかたなくAPI経由でCSVの中身をインポートする機能を実装することにした

APIの利用に際して前提として必要なこと

  • https://www.notion.so/my-integrations にてインテグレーションを作成する
    • このインテグレーションのシークレットをAPIエンドポイントへの認証に使用する
  • Notion上で、APIによるアクセスが必要なページ・データベースに対して、上記のインテグレーションにアクセス権を付与する
    • 画面右上の ... → コネクトの追加 → インテグレーションを選択

データベースの様子

プロパティ名 種類
名前 タイトル
種別 セレクト
タグ マルチセレクト
プロジェクト プロジェクト データベースへのリレーション
担当者 従業員 データベースへのリレーション
依頼者 従業員 データベースへのリレーション
日付 日付
ステータス ステータス

CSVの様子

名前 種別 タグ プロジェクト 担当者 依頼者 日付 ステータス 本文 リンク先ページ名
レコードの名前1 種別1 タグ1,タグ2 プロジェクト名1 従業員名1 従業員名2 2023-10-16
レコードの名前2 種別2 タグ1,タグ3 プロジェクト名2 従業員名2 従業員名3 2023-10-16,2023-10-18 完了 必要に応じて
本文を書く
ページ名1
  • プロジェクト、担当者、依頼者には、データベースIDではなくデータベース名を書けるようにしたかった
  • 任意の本文に加えて、本文内に任意のページへのリンクを記載できるようにしたかった
  • 日付は1日指定と期間指定のどちらもできるようにしたかった

ちなみにNotion標準のCSV取り込み機能だとできなかったこと

あくまで記事執筆時点の実験結果です。そのうち機能が改善されたりするかもしれません。

  • CSV内に同一の「名前」の行があると正常に取り込めなかった
  • マルチセレクトの項目にはどのような書き方をしても値をセットすることができなかった
  • リレーションの項目にはどのような書き方をしても値をセットすることができなかった
  • 日付の項目は yyyy-mm-dd 書式でないと正常に取り込めなかった
    • 少なくとも yyyy/m/d などはダメだった
    • ので、Excelとの相性が悪い
  • レコード(ページ)の本文に値をセットする手段は存在しなかった

実装

PHPで symfony/http-client というHTTPクライアントライブラリを使って実装したコードを示します。適当に読み替えながら参考にしていただければ🙏

なお、CSVをパースする処理についてはここでは割愛します。

あと、社内ツールにつきまあまあ雑な実装なのでご注意ください。

CSVの行データを表すValueObject

<?php

declare(strict_types=1);

namespace App\Notion;

readonly class Row
{
    public function __construct(
        public string $name,

        public string $type,

        public string $ownerName,

        public \DateTimeInterface $startedDate,

        /** @var array<string> */
        public array $tags = [],

        public ?string $projectName = null,

        public ?string $ordererName = null,

        public ?\DateTimeInterface $endedDate = null,

        public ?string $status = null,

        public ?string $body = null,

        public ?string $linkPageName = null,
    ) {
    }
}

プロジェクト名からプロジェクトIDを取得するメソッド

private static array $projectIds = []; // 一度APIで取得した情報は使い回す

public function getProjectId(string $projectName): ?string
{
    if ($projectId = self::$projectIds[$projectName] ?? null) {
        return $projectId;
    }

    $res = $this->notionClient->request('POST', sprintf('/v1/databases/%s/query', $this->projectDatabaseId), [
        'json' => [
            'filter' => [
                'and' => [
                    [
                        'property' => '名前',
                        'title' => [
                            'equals' => $projectName,
                        ],
                    ],
                ],
            ],
            'page_size' => 1,
        ],
    ]);
    if (400 === $res->getStatusCode()) {
        return null;
    }
    $res = json_decode($res->getContent(), flags: JSON_THROW_ON_ERROR);
    $projectId = $res->results[0]?->id ?? null;

    if ($projectId) {
        self::$projectIds[$projectName] = $projectId;
    }

    return $projectId;
}
  • インテグレーションに プロジェクト データベースへのアクセス権を渡しておくのを忘れずに

従業員名から従業員IDを取得するメソッド

private static array $staffIds = []; // 一度APIで取得した情報は使い回す

public function getStaffId(string $staffName): ?string
{
    if ($staffId = self::$staffIds[$staffName] ?? null) {
        return $staffId;
    }

    $res = $this->notionClient->request('POST', sprintf('/v1/databases/%s/query', $this->staffDatabaseId), [
        'json' => [
            'filter' => [
                'and' => [
                    [
                        'property' => '氏名',
                        'title' => [
                            'equals' => $staffName,
                        ],
                    ],
                ],
            ],
            'page_size' => 1,
        ],
    ]);
    if (400 === $res->getStatusCode()) {
        return null;
    }
    $res = json_decode($res->getContent(), flags: JSON_THROW_ON_ERROR);
    $staffId = $res->results[0]?->id ?? null;

    if ($staffId) {
        self::$staffIds[$staffName] = $staffId;
    }

    return $staffId;
}
  • インテグレーションに 従業員 データベースへのアクセス権を渡しておくのを忘れずに

ページ名からリンク先ページのページIDを取得するメソッド

private static array $pageIds = []; // 一度APIで取得した情報は使い回す

public function getPageId(string $pageName, string $ancestorPageId = null): ?string
{
    if ($pageId = self::$pageIds[$pageName] ?? null) {
        return $pageId;
    }

    $res = $this->notionClient->request('POST', '/v1/search', [
        'json' => [
            'query' => $pageName,
            'filter' => [
                'value' => 'page',
                'property' => 'object',
            ],
        ],
    ]);
    // この時点では検索にヒットした全ページが結果に含まれている(ページ名完全一致以外のページやアーカイブ済みのページも含まれる)
    $res = json_decode($res->getContent(), flags: JSON_THROW_ON_ERROR);

    foreach ((array) $res->results as $result) {
        if (($result->archived ?? null) || !($result->properties?->title?->title ?? null)) {
            continue;
        }
        if (($result->properties->title->title[0]?->text?->content ?? null) === $pageName) {
            $pageId = $result->id ?? null;
            break;
        }
    }

    if ($pageId) {
        self::$pageIds[$pageName] = $pageId;
    }

    return $pageId;
}
  • インテグレーションに検索対象のフォルダ(ページ)へのアクセス権を渡しておくのを忘れずに
  • 今回は手抜きで、見つかったページのうちページ名が完全に一致していてアーカイブ済みでないページがもし複数あった場合は、最初に見つかったものが採用される仕様にしています

レコードを挿入するメソッド

public function insert(Row $row): void
{
    // プロジェクトのIDを取得
    $projectId = $row->projectName ? $this->getProjectId($row->projectName) : null;

    // 担当者のIDを取得
    $ownerId = $this->getStaffId($row->ownerName);
    if (!$ownerId) {
        throw new \RuntimeException('指定された担当者が見つかりませんでした。');
    }

    // 依頼者のIDを取得
    $ordererId = $row->ordererName ? $this->getStaffId($row->ordererName) : null;
    if ($row->ordererName && !$ordererId) {
        throw new \RuntimeException('指定された発注者が見つかりませんでした。');
    }

    // リンク先ページのIDを取得
    $linkPageId = $row->linkPageName ? $this->getPageId($row->linkPageName) : null;

    $options = [
        'json' => [
            'parent' => [
                'database_id' => $this->targetDatabaseId,
            ],
            'properties' => [
                '名前' => ['title' => [['text' => ['content' => $row->name]]]],
                '種別' => ['select' => ['name' => $row->type]],
                'タグ' => ['multi_select' => array_map(fn (string $tag) => ['name' => $tag], $row->tags)],
                'プロジェクト' => ['relation' => [['id' => $projectId]]],
                '担当者' => ['relation' => [['id' => $ownerId]]],
                '依頼者' => ['relation' => [['id' => $ordererId]]],
                '日付' => ['date' => [
                    'start' => $row->startedDate->format('Y-m-d'),
                    'end' => $row->endedDate?->format('Y-m-d'),
                ]],
            ],
        ],
    ];
    
    if ($row->status) {
        $options['json']['properties']['ステータス'] = ['status' => ['name' => $row->status]];
    }
    
    $bodyBlocks = $row->body ? [[
        'object' => 'block',
        'type' => 'paragraph',
        'paragraph' => [
            'rich_text' => [
                [
                    'type' => 'text',
                    'text' => [
                        'content' => $row->body,
                    ],
                ],
            ],
        ],
    ]] : [];

    $linkPageBlocks = $linkPageId ? [
        [
            'object' => 'block',
            'type' => 'heading_1',
            'heading_1' => [
                'rich_text' => [[
                    'type' => 'text',
                    'text' => [
                        'content' => '関連ページ',
                    ],
                ]],
            ],
        ],
        [
            'object' => 'block',
            'type' => 'link_to_page',
            'link_to_page' => [
                'type' => 'page_id',
                'page_id' => $linkPageId,
            ],
        ],
    ] : [];

    if ($children = array_filter(array_merge($bodyBlocks, $linkPageBlocks)) ?: null) {
        $options['json']['children'] = $children;
    }

    // レコードを挿入
    $res = $this->notionClient->request('POST', '/v1/pages', $options);

    if (201 === $res->getStatusCode()) {
        throw new \RuntimeException($res->getContent(false));
    }
}
  • CSVの「本文」カラムに @ページ名 とか書けば自動でパースしてリンクにしてくれるようにする、とかもできそうですが面倒だったのでやりませんでした

ちなみにリクエスト制限について

参考リンク

GitHubで編集を提案

Discussion

konikoni

有用な記事ありがとうございます!

ちなみにNotion標準のCSV取り込み機能だとできなかったこと
あくまで記事執筆時点の実験結果です。そのうち機能が改善されたりするかもしれません。
マルチセレクトの項目にはどのような書き方をしても値をセットすることができなかった

同じ目的のためにNotionにCSV取り込みしたところ、マルチセレクトのプロパティにもともとあるオプションの値は入れられるようでした!オプションとして入っていない項目は空のままでした。

もしかしたら記事執筆後改善されたのかもしれません。