Pythonで商品レビュー件数スクレイピング

プロジェクト概要

背景

とあるECサイトの競合他社の売れ筋商品を調査し、自社の取扱商品の参考にしたいとの依頼がありました。ECサイトの商品に対する口コミレビューの総件数は、競合他社の人気商品や売れ筋商品を把握する上で重要な指標です。商品情報は7000件ほどあり、商品名とレビュー件数を中心に情報取得できればソートで人気商品を把握できます。しかし、今回の商品詳細ページでは一部のサンプル件数(例:1500件)のみが表示され、実際の総レビュー件数(例:6500件以上)を取得できませんでした。このプロジェクトでは、最小限のHTTPリクエストで正確に総レビュー件数を取得する仕組みをChatGPTを利用しながらPythonで実装しました。

課題の詳細

  1. 商品詳細ページの限界
    • <span itemprop="reviewCount">1500</span> のように一部のみが表示される。
    • 実際の全レビュー数はページネーションの最終ページでしか分からない。
  2. 全ページ巡回のコスト
    • 全てのレビューページを順次リクエストすると、アクセス数と処理時間がページ数に応じて増大。
    • サーバー負荷の増大やIPブロックのリスク。
  3. 100件以下の例外対応
    • 逆にレビュー数が100件以下の場合、ページネーション要素自体が出現せず、該当箇所から取得できない。

ソリューション概要

  • URLパターンの逆算
    商品詳細URLからレビュー最終ページのURLを一意に組み立て、1回のリクエストで総レビュー件数を取得。
  • フォールバックロジック
    ページネーション要素がない場合は、商品詳細ページのitemprop="reviewCount"属性から取得。
  • その他の情報もついでに
    自社取扱商品の参考になりそうな情報(最安価、商品ID、カテゴリ)が同ページにあったのでついでに取得

CSV帳票イメージ

original_urltitlecategorycategory_idprice_minavg_ratingreview_countstatuselapsed
https://<対象ドメイン>/detail/item123.htmlサンプル商品Aサプリ100113874.564842000.85
https://<対象ドメイン>/detail/item456.htmlサンプル商品B美容用品100225904.232402000.92
https://<対象ドメイン>/detail/item789.htmlサンプル商品C健康食品10039803.815722001.10

statusとelapsedはテスト時のサーバー負荷を確認するために入れています、

使用ライブラリと役割

この辺の知識はありませんでした。組み立て方に考える時間を割きたいので、設定や学習にかける時間的コストを大幅に下げてくれるAIには感謝です。

ライブラリ役割
requestsHTTPリクエストの送信。ヘッダー偽装やタイムアウト設定が可能。
BeautifulSoup (bs4)HTMLパースおよびCSSセレクタ抽出。
re(正規表現)ページネーションテキストから数値を抽出。
urllib.parseURLのパスやクエリを解析・分割。
concurrent.futures並列処理のためのスレッドプール。
time, randomリクエスト間インターバル制御、サーバー負荷軽減。

ソースコード

import requests  # HTTPリクエストを送信するライブラリ
from bs4 import BeautifulSoup  # HTMLを解析するライブラリ
import concurrent.futures  # 並列処理用スレッドプール
import pandas as pd  # データフレーム操作用ライブラリ
import time  # 処理時間計測とウェイト挿入用
import urllib.parse as up  # URL解析用
import random  # ランダムウェイト用
import re  # 正規表現

# ─────────────────────────────────
# 設定: 入力URLリスト、出力CSVファイル名、対象ドメイン
DETAIL_URLS_FILE = "<入力URLリスト>.txt"
CSV_OUTPUT_FILE = "<出力ファイル名>.csv"
BASE_DOMAIN = "<対象ドメイン>"  # 例: example.com
# ─────────────────────────────────

# ブラウザ風User-Agentヘッダー
HEADERS = {"User-Agent": "Mozilla/5.0"}


