以下の内容はhttps://product.st.inc/entry/2025/05/20/142845より取得しました。


10万ページ以上のナレッジをNotionに移行しました (スクリプト編)

こんにちは。yubrotです。STORESではWebを主戦場に色々見ていますが、今回はそれとはまったく関係ない話をします。

事の始まり: Notionに社内のナレッジを集約する

という意思決定を組織として行いました。1 STORESには、既に別のナレッジデータベースにこれまでのドキュメントがたくさん蓄積されていましたので、それをNotionにまるっと移行する必要があります。また、ナレッジの集約を目的としているため、元のナレッジデータベースは移行後に閉じる想定をします。そのため、

  • すべてのドキュメントを、
  • できるだけ情報の損失や読みやすさの低下を避けつつ、
  • 全 STORES 社員が利用者が混乱しない形で

移行しきることが求められます。

やり方: 移行にNotion APIをフル活用する

移行にNotion APIをフル活用することにしました。Notionの Web UI ではドキュメントのインポート機能が提供されていますが、これは使用せず、 Notionページの作成、コンテンツの挿入などのデータ移行の全てをAPIで行います。

事前に少し検証したところ、APIを活用した移行は十分に現実的で、かつ上記のような要求がある中で最も細かい調整が効き、情報のロスがない手段だと判断しました。以下で解説していきますが、Web UI で提供されているインポート機能ではなかなか実現が難しいものもあるかと思います。

本記事では、Notion APIを活用した移行の詳細を以下の2章に分けて紹介したいと思います。

  1. APIでNotionページを組み立てる
  2. 10万ページ以上の移行をスケジュールする

(1) APIでNotionページを組み立てる

本章の成果物を npm package として公開しています!

Notionでは、コンテンツのすべては ブロック として表現されています。これはAPI上でも同様で、Blocks APIを用いてページのコンテンツを組み立てることができます。例として、一部が太字強調されたテキストを含む、パラグラフブロックをAPIで追加してみます。

const client = new notion.Client({ auth: process.env.NOTION_API_KEY })
client.blocks.children.append({
  // これらブロックの追加先; 以下、サンプルコードのid指定は適当な例になります
  block_id: '0f552c1c4d425a89b32b25208aea0d73',
  children: [
    {
      object: 'block',
      type: 'paragraph',
      paragraph: {
        rich_text: [
          { type: 'text', text: { content: 'Hello, ' } },
          { type: 'text', annotations: { bold: true }, text: { content: 'World!' } },
        ],
      },
    },
  ],
})

実行結果

ちょっと組み立てるのが面倒そうなJSONですね。一方で、このような入力を取るということは、APIが対応している範囲で、限りなくNotionネイティブな表現でコンテンツを作成できることを意味しています。移行元のドキュメントからできるだけ情報を落とさないような工夫を、APIが許す限りこだわることができます。いくつか取り上げていきましょう。

リンクはNotionのメンションに変換する

これまで蓄積された様々な情報 (たとえば、Slackのログなど) には、旧ナレッジデータベース上のページを指すURLが残っています。これらをすべて遡って書き換えなくても問題ないように、 「旧ナレッジデータベース上のページのURLにアクセスしたら、移行先Notionページに自動リダイレクトする」 というChrome拡張を別途用意しました。

しかし依然として、ページ間のつながりは重要な情報です。Notion上でページ同士が Notionネイティブの表現で 繋がっていることで、バックリンクが機能するほか、Notion AIの情報源が増えるなどの期待もあります。

APIを使用している場合、このようなNotionネイティブのページ参照...メンションの作成は簡単です。パラグラフ等のブロック中の rich_text の要素として含めることができます:

{
  object: 'block',
  type: 'paragraph',
  paragraph: {
    rich_text: [
      { type: 'mention', mention: { page: { id: "b4395f2514af5e249cc2003cd2a245af" } } }
      { type: 'text', text: { content: ' へのメンション' } },
    ],
  },
}

実行結果

しかし、メンションを作成する上での真の課題は、「データの移行中に、他のページ (まだデータ移行前かもしれない) へのリンクを有効なNotionページIDに置き換えなければならない」という点です。NotionページIDを発行・管理する方法は次章で取り上げています。

Notionでは、画像はインライン要素ではない。しかしテーブルと画像を分離して置くことはできる

