FLASKとREACTを用いたスクレイピングWebアプリ

スクレイピング

自己紹介

こんにちは、真希(@ShinTech0819)です!

今回が初めてのブログになります! これを書くのに丸1週間かかってしまいました…

ブログを書き始める前は、「僕はそこそこの大学を出てるから、ブログくらいならささっと書けるだろう!」と思っていたら大間違い!

いざ書こうと思っても文章構成も決まらないし、文章自体も酷いものでした…

はっきり言ってセンスがない!

世のブロガーさんたちは凄いな…と思いながら、意地で書き上げました(笑)

これから自分がエンジニアとして学んだことを自分の技術定着のために、また他のエンジニアさんが少しでも助かればという気持ちのもと書いていきますのでよろしくお願いいします!

スクレイピングWebアプリ

今回作ったものは大手ハンドメイドサイトMinneのスクレイピングWebアプリです。

コードはgithub上にアップしていますhttps://github.com/ShintaroE/MinneScrapingUsingReactAndFlask

実際の挙動はこちら

立ち上がった画面の検索欄にminne上で、検索したいキーワードを入れて

データを取得している間待つと(現時点ではここが結構時間かかる…)

Minne内の検索上位の結果が表示されるようになっています!(金額の昇順)

最低金額、平均金額、最高金額はおまけ…

並び替えをいいね順にすると…

いいね順に並び変わるようになっています!(いいねの降順)

スクレイピングWebアプリでしたかったこと

今回作ったWebアプリは以下の機能を持たせました。

Webアプリの機能

✅ Minne商品の検索機能

✅ 検索結果をリスト型にし、商品画像・商品名・価格・評価・レビュー数・いいね数・商品ページURLを表示する機能

✅ 最高金額、最低金額、平均金額を表示する機能

✅ 金額順といいね順に並べ替える機能

これらの機能の実装を実際に見ていきましょう。

コーディング内容

今回使った技術スタックはREACTとFLASKになります。

実際のコーディング内容を見ていきましょう。CSSは省略します。

① REACTのコーディング

import React, { useState } from 'react';
import axios from 'axios';
import './App.css';  // CSSファイルを読み込む

function App() {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(false);
  const [keyword, setKeyword] = useState('');
  const [error, setError] = useState(null);
  const [sortOption, setSortOption] = useState('price'); // デフォルトで金額順

  const fetchProducts = async () => {
    if (keyword.trim() === '') {
      setError('検索ワードを入力してください');
      return;
    }

    setLoading(true);
    setError(null);
    try {
      const response = await axios.get(`http://127.0.0.1:5000/api/search?keyword=${keyword}`, {
        timeout: 10000000 //タイムアウト設定
      });
      setProducts(response.data);
    } catch (error) {
      if (error.code === "ECONNABORTED") {
        console.error('リクエストがタイムアウトしました', error);
      } else {
        console.error('データの取得に失敗しました', error);
      }
    }
    setLoading(false);
  };

  const handleInputChange = (e) => {
    setKeyword(e.target.value);
    setError(null);
  };

  const handleSortChange = (e) => {
    setSortOption(e.target.value);
  };

  const sortedProducts = products.slice().sort((a, b) => {
    if (sortOption === 'price') {
      return parseFloat(a.price.replace(/[^0-9.-]+/g, '')) - parseFloat(b.price.replace(/[^0-9.-]+/g, ''));
    } else if (sortOption === 'favoritecount') {
      return parseInt(b.favoritecount.replace(/[^0-9]/g, '')) - parseInt(a.favoritecount.replace(/[^0-9]/g, ''));
    }
    return 0;
  });

  const prices = products.map(product =>
    parseFloat(product.price.replace(/[^0-9.-]+/g, ''))
  );

  const maxPrice = Math.max(...prices).toLocaleString();
  const minPrice = Math.min(...prices).toLocaleString();
  const avgPrice = (prices.reduce((a, b) => a + b, 0) / prices.length).toFixed(2).toLocaleString();

  return (
    <div className="container">
      <h1 className="title">Minne 商品検索</h1>
      <div className="searchContainer">
        <input 
          type="text" 
          value={keyword} 
          onChange={handleInputChange} 
          placeholder="検索ワードを入力" 
          className="input" 
        />
        <button className="button" onClick={fetchProducts}>検索</button>
      </div>
      {error && <p className="error">{error}</p>}
      <div className="sortContainer">
        <label htmlFor="sort" className="label">並べ替え:</label>
        <select id="sort" value={sortOption} onChange={handleSortChange} className="select">
          <option value="price">金額順</option>
          <option value="favoritecount">いいね数順</option>
        </select>
      </div>
      {loading ? (
        <p>データを取得しています...</p>
      ) : (
        <>
          <div className="stats">
            <p>最高金額: {maxPrice}円</p>
            <p>最低金額: {minPrice}円</p>
            <p>平均金額: {avgPrice}円</p>
          </div>
          <ProductList products={sortedProducts} />
        </>
      )}
    </div>
  );
}

