🦁

Kibela から Notion に約2万+件の記事を移行するために移行ツールを作った話

2021/12/21に公開

はじめに

これは先日弊社の社内 Wiki のお引っ越しをした際に体験した様々なうんちくを書き残したものです。
Kibela から Notion への移行ですが、Markdown で Notion にインポートできれば他のプラットフォームでも活きることがあるかも知れません。

沿革

社内 Wiki としてもともとは Kibela を利用してきました。

しかしプロジェクトなどで同時編集をする機会が増えたり、ナレッジの蓄積に社員以外(e.g. パートナーさん or バイトさん)が気軽に投稿できなかったりと辛い思いをしてきました。
Kibela にもゲスト機能はあるのですが月に 1 回でも利用すると通常ユーザと同じ料金がかかるため、管理部としてはナレッジを閲覧するために気軽にゲストアカウントを発行できない辛みと権限問題をどのようにするかを既存のフォルダやグループで考え直さないと行けなかったりと使いこなせなかった経緯があります。

そこで Dropbox に白羽の矢が立ちました。
もともと、クラウドストレージとして利用していたため、その延長で Dropbox Paper を皆利用していきました。
Dropbox Paper 導入初期の反応はおおむね良く、純粋なテキストエディタとしての書きごこちと同時編集機能はとても気に入られていました。
しかし程なくして以下のような課題が浮き彫りになりました。

  • 文字数が多くなるとページローディングがかなり遅延することがある
  • 同時編集ユーザが 5〜7 人を超えるとページがフリーズしたり、オフラインになることがある
  • ナレッジの蓄積場所は変わらず Kibela が優位に立っていたため、Dropbox Paper で書いた記事を Kibela にインポートする手間が発生していた

そんなときに Notion の噂を聞きつけました。
気になってみて調べてみると Dropbox Paper と同じような同時編集機能があり、またゲスト機能も無料で搭載されている。
そして価格も割とリーズナブルで、 Trello やスプレッドシートのような使い方も出来るということです。豊富な機能に惹かれチームの一部で導入してみることにしました。
実際に利用してみてまず感じたことは、文章としての書きごこちは圧倒的に Kibela や Dropbox Paper に軍杯があがるのですが、コンテンツを自由に柔軟に組み合わせたりカスタマイズできる点が非常に魅力的で運用しやすそうということです。

チームでの好評から全社的にもスケールし、まずは社内のポータルをこれまで運用してきた Google Site から Notion に移行しました。
そして社内の部署ページや部活、既存プロジェクトなども徐々に新設/移行され、初めはなかなか使いこなせる人が少なかったのですが時間と共に Notion リテラシーやナレッジや tips が蓄積され市民権を得てきました。

この頃は日報は Kibela で書いていましたが Notion 上で Wiki のデータベースが新設されており、そこにナレッジを蓄積しよういう動きがあり、ナレッジも Kibela ではなく Notion で良いじゃないという雰囲気になっていったんだと思います(要出典)。
もはや日報をかくプラットフォームとしてのみ弊社としては利用していたため、管理部としてはコスト削減と情報の集約を兼ねて Kibela に蓄積された数々の記事を Notion に移行したい要望が上がりました。

そんな経緯で、今回の奮闘記が始まります。
記事の移行では多くの落とし穴があり、一人コーポレートエンジニアとして奮闘したエピソードをここに備忘録として書き残しておきます。

Kibela のエクスポート機能の優秀さ

まず Kibela の標準搭載されている記事の一括エクスポート機能の優秀さに脱帽しました。
弊社の場合は総件数が約 2 万件であったが、ものの数十分で zip 化されたアーカイブファイルが 19 件ほどで以下のような形式でメールに送付されてきました。
解凍後のフォルダ構成は 0-18 までの suffix がついた以下の通りです。

 Downloads/
   ├── kibela-75asa-0/
   │   ├── notes/
   │   │   ├── 1-hoge.md
   │   │   ├── 2-foo.md
   │   │   └── 3-bar.md
   │   └── attachments/
   │       ├── 10.png
   │       ├── 39.jpeg
   │       └── 3.gif
   ├── kibela-75asa-1/
   │   ├── notes
   │   └── attachments
   └── kibela-75asa-2/
       ├── notes
       └── attachments

