Zennには記事(Article)とは別に、複数の章をまとめて出す「本(Book)」がある。自分はその本をGitHub連携で運用していて、原稿をGitHubへpushするとZenn側へ自動で反映される。
章を更新してpushしたら、反映されたかを確認する。
公開ページをcurlで叩いて、追加したはずの文字列をgrepする。0件。届いていない。
そう判断して、デプロイの記録を洗い始めた。判断が間違っていた。 実際はpushの6秒後に反映されていた。
この記事は、その誤判定の中身と、正しい確認手順を残すためのもの。Zennで本(Book)をGitHub連携で運用している人向け。記事(Article)とは挙動が違うので、そこも分けて書く。
未認証のHTMLに、本の章の本文は1文字も入っていない
まず、やってしまった確認方法。
curl -s https://zenn.dev/<user>/books/<book>/viewer/<chapter> | grep -c "追加した文字列"
# → 0
返ってきたHTMLは38,641バイトあった。空ではない。だから「ページは取れている。文字列が無い=届いていない」と読んだ。
実際にこのHTMLに何が入っているかを数えたら、こうなった。
| 探した語 | 出現 | 実体 |
|---|---|---|
| 本のタイトルに含まれる語 | 20 | ヘッダ・パンくず・OGP・サイドバー |
| 章の本文にしかない語(3種類) | 0 | 入っていない |
つまり本のタイトル、目次、メタ情報は入る。章の本文は入らない。 Zennの本のビューアは本文をクライアント側で取りに行く作りなので、素のHTMLを取っても本文は付いてこない。
届いていても0が出る。届いていなくても0が出る。この確認方法では、そもそも区別がつかない。
対照を置いたのに、その対照が無効だった
ここが自分でも一番こたえた部分だ。
「取得自体が失敗していないか」を確かめるため、対照になる語を1つ入れていた。ページに必ずあるはずの語をgrepして、それが取れていれば取得は成功している、という判定。
選んだのは「稟議」。本の内容に関わる語で、そのページに必ずある。取れた。だから「取得は成功、文字列は本当に無い」と結論した。
後で確認したら、「稟議」が20回出てきた場所は全部本のタイトルの中だった。
Claude Code チーム/企業導入 安全パック——稟議・ポリシー・権限設計・月次レビューの型
タイトルはHTMLに入る。本文は入らない。タイトルにも含まれる語を対照に選んだ時点で、その対照は「本文が取れているか」を何も検証していなかった。
対照を置くこと自体は正しい。ただ、対照は「検証したい経路を通っているもの」から選ばないと意味がない。本文の到達を測りたいなら、対照も本文にしか出ない語でなければならない。
ちなみに grep -c を使っていたのも効いていた。-c はマッチした行数を返す。ZennのHTMLは実質1行なので、20回出現していても返り値は1になる。「1件ある」と読んで、出現回数を確認しなかった。
正しい確認手順
認証済みのブラウザから、3つのAPIを順に見る。私はCDP(Chrome DevTools Protocol、ブラウザをプログラムから操作する仕組み)経由で叩いているが、ログイン済みのブラウザのコンソールで実行しても同じ。
1. デプロイが成功したか
const r = await fetch('/api/me/github_deployments', {credentials:'include'});
const d = (await r.json()).github_deployments[0];
console.log(d.deploy_status, d.failure_reason, d.notice_list);
deploy_status が SUCCESS かどうか。failure_reason と notice_list に落ちた理由が日本語で入る。
私のケースではここが SUCCESS だった。この時点で「デプロイされていない」という当初の見立ては崩れている。
2. 何がどこへ着地したか
同じレスポンスの deployed_items に、ファイルと反映先の対応が入っている。
d.deployed_items.map(i => i.repo_path + ' -> ' + i.zenn_path)
// → "books/cc-team-safety-pack/intro.md -> /link/books/cc-team-safety-pack?chapter_slug=intro"
反映先のURLを自分で組み立てて確認しに行くと、推測を間違えたときに「届いていない」と誤読する。ここに書いてあるものを使う。
3. 本文が実際に置き換わったか
章のIDを取ってから、章のAPIを見る。
const book = await (await fetch('/api/books/<book-slug>', {credentials:'include'})).json();
const ch = book.chapters.find(c => c.slug === 'intro');
const chapter = (await (await fetch('/api/chapters/' + ch.id, {credentials:'include'})).json()).chapter;
console.log(chapter.body_updated_at);
console.log(chapter.body_html.includes('追加した文字列'));
body_html に文字列が入っているかで判定する。あわせて body_updated_at を見れば、いつ書き換わったかが分かる。私のケースはこうだった。
body_updated_at: 2026-08-07T22:19:59
pushの6秒後。最初から届いていた。
なお body_markdown というフィールドも存在するが、私の環境では空文字が返った。判定には body_html を使うほうが確実だった。
ついでの注意点
notice_list は遅れて埋まる。デプロイ直後に読むと空で、1分後に読み直すと2件入っていたことがある。空を「問題なし」の根拠にしない。
failure_reason が null でも notice_list に理由が入ることがある。片方だけ見ない。実際にこれで、章の本文が5万字の上限を超えて保存に失敗していたのを1か月半見逃したことがある。pushは成功し、GitHubも200を返すので、外からは成功と区別がつかない。
記事(articles)は素のcurlで確認できる。本の章と違って本文がHTMLに入るので、公開ページをcurlしてgrepする方法がそのまま使える。同じ感覚で本にも適用したのが今回の失敗だった。
まとめ
- Zennの本の章は、未認証のHTMLに本文が入らない。curl + grepでは届いていても0が出る
- 反映の確認は
/api/me/github_deploymentsのdeploy_statusとdeployed_items、/api/chapters/<id>のbody_htmlで行う - 判定に対照を置くときは、検証したい経路を通っている対照を選ぶ。タイトルにも本文にもある語では、本文の到達を検証できない
-
grep -cは行数を返す。1行のHTMLに対して使うと、20回出現していても1になる
自分は「対照を置け」という原則を守ったつもりでいた。守ったのは形だけで、その対照が何を検証しているかを考えていなかった。0という数字を見て原因の調査を始めるより先に、その0が本当に測りたいものを測った結果なのかを疑うべきだった。
Claude Code まわりの「成功に見えるのに、実際には届いていない」は、毎月あたらしい形で出てくる。その月に実際に起きた事故と仕様の変更を、実機で確かめてから月に1本にまとめている(Claude Code 安全運用便・¥500/月・最初の1か月は無料)。無料で足りる人は、cc-safe-setup の hook をそのまま使ってほしい。