見出し画像

34 Python×Djangoで作るホットペッパーグルメのデータ収集アプリの作り方 ~データ取得からExcel出力概要編~

こんにちは!TechCommitメンバーの友季子です♬今回は、「ホットペッパーグルメのビッグデータを取得するDjangoアプリ」について綴ります。
このアプリは、PythonとDjangoを使って、複数文言の検索キーワードを基点にホットペッパーグルメから飲食店のデータを取得し、データを処理するプロジェクトです。
PythonやDjangoに興味がある方にお役に立てればと思い執筆しました!
特に初心者の方でも理解しやすいように解説していますので、ぜひ参考にしてみてくださいね。




0. 前提

このアプリケーションは、Djangoを使用して、ホットペッパーグルメAPIからデータを取得し、それをExcelファイルに出力するものです。開発環境として、Python 3.9以上を使用しています。また、APIキーの取得方法や、ホットペッパーグルメAPIの基本的な使い方も理解していることが前提です。実際に作りたい方はAPI定義書のセクションを熟読なさってくださいね。

※Django版はブルーにしました♪
※抽出リストはこんな感じで出力されます!お電話番号と口コミ数はスクレイピングしました。

1. 全体の完成コード(サンプル)

以下に、今回の1-1アプリケーションの主要なコードを示します。Djangoのview.pyファイルに記述されるコードです。

1-1 view.py

from django.shortcuts import render
from django.http import HttpResponse
import requests
import pandas as pd
from bs4 import BeautifulSoup
import time
import ctypes
from datetime import datetime, timedelta
import os
from io import BytesIO
import random

# ホットペッパーAPIのキーとURLを設定
API_KEY = '634c40hoehoge'
URL = 'http://webservice.recruit.co.jp/hotpepper/gourmet/v1/'

# コンピュータのスリープを防止する関数
def prevent_sleep():
    ctypes.windll.kernel32.SetThreadExecutionState(
        0x80000000 | 0x00000001 | 0x00000040)

# コンピュータのスリープを許可する関数
def allow_sleep():
    ctypes.windll.kernel32.SetThreadExecutionState(0x80000000)

# 中間データをCSVファイルに保存する関数
def save_interim_data(results, file_path, batch_num):
    """中間データをCSVファイルに保存します。"""
    try:
        # 保存するキーを定義し、結果から利用可能なキーを選別
        keys = ['name', '電話番号', 'service_area.name', 'address', 'catch', 'open', 'close', 
                'budget.average', 'capacity', 'genre.name', 'sub_genre.name', 'PC向けURL', '口コミ数']
        available_keys = [key for key in keys if key in results[0]]
        df = pd.DataFrame(results, columns=available_keys)
        df.to_csv(file_path, index=False, mode='a', header=batch_num==0, encoding='utf-8-sig')
        print(f"中間データを保存しました: 行数: {len(results)}")
    except Exception as e:
        print(f"データの保存中にエラーが発生しました: {e}")

# APIリクエストをリトライ付きで実行する関数
def fetch_data_with_retry(params, retries=6, backoff_factor=1):
    """APIリクエストをリトライ付きで実行する"""
    for attempt in range(retries):
        try:
            response = requests.get(URL, params=params, timeout=10)
            response.raise_for_status()
            return response
        except requests.exceptions.RequestException as e:
            print(f"APIリクエストでエラーが発生しました: {e}, リトライ回数: {attempt + 1}")
            if attempt < retries - 1:
                sleep_time = backoff_factor * (2 ** attempt) + random.uniform(0, 1)
                print(f"{sleep_time:.2f}秒後に再試行します...")
                time.sleep(sleep_time)
            else:
                print("リトライの上限に達しました。")
                return None

