0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

公式ドキュメントに載っていない仕様は、実フォームを開かないと分からない — Playwright + CDPで出品フォーム自動化を組んだ話

0
Posted at

環境

  • Node.js 20.11.x / TypeScript 5.4
  • Playwright 1.42(connectOverCDP を使用)
  • Google Chrome 123 stable(--remote-debugging-port=9222 で起動、ログイン済みプロファイルを再利用)
  • 対象: 某出品プラットフォームの出品フォーム(ReactベースのSPA)
  • 実行基盤: GitHub Actions self-hosted runner(xvfb上のフルChrome)

TL;DR

  • 公式ドキュメントの「3段階プラン」を前提に入稿データを設計したが、対象カテゴリの実フォームにはプラン設定UIそのものが存在しなかった
  • DOM全探索で「プラン要素ゼロ」を確認して原因が確定。ドキュメントは全カテゴリ共通の一般論で、カテゴリ別の出し分けまでは書かれていない
  • 回避策は単一価格 + 有料オプション2件の積み上げ式。オプション名に「〜相当」を付けて3段階を表現する
  • 以降、この自動化では「フォーム仕様は実機probeが唯一の真実」を設計原則にしている

なぜ「ドキュメント通り」が通じないのか

ドキュメントは「プラットフォーム全体で使える機能」を網羅する形で書かれる。一方で実際の出品フォームは、カテゴリ・アカウント種別・本人確認状態・ABテストの割当など、複数の条件でUIが出し分けされる。つまり「機能が存在する」ことと「そのカテゴリのフォームに出る」ことは別問題だ。

ドキュメントを読んで入稿データのスキーマを先に固めると、tier フィールドを必須として設計してしまい、後段のバリデーション・在庫管理・価格計算がすべて作り直しになる。これを避けるには、コードを書く前に実フォームをprobeするのが最も費用対効果が高い。

手順: 実機probeでフォーム仕様を確定させる

Step 1. ChromeをCDP有効で起動して接続する

import { chromium } from 'playwright';

const CDP_ENDPOINT = process.env.CDP_ENDPOINT ?? 'http://127.0.0.1:9222';

const browser = await chromium.connectOverCDP(CDP_ENDPOINT);
// connectOverCDP では既存contextを再利用する。browser.newContext() は基本使わない
const context = browser.contexts()[0];
const page = context.pages()[0] ?? (await context.newPage());

chromium.launch() ではなく connectOverCDP を使う理由は、ログイン済みセッション・Cookie・拡張をそのまま流用できる点。多要素認証やbot判定を毎回突破する実装コストを考えれば、CDP接続のほうが現実的だ。

Step 2. プラン設定UIの有無をDOM全探索で確認する

「セレクタが見つからない」だけでは、遅延レンダリングなのか本当に存在しないのか区別できない。そこでキーワードベースの全探索をかける。

const PLAN_KEYWORDS = ['プラン', 'plan', 'ライト', 'スタンダード', 'プレミアム', '料金体系'];

const report = await page.evaluate((keywords: string[]) => {
  const all = Array.from(document.querySelectorAll<HTMLElement>('body *'));
  const hits: { tag: string; text: string; hint: string }[] = [];

  for (const el of all) {
    if (el.children.length > 0) continue; // リーフノードのみ対象
    const text = (el.textContent ?? '').trim();
    if (!text || text.length > 40) continue;
    if (keywords.some((k) => text.toLowerCase().includes(k.toLowerCase()))) {
      hits.push({
        tag: el.tagName.toLowerCase(),
        text,
        hint: el.getAttribute('data-testid') ?? el.id ?? el.className.toString().slice(0, 60),
      });
    }
  }

  return {
    url: location.href,
    totalLeafNodes: all.length,
    iframeCount: document.querySelectorAll('iframe').length,
    hits,
  };
}, PLAN_KEYWORDS);

if (report.hits.length === 0) {
  console.warn('[probe] プラン設定UIは存在しない → 積み上げ式にフォールバック');
}

totalLeafNodes と iframeCount を同時に取るのがミソで、「そもそも描画が終わっていない」のか「iframeの中に隔離されている」のかを切り分けられる。

Step 3. 判定結果をスナップショットとして残す

import { mkdir, writeFile } from 'node:fs/promises';

await mkdir('probes', { recursive: true });
await writeFile(`probes/${categoryId}.json`, JSON.stringify(report, null, 2));
await page.screenshot({ path: `probes/${categoryId}.png`, fullPage: true });

DOMのJSONとスクリーンショットを両方残す。スクリーンショットは「描画されていないこと」の証跡になり、レビュー時の説得力が段違いになる。これをCIで日次実行し、差分が出たらアラートを飛ばせば、フォーム仕様のスナップショットテストとして機能する。

回避策の実装: 単一価格 + 有料オプション2件

プランUIが無い以上、3段階は「価格の積み上げ」で表現するしかない。基準価格を下位プランに合わせ、中位・上位への差額を有料オプションとして載せる。

type Tier = 'basic' | 'standard' | 'premium';