function ProductList({ products }) {
  if (products.length === 0) return null;

  return (
    <div className="listContainer">
      <div className="productList">
        {products.map((product, index) => (
          <div key={index} className="productItem">
            <img src={product.img} alt={product.name} className="productImage" />
            <h3 className="productName">{product.name}</h3>
            <p className="productPrice">価格: {product.price}</p>
            <p className="productRating">評価: {product.ratingcount}</p>
            <p className="productReview">レビュー数: {product.reviewcount}</p>
            <p className="productFavorite">いいね数: {product.favoritecount}</p>
            <a className="productLink" href={product.url} target="_blank" rel="noopener noreferrer">商品ページへ</a>
          </div>
        ))}
      </div>
    </div>
  );
}

export default App;

② FLASKのコーディング

from flask import Flask, request, jsonify
from flask_cors import CORS
import requests
from bs4 import BeautifulSoup
import re
from concurrent.futures import ThreadPoolExecutor

app = Flask(__name__)
CORS(app)  # 全てのオリジンを許可

@app.route('/api/search', methods=['GET'])
def search_kimono():
    print("スクレイピング開始")
    keyword = request.args.get('keyword', '').strip()

    if not keyword:
        return jsonify({'error': '検索ワードが空です。'}), 400

    data = scrape_minne(keyword)
    return jsonify(data)

def scrape_minne(keyword):
    url = f'https://minne.com/category/saleonly?input_method=typing&q={keyword}&commit=%E6%A4%9C%E7%B4%A2'
    headers = {'User-Agent': 'Mozilla/5.0'}
    response = requests.get(url, headers=headers)
    soup = BeautifulSoup(response.content, 'html.parser')

    products = []
    items = soup.find_all('div', class_='MinneProductCardList_list-item__TMXly')
    
    # 並列処理で各商品の詳細ページから情報を取得
    with ThreadPoolExecutor() as executor:
        futures = [executor.submit(get_product_details, item) for item in items]
        for future in futures:
            product = future.result()
            products.append(product)

    print(products)
    return products

def get_product_details(item):
    name = item.find('span', class_='MinneProductCard_product-title-text__IpXRH').get_text()
    price = item.find('div', class_='MinneProductCard_product-price-tag__xWZpW').get_text()
    item_url = "https://minne.com" + item.find('a', class_='MinneProductCard_grid__u4vOU')['href']
    img = item.find('img', src=re.compile('^https://image.minne.com/minne/mobile_app_product/480x480cq85'))['src']
    ratingcount = item.find('span', class_="MinneStarRating_average-rating__L1Vs3").get_text()
    reviewcount = item.find('span', class_="MinneStarRating_reviews-count-pc__a6Qt2").get_text()
    favoritecount = get_favorite(item_url)
    
    return {
        'name': name,
        'price': price,
        'url': item_url,
        'img': img,
        'ratingcount': ratingcount,
        'reviewcount': reviewcount,
        'favoritecount': favoritecount,
    }

def get_favorite(url):
    headers = {'User-Agent': 'Mozilla/5.0'}
    response = requests.get(url, headers=headers)
    soup = BeautifulSoup(response.content, 'html.parser')

    favorite_count = soup.find('div', class_="MinneProductSummary_favorite-count__Xa37W").get_text()

    return favorite_count

if __name__ == '__main__':
    app.run(debug=True)

処理の流れ

useStateは商品一覧・検索のローディング状態・検索キーワード・エラーの種類・並び替えのオプションがあります。

