環境
- 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) — 日々の知見を発信中