2026年10月2日

2026年10月2日

WordPressにNotion APIからコンテンツを自動インポートする実装手順

Notion で書いた記事を WordPress に自動で取り込むには、Notion の「インテグレーション」を作ってデータベースを共有し、WordPress 側で WP-Cron から Notion API を定期的に呼び出して、ページ本文(ブロック)を HTML に変換して下書き投稿として保存する、という流れで実装します。この記事では、mu-plugin による実装例と、重複登録・画像の期限切れ・レート制限といったつまずきやすい点の対処を説明します。

一度きりの移行ではなく「Notion を執筆場所にして、WordPress へ継続的に反映する」運用を想定しています。

全体の設計を先に決める

コードを書く前に、次の3点を決めておくと後の手戻りが減ります。

  • どちらを正とするか:Notion を正として WordPress を上書きするのか、取り込み後は WordPress で編集するのか。この記事の例では「WordPress 側で未公開(下書き)の投稿だけを Notion の内容で更新し、公開済みの投稿は上書きしない」方針にしています。公開後に WordPress で手直しした内容が消える事故を防ぐためです。
  • 取り込む条件:Notion データベースのステータス列などで「公開準備ができたページ」だけを対象にします。
  • 重複の防ぎ方:Notion のページ ID を投稿メタ _notion_page_id に保存し、同じページは同じ投稿を更新します。ページの last_edited_time も保存し、変更がないページは処理を飛ばします。

事前準備(Notion 側)

  1. Notion の開発者向け画面で内部インテグレーション(Internal integration)を作成し、シークレット(API トークン)を控えます。権限は「コンテンツの読み取り」だけで足ります。
  2. 取り込み元のデータベースを開き、ページのメニューにある接続(コネクション)の設定から、作成したインテグレーションを追加します。この共有をしないと API からは見えず、404 が返ります。
  3. データベースの URL に含まれる 32 文字のデータベース ID を控えます。
  4. データベースに「タイトル(Name)」と、ステータス型の「Status」列を用意しておきます。

画面の表記や項目名は Notion の更新で変わることがあります(2026年10月現在)。わからない場合は Notion の開発者ドキュメントの「Getting started」を参照してください。

事前準備(WordPress 側)

API トークンはデータベースや投稿に保存せず、wp-config.php に定数として書きます。Git に入れないファイルで管理し、画面やログに出力しないようにします。

// wp-config.php(値はダミー)
define( 'NOTION_TOKEN', 'your-notion-secret' );
define( 'NOTION_DATABASE_ID', 'your-database-id' );

実装は wp-content/mu-plugins/notion-import.php に置きます。作業はステージング環境で行い、事前にバックアップを取ってください。

ステップ1:Notion API を呼び出す共通関数

API 呼び出しは1つの関数にまとめ、認証ヘッダー、API バージョン、エラー処理、待ち時間をここで一括して扱います。

<?php
/**
 * Plugin Name: Notion Import
 */
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

function wpdr_notion_request( $method, $path, $body = null ) {
	// Notion のレート制限(平均で毎秒3リクエスト程度)に合わせて間隔を空ける。
	usleep( 350000 );

	$args = array(
		'method'  => $method,
		'timeout' => 20,
		'headers' => array(
			'Authorization'  => 'Bearer ' . NOTION_TOKEN,
			'Notion-Version' => '2022-06-28',
			'Content-Type'   => 'application/json',
		),
	);
	if ( null !== $body ) {
		$args['body'] = wp_json_encode( $body );
	}

	$res = wp_remote_request( 'https://api.notion.com/v1/' . ltrim( $path, '/' ), $args );
	if ( is_wp_error( $res ) ) {
		return $res;
	}
	$code = wp_remote_retrieve_response_code( $res );
	if ( 429 === $code ) {
		return new WP_Error( 'notion_rate_limited', 'Rate limited. Retry-After: ' . wp_remote_retrieve_header( $res, 'retry-after' ) );
	}
	if ( $code < 200 || $code >= 300 ) {
		return new WP_Error( 'notion_http_' . $code, wp_remote_retrieve_body( $res ) );
	}
	return json_decode( wp_remote_retrieve_body( $res ), true );
}

