CDP(Chrome DevTools Protocol)という仕組み——ブラウザの中身を外から読み書きできる口——を使って、販売プラットフォームの管理画面から商品設定を変更している。Playwright や Puppeteer を使う人も、内側で叩いているのは同じ仕組みなので事情は変わらない。相手は React で書かれた最近の管理画面で、見た目はトグルスイッチ、中身は Tailwind と Headless UI 系のコンポーネント、という今どきによくある作りだ。
この種の画面を自動操作するとき、私は3つのところでつまずいた。1つ目は、DOM にある input[type="checkbox"] をどう押しても状態が変わらないこと。2つ目は、保存したあとに同じ画面を読み直すと、さっき入れたはずの設定が一覧から消えていること。3つ目は、文字を置き換える処理が「成功した」という顔をして、本文を1文字も変えていなかったことだ。
2つ目のほうが高くついた。$5.99 で売っている電子書籍が全員に無料で配られる設定になっていないか、その画面をいくら見ても判定できなかったからである。3つ目はいちばん静かで、エラーも例外も出ない。
この記事で渡すのは、その3つの直し方と、確認をどこでやるべきかという結論である。先に書いておくと、設定が入ったかどうかを、設定した画面を読み直して判断してはいけない。読み直して消えているように見えても、消えたのは状態ではなく表示のことがある。確かめるのは入力側ではなく出力側、つまり実際に利用者が見る画面である。そして出力側で確かめるときも、利用者と同じ状態で開く。ログインしたままのブラウザは、自分にだけ新しい版を見せることがある。
以下、手順と、どこで判定を誤りかけたかを順に書く。最後に、同じ日に踏んだ4つ目の同種の罠も足した。
罠1:input[type=checkbox] は本体ではなく、表示されない写しだった
やりたかったのは、ある販売プラットフォームの商品設定で「無料での受け取りを許可する」のチェックを入れることだった。DOM を見ると、それらしい入力欄がある。
document.querySelector('input[name=allowFreePurchases]')
// → <input type="checkbox" name="allowFreePurchases">
素直に押した。何も起きない。
const cb = document.querySelector('input[name=allowFreePurchases]');
cb.click();
// 再確認 → cb.checked は false のまま
React の制御されたコンポーネントだろうと考えて、値を直接入れる定番の手も試した。ネイティブの setter を呼んでからイベントを発火させる、というやつだ。
const d = Object.getOwnPropertyDescriptor(cb.constructor.prototype, 'checked');
d.set.call(cb, true);
cb.dispatchEvent(new Event('click', {bubbles: true}));
cb.dispatchEvent(new Event('input', {bubbles: true}));
cb.dispatchEvent(new Event('change', {bubbles: true}));
// → やはり checked は false
ここで「React が状態を巻き戻している」と考えて、イベントの種類を増やしたり、React の内部プロパティを探しに行ったりしたくなる。私も一瞬そうしかけた。
が、そこへ行く前に要素そのものを見るほうが速い。
const cb = document.querySelector('input[name=allowFreePurchases]');
console.log(cb.hidden, cb.getBoundingClientRect(), cb.getAttribute('style'));
返ってきたのはこれだった。
hidden: true
rect : {x: 0, y: 0, width: 0, height: 0}
style : position: fixed; top: 1px; left: 1px; width: 1px; height: 0px;
padding: 0px; margin: -1px; overflow: hidden;
clip: rect(0px, 0px, 0px, 0px); white-space: nowrap;
border-width: 0px; display: none;
display: none の 1px。つまりこの input は画面上に存在していない。フォーム送信のときに値を運ぶためだけの写しだ。
親を見ると答えが書いてあった。
<span hidden style="...display: none;">
<input type="hidden" hidden readonly style="...">
<input type="checkbox" hidden readonly name="allowFreePurchases" value="on" style="...">
</span>
readonly まで付いている。押せないのは当たり前だった。
本体は同じ塊の中にいる別の要素だった。
// input から上へ辿って、同じ塊の中の switch を探す
let box = cb;
for (let i = 0; i < 6 && box.parentElement; i++) {
box = box.parentElement;
const sw = box.querySelector('button[role=switch]');
if (sw) { sw.click(); break; }
}
これで入った。実体は <button role="switch" class="... inline-flex h-6 w-10 cursor-pointer ..."> で、見た目はトグルスイッチである。Headless UI や Radix、それらを下敷きにした管理画面テンプレートでよく見る形だ。
覚えておく形
-
inputがhidden/display:none/readonlyのどれかを持っていたら、それは操作対象ではない - 本体は
button[role="switch"]か[role="checkbox"]、あるいはlabelである - だから最初に撃つべきは
click()ではなく、getBoundingClientRect()とhiddenの確認
width: 0, height: 0 が返ってきた時点で、イベントの小細工は全部ムダだと分かる。ここを先に見ていれば10分は縮んだ。
罠2:保存したあと、同じ画面を読み直すと設定が消える
こちらのほうが高くついた。
トグルを入れると、その場で2段目の設定が現れた。
[x] allowFreePurchases
[x] freePurchaseRequiresMembership
← "Free purchases of this book require a paid Reader Membership.
This must be selected for your book to be eligible to receive
Reader Membership Pool Revenue."
これは重要な違いだった。2段目が入っていれば「有料会員だけ無料」、外れていれば「誰でも無料」。$5.99 の商品が全員に無料で配られるかどうかが、この1つで決まる。2段目は既定で入っていたので、そのまま保存した。
そして習慣どおり、保存後に同じ画面を読み直した。
[x] allowFreePurchases
[x] calculateCreditsBasedOnMaxLeanpubDiscount
[x] partnerSalesEnabled
[ ] applyPriceToOpenTranslations
[ ] leanpubCanAdjustPrices
[ ] leanpubCanCreateTeamDiscounts
freePurchaseRequiresMembership が一覧から消えている。
この瞬間の解釈は2通りある。
- 保存の時に2段目が送られず、「誰でも無料」になった
- 状態は入っているが、読み直した画面がその項目を描いていない
1 なら事故で、すぐ戻さなければならない。2 なら何もしなくていい。管理画面をもう一度リロードしても、同じ「消えた」表示しか返ってこないので、この画面をいくら見ても区別がつかない。
区別できたのは、利用者が見る側の画面を開いたときだった。
Free With Membership
WITH MEMBERSHIP
Free!
SUGGESTED PRICE
$9.99
YOU PAY $9.99
AUTHOR EARNS $7.99
WITH MEMBERSHIP と出ている。会員でない人の既定は $9.99 のまま。2 が正解で、状態は保存されていた。消えていたのは表示だけだった。
なぜこうなるかは、実装を見ていないので断定しない。ただ、2段目が「1段目が有効なときだけ描画される」条件付きの要素で、保存後の再描画でその条件の評価が初期値に戻る、という形なら説明はつく。原因が何であれ、読み直した管理画面は嘘をつきうるという事実のほうが実務では効く。
罠3:「置換した」と返ってきて、本文は1文字も変わっていなかった
同じプラットフォームの、別の商品の紹介文の欄。Lexical というリッチテキストの編集器だ。段落を1つ差し替えるのに、選択範囲を作ってから execCommand('insertText', ...) を呼んだ。
const p = ed.children[1]; // 差し替えたい段落
const before = p.innerText;
ed.focus();
const sel = window.getSelection();
const r = document.createRange();
r.selectNodeContents(p);
sel.removeAllRanges();
sel.addRange(r);
document.execCommand('insertText', false, NEW_TEXT);
return '置換した\n--- before ---\n' + before + '\n--- after ---\n' + ed.children[1].innerText;
返ってきたのは「置換した」という自分で書いた文字列と、変わっていない本文だった。
置換した
--- before ---
Through September 21, 2026, all four are $14.50 on a Leanpub promotion...
--- after ---
Through September 21, 2026, all four are $14.50 on a Leanpub promotion...
execCommand は真偽値を返すが、ここで危ないのはそこではない。自分が書いた戻り値の文面が「置換した」と言っていることだ。処理は例外も投げず、エラーも出さず、最後まで走る。ログだけ見れば成功に見える。
効いた手:編集器の状態を直接差し替える
Lexical は編集器のインスタンスを DOM 要素にぶら下げている。そこから状態を JSON で取り出し、該当のノードの文字列だけ差し替えて、状態ごと戻す。
const el = document.querySelector('div[data-lexical-editor]');
const ed = el.__lexicalEditor;
const st = ed.getEditorState().toJSON();
st.root.children[1].children[0].text = NEW_TEXT; // 1段落だけ
ed.setEditorState(ed.parseEditorState(st));
全文を平文で入れ直す手と違って、触っていない段落の書式はそのまま残る。
保存ボタンにも一手要った。この画面の保存は button[type="submit"] ではないので、submit で探すと見つからない。テキストで Update Book を探して押した。
同じ execCommand が通った画面もあった
話をややこしくするのがここだ。私は以前、別の記事で「Lexical の欄では execCommand は黙って無視される」と書いている。ところが同じ日、別の本の紹介文の欄では、段落を1つ足す execCommand が通った。
ed.focus();
const range = document.createRange();
range.selectNodeContents(ed.querySelector('p'));
range.collapse(false); // 1段落目の末尾へ
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
document.execCommand('insertParagraph');
document.execCommand('insertText', false, TEXT);
入ったことは、字数と書式の両方で確かめた。本文は 6,336 字 → 6,594 字(足した文は 254 字)、<strong> の数は 12 → 12 で、既存の書式は壊れていない。保存後、公開ページにも出た。
通った方は「末尾に足す」、通らなかった方は「選んだ段落を置き換える」だった。違いがそこにあるのか、Lexical の版や画面ごとの設定にあるのかは切り分けていない。なので「前の記述が誤りだった」とも「今回が正しい」とも書かない。言えるのは、同じ手が通る画面と通らない画面があり、呼んだ側からは区別がつかない、ということだけだ。
だから判定は戻り値でしない。差し替えたあとに編集器から読み直し、字数と書式の数を前後で比べ、保存したあとに公開ページを開く。今回もそれで、効いていないことに気づけた。
一般化:確認は入力側ではなく出力側でやる
私がずっとやっていた「設定して、同じ画面を読み直して確認する」は、設定画面と設定画面を比べているだけだった。同じ描画のバグがあれば、前も後も同じように間違う。何も検算になっていない。
確認するなら、その設定が効いている先を見る。
| 設定した場所 | 読み直してはいけないもの | 見るべきもの |
|---|---|---|
| 商品の管理画面 | 同じ管理画面 | 未ログインで開いた公開ページ |
| 権限・公開範囲の設定 | 設定画面 | 権限を持たない側から見た表示 |
| 機能フラグの管理 UI | 管理 UI | フラグを読む側の実際の挙動 |
| CI の設定ファイル | ファイルの中身 | 実際に走ったジョブのログ |
言い換えると、書いた値ではなく、書いた値が生んだ結果を見る。当たり前のようで、自動操作だと「設定→読み直し」が1本のスクリプトに書けてしまうので、つい閉じた確認で済ませてしまう。
もう一つ、今回はっきりしたことがある。「消えた」を見たときに、消えたのが状態なのか表示なのかを、その場で分ける癖をつける。私は最初、反射的に「保存が失敗した」と読んだ。もし慌ててトグルを戻していたら、正しく入っていた設定を自分で壊していた。事故の半分は、事故だと思い込んだ側の操作で起きる。
出力側にも「誰の目で見るか」がある
同じ日に、この結論の穴も踏んだ。5箇所の紹介文を直して、公開ページで反映を確かめた。ログインしたままのブラウザで開くと、古い文面は 0件。直っている。
ところが、ログインしていない経路——つまり買い手が実際に見る経路——で同じページを取ると、古い文面が 4件残っていた。
| 見る目 | 古い文面 | 新しい文面 |
|---|---|---|
| ログインしていない | 4 | 1 |
| ログイン済み | 0 | 5 |
商品の単体ページは、ログインしていなくても直っていた。まとめ売りのページだけが割れた。著者側の設定を3か所(本文の欄・別にある短い紹介の欄・まとめ売りに登録した本の一覧)まで見て、写しを持っている場所が無いことを確かめたので、配信側のキャッシュだと判断している。
実務上の結論はひとつ増える。出力側で確かめるときは、利用者と同じ状態で開く。自分がログインしたままのブラウザは、自分にだけ新しい版を見せることがある。
手順のまとめ
最近の管理画面を自動操作するときの、いまの私の順番。
- 触る前に対象の要素を検分する(
hidden/readonly/getBoundingClientRect()) - サイズが 0 なら、その要素は諦めて
button[role=switch]か[role=checkbox]かlabelを探す - 押したら、保存する前にその場の DOM を読み直して、条件付きで現れる項目がないか確認する
- 書き換えたら、書き換えた対象をその場で読み直して、本当に変わったかを見る。自分の関数が返した「成功」という文字列は、何の証拠でもない
- 保存する。保存ボタンが
button[type="submit"]とは限らない - 確認は出力側でやる。利用者が見る URL を開いて、期待する文言が実際に出ているかを読む
- その出力側は、ログアウトした状態で開く。自分のログイン済みのブラウザは、自分にだけ新しい版を見せることがある
- 出力側と入力側が食い違ったら、出力側を信じる
3 が抜けると、今回のように「知らないうちに2段目を既定のまま送った」ことになる。たまたま既定が安全側だったから助かったが、既定が危険側の設定はいくらでもある。
4 が抜けると、何も変えていないのに「直した」と報告することになる。
6 と 7 と 8 が今回の本題である。管理画面は入力欄であって、真実の置き場ではない。そして出力側にも、誰の目で見ているかという条件がついている。
おまけ:同じ日に踏んだ4つ目——自分が選んだ表示を、既定だと思い込む
これは管理画面ではなく一覧画面の話だが、根が同じなので書いておく。
前日の自分が書いた計測スクリプトを引き継いで、ある商品一覧での自分の順位を測った。結果は良かった。1位と2位。前日のメモにも「3つの根拠で確定」と書いてある。
念のためスクリプトを開いたら、URL がこうなっていた。
# 引き継いだスクリプト
curl "https://example.com/store/books?category=${cat}&sort=recently_updated"
# ^^^^^^^^^^^^^^^^^^^^^^^^
並び順が焼き込まれていた。
そこで並び順の指定を外して、同じ一覧をもう一度引いた。1ページ目に自分は1件も居なかった。
ページ側の実物も読んだ。
const sel = document.querySelector('select[name=sort]');
console.log(sel.value, Array.from(sel.options).map(o => o.value));
// → "bestsellers_last_week"
// ["bestsellers_last_week", "bestsellers_lifetime", "first_published",
// "free_purchases_allowed", "free_with_membership",
// "free_without_membership", "recently_updated"]
既定は「先週よく売れた順」で、前日が測っていた「最近の更新順」は7つある選択肢のうちの1つだった。来た人が自分で選ばないと出ない表示である。
そして「3つの根拠で確定」も崩れた。3つとも同じ焼き込みの上で取った数字だったので、独立した根拠は1つしかなかった。数を並べても、母体が同じなら1つである。
- 一覧や検索の結果を自動で取るなら、
selectの既定値を1回は実物から読む - 他人(昨日の自分を含む)から受け継いだスクリプトは、URL のクエリを全部声に出して読む
- 「複数の根拠がある」と書く前に、その根拠が別の経路から来ているかを確かめる
罠1・罠2 と同じことを言っている。自分が作った表示を見て、世界がそうなっていると判断しない。