EmDash の記事を API で一括更新する。_rev の場所と draft → publish
ブログのタイトルを 51 本、本文の言い回しを 15 本、記事どうしのリンクを 32 本、1 日でまとめて直しました。管理画面で 1 本ずつ開く量ではないので、Astro 製の CMS「EmDash」の API を直接叩いて一括更新しました。その手順と、途中で踏んだものを書きます。
対象は EmDash 0.29 です。CLI(emdash content update)を使わず API にしたのは、更新内容が「タイトルだけ」「本文の特定の文だけ」で、Portable Text をプログラムで書き換えたかったからです。CLI は markdown との相互変換を挟むので、生 HTML ブロックを含む移行記事では読み書きで壊れることがありました。
更新の流れ
1 記事あたり、次の 3 リクエストです。
GET /_emdash/api/content/posts/{slug} # 現在の中身と _rev を取る
PUT /_emdash/api/content/posts/{id} # data を書き換えて下書きとして保存
POST /_emdash/api/content/posts/{id}/publish # 公開PUT の body はこの形です。
{
"data": { "...item.data をまるごと", "title": "新しいタイトル" },
"_rev": "MzoyMDI2LTA4LTA2VDIyOjI0OjM2LjMwNlo=",
"status": "draft"
}data は部分更新ではなく、取得した item.data を丸ごと入れて必要な項目だけ書き換えます。status: "draft" を付けると自動公開されず、下書きとして保存されます。公開は別の POST です。
_rev はレスポンスの data._rev にある
最初の PUT は 400 で落ちました。
{"error":{"code":"VALIDATION_ERROR","message":"Invalid request data",
"details":{"issues":[{"path":"_rev","message":"Invalid input"}]}}}記事には version: 3 という数値があり、これを _rev に渡していました。CLI のヘルプには「Revision token from get」とあるので get のレスポンスを見直すと、_rev は item の中ではなく、その外側にありました。
{
"data": {
"item": { "id": "...", "version": 3, "data": { ... } },
"_rev": "MzoyMDI2LTA4LTA2VDIyOjI0OjM2LjMwNlo="
}
}Base64 を戻すと 3:2026-08-06T22:24:36.306Z、バージョンと更新時刻の組です。サーバーは decodeRev でこれを戻し、両方が今の記事と一致しないと 409 CONFLICT を返します。
if (decoded.version !== item.version || decoded.updatedAt !== item.updatedAt) return {
valid: false,
message: "Content has been modified since last read (version conflict)"
};実際に 1 回、409 を踏みました。GET の直後に PUT しているのにずれた理由は追い切れていません(D1 の読み取りが直前の書き込みを反映していなかったのではないかと疑っていますが、確認できていません)。409 のときはもう一度 GET して _rev を取り直し、同じ PUT を送り直せば通りました。
未公開の下書きがある記事は触らない
GET のレスポンスに liveData があるときは、公開版と別に未公開の下書きが存在します。このとき item.data は下書きの側です。ここに機械的な書き換えを乗せて公開すると、誰かが途中まで書いていた下書きごと公開してしまいます。
一括処理では liveData があれば飛ばして、あとで手で見ることにしました。113 記事のうち該当は 1 本で、それは前の処理が PUT の直後に止まって publish まで進まなかった自分の下書きでした。
Portable Text は text キーだけを書き換える
本文は Portable Text の配列です。タイトルを引用しているリンクの文言を新しいタイトルに揃えるとき、配列全体を再帰的に歩いて text キーだけを置換しました。
const walk = (node, fn) =>
Array.isArray(node) ? node.map((n) => walk(n, fn))
: node && typeof node === "object"
? Object.fromEntries(Object.entries(node).map(([k, v]) =>
[k, k === "text" && typeof v === "string" ? fn(v) : walk(v, fn)]))
: node;コードブロックは text ではなく code キーに本文を持つので、この歩き方なら触りません。WordPress から移行した記事の生 HTML ブロック(htmlBlock)は html キーなので、こちらも素通りです。逆に言うと、HTML ブロックの中の文をリンクにしたい場合は html を文字列として編集する必要があり、2 本だけそうしました。
既存の文の一部をリンクにする場合は、その文を含むスパンを 3 つに割ります。前・リンク部分・後ろです。リンク部分には新しい markDefs のキーを marks に足します。
{
"markDefs": [{ "_key": "l1", "_type": "link", "href": "/posts/nodemailer" }],
"children": [
{ "_type": "span", "text": "添付については ", "marks": [] },
{ "_type": "span", "text": "Base64 の画像を添付する方法", "marks": ["l1"] },
{ "_type": "span", "text": " に分けて書いています。", "marks": [] }
]
}置換する語句が 1 つのスパンに収まっていないと(途中に太字が挟まっているなど)この分割はできません。そのときは語句を変えるか、その記事は見送ります。34 本のうち 1 本がこれで語句を変えました。
タグの割り当てはコンテンツの更新とは別の経路
タグは data の中にありません。POST /_emdash/api/content/posts/{id}/terms/tag に {"termIds": [...]} を送ります。スラッグではなくタームの ID で、POST は追加ではなく置き換えです。既存のタグを残すなら、既存分の ID も一緒に送ります。
この経路はコンテンツの更新とは別扱いで、EmDash はここではエッジキャッシュを失効させません。私のサイトではミドルウェアで「タームの割り当てが成功したら全 purge」を補っていますが、その結果、79 記事にタグを付けた数分間で 79 回サイト全体のキャッシュを消していました。記事を 1 本書くたびに付ける分には問題にならず、一括で付けるときだけ気にする話です。
タグの割り当ては記事の updatedAt を動かしません。タイトルや本文の更新は動かします。記事ページに更新日を出しているので、一括でタイトルを変えた記事は全部その日の日付になりました。
503 は前提にする
書き込みの経路はエッジキャッシュの対象外なので、連続で叩くと Workers 無料プランの CPU 上限に当たって 503 が返ることがあります。8 月の初めに別のサイトで 57 記事を流したときは、update 2 件と publish 6 件が 503 で、再実行して通しました。今回は 503 を受けたら 3 秒待って再送する形にしておき、110 記事が途中で止まらずに終わりました。
この構成で気をつけること
_revはGETのレスポンスのdata._rev。item.versionを渡すとInvalid inputで 400PUTはstatus: "draft"で保存し、POST .../publishで公開する。2 段にしておくと、公開前にdataを照合できるliveDataがあれば未公開の下書きがある。機械的な更新を乗せない- Portable Text は
textキーだけを歩けば、コードブロックと HTML ブロックを壊さない - タグは別 API で、ID を渡す置き換え。キャッシュの失効も更新日も、コンテンツ更新とは挙動が違う
- 409 は
_revを取り直して再送、503 は待って再送
一括更新のスクリプトは使い捨てにしました。残したのは「どの記事のどの文をどう変えたか」の一覧だけで、次に同じことをするときはこの記事を読み直せば足ります。