ビックカメラの商品情報をスクレイピングしてみた

今回はビックカメラの商品情報をスクレイピングする方法について紹介します。
Dockerを使って環境を構築し、Pythonでスクレイピングを行います。

# GitHub
https://github.com/kenkono/BC_scraiping

# 仕様
- ビックカメラの検索窓に冷蔵庫と打ち込んだ検索結果から1,2ページの下記情報を抜き出してCSVで吐き出す
    - 商品名
    - 価格
    - ポイント
- 環境依存をなくすため、Dockerでの実行を前提とする
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/291139/25e7f046-9198-6672-0940-f94c828170c4.png)
https://www.biccamera.com/bc/category/?q=%97%E2%91%A0%8C%C9

# 動作環境
OS:Windows
Docker Desktop

# ディレクトリ構成
```
workspace/
├── Dockerfile
├── requirements.txt
├── Constants.py
├── PageFetcher.py
├── ProductExtractor.py
└── main.py
```


# 構築手順
1. Dockerでの環境構築
1. 検索結果ページのHTMLを取得するクラスの実装
1. HTMLから必要な情報を抽出するクラスの実装
1. データをCSVに出力するクラスの実装
1. メイン処理を行うスクリプトの作成

# 詳細
## Dockerでの環境構築
### Docker デスクトップのインストール
Dockerデスクトップをインストールします。
以下のリンクからダウンロードできます。
https://www.docker.com/ja-jp/products/docker-desktop/

### Docker関連ファイルの作成
```python:Dockerfile
# ベースイメージとしてPythonを使用
FROM python:3.9-slim

# 作業ディレクトリを設定
WORKDIR /app

# 依存関係をコピーしてインストール
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# アプリケーションのソースコードをコピー
COPY . .

# メインスクリプトを実行
CMD ["python", "main.py"]
```

```python:requirements.txt
beautifulsoup4
requests
pandas
```

## 検索結果ページのHTMLを取得するクラスの実装
### ビックカメラの検索窓に`冷蔵庫`と打った場合の表示画面取得

```python:PageFetcher.py
import requests
from Constants import Constants
import urllib.parse

class PageFetcher:
    def __init__(self):
        # 基本URLとヘッダーを設定する
        self.constants = Constants()
        self.base_url = self.constants.BASE_URL
        self.headers = {
            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"
        }
        self.encoded_query_str = urllib.parse.quote(self.constants.encoded_query) # クエリ文字列をURLエンコードして、encoded_query_strに格納

    def fetch_page(self, page_number):
        # 指定したページ番号の検索結果を取得する
        search_url = f"{self.base_url}?q={self.encoded_query_str}&p={page_number}"
        response = requests.get(search_url, headers=self.headers, verify=False) # 証明書を無視
        response.raise_for_status()  # エラーチェック
        response.encoding = 'shift_jis'  # 文字化け防止のためShiftJISに設定
        return response.text

    def fetch_pages(self, start_page, end_page):
        # 指定した範囲のページの検索結果を取得する
        pages = []
        for page_number in range(start_page, end_page + 1):
            page_content = self.fetch_page(page_number)
            pages.append(page_content)
        return pages
```

### 定数管理
```python:Constants.py
class Constants:
    query = "冷蔵庫" #検索窓に打ちたいキーワード

     #検索後に遷移されるURL
    __BASE_URL = "https://www.biccamera.com/bc/category/"
     #ビックカメラのページで指定されている文字コードで文字化け対策
    __encoded_query = query.encode("shift_jis")

    @property
    def BASE_URL(self):
        return self.__BASE_URL

    @BASE_URL.setter
    def BASE_URL(self, value):
        raise AttributeError("Cannot modify read-only attribute")

    @property
    def encoded_query(self):
        return self.__encoded_query

    @encoded_query.setter
    def encoded_query(self, value):
        raise AttributeError("Cannot modify read-only attribute")
```

## HTMLから必要な情報を抽出するクラスの実装
ここでは、商品検索結果から以下を抜き出すこととします。
- 商品名
- 価格
- ポイント

また、商品によってはポイント等が無い可能性があるため、その場合はNAとする仕様にする。