notes に全てのファイルがマークダウンファイルとしてエクスポートされ、attachments に Kibela 文中やコメントで使用されたファイル(画像や動画、Excel なども含む)がエクスポートされています。
マークダウンファイルには YAML ヘッダーが付属しており、記事のタイトルや作成日時、更新日時、作成者、更新者、フォルダやグループなどが記載されています。
YAML ヘッダーは以下のような形式になっています。

---
id: QmxvZy8zMzc1
path: "/notes/3375"
author: "@asato"
contributors:
  - "@asato"
coediting: true
folders:
  - 日報 / 日報
groups:
  - 日報
published_at: "2019-04-18 10:38:14 +0900"
updated_at: "2020-02-13 13:42:09 +0900"
archived_at:
comments: []
---

# 日報 2019/04/18 asato

ですが、1 つ注意したいのが kibela-75asa-0/notes/1-hoge.md で利用されてるファイルが必ずしも kibela-75asa-0/attachments に存在するわけではないということです。
この仕様で画像処理に関して若干ハマりました。..。詳しくは後述します。

Kibela の標準エクスポートと Notion の標準インポートだけだとここが辛い

誰が書いたかやグループやフォルダなどの情報が記事からわからない

上記のような YAML ヘッダーがあるのですが、Notion のインポート方法ではマークダウンファイルからのインポートとなります。
その際に、せっかく丁寧にエクスポートされた YAML ヘッダーの情報が全て消えてしまいます。
これはまあそうで、ページとしてインポートするとプロパティなどは元より存在しないからなのです。
(データベースのページとしてなら可能なのですが。..)

画像が Notion ページ内で参照できない

記事内で参照している画像が相対パスのため、インポートした際にリンク切れという形になってしまう問題がありました。

e.g. <img src="../attachments/10.png">

このような理由から kibela-to-notion を作ろうと至りました。

さあ、どうしよう

ここで、今のままで主に困っている点を列挙してみることにしました。

  • 誰が書いたか等の YAML ヘッダーの属性情報を Notion でも再現したい
  • 文中で参照している画像をなんとか表示したい

YAML ヘッダー問題

これに関しては Notion に Kibela archive として 1 種のデータベースを作成し、プロパティで YAML ヘッダーの属性値を表現することにしました。
Kibela の現在(2021/12/17)時点での属性は以下の通りです。

  • id
  • path
  • author
  • contributors
  • coediting
  • folders
  • groups
  • published_at
  • updated_at
  • archived_at
  • comments

この中で主に利用するであろう属性を以下の通りピックアップしました。

  • author
  • contributors
  • folders
  • groups
  • published_at
  • updated_at
  • comments

そしてこの属性を Notion のプロパティで表現すると以下の通りのデータタイプとなります。
Notion のデータタイプ表記は API Reference の Property Object より。

  • author: select
  • contributors: multi_select
  • folders: multi_select
  • groups: multi_select
  • published_at: date
  • updated_at: date
  • comments: text

コメントはできれば Notion のコメントにあてがいたかったのですが、現時点(2021/12/17)ではまだ API が提供されておらず断念しました。

このようにあらかじめデータベースにプロパティを設定しておくことで、あとはローカルの md ファイルから YAML ヘッダーを読み取り、Notion のデータベースに存在する同じ ID のページのプロパティを更新することで機能実現はできそうです。

このようにしていざ実装を進めていると思わぬ盲点にエンカウンタしました。

select multi_select の場合選択肢に同じ名前が存在していても同一とみなされない

これは内部では ID が採番され、Notion 内部の処理では ID 管理されているためです。
そのため、ローカルの 1-hoge.md と 2-piyo.md にて YAML ヘッダーに @author: @asato と記載があるとします。
このまま Notion のプロパティを更新すると author プロパティの選択肢に @asato, @asato と複数の選択肢が生成されてしまいます。
ID で管理・指定すれば良いのですが、今回のユースケースではローカルに大量の選択肢が値として存在するため相応しくありません。