const OPTION_A = { name: 'スタンダード相当オプション', price: 2000 }; // basic → standard の差額
const OPTION_B = { name: 'プレミアム相当オプション', price: 3000 };   // standard → premium の差額

function toFormPayload(input: { baseYen: number; tier: Tier }) {
  const options: { name: string; price: number }[] = [];

  if (input.tier !== 'basic') options.push(OPTION_A);
  if (input.tier === 'premium') options.push(OPTION_B);

  return {
    price: input.baseYen,          // 常に単一価格
    options,                       // 下位=0件 / 中位=Aのみ / 上位=A+B
    descriptionSuffix: '',
  };
}

重要なのは、オプションBだけが選択された状態は不正だということ。standard を飛ばして premium になる経路を塞ぐため、入稿前にバリデーションを入れる。

function validate(payload: ReturnType<typeof toFormPayload>) {
  const hasB = payload.options.some((o) => o.name === OPTION_B.name);
  const hasA = payload.options.some((o) => o.name === OPTION_A.name);
  if (hasB && !hasA) throw new Error('プレミアム相当はスタンダード相当とセットでのみ選択可能');
}

なお「上位プラン価格から割引する」方向の設計は採れない。多くのプラットフォームはオプションに負の価格を許可しておらず、実フォームでもマイナス入力は弾かれる。だから積み上げ一択になる。

比較表: 3段階プランUI前提 vs 積み上げ式

観点 3段階プランUI前提 単一価格+オプション2件
公式ドキュメント 記載あり(全カテゴリ共通) 記載なし(実機で確認)
対象カテゴリでの可否 UIが存在せず破綻 全カテゴリで動作
入稿データ tier をそのまま送信 price + options に変換
在庫・SKU管理 tier数 = SKU数 オプション組合せで管理
購入者体験 プラン選択UI オプション選択(「〜相当」表記)
カテゴリ追加時 追加のたびに破綻しうる 差分を吸収しやすい

ハマりポイント

  • waitForLoadState('networkidle') はSPAでは信用できない。遅延チャンク読み込みが続くため、リーフノード数が安定するまで page.waitForFunction で待つほうが確実。
  • shadow DOMは document.querySelector では貫通しない。page.locator() はpierceするが、page.evaluate 内の素のクエリはしない。探索コードを書くなら shadowRoot を再帰的に辿る必要がある。
  • connectOverCDP では context.route が効かないケースがある。既存contextはPlaywrightの管理下に完全には入らないため、ネットワーク傍受に依存した設計は避ける。
  • iframe内フォームはフレームを明示的に取る。page.frameLocator() を使わないと、いつまでも要素が見つからない。
  • アカウント属性でUIが変わる。本人確認未完了・住所未登録だと価格欄自体が出ないことがある。probeは必ず「本番と同じ属性のアカウント」で実行する。

FAQ

Q. connectOverCDP と chromium.launch() はどちらを使うべき?
A. ログイン済みセッションを再利用したい、多要素認証やbot検知を避けたいならCDP。ただしcontextは既存のものを再利用する前提で設計する。真っさらな状態で再現したいテストなら launch() が扱いやすい。

Q. DOM探索で要素が見つからないとき、次に何を疑う?
A. 順に、①iframe、②shadow DOM、③遅延レンダリング、④アカウント・カテゴリ・ABテストによる出し分け、⑤表示条件(本人確認・住所など)。①〜③は技術で解決でき、④⑤は「その条件下では存在しない」という結論になる。

Q. probeはどの頻度で回すべき?
A. PR時と日次。日次で差分検知してアラートにしておくと、UI変更をリリース前に拾える。仕様のスナップショットテストとして機能する。

Q. 「〜相当」というオプション名は規約的に大丈夫?
A. プラットフォーム規約と景表法の両方を確認する必要がある。実際のプランと誤認させない表記(例: 「スタンダード相当オプション(当店基準)」)にし、説明欄にも補足を入れるのが安全。

まとめ

  • ドキュメントは「機能の存在」を保証するが、「そのカテゴリのフォームに出る」ことは保証しない
  • 実フォームのprobeを最初に挟むだけで、後工程の作り直しを丸ごと回避できる
  • プランUIが無い場合は、単一価格+有料オプションの積み上げで段階を表現し、遷移のバリデーションを必ず入れる
  • DOM探索はキーワード+リーフノード走査で。JSONとスクリーンショットを証跡として残す

ドキュメントの記載を前提に設計を始めていませんか? 一度、実フォームを開いて要素を数えるところから始めると、だいたいの見積もりが変わります。


この記事を書いた人

BENTEN Web Works — 業務自動化・システム開発のフリーランスエンジニアです。

GAS / Python / RPA を使った業務自動化や、Web制作・システム開発のご相談を承っています。
「こんなこと自動化できる?」というご質問だけでもお気軽にどうぞ。

👉 業務自動化サービス — 詳細・お問い合わせはこちら
🐦 X(旧Twitter) — 日々の知見を発信中

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?