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対応の要点
page.query_selector_all()ではiframe内にアクセスできない-
2つのアプローチ
- 直接アクセス(速い、シンプル)
- メインページ経由(柔軟)
- フォールバック機構を実装する
- 適切な待機時間を設定する
学んだこと
- HTMLダンプはデバッグの強力な武器
- iframe検出には
page.framesを使う -
frame.evaluate()でJavaScriptを実行できる - フォールバック機構で堅牢性を高める
実際に使えるコード
記事で紹介したコードは、実際に動作確認済みです。
GitHubにも公開していますので、ぜひ参考にしてください。
GitHub: keiz_camera_scraper(※URLは適宜変更)
参考リンク
この記事が、同じ問題で困っている方の助けになれば幸いです。
質問やフィードバックがあれば、コメント欄でお待ちしています!
Happy Scraping! 🎯
Discussion