scrape dynamic web pages

動的なWebページをスクレイピングする際には、最初のリクエストで取得するHTMLに含まれないコンテンツを扱う必要があります。最近のサイトは商品グリッド、価格、コメント、フィードをJavaScriptでブラウザ内に構築するため、単純なHTTPリクエストでは、データがあるはずの場所に空の枠組みしか返されません。

本ガイドはPythonで実践する一連の手順を解説します。まず生のレスポンスとレンダリング後のDOMを比較して動的コンテンツを検出し、続いて抽出方法を軽量なものから負荷の高いものへ順に紹介します。隠れたJSON APIの発見、PlaywrightやSeleniumの操作、無限スクロールと「もっと読み込む」ボタンへの対応、プロキシの帯域幅の削減、リトライの追加、並行スクレイピング、そして整形式のJSONとCSVへのエクスポートです。

DataImpulseは倫理的なプロキシプロバイダーで、195か国にわたって9,000万を超えるレジデンシャル、モバイル、データセンターのIPアドレスを提供します。1GBあたり1ドルからの従量課金モデルを採用し、トラフィックは失効しません。Webスクレイピング、広告検証、価格モニタリング、市場調査、複数アカウント管理に利用されています。

要点

  • 動的ページのスクレイピング: JavaScriptで構築されたコンテンツは生のHTMLに含まれないため、ページが取得している元のJSON APIを呼び出すか、ヘッドレスブラウザでページをレンダリングします。
  • 最適なプロキシの種類: ローテーティングのレジデンシャルプロキシ。検出を回避する実際の一般ユーザーのIPを使用します。
  • 価格: 1GBあたり1ドルから、従量課金制で、トラフィックは失効せず、サブスクリプションも不要です。
  • カバレッジ: 195か国にわたる、倫理的に調達された9,000万超のIP。
  • 信頼性: 成功率99.51%、G2で5点満点中4.8の評価。
  • プロトコルとターゲティング: HTTP、HTTPS、SOCKS5に対応し、国単位のターゲティングを標準で含みます。
動的なWebページをスクレイピングする際の手法の優先順位

Webページが動的であるとはどういうことか?

ページが動的だというのは、意味のあるコンテンツが最初のHTMLレスポンスで届けられるのではなく、ブラウザ内のJavaScriptによって生成される場合です。サーバーは軽量なHTMLの骨組みを送り、その後クライアント側のコードがデータを取得し、ページが開いた後にDOMへ挿入します。

最も手早い手動チェックは、同じページの2つの表示を比べることです。右クリックして「ページのソースを表示」を選び、サーバーが返した生のHTMLを確認します。次に開発者ツールを開き、スクリプト実行後の現在のDOMを示すElementsパネルを調べます。欲しい価格や一覧がElementsパネルには現れるのにソース表示にはない場合、そのコンテンツは動的であり、単一のリクエストでは取得できません。

  • 静的コンテンツはソース表示に存在し、HTTPレスポンスから直接解析できます。
  • 動的コンテンツはレンダリング後のDOMにのみ現れ、バックグラウンドのリクエストを通じて読み込まれます。

この診断はコードで自動化できます。まず、通常のHTTPクライアントがダウンロードする生のHTMLに含まれる対象要素の数を調べます:

import requests
from bs4 import BeautifulSoup

url = "https://example.com/products"
headers = {"User-Agent": "Mozilla/5.0"}

resp = requests.get(url, headers=headers, timeout=30)
soup = BeautifulSoup(resp.text, "html.parser")

raw_cards = soup.select("div.product-card")
print("Cards in raw HTML:", len(raw_cards))

# If this prints 0 but the page clearly shows products in a browser,
# the content is injected by JavaScript and the page is dynamic.

次に、同じURLをヘッドレスブラウザでレンダリングして再度数えます。両者の数の違いで判別できます:

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    page.goto("https://example.com/products", wait_until="networkidle")
    rendered_cards = page.query_selector_all("div.product-card")
    print("Cards after JavaScript runs:", len(rendered_cards))
    browser.close()

# Raw HTML 0, rendered DOM 48 means the page builds its content dynamically.

本格的なスクレイパーを作成する前に、対象が自動アクセスを許可しているか確認しておくことも重要です。サイトがスクレイピングを許可しているか確認する方法のガイドでは、robotsルールと利用規約の読み方を扱っています。

動的ページのスクレイピングにはどの手法を使うべきか?