Notionにおいて、画像はブロックです。 rich_text に含められる要素 (いわゆるインライン要素) ではありません。そのため、 rich_text が要求される箇所...たとえばテーブルのセルなどに画像が含まれるドキュメントは、そのままNotionに移行することはできません。

例: そのまま移行はできないテーブル

これについては、アドホックながらも、変換を工夫して読みやすさの低下を防ぐようにしました。アドホックとはどういうことかというと、具体的には以下のように変換されます:

移行スクリプトによるNotionへの移行結果

スクリプトでは、まず内部的に FlexibleBlock というインライン要素 かもしれない 中間表現を設けます。コンテンツの変換では、まずこの FlexibleBlock[] を生成します。それから、コンテンツが挿入される場所次第でインライン要素とブロック要素を分離するような変換をかけられるようにしています。

export function toInlines(fbs: FlexibleBlock[], referencedBlocks: Block[] = []): [Inline[], Block[]] {
  const ret: Inline[] = []

  for (const fb of fbs) {
    if (fb.type == 'block') {
      // いわゆるインライン要素が要求されるところにブロックは含められないので、アンカー文字列 `*n` で置き換える
      const anchor = text(`*${referencedBlocks.length + 1}`, { code: true })
      ret.push(...anchor)
      // ブロック自身は `referencedBlocks` に加える; キャプションを付与できる種類のブロックなら、同じくアンカー文字列 `*n` を加える
      referencedBlocks.push(mapCaption(fb, caption => [...anchor, ...text(' '), ...caption]))
    } else {
      ret.push(fb)
    }
  }
  return [ret, referencedBlocks]
}

実際にこの関数を使用している例として、markdown tableからNotionブロックへの変換を行っている https://github.com/yubrot/notion-ext/blob/main/notion-markdown/src/translate.ts#L193-L211 などがあります。

(2) 10万ページ以上の移行をスケジュールする

Notion移行の課題のうち、

  • できるだけ情報の損失や読みやすさの低下を避けつつ、

というところについては、前章のような工夫を重ねることである程度達成できる見込みが立ちました。一方で、Notionへの移行対象となるページの数は10万以上あるので、

  • すべてのドキュメントを、
  • 全 STORES 社員が利用者が混乱しない形で

移行できるようスケジュールする必要があります。

あるページの移行を、 allocate, import, freeze の3ステップに分ける

まず、ページごとに3段階のステップを分けて移行できるようにしました。

  1. allocate - 対応するNotionページを確保する。まだコンテンツは空
  2. import - @yubrot/notion-markdown を使い、移行元のドキュメントをmarkdown形式から変換してNotionページに挿入する
  3. freeze - 移行元のページを、移行先Notionページへのリンクに書き換える、ロックするなどの修正を行い、このページが移行済みとユーザにわかる形にする

allocateimport の分離はとても重要です。 すべてのページの移行は、土日の間にバッチスクリプトを走らせておけばまるっと移行しきれる、という量ではありません。そのためページ移行は、 ソースの特定の階層ごとに移行を行うアナウンスをして移行を実施していく 、という形を取ることにしました。例えば以下のようなスケジュールです:

日時 移行対象
03/15 /XXX部門 以下のページ
03/16 /YYY部門/AAA 以下のページ
03/17 /YYY部門/BBB 以下のページ

このような階層ごとの移行を行う上で、階層の外、まだ移行していないページへのリンクが有効なメンションとなるように import するには、階層の外のページもまず確保できる allocate ステップを設ける必要がありました。2

また freeze ステップは、ページの移行後は移行先のNotionページの利用を促さなければならない、という点で重要です。いずれ移行元のナレッジデータベースは削除されるので、誤って移行済みの元ページを編集してしまうと、その変更はいずれ失われる運命とってしまいます。

また、ページごとの移行進捗をデータベースに保存するようにしました。以下はPrismaによるスキーマ定義です:

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

// ページごとの移行状態
model SourcePageMigration {
  url          String    @id
  notionPageId String    @unique
  importedAt   DateTime?
  freezedAt    DateTime?
  createdAt    DateTime  @default(now())
  updatedAt    DateTime  @updatedAt
}