Redis の導入

これを解消するため、KVS の Redis を導入しました。
これにより各 YAML ヘッダーの属性値の値と ID を記録することにしました。
シナリオとしては以下の通りです。

  1. YAML ヘッダーをパースし各属性値を読み込む
  2. Redis に属性値があるか確認する
  3. あれば ID として Notion をアップデートする
  4. なければ値として Notion にアップサートし戻り値の ID を Redis に記録する

key-value の形式としては ${属性名:値}:${Notion の選択肢 ID} としました。
このようにすることで YAML ヘッダー問題も解消出来ました。

画像の参照問題

さて、次は文中で参照してる画像問題です。
理想としては文中でインライン表示されていて欲しいところですね。
ですがプロダクト作成時には画像のアップロード機能が API として提供されておらず代案を考えることになります。
そこで、今回はストレージ先として S3 や Google Drive を使用しようと至りました。

まず着手したのはストレージ先として S3 を使う方法です。
S3 の公開バケットに画像をアップロードし、Amazon ACL でファイル自体の URL を知らなければファイルに到達できないようにし、ファイル ID 自体は UUID v4 で生成されたランダムな ID で管理すれば良いのでは? と考えました。
これにより Gyazo や GitHub にアップロードした画像のレベルでのセキュリティは担保されると考えです。

