WordPress REST APIで記事が全件取れないときの直し方|per_page上限と、ページ送りが途中で400になる境目
WordPress REST APIで記事が全件取れないときの直し方|per_page上限と、ページ送りが途中で400になる境目
WordPressのREST APIで記事を一覧取得すると、最初は素直に動きます。/wp-json/wp/v2/posts を叩けばJSONが返り、記事が並ぶ。ところがサイトが育ってから、こういう症状が出ます。
- 記事が100本しか取れない
- ページ送りを実装したら、あるとき突然 400エラー で止まった
- 昨日まで動いていたのに、新しい記事を1本公開した瞬間に落ちた
結論から書きます。ページ送りの終了条件を「返ってきた件数がper_page未満か」で判定してはいけません。 正しくは、レスポンスヘッダーの X-WP-TotalPages(総ページ数)を読み、そこに達したら止めます。
件数で判定する書き方は、記事総数がper_pageの倍数にぴったり一致した日にだけ壊れます。普段は正常に動き、記事が1本増えた瞬間に落ちて、また1本増えると自然に直る。再現条件が「記事本数」という外部要因なので、コードを見ても原因が見つからないのがこの不具合の厄介なところです。
この記事では、実際に208本の記事を持つ自社のヘッドレス構成で境界を再現した実測値と、そのまま使える実装、公開前の確認手順を書きます。
—
1. 前提:per_pageの上限は100。101以上は必ず400になる
まず取得の上限です。WordPressのREST APIは、1リクエストで返す件数を per_page で指定しますが、指定できるのは1〜100です。
101以上を投げると、記事が何本あっても200は返りません。自社サイト(記事208本)で実測した結果です。
GET /wp-json/wp/v2/posts?per_page=101
→ HTTP 400
{"code":"rest_invalid_param",
"message":"無効なパラメータ: per_page",
"data":{"status":400,"params":{"per_page":"per_page は1以上、100以下でなければなりません。"}}}
per_page=-1(全件取得)はWP_Queryでは使えますが、REST APIでは使えません。管理画面側のPHPコードから移植したときに、ここで最初に詰まります。
つまり101本以上の記事を持つサイトでは、ページ送りの実装は必須です。回避策はありません。
総数はヘッダーで返ってくる
そしてページ送りに必要な情報は、JSONの本文ではなくレスポンスヘッダーに入っています。ここが見落とされやすい箇所です。
| ヘッダー | 内容 |
|---|---|
X-WP-Total |
条件に一致する総件数 |
X-WP-TotalPages |
そのper_pageでの総ページ数 |
同じサイトでの実測です。
GET /posts?per_page=1 → X-WP-Total: 208 / X-WP-TotalPages: 208
GET /posts?per_page=52 → X-WP-Total: 208 / X-WP-TotalPages: 4
X-WP-TotalPages は per_page によって変わる値です。この2つを読めば、そもそも「何ページ取ればいいか」は最初のリクエストで確定します。
—
2. 「per_page未満で終了」が壊れる境目を実測で再現する
多く書かれている終了条件は、こういう形です。
// これが後から壊れる書き方
if (body.length < perPage) break;
「満杯で返ってきたということは、まだ続きがある」という判断です。9割の場面では正しく動きます。壊れるのは、総数がper_pageの倍数にぴったり一致したときだけです。
総記事数208本のサイトで、per_page=52(208 ÷ 52 = ちょうど4ページ)にして境界を実測しました。
| リクエスト | 結果 | 返却件数 |
|---|---|---|
per_page=52&page=3 |
HTTP 200 | 52件 |
per_page=52&page=4 |
HTTP 200 | 52件(満杯) |
per_page=52&page=5 |
HTTP 400 | rest_post_invalid_page_number |
最終ページである4ページ目が、52件フルで返ります。ここで「満杯だからまだ続きがある」と判断したコードは5ページ目を取りにいき、WordPressは存在しないページに対してこう返します。
{"code":"rest_post_invalid_page_number",
"message":"リクエストされたページ数は存在するページ数を上回っています。",
"data":{"status":400}}
比較として、端数があるときは正常に止まれます。
| リクエスト | 結果 | 返却件数 |
|---|---|---|
per_page=100&page=2 |
HTTP 200 | 100件 |
per_page=100&page=3 |
HTTP 200 | 8件(per_page未満) → ここで終了できる |
per_page=100&page=4 |
HTTP 400 | rest_post_invalid_page_number |
208本なら、per_page=100 のときは3ページ目が8件なので無事に止まります。しかし記事があと92本増えて300本ちょうどになった日、3ページ目が100件フルで返り、同じコードが400で落ちます。
per_page=100で運用しているサイトなら、記事本数が100の倍数に到達した瞬間が発火点です。100本目、200本目、300本目。毎日記事を公開しているサイトなら、必ずいつか踏みます。
この不具合が現場でどう見えるか
ヘッドレス構成(ビルド時にWordPressから記事を取得して静的HTMLを生成する形)で使っている場合、症状はこう出ます。
- 公開画面は壊れない。 前回のビルド成果物がそのまま残るため、サイトは正常に見えます
- 新しい記事だけが本番に出てこない。 ビルドが失敗しているので、成果物が更新されない
- 記事はWordPress側では確かに公開されている。管理画面でもプレビューでも見える
つまり「サイトは正常、記事も公開済み、なのに本番に出ない」という状態になります。エラー画面が出ないので、自動ビルドのログを見にいかない限り気づけません。 自動ビルドを30分ごとなどで回している場合、失敗が静かに積み上がります。
だからこそ、この症状は設計時点で潰しておく種類のものです。記事が増えてから対処するのでは、気づくまでの間ずっと更新が止まります。
—
3. 正しい実装:総ページ数で止める
修正は数行です。X-WP-TotalPages(または X-WP-Total)を読んで終了を判定します。
TypeScript(ヘッドレス構成のビルド時取得)
async function fetchAll(path: string, params: Record<string, string> = {}) {
const perPage = 100; // 上限。101以上は400
const out: any[] = [];
let page = 1;
let totalPages = 1;
for (;;) {
const qs = new URLSearchParams({
per_page: String(perPage),
page: String(page),
...params,
});
const res = await fetch(`${API}/${path}?${qs}`);
if (!res.ok) {
throw new Error(`WP API ${res.status} ${res.statusText} : ${path} page=${page}`);
}
// ★終了判定はヘッダーで行う
totalPages = Number(res.headers.get('x-wp-totalpages') ?? 1);
const body = await res.json();
if (!Array.isArray(body) || body.length === 0) break;
out.push(...body);
if (page >= totalPages) break; // ← 倍数ちょうどでも正しく止まる
page += 1;
if (page > 100) break; // 暴走よけの保険
}
return out;
}
要点は3つです。
page >= totalPagesで止める。 件数を見ないres.okでない場合は例外を投げて処理を止める。 ここでエラーを握りつぶして空配列を返すと、記事ゼロ件のサイトが黙って生成・公開されます。取得失敗はビルドを止めるほうが安全です- 暴走よけのページ上限も残す。 ヘッダーが想定外の値だった場合の保険
X-WP-Total(総件数)で判定する形も同じ効果です。if (out.length >= total) break; と書けば、取得済み件数が総数に達した時点で止まります。既存コードへの追加が1行で済むので、修正としてはこちらが最小です。
Python(requests)
import requests
def fetch_all(base, path, params=None, per_page=100, auth=None):
items, page, total_pages = [], 1, 1
while True:
q = {"per_page": per_page, "page": page, **(params or {})}
r = requests.get(f"{base}/{path}", params=q, auth=auth, timeout=30)
r.raise_for_status() # 400は例外にする
total_pages = int(r.headers.get("X-WP-TotalPages", 1))
body = r.json()
if not body:
break
items.extend(body)
if page >= total_pages: # ← ここで止める
break
page += 1
return items
総ページ数を取ってから並列で取得する形
記事が数百本を超えてビルド時間が気になるなら、1回目のリクエストで総ページ数を確定し、残りを並列で取る形にできます。
const first = await fetch(`${API}/posts?per_page=100&page=1`);
const totalPages = Number(first.headers.get('x-wp-totalpages') ?? 1);
const rest = await Promise.all(
Array.from({ length: totalPages - 1 }, (_, i) =>
fetch(`${API}/posts?per_page=100&page=${i + 2}`).then(r => r.json()))
);
順番に取ると1ページあたりの待ち時間が積み上がりますが、この形なら総ページ数が確定しているぶん安全に並列化できます。「終わりが分からないから順番に取るしかない」という制約自体が、終了条件を件数で見ていたことの副作用です。
—
4. 取得側で他にハマる4点
ページ送り以外に、記事の一覧取得でつまずく箇所をまとめます。どれも「200が返っているのに結果がおかしい」という形で出るため、エラーとして表面化しません。
① _embed を付けると重くなる。必要なフィールドだけ取る
アイキャッチ画像やカテゴリ名をまとめて取るために _embed=1 を付けると、レスポンスが倍近くまで膨らみます。同じサイトで10件取得したときの実測です。
| リクエスト | レスポンスサイズ |
|---|---|
?per_page=10 |
約488KB |
?per_page=10&_embed=1 |
約983KB(約2倍) |
?per_page=10&_fields=id,slug,title,link |
約4KB(100分の1以下) |
対策は _fields で必要な項目だけを指定することです。
/posts?per_page=100&page=1&_fields=id,slug,date,modified,title,link
一覧の生成に本文が要らないなら、content を外すだけで転送量が二桁変わります。100件×数ページとなると、ビルド時間に直接効く差です。_embed は本当に必要なエンドポイントだけに絞るのが基本です。
② 日本語スラッグはパーセントエンコードされて返る
日本語のスラッグを使っている記事は、REST APIのレスポンスで %e3%83%96... のようにエンコードされた文字列として返ります。これをそのままファイル出力のパスやディレクトリ名に使うと、「%e3…」という名前のディレクトリが生成され、公開後に404になります。
サーバー側(Apacheなど)はURLをデコードした後の実バイト名でファイルを探すため、エンコード済みの名前で保存されたファイルには到達できません。取得直後に decodeURIComponent() で戻してから使います。
const slug = decodeURIComponent(post.slug);
これは記事1本ごとに404になるため、日本語スラッグの記事が数十本あると被害がまとまります。移行や再構築のタイミングで確認しておく箇所です。
③ プラグイン製のRESTエンドポイントは独自の上限を持つ
/wp/v2/ 配下ではなく、プラグインが独自に生やしたエンドポイントには、プラグイン側の件数上限が別途かかります。
別案件で口コミ表示プラグインのAPIを検証したとき、per_page=500 を指定しても返ってきたのは常に50件でした。HTTPは200で、エラーも出ません。「指定は通ったが、上限で切られている」状態です。
判定は簡単で、per_page を変えて返却件数が変わるかを見るだけです。変わらないなら上限に当たっています。この場合もページ送りで取り切る必要があります。
④ 下書き・非公開はデフォルトでは返らない
/wp/v2/posts は、認証なしでは公開済みの記事しか返しません。「WordPressの管理画面では200本あるのに、APIでは180本しか取れない」というときは、まず下書きと非公開の本数を疑います。
下書きを含めたい場合は認証(アプリケーションパスワード等)を通したうえで、status を明示します。
/posts?status=publish,draft,private&per_page=100
なお認証を通さずに status=draft を指定すると、空配列ではなく400が返ります(rest_invalid_param)。実測でも同じ結果でした。「0件だった」ではなくエラーになるので、認証漏れはこの形で表面化します。
逆に、公開サイトを生成する用途では下書きが混ざらないほうが安全です。認証なしで取得する構成にしておけば、下書きが誤って本番に出る事故は構造的に起きません。
—
5. 公開前に確認する4項目
取得処理を実装したら、記事が増える前に以下を確認しておきます。どれも数分で終わります。
① 総数と取得件数が一致するか
# ヘッダーの総数
curl -sI "https://example.com/wp-json/wp/v2/posts?per_page=1" | grep -i x-wp-total
# 実装が取得した件数と突き合わせる
一致しなければページ送りが途中で終わっています。ここが一致するまでは実装完了と見なさない、という基準にします。
② 倍数ちょうどの状況を意図的に作って試す
記事本数を変える必要はありません。per_page を割り切れる数にすれば同じ状況を再現できます。総数208本なら per_page=52、100本なら per_page=50 や per_page=25。この状態で最後まで取り切れれば、記事が何本になっても落ちません。
③ 取得失敗時にビルドが止まるか
APIのURLをわざと間違えて実行し、エラーで止まることを確認します。空の配列を返して処理が続く実装だと、記事ゼロ件のサイトがそのまま公開されます。
④ 生成後のファイル名に % が混ざっていないか
日本語スラッグを使っているなら、出力ディレクトリを確認します。
ls dist/blog | grep '%' && echo "デコード漏れあり"
—
6. 判断基準:どこまで作り込むか
記事の取得処理をどこまで堅くするかは、サイトの更新頻度で決めます。
| 更新頻度 | 必要な作り込み |
|---|---|
| 年に数回・記事100本未満 | ページ送り自体が不要。ただし将来100本を超える前提なら最初から入れる |
| 月数本 | 総ページ数での終了判定+取得失敗でビルド停止。ここまでで十分 |
| 毎日更新・自動投稿あり | 上記+ビルド失敗の通知。失敗が静かに続く時間をゼロにする |
毎日記事が増えるサイトで一番効くのは、実は取得処理そのものより失敗の通知です。この不具合の本質は「壊れること」ではなく「壊れても画面が正常なので気づけないこと」にあります。ビルドが失敗したらメールなり通知なりが飛ぶようにしておけば、原因が何であれ当日中に気づけます。
—
まとめ
per_pageは1〜100。101以上は必ず400になる。per_page=-1はREST APIでは使えない- ページ送りの終了条件は、返却件数ではなく
X-WP-TotalPages/X-WP-Totalで判定する - 件数で判定する実装は、総数がper_pageの倍数にぴったり一致した日にだけ落ちる。実測でも、208本÷52で最終ページが52件フルで返り、次ページが400になることを確認した
- per_page=100運用なら、記事が100の倍数に到達した瞬間が発火点。毎日更新するサイトは必ずいつか踏む
- ヘッドレス構成では、公開画面は正常なまま新記事だけが反映されないという形で出る。エラー画面が出ないので、通知を用意しておく
- 取得側の他の注意点は、
_fieldsでの軽量化・日本語スラッグのデコード・プラグインAPIの独自上限・下書きの扱いの4つ
WordPress REST APIの取得は、動き始めが簡単なぶん、動いている状態のまま将来壊れる設計になりやすい領域です。終了条件をヘッダーで判定する数行を入れておくだけで、記事が何本に増えても止まりません。実装した直後に、per_page を割り切れる数にして最後まで取り切れるかを一度試しておくことをおすすめします。
graciautoでは、WordPressをヘッドレスCMSとして使った高速サイトの構築や、記事の自動生成・自動公開の仕組みづくりを行っています。「記事は書いているが更新の反映や運用が回らない」という段階でのご相談も承っています。