def fetch_info(detail_url: str) -> dict:
    """
    商品詳細URLからレビュー一覧URLを生成し、以下の情報を取得して返却します:
      - original_url : 入力された詳細ページのURL
      - title        : 商品タイトル
      - category     : カテゴリ名
      - category_id  : カテゴリID
      - price_min    : 最安価格(整数)
      - avg_rating   : 平均星評価(小数)
      - review_count : 総レビュー件数(整数)
      - status       : HTTPステータスコード
      - elapsed      : リクエストにかかった時間(秒)
    """
    result = {
        "original_url": detail_url,
        "title": "",
        "category": "",
        "category_id": "",
        "price_min": None,
        "avg_rating": None,
        "review_count": None,
        "status": None,
        "elapsed": None,
    }

    # detail_urlからファイル名部分だけを抽出
    path = up.urlparse(detail_url).path
    filename = path.rsplit('/', 1)[-1]

    # レビュー一覧ページのURLを組み立て
    review_url = (
        f"https://{BASE_DOMAIN}/reviews/page:100/lmt:100/purl:{filename}"
    )

    try:
        # リクエスト開始時刻
        start = time.time()
        resp = requests.get(review_url, headers=HEADERS, timeout=10)
        result['elapsed'] = time.time() - start
        result['status'] = resp.status_code
        resp.raise_for_status()

        soup = BeautifulSoup(resp.text, 'html.parser')

        # 商品タイトルを取得
        if h2 := soup.select_one('h2.review-title'):
            result['title'] = h2.get_text(strip=True)

        # パンくずリストからカテゴリ名とIDを取得
        crumbs = soup.select('ul.breadcrumbs-list li a.breadcrumb-anchor')
        if len(crumbs) >= 2:
            cat = crumbs[1]
            result['category'] = cat.get_text(strip=True)
            params = dict(up.parse_qsl(up.urlparse(cat['href']).query))
            result['category_id'] = params.get('cid', '')

        # 最安価格を取得(例: "1,234円~" を数値化)
        if price_tag := soup.select_one('.lowest-price-review__value'):
            text = price_tag.get_text(strip=True).replace('円~', '').replace(',', '')
            if text.isdigit():
                result['price_min'] = int(text)

        # 平均星評価を取得(imgタグのalt属性)
        if star := soup.select_one('img.star[alt]'):
            try:
                result['avg_rating'] = float(star['alt'])
            except ValueError:
                pass

        # ページネーション表示から総レビュー件数を取得
        if pager := soup.select_one('div.paging'):
            text = pager.get_text(strip=True)
            m = re.search(r"(\d+)\s*-\s*(\d+)件目", text)
            if m:
                result['review_count'] = int(m.group(2))

        # 上記取得に失敗した場合は itemprop='reviewCount' から取得
        if result['review_count'] is None:
            if rc := soup.select_one("span[itemprop='reviewCount']"):
                try:
                    result['review_count'] = int(rc.get_text(strip=True))
                except ValueError:
                    pass

    except Exception:
        # エラー時は初期状態のまま返却
        pass

    # サーバー負荷軽減のためランダムウェイト
    time.sleep(random.uniform(0.3, 1.0))
    return result


def main():
    """
    URLリストをfetch_infoで処理し、結果をCSVに出力します。
    """
    # URLリストを読み込み
    with open(DETAIL_URLS_FILE, encoding='utf-8') as f:
        urls = [line.strip() for line in f if line.strip()]

    # 並列処理で情報収集
    results = []
    with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
        for info in executor.map(fetch_info, urls):
            results.append(info)

    # 結果をDataFrame化し、review_countで降順ソート
    df = pd.DataFrame(results)
    df = df.sort_values('review_count', ascending=False).reset_index(drop=True)

    # CSVファイルとして保存
    df.to_csv(
        CSV_OUTPUT_FILE,
        index=False,
        encoding='utf-8-sig',
        columns=[
            'original_url', 'title', 'category', 'category_id',
            'price_min', 'avg_rating', 'review_count', 'status', 'elapsed'
        ]
    )
    print(f"完了: {CSV_OUTPUT_FILE} に {len(df)} 件を出力しました。")


if __name__ == '__main__':
    main()

実装のポイント

  • サーバー負荷軽減:1回のリクエストで完結、リクエスト間にランダムウェイト。
  • エラー制御raise_for_status()でHTTPエラー検出、フォールバックロジックで柔軟対応。
  • 並列実行:I/O待ち時間を有効活用し、高速化。

実行結果と効果

  • サーバー負荷:5スレッド並列と0.3, 1.0のランダムウェイトで安定
  • 1商品あたりリクエスト数: 1回
  • 平均取得時間: 約3秒
  • 大規模クロール: 7000件

全体を通してかかった時間と感想

  • 情報収集   1時間
  • 環境設定   30分
  • コーディング 30分
  • 調整     2時間

「最も時間を要したのは、レビュー件数の取得方法を検討していたときです。最初は JSON 経由で一発取得できると思っていましたが、実際にはうまく動かず、ページネーションも含めて HTML を解析する手法に切り替えました。また、スクレイピング自体が初めてだったため、『サイトの利用規約に抵触しないか』『過度なアクセスでサーバーに負荷をかけないか』といったマナー面の調査にも同じくらい時間をかけました。いい経験なりました。個人的によく使いそうな技術です。

良かったらフォローお願いします

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です