Notion-Version ヘッダーは必須です。ここでは長く使われてきた 2022-06-28 を指定しています。Notion は 2025年に、データベースを「データソース」単位で扱う新しい API バージョンを公開しており、新バージョンではデータベースの問い合わせ先エンドポイントも変わっています。古いバージョンがいつまで使えるかは Notion の告知を確認し、移行する場合は次のステップの問い合わせ部分を新しいエンドポイントに合わせて書き換えてください。

ステップ2:対象ページの一覧を取得する

データベースへの問い合わせ(query)で、Status が「公開準備OK」のページだけを取得します。結果は最大100件ずつ返り、続きがあるときは has_more と next_cursor が返るので、ループで最後まで読みます。

function wpdr_notion_fetch_pages() {
	$pages  = array();
	$cursor = null;
	do {
		$body = array(
			'page_size' => 50,
			'filter'    => array(
				'property' => 'Status',
				'status'   => array( 'equals' => '公開準備OK' ),
			),
		);
		if ( $cursor ) {
			$body['start_cursor'] = $cursor;
		}
		$data = wpdr_notion_request( 'POST', 'databases/' . NOTION_DATABASE_ID . '/query', $body );
		if ( is_wp_error( $data ) ) {
			return $data;
		}
		$pages  = array_merge( $pages, $data['results'] );
		$cursor = ! empty( $data['has_more'] ) ? $data['next_cursor'] : null;
	} while ( $cursor );

	return $pages;
}

Status 列を「ステータス」型ではなく「セレクト」型で作った場合は、フィルターの status を select に変えます。型が合っていないと 400 エラーになります。

ステップ3:ブロックを HTML に変換する

ページ本文は「ブロック」の配列で、ページ ID を指定してブロックの子要素を取得します。文字の装飾はリッチテキストの annotations に入っているので、エスケープしたうえでタグを付けます。

function wpdr_notion_plain( $items ) {
	return implode( '', wp_list_pluck( (array) $items, 'plain_text' ) );
}

function wpdr_notion_rich_text( $items ) {
	$out = '';
	foreach ( (array) $items as $t ) {
		$s = esc_html( $t['plain_text'] ?? '' );
		$a = $t['annotations'] ?? array();
		if ( ! empty( $a['code'] ) )   { $s = '<code>' . $s . '</code>'; }
		if ( ! empty( $a['bold'] ) )   { $s = '<strong>' . $s . '</strong>'; }
		if ( ! empty( $a['italic'] ) ) { $s = '<em>' . $s . '</em>'; }
		if ( ! empty( $t['href'] ) )   { $s = '<a href="' . esc_url( $t['href'] ) . '">' . $s . '</a>'; }
		$out .= $s;
	}
	return $out;
}

function wpdr_notion_blocks_html( $page_id, $post_id ) {
	$html   = '';
	$list   = '';
	$cursor = null;
	do {
		$path = 'blocks/' . $page_id . '/children?page_size=100'
			. ( $cursor ? '&start_cursor=' . rawurlencode( $cursor ) : '' );
		$data = wpdr_notion_request( 'GET', $path );
		if ( is_wp_error( $data ) ) {
			return $data;
		}
		foreach ( $data['results'] as $b ) {
			$type = $b['type'];
			$tag  = 'bulleted_list_item' === $type ? 'ul' : ( 'numbered_list_item' === $type ? 'ol' : '' );
			if ( $list && $list !== $tag ) { $html .= "</{$list}>\n"; $list = ''; }
			if ( $tag && ! $list )         { $html .= "<{$tag}>\n";   $list = $tag; }

			$rt = isset( $b[ $type ]['rich_text'] ) ? wpdr_notion_rich_text( $b[ $type ]['rich_text'] ) : '';
			switch ( $type ) {
				case 'paragraph':
					$html .= '' !== $rt ? "<p>{$rt}</p>\n" : '';
					break;
				case 'heading_1':
				case 'heading_2':
					$html .= "<h2>{$rt}</h2>\n";
					break;
				case 'heading_3':
					$html .= "<h3>{$rt}</h3>\n";
					break;
				case 'bulleted_list_item':
				case 'numbered_list_item':
					$html .= "<li>{$rt}</li>\n";
					break;
				case 'quote':
					$html .= "<blockquote><p>{$rt}</p></blockquote>\n";
					break;
				case 'code':
					$html .= '<pre><code>' . esc_html( wpdr_notion_plain( $b['code']['rich_text'] ) ) . "</code></pre>\n";
					break;
				case 'divider':
					$html .= "<hr>\n";
					break;
				case 'image':
					$html .= wpdr_notion_image( $b, $post_id );
					break;
				default:
					error_log( 'Notion import: unsupported block type ' . $type );
			}
		}
		$cursor = ! empty( $data['has_more'] ) ? $data['next_cursor'] : null;
	} while ( $cursor );

	if ( $list ) {
		$html .= "</{$list}>\n";
	}
	return $html;
}