# データ取得メイン関数
def get_data(keywords, total_count=12000):  # 最大12000件に設定
    """データ取得メイン関数"""
    prevent_sleep()
    keyword_str = ' '.join(keywords)
    all_results = []
    params = {
        'key': API_KEY,
        'keyword': keyword_str,
        'format': 'json',
    }

    quot = total_count // 100
    remain = total_count % 100
    req_cnt = quot + (1 if remain > 0 else 0)

    start_time = datetime.now()
    total_scraping_time = timedelta(0)
    total_api_time = timedelta(0)

    downloads_dir = os.path.expanduser('~/Downloads')
    file_path = os.path.join(downloads_dir, 'interim_data.csv')

    # 中間データの保存用にファイルを開く
    if os.path.exists(file_path):
        os.remove(file_path)

    for i in range(req_cnt):
        if i == req_cnt - 1 and remain > 0:
            count = remain
        else:
            count = 100

        # バッチリクエスト処理のループ
        batch_results = []
        remaining_to_fetch = count  # このバッチで取得する必要がある件数

        while remaining_to_fetch > 0:
            params['count'] = remaining_to_fetch
            params['start'] = i * 100 + 1 + len(batch_results)  # 既に取得した数を考慮した開始位置

            print(f"APIリクエストを送信しています... (Batch {i + 1}/{req_cnt}, 残り: {remaining_to_fetch})")
            api_start_time = datetime.now()
            response = fetch_data_with_retry(params)
            if response is None:
                print("APIリクエストに失敗しました。次のバッチに進みます。")
                break  # リトライ後も失敗した場合は次のバッチに進む

            api_end_time = datetime.now()
            total_api_time += api_end_time - api_start_time

            if response.status_code == 200:
                try:
                    data = response.json()
                    current_batch_results = data.get('results', {}).get('shop', [])
                    batch_results.extend(current_batch_results)

                    # 残りの件数を減らす
                    remaining_to_fetch -= len(current_batch_results)

                    # ここでスクレイピングを実行
                    scraping_start_time = datetime.now()
                    for result in current_batch_results:
                        try:
                            pc_url = result['urls']['pc'].split('?')[0]
                            tel_url = pc_url + 'tel'
                            result['電話番号'] = get_phone_number(tel_url)
                            result['電話番号URL'] = tel_url
                            result['口コミ数'] = get_review_count(pc_url)
                            result['PC向けURL'] = pc_url
                        except Exception as e:
                            print(f"データ取得中にエラーが発生しました(個別店舗処理): {e}")
                    scraping_end_time = datetime.now()
                    total_scraping_time += scraping_end_time - scraping_start_time

                except Exception as e:
                    print(f"APIレスポンスの処理中にエラーが発生しました: {e}")
                    continue
            else:
                print("APIリクエストでエラーが発生しました。ステータスコード:", response.status_code)
                break

        # このバッチで取得したデータを追加
        all_results.extend(batch_results)
        save_interim_data(batch_results, file_path, i)

        print(f"現在の取得件数: {len(all_results)} 件")
        elapsed_time = datetime.now() - start_time
        avg_time_per_batch = (elapsed_time + total_scraping_time + total_api_time) / (i + 1)
        remaining_batches = req_cnt - (i + 1)
        estimated_remaining_time = avg_time_per_batch * remaining_batches
        estimated_completion_time = datetime.now() + estimated_remaining_time
        print(f"予測完了時間: {estimated_completion_time.strftime('%Y-%m-%d %H:%M:%S')}")
        print(f"残り時間: {estimated_remaining_time}\n")

        if len(all_results) < total_count:
            print("1.5秒の休憩中...")
            time.sleep(1.5)

    print(f"データ収集が完了しました。ファイルパス: {file_path}")

    allow_sleep()
    return all_results

# 口コミ数を取得する関数
def get_review_count(url, retries=6):
    """口コミ数を取得します。ネットワークエラーに対してリトライを行います。"""
    review_count_text = "口コミ数なし"
    for attempt in range(retries):
        try:
            response = requests.get(url, timeout=10)
            response.raise_for_status()
            soup = BeautifulSoup(response.text, 'html.parser')
            review_count_elem = soup.find('span', class_='list_01_box_count')
            if review_count_elem:
                review_count_text = review_count_elem.get_text(strip=True)
            break
        except requests.exceptions.RequestException as e:
            print(f"口コミ数取得中にエラーが発生しました: {e}, リトライ回数: {attempt + 1}")
            if attempt < retries - 1:
                sleep_time = 2 + random.uniform(0, 1)
                print(f"{sleep_time:.2f}秒後に再試行します...")
                time.sleep(sleep_time)
            else:
                print("口コミ数の取得リトライ上限に達しました。")
    return review_count_text

