【Notion限界突破】数千ページのMarkdown大規模移行ハック:文字化け・階層崩壊・リンク切れを完全殲滅する自動化パイプライン
エンジニア組織のナレッジベースをObsidian、Qiita、あるいは各所のGitリポジトリ(Markdown)からNotionへ移行する――。
この一見シンプルなタスクが、数千ページ規模に膨らんだ途端、地獄の様な手作業へと変貌することは、場数を踏んだDevOpsエンジニアやテックリードであれば誰もが知るところだ。
Notion公式のインポート機能は、小規模なドキュメントであれば十分機能する。しかし、これを数千〜数万ページ、しかも階層構造を維持したまま流し込もうとすれば、以下の「3大悪夢」に必ず直面する。
1. 文字化け(Encoding Hell): Shift-JISやWindows-31Jが混在する遺産(レガシー)Markdownが、UTF-8への強制変換時に文字化け、あるいはパースエラーを引き起こす。
2. 階層構造の消滅(Flat Nightmare): ディレクトリ階層がNotion側でフラットに展開され、親ページと子ページのリンク関係が完全に破壊される。
3. 画像と相対パスの孤立(Broken Links): `` のような相対パスがNotionのブロック構造と同期できず、すべてリンク切れになる。
GUIのポチポチ作業でこれを解決しようなどと考えてはならない。人間の手作業はスケールしない。
本稿では、Notion APIとNode.jsを用いた完全自動化パイプラインを構築し、数千ページのMarkdownを文字化けゼロ・完全な階層構造・アセットリンク完備の状態でNotionへねじ込むための「極限の移行ハック」を伝授する。
—
1. 移行前夜:ローカルファイルシステムの前処理(Pre-processing)
Notion APIにデータを流し込む前に、源流となるMarkdownファイルを完璧に正規化する必要がある。NotionのAPIは寛容ではない。不正な構文や不統一な改行コードは、インポート処理そのものをクラッシュさせるか、データベースの破損を招く。
1.1 エンコーディングの完全統一(UTF-8 with BOMなし)
レガシーな環境からエクスポートされたMarkdownには、Shift-JISやCP932、あるいはBOM付きUTF-8が混入している。これらをすべて、例外なく `UTF-8 (No BOM)` に変換する。
以下のシェルスクリプトを移行元ディレクトリのルートで実行し、すべてのファイルを強制的にUTF-8へ変換する。
!/bin/bash
enc_normalize.sh
依存関係: iconv, file
find . -type f -name “.md” | while read -r file; do
# 現在の文字コードを判定
encoding=$(file -bi “$file” | sed -n ‘s/.charset=\(.\)/\1/p’)
if [ “$encoding” != “utf-8” ] && [ “$encoding” != “us-ascii” ]; then
echo “Converting: $file ($encoding -> UTF-8)”
iconv -f “$encoding” -t UTF-8//IGNORE “$file” -o “${file}.tmp” && mv “${file}.tmp” “$file”
fi
# BOMの削除(存在する場合)
sed -i ‘1s/^\xEF\xBB\xBF//’ “$file”
done
echo “Encoding normalization completed.”
1.2 フロントマター(Frontmatter)の標準化
ObsidianやHugoなどで使われるYAMLフロントマター(`—`で囲まれたメタデータ領域)は、Notion APIでページプロパティ(タイトル、タグ、作成日など)としてインポートするための重要な手がかりになる。
だが、キー名(`title`, `date`, `tags`など)の揺れがあるとAPI側で弾かれるため、あらかじめ正規化スクリプトを通しておく。
—
2. アーキテクチャ設計:Markdown ASTからNotionブロックへの変換理論
Notionのデータ構造は、単なるテキストファイルではなく、「ブロック(Block)」のツリー構造である。H1、段落、コードブロック、リストのすべてが固有のUUIDを持つブロックIDとして管理されている。
したがって、Markdownを移行するということは、「Markdownの抽象構文木(AST)」を「Notion Blocks APIのペイロード構造」に正確にシリアライズする作業に他ならない。
Markdown to Notion Block 変換マッピングの要点
| Markdown要素 | Notion Block Type | 備考 |
| :— | :— | :— |
| `# Heading 1` | `heading_1` | リッチテキストオブジェクトに分割 |
| `- List item` | `bulleted_list_item` | 子要素のネストに注意 |
| ` ` | `code` | 言語指定(`language`)のバリデーション必須 |
| `` | `image` | 外部URLか、S3等へアップロード済みのURLが必要 |
—
3. 実装:数千ページを爆速で流し込むTypeScript移行スクリプト
ここからが本題だ。Node.jsと `@notionhq/client` を使用し、ディレクトリ構造を再帰的に走査してNotionの親ページ下に子ページとして自動生成するスクリプトを実装する。
3.1 依存パッケージのインストール
npm init -y
npm install @notionhq/client marked glob dotenv
npm install -D typescript @types/node ts-node
npx tsc –init
3.2 移行スクリプト本編 (`migrate.ts`)
import { Client } from ‘@notionhq/client’;
import as fs from ‘fs’;
import as path from ‘path’;
import { glob } from ‘glob’;
import { marked } from ‘marked’;
import as dotenv from ‘dotenv’;
dotenv.config();
const notion = new Client({ auth: process.env.NOTION_API_KEY });
const ROOT_PAGE_ID = process.env.NOTION_ROOT_PAGE_ID as string;
const TARGET_DIR = path.resolve(process.env.TARGET_MARKDOWN_DIR as string);
// レートリミット(3秒間に平均3回)を回避するためのスリープ関数
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/
- 簡易的なMarkdownテキストをNotionリッチテキストオブジェクトに変換するパーサー
- ※本番環境ではMarkdown AST(remark等)を使用することを強く推奨
/
function parseTextToRichText(text: string) {
// Notionの制限: 1つのリッチテキストは最大2000文字
const truncated = text.slice(0, 2000);
return [
{
type: ‘text’ as const,
text: { content: truncated },
},
];
}
/
- Markdownの行をNotionブロックに変換(極限までシンプル化した例)
/
function convertMarkdownToBlocks(markdownContent: string) {
const lines = markdownContent.split(‘\n’);
const blocks: any[] = [];
for (const line of lines) {
if (line.startsWith(‘# ‘)) {
blocks.push({
object: ‘block’,
type: ‘heading_1’,
heading_1: { rich_text: parseTextToRichText(line.replace(‘# ‘, ”)) },
});
} else if (line.startsWith(‘
‘)) {
blocks.push({
object: ‘block’,
type: ‘heading_2’,
heading_2: { rich_text: parseTextToRichText(line.replace(‘
‘, ”)) },
});
} else if (line.startsWith(‘- ‘)) {
blocks.push({
object: ‘block’,
type: ‘bulleted_list_item’,
bulleted_list_item: { rich_text: parseTextToRichText(line.replace(‘- ‘, ”)) },
});
} else if (line.trim() !== ”) {
blocks.push({
object: ‘block’,
type: ‘paragraph’,
paragraph: { rich_text: parseTextToRichText(line) },
});
}
}
return blocks;
}
/
- 再帰的にファイルを読み込み、Notionへページを作成する
/
async function migrateFile(filePath: string, parentPageId: string) {
const content = fs.readFileSync(filePath, ‘utf-8’);
const fileName = path.basename(filePath, ‘.md’);
// 最大ブロック数制限(Notionは1リクエスト最大100ブロック)を考慮し分割
const rawBlocks = convertMarkdownToBlocks(content);
const chunkedBlocks = [];
for (let i = 0; i < rawBlocks.length; i += 100) {
chunkedBlocks.push(rawBlocks.slice(i, i + 100));
}
try {
// 1. まずページ箱を作成
const response = await notion.pages.create({
parent: { page_id: parentPageId },
properties: {
title: {
title: [
{
text: { content: fileName },
},
],
},
},
// 最初の100ブロックを同時に流し込む
children: chunkedBlocks[0] || [],
});
const newPageId = response.id;
console.log(`[Success] Created page: ${fileName} (${newPageId})`);
// 2. 100ブロックを超える分は append_children で追加
if (chunkedBlocks.length > 1) {
for (let i = 1; i < chunkedBlocks.length; i++) {
await sleep(350); // APIレートリミット対策
await notion.blocks.children.append({
block_id: newPageId,
children: chunkedBlocks[i],
});
}
}
await sleep(350); // 安全マージン
} catch (error) {
console.error(`[Error] Failed to migrate ${filePath}:`, error);
}
}
async function main() {
console.log('=== Notion Migration Pipeline Started ===');
const files = await glob('/.md', { cwd: TARGET_DIR, absolute: true });
for (const file of files) {
await migrateFile(file, ROOT_PAGE_ID);
}
console.log('=== Migration Pipeline Completed ===');
}
main().catch(console.error);
---
4. プロが踏むべき「死線」:パフォーマンスとAPI制限の回避ハック
数千ページ規模の移行において、エンジニアが必ず直面するのが Notion APIのレートリミット(HTTP 429 Too Many Requests) と メモリリーク だ。これを突破するための実戦知見を共有する。
4.1 アダプティブ・バックオフ(Adaptive Backoff)の導入
Notion APIは、秒間あたりのリクエスト数に厳格な制限(平均3リクエスト/秒)を設けている。単純な `setTimeout` では、ページ数が数千を超えた瞬間に429エラーの嵐に見舞われる。
プロダクションレベルのスクリプトでは、レスポンスヘッダーの `Retry-After` を監視し、指数バックオフ(Exponential Backoff)でリトライするラッパー関数を必ず実装せよ。
async function callWithRetry
try {
return await fn();
} catch (error: any) {
if (error.status === 429 && retries > 0) {
console.warn(`Rate limited. Retrying in ${delay}ms…`);
await sleep(delay);
return callWithRetry(fn, retries – 1, delay 2);
}
throw error;
}
}
4.2 画像アセットの扱い:ローカルパスからクラウドストレージへの退避
Markdown内の `` は、Notionインポート後には当然リンク切れを起こす(ローカルのパスをNotionは解釈できない)。
大規模移行を完璧に行うには、以下のパイプラインを前段に挟む必要がある。
1. Markdown内の画像パスを検出し、AWS S3やCloudflare R2などのオブジェクトストレージへ一括アップロード。
2. Markdown内の相対パスを、発行された公開URL(`https://cdn.example.com/…`)に置換。
3. 置換済みのMarkdownをNotion APIに渡し、`image` ブロックとして生成する。
—
5. 結び:ツールの限界を知る者だけが、組織のナレッジを制す
Notionは素晴らしいコラボレーションツールだが、巨大なレガシーデータの「無菌移行」を標準機能だけで完結させることは不可能に近い。
今回紹介した、文字化けの根本治療、ASTの概念に基づいたブロック変換、そしてAPIのレートリミットを調教する非同期パイプラインの構築こそが、エンジニアリングの力でプロジェクトの停滞を防ぐ唯一の解である。
ドキュメントの移行に数週間も悩まされる時代は終わった。このスクリプトをベースに自社のリポジトリ構造へ最適化し、一晩で数千ページのナレッジベースをNotion上に降臨させろ。開発チームのベロシティは、ここから劇的に加速する。