🐷

PlaywrightでiframeのWebスクレイピングに挑んだ話【実践編】

に公開

はじめに

Webスクレイピングをしていると、「あれ?商品情報が取れない...」という壁にぶち当たることがあります。

私もつい先日、あるカメラショップのサイトをスクレイピングしようとして、まさにこの状況に陥りました。セレクタは合っているはずなのに、なぜか <b> タグが0個。HTMLを眺めても、商品名らしきものが見当たらない。

結論から言うと、商品リストは iframe の中に隠れていました。

この記事では、その問題をどう発見し、どう解決したかを、実際のコードと共に解説します。

この記事で学べること

  • iframe内のコンテンツをスクレイピングする方法
  • Playwrightでのフレーム操作
  • 実際のデバッグプロセス
  • フォールバック機構の実装

環境

Python: 3.10+
Playwright: 最新版
対象サイト: ケイズカメラ(http://ks-camera.jp/)

問題発見までの道のり

最初の実装(v5)

まずは普通にスクレイピングしてみました。

from playwright.sync_api import sync_playwright

def scrape():
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()
        page.goto("http://ks-camera.jp/hanbai/index.html")
        
        # 商品名を取得
        products = page.query_selector_all('b')
        print(f"商品数: {len(products)}")  # → 0件!?
        
        browser.close()

scrape()

結果: 0件

「おかしいな...」と思ってセレクタを変えてみても、やはり0件。

デバッグ開始

HTMLをダンプして確認してみることに。

def debug_page(page):
    html = page.content()
    with open('debug.html', 'w', encoding='utf-8') as f:
        f.write(html)
    
    # 統計情報
    print(f"HTML長: {len(html)}")
    print(f"<b>タグ: {len(page.query_selector_all('b'))}個")
    print(f"<table>タグ: {len(page.query_selector_all('table'))}個")

出力:

HTML長: 2,847文字
<b>タグ: 0個
<table>タグ: 0個

明らかに短すぎる。商品リストが含まれていない。

真犯人は iframe だった

ダンプしたHTMLを開いて確認すると...

<!DOCTYPE html>
<html>
<head>
    <title>中古カメラ販売-ケイズカメラリペア</title>
</head>
<body>
    <p>2019年3月現在、取り扱っている商品です...</p>
    
    <!-- これだ! -->
    <iframe 
        src="http://ks-camera.jp/hanbai/kscameraorder/cgi-bin/shopping/main.cgi?display=normal&class=&word=&FF=&NP=&TOTAL=&enumber=" 
        width="710" 
        height="1680">
    </iframe>
</body>
</html>

商品リストは iframe の中にありました。

通常の page.query_selector_all() では、iframe内のコンテンツにアクセスできません。これが0件だった理由です。


解決方法:iframe対応

Playwrightでは、フレームを明示的に取得する必要があります。

方法1: iframeのURLに直接アクセス

最もシンプルな方法は、iframeのURLに直接アクセスすることです。

def scrape_direct():
    IFRAME_URL = "http://ks-camera.jp/hanbai/kscameraorder/cgi-bin/shopping/main.cgi?display=normal&class=&word=&FF=&NP=&TOTAL=&enumber="
    
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()
        
        # iframeのURLに直接アクセス
        page.goto(IFRAME_URL)
        
        # これで取れる!
        products = page.query_selector_all('b')
        print(f"商品数: {len(products)}件")  # → 89個!
        
        browser.close()

結果: 89個の要素を取得成功!

方法2: メインページからiframeを取得

URLが分からない場合や、動的に生成される場合は、メインページからiframeを探します。

def scrape_via_iframe():
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()
        
        # メインページにアクセス
        page.goto("http://ks-camera.jp/hanbai/index.html")
        
        # 全てのフレームを列挙
        frames = page.frames
        print(f"フレーム数: {len(frames)}")
        
        for i, frame in enumerate(frames):
            print(f"フレーム[{i}]: {frame.url}")
            
            # CGIを含むフレームを探す
            if 'cgi-bin' in frame.url or 'shopping' in frame.url:
                # このフレームから商品を取得
                products = frame.query_selector_all('b')
                print(f"商品数: {len(products)}件")
                break
        
        browser.close()

出力:

フレーム数: 2
フレーム[0]: http://ks-camera.jp/hanbai/index.html
フレーム[1]: http://ks-camera.jp/hanbai/kscameraorder/cgi-bin/shopping/main.cgi...
商品数: 89件

実践:完全なスクレイパー実装

両方の方法を組み合わせて、堅牢なスクレイパーを作ります。

from playwright.sync_api import sync_playwright
import re

class KeizCameraScraper:
    MAIN_URL = "http://ks-camera.jp/hanbai/index.html"
    IFRAME_URL = "http://ks-camera.jp/hanbai/kscameraorder/cgi-bin/shopping/main.cgi?display=normal&class=&word=&FF=&NP=&TOTAL=&enumber="
    
    def scrape(self):
        with sync_playwright() as p:
            browser = p.chromium.launch(headless=True)
            page = browser.new_page()
            
            products = []
            
            try:
                # 方法1: 直接アクセス(推奨)
                products = self._scrape_direct(page)
            except Exception as e:
                print(f"直接アクセス失敗: {e}")
                
                try:
                    # 方法2: メインページ経由(フォールバック)
                    products = self._scrape_via_main(page)
                except Exception as e:
                    print(f"iframe取得失敗: {e}")
            
            browser.close()
            return products
    
    def _scrape_direct(self, page):
        """方法1: iframeに直接アクセス"""
        page.goto(self.IFRAME_URL, wait_until="networkidle")
        page.wait_for_timeout(3000)  # 安定待機
        
        return self._parse_frame(page.main_frame)
    
    def _scrape_via_main(self, page):
        """方法2: メインページからiframeを探す"""
        page.goto(self.MAIN_URL, wait_until="networkidle")
        page.wait_for_timeout(3000)
        
        # iframeを探す
        for frame in page.frames:
            if 'cgi-bin' in frame.url or 'shopping' in frame.url:
                return self._parse_frame(frame)
        
        raise Exception("商品iframeが見つかりません")
    
    def _parse_frame(self, frame):
        """フレームから商品情報を抽出"""
        products = []
        
        # セレクタで商品名を探す
        name_elems = frame.query_selector_all('font.gallet b')
        
        if not name_elems:
            # プランB: より広いセレクタ
            name_elems = frame.query_selector_all('b')
        
        for elem in name_elems:
            try:
                name = elem.inner_text().strip()
                
                # ランキング番号があるものだけ
                if not re.match(r'^[\[\(]?\s*\d+\s*[\]\)]?', name):
                    continue
                
                # 親要素から価格を探す
                parent_html = frame.evaluate('''(elem) => {
                    let parent = elem.closest("table");
                    if (!parent) parent = elem.closest("tr");
                    return parent ? parent.outerHTML : "";
                }''', elem)
                
                # 価格抽出
                price_match = re.search(r'([0-9,]+)\s*', parent_html)
                if price_match:
                    price = price_match.group(1).replace(',', '')
                    products.append({
                        'name': name,
                        'price': int(price)
                    })
            
            except Exception as e:
                continue
        
        return products

# 使用例
scraper = KeizCameraScraper()
products = scraper.scrape()

for p in products[:5]:
    print(f"{p['name']}: {p['price']:,}円")

出力例:

[1] makina67 後期モデル: 39,090円
[2] ハッセル500CM: 36,363円
[3] makina67専用グリップ: 4,545円
...

トラブルシューティング

問題1: iframeが見つからない

症状:

for frame in page.frames:
    print(frame.url)
# → メインページのURLしか表示されない

原因:

  • ページの読み込みが完了していない
  • iframeが動的に生成される

解決策:

page.goto(url, wait_until="networkidle")  # ネットワークが安定するまで待つ
page.wait_for_timeout(3000)  # 追加で3秒待つ

問題2: フレーム内の要素が取得できない

症状:

frame.query_selector_all('b')  # → []

原因:

  • フレームの読み込みが完了していない
  • セレクタが間違っている

解決策:

# フレームの読み込みを待つ
page.wait_for_timeout(2000)

# 複数のセレクタを試す
selectors = ['font.gallet b', 'td font b', 'b']
for sel in selectors:
    elems = frame.query_selector_all(sel)
    if elems:
        break

問題3: 価格が取得できない

原因:

  • 親要素が遠すぎる
  • HTMLの構造が複雑

解決策:

# closest() で最も近い親要素を探す
parent_html = frame.evaluate('''(elem) => {
    let parent = elem.closest("table");
    if (!parent) parent = elem.closest("tr");
    if (!parent) parent = elem.closest("td");
    return parent ? parent.outerHTML : "";
}''', elem)

パフォーマンスの最適化

タイムアウトの調整

class Constants:
    PAGE_LOAD_TIMEOUT_MS = 60000  # 60秒
    STABILITY_WAIT_MS = 3000      # 3秒

ヘッドレスモードの活用

browser = p.chromium.launch(
    headless=True,  # 高速化
    args=[
        '--no-sandbox',
        '--disable-setuid-sandbox',
        '--disable-dev-shm-usage'  # メモリ使用量削減
    ]
)

コンテキストの再利用

# 非推奨:毎回ブラウザを起動
for url in urls:
    with sync_playwright() as p:
        browser = p.chromium.launch()
        # ...

# 推奨:コンテキストを再利用
with sync_playwright() as p:
    browser = p.chromium.launch()
    for url in urls:
        page = browser.new_page()
        # ...
        page.close()
    browser.close()

応用:複数ページの取得

多くのECサイトはページネーションを持っています。

def scrape_all_pages(self):
    """全ページを取得"""
    all_products = []
    
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()
        
        page_num = 1
        while True:
            url = f"{self.BASE_URL}&page={page_num}"
            
            try:
                page.goto(url)
                products = self._parse_frame(page.main_frame)
                
                if not products:
                    break  # 商品がなければ終了
                
                all_products.extend(products)
                print(f"ページ{page_num}: {len(products)}件取得")
                page_num += 1
                
            except Exception as e:
                print(f"ページ{page_num}でエラー: {e}")
                break
        
        browser.close()
    
    return all_products

まとめ

iframe対応の要点

  1. page.query_selector_all() ではiframe内にアクセスできない
  2. 2つのアプローチ
    • 直接アクセス(速い、シンプル)
    • メインページ経由(柔軟)
  3. フォールバック機構を実装する
  4. 適切な待機時間を設定する

学んだこと

  • HTMLダンプはデバッグの強力な武器
  • iframe検出には page.frames を使う
  • frame.evaluate() でJavaScriptを実行できる
  • フォールバック機構で堅牢性を高める

実際に使えるコード

記事で紹介したコードは、実際に動作確認済みです。
GitHubにも公開していますので、ぜひ参考にしてください。

GitHub: keiz_camera_scraper(※URLは適宜変更)


参考リンク


この記事が、同じ問題で困っている方の助けになれば幸いです。
質問やフィードバックがあれば、コメント欄でお待ちしています!

Happy Scraping! 🎯

ウェブサイト: https://portfolio-crystal-dreamscape.vercel.app

Discussion