# 電話番号を取得する関数
def get_phone_number(url, retries=6):
    """電話番号を取得します。ネットワークエラーに対してリトライを行います。"""
    phone_number = "電話番号なし"
    for attempt in range(retries):
        try:
            response = requests.get(url, timeout=10)
            response.raise_for_status()
            soup = BeautifulSoup(response.text, 'html.parser')
            phone_number_elem = soup.find('p', class_='tel_number')
            if phone_number_elem:
                phone_number = phone_number_elem.get_text(strip=True)
            break
        except requests.exceptions.RequestException as e:
            print(f"電話番号取得中にエラーが発生しました: {e}, リトライ回数: {attempt + 1}")
            if attempt < retries - 1:
                sleep_time = 2 + random.uniform(0, 1)
                print(f"{sleep_time:.2f}秒後に再試行します...")
                time.sleep(sleep_time)
            else:
                print("電話番号の取得リトライ上限に達しました。")
    return phone_number

def download_excel(request):
    """DjangoビューからのExcelファイルのダウンロード"""
    # 使用するキーワード
    keywords = ['ラーメン', 'カフェ', 'イタリアン']  
    results = get_data(keywords)

    # 取得データをPandas DataFrameに変換
    keys = ['name', '電話番号', 'service_area.name', 'address', 'catch', 'open', 'close', 
            'budget.average', 'capacity', 'genre.name', 'sub_genre.name', 'PC向けURL', '口コミ数']
    df = pd.DataFrame(results, columns=[key for key in keys if key in results[0]])

    # Excelファイルに書き込む
    excel_file = BytesIO()
    df.to_excel(excel_file, index=False, encoding='utf-8-sig')

    # ダウンロード用にレスポンスを作成
    response = HttpResponse(excel_file.getvalue(), content_type='application/vnd.ms-excel')
    response['Content-Disposition'] = 'attachment; filename=store_data.xlsx'
    return response

1-2 index.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>HotPepper 検索</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            background-color: #f0f8ff;
            color: #333;
            margin: 0;
            padding: 0;
            line-height: 1.6;
        }
        header {
            background-color: #007bff;
            color: #fff;
            padding: 10px 0;
            text-align: center;
            position: relative;
        }
        header .powered-by {
            position: absolute;
            bottom: 10px;
            right: 10px;
            font-size: 12px;
            color: #e0e0e0;
        }
        h1 {
            margin: 0;
        }
        .container {
            width: 80%;
            margin: 0 auto;
            padding: 20px;
        }
        form {
            margin-bottom: 20px;
            background-color: #fff;
            padding: 15px;
            border-radius: 8px;
            box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
        }
        label {
            display: block;
            margin: 10px 0 5px;
        }
        input[type="text"],
        input[type="number"] {
            width: 100%;
            padding: 8px;
            border: 1px solid #ddd;
            border-radius: 4px;
            box-sizing: border-box;
            margin-bottom: 10px;
        }
        button {
            background-color: #007bff;
            color: #fff;
            border: none;
            padding: 10px 15px;
            border-radius: 4px;
            cursor: pointer;
            font-size: 16px;
        }
        button:hover {
            background-color: #0056b3;
        }
        table {
            width: 100%;
            border-collapse: collapse;
            margin-top: 20px;
            background-color: #fff;
            box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
            border-radius: 8px;
            overflow: hidden;
        }
        th,
        td {
            padding: 12px;
            border: 1px solid #ddd;
            text-align: left;
        }
        th {
            background-color: #007bff;
            color: #fff;
        }
        tr:nth-child(even) {
            background-color: #f9f9f9;
        }
        a {
            color: #007bff;
            text-decoration: none;
        }
        a:hover {
            text-decoration: underline;
        }
        .results-container {
            margin-top: 20px;
        }
        .error-message {
            color: #dc3545;
            font-weight: bold;
        }
    </style>