ACL Bucket Policy は以下のようにしました。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AddPerm",
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": ["arn:aws:s3:::kibela-to-notion/*/*"]
    }
  ]
}

そしてバケット設定は以下のようにしました。

Bucket Setting

これにより以下のようなフォルダ構成になり、エンドユーザ側からはファイル単体の URL をアクセスできないようにしました。
また kibela-to-notion/0 を URL で指定してもバケット設定でアクセスを許可していないため配下のファイルやフォルダが列挙されることはありません。

 kibela-to-notion/
   ├── 0/
   │   ├── ${UUIDv4}_32.png
   │   ├── ${UUIDv4}_48.jpeg
   │   └── ${UUIDv4}_92.gif
   ├── 1
   └── 2

こうすることでローカルの attachments フォルダに格納されているフィイル群は全てアップロード可能となりました。

文中の画像参照先を置き換える

次は、実際に md ファイル文中で相対パスで参照しているファイルを S3 の URL に置き換える処理が必要です。
実際のファイルはこのようになっています。

## S-line  ~エスライン~

<img title='cyataku.jpg' src='../attachments/23.jpg' width="250" data-meta='{"width":250,"height":250}'>

これをこうしたい。

## S-line  ~エスライン~

<img title='cyataku.jpg' src=${S3URL} width="250" data-meta='{"width":250,"height":250}'>

オリジナルの文中では Kibela のサーバサイドで採番されたファイル名をしようしています。
そのため、一括でファイルを先にアップロードし、ローカルの Redis で元のファイル名(e.g. 43.png)とアップロード後の S3 URL を KVS に記録しておくことにしました。

さていよいよ機は熟しました。

後は、全ての md ファイルをチャンクし、各行ごとに <img * src='../attachments/{n}.*' > の形式で RegExp を書き人したものを置き換えます。
置き換えたファイルはオリジナルのものとは区別したいため out/ に出力することにしました。

そして Notion に手動インポートをすれば、S3 URL は外部 URL として認識されるため文中でも参照されるという訳です。

これでめでたしとなるはずでした。..
しかしそうは問屋が卸さない。そうセキュリティ上の懸念にエンカウンタしました。

セキュリティ上の懸念

プロトタイプを実装し社内の技術顧問と相談した際に、いくら推測されにくい URL にしても今後 n 年と運用していく際に何もインシデントが起こらない保証はないし、それに関して自分がどこまで責任をとれるか。というごもっともな理由で諭されました。

これが例えばサークルやコミュニティの Wiki であれば比較的許されるかもしれないですが、案件情報やエンドユーザが外部に公開されると思ってアップロードしたファイルではないため、そういった面で軽率でした。

個人的には、実装初期の方では

GitHub や Gyazo も同様のファイル管理をしているじゃあないか

と思ったのですが、以下のように GitHub は公式に注意勧告をしていました。

Warning: If you add an image or video to a pull request or issue comment, anyone can view the anonymized URL without authentication, even if the pull request is in a private repository. To keep sensitive media files private, serve them from a private network or server that requires authentication. For more information on anonymized URLs see "About anonymized URLs".

FYI: Attaching files: You can convey information by attaching a variety of file types to your issues and pull requests.

Gyazo はサービスの性質上、それでも気軽に URL で何かを共有したいといったユースケースに特化しているため、比較対象にはそぐわないなと考え直しました。

このような背景からセキュリティ要件の高いファイルを安全に参照できるようにストレージ先に GoogleDrive を選択できるように実装することにしました。

実装としては I/F を挟み、S3 or GoogleDrive のリポジトリを選択できるようにしたので、各々のユースケースに合わせることができます。
Drive 側の手順としては、GCP のサービスアカウントを作成し、任意の Google Drive フォルダにサービスアカウントを招待します。
後はローカルでプログラムを走らせ、ファイルをアップロードし、アップロードに成功した場合 URL をファイル名と紐付けて保存と 主なロジックは S3 と同じです。

この場合、Notion の文中ではインライン表示はできないが URL ジャンプで該当のファイル自体にはアクセスできます。
例えば共有設定で社内のメンバーだけと限定できるのでセキュリティ上の懸念は払拭できます。

また、執筆時点(2021/12/17)ではまだ Notion API が以下の機能を提供していないため、現状文中でのインライン表示は難しいです。

  • md ファイルでインポートした文中の Image Block の取得
  • Google Drive Embed Block

今後のアップデートに期待したいところです。

Notion API [beta] の辛いところ

実装時、色々と辛い場面に遭遇しました。
このセクションではいくつかピックアップしたいと思います。

md ファイルでインポートした場合、文中で参照されている Image Block が取得できない

これは本当に驚きました。
実装時に Image Block が GA となったためこれは画像問題も解決するのでは? と思い、Retrieve block children でページ内のブロックを取得しました。
ところが、GUI 上ではそこに画像があるのに、API ではそのブロック自体が存在しないことになっていたのです。
現状、API もベータであるためサポートされていないブロックタイプは { type: unsupported } と表示され、「あゝ、まだなのか」と理解できます。
しかし ImageBlock は対応されているがインポートした image Block は別ということらしいです。
試しにインポートしたページに直接画像をアップロードすると難なく Image Block が表示されました。
これはサポートに問い合わせたところ、「開発チームに聞いてみるね!」と言われ、数日たったあと「インポートしたファイルはまだ実装していないっぽい! 開発チームに需要あったよっていっとくね!」と返事がきました。
近い将来に実装されるのを楽しみに待ちましょう。

ページ数が多いデータベースに対して API を走らせるとたまに 502 Bad Gateway が返ってくる

データベースでののみ確認した事例です。
別プロジェクトでも遭遇しました。
今回は CLI なので失敗したら再度すればいいのですが、処理を続けないといけないユースケースであれば 502 は Sentry などに記録はするが throw new Error() はしない運用などがおすすめです。

平均 3 回/sec のリクエスト制限

これは YAML ヘッダーの属性値で Notion ページを更新する処理(Tag)を非同期にした際に遭遇しました。
$ yarn start:TAG -m 18 とするとローカルにある Kibela アーカイブファイルの 0〜18 までの notes/_ にある _.md を Notion ページと照合し更新していくのですが、プロトでは直列で記述していたため実行時間がネックでした。
ただ何も考えずに await Promise.all() で回してしまうと、レギュレーションに引っかかってしまいます。
そこで 1 秒間に n 回のように指定できないかなと模索していたところ、以下のステキな記事と巡り会えました。

https://zenn.dev/tsugitta/articles/concurrency-lock

まさにやりたかったことで、実装のスニペットでした。

@tsugitta さんに感謝しつつ少しリファクタしたものを実装しました。

Notion API のリクエスト制限は @CatNose さんのこちらの記事もおすすめです

https://zenn.dev/catnose99/articles/ab3afcb4338cbe

md ファイルから手動でのインポートする際のあれこれ

これは 開発者が利用可能な API として提供されてないのですが、手動で md ファイルをインポートする過程で遭遇した事例です。

HTML タグで書かれたテーブルはインポートできない

Kibela は他のマークダウンと同様に HTML をサポートしています。
そのため、マークダウンで書くと辛いテーブルなどは HTML で記述されることも多々あります。
しかし Notion ではテーブルは全てデータベースもしくはシンプルテーブルとなります。
マークダウンのテーブルだと成功するのですが <th>, <td> などのタグで記述するとインポート時に失敗するようです。
パース対象としてはマークダウンで記述されたテーブルのみをサポートしているのだと思います。

キャッシュの影響かアップロード件数がトータル件数や以前のアップロード件数となることがある

約 2 万件の md ファイルをインポートする際、任意のフォルダにいくつかのファイルがあるとします。
ディレクトリにすると以下のような形です

.
├── 0/
│   └── notes(100件)/
│       ├── 1-hoge.md
│       └── ...
├── 1/
│   └── notes(40件)/
│       ├── 94-fuga.md
│       └── ...
└── 2/
    └── notes(450件)/
        ├── 999-piyo.md
        └── ...

またインポートの手順としては以下です

  • 0 から順番にインポート作業を行う
  • 0 フォルダの notes をアップロードする
    • Notion 内部の インポートフローは 1. ファイルのアップロード 2. ファイルのインポート
  • アップロードに成功すると(n 件)と表示される

この際に稀にアップロードした件数がこれまで合算になったり、以前アップロードしたフォルダの件数で表示されることが多々ありました。
その場合は、一度アップロードを中止(スーパーリロードやタブを閉じる)し、再度ファイルを選びインポートすると正常に機能します。
キャッシュ? の影響なのでしょうか。..。

インポートは全てページになる、データベースに md ファイルの直接インポートはできない

地味にしんどかったのがこれです。
最終的にデータベースに収束するため、ページを挟む必要はないのです。
現状(2021/12/17 時点)ではデータベースは CSV 形式のみインポートに対応しているようです。

Notion で大量のページを移動できない

上記の理由でまずはページにインポートし、そこから複数のページをドラッグしメニューより"ページを移動"をするのですが移動できるサイズがあらかじめ決められているようです。
ちなみに許容サイズを超えると、一瞬移動に成功するのですが、すぐさまロールバックされてしまいます。

成果物

こちらが弊社の Kibela archive ページです。
このデータベースを他のページで Linked Database として参照し適宜好きなフィルターをかけて利用しています。

Kibela: archive/notes: main

リポジトリはこちらです

https://github.com/75asa/kibela-to-notion

制作期間としては 僕一人で 7 月の終わりから実装着手し、10 月の頭に GA したので 2.5 ヶ月ほどでしょうか
GitHub の Commit-アクティビティ を振り返ると最初ガッツリコミットしてプロトを作成し、その後 FB でちょこちょとリファクタした感じを思い出しました。

kibela-to-notion: Commits

インポート元は Kibela のみですが、md ファイルをエクスポートできれば他のプラットフォームのものでも流用できると思います。
Issue, PR, Fork はお気軽にどうぞ。

まとめ

今回の Kibela から Notion の移行は会社としては「いつかやらないとね〜、だけど人手が足りない」「ちゃんとインポートするには手を入れないとな」みたいな温度感でした。
しかし、日報を書くだけのツールになってしまってる Kibela にもライセンス代はもちろん中々かかっていたので、管理部としては早く統合したいという気持ちが強かったです。
此度のプロジェクトで節約に大いに貢献できたのでとても嬉しいです。

GitHubで編集を提案

Discussion