必要なデータを確実に取得できる、最も軽量な方法を選びます。隠れたJSON APIが存在すればそれを呼び出し、ない場合にのみヘッドレスブラウザに切り替えます。どの選択肢も、ページの再現性と引き換えに、速度とコストが変わります。

下の表では、コードを書く前に選べるよう、よくある4つのアプローチを比較しています。実際には、多くのプロジェクトがこのうち2つを組み合わせ、大量の取得には隠れたAPIを、それが難しいページにはブラウザを使います。

手法 速度 コスト 適した用途 主な制約
隠れたJSON API 最速 最安 整形式のXHR呼び出しで取得できるデータ エンドポイントが変わったりトークンが必要になったりする
Playwright 中程度 やや高い 完全なレンダリング、モダンな非同期制御 CPU、メモリ、帯域を消費する
Selenium 中程度 やや高い レガシー環境、幅広い言語対応 セットアップが遅く、待機処理が冗長
requests-html 低速 低い 単純なページの軽いJavaScript ほとんど保守されておらず、レンダリングが脆い

コストは重要です。従量制のプロキシ経由で動くヘッドレスブラウザは、単一のAPI呼び出しよりはるかに多くのバイトをダウンロードするからです。大規模にスクレイピングするなら、選ぶ手法はコードと同じくらい請求額を左右します。リクエスト間隔やヘッダー設定を含む、より幅広い実践方法については、Webスクレイピングのベストプラクティスをご覧ください。

動的ページの背後にある隠れたAPIをどう見つけるか?

ページがデータ取得のために行うバックグラウンドのリクエストを探します。そのエンドポイントは通常、直接呼び出せる整形式のJSONを返すからです。これは最も速く信頼できる方法で、ブラウザの実行を完全に回避できます。

開発者ツールを開き、Networkタブに切り替え、Fetch/XHRでフィルターしてページを再読み込みします。コンテンツが表示される過程で、欲しい値を含むJSONを返すリクエストに注目します。リクエストのURL、メソッド、クエリパラメータ、送信されるヘッダーやトークンを調べ、右クリックしてcURLとしてコピーし、リクエスト全体を確認します。その呼び出しをHTTPクライアントで再現できれば、レンダリングを完全に省いて、低コストで構造化データを取得できます。

プロキシ経由でJSONエンドポイントを呼び出し、フィールドを直接取得する最小限の例です:

import requests

proxy = "http://USERNAME:[email protected]:823"
proxies = {"http": proxy, "https": proxy}

headers = {
    "User-Agent": "Mozilla/5.0",
    "Accept": "application/json",
    "Referer": "https://example.com/products",
}
params = {"category": "laptops", "page": 1}

r = requests.get(
    "https://example.com/api/v2/products",
    headers=headers,
    params=params,
    proxies=proxies,
    timeout=30,
)
r.raise_for_status()
data = r.json()

for item in data["items"]:
    print(item["name"], item["price"])

エンドポイントがページ分割されている場合は、pageパラメータを増やすか、レスポンスが返す次のカーソルをたどります。リクエストにトークンが必要なときは、ページが最初にトークンを発行する箇所を確認し、同一セッションで再利用します。呼び出しをDataImpulse経由でルーティングすると、エンドポイントは自分のサーバーのIPアドレスではなくレジデンシャルIPを見ます。これはAPIが送信元ごとにレート制限を行う場合に重要です。プロキシ自体で認証の壁にぶつかった場合は、プロキシ認証に関するメモが認証情報の形式を説明しています。

Playwrightで動的ページをどうスクレイピングするか?

直接呼び出せる整形式のAPIがない場合は、Playwrightのようなヘッドレスブラウザを操作してユーザーと同じようにJavaScriptを実行させ、完成したDOMを読み取ります。Playwrightが現代の定番なのは、信頼できる自動待機、充実した非同期サポート、そしてシンプルなプロキシオプションを備えているからです。

まずインストールし、ブラウザのバイナリをダウンロードします:

pip install playwright
playwright install chromium

核心となる作法は、固定のスリープで当て推量せず、必要な特定の要素を待つことです。下の例では、Chromiumを起動し、辞書形式の設定でプロキシ経由にルーティングし、商品グリッドを待ってから、レンダリングされた値を抽出します:

from playwright.sync_api import sync_playwright

