2026年5月26日
2026年5月26日
WordPressの管理画面にCSVダウンロードボタンを追加する方法
WordPressの管理画面にCSVダウンロードボタンを付けるには、ボタンのリンク先を admin-post.php にし、admin_post_{アクション名} フックで「権限チェック → nonce検証 → ヘッダー送信 → fputcsv() で出力 → exit」の順に処理するのが定番の作り方です。この記事では、投稿一覧の上部に「CSVダウンロード」ボタンを置き、公開済み記事の一覧をExcelで文字化けせずに開けるCSVとして書き出すコードを、つまずきやすい点と一緒に解説します。
全体の流れと、admin-post.php を使う理由
CSVのダウンロードは、普通の管理画面を表示する処理とは相性がよくありません。管理画面のページはHTMLのヘッダーやメニューを出力してから本文を描画するため、その途中でCSV用のヘッダーを送ろうとすると「headers already sent」の警告が出て、ファイルとして保存されないためです。
admin-post.php は、管理画面のHTMLを出さずに任意の処理だけを実行するための入口です。admin-post.php?action=mysite_export_posts_csv にアクセスすると、ログイン中のユーザーに対して admin_post_mysite_export_posts_csv フックが実行されます。ここで最初からCSVだけを出力すれば、余計なHTMLが混ざりません。
作るものは次の2つです。
- 投稿一覧に表示するボタン(nonce付きのリンク)
- ボタンを押したときに動くCSV出力処理
コードは子テーマの functions.php でも動きますが、テーマを替えても使い続ける管理機能なので、小さなプラグインか mu-plugins に置く方が向いています。
ステップ1:投稿一覧にボタンを表示する
投稿一覧の表の上(一括操作や絞り込みの並び)には、manage_posts_extra_tablenav アクションで要素を追加できます。このフックは表の上と下で2回呼ばれ、引数 $which に top か bottom が入ります。
<?php
/**
* 投稿一覧の上部に「CSVダウンロード」ボタンを表示する
*/
function mysite_csv_download_button( $which ) {
global $typenow;
if ( 'top' !== $which || 'post' !== $typenow ) {
return;
}
if ( ! current_user_can( 'export' ) ) {
return;
}
$url = wp_nonce_url(
admin_url( 'admin-post.php?action=mysite_export_posts_csv' ),
'mysite_export_posts_csv'
);
printf(
'<div class="alignleft actions"><a href="%s" class="button">%s</a></div>',
esc_url( $url ),
esc_html__( 'CSVダウンロード', 'mysite' )
);
}
add_action( 'manage_posts_extra_tablenav', 'mysite_csv_download_button' );
$typenow で投稿タイプを判定しているのは、このフックが固定ページやカスタム投稿タイプの一覧でも呼ばれるためです。wp_nonce_url() はURLに _wpnonce パラメーターを付け、第三者が用意したリンクを踏まされてダウンロードが実行される、いわゆるCSRFを防ぐ材料になります。
権限は export(エクスポート機能を使える権限。標準では管理者が持つ)で判定しています。編集者にも許可したいなど運用に合わせて変える場合も、ボタンの表示側と処理側の両方で同じ権限を確認してください。表示側だけ隠しても、URLを直接開けば処理が動いてしまいます。
ステップ2:CSVを出力する処理を書く
ボタンのリンク先で動く本体です。セルの先頭が = などの記号だと表計算ソフトが数式として解釈してしまう問題(CSVインジェクション)への対策も入れています。
<?php
/**
* 表計算ソフトで数式扱いされないようにセルを整える
*/
function mysite_csv_cell( $value ) {
$value = (string) $value;
if ( '' !== $value && in_array( $value[0], array( '=', '+', '-', '@', "\t", "\r" ), true ) ) {
$value = "'" . $value;
}
return $value;
}
/**
* 公開済み投稿をCSVで出力する
*/
function mysite_export_posts_csv() {
if ( ! current_user_can( 'export' ) ) {
wp_die( esc_html__( 'この操作を行う権限がありません。', 'mysite' ), '', array( 'response' => 403 ) );
}
check_admin_referer( 'mysite_export_posts_csv' );
// それまでに溜まった出力があれば破棄する(BOMや空白の混入対策)
while ( ob_get_level() > 0 ) {
ob_end_clean();
}
$filename = 'posts-' . wp_date( 'Ymd-His' ) . '.csv';
nocache_headers();
header( 'Content-Type: text/csv; charset=UTF-8' );
header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
$out = fopen( 'php://output', 'w' );
// Excel で文字化けしないよう UTF-8 の BOM を先頭に書く
fwrite( $out, "\xEF\xBB\xBF" );
fputcsv( $out, array( 'ID', 'タイトル', '公開日', 'カテゴリー', 'URL' ), ',', '"', '' );
$per_page = 200;
$paged = 1;
do {
$posts = get_posts(
array(
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => $per_page,
'paged' => $paged,
'orderby' => 'ID',
'order' => 'ASC',
'no_found_rows' => true,
'update_post_meta_cache' => false,
)
);
foreach ( $posts as $post ) {
$categories = wp_list_pluck( get_the_category( $post->ID ), 'name' );
$row = array(
$post->ID,
$post->post_title,
get_the_date( 'Y-m-d H:i', $post ),
implode( ' / ', $categories ),
get_permalink( $post ),
);
fputcsv( $out, array_map( 'mysite_csv_cell', $row ), ',', '"', '' );
}
$paged++;
} while ( count( $posts ) === $per_page );
fclose( $out );
exit;
}
add_action( 'admin_post_mysite_export_posts_csv', 'mysite_export_posts_csv' );
処理の順番には意味があります。
- 権限チェックとnonce検証を最初に行う:
check_admin_referer()はnonceが無い・不正・期限切れの場合に処理を止めます。ヘッダーを送る前に済ませておかないと、エラー画面ではなく壊れたCSVが落ちてくることになります。 - 出力バッファを捨ててからヘッダーを送る:他のプラグインや functions.php の閉じタグ後の空白などが先に出力されていると、CSVの1行目が汚れます。
- **
php://outputにfputcsv()で書く**:カンマや改行、ダブルクォートを含むタイトルも、正しくクォートされて1セルに収まります。区切り文字などの引数を明示しているのは、PHP 8.4以降で第5引数(エスケープ文字)を省略すると非推奨の警告が出るためです。空文字を渡すと、表計算ソフトと相性のよい標準的なクォート方式になります(PHP 7.4以降)。 - **最後に
exit**:CSVの出力がここで終わることを明示し、同じフックに登録された他の処理などの出力が末尾に混ざるのを防ぎます。
タイトルに get_the_title() ではなく $post->post_title を使っているのは、get_the_title() が表示用にアポストロフィなどを文字参照(’ など)へ変換するためです。CSVはHTMLではないので、生の値の方が扱いやすくなります。
admin_post_nopriv_ 版のフックは登録していません。ログインしていない訪問者には処理を開放しない、という意図です。
ステップ3:Excelで開いて確認する
保存後、「投稿」→「投稿一覧」を開くと、表の上に「CSVダウンロード」ボタンが表示されます。クリックしてファイルが保存されたら、次の点を確認します。
- Excelでダブルクリックして開いたとき、日本語が文字化けしていないか
- タイトルにカンマを含む記事が、1つのセルに収まっているか
- 先頭行に余計な空白や
<から始まるHTMLが混ざっていないか - 管理者以外のアカウントでボタンが表示されず、URLを直接開いても拒否されるか
文字化けする場合は、BOMの書き込みより前に何かが出力されている可能性があります。テキストエディターでCSVを開き、先頭に空行や警告文が入っていないかを見てください。
Shift_JIS が必要な場合
取引先のシステムがShift_JISのCSVしか受け付けない場合は、各セルを mb_convert_encoding() でCP932に変換し、BOMは書かずに charset=Shift_JIS で送る方法があります。ただし絵文字や一部の記号など、Shift_JISに無い文字は変換時に失われます。特に事情がなければ、Windows版Excelでも(2026年10月現在)問題なく開けるBOM付きUTF-8をおすすめします。
件数が多いときとプライバシーの注意
上のコードは200件ずつ取得して書き出すため、全件を一度に配列へ読み込む方法よりメモリの使用量を抑えられます。それでも数万件規模になると、サーバーの実行時間制限に達して途中で切れることがあります。その場合は期間やカテゴリーで分けて出力するか、サーバーにSSHで入れるならWP-CLIの wp post list --post_type=post --fields=ID,post_title,post_date --format=csv で書き出す方が安定します。
投稿ではなくユーザー情報や問い合わせ内容、注文データを出力する場合は、扱いに一段の注意が必要です。
- 出力する列を必要な項目だけに絞る(メールアドレスや電話番号を「念のため」入れない)
- 権限は管理者など最小限のロールに限定する
- ダウンロードしたファイルの保管場所や削除のルールを決めておく
- サーバー上にCSVファイルを保存して公開ディレクトリに置かない(今回のコードのように直接出力すればファイルは残りません)
個人情報を含むデータの取り扱いは、サイトのプライバシーポリシーや社内規程と矛盾しないかも確認してください。
投稿一覧ではなく専用画面に置きたい場合
「ツール」メニューの下に専用画面を作りたい場合は、add_management_page() で画面を登録し、その中に admin-post.php 宛てのフォームを置きます。フォームには wp_nonce_field( 'mysite_export_posts_csv' ) と、name="action" の hidden 項目を入れてください。出力処理は上の関数をそのまま使えます。check_admin_referer() はPOSTで送られたnonceも検証できるためです。
まとめ
- CSVダウンロードは
admin-post.phpとadmin_post_{アクション名}フックで作ると、管理画面のHTMLが混ざらずに済みます。 - ボタンは
manage_posts_extra_tablenavで投稿一覧に追加し、wp_nonce_url()でnonceを付けます。 - 処理側では
current_user_can()とcheck_admin_referer()を最初に行い、出力バッファを捨ててからContent-TypeとContent-Dispositionを送ります。 php://outputにfputcsv()で書き出し、Excel向けにUTF-8のBOMを付け、最後はexitで終えます。- 件数が多いときは分割取得やWP-CLIを使い、個人情報を含むデータは列と権限を絞って扱います。