</head>
<body>
    <header>
        <h1>HotPepper 検索</h1>
        <div class="powered-by">Powered by ホットペッパーグルメ Webサービス</div>
    </header>
    <div class="container">
        <form method="POST">
            {% csrf_token %}
            <label for="keywords">検索キーワード:</label>
            <input type="text" id="keywords" name="keywords" required>
            <label for="count">最大取得件数:</label>
            <input type="number" id="count" name="count" value="10000">
            <button type="submit">検索</button>
        </form>

        {% if results %}
        <div class="results-container">
            <h2>検索結果</h2>
            <form method="POST" action="{% url 'download_excel' %}">
                {% csrf_token %}
                <button type="submit">Excelファイルをダウンロード</button>
            </form>
            <table>
                <thead>
                    <tr>
                        <th>店舗名</th>
                        <th>電話番号</th>
                        <th>サービスエリア名</th>
                        <th>住所</th>
                        <th>キャッチコピー</th>
                        <th>営業時間</th>
                        <th>定休日</th>
                        <th>ディナー予算</th>
                        <th>総席数</th>
                        <th>ジャンル名</th>
                        <th>サブジャンル名</th>
                        <th>PC向けURL</th>
                        <th>口コミ数</th>
                    </tr>
                </thead>
                <tbody>
                    {% for result in results %}
                    <tr>
                        <td>{{ result.name }}</td>
                        <td>{{ result.電話番号 }}</td>
                        <td>{{ result.service_area.name }}</td>
                        <td>{{ result.address }}</td>
                        <td>{{ result.catch }}</td>
                        <td>{{ result.open }} - {{ result.close }}</td>
                        <td>{{ result.close }}</td>
                        <td>{{ result.budget.average }}</td>
                        <td>{{ result.capacity }}</td>
                        <td>{{ result.genre.name }}</td>
                        <td>{{ result.sub_genre.name }}</td>
                        <td><a href="{{ result.PC向けURL }}" target="_blank">詳細</a></td>
                        <td>{{ result.口コミ数 }}</td>
                    </tr>
                    {% endfor %}
                </tbody>
            </table>
        </div>
        {% elif request.method == 'POST' %}
        <div class="error-message">
            ダウンロードできるデータがありません。
        </div>
        {% endif %}
    </div>
</body>
</html>

2. コードの詳細解説

このコードでは、Djangoのビューを使用してホットペッパーAPIからデータを取得し、それを処理してExcelファイルとしてダウンロードします。特に、APIリクエストのリトライ機能、データ取得の進行状況の表示、そして中間データの保存機能が含まれています。

主な機能の説明

  1. リトライ機能 (fetch_data_with_retry関数):

    • APIリクエストが失敗したときに、指定した回数(ここでは3回まで)リトライします。リトライ間隔は、失敗ごとに少しずつ長くなるように設定されています。

  2. 進行状況表示 (get_data関数):

    • データを取得している最中に、ターミナルに「現在の取得件数」や「予測完了時間」などを表示します。これにより、どのくらいの時間がかかるのかを確認しながら作業を進めることができます。

  3. 中間データの保存 (save_interim_data関数):

    • データを部分的に取得するたびに、そのデータを「interim_data.csv」というファイルに保存します。これによって、途中で何か問題があっても、それまでに取得したデータは保存されます。

  4. 電話番号と口コミ数の取得 (get_phone_number関数 & get_review_count関数):

    • 各店舗の詳細情報ページから電話番号と口コミ数を取得します。電話番号が無い場合や、口コミ数が取得できない場合は、その旨を表示します。

3. コードの流れ

  • prevent_sleep: スリープを防止します。

  • fetch_data_with_retry: APIリクエストをリトライ付きで実行します。

  • get_data: キーワードに基づいてデータを取得し、中間データを保存しながら全件取得します。

  • get_review_countとget_phone_number: それぞれ口コミ数と電話番号を取得します。

  • download_excel: 取得したデータをExcelファイルに変換し、Djangoビューからダウンロードさせます。


4. コードの技術的解説

  • APIリクエストのリトライ: APIリクエストでエラーが発生した際に、指定された回数だけリトライを行い、リトライ間隔を指数的に増加させることで、APIサーバーへの負担を軽減しながら信頼性を確保します。

  • データ取得の進行状況: 各バッチでのデータ取得件数、進行中のバッチ番号、残りバッチ数、予測終了時間をターミナルに表示することで、デバッグや進捗確認を容易にします。

  • データ保存: 中間データをCSV形式で保存し、アプリケーションの途中で予期せぬ終了が発生しても、既に取得したデータを失うことがないようにしています。


5. API定義書

Powered by ホットペッパーグルメ Webサービス


6. おわりに

この記事では、DjangoとホットペッパーAPIを使用して、Webデータを取得しExcelファイルに保存する方法を解説しました。APIリクエストのリトライ機能や進行状況の表示、そしてデータの中間保存といったポイントを押さえることで、信頼性の高いデータ収集が可能になります。

もしこの記事が少しでもお役に立てれば幸いです。皆さんもぜひ、この記事を参考にDjangoアプリケーションを作ってみてくださいね!

いいなと思ったら応援しよう!