PROXY = {
    "server": "http://gw.dataimpulse.com:823",
    "username": "USERNAME",
    "password": "PASSWORD",
}

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True, proxy=PROXY)
    context = browser.new_context(user_agent="Mozilla/5.0")
    page = context.new_page()
    page.goto("https://example.com/products", wait_until="domcontentloaded")

    page.wait_for_selector("div.product-card", timeout=15000)

    cards = page.query_selector_all("div.product-card")
    for card in cards:
        name = card.query_selector(".title").inner_text()
        price = card.query_selector(".price").inner_text()
        print(name, price)

    browser.close()

待機処理は多くの動的ページ用スクレイパーの成否を左右するため、4つの戦略とそれぞれが適する場面を知っておくと役立ちます。長時間接続を維持するページでは停止してしまうおそれがあるnetworkidleよりも、具体的なセレクターや条件を待つ方を選びましょう:

# Wait for a specific element (most reliable)
page.wait_for_selector("div.product-card", timeout=15000)

# Wait for the initial DOM to be built, before every script finishes
page.goto(url, wait_until="domcontentloaded")

# Wait until there are no network connections for 500 ms
page.goto(url, wait_until="networkidle")

# Wait for a custom condition, such as a minimum item count
page.wait_for_function(
    "document.querySelectorAll('div.product-card').length > 20"
)

対象がブラウザで動くスクリプトに大きく依存している場合、関連ガイドのJavaScriptによるWebスクレイピングで、このようなページの取得を難しくするクライアント側レンダリングをより深く掘り下げています。

無限スクロールと「もっと読み込む」ボタンにどう対応するか?

ページが追加コンテンツの読み込みに使うイベントを発火させ、読み取る前に新しいバッチごとのレンダリングを待ちます。無限スクロールと「もっと読み込む」ボタンはユーザーの操作に応じてしかデータを取得しないため、一度ページを読み込むだけでは、最初に表示される分しか取得できません。

ブラウザ自動化に進む前に、もう一度Networkタブを確認しましょう。無限スクロールはほぼ必ずページネーションされたXHR呼び出しで動いており、そのエンドポイントを先ほどのrequestsの手法でループする方がスクロールよりはるかに低コストだからです。どうしてもスクロールが必要なら、新しい項目が現れなくなったら止まる制御されたループで実行します:

def scroll_until_stable(page, item_selector, max_rounds=30):
    seen = 0
    for _ in range(max_rounds):
        page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
        page.wait_for_timeout(1500)
        count = len(page.query_selector_all(item_selector))
        if count == seen:
            break            # no new items loaded, stop
        seen = count
    return seen

スクロールの代わりに明示的なボタンを使うページには、クリックのループが必要です。クリックごとに項目数を読み取り、ボタンをクリックし、数が増えるのを待つことで、レンダリング完了前に次の操作をしないようにします:

def click_load_more(page, button_selector, item_selector, max_clicks=50):
    for _ in range(max_clicks):
        button = page.query_selector(button_selector)
        if button is None or not button.is_visible():
            break
        before = len(page.query_selector_all(item_selector))
        button.click()
        page.wait_for_function(
            "([sel, n]) => document.querySelectorAll(sel).length > n",
            arg=[item_selector, before],
        )
    return len(page.query_selector_all(item_selector))

取得時に重複を除去しましょう。各周回で同じノードを読み直すことはよくあるからです。一意のIDやURLの集合を保持し、まだ見ていないレコードだけを追加します。

Playwrightの代わりにSeleniumを使えるか?

はい。Seleniumも同じように実際のブラウザを操作し、チームが既に使っている場合や幅広い言語対応が必要な場合には堅実な選択肢です。パターンはPlaywrightと同様で、ページを読み込み、WebDriverWaitで条件を待ち、レンダリングされたDOMから要素を読み取ります。

from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

options = Options()
options.add_argument("--headless=new")
options.add_argument("--proxy-server=http://gw.dataimpulse.com:823")

driver = webdriver.Chrome(options=options)
driver.get("https://example.com/products")

WebDriverWait(driver, 15).until(
    EC.presence_of_element_located((By.CSS_SELECTOR, "div.product-card"))
)

for card in driver.find_elements(By.CSS_SELECTOR, "div.product-card"):
    name = card.find_element(By.CSS_SELECTOR, ".title").text
    price = card.find_element(By.CSS_SELECTOR, ".price").text
    print(name, price)

driver.quit()

