ScrapboxからNotionへの移行

概要

カミナシはこれまでドキュメント管理ツールにScrapboxを使っていました。 手軽にかけ、リアルタイムに複数人で編集できるのでチームで議論しやすかったです。一方でドキュメント管理ツール以外に使ってるツールが増えたり、ストックしたい情報が探しにくくなっていたので、全社的にNotionを利用することにしました。

今回はドキュメントツールの移行に伴ってScrapboxにあったページを全てNotionにコピーした流れを紹介していきます。

ScrapboxのデータをMarkdownに整形する

Scrapboxはプロジェクトの設定ページからJSONファイルとしてエクスポートできます。 JSONファイルは1行ずつ配列の要素になっています。

{
  "name": "kaminashi",
  "displayName": "kaminashi",
  "exported": 1616557445,
  "pages": [
    {
      "title": "2020/03/02 議事録",
      "created": 1556528208,
      "updated": 1556528208,
      "id": "XXXXXXXX",
      "lines": [
        "2020/03/02 議事録",
        "",
        "",
        "メモ",
        " カミナシ",
        "[** 見出し]",
        "[リンク https://example.com]"
    }
}

Pythonを使ってMarkdownになおす時はJSONファイルを辞書に直して1行ずつ正規表現で直しました。 変換する関数は以下になります。

def scrapbox_to_markdown(text: str) -> str:
    text = text.replace("\u3000", "\t")
    replace_patterns = [
        {'name': 'heading1', 'scrapbox': r'\t*\[#*\*{3,}#* (.+)]', 'markdown': r'# \1'},
        {'name': 'heading2', 'scrapbox': r'\t*\[#*\*{2}#* (.+)]', 'markdown': r'## \1'},
        {'name': 'heading3', 'scrapbox': r'\t*\[#*\*{1}#* (.+)]', 'markdown': r'### \1'},
        {'name': 'link', 'scrapbox': r'\[([^ ]*)[\t| ](http.+)\]', 'markdown': r'[\1](\2)'},
        {'name': 'list', 'scrapbox': r'^\t(\t*)(.*)', 'markdown': r'\1- \2'},
        {'name': 'italic', 'scrapbox': r'\[\/ ([^\]]+)]', 'markdown': r'_\1_'},
        {'name': 'strike', 'scrapbox': r'\[- ([^\]]+)]', 'markdown': r'~~\1~~'}
    ]
    for replace_pattern in replace_patterns:
        text = re.sub(
            replace_pattern['scrapbox'],
            replace_pattern['markdown'],
            text
        )
    return text

見出しはこれから説明するライブラリの都合上3つまでにしています。 リストの部分は出力によってタブだったり空白になっているので一括でタブに変換しています。

Notionライブラリ

Notionは公式APIがありますが、まだプライベートβ版のため内部APIを使用することにしました。 Notionへのアップロードには2つのライブラリを使用しています。

github.com

github.com

notion-py はページを作成したり、ブロックを追加したりできます。 md2notionMarkdownファイルや文字列からNotionのブロックを作成できます。

※注意

2021年3月25日現在は notion-py を使ってAPIを実行すると以下のエラーがでます。

[ERROR] HTTPError: Invalid input.

すでにGItHubのIssueにも起票されていますが、Limitのパラメータを変更することで解消できます。

notion/client.py

        data = {
            "query": search,
            "parentId": parent_id,
     -      "limit": 10000,
     +      "limit": 100,

ページの作成

事前にデータベースを作成して、データベース内のページとして作成していきます。

from notion.client import NotionClient

notion_client = NotionClient(token_v2=NOTION_TOKEN)
collection_view = notion_client.get_collection_view(COLLECTION_VIEW_URL)
new_page = collection_view.collection.add_row()
new_page.title = 'Scrapboxのタイトル'

COLLECTION_VIEW_URL はデータベースのURLです。URLはページIDとビューIDをもっています。

https://www.notion.so/{pageId}?v={viewId}

Collection Viewからページを作成し、タイトルやプロパティを変更できます。

Markdownをページにコピー

Markdownのテキストに変換した文字列をNotionのページにブロック形式で保存します。

from md2notion.upload import convert, uploadBlock

markdown_text = parse_scrapbox_lines(scrapbox_page_lines)
notion_block = convert(markdown_text)
for blockDescriptor in notion_block:
    uploadBlock(blockDescriptor, new_page, '')

ブロックの生成は md2notionconvert 関数を使います。 convert 関数はブロックの配列を生成するので、一個づつuploadBlock 関数を使ってアップロードします。 アップロードするときに先程生成したページを引数に渡すと、ページの内部にブロックがコピーされていきます。

並列実行

これらのブロック生成処理はある程度時間がかかるため、一つの関数にまとめて並列処理を行いました。

from concurrent.futures import ProcessPoolExecutor, wait

with ProcessPoolExecutor(max_workers=10) as executor:
    futures = [executor.submit(upload_markdown_block, scrapbox_page) for scrapbox_page in scrapbox_pages]
    done, not_done = wait(futures)
    [future.result() for future in futures]

def upload_markdown_block(scrapbox_page):
    # 1ページ移行する関数

感想

ScrapboxからNotionに移行するスクリプトPythonで作成しましたがScrapboxの記法が独特で変換するのが大変でした。

特に今回実装した正規表現ではGyazoの画像や内部リンクを移行できていないです。 内部リンクは一度全て移行した後にリンク先のページを検索して更新する必要があります。

次の機会があったらより正確にScrapboxMarkdownを変換できるようにしたいです。