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

目次
プロジェクト概要
背景
とあるECサイトの競合他社の売れ筋商品を調査し、自社の取扱商品の参考にしたいとの依頼がありました。ECサイトの商品に対する口コミレビューの総件数は、競合他社の人気商品や売れ筋商品を把握する上で重要な指標です。商品情報は7000件ほどあり、商品名とレビュー件数を中心に情報取得できればソートで人気商品を把握できます。しかし、今回の商品詳細ページでは一部のサンプル件数(例:1500件)のみが表示され、実際の総レビュー件数(例:6500件以上)を取得できませんでした。このプロジェクトでは、最小限のHTTPリクエストで正確に総レビュー件数を取得する仕組みをChatGPTを利用しながらPythonで実装しました。
課題の詳細
- 商品詳細ページの限界
<span itemprop="reviewCount">1500</span>のように一部のみが表示される。- 実際の全レビュー数はページネーションの最終ページでしか分からない。
- 全ページ巡回のコスト
- 全てのレビューページを順次リクエストすると、アクセス数と処理時間がページ数に応じて増大。
- サーバー負荷の増大やIPブロックのリスク。
- 100件以下の例外対応
- 逆にレビュー数が100件以下の場合、ページネーション要素自体が出現せず、該当箇所から取得できない。
ソリューション概要
- URLパターンの逆算
商品詳細URLからレビュー最終ページのURLを一意に組み立て、1回のリクエストで総レビュー件数を取得。 - フォールバックロジック
ページネーション要素がない場合は、商品詳細ページのitemprop="reviewCount"属性から取得。 - その他の情報もついでに
自社取扱商品の参考になりそうな情報(最安価、商品ID、カテゴリ)が同ページにあったのでついでに取得
CSV帳票イメージ
| original_url | title | category | category_id | price_min | avg_rating | review_count | status | elapsed |
|---|---|---|---|---|---|---|---|---|
| https://<対象ドメイン>/detail/item123.html | サンプル商品A | サプリ | 1001 | 1387 | 4.5 | 6484 | 200 | 0.85 |
| https://<対象ドメイン>/detail/item456.html | サンプル商品B | 美容用品 | 1002 | 2590 | 4.2 | 3240 | 200 | 0.92 |
| https://<対象ドメイン>/detail/item789.html | サンプル商品C | 健康食品 | 1003 | 980 | 3.8 | 1572 | 200 | 1.10 |
statusとelapsedはテスト時のサーバー負荷を確認するために入れています、
使用ライブラリと役割
この辺の知識はありませんでした。組み立て方に考える時間を割きたいので、設定や学習にかける時間的コストを大幅に下げてくれるAIには感謝です。
| ライブラリ | 役割 |
|---|---|
requests | HTTPリクエストの送信。ヘッダー偽装やタイムアウト設定が可能。 |
BeautifulSoup (bs4) | HTMLパースおよびCSSセレクタ抽出。 |
re(正規表現) | ページネーションテキストから数値を抽出。 |
urllib.parse | URLのパスやクエリを解析・分割。 |
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 を解析する手法に切り替えました。また、スクレイピング自体が初めてだったため、『サイトの利用規約に抵触しないか』『過度なアクセスでサーバーに負荷をかけないか』といったマナー面の調査にも同じくらい時間をかけました。いい経験なりました。個人的によく使いそうな技術です。

2009年那覇でホームレスになるも沖縄の方々に助けられ、2010年からNPOで地域密着で困窮支援。2016-2024年まで株式会社FM那覇代表取締役。沖縄の支援団体情報ポータルサイト「カケハシオキナワ」設立運営。防災士。コンサル・エンジニア。