一点注意が必要です。素の–proxy-serverフラグはユーザー名とパスワードを受け付けないため、認証付きプロキシには追加の設定が必要です。Selenium Wireライブラリは認証情報を適切に設定でき、Selenium Wireプロキシのセットアップに関する解説が正確な設定を示しています。SeleniumはPlaywrightより設定の手間が大きく、待機処理も冗長なので、Seleniumに留まる理由がない限り、新しいプロジェクトはPlaywrightから始めることが多いです。

ページをレンダリングする際にプロキシの帯域幅をどう削減するか?

不要なリソースをブロックして、解析しない画像、フォント、メディアをブラウザがダウンロードしないようにします。ヘッドレスブラウザは既定ですべてのアセットを取得し、その通信量はギガバイト単位で課金されるプロキシを経由するとすべてお金がかかります。

Playwrightではリクエストを傍受し、抽出に関係のないものを中断できます。画像、メディア、フォントをブロックすれば、実際に読み取るJSONとHTMLはそのままに、プロキシトラフィックを大幅に削減できます:

BLOCK = {"image", "media", "font"}

def block_heavy(route):
    if route.request.resource_type in BLOCK:
        route.abort()
    else:
        route.continue_()

context = browser.new_context()
context.route("**/*", block_heavy)
page = context.new_page()
page.goto("https://example.com/products", wait_until="domcontentloaded")

同じハンドラーを拡張して、データを増やさずに負荷だけを増やす分析・広告用のドメインをスキップできます。DataImpulseのトラフィックは1GBあたり1ドルからの従量課金で失効しないため、無駄なバイトを削ることが各スクレイピング処理のコストを直接下げます。関連ページ間で1つのブラウザコンテキストを再利用すれば起動コストの繰り返しも避けられ、最も安いリクエストは、隠れたAPIに切り替えることで避けられるリクエストです。

エラー処理とリトライをどう加えるか?

各ページ遷移を指数バックオフ付きのリトライループで包み、一度の遅い読み込みや一時的なブロックが処理全体を失敗させないようにします。動的ページは断続的に失敗し、最初のタイムアウトで停止するスクレイパーは、大規模になるとレコードの大部分を失います。

Playwrightが発生させるタイムアウトを捕捉し、徐々に長くなる間隔で待機し、限られた回数リトライしてから、失敗を記録して次へ進みます:

import time
from playwright.sync_api import TimeoutError as PlaywrightTimeout

def fetch_with_retries(page, url, selector, retries=3, backoff=2):
    for attempt in range(1, retries + 1):
        try:
            page.goto(url, wait_until="domcontentloaded", timeout=30000)
            page.wait_for_selector(selector, timeout=15000)
            return page.query_selector_all(selector)
        except PlaywrightTimeout:
            wait = backoff ** attempt
            print(f"Attempt {attempt} timed out, retrying in {wait}s")
            time.sleep(wait)
    raise RuntimeError(f"Failed to load {url} after {retries} attempts")

リトライをプロキシローテーションと組み合わせ、各試行が新しいIPアドレスから送信されるようにします。ある出口がレート制限されても、次のリクエストは別のIPアドレスから送信され、たいてい成功します。レジデンシャルプロキシモバイルプロキシのローテーションプールは、リクエストごとに新しいIPを自動で割り当てるため、多くの厄介なブロックが通常のリトライで解消できることが多くなります。プロキシレベルの失敗への対処については、HTTPエラー407に関するメモが最も一般的な認証応答を扱っています。

多数の動的ページを並行してスクレイピングするには?

Playwrightの非同期APIをasyncioのセマフォと組み合わせて使い、マシンや対象サイトに過剰な負荷をかけずに複数ページを同時にレンダリングします。各ブラウザページは大半の時間をネットワーク待ちに費やすため、並行処理こそ動的ページのスクレイピングがスループットを取り戻す場面です。

セマフォは同時に処理するページ数の上限を決めます。1つのブラウザとコンテキストをタスク間で共有し、URLごとにページを開き、結果を集めます:

import asyncio
from playwright.async_api import async_playwright

PROXY = {
    "server": "http://gw.dataimpulse.com:823",
    "username": "USERNAME",
    "password": "PASSWORD",
}

async def scrape_one(context, url, sem):
    async with sem:
        page = await context.new_page()
        try:
            await page.goto(url, wait_until="domcontentloaded")
            await page.wait_for_selector("div.product-card", timeout=15000)
            cards = await page.query_selector_all("div.product-card")
            return [await c.inner_text() for c in cards]
        finally:
            await page.close()

