売っている技術書の値段を上げた。販売ページは同じ日に直した。
それで終わったつもりでいた。
11日後、自分が書いた記事が古い値段を案内し続けていることに気づいた。
数え直したら106本あった。読者が押した先で、値段が1.9倍になる。
自分は非エンジニアで、Claude Code——AIがコマンドを実行しながら作業を代行するツール——に
記事の管理や公開の作業を任せている。コードは書けないので、実装はAIがやる。
今回の話は、その「一括で直しておいて」を投げる前に、1回だけ確かめたことについて。
確かめてよかった。素朴にやっていたら、直してはいけない121行まで書き換えていた。
この記事は2026年8月時点の実測に基づく。
「¥800 を ¥1,500 に置換して」で壊れるもの
思いつく解はこれだ。全記事を取ってきて、文字列を置換して、書き戻す。
5分で終わる。AIに頼めば3分で終わる。
壊れる。
自分は同じ ¥800 という値段の本を、その時点で4冊売っていた。
値上げしたのは、そのうち1冊だけ。
残り3冊はいまも ¥800 で正しい。加えて Kindle 版が1つあって、
これは「買った時点の版で、毎月の追補は付かない」という違いがあるので、
意図して ¥800 のまま残してある。
つまり文字列 ¥800 は、直すべきものと直すと嘘になるものが混ざっている。
grepは両者を区別しない。
実測:244行のうち、直してよかったのは113行だった
推測で書きたくなかったので、記事251本の本文をAPIで全部取ってきて数えた。
| 数えたもの | 実測 |
|---|---|
| 走査した記事 | 251本 |
¥800 / 800円 を含む行 |
244行(165本) |
| そのうち、値上げした本のリンクと同じ行にあるもの | 113行(106本) |
| 残り | 131行=この時点では対象から外した(うち10行は取りこぼしだった。後述) |
半分より少ない。文字列で拾った作業表を鵜呑みにしていたら、
正しい記述を121箇所こわしていた。
混ざっていたものの内訳はこうだった。
- いまも ¥800 で正しい別の本(記事に実際に出てきたのは2冊・76行)
- Kindle 版の ¥800(40行)。同じ本のKindle版は「買った時点の版で追補は付かない」ので、
値段も据え置くと決めてある。直すと、その判断ごと消える - リンクが同じ行に無い行(15行)。当時の値段の引用が4行、別の本の古い値段が1行、
そして、あとで述べる取りこぼしが10行
3番目の中の「当時の値段の引用」が特に危ない。
「| 4 | ○○さん | 1冊 | ¥800 |」や「3月の初回の公開。¥800で14章の構成で公開した」。
直しても文法は壊れないし、見た目にも違和感がない。
気づけるのは、その行が何を指しているか読んだ人だけだ。
補足(技術)
一括置換の事故がやっかいなのは、失敗が静かなことにある。
構文エラーなら実行時に落ちる。だが文章の置換は必ず「成功」する。
誤って置換した121行も、置換処理としては正常終了する。
だから対象の選び方そのものに検査を入れるしかない。
対象は文字列で決めない。行き先で決める
やり方を変えた。「¥800 を含む行」ではなく、
「値上げした本へのリンクと、¥800 が同じ行にあるもの」を対象にした。
リンクのURLには本の識別子が入っている。これは一意で、他の本と衝突しない。
値段の数字は衝突するが、識別子は衝突しない。
import re
#「1800円」「¥8000」に当てないよう、前後の数字を除く
PRICE = re.compile(r'(?<![0-9,,])(?:[¥¥]\s?800|800\s?円)(?![0-9])')
TARGET = '6076c23b1cb18b' # 値上げした本の識別子
def is_target(line):
"""その行が「直すべき古い値段」かどうか。
値段の文字列ではなく、同じ行のリンクの行き先で決める。"""
return TARGET in line and PRICE.search(line)
これで 244行 → 113行に絞れた。
数字で対象を決めると、同じ数字を持つ全員が引っかかる。
行き先で決めると、その商品の話をしている行だけが残る。
同じ行に2つの商品があるとき
絞り込んでも、まだ罠があった。1行の中に2冊が並んでいる記事があった。
…をまとめた本(https://zenn.dev/.../6076c23b1cb18b)(¥800・第3章まで無料)もあります。
800時間走らせた全記録は(https://zenn.dev/.../3c3c3baee85f0a19)(¥800・第2章まで無料)。
表示の都合で折り返しているが、これは1行だ。
前半は直す。後半は直さない。同じ行にある。同じ文字列だ。
この行を対象に入れると、正しいほうの ¥800 まで消える。
対象から外すと、間違っているほうが残る。どちらも不正解。
やったのは、先に「正しいほう」を退避してから置換し、最後に戻すという手順。
KEEP = '@@KEEP@@'
# 直さない本のリンクの直後にある値段を、いったん別の文字列へ逃がす
PROTECT = re.compile(r'(3c3c3baee85f0a19[^)]*\)[^¥¥0-9]{0,8})([¥¥]\s?800)')
def strip_price(line):
out = PROTECT.sub(lambda m: m.group(1) + KEEP, line) # 逃がす
out = re.sub(r'([¥¥]\s?800[・、]\s*', '(', out) # 置換する
# …他の書き方のぶんも同様に…
return out.replace(KEEP, '¥800') # 戻す
やっていることは単純だが、この手順を入れるかどうかで結果が変わる行が3本あった。
251本のうち3本。見落としても気づかない量だ。
補足(技術)
「保護してから置換して戻す」は、テンプレート処理やコードの整形でよく使う型。
置換の規則を複雑にして例外を書き分けるより、
対象から先に抜いて、後で戻すほうが規則が単純に保たれる。
正規表現に否定先読みを積み上げると、次に読む人(数日後の自分を含む)が読めなくなる。
行き先で決めても、まだ取りこぼす
絞り込みが効いたので満足しかけたが、対象から外れた131行を1行ずつ読んでみた。
そこに、リンクが同じ行に無いだけで、値上げした本を指している記述が10行あった。
### 事故の全体像を体系で押さえたい方へ(¥800)
…事故の型ごとに止め方をまとめた800円の事故防止本(無料章あり)にまとめてある。
…実際の事故を集めて対策を整理した本(¥800)も用意しています。記事の最後に置いておきます。
見出しに値段を書いた行。本文で「800円の◯◯本」と呼んだ行。
リンクは2行下や、記事の末尾にある。
行き先で決める方法は、リンクと値段が同じ行に居ることを前提にしている。
その前提は、自分で書いた文章のうち9割では成り立っていて、1割で成り立っていなかった。
ここは自動判定を諦めて、外れた15行を目視で分類した(うち10行が取りこぼし、
4行が当時の値段の引用、1行は別の本の古い値段)。
機械で絞ってから人が読む範囲を小さくする、というのが結局いちばん速い。
一度直したはずのものが、逆向きに壊れていた
ここまでで気が済んだので、念のため売っている12冊すべての現在の値段を取ってきて、
記事に書かれた金額と突き合わせた。値上げした1冊だけでなく、全部。
8本出た。
実物が ¥800 の別の本を、記事が「¥1,500」と案内していた。
[AIに仕事を任せてみた——800時間の全記録](https://…/3c3c3baee85f0a19)(¥1,500・第2章まで無料)
押した先で、思っていたより安い。損はしないが、書いてあることは間違っている。
閲覧数を足すと 11,807。8本のうち5本は、直そうとしていた106本のどれより読まれていた。
そこで、この本にリンクしている記事を全部(74本)並べて、更新日時で割ってみた。
| 記事 | 「¥1,500」と書いていた |
|---|---|
| 前日の14時から16時のあいだに更新された7本 | 7本 |
| それ以外の67本 | 1本 |
前日のその時間にやっていたのが、値上げした本の価格の書き換えだ。
触った7本は、7本とも間違っていた。触っていない67本は、66本まで正しかった。
Qiitaに版の履歴は無いので、何がその値を書いたかは確定できない。
残る1本は25日前の更新で、こちらは是正では説明がつかない。
値段の写しは、ひとつの経路だけで腐るわけではない。
是正が、別の場所に同じ種類の誤りを作る。
これが一番怖いところだと思う。直した側は「直した」と記録して終わる。
その記録には、直していない側を測った形跡がない。
今回は、7本が翌日に見つかって、1本は26日ぶん残っていた。
翌日に見つかったのは、たまたま次の日に全部を数え直したからだ。
数え直していなければ、次に誰かが同じ棚卸しをするまで残っていた。
そのまま8本とも直した。ここでも ¥1,500 を ¥800 に書き換えるのではなく、
数字ごと落とした。
検算は「APIが成功を返したか」ではやらない
置換を書き戻したあと、APIは全件 200 を返した。
それを信じない、というのは今回意図的に決めたことだった。
以前、公開ページの更新でボタンを3回押したのに画面が変わらず「失敗した」と判断した件がある。
実際には最初から成功していて、非同期で処理されていただけだった。
逆もある。応答は成功なのに、書き戻された中身が期待と違うことがある。
なので、1件ごとに書き戻した直後にもう一度取得して、残っている件数を数えるようにした。
api('PATCH', f'/items/{aid}', payload)
again = api('GET', f'/items/{aid}') # 応答ではなく、取り直した本文で見る
left = [l for l in again['body'].split('\n') if is_target(l)]
ok = (len(left) == 0) and (again['body'] == new)
30本流して、1本だけ left = 1 が出た。
調べたら、それが上の「1行に2冊」の行だった。残っていたのは正しいほうの値段で、
つまり検算の条件のほうが雑だった。壊れていたわけではない。
このときも、条件が雑だと分かったのは中身を見たからで、
件数だけ見ていたら「1件失敗した」で終わっていた。
なお、この記事を書いている時点で、残りはまだ76本ある。
1日15〜20本ずつ直している。一晩で全部叩かないのは、
この面が1日にどれだけの更新を許すのかを知らないからだ。
恒久策:値段を書かない
直し方をもう一つ選べた。¥800 を ¥1,500 に書き換える、という選択だ。
選ばなかった。
次に値段を動かしたとき、また106本が古くなるからだ。
今回12日放置したのは、値上げの手順に「その値段を書いた場所を数える」という段が
無かったからだ。だが、その段を足しても、人間もAIもまた忘れる。
なので、値段の数字ごと落とした。
(¥800・第3章まで無料) → (第3章まで無料)
「第3章まで無料」は値段が変わっても嘘にならない。
商品ページの値段は常に正しいのだから、記事の側に写しを持つ必要がない。
計算できる値、どこかに正解がある値を、別の場所へ手で書き写した瞬間、
更新されない複製がひとつ増える。
今回の106本は、その複製が12日ぶん腐ったものだった。
まとめ
- 一括置換の対象を文字列で決めると、同じ文字列を持つ無関係なものが混ざる。
今回は 244行のうち121行(50%)が「直してはいけない」側で、
同時に10行を取りこぼしていた。多すぎるのと足りないのは同時に起きる - 対象は行き先(URL・識別子)で決める。数字は衝突するが識別子は衝突しない
- ただし行き先で絞っても、リンクと値が別の行にある記述は取りこぼす。
絞ったあと、外れたほうも一度は読む - 1行に対象と非対象が同居することがある。先に逃がして、後で戻す
- 検算は応答の成功ではなく、取り直した中身で数える
- 是正のあとに、直していない側も測る。今回いちばん読まれている誤りは、
古い値を探す片方向の検索では出てこない向きに入っていた - そもそも、他所に正解がある値を自分の文章へ書き写さない
AIに「全部直しておいて」と投げる前に、その作業表が
一致した件数なのか直すべき件数なのかを一度だけ見る。
今回はそこが2倍ずれていた。
そして直したあと、直したものだけを数えない。
自分の場合、直したつもりの作業が、隣で新しい誤りを作っていた。