Notion の見出し1は、WordPress では記事タイトルが h1 になるため h2 に落としています。トグルや入れ子のリストなど、子ブロックを持つブロック(has_children が true)はこの例では展開していません。必要なら、そのブロック ID で同じ取得処理を再帰的に呼び出します。対応していない種類はログに残るので、運用開始後にログを見て対応範囲を広げていくと効率的です。

ステップ4:画像をメディアライブラリに取り込む

Notion にアップロードされた画像の URL は署名付きで、1時間ほどで期限が切れます。URL をそのまま本文に書くと後で表示されなくなるため、取り込み時に WordPress のメディアライブラリへ保存します。

function wpdr_notion_image( $block, $post_id ) {
	$found = get_posts( array(
		'post_type'      => 'attachment',
		'post_status'    => 'inherit',
		'meta_key'       => '_notion_block_id',
		'meta_value'     => $block['id'],
		'fields'         => 'ids',
		'posts_per_page' => 1,
	) );

	if ( $found ) {
		$att_id = (int) $found[0];
	} else {
		$img    = $block['image'];
		$src    = 'external' === $img['type'] ? $img['external']['url'] : $img['file']['url'];
		$att_id = media_sideload_image( $src, $post_id, null, 'id' );
		if ( is_wp_error( $att_id ) ) {
			error_log( 'Notion import: image failed ' . $att_id->get_error_message() );
			return '';
		}
		update_post_meta( $att_id, '_notion_block_id', $block['id'] );
	}

	$alt = wpdr_notion_plain( $block['image']['caption'] ?? array() );
	return sprintf(
		'<figure><img src="%s" alt="%s" class="wp-image-%d"></figure>' . "\n",
		esc_url( wp_get_attachment_image_url( $att_id, 'large' ) ),
		esc_attr( $alt ),
		$att_id
	);
}

画像ブロックの ID を添付ファイルのメタに記録しているので、ページを更新するたびに同じ画像が増えることはありません。wp-image-{ID} クラスを付けておくと、表示時に WordPress が srcset や loading 属性を補ってくれます。Notion のキャプションを代替テキストとして使っているため、執筆時にキャプションを書く運用にしておくとアクセシビリティの面でも助かります。

ステップ5:投稿の作成・更新と定期実行

最後に、ページ単位で投稿を作成または更新する処理と、WP-Cron への登録を書きます。