async def main(urls):
    sem = asyncio.Semaphore(5)   # at most 5 pages at once
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True, proxy=PROXY)
        context = await browser.new_context()
        tasks = [scrape_one(context, u, sem) for u in urls]
        results = await asyncio.gather(*tasks)
        await browser.close()
    return results

urls = ["https://example.com/products?page=%d" % i for i in range(1, 21)]
data = asyncio.run(main(urls))

並行数の上限は控えめに保ちましょう。同時に処理するページが多すぎるとメモリを使い果たし、レート制限に引っかかり、並列化で得られる分より多くのリトライを招きます。5前後から始め、成功率が高いあいだだけ引き上げます。

スクレイピングしたデータをJSONとCSVにどうエクスポートするか?

各ページの結果を辞書のリストにまとめ、ネストしたデータならJSONに、平坦で表計算に向くテーブルならCSVにそのリストを書き出します。抽出とエクスポートを分けておくと、スクレイパーのテストや再実行が容易になります。

import json
import csv

records = [
    {"name": "Laptop A", "price": "999"},
    {"name": "Laptop B", "price": "1299"},
]

# Export to JSON
with open("products.json", "w", encoding="utf-8") as f:
    json.dump(records, f, ensure_ascii=False, indent=2)

# Export to CSV
with open("products.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=["name", "price"])
    writer.writeheader()
    writer.writerows(records)

レコードがネストしたリストやオブジェクトを含むならJSONを、すべてのレコードが同じフラットなフィールドを共有し、アナリストが表計算で開くならCSVを選びます。書き出す前にフィールドを正規化してすべてのレコードが同じキーを持つようにすれば、動的ページがどうレンダリングされたかに関わらずエクスポート工程は単純なままです。ここから、同じJSONが追加の解析なしでデータベースの取り込みや分析ジョブに供給されます。

遅れて読み込まれるコンテンツのための待機戦略

よくある質問

スクレイパーを書く前に、ページが動的かどうかをどう見分ければよいですか?

開発者ツールでソース表示とElementsパネルを比べるか、生のHTMLとレンダリング後のDOMで対象要素の数を数えます。JavaScriptが実行された後にのみデータが現れる場合、そのページは動的であり、APIの呼び出しかヘッドレスブラウザが必要です。

隠れたAPIを呼び出すのと、ヘッドレスブラウザを使うのはどちらがよいですか?

見つけられるなら隠れたAPIを呼び出しましょう。レンダリングに比べてごく低いコストで、しかも高速に整形式のJSONを返すからです。再現可能なAPIが存在しない場合や、データが複雑なクライアント側スクリプトに依存する場合にのみヘッドレスブラウザを使います。

動的ページのスクレイピングにはPlaywrightとSeleniumのどちらを使うべきですか?

Playwrightは信頼できる自動待機と組み込みの非同期サポートのおかげで現代の定番であり、新しいプロジェクトはたいていそこから始めます。Seleniumは、あなたの環境が既にそれを使っている場合や、より幅広い言語対応が必要な場合によく合います。

ヘッドレスブラウザを使うとき、プロキシの帯域幅をどう減らせますか?

リクエストを傍受し、解析しない画像、フォント、メディア、分析用リクエストを中断します。プロキシのコストはギガバイト単位で測られるため、これはトラフィックを大幅に削減でき、各ページの読み込みも速くなります。

無限スクロールで読み込まれるコンテンツはどうスクレイピングしますか?

まずNetworkタブでスクロールの背後にあるページネーションされたXHR呼び出しを確認し、そのエンドポイントを直接ループします。どうしてもスクロールが必要なら、制御されたループで行い、読み取る前に新しいバッチごとのレンダリングを待ちます。

DataImpulseが適さないのはどんなときですか?

静的なISPプロキシ、フルマネージドのスクレイピングAPI、あるいは銀行や政府のサイトへのアクセスが必要なら、DataImpulseは適したツールではありません。公開データの収集やコンテンツへのアクセスのための、ローテーション型のレジデンシャル、モバイル、データセンタープロキシに特化しています。

信頼できるプロキシで動的ページをスクレイピングする

スクレイパーに、実際の訪問者のように振る舞うレジデンシャル、モバイル、データセンターのIPが必要なら、1GBあたり1ドルからの従量課金トラフィックで始められます。DataImpulseアカウントを作成し、動的ページのスクレイピングをローテーティングまたはスティッキーセッション経由でルーティングしましょう。


Share article: