Web スクレイピング:実装例
基本パターン
// browser-scraper.ts
import { launch } from 'puppeteer' ;
interface ScrapedProduct {
title : string ;
price : number ;
rating : number ;
url : string ;
}
class EcommerceScraper {
private browser : any ;
async initialize () {
this .browser = await launch ({
headless: true ,
args: [
'--no-sandbox' ,
'--disable-setuid-sandbox' ,
'--disable-blink-features=AutomationControlled' , // ロボット検出回避
],
});
}
async scrapeProductListing (
baseUrl : string ,
pageCount : number = 3
) : Promise < ScrapedProduct []> {
const results : ScrapedProduct [] = [];
const page = await this .browser. newPage ();
// User-Agent をスプーフィング
await page. setUserAgent (
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
);
for ( let i = 1 ; i <= pageCount; i ++ ) {
const url = `${ baseUrl }?page=${ i }` ;
console. log ( `Scraping: ${ url }` );
try {
// ページ読み込み待機
await page. goto (url, { waitUntil: 'networkidle2' , timeout: 30000 });
// 動的コンテンツの読み込み待機
await page. waitForSelector ( '.product-item' , { timeout: 10000 });
// DOM から製品情報を抽出
const products = await page. evaluate (() => {
const items = document. querySelectorAll ( '.product-item' );
return Array. from (items). map (( item ) => {
const titleEl = item. querySelector ( '.product-title' );
const priceEl = item. querySelector ( '.product-price' );
const ratingEl = item. querySelector ( '.product-rating' );
const linkEl = item. querySelector ( 'a.product-link' );
return {
title: titleEl?.textContent?. trim () || 'N/A' ,
price: parseFloat (
priceEl?.textContent?. replace ( / [ ^ \d.] / g , '' ) || '0'
),
rating: parseFloat (ratingEl?.textContent || '0' ),
url: linkEl?. getAttribute ( 'href' ) || '' ,
};
});
});
results. push ( ... products);
console. log ( `Page ${ i }: ${ products . length } products found` );
// レート制限回避: ページ間に遅延を入れる
await page. waitForTimeout ( 2000 + Math. random () * 3000 );
} catch (error) {
console. error ( `Error scraping page ${ i }:` , error);
// フォールバック: AI に手動確認を依頼
}
}
await page. close ();
return results;
}
async close () {
await this .browser. close ();
}
}
// 使用例
async function main () {
const scraper = new EcommerceScraper ();
await scraper. initialize ();
const products = await scraper. scrapeProductListing (
'https://example-ecommerce.com/products' ,
3
);
console. log ( `Total products scraped: ${ products . length }` );
console. log ( JSON . stringify (products, null , 2 ));
await scraper. close ();
}
main (). catch (console.error);
レート制限への対応
class RateLimitedScraper {
private requestTimes : number [] = [];
private maxRequestsPerMinute : number = 10 ;
async ensureRateLimit () {
const now = Date. now ();
const oneMinuteAgo = now - 60 * 1000 ;
// 1分以内のリクエストを数える
this .requestTimes = this .requestTimes. filter (( t ) => t > oneMinuteAgo);
if ( this .requestTimes. length >= this .maxRequestsPerMinute) {
// 相手の上限に達したら待機(429 を受けたあとの挙動は後半で扱います)
const waitTime = 60 * 1000 - (now - this .requestTimes[ 0 ]);
console. log ( `Rate limit reached. Waiting ${ waitTime }ms...` );
await new Promise (( r ) => setTimeout (r, waitTime));
}
this .requestTimes. push (now);
}
async scrapePage ( url : string ) {
await this . ensureRateLimit ();
// スクレイピング処理...
}
}
出力例 :
[
{
"title" : "高性能 AI ノートパソコン" ,
"price" : 1580 ,
"rating" : 4.8 ,
"url" : "https://example.com/product/ai-laptop-pro"
},
{
"title" : "USB-C ドッキングステーション" ,
"price" : 25800 ,
"rating" : 4.5 ,
"url" : "https://example.com/product/usb-c-dock"
}
]
フォーム自動入力とログイン
複雑なチェックボックスと条件分岐
class FormFiller {
async fillComplexForm ( page : any ) {
// ステップ1: テキスト入力フィールドを埋める
await page. type ( '#name-input' , 'Masaki Hirokawa' );
await page. type ( '#email-input' , 'user@example.com' );
// ステップ2: ラジオボタン選択
await page. click ( 'input[value="business"]' );
// ステップ3: 条件分岐(選択内容に応じて出現するフィールド)
await page. waitForSelector ( '#company-name-field' , { timeout: 5000 });
await page. type ( '#company-name-field' , 'Dolice Labs' );
// ステップ4: マルチセレクト(複数選択可能)
const tags = [ 'AI Development' , 'Web Automation' , 'Agent System' ];
for ( const tag of tags) {
// Cmd/Ctrl + click で複数選択
await page. click ( `label:contains("${ tag }")` , { modifiers: [ 'Control' ] });
}
// ステップ5: ファイルアップロード
const uploadInput = await page. $ ( 'input[type="file"]' );
await uploadInput. uploadFile ( '/path/to/resume.pdf' );
// ステップ6: 利用規約に同意
await page. click ( '#terms-checkbox' );
await page. click ( '#privacy-checkbox' );
// ステップ7: 送信ボタンをクリック
await Promise . all ([
page. waitForNavigation ({ waitUntil: 'networkidle0' }),
page. click ( '#submit-button' ),
]);
console. log ( 'Form submitted successfully' );
}
}
セッション保持とログイン状態の維持
class AuthenticatedSession {
private cookiesFile = './session-cookies.json' ;
async saveCookies ( page : any ) {
const cookies = await page. cookies ();
require ( 'fs' ). writeFileSync ( this .cookiesFile, JSON . stringify (cookies, null , 2 ));
}
async restoreCookies ( page : any ) {
if ( require ( 'fs' ). existsSync ( this .cookiesFile)) {
const cookies = JSON . parse (
require ( 'fs' ). readFileSync ( this .cookiesFile, 'utf8' )
);
await page. setCookie ( ... cookies);
console. log ( 'Cookies restored from previous session' );
}
}
async login ( page : any , email : string , password : string ) {
await page. goto ( 'https://example.com/login' );
// ログイン処理
await page. type ( '#email' , email);
await page. type ( '#password' , password);
await page. click ( 'button[type="submit"]' );
// ログイン完了待機
await page. waitForNavigation ({ waitUntil: 'networkidle0' });
// クッキーを保存(次回以降の復元用)
await this . saveCookies (page);
}
async accessProtectedPage ( url : string ) {
const browser = await launch ();
const page = await browser. newPage ();
// 前回のセッションを復元
await this . restoreCookies (page);
await page. goto (url);
return await page. content ();
}
}
E2E テスト自動化
ユーザーシナリオベースのテスト
class E2ETestRunner {
async testCheckoutFlow ( baseUrl : string ) {
const browser = await launch ();
const page = await browser. newPage ();
try {
// シナリオ1: 商品検索
console. log ( 'Test 1: Search for product' );
await page. goto ( `${ baseUrl }/` );
await page. type ( '#search-input' , 'AI notebook' );
await page. click ( '#search-button' );
await page. waitForSelector ( '.product-grid' , { timeout: 10000 });
// シナリオ2: 商品詳細ページへ移動
console. log ( 'Test 2: Navigate to product detail' );
await page. click ( 'a.product-link:first-child' );
await page. waitForSelector ( '.product-detail' , { timeout: 5000 });
// シナリオ3: カートに追加
console. log ( 'Test 3: Add to cart' );
await page. click ( '#add-to-cart-button' );
const cartBadge = await page. $eval (
'.cart-count' ,
( el ) => el.textContent
);
console. assert (cartBadge === '1' , 'Cart should have 1 item' );
// シナリオ4: チェックアウト
console. log ( 'Test 4: Proceed to checkout' );
await page. click ( 'a[href="/checkout"]' );
await page. waitForSelector ( '#shipping-form' , { timeout: 5000 });
// シナリオ5: 配送情報入力
console. log ( 'Test 5: Fill shipping info' );
await page. type ( '#address' , '123 Tech Street, San Francisco' );
await page. select ( '#country' , 'US' );
// シナリオ6: 決済情報入力(本番では Stripe テストキーを使用)
console. log ( 'Test 6: Fill payment info' );
// iframe 内の Stripe フォーム
const stripeFrame = page. frames (). find (( f ) =>
f. url (). includes ( 'stripe.com' )
);
if (stripeFrame) {
await stripeFrame. type ( '[placeholder="Card number"]' , 'YOUR_TEST_CARD_NUM' );
}
// シナリオ7: 注文完了
console. log ( 'Test 7: Complete order' );
await Promise . all ([
page. waitForNavigation ({ waitUntil: 'networkidle0' }),
page. click ( '#place-order-button' ),
]);
// 確認ページのスクリーンショット
const screenshot = await page. screenshot ({
path: './test-results/order-confirmation.png' ,
});
console. log ( '✓ Checkout flow completed successfully' );
return true ;
} catch (error) {
console. error ( '✗ Test failed:' , error);
await page. screenshot ({ path: './test-results/error-screenshot.png' });
return false ;
} finally {
await browser. close ();
}
}
}
// テスト実行
const runner = new E2ETestRunner ();
runner. testCheckoutFlow ( 'https://example-ecommerce.com' )
. then (( success ) => process. exit (success ? 0 : 1 ));
本番環境でのセキュリティ設計
認証情報の安全な管理
ブラウザ自動化で最初に守るべきは、エージェントに渡す認証情報の寿命です。よく見かけるのが、使い終わったパスワードを「上書きして消す」という次のような書き方です。
// ⚠️ 動いているように見えて、実際には何も消えていない例
try {
return await this . executeTask (task, decrypted);
} finally {
// 文字列は不変。新しい Buffer を代入しても、元の文字列はヒープに残る
decrypted.password = Buffer. alloc (decrypted.password. length , " \0 " );
}
JavaScript の文字列は不変です。別の値を代入できるのは変数の参照だけで、元の文字列そのものは書き換えられません。ガベージコレクタが回収するまでヒープに残り、その間にヒープダンプを取られれば読めてしまいます。消したつもりになるのがいちばん危ない状態です。
消す前提で扱うなら、復号した瞬間から Buffer で持ち回します。
type Secret = { user : string ; password : Buffer };
class SecureAutomation {
constructor ( private vault : CredentialsManager ) {}
async runWithCredentials < T >(
task : string ,
fn : ( secret : Secret ) => Promise < T >,
) : Promise < T > {
// 復号結果を文字列にせず、最初から Buffer で受け取る
const secret = await this .vault. decryptToBuffer (task);
try {
return await fn (secret);
} finally {
// Buffer は可変。同じメモリ領域をゼロで塗り潰せる
secret.password. fill ( 0 );
}
}
}
// 使う側は Buffer のまま渡し、必要な瞬間だけ文字列化する
await automation. runWithCredentials ( "nightly-check" , async ( secret ) => {
await page. type ( "#password" , secret.password. toString ( "utf8" ));
return page. click ( "#submit" );
});
toString() を呼んだ時点で不変の文字列が生まれる点は変わりません。それでも、生存期間をタイプ操作の一瞬に閉じ込められるかどうかで、事故が起きたときの露出面はまったく違います。完全な消去を目指すのではなく、露出している時間を短く保つ。これが現実的な落とし所だと考えています。
相手のレート制限を壊さない — 実測してわかったこと
夜間の自動処理で次に効いてくるのが、アクセスの速度です。私は個人開発のアプリを運用しながら Cloudflare 上でサイトも動かしているので、押し寄せる側だけでなく、429 を返す側の景色も見えています。返す側から見ると、無配慮な自動化は「速い訪問者」ではなく「同じ要求を何度も投げ直す訪問者」として現れます。
そこで、上限が途中で変わる相手を手元に用意して測りました。スタブサーバは通常 8 リクエスト/秒まで受け付け、開始 4 秒後から 9 秒後までは 3 リクエスト/秒に絞り、超過分には Retry-After: 1 を付けた 429 を返します。相手の上限が固定でないのは、実際の API でよくある挙動です。
クライアント側は三系統・六条件を比較しました。制限なしのバースト、固定レート二種、そして 429 を受けるたびに送信レートを下げる適応制御(AIMD)を係数違いで三種です。適応制御の実装は次のとおりです。
type AimdOptions = { initial : number ; min : number ; max : number ; dec : number ; rec : number };
async function fetchAllAdaptive ( urls : string [], opt : AimdOptions ) {
let rate = opt.initial; // 現在の送信レート(リクエスト/秒)
let lastRecover = Date. now ();
let throttled = 0 ;
const done : string [] = [];
const queue = [ ... urls];
while (queue. length > 0 ) {
const res = await fetch (queue[ 0 ]);
if (res.status === 429 ) {
throttled ++ ;
// 乗算的減少:拒否されたら一気に引く
rate = Math. max (opt.min, rate * opt.dec);
lastRecover = Date. now ();
const wait = Number (res.headers. get ( "retry-after" ) ?? 1 );
await sleep (wait * 1000 ); // Retry-After は必ず尊重する
continue ; // 同じ URL を再試行(キューから外さない)
}
done. push ( await res. text ());
queue. shift ();
// 加算的増加:成功が続いたら少しずつ戻す
const elapsed = (Date. now () - lastRecover) / 1000 ;
if (elapsed >= 1 ) {
rate = Math. min (opt.max, rate + opt.rec * elapsed);
lastRecover = Date. now ();
}
await sleep ( 1000 / rate);
}
return { done, throttled, finalRate: rate };
}
45 件を取得しきるまでの時間と、その過程で相手に返させた 429 の回数を測った結果です(各条件 3 回試行の中央値、実行間のばらつきは 10 ミリ秒未満)。
送信方式 完了までの時間 相手に返させた429
制限なしバースト 8.3 秒 147 回
固定 5 リクエスト/秒 13.4 秒 3 回
固定 8 リクエスト/秒 11.1 秒 4 回
AIMD(減少 0.5・回復 0.5/秒) 14.0 秒 3 回
AIMD(減少 0.7・回復 2.0/秒) 10.2 秒 4 回
AIMD(減少 0.9・回復 2.0/秒) 11.6 秒 6 回
いちばん目を引くのは最上段です。バーストは確かに最速で、8.3 秒で終わります。ただし 45 件を通すために 147 回の拒否を相手に踏ませています。成功 1 件あたり 3.3 件の無駄な要求を、相手のサーバに肩代わりさせている計算です。2 秒前後の短縮の対価としては割に合いません。
そしてもう一つ、測る前の見立てが外れた点があります。適応制御の効果は、係数の選び方で符号が変わりました。減少 0.7・回復 2.0 の組み合わせは 10.2 秒で、固定 8 リクエスト/秒より 0.9 秒(8%)速く、429 の回数は同じ 4 回です。ところが減少係数を 0.5 に振ると 14.0 秒まで落ちます。混雑窓を抜けたあとも送信レートが戻りきらないまま走り続けるためで、六条件のなかで最も遅い結果でした。同じ仕組みのまま、係数ひとつで完了時間が 37% 動く計算になります。0.9 まで緩めれば 11.6 秒に戻りますが、今度は 429 が 6 回に増えます。適応制御は「入れれば速くなる仕組み」ではなく、係数を外すと固定レートより遅くなる仕組みだと考えたほうが安全です。
ばらつきにも触れておきます。減少 0.7・回復 2.0 だけは 3 回のうち 2 回が 10.2 秒・429 が 4 回、残る 1 回が 11.2 秒・5 回でした。混雑窓に入る瞬間がリクエストの境界のどこに当たるかで、拒否が 1 回増減します。他の条件は 3 回とも 10 ミリ秒以内に収まりました。
では上限を読み違えたときはどうか。12 リクエスト/秒だと見積もったのに、実際の上限が 8 だった場合を測り直しました。
送信方式 完了までの時間 相手に返させた429
固定 12 リクエスト/秒(見積もり過大) 12.2 秒 7 回
AIMD(初期 12・減少 0.7・回復 2.0/秒) 12.0 秒 6 回
適応制御が本領を発揮するはずの条件でも、差は 0.2 秒と 1 回でした。理由は測ってみて腑に落ちました。この相手は Retry-After: 1 を返すので、一度つまずくと 1 秒の待機が発生します。その待機が支配的で、送信レートの微調整が入り込む余地はほとんどありません。
ここから導けることは、思っていたより単純でした。効き幅の大半は Retry-After を尊重して再試行することにあり、送信レートの制御方式はその後の小さな差でしかない 、ということです。429 は 147 回から 3〜7 回まで落ちますが、その落差を作っているのは適応制御ではなく「拒否されたら言われた時間だけ待つ」という一行です。
上限が公式に公開されている相手なら、適応制御は入れないことを推奨します。調律の手間に見合いません。実装の順序としては、次のように考えています。
優先度 やること 判断の理由
最優先 Retry-After を読んで待つ・同じ URL を再試行429 を 147 回から一桁に落とすのはここ
次 控えめな固定レート(相手の公開上限の 7 割程度) 上限が公開されているなら適応制御は不要
最後 AIMD による適応制御(減少 0.7・回復 2.0/秒 から始める) 上限が非公開の相手向け。係数を外すと固定レートより遅くなる
減少係数を 0.5 にしたくなる気持ちは分かります。安全側に倒す判断として自然だからです。ただ今回の測定では、その保守性は 429 を 1 回減らす代わりに 3.8 秒を失う取引でした。安全側の設定にもコストがあること、そしてそのコストが係数ひとつで三割以上動くことを、数字で持っておくと迷いません。
検出回避を扱わない理由
ヘッダーを人間らしく偽装する、navigator.webdriver を隠す、プロキシで発信元を分散させる。この種の手法は、この記事では扱いません。技術的に不可能だからではなく、運用の前提として選ばないからです。
自社サイトの E2E テストなら、そもそも偽装する相手がいません。他社のサイトが自動化を拒んでいるなら、それは公式 API を使うか、許諾を取るかの分岐であって、迂回する場面ではないと考えています。個人開発では、規約違反で API キーやアカウントを失ったときに、代わりに交渉してくれる法務部門が後ろにいません。だからこそ、隠さなくても成立する設計だけを本番に載せています。
robots.txt を読んで対象外のパスを踏まない、正体の分かる User-Agent を名乗る、Retry-After を守る。地味ですが、この三つを満たしていれば、アクセス先から連絡が来たときに説明できます。
アプリを App Store と Google Play に出し続けてきた経験から言うと、審査でも運用でも、後から効いてくるのは速さではなく「説明できるかどうか」でした。ブラウザ自動化も同じ性質を持っています。説明できる状態を保っておくことが、自動化を長く続けるための実務だと感じています。
ベストプラクティス
エラーハンドリングは徹底的に
ネットワーク障害・タイムアウトへの対応を実装。CAPTCHA が出たら迂回せず中断して通知
フェイルオーバー戦略を用意(リトライ・ログと通知)
ページ読み込み待機を適切に
waitForNavigation(): ページ遷移時
waitForSelector(): 動的コンテンツ読み込み時
waitForTimeout(): は最後の手段(できるだけ避ける)
メモリリークを防ぐ
ブラウザインスタンスを適切にクローズ
大量の Cookie/ローカルストレージをクリア
ログを活用する
各ステップでログを記録
スクリーンショットを取得(失敗時は必須)
自律実行の権限設計 —「黙って自動承認」から学ぶ
Browser Sub-Agent を夜間の自動処理に組み込むと、必ず向き合うことになるのが「どこまでをエージェントの判断に委ねるか」という問いです。
2026年7月の Antigravity CLI 1.1.3 では、ヘッドレス実行(-p)にまつわる二つの修正が入りました。確認が必要なツールでエージェントが固まる、あるいは黙って自動承認してしまう挙動が改められ、今後はソフト拒否したうえで、許可に必要な allow ルール名を stderr に示すようになっています。always-proceed モードでワークスペース外へファイルを書き込む操作が誤って自動承認されていた問題も塞がれました。
この修正が静かに示しているのは、「自動で進める」ことと「何を自動で進めてよいか」は別の設計だという事実です。ブラウザ操作は、とりわけこの線引きが効いてきます。フォーム送信ひとつが決済や退会につながる場面では、自動承認の既定値がそのまま事故の入口になります。
私自身、個人開発で続けている壁紙アプリの運用で、管理画面の在庫チェックを自動化したとき、最初はすべてのブラウザ操作を無条件に通していました。ある晩、セッション切れの状態でエージェントがログインフォームに空の認証情報を送り続け、アカウントが一時的にロックされたことがあります。ログが薄かったせいで、原因にたどり着くまで丸一日を費やしました。それ以来、ブラウザ操作を「読み取り専用」「状態変更」「不可逆」の三層に分け、後ろの二つには必ず allow ルールと事前確認を挟むようにしています。個人開発では、こうした事故の復旧を代わってくれる人がいません。だからこそ、止まる設計を先に用意しておくことが、任せ続けるための前提になります。
次のコードは、その線引きをエージェントの実行前に一枚挟むための、操作分類の最小実装です。
type ActionRisk = "read" | "mutate" | "irreversible" ;
// 操作の意図を三層に分類する
function classifyAction ( action : { type : string ; url : string }) : ActionRisk {
const irreversible = / \/ (delete | cancel | withdraw | charge | pay | checkout)/ i ;
const mutate = /(submit | post | put | update | create)/ i ;
if (irreversible. test (action.url)) return "irreversible" ;
if (mutate. test (action.type)) return "mutate" ;
return "read" ;
}
// 分類に応じて自動承認するか、人間の確認を挟むかを決める
async function guard (
action : { type : string ; url : string },
allowRules : Set < string >,
) {
const risk = classifyAction (action);
if (risk === "read" ) return true ; // 読み取りは自動で通す
const ruleName = `${ risk }:${ new URL ( action . url ). hostname }` ;
if ( ! allowRules. has (ruleName)) {
// ソフト拒否。必要な allow ルール名を明示して人間に委ねる
console. error ( `[guard] blocked ${ risk }. add allow rule: ${ ruleName }` );
return false ;
}
return true ;
}
ポイントは、拒否したときに「どの allow ルールを足せば通るか」を必ず出力することです。CLI 1.1.3 が stderr に allow ルール名を示すようになったのと同じ発想で、止まった理由が翌朝ログから一目で分かる状態にしておきます。ここが曖昧だと、私が一日を溶かしたのと同じ切り分けコストを毎回払うことになります。
任せる範囲を決める判断フレームワーク
操作を三層に分けたら、それぞれに既定の扱いと保護を割り当てます。私が実際に使っている対応表を示します。
層 操作の例 既定の扱い 必要な保護
読み取り専用 ページ遷移、テキスト抽出、スクリーンショット 自動承認 レート制限とログのみ
状態変更 フォーム送信、設定更新、下書き保存 allow ルール必須 ドメイン単位の許可+差分ログ
不可逆 決済、退会、削除、公開 人間の事前確認 ドライラン+二段階承認+通知
この表を眺めていると、判断が難しいのは中央の「状態変更」だと気づきます。読み取りと不可逆は迷いません。曖昧なのは、下書き保存のように「戻せるが影響が残る」操作です。ここは失敗時の影響が金銭やユーザー体験にどれだけ波及するか、そして復旧手順が明文化されているかで、上下どちらの層に寄せるかを決めています。判断軸を数値ではなく「戻せるか」「誰が困るか」で持っておくと、新しい操作が出てきても迷いません。
検証してから任せる — 段階的な移行
権限設計ができたら、いきなり夜間の無人運用に載せず、段階を踏みます。私は次の順序を守っています。
手元で有人実行 。すべての操作を承認プロンプト付きで一度通し、分類が意図どおりかを目視します。
観測メトリクスとアラートを設置 。操作種別ごとの成功率・所要時間・拒否回数を記録し、拒否が急増したら通知が飛ぶようにします。
限定ロールアウトで本番検証 。まず読み取り専用の操作だけを無人化し、一週間ログを見てから状態変更層を解禁します。
不可逆層は最後まで人間の手に残す 。ここを自動化する誘惑に負けないことが、長く安全に任せ続けるための一線です。
この順序は遠回りに見えますが、事故が起きたときの復旧コストを考えれば、結局いちばん速い道でした。
まとめ
Browser Sub-Agent は、AI に「手」を与える強力な機能です。だからこそ、動かすことと同じ熱量で、任せる範囲を設計する価値があります。今日できる次の一歩として、既存の自動化スクリプトの操作を「読み取り/状態変更/不可逆」の三層に分類し、後ろの二つに allow ルールを一つずつ足してみてください。
私自身もまだ設計を磨いている途中です。実装の参考になれば幸いです。お読みいただき、ありがとうございました。