function wpdr_notion_import_page( $page ) {
	$existing = get_posts( array(
		'post_type'      => 'post',
		'post_status'    => 'any',
		'meta_key'       => '_notion_page_id',
		'meta_value'     => $page['id'],
		'fields'         => 'ids',
		'posts_per_page' => 1,
		'no_found_rows'  => true,
	) );
	$post_id = $existing ? (int) $existing[0] : 0;

	if ( $post_id ) {
		if ( 'publish' === get_post_status( $post_id ) ) {
			return; // 公開済みは上書きしない。
		}
		if ( get_post_meta( $post_id, '_notion_last_edited', true ) === $page['last_edited_time'] ) {
			return; // 変更なし。
		}
	}

	$title = wpdr_notion_plain( $page['properties']['Name']['title'] ?? array() );
	if ( ! $post_id ) {
		$post_id = wp_insert_post( array(
			'post_title'  => sanitize_text_field( $title ),
			'post_status' => 'draft',
			'post_type'   => 'post',
		), true );
		if ( is_wp_error( $post_id ) ) {
			return $post_id;
		}
		update_post_meta( $post_id, '_notion_page_id', $page['id'] );
	}

	$html = wpdr_notion_blocks_html( $page['id'], $post_id );
	if ( is_wp_error( $html ) ) {
		return $html;
	}
	wp_update_post( array(
		'ID'           => $post_id,
		'post_title'   => sanitize_text_field( $title ),
		'post_content' => wp_kses_post( $html ),
	) );
	update_post_meta( $post_id, '_notion_last_edited', $page['last_edited_time'] );
}

add_action( 'wpdr_notion_sync', 'wpdr_notion_sync_run' );
add_action( 'init', function () {
	if ( ! wp_next_scheduled( 'wpdr_notion_sync' ) ) {
		wp_schedule_event( time() + 60, 'hourly', 'wpdr_notion_sync' );
	}
} );

function wpdr_notion_sync_run() {
	if ( ! defined( 'NOTION_TOKEN' ) || ! defined( 'NOTION_DATABASE_ID' ) ) {
		return;
	}
	if ( get_transient( 'wpdr_notion_lock' ) ) {
		return; // 前回の処理がまだ動いている。
	}
	set_transient( 'wpdr_notion_lock', 1, 15 * MINUTE_IN_SECONDS );

	require_once ABSPATH . 'wp-admin/includes/media.php';
	require_once ABSPATH . 'wp-admin/includes/file.php';
	require_once ABSPATH . 'wp-admin/includes/image.php';

	$pages = wpdr_notion_fetch_pages();
	if ( is_wp_error( $pages ) ) {
		error_log( 'Notion import: ' . $pages->get_error_message() );
	} else {
		foreach ( $pages as $page ) {
			$result = wpdr_notion_import_page( $page );
			if ( is_wp_error( $result ) ) {
				error_log( 'Notion import: ' . $page['id'] . ' ' . $result->get_error_message() );
			}
		}
	}
	delete_transient( 'wpdr_notion_lock' );
}

取り込んだ投稿は下書きになるので、編集者が WordPress 上で内容・カテゴリー・アイキャッチを確認してから公開します。本文は wp_kses_post() を通しているため、投稿で許可されていないタグや属性は取り除かれます。

media_sideload_image() は管理画面用のファイルで定義されているため、Cron から呼ぶときは上のように require_once で読み込む必要があります。これを忘れると「未定義の関数」で処理が止まります。

動作確認と運用

  • wp cron event run wpdr_notion_sync で手動実行し、下書きが作られることと、ログ(WP_DEBUG_LOG を有効にした場合は wp-content/debug.log)にエラーが出ていないことを確認します。
  • Notion 側でページを編集して再実行し、同じ投稿が更新され、画像が増えていないことを確かめます。
  • WP-Cron はサイトへのアクセスをきっかけに動くため、アクセスが少ないサイトでは実行が遅れます。安定させたい場合は DISABLE_WP_CRON を有効にし、サーバーの cron から WP-CLI で定期実行します。
  • 401 が出たらトークン、404 が出たらデータベースとインテグレーションの共有、400 が出たらフィルターの型やプロパティ名を最初に疑います。

まとめ

  • Notion 側でインテグレーションを作り、取り込み元データベースに接続を追加してから API を呼び出します。
  • トークンは wp-config.php の定数で管理し、ログや画面に出しません。
  • Notion のページ ID と最終更新日時を投稿メタに保存し、重複作成と無駄な更新を防ぎます。公開済み投稿は上書きしない方針にすると事故が減ります。
  • 画像 URL は期限付きなので、取り込み時にメディアライブラリへ保存します。
  • API バージョン(Notion-Version)の更新とレート制限を意識し、エラーはログで確認しながら対応ブロックを増やしていきます。

お気軽にご相談ください

お見積りへ お問い合わせへ
▲