データベースの整合性をそのまま競合処理に活用している他、移行スクリプトの再開・並列実行もこれでサポートしています。

Source インターフェースを設け、任意の移行元ナレッジデータベースを実装として与える

移行元のナレッジデータベースから、ページ一覧や各ページの内容を取得する必要がありますが、それを Source というインターフェースで統一的に表現することにしました。

export interface Source {
  id: string
  pages(options?: {
    cursor?: string
    pathStartsWith?: Path
    // ...
  }): Promise<{
    pages: Page[]
    nextCursor?: string
    totalCount?: number
  }>
  page(url: string): Promise<Page | null>
  ref(url: string): Ref | null
}

export type Ref =
  | { type: 'page'; url: string }
  | { type: 'path'; path: Path }
  | { type: 'image'; url: string }
  | { type: 'embed'; url: string }

export type Path = string[]

export interface Page {
  source: Source
  url: string
  path: Path
  contents(): Promise<string>
  freeze?(notionPageUrl: string): Promise<void>
  // ...
}

この型定義は一発で書き出したものではなく、移行スクリプト実装の中で調整を加えつつ固めていきました。この定義の背景にはいくつかの判断があります:

Source は移行のための最低限の機能を提供する

Source はまず、特定の範囲のページを列挙する pages, 特定のURLのページを読み取る page を提供します。また、あるURLがこのソースに属する要素を指すものかか判断し、URLならノーマライズする ref を提供し、あるURLがどのソースのどの要素かというのを判定できるようにします。

Page はコンテンツをmarkdown形式で提供するものとする

ドキュメントの変換を統一的に、また質を保つ上で、markdownを中間言語とします。

Page は特定のパス(Path)に対応するものとする

「その移行元ページを、どのNotionページに対応付けるか」をこの値で決定します。

ソース上の要素の識別は、すべてURLで行う

移行元となるソースの実装は複数存在します。さらに、ソースをまたいだリンクも多数存在します。そのため、例えば「ページ番号」のような、ソース内でのみユニークと思われる識別子を使用するのではなく、グローバルにユニークなURLを識別子として常に使用します。これにより、例えば docs-a.example.com/pages/123 から docs-b.example.com/pags/123 へのリンクも区別して扱えます。

※URLが一意とならないようなソースもありますが ref 等がURLのノーマライズを伴うものとします

移行を実施していく

本章で解説した移行スクリプトの実装例を yubrot/notion-extのmigrator-example/ で公開しています! こちらは利用者ごとにカスタマイズすることになるだろうと思い、 npm package 化はしていません。

10万件以上のページを移行を実施していくにあたっては、「移行スクリプト自身にも想定できていなかった入力があってエラーが発生した」というような何らかの問題が存在しないはずがないので、エラーもまたデータベースに記録するようにしました。

// schema.prisma
model SourcePageMigrationError {
  id        Int      @id @default(autoincrement())
  url       String
  message   String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@index([url])
}

移行に失敗したページについてはこのレコードを作成しつつ、移行スクリプト自体は停止せず移行を進めていきます。ここでも allocate ステップを分離したことが活きていて、あるページの依存先ページは、 allocate さえ終わっていればNotionページIDが存在するので、そのページへのリンクを有効なメンションに置き換えつつページ移行を進めることができます。

移行を終えて

移行の進捗を随時Slackでアナウンスしつつ、移行状態を確認できるNotionページを作って進めてきましたが、大きな混乱もなく無事に移行を終えることができました。

振り返るとこのNotion移行は、要求ははっきりしているもののひと手間では済まず、しかしサクサクと実装して取り組めるプログラミング課題で、いい息抜きになりました。Notionを本格的に使いたい方の参考になれば幸いです。

最後に、本記事では紹介していない大きな課題がもう一つあります。プライベートな画像リソースの扱いです。これについては別途記事が公開されると思いますのでしばらくお待ち下さい…!


  1. この意思決定の背景は省略します (別の誰かがブログ記事を書くかもしれません!) また、開発・運用のためのドキュメントはgitリポジトリ上で引き続き取り扱っています
  2. 参照情報に基づいてトポロジカルソートをするという方法も考えられますが、相互参照するページも存在するため、この分離はいずれにせよ必要でした



以上の内容はhttps://product.st.inc/entry/2025/05/20/142845より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14