副業・収益化

KDP自動化スクリプトが深夜に止まり続けた原因と5時間かけて気づいたタイミングバグの話

Ichinose Taito — MBTI × 心理学 × AI

心理学で、
人間関係を
ちょっとラクにする。

MBTIとアドラー心理学を軸に、
自分と他者を理解するヒントを発信しています。

記事を読む ›
ちのくん
ちのくん
— こんな活動をしています —
KDP自動化スクリプトが深夜に止まり続けた原因と5時間かけて気づいたタイミングバグの話

何が起きたか

KDP自動出版パイプラインを深夜に回したら、価格設定ページで止まり続けた。スクリプトは正常終了を報告しているのに、Kindle管理画面を開くと本が「下書き」のまま。翌朝また回す→また止まる。これを2回繰り返した。原因は「価格フォームの入力は通っているが、保存前に次ページへ遷移しようとしていた」という単純なタイミングバグだった。気づくまでに合計で5時間くらい溶けた。


環境

  • macOS Sequoia / MacBook Pro M5 32GB
  • Claude Code(Sonnet)でスクリプト修正
  • Playwright(非同期)で KDP ダッシュボードを操作
  • スクリプト: 01_Scripts/kdp/kdp_ai_book_generator.py
  • 価格設定ロジック: 同ファイル内 _set_pricing() メソッド

詰まったポイント

最初の失敗ログがこれ。深夜5時過ぎに回したバッチだ。

価格フォームにはちゃんと数字が入っている。KDPの「マーケットプレイスごとの価格設定」テーブルに 9.99 USD が表示されている。見た目は正常。

ところが「保存して続行」ボタンを叩いた直後、ページが遷移せずに止まる。

URLは https://kdp.amazon.co.jp/title-setup/kindle/.../pricing のまま動かない。エラーメッセージも出ない。ただ固まっている。

スクリプト側のログには "pricing saved: True" と出ているのに、ブラウザ上では保存が完了していなかった。

問題の箇所を抜き出すとこう↓。

# 修正前(バグあり)
await page.fill('input[data-testid="price-input-US"]', "9.99")
await page.click('button[data-testid="save-and-continue"]')
await page.wait_for_url("**/review**", timeout=10000)

fill() は DOM の value を書き換えるが、React の state までは更新しない場合がある。KDPのフォームはReact製で、fill() 直後に save-and-continue を押すと「未入力」扱いでバリデーションが通らないことがある——これが推測だったが、後で page.evaluate() で確認して合っていた。


解決までの手順

ステップ1 — 「保存済み」と「実際に保存された」を分離して確認する

page.fill() 後に入力値を page.input_value() で読み直して、期待値と一致しているか確認するコードを追加。

filled_val = await page.input_value('input[data-testid="price-input-US"]')
assert filled_val == "9.99", f"入力値不一致: {filled_val}"

これは通った。入力値は合っている。

ステップ2 — React の state 更新をトリガーする

fill() だけでは React が変化を検知しないパターンがある。dispatch_event()input イベントを明示的に送った。

await page.fill('input[data-testid="price-input-US"]', "9.99")
await page.dispatch_event('input[data-testid="price-input-US"]', "input")
await page.dispatch_event('input[data-testid="price-input-US"]', "change")

これで React side effect が走り、ボタンが「押せる状態」に変わった(DOM の disabled 属性が消えるのを wait_for_selector で確認できた)。

ステップ3 — ボタンが enabled になるまで待ってから叩く

await page.wait_for_selector(
    'button[data-testid="save-and-continue"]:not([disabled])',
    timeout=8000
)
await page.click('button[data-testid="save-and-continue"]')

クリック後の wait_for_url タイムアウトも 10s → 20s に伸ばした。KDP の画面遷移はサーバーサイド処理が入るので遅い。

ステップ4 — 再実行して出版前状態を確認

レビューページへの遷移を確認。/pricing から /review へ移動している。あとは publish ボタンを叩くだけの状態になった。


コード/設定の抜粋

_set_pricing() の修正後の核心部分だけ。

async def _set_pricing(self, page, price_usd: float) -> bool:
    price_str = f"{price_usd:.2f}"

    # 入力
    selector = 'input[data-testid="price-input-US"]'
    await page.fill(selector, price_str)

    # React state 更新を強制
    await page.dispatch_event(selector, "input")
    await page.dispatch_event(selector, "change")

    # ボタンが有効になるまで待つ
    btn = 'button[data-testid="save-and-continue"]:not([disabled])'
    try:
        await page.wait_for_selector(btn, timeout=8000)
    except TimeoutError:
        self.logger.error("保存ボタンが有効化されなかった")
        return False

    await page.click(btn)

    # 遷移確認
    try:
        await page.wait_for_url("**/review**", timeout=20000)
        return True
    except TimeoutError:
        current = page.url
        self.logger.error(f"遷移失敗: {current}")
        return False

KDP の URL パターンはロケールによって微妙に変わるので **/review** のグロブが安全。


試してわかったこと

React製フォームは fill() で見た目が変わっても内部 state が追いついていないことがある。特に KDP・note・coconala といった「保存ボタンが動的に disabled/enabled を切り替えるタイプ」の画面で詰まりやすい。

手順を整理すると:
fill()dispatch_event("input") + dispatch_event("change")wait_for_selector(:not([disabled]))click()wait_for_url()
この5ステップを全部踏まないと「保存した気になっているだけ」になる。

タイムアウト値は「体感より2倍長く」がちょうどいい。KDP のサーバー処理は夜中でも10〜15秒かかることがある。焦って短くすると夜中に失敗を量産する。

あと「スクリプトが成功ログを出した」と「実際に保存された」は別の話だと今回痛感した。成功判定のロジックを「URL遷移の確認」に変えてから、偽陽性がゼロになった。


まとめ

React フォームは fill() だけでは state が更新されない場合がある。dispatch_event("input")change を両方送ること。ボタンの disabled 解除を待ってからクリックし、URL 遷移で成功を確認するまでがセット。「保存された」と「保存されたログが出た」は別物——これだけで夜中の積み重ねは防げる。