const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(false);
const [keyword, setKeyword] = useState('');
const [error, setError] = useState(null);
const [sortOption, setSortOption] = useState('price'); // デフォルトで金額順

検索ボックスに検索キーワードを入力すると、キーワードをsetKeywordでKeywordに設定し、未入力によるエラーは出ないようにしました。

<input 
type="text" 
value={keyword} 
onChange={handleInputChange} 
placeholder="検索ワードを入力" 
className="input" 
/>
const handleInputChange = (e) => {
    setKeyword(e.target.value);
    setError(null);
  };

その後、検索ボタンを押下すると、ローディングステータスをtrueにし「データを取得しています」と表示させ、FLASKで作ったAPIに対してgetメソッドを送信し検索結果を取得します。取得結果をProductsにセットしたのち、ローディングステータスをfalseにします。

<button className="button" onClick={fetchProducts}>検索</button>
const fetchProducts = async () => {
    if (keyword.trim() === '') {
      setError('検索ワードを入力してください');
      return;
    }

    setLoading(true);
    setError(null);
    try {
      const response = await axios.get(`http://127.0.0.1:5000/api/search?keyword=${keyword}`, {
        timeout: 10000000 //タイムアウト設定
      });
      setProducts(response.data);
    } catch (error) {
      if (error.code === "ECONNABORTED") {
        console.error('リクエストがタイムアウトしました', error);
      } else {
        console.error('データの取得に失敗しました', error);
      }
    }
    setLoading(false);
  };

取得したProductsのデータを指定の並び替えを実施し(金額順もしくはいいね順)、最高金額・最低金額・平均金額を求めます。

const sortedProducts = products.slice().sort((a, b) => {
    if (sortOption === 'price') {
      return parseFloat(a.price.replace(/[^0-9.-]+/g, '')) - parseFloat(b.price.replace(/[^0-9.-]+/g, ''));
    } else if (sortOption === 'favoritecount') {
      return parseInt(b.favoritecount.replace(/[^0-9]/g, '')) - parseInt(a.favoritecount.replace(/[^0-9]/g, ''));
    }
    return 0;
  });

  const prices = products.map(product =>
    parseFloat(product.price.replace(/[^0-9.-]+/g, ''))
  );

  const maxPrice = Math.max(...prices).toLocaleString();
  const minPrice = Math.min(...prices).toLocaleString();
  const avgPrice = (prices.reduce((a, b) => a + b, 0) / prices.length).toFixed(2).toLocaleString();

最後にレンダリングしブラウザ上に表示させます。次にバックエンドのFLASK側を見ていきましょう

FLASKではREACT側からキーワードを受け取り、Minne上でスクレイピングを行い商品情報を取得してきます。ここでは詳しいスクレイピングの方法については割愛します。

はじめにREACTからのアクセスを許可するためにCORSの設定を行いましょう。これをしないとブラウザのCORSポリシーにひっかかります。

app = Flask(__name__)
CORS(app)  # 全てのオリジンを許可

次にREACTからアクセスするエンドポイントの設定を行います。FLASKでルーティング設定を行いAPIを作ります。エンドポイントにアクセスしたあとはscrape_minne関数にキーワードを渡しています。

@app.route('/api/search', methods=['GET'])
def search_kimono():
    print("スクレイピング開始")
    keyword = request.args.get('keyword', '').strip()

    if not keyword:
        return jsonify({'error': '検索ワードが空です。'}), 400

    data = scrape_minne(keyword)
    return jsonify(data)

この流れでスクレイピングをしているのですが、ただ普通にスクレイピングをするだけでは時間がかかりすぎるので、スクレイピングを並列処理することで処理時間を短縮しています。

# 並列処理で各商品の詳細ページから情報を取得
    with ThreadPoolExecutor() as executor:
        futures = [executor.submit(get_product_details, item) for item in items]
        for future in futures:
            product = future.result()
            products.append(product)

最後に

今回はFLASKとREACTでMinneのスクレイピングWebアプリを作りました。

自分で何を作るかを考えて、アウトプットしたものを説明するというのは思ったより勉強になり、エンジニア界隈でテックブログがおすすめされている理由がよくわかりました。

始めたばかりで拙く見にくいブログになっていると思いますが、これからも諦めずに続けていきたい!

コメント

タイトルとURLをコピーしました