はじめに
WordPress REST APIを使うと、外部ツールから投稿を更新できます。しかし、WordPressの管理画面で編集した後に、外部ツールが古い本文を送信すると、新しい修正を上書きしてしまう可能性があります。
対策の基本は、取得時の更新日時を覚えておき、保存直前の更新日時と一致するときだけ更新することです。さらに、更新対象を下書きに限定し、保存後の値を再取得して照合します。
modifiedを使ったクライアント側の競合検知を実装します。WordPressが更新を自動的にロックしてくれる設定ではありません。下書きを安全に更新する4段階
今回の更新処理は、次の4段階に分けます。
| 段階 | 確認する内容 | 防げる問題 |
|---|---|---|
| 1. 最新版の取得 | 状態、modified、本文、分類 |
古い情報を前提にした更新 |
| 2. 更新前の比較 | 取得時と現在のmodified |
別画面で行われた編集の上書き |
| 3. 下書き限定で更新 | statusがdraftか |
公開済み記事の誤更新 |
| 4. 保存後の照合 | 本文、スラッグ、カテゴリーなど | 保存漏れや想定外の変換 |
この考え方は、データを取得した時点と更新する時点の状態を比較する「楽観的な競合検知」に近い方法です。
実行前の準備
コード例は、組み込みのfetchが使えるNode.js 18以降を前提にします。WordPress側では、投稿を編集できるユーザーのアプリケーションパスワードを発行してください。
認証情報はコードへ直接書かず、環境変数から読み込みます。
WP_SITE_URL=https://example.com
WP_USERNAME=api-user
WP_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx
アプリケーションパスワードはHTTPS経由で使います。通信を暗号化する理由は、HTTPとHTTPSの違いとHTTPSが必要な理由で確認できます。
最新の下書きと更新日時を取得する
まず、更新対象の投稿をcontext=editで取得します。_fieldsを指定すると、必要な項目だけにレスポンスを絞れます。
const siteUrl = process.env.WP_SITE_URL;
const username = process.env.WP_USERNAME;
const appPassword = process.env.WP_APP_PASSWORD;
const authorization = `Basic ${Buffer.from(
`${username}:${appPassword}`,
).toString("base64")}`;
async function getDraft(postId) {
const url = new URL(`/wp-json/wp/v2/posts/${postId}`, siteUrl);
url.searchParams.set("context", "edit");
url.searchParams.set(
"_fields",
"id,status,modified,slug,title,content,categories,tags",
);
const response = await fetch(url, {
headers: {
Accept: "application/json",
Authorization: authorization,
},
});
if (!response.ok) {
throw new Error(`下書きの取得に失敗しました: ${response.status}`);
}
const post = await response.json();
if (post.status !== "draft") {
throw new Error(`更新できるのは下書きだけです: ${post.status}`);
}
return post;
}
編集を始めるときに取得したmodifiedを、expectedModifiedとして保存しておきます。更新処理へ渡すのは本文だけでなく、この更新日時も必要です。
更新直前に競合を検知する
更新ボタンを押した時点で、同じ投稿をもう一度取得します。取得時からmodifiedが変わっていたら、管理画面や別ツールで編集された可能性があるため処理を中止します。
async function updateDraftSafely(postId, expectedModified, input) {
const current = await getDraft(postId);
if (current.modified !== expectedModified) {
throw new Error(
`更新競合を検出しました。取得時=${expectedModified}、現在=${current.modified}`,
);
}
const response = await fetch(
new URL(`/wp-json/wp/v2/posts/${postId}`, siteUrl),
{
method: "POST",
headers: {
Accept: "application/json",
Authorization: authorization,
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify({
title: input.title,
content: input.content,
slug: input.slug,
categories: input.categoryIds,
tags: input.tagIds,
status: "draft",
}),
},
);
if (!response.ok) {
throw new Error(`下書きの更新に失敗しました: ${response.status}`);
}
return verifySavedDraft(postId, input);
}
ポイントは、入力値から公開状態を受け取らず、コード側でstatus: "draft"に固定することです。公開操作が必要になっても、下書き更新とは別の権限と処理に分けた方が安全です。
保存後にWordPressの値を照合する
HTTPリクエストが成功しても、意図した値がすべて保存されたとは限りません。更新後の投稿を再取得し、重要な項目を比較します。
function normalizeHtml(value) {
return String(value ?? "")
.replace(/\r\n?/g, "\n")
.replace(/�*([0-9a-f]+);/gi, (_, code) =>
String.fromCodePoint(Number.parseInt(code, 16)),
)
.replace(/�*([0-9]+);/g, (_, code) =>
String.fromCodePoint(Number.parseInt(code, 10)),
)
.trim();
}
function sameNumberSet(left = [], right = []) {
const a = [...new Set(left.map(Number))].sort((x, y) => x - y);
const b = [...new Set(right.map(Number))].sort((x, y) => x - y);
return a.length === b.length && a.every((value, index) => value === b[index]);
}
async function verifySavedDraft(postId, input) {
const saved = await getDraft(postId);
const mismatches = [];
if (saved.title.raw !== input.title) mismatches.push("title");
if (normalizeHtml(saved.content.raw) !== normalizeHtml(input.content)) {
mismatches.push("content");
}
if (saved.slug !== input.slug) mismatches.push("slug");
if (!sameNumberSet(saved.categories, input.categoryIds)) {
mismatches.push("categories");
}
if (!sameNumberSet(saved.tags, input.tagIds)) mismatches.push("tags");
if (mismatches.length > 0) {
throw new Error(`保存後の照合に失敗しました: ${mismatches.join(", ")}`);
}
return {
postId: saved.id,
modified: saved.modified,
verified: true,
};
}
カテゴリーとタグは順番ではなく、IDの集合として比較しています。本文は改行コードと数値文字参照を正規化し、意味が同じなのに表記だけが違うケースを減らしています。
この方法だけでは完全な排他制御にならない
modifiedの比較は、外部ツールが古い下書きを送る事故を減らすために有効です。ただし、「最後の確認」と「更新リクエスト」の間へ別の更新が入る可能性は残ります。また、更新日時の精度だけでは、非常に短い間隔の変更を区別できない場合があります。
クライアント側だけで実装する場合も、次の対策を組み合わせると事故の範囲を小さくできます。
- 更新対象を下書きだけに限定する
- 更新直前に最新版を再取得する
- 競合時は自動で再試行せず、人が差分を確認する
- 保存後にWordPress側の値を再取得する
- 更新前の本文を復元できる形で残す
- 公開と削除は別の処理に分ける
よくあるエラーの確認ポイント
| 状況 | 主な確認項目 |
|---|---|
| 401 Unauthorized | ユーザー名、アプリケーションパスワード、HTTPS、Authorizationヘッダー |
| 403 Forbidden | ユーザーに対象投稿を編集する権限があるか |
title.rawなどがない |
context=editを指定し、認証できているか |
| 更新直後に照合が失敗する | WordPressやプラグインによるHTML変換、カテゴリー・タグID、スラッグの自動変更 |
| 競合を検出した | 最新版を取得し直し、ローカルの変更との差分を人が確認する |
照合エラーを無視して公開処理へ進むのではなく、どの項目が一致しなかったかを記録して止める設計が重要です。
まとめ
WordPress REST APIで既存の下書きを更新するときは、更新リクエストを送るだけでなく、取得時のmodifiedと現在の値を比較します。さらに、対象を下書きへ限定し、保存後の本文や設定を再取得して照合すると、誤上書きや保存漏れに気づきやすくなります。
この方法はクライアント側の安全策であり、完全な排他制御ではありません。まずは下書きだけの小さな範囲から導入し、競合時に自動更新しない運用と組み合わせてください。



コメント