![見出し画像](https://assets.st-note.com/production/uploads/images/140752614/rectangle_large_type_2_c6ef9a63def9739e96dad2e85274c2af.png?width=1200)
NotionからEsaへの移行
ドキュメント共有ツールをNotionからEsaに移行しました。EsaにはNotionのデータを直接インポートする機能はないため、APIを使用する必要があります。この記事では、NotionからエクスポートしたデータをPythonを使ってEsaにインポートする方法を紹介します。
なお、うちの環境では画像などの添付ファイルがほとんどなかったので、ページデータのみをインポートする方法に焦点を当てています。
▍Notionでのエクスポート
Notionでは、データをワークスペース全体または特定のページごとにエクスポートできます。
エクスポートを行う際には、[サブページを含める] および [サブページのフォルダーを作成] のオプションを有効にしてください。ワークスペース全体をエクスポートする場合、[サブページを含める] オプションは表示されません。
また、本記事ではページのみを対象にしているので、対象コンテンツを[ファイルや画像以外] としています。
![](https://assets.st-note.com/img/1715765688165-HEjITL8iG2.png)
エクスポートの詳細な手順については、以下の公式ヘルプを参照ください。
エクスポートされたデータはzip形式でダウンロードされます。このファイルを展開(解凍)すると、「Export」という名前を含むフォルダが生成されます。
▍NotionのページとEsaの記事の対応
手順の前に、Notionのページ構造とEsaの記事構造の対応について説明します。
■ Notionのページ
Notionで以下のようなページ構造があるとします。
my page
├─ child1
│ └─ grandchild1-1
└─ child2
これを「my page」でエクスポートした場合、以下のようなファイル構造が作られます。
UUID1_Export-UUID2
├─ my page UUID3.md
└─ my page UUID3
├─ child1 UUID4.md
├─ child1 UUID4
│ └─ grandchild1-1 UUID6.md
└─ child2 UUID5.md
UUIDはエクスポート時に自動で付与されます。
.md はマークダウン形式のファイルを示します。これがNotionのページに該当します。
親ページ内に子ページが含まれる場合、親ページと同名のフォルダが作成されます。
■ Esaの記事
Notionのページ構造をそのままEsaに移行する場合、以下の構造が考えられます。
my page
my page/child1
my page/child1/grandchild1-1
my page/child2
しかし、Esaでは最上位の「my page」は (no category) に区分されてしまうため、対処が求められます。
うちでは、以下のようにルートページをREADMEとして扱うことにしました。
my page/README
my page/child1
my page/child1/grandchild1-1
my page/child2
この対応方法は一例ですので、異なる方法を希望する場合は後述するコードを適宜変更してください。
READMEの扱いなど、Esaの記事構造については、以下の公式ヘルプを参照ください。
▍Esaへのインポート
■ 環境準備
コードでは pathlib モジュールを使用しているため、Python 3.4 以降の環境が必要です(開発環境は 3.11.6)。また、HTTPリクエストの送信には requests ライブラリを使用しているので、事前にインストールが必要です。
ファイルの配置場所は任意ですが、PythonファイルとNotionのエクスポートデータを同じフォルダに配置するとファイル指定がスムーズになります。
■ コードの内容
以下のコードは、指定されたNotionのエクスポートフォルダからデータを読み込み、それをEsaにインポートするためのものです。なお、エラー処理は実装していないのでご容赦ください。
import requests
from pathlib import Path
def post_to_esa(api_url, api_token, name, body_md, category, wip=False, user='esa_bot'):
"""
Esa APIで新しい投稿を作成するためのPOSTリクエストを送信する。
Args:
api_url (str): Esa APIのURL。
api_token (str): Esa APIトークン。
name (str): 投稿の名前。
body_md (str): 投稿のMarkdownコンテンツ。
category (str): 投稿のカテゴリ。
wip (bool): 投稿が作成中(WIP)かどうか。デフォルトはFalse。
user (str): 投稿者のユーザー名。デフォルトは'esa_bot'。
"""
headers = {
'Authorization': f'Bearer {api_token}',
'Content-Type': 'application/json'
}
post_data = {
'post': {
'name': name,
'body_md': body_md,
'category': category,
'wip': wip,
'user': user,
}
}
response = requests.post(api_url, headers=headers, json=post_data)
if response.status_code == 201:
print(f'投稿成功: {category}/{name}')
else:
print(f'投稿失敗: {category}/{name}')
print(response.text)
def extract_name(item):
"""
ファイルまたはディレクトリの名前からUUIDを除外して名前を抽出する。
Args:
item (str): ファイルまたはディレクトリの名前。
Returns:
str: UUIDを除外した名前。
"""
return item.rsplit(' ', 1)[0]
def process_root_directory(root_directory, api_url, api_token):
"""
ルートディレクトリを処理し、各Markdownファイルをカテゴリとしてサブディレクトリを処理する。
Args:
root_directory (str): ルートディレクトリのパス。
api_url (str): Esa APIのURL。
api_token (str): Esa APIトークン。
"""
root_path = Path(root_directory).resolve()
# ルートディレクトリ直下のMarkdownファイルをカテゴリとして処理
for item in root_path.iterdir():
if item.is_file() and item.suffix == '.md':
category_name = extract_name(item.stem)
process_file(item, category_name, api_url, api_token, name_override='README')
# 対応するサブディレクトリの処理を開始
for item in root_path.iterdir():
if item.is_dir():
category_name = extract_name(item.stem)
process_directory(item, category_name, api_url, api_token)
def process_file(file_path, category, api_url, api_token, name_override=None):
"""
単一のMarkdownファイルを処理してEsaに投稿する。
Args:
file_path (Path): Markdownファイルのパス。
category (str): 投稿のカテゴリ。
api_url (str): Esa APIのURL。
api_token (str): Esa APIトークン。
name_override (str, optional): 投稿に使用する名前。デフォルトはNone。
"""
with open(file_path, 'r', encoding='utf-8') as file:
body_md = file.read()
name = name_override if name_override else extract_name(file_path.stem)
post_to_esa(api_url, api_token, name, body_md, category)
def process_directory(directory_path, current_category, api_url, api_token):
"""
Markdownファイルとサブディレクトリを再帰的に処理する。
Args:
directory_path (Path): ディレクトリのパス。
current_category (str): 現在処理しているカテゴリ。
api_url (str): Esa APIのURL。
api_token (str): Esa APIトークン。
"""
for item in Path(directory_path).iterdir():
if item.is_dir():
new_category = f'{current_category}/{extract_name(item.stem)}'
process_directory(item, new_category, api_url, api_token)
elif item.is_file() and item.suffix == '.md':
process_file(item, current_category, api_url, api_token)
if __name__ == '__main__':
# EsaのAPIトークンとチーム名を設定
ESA_API_TOKEN = 'your_esa_api_token'
ESA_TEAM_NAME = 'your_esa_team_name'
ESA_API_URL = f'https://api.esa.io/v1/teams/{ESA_TEAM_NAME}/posts'
# インポート対象のデータのルートフォルダを指定
root_directory = Path('your_notion_export_folder').resolve()
# ルートディレクトリの処理を開始
process_root_directory(root_directory, ESA_API_URL, ESA_API_TOKEN)
指定するパラメーターは以下です。
ESA_API_TOKEN:EsaのAPIのアクセストークン
ESA_TEAM_NAME:Esaのチーム名
root_directory:インポート対象のデータのルートフォルダ
既定では、記事の WIP は False(公開)で、投稿者は esa_bot と指定しています。
APIのパラメーターやリクエスト数の制限、制限の緩和については以下の公式ドキュメントを参照ください。特に、大量のページを一度にインポートする場合には必ずご覧ください。
▍インポート後の対応
インポートした記事に対しては、いくつかの注意点があります。
・別ページへのリンク
Notion内の別ページへのリンクは、インポート後も元のリンク先を指しているため、正しく機能しません。これらのリンクは削除するか、またはEsaの適切な記事を指すように修正する必要があります。
・Notion特有の表現
Notionで利用できるトグル、コールアウト、列などの特殊な表現は、Esaでは再現できません。これらはEsaのマークダウン形式に合わせて修正または削除する必要があります。
特に列を利用して情報を構造化していた場合は注意が必要です。例えば、Notionで以下のように項目とその値を列で表示していたとします。
住所 東京都文京区...
電話 050-3612-...
マークダウンでは列の概念がないため、Esaでは以下のように項目が先に並び、次に値が続く形になります。
住所
電話
東京都文京区...
050-3612-...
元の構造が失われてしまうため、情報の並びを正しく修正する必要があります。
ツール特有の機能や表現は魅力的ですが、他のプラットフォームへの移行時には障壁となることもあります。このようなバランスを取ることは常に課題ですね。
▍おわりに
NotionのページをEsaに移行する方法を紹介しました。インポート方法のニーズはそれぞれ異なるかと思いますが、この内容が参考になれば幸いに思います。
ご精読いただき、ありがとうございました!
私たちのデジタル技術活用の記事は以下のマガジンにあります。ぜひご覧ください!