```python:ProductExtractor.py
from bs4 import BeautifulSoup

class ProductExtractor:
    def __init__(self):
        # 初期化処理（特に必要な場合）
        pass

    def extract_products(self, html_content):
        products = []

        # BeautifulSoupを使用してHTMLコンテンツを解析する
        soup = BeautifulSoup(html_content, 'html.parser')

        # <li data-item="data-item"> タグをすべて抽出する
        items = soup.select('li[data-item="data-item"]')

        # 各アイテムから商品名、価格、ポイントを抽出する
        for item in items:
            name_tag = item.select_one('p.bcs_title a.bcs_item')
            price_tag = item.select_one('p.bcs_price')
            point_tag = item.select_one('p.bcs_point')

            # 商品名を抽出、タグが存在しない場合は 'NA' を設定
            name = name_tag.get_text(strip=True) if name_tag else 'NA'
            # 価格を抽出、タグが存在しない場合は 'NA' を設定
            price = price_tag.get_text(strip=True) if price_tag else 'NA'
            # ポイントを抽出、タグが存在しない場合は 'NA' を設定
            point = point_tag.get_text(strip=True) if point_tag else 'NA'

            product_info = {
                'name': name,
                'price': price,
                'point': point
            }
            products.append(product_info)
        # 製品情報を返す
        return products
```

## データをCSVに出力するクラスの実装

吐き出すCSVは以下の仕様とする。
- 取得日の日付を記載
- CSVが存在し且つ取得日の日付が同じ場合、ver1, ver2と数字をあげていく

```python:CSVWriter.py
import csv
from datetime import datetime
import os
import re

class CSVWriter:
    def __init__(self):
        # 現在の日付を取得
        self.date_str = datetime.now().strftime('%Y%m%d')
        self.filename = self.generate_filename()

    def generate_filename(self):
        # ファイル名のパターンを定義
        pattern = re.compile(rf'ver(\d+)_{self.date_str}_products\.csv')
        version = 1

        # カレントディレクトリのファイルをチェック
        for file in os.listdir('.'):
            match = pattern.match(file)
            if match:
                current_version = int(match.group(1))
                if current_version >= version:
                    version = current_version + 1

        # 新しいファイル名を生成
        return f'ver{version}_{self.date_str}_products.csv'

    def write_to_csv(self, products):
        with open(self.filename, mode='w', newline='', encoding='utf-8') as file:
            writer = csv.DictWriter(file, fieldnames=['name', 'price', 'point'])
            writer.writeheader()
            for product in products:
                writer.writerow(product)
```

## メイン処理を行うスクリプトの作成

複数ページ取得をしたい場合、下記の引数を変更することで対応可能
`pages_content = fetcher.fetch_pages(1, 2)`
```python:main.py
from PageFetcher import PageFetcher
from ProductExtractor import ProductExtractor
from CSVWriter import CSVWriter


def main():
    fetcher = PageFetcher()
    extractor = ProductExtractor()

    # 1ページ目と2ページ目のHTMLコンテンツを取得
    pages_content = fetcher.fetch_pages(1, 2)

    # 各ページの製品名を抽出して表示
    all_products = []
    for content in pages_content:
        products = extractor.extract_products(content)
        all_products.extend(products)

    # CSVWriterを使用して製品情報をCSVに書き込む
    csv_writer = CSVWriter()
    csv_writer.write_to_csv(all_products)

if __name__ == "__main__":
    main()
```
# 実行
```
# Dockerイメージのビルド
docker build -t biccamera_scraiping .

# Dockerコンテナの実行
## Windows の PowerShellの場合
docker run --rm -v ${PWD}:/app biccamera_scraiping
## Macの場合
docker run --rm -v $(pwd):/app biccamera_scraiping
```
## 結果のサンプル
```python:ver1_20240831_products.csv
name,price,point
東芝　2ドア冷蔵庫 セミマットブラック GR-V15BS(K) [幅47.9cm /153L /2ドア /右開きタイプ /2023年],"40,780円","4,078ポイント"
パナソニック　冷蔵庫 FVFシリーズ ハーモニーホワイト NR-FVF45S1-W [幅68.5cm /451 /6ドア /観音開きタイプ /2024年] 《基本設置料金セット》,"177,550円","17,755ポイント"
東芝　3ドア冷蔵庫 マットチャコール GR-V33SC(KZ) [幅60cm /326L /3ドア /右開きタイプ /2023年] 《基本設置料金セット》,"95,230円","9,523ポイント"
東芝　冷蔵庫 マットホワイト GR-U36SC-WU [幅60cm /356L /3ドア /右開きタイプ /2022年] 《基本設置料金セット》,"94,800円",948ポイント
...
```

# まとめ
今回は、Dockerを使ってビックカメラの商品情報をスクレイピングする方法を紹介しました。Dockerを使うことで、環境構築が簡単になり、どのOSでも同じ手順で実行できるのが便利です。ぜひ試してみてください.