はじめに
毎月、クライアントからメールでチラシの PDF が届きます。メールの添付ファイルは GAS(Google Apps Script)で Google ドライブに自動保存される仕組みにしてあるので、ファイルの取り込みまでは人手いらず。問題はその先で、保存された PDF を CMS の管理画面に登録して、さらに管理用の Google スプレッドシートにも記録する、という作業をずっと手でやっていました。PDF に「いつからいつまで・どの店舗向けか」が書かれていて、それを読み取って登録していく流れです。1件ごとに:
- メールに添付された PDF を見てタイトルや期間を読み取る
- 命名規則に沿ってタイトルを整える
- 管理画面のフォームに入力して PDF をアップロード
- スプレッドシートに同じ情報を追記
これを月に十数件。届くたびに同じことを繰り返す、地味だけど神経を使う作業で、コピペミスも起きます。
これを Claude(Claude Code)で自動化したんですが、ポイントは 「判断が必要な部分」と「ただの作業」を分けて、それぞれ別の道具に任せた ことでした。この記事ではその構成と、ブラウザ自動化の選択肢(Skills / Chrome MCP / Playwright)の使い分けをまとめます。
先に結論
ブラウザ自動化の選択肢は大きく3つ。住み分けはこうなります。
| Skills | Chrome MCP | Playwright | |
|---|---|---|---|
| コーディング | 不要 | 不要 | 必要 |
| 実行速度 | ふつう | ふつう | 速い |
| 繰り返し運用 | ○ | ○ | ◎ |
| クロスプラットフォーム | 手順次第 | ○ | ○ |
| API コスト | 操作数しだい | 操作数しだい | かからない |
| 向いてる作業 | 手順を自然言語で書ける作業 | 判断が必要な作業 | 毎回同じ定型作業 |
ポイントは、Skills と Chrome MCP はどちらも「LLM がページを見て1操作ずつ判断する」仕組み だということです。だから速度もコストもこの2つは同じ土俵で、1操作ごとに「ページを読む → LLM が判断 → 実行」の往復が入ります。数ステップなら気になりませんが、操作数に比例してトークンと時間がかかります。一方 Playwright はこの往復がなく、コードが直接ブラウザを叩くので速くて実行時のコストもゼロです。
そして今回いちばん効いたのは、1つに絞らず「判断が必要な部分は LLM、定型の作業は Playwright」と分業させた ことでした。以下、具体的にどう組んだかを書いていきます。
全体の流れ
最終的にこういう構成に落ち着きました。
前段の Gmail → ドライブ保存 は GAS(Google Apps Script)で自動化してあります。クライアントからチラシのメールが届くと、添付 PDF を受信日ごとのフォルダに自動で振り分けて保存してくれます。ここまでは人手ゼロです。
そしてこの記事の本題は、保存された PDF から先の LLM → JSON → Playwright の3段です。肝は真ん中の JSON。
- 前半(①): PDF を読んで「何を登録すべきか」を判断する。ここは内容の解釈が必要なので Claude(LLM) に任せる
- 後半(②): 決まったデータを決まった場所に入れる。ここは手順が固定なので Playwright(スクリプト) に任せる
JSON が両者の「契約」になっていて、前半と後半を完全に切り離せるのがミソです。
① LLM パート:Claude に PDF を読ませて JSON を作る
まず Claude に PDF を読ませます。やっていることは単純で、こんな指示です。
このPDFを読んで、チラシ登録用のJSONを作って。
- D:\...\stores.json で店舗の正式名を照合すること
- 命名規則は CLAUDE.md のとおり命名規則みたいな「毎回守ってほしいルール」は、プロンプトに毎回書くのではなく CLAUDE.md に置いておきます。Claude Code はカレントディレクトリの CLAUDE.md を自動で読むので、指示しなくても守ってくれます。
### 命名規則(厳守)
- 店名は短縮形(「○○ストア △△店」→「△△店」)
- 「10周年祭」などの企画名は入れない
- 日付と店名の間は全角スペース(U+3000)
### 登録値
- status: 必ず 0(非公開)
- 公開開始時間: 08:00:00 / 終了時間: 23:30:00
- テキスト: セール期間:{月/日}-{月/日}ここが LLM の出番です。「PDF に書かれた長い店舗名を短縮形に直す」「複数の表記ゆれを正式名に揃える」といった作業は、ルールベースで書くと例外が多くて面倒ですが、LLM なら文脈で判断してくれます。
出力されるのはこんな JSON です。
{
"name": "6/20-22 駅前店単独チラシ",
"text": "セール期間:6/20-22",
"beginDate": "2026/06/20",
"beginTime": "08:00:00",
"endDate": "2026/06/22",
"endTime": "23:30:00",
"status": "0",
"stores": ["駅前店"],
"pdfPath": "C:\\...\\tmp\\260620-22_ekimae.pdf"
}この JSON さえ正しく作れれば、後半は完全に機械的に処理できます。「判断」をここで JSON という形に固めてしまう のがポイントです。
② Playwright パート:JSON を渡してフォームに流し込む
後半は判断ゼロです。JSON を受け取って、決まったフォームに決まった順で入れていくだけ。
node upload-flyer.mjs json/example.jsonスクリプトは既存の Chrome に CDP 接続して動きます。ログイン済みの Chrome をそのまま使うので、認証情報をスクリプトに渡す必要がありません。
import { chromium } from "playwright-core";
import { readFileSync } from "node:fs";
const data = JSON.parse(readFileSync(process.argv[2], "utf8"));
// 起動済み Chrome に CDP 接続(ログイン状態をそのまま使う)
const browser = await chromium.connectOverCDP("http://localhost:9222");
const context = browser.contexts()[0];
const page = await context.newPage();
// フォームに値を流し込む
await page.goto(`${BASE_URL}/admin/flyer/add`);
await page.evaluate((d) => {
const setVal = (sel, val) => {
const el = document.querySelector(sel);
el.value = val;
// input/change/blur を発火させないと CMS 側の状態が更新されない
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
el.dispatchEvent(new Event("blur", { bubbles: true }));
};
setVal("#FlyerName", d.name);
setVal("#FlyerText", d.text);
setVal("#FlyerPublishBeginDate", d.beginDate);
// ...
}, data);
// PDF アップロードはファイル選択 input にパスを渡すだけ
await page.setInputFiles("#FlyerFile", data.pdfPath);
await page.click('form input[type="submit"]');一度書いてしまえば LLM を挟まないので速いし、API コストもかかりません。同じスクリプトが Windows でも Mac でも動きます。
値を .value = ... で入れるだけだと、CMS 側の JS が「変更された」ことに気づかず保存されないことがあります。input / change / blur イベントを自分で発火させるのが定番の対策です。
なぜ「判断」と「作業」を分けたのか
実は最初から Playwright だったわけではなく、ブラウザ自動化の選択肢をいくつか検討しました。
Skills
.skill ファイルに手順を自然言語で書いておくと、Claude が読んで実行してくれる方式です。
- メリット: コードを書かなくていい。手順を文章で表現できるので、エンジニア以外でも作れる・直せる
- デメリット: 実行は LLM が担うので、スクリプトのように「毎回まったく同じ動作」が保証されるわけではない。
SKILL.mdに OS 固有の操作(Finder やエクスプローラ)が混ざると環境依存になる。毎操作で LLM を呼ぶので、繰り返すほど時間とトークンがかかる
今回のチラシ入稿だと: 毎月十数件を同じ手順で淡々と処理したい作業なので、「動作が決定論的に保証されない」「件数ぶんトークンがかかる」のが噛み合いませんでした。手順を自然言語で持つ強みも、最終的にコードで固めたい今回では活きません。
Chrome MCP(chrome-devtools MCP)
Claude がリアルタイムでページを見ながら操作を判断する方式です。
- メリット: ページの状態を読んで「次に何をすべきか」をその場で判断できる。想定外の画面やエラーにも対応しやすい
- コンソールエラーを読んで原因を診断させる
- Lighthouse スコアを見て改善点を出させる
- 内容を解釈してフォームの適切な場所に振り分ける
- デメリット: 毎操作で「ページを読む → LLM が判断 → 実行」の往復が入るので、操作数に比例して時間とトークンがかかる
別案件(ドキュメントから内容を読み取って入稿する作業)ではこちらを使っています。あちらは「内容を解釈してフォームに振り分ける」判断が実行中ずっと必要なので MCP が最適でした。
今回のチラシ入稿だと: 判断が必要なのは「PDF を読んで何を登録するか決める」最初だけで、フォーム入力自体は完全に決まりきった手順です。その定型部分にまで毎回 LLM を挟むのは、遅いしトークンの無駄でした。
Playwright
手順をコードに固定する方式です。
- メリット: 判断を挟まないので速く、実行時のトークンコストはゼロ。毎回まったく同じ動きをするので結果が予測できる。同じスクリプトが Windows でも Mac でも動く
- デメリット: コードを書く必要がある。判断が要る作業(毎回違う画面・例外処理)は苦手
今回のチラシ入稿だと: フォーム入力という完全に定型の作業にぴったりでした。一度書けば毎月使い回せて、実行コストもかかりません。唯一の弱点だった「判断ができない」部分は、前段の LLM が JSON を作ることで補っています。
結局どう分けたか
今回の作業を分解すると:
| 工程 | 性質 | 任せた先 |
|---|---|---|
| PDF の読み取り・命名・店舗名の照合 | 判断が必要 | Claude(LLM) |
| フォーム入力・PDF アップロード・スプレッドシート追記 | 完全に定型 | Playwright |
判断が必要な前半だけ LLM に任せ、定型の後半はスクリプトで固める。 こうすると、毎回 LLM にブラウザ操作までさせるより速くて安定するし、後半はコードなので動作が完全に予測できます。Skills や Chrome MCP に全部やらせる選択肢もありましたが、「PDF 読み取り」と「フォーム入力」を JSON で分けたことで、それぞれを一番得意な道具に振れました。
ハマったポイント(Playwright 編)
後半のスクリプト、定型とはいえ正確に動かすまでに細かい罠がありました。
日本語が途中で消える(keyboard.type() 問題)
keyboard.type("セール期間:6/21-27") と打つと、全角コロン :(U+FF1A)の手前で入力が止まって残りが消えました。
// NG: 全角コロンで止まる
await page.keyboard.type("セール期間:6/21-27");
// OK: クリップボード経由で貼り付ける
await page.evaluate((v) => navigator.clipboard.writeText(v), value);
await page.keyboard.press("Control+v");クリップボードを使うには事前に権限付与が必要です。
const context = browser.contexts()[0];
await context.grantPermissions(["clipboard-read", "clipboard-write"]);Google Sheets のプルダウンにテキストが貼れない
データ入力規則(プルダウン型)のセルに Ctrl+V で貼り付けようとしたら、データ検証に弾かれました。ドロップダウンを開いて項目をクリックするのが正解でした。
async function selectFromDropdown(page, items) {
await page.keyboard.press("Enter"); // ドロップダウンを開く
await page.waitForTimeout(600);
for (let i = 0; i < items.length; i++) {
// 検索ボックスに貼り付けて絞り込む
await page.evaluate((v) => navigator.clipboard.writeText(v), items[i]);
await page.keyboard.press("Control+v");
await page.waitForTimeout(400);
await page.locator('[role="menuitemcheckbox"]')
.filter({ hasText: items[i] }).first().click();
if (i < items.length - 1) {
await page.keyboard.press("Control+a");
await page.keyboard.press("Delete");
}
}
await page.keyboard.press("Escape"); // 閉じて確定
}Alt+ArrowDown でプルダウンを開こうとしましたが Google Sheets では効きませんでした。Enter キーが正解です。
Ctrl+End で最終行が取れない
追記先の行を探すのに Ctrl+End を押したら、1000行目(シートのデフォルト最大行)に飛びました。データのある最終行ではなく、シートの物理的な末尾に飛んでしまいます。
// NG: シートの最大行(1000行目)に飛ぶ
await page.keyboard.press("Control+End");
// OK: A1 から Ctrl+↓ でデータのある最終行に移動
await goToCell(page, "A1");
await page.keyboard.press("Control+ArrowDown");
const cell = await getNameBoxValue(page); // 例: "A42"
const lastRow = parseInt(cell.match(/\d+$/)[0]);まとめ
- 判断と作業を分ける:内容の解釈が必要な前半は LLM、定型の後半はスクリプト。間を JSON でつなぐと、それぞれを得意な道具に任せられる
- Skills:手順を自然言語で書ける作業に。OS 依存の操作が混ざると環境依存になるので注意
- Chrome MCP:LLM がページを見て判断する作業に最適。ただし操作数に比例して時間とトークンがかかる
- Playwright:定型作業の繰り返しに最適。一度書けば速くて安定。日本語入力や Google Sheets プルダウンには独特の詰まりポイントあり
「全部 AI にやらせる」より、「判断だけ AI に任せて、作業はスクリプトで固める」方が、今回のような定型業務では速くて安定しました!