MENU

【副業スキル】Pythonで物件情報を自動収集|スクレイピングツール開発で月5万円を目指す

目次

はじめに

引越し先を探すとき、SUUMOで気になる物件を何件も見つけても、比較するのって大変じゃないですか?

  • タブを大量に開いて行ったり来たり…
  • 手作業でExcelに転記して比較表を作る…
  • 敷金・礼金・管理費など細かい条件を見落とす…

「この作業、自動化できたらラクなのに」と思ったことありませんか?

実は、Pythonでスクレイピングツールを作れば、この面倒な作業を一瞬で終わらせることができます。しかも、このスキルを身につければ、クラウドソーシングでスクレイピング案件に応募できるようになるんです。

この記事でわかること

この記事では、SUUMOの物件情報を自動取得するWebアプリを実際に作りながら、副業で使えるスクレイピングスキルを身につける方法を解説します。

リポジトリ: https://github.com/yamato-snow/2025-12-07_sumo-scraping


このツールを作ると何が身につく?

「スクレイピングって難しそう…」と思うかもしれませんが、このツールを作り終える頃には、以下のスキルが身についています。

1. スクレイピング案件に応募できる実力

クラウドソーシングサイトを見ると、「Webサイトから情報を収集してほしい」という案件が山ほどあります。

  • 競合サイトの価格情報を収集
  • 求人情報を定期的に取得
  • ECサイトの商品データを抽出

これらの案件に必要なスキルが、このツールを作ることで一通り身につきます。

2. Webアプリ開発の基礎

スクレイピングだけでなく、Flaskを使ったWebアプリ開発の基礎も学べます。

  • APIエンドポイントの設計
  • フロントエンドとバックエンドの連携
  • リアルタイム通信(Server-Sent Events)

3. ポートフォリオになる実績

GitHubにリポジトリを公開すれば、それがそのままポートフォリオになります。「実際に動くものを作った」という実績は、案件獲得において強力な武器になりますよね。


完成形のデモ

まずは、このツールで何ができるのかを見てみましょう。

主な機能

  1. 物件情報の自動抽出 – SUUMOの物件URLから詳細情報を取得
  2. お気に入り一括取得 – SUUMOアカウントでログインして保存済み物件を自動取得
  3. 複数フォーマットでエクスポート – Excel/CSV/JSON 3形式に対応
  4. リアルタイム進捗表示 – Server-Sent Events で処理状況をリアルタイム表示

取得できる情報(全31項目)

カテゴリ取得項目
基本情報URL、物件名、所在地
費用賃料・初期費用、管理費・共益費、敷金、礼金、保証金、敷引・償却
物件詳細間取り、専有面積、向き、建物種別、築年数
アクセス最寄り駅(最大3つ)、徒歩時間
その他損保、駐車場、仲介手数料、保証会社、ほか初期費用、ほか諸費用、備考

手作業で30分かかる比較表作成が、ボタン1つで3分で完了します。


使用技術と副業市場での需要

このツールで使っている技術と、副業市場での需要を見てみましょう。

領域技術役割副業案件での需要
BackendFlask 3.xWebフレームワーク高(軽量API開発に最適)
FrontendBootstrap 5 + Vanilla JSUI中(基礎知識として必須)
スクレイピングBeautifulSoup4 + RequestsHTML解析・HTTP通信非常に高(案件の9割がこれ)
ブラウザ自動化Selenium + webdriver-managerログイン処理高(動的サイト対応に必須)
データ出力pandas + openpyxlExcel/CSV/JSON出力高(納品形式として必須)

クラウドソーシングでの案件相場

参考までに、スクレイピング案件の相場を紹介します。

  • 単発案件: 1〜5万円(サイト1つから特定情報を抽出)
  • 定期収集案件: 月3〜10万円(週次・日次で情報を更新)
  • ツール開発案件: 10〜30万円(汎用的なスクレイピングツール作成)

このツールを作れるレベルになれば、月5万円程度の副業収入は十分に目指せます。

なぜこの組み合わせを選んだのか?

Flask を選んだ理由は、シンプルさです。Djangoのような大規模フレームワークはこの規模のツールにはオーバースペックです。APIエンドポイントの定義、SSEの実装、ファイルダウンロードなど、必要な機能をシンプルに実装できます。

BeautifulSoup4 は、HTMLの解析においてCSSセレクタとDOM操作の両方に対応しており、直感的に記述できます。Scrapy のようなフレームワークは今回のようなシンプルな用途には過剰です。

Selenium は、ログイン処理に必要です。SUUMOのお気に入り機能を利用するにはログインが必要であり、JavaScriptで動的に生成されるページを扱うにはブラウザ自動化が必要になります。


作る前に絶対知っておくべきこと

スクレイピングは便利な技術ですが、法的リスクや倫理的な問題を理解せずに行うと、トラブルに発展する可能性があります

副業でスクレイピング案件を受ける前に、必ず以下の内容を理解しておいてください。これを知らないと、最悪の場合、犯罪になります

1. 法的リスクを理解する

スクレイピングに関連する主な法律は以下の3つです。

不正アクセス禁止法

アクセス制限がかけられているシステムに、その制限を回避してアクセスする行為は違法です。

  • ログイン認証の突破: 本ツールのお気に入り取得機能は、ユーザー自身のアカウントでログインする機能です。他人のアカウントを使用したり、認証を回避する行為は違法です
  • CAPTCHA回避: CAPTCHA(画像認証)を自動的に突破する行為は不正アクセスとみなされる可能性があります
  • レート制限の回避: サーバー側で設定されているアクセス制限を意図的に回避する行為も問題になり得ます

著作権法

ウェブサイトのコンテンツは著作物として保護されています。

  • 私的利用の範囲: 個人的に情報を整理・比較する目的であれば、著作権法の「私的使用のための複製」に該当する可能性があります
  • 再配布・商用利用は禁止: 取得したデータを第三者に配布したり、商用目的で利用することは著作権侵害になります
  • データベースの著作権: 物件情報の集合体としてのデータベースにも著作権が発生する場合があります

業務妨害罪(偽計業務妨害・電子計算機損壊等業務妨害)

サーバーに過度な負荷をかけ、サービスの運営を妨害する行為は犯罪です。

  • 大量リクエスト: 短時間に大量のリクエストを送信し、サーバーをダウンさせる行為
  • 継続的な高負荷: 長時間にわたって高頻度でアクセスし続ける行為

過去の判例: 2010年の岡崎市立中央図書館事件では、図書館のウェブサイトに対する自動アクセスが業務妨害として問題になりました(最終的に不起訴)。技術的には問題のないアクセス頻度でも、相手側のシステムによっては問題視される可能性があります。

2. 事前に確認すべきこと

スクレイピングを実行する前に、必ず以下を確認してください。

robots.txt の確認

robots.txtは、ウェブサイトがクローラー(自動アクセスプログラム)に対してアクセス可否を示すファイルです。

SUUMOのrobots.txt(https://suumo.jp/robots.txt)の主な内容:

User-agent: *
# 編集・管理関連
Disallow: /edit/rewrite/
Disallow: /edit/include
Disallow: /edit/ssi
Disallow: /edit/error/error.html

# モバイル
Disallow: /mb/

# 内部パーツ(JS/CSS/API)
Disallow: /jj/kaisha/top/JJ052FA001/
Disallow: /jj/chintai/common/JJ901FI396/
# ... 多数のjjパスがDisallow

User-agent: bingbot
Crawl-delay: 30        # 30秒の待機を推奨
Allow: /journal/

SUUMOのrobots.txtの重要ポイント:

項目内容
Disallowされているパス/edit//mb//jj/配下の多数のパス、印刷ページ、API、内部機能
賃貸物件詳細ページ/chintai/tokyo/sc_xxxxx/のような通常の物件ページは明示的にDisallowされていない
Crawl-delaybingbotには30秒の待機が指定されている

重要: Crawl-delay: 30 – bingbotには30秒の待機が指定されています。これはSUUMOがクローラーに対して「30秒間隔でアクセスしてほしい」という意思表示です。一般的なスクレイピングでも、この値を参考に十分な間隔を空けるべきです。

利用規約の確認

SUUMOの利用規約では、以下の点が明記されています。

第2条(著作権):

本サイトを通じて提供されるすべてのコンテンツについて、当社の事前の承諾なく著作権法で定めるユーザー個人の私的利用の範囲を超える使用をしてはならない

第3条(禁止行為):

(2) 他のユーザーまたは第三者の著作権、肖像権、その他知的財産権を侵害する行為
(7) 商業目的で利用する行為(当社が認める場合を除く)

SUUMOの利用規約のポイント:

  • スクレイピング自体を明示的に禁止する条項は見当たらない
  • ただし「私的利用の範囲を超える使用」は禁止されている
  • 商業目的での利用」は明確に禁止されている

つまり、個人的な物件比較のために数件〜数十件の情報を取得する程度であれば「私的利用の範囲」と解釈できる可能性がありますが、大量のデータ収集や商用利用は明確に利用規約違反となります。

免責事項: 本ツールは技術的な学習・研究目的で作成されています。実際にスクレイピングを行う場合は、必ずご自身で利用規約を確認し、自己責任で判断してください。利用規約に違反した場合のトラブルについて、作者は一切の責任を負いません。

3. スクレイピングのマナー(ベストプラクティス)

法的・倫理的に問題のないスクレイピングを行うためのガイドラインです。副業案件を受ける際も、これらを守ることが信頼につながります。

アクセス間隔を十分に空ける

# 悪い例:待機なし
for url in urls:
    scrape(url)  # サーバーに過大な負荷

# 良い例:十分な間隔を空ける
import time
for url in urls:
    scrape(url)
    time.sleep(5)  # 5秒以上待機を推奨

推奨間隔の目安:

サイトの指定推奨間隔
Crawl-delay指定あり指定値以上(SUUMOは30秒)
Crawl-delay指定なし最低5秒以上
保守的に運用する場合10〜30秒

SUUMOの場合: robots.txtでbingbotに対してCrawl-delay: 30が指定されています。本ツールでは最低2秒の間隔を設定していますが、より安全に運用する場合は10〜30秒の間隔を推奨します。

同時接続数を制限する

複数スレッドで同時にアクセスする場合は、同時接続数を制限してください。

from concurrent.futures import ThreadPoolExecutor

# 同時接続数を2〜4に制限
executor = ThreadPoolExecutor(max_workers=2)

適切なUser-Agentを設定する

自分が何者かを明示することは、最低限のマナーです。

headers = {
    'User-Agent': 'Mozilla/5.0 (compatible; MyBot/1.0; +http://example.com/bot)'
}

アクセス時間帯に配慮する

  • 深夜〜早朝(サーバー負荷が低い時間帯)にアクセスする
  • サービスの繁忙期(引越しシーズンなど)は避ける

必要最小限のデータのみ取得する

  • 不要なページにはアクセスしない
  • 一度取得したデータはキャッシュして再利用する
  • 全ページを網羅的に取得するような行為は避ける

4. 副業案件でスクレイピングを行う際の注意

副業でスクレイピング案件を受ける場合、特に以下の点に注意してください。

受けてはいけない案件:

  • 「競合サイトのデータを全件取得してほしい」→ 著作権侵害・業務妨害のリスク
  • 「ログイン認証をバイパスしてほしい」→ 不正アクセス禁止法違反
  • 「規約違反だけど気にしないで」→ あなたが責任を負う可能性

受ける前に確認すること:

  • 対象サイトのrobots.txt
  • 対象サイトの利用規約
  • 取得データの利用目的
  • クライアントの正当性

プロジェクト構成

Project_Suumo/
├── app/                    # Flaskアプリケーション
│   ├── __init__.py         # アプリファクトリ
│   ├── routes.py           # APIエンドポイント定義
│   ├── scraper/            # スクレイピングモジュール
│   │   ├── suumo.py        # 物件情報スクレイパー
│   │   └── auth.py         # Selenium認証モジュール
│   ├── exporters/          # エクスポートモジュール
│   │   └── exporter.py     # Excel/CSV/JSON出力
│   ├── templates/          # HTMLテンプレート
│   │   ├── base.html
│   │   └── index.html
│   └── static/css/         # スタイルシート
├── config.py               # Flask設定
├── run.py                  # エントリーポイント
├── requirements.txt        # 依存パッケージ
└── tests/                  # テストコード

セットアップ方法

前提条件

  • Python 3.11以上
  • Google Chrome(お気に入り取得機能を使う場合)

インストール手順

# 1. リポジトリをクローン
git clone https://github.com/yamato-snow/2025-12-07_sumo-scraping.git
cd 2025-12-07_sumo-scraping

# 2. 仮想環境をセットアップ
python -m venv .venv
source .venv/bin/activate  # macOS/Linux
# .venv\Scripts\activate   # Windows

# 3. 依存関係をインストール
pip install -r requirements.txt

# 4. アプリを起動
python run.py

ブラウザで http://localhost:5001 にアクセスすると、Webインターフェースが表示されます。

依存パッケージ

# Flask Web Framework
flask>=3.0.0
flask-cors>=4.0.0

# Data Processing
pandas>=2.0.0
openpyxl>=3.1.0

# Web Scraping
beautifulsoup4>=4.12.0
requests>=2.31.0
selenium>=4.15.0
webdriver-manager>=4.0.0

# Testing
pytest>=7.0.0

使い方

方法1: URL手動入力モード

  1. テキストエリアにSUUMO物件URLを1行ずつ入力
  2. 出力形式(Excel/CSV/JSON)を選択
  3. 「スクレイピング開始」ボタンをクリック
  4. 処理完了後、ダウンロードボタンから結果を取得

方法2: お気に入り取得モード

  1. 「お気に入りから取得」タブを選択
  2. SUUMOアカウントのメールアドレスとパスワードを入力
  3. 「お気に入りを取得」ボタンをクリック
  4. 取得されたURLが自動でテキストエリアに入力される
  5. 以降は方法1と同様

主要なコードのポイント

ここからは、実装の詳細を解説します。副業案件でスクレイピングツールを作る際にも使えるテクニックばかりなので、ぜひ参考にしてください。

1. スクレイピング処理(suumo.py)

物件情報のスクレイピングを担当するモジュールです。

PropertyData データクラス

dataclassを使って、物件情報を型安全に管理しています。

from dataclasses import dataclass, field
from typing import Dict, Any, List

@dataclass
class PropertyData:
    """Data class for property information"""
    url: str = ""
    property_name: str = ""
    rent_cost: str = ""
    management_fee: str = ""
    deposit: str = ""      # 敷金
    key_money: str = ""    # 礼金
    guarantee_money: str = ""
    depreciation: str = ""
    layout: str = ""
    area: str = ""
    direction: str = ""
    building_type: str = ""
    age: str = ""
    access_info: List[str] = field(default_factory=list)
    location: str = ""
    features: str = ""
    table_data: Dict[str, str] = field(default_factory=dict)

    def to_dict(self) -> Dict[str, Any]:
        """Convert to dictionary for Excel export"""
        return {
            'URL': self.url,
            '物件名': self.property_name,
            '賃料・初期費用': self.rent_cost,
            '管理費・共益費': self.management_fee,
            '敷金': self.deposit,
            '礼金': self.key_money,
            # ... 以下省略
        }

dataclassを使うメリットは以下の通りです。

  • __init____repr____eq__が自動生成される
  • 型ヒントによりIDEの補完が効く
  • デフォルト値の設定が簡単

URL検証

SUUMOのURLかどうかを正規表現でチェックしています。

import re

class SuumoScraper:
    SUUMO_URL_PATTERN = re.compile(r'^https?://suumo\.jp/(?:chintai|juhan)/.*')

    @staticmethod
    def validate_url(url: str) -> bool:
        """Validate if URL is a SUUMO property URL"""
        if not url:
            return False
        return bool(SuumoScraper.SUUMO_URL_PATTERN.match(url))

chintai(賃貸)とjuhan(中古住宅)の両方に対応しています。

HTML解析のポイント

BeautifulSoup4を使ったHTML解析の実装例です。

from bs4 import BeautifulSoup
import requests

class SuumoScraper:
    DEFAULT_HEADERS = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
    }
    REQUEST_TIMEOUT = 10

    def __init__(self):
        self.session = requests.Session()
        self.session.headers.update(self.DEFAULT_HEADERS)

    def scrape_property(self, url: str) -> Optional[PropertyData]:
        """Extract property information from a single URL"""
        if not self.validate_url(url):
            return None

        response = self.session.get(url, timeout=self.REQUEST_TIMEOUT)
        if response.status_code != 200:
            return None

        soup = BeautifulSoup(response.content, 'html.parser')

        # 各情報を抽出
        property_data = PropertyData(
            url=url,
            property_name=self._extract_property_name(soup),
            rent_cost=self._extract_rent_cost(soup),
            # ... 以下省略
        )
        return property_data

ポイント:

  • requests.Session()を使うことで、接続の再利用やCookieの維持が可能
  • User-Agentを設定しないとブロックされる場合がある
  • タイムアウトを設定してハングアップを防止

情報抽出メソッド

SUUMOのHTML構造に合わせてCSSセレクタで情報を抽出します。

def _extract_property_name(self, soup: BeautifulSoup) -> str:
    """Extract property name"""
    tag = soup.find('h1', class_='section_h1-header-title')
    return self.clean_text(tag.text) if tag else '物件名が見つかりません'

def _extract_deposit_key_money(self, soup: BeautifulSoup) -> tuple:
    """Extract deposit and key money separately"""
    tag = soup.find('div', class_='property_data-title', string='敷金/礼金')
    if tag:
        body_tag = tag.find_next('div', class_='property_data-body')
        if body_tag:
            text = self.clean_text(body_tag.text)
            # "敷金 / 礼金" 形式をパース(例: "- / 6.45万円")
            if '/' in text:
                parts = text.split('/')
                deposit = parts[0].strip() if len(parts) > 0 else '-'
                key_money = parts[1].strip() if len(parts) > 1 else '-'
                return (deposit, key_money)
    return ('情報なし', '情報なし')

find_next()を使って、特定のラベル要素の次にある値要素を取得するパターンは、HTMLテーブルの解析でよく使います。

テーブルデータの汎用抽出

SUUMOの物件ページには複数のテーブルがあり、それらを汎用的に抽出するメソッドです。

def _extract_table_data(self, soup: BeautifulSoup) -> Dict[str, str]:
    """Extract table data"""
    extracted_data: Dict[str, str] = {}

    for row in soup.select('tr'):
        headers = row.find_all('th')
        values = row.find_all('td')

        if len(headers) == 2 and len(values) >= 2:
            # 2列テーブルの場合
            key1 = headers[0].get_text(strip=True)
            key2 = headers[1].get_text(strip=True)
            value1 = values[0].get_text(strip=True)
            value2 = values[1].get_text(strip=True)
            extracted_data[key1] = value1
            extracted_data[key2] = value2
        elif len(headers) == 1 and len(values) >= 1:
            # 1列テーブルの場合
            key = headers[0].get_text(strip=True)
            if values[0].find('li'):
                # リスト形式の場合は結合
                value = ' '.join(li.get_text(strip=True) for li in values[0].find_all('li'))
            else:
                value = values[0].get_text(strip=True)
            extracted_data[key] = value

    return extracted_data

このメソッドにより、テーブル形式で表示されている情報(損保、駐車場、仲介手数料など)を一括で取得できます。

2. Selenium認証モジュール(auth.py)

SUUMOへのログインとお気に入り物件の取得を担当します。

WebDriverの設定

from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager

class SuumoAuth:
    def _create_driver(self) -> webdriver.Chrome:
        """Create WebDriver"""
        options = Options()

        if self.headless:
            options.add_argument("--headless=new")

        options.add_argument("--incognito")
        options.add_argument("--no-sandbox")
        options.add_argument("--disable-dev-shm-usage")
        options.add_argument("--disable-gpu")
        options.add_argument("--window-size=1920,1080")
        options.add_argument(
            "--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
            "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
        )

        service = Service(ChromeDriverManager().install())
        return webdriver.Chrome(service=service, options=options)

設定のポイント:

  • --headless=new: 新しいヘッドレスモード(Chrome 112以降)
  • --no-sandbox: Docker環境などで必要
  • --disable-dev-shm-usage: 共有メモリ問題の回避
  • webdriver-manager: ChromeDriverのバージョン管理を自動化

ログイン処理

from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

def login(self, email: str, password: str) -> bool:
    """Login to SUUMO"""
    self.driver = self._create_driver()
    self.driver.get(self.LOGIN_URL)

    # メールアドレスを入力
    email_input = WebDriverWait(self.driver, self.ELEMENT_TIMEOUT).until(
        EC.presence_of_element_located((By.ID, "mainEmail"))
    )
    email_input.clear()
    email_input.send_keys(email)

    # パスワードを入力してログイン
    password_input = WebDriverWait(self.driver, self.ELEMENT_TIMEOUT).until(
        EC.presence_of_element_located((By.ID, "passwordText"))
    )
    password_input.clear()
    password_input.send_keys(password)
    password_input.send_keys(Keys.RETURN)  # Enterキーでログイン

    # ログイン完了を待機
    time.sleep(self.LOGIN_WAIT_TIME)

    # ログイン成功の確認
    if "login" in self.driver.current_url.lower():
        # まだログインページにいる場合はエラー
        error_elements = self.driver.find_elements(By.CLASS_NAME, "error")
        if error_elements:
            raise SuumoAuthError(f"Login failed: {error_elements[0].text}")

    return True

ポイント:

  • WebDriverWaitexpected_conditionsを使った明示的な待機
  • time.sleep()は避けたいところですが、ログイン処理後のリダイレクト待機には必要な場合がある
  • エラーハンドリングで失敗時の原因を明確に

Context Manager対応

with文で使えるようにすることで、リソースの確実な解放を保証します。

def __enter__(self):
    return self

def __exit__(self, exc_type, exc_val, exc_tb):
    self.close()
    return False

def close(self):
    """Close WebDriver"""
    if self.driver:
        try:
            self.driver.quit()
        finally:
            self.driver = None

使用例:

with SuumoAuth(headless=True) as auth:
    auth.login(email, password)
    urls = auth.get_favorite_urls()
# 自動的にWebDriverが閉じられる

3. エクスポート処理(exporter.py)

pandas を使ったデータ出力の実装です。

import io
import json
from enum import Enum
import pandas as pd

class ExportFormat(Enum):
    """Export format enum"""
    EXCEL = "excel"
    CSV = "csv"
    JSON = "json"

class DataExporter:
    @staticmethod
    def export_to_excel(data: List[Dict[str, Any]]) -> io.BytesIO:
        """Export data to Excel format"""
        df = pd.DataFrame(data)
        output = io.BytesIO()
        with pd.ExcelWriter(output, engine='openpyxl') as writer:
            df.to_excel(writer, index=False, sheet_name='Property Data')
        output.seek(0)
        return output

    @staticmethod
    def export_to_csv(data: List[Dict[str, Any]], encoding: str = 'utf-8-sig') -> io.BytesIO:
        """Export data to CSV format"""
        df = pd.DataFrame(data)
        output = io.BytesIO()
        df.to_csv(output, index=False, encoding=encoding)
        output.seek(0)
        return output

    @staticmethod
    def export_to_json(data: List[Dict[str, Any]], indent: int = 2) -> io.BytesIO:
        """Export data to JSON format"""
        output = io.BytesIO()
        json_str = json.dumps(data, ensure_ascii=False, indent=indent)
        output.write(json_str.encode('utf-8'))
        output.seek(0)
        return output

ポイント:

  • io.BytesIOを使うことで、一時ファイルを作成せずにメモリ上でファイルを生成
  • CSVはutf-8-sig(BOM付きUTF-8)でExcelでの文字化けを防止
  • JSONはensure_ascii=Falseで日本語をそのまま出力

4. Server-Sent Events(SSE)による進捗表示

リアルタイム進捗表示の実装です。WebSocketよりも実装がシンプルで、一方向の通知に適しています。

バックエンド実装(routes.py)

from concurrent.futures import ThreadPoolExecutor
from flask import Response, jsonify
import threading
import json

# タスク管理
tasks: Dict[str, Dict[str, Any]] = {}
tasks_lock = threading.Lock()
executor = ThreadPoolExecutor(max_workers=4)

@api_bp.route('/scrape', methods=['POST'])
def start_scrape():
    """Start scraping"""
    # ... URL検証等の処理 ...

    # タスクIDを生成
    task_id = str(uuid.uuid4())

    # タスク情報を初期化
    tasks[task_id] = {
        'status': 'pending',
        'urls': urls,
        'progress': 0,
        'total': len(urls),
        'current_url': '',
        'results': [],
        'errors': [],
    }

    # バックグラウンドでスクレイピング実行
    executor.submit(run_scraping_task, task_id)

    return jsonify({'task_id': task_id, 'total': len(urls)})


@api_bp.route('/scrape/stream/<task_id>')
def scrape_stream(task_id: str):
    """Server-Sent Events for scraping progress"""
    if task_id not in tasks:
        return jsonify({'error': 'Task not found'}), 404

    def generate() -> Generator[str, None, None]:
        last_progress = -1

        while True:
            task = tasks.get(task_id)
            if not task:
                yield format_sse({'type': 'error', 'message': 'Task not found'})
                break

            # 進捗が更新されたら送信
            if task['progress'] != last_progress or task['status'] in ['completed', 'error']:
                last_progress = task['progress']

                data = {
                    'type': 'progress',
                    'status': task['status'],
                    'progress': task['progress'],
                    'total': task['total'],
                    'current_url': task['current_url'],
                    'errors': task['errors']
                }

                yield format_sse(data)

                if task['status'] in ['completed', 'error']:
                    break

            time.sleep(0.5)

    return Response(
        generate(),
        mimetype='text/event-stream',
        headers={
            'Cache-Control': 'no-cache',
            'Connection': 'keep-alive',
            'X-Accel-Buffering': 'no'  # Nginx用
        }
    )


def format_sse(data: Dict[str, Any]) -> str:
    """Format data for SSE"""
    return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"

SSEのポイント:

  • Content-Type: text/event-streamで接続を維持
  • データはdata:プレフィックスと\n\n(2つの改行)で区切る
  • X-Accel-Buffering: noでNginxのバッファリングを無効化

フロントエンド実装

const eventSource = new EventSource(`/api/scrape/stream/${taskId}`);

eventSource.onmessage = function(event) {
    const data = JSON.parse(event.data);

    if (data.type === 'progress') {
        // プログレスバーを更新
        const percentage = (data.progress / data.total) * 100;
        progressBar.style.width = `${percentage}%`;
        progressBar.textContent = `${data.progress}/${data.total}`;

        // 現在処理中のURLを表示
        currentUrlText.textContent = data.current_url;

        if (data.status === 'completed') {
            eventSource.close();
            // ダウンロードボタンを有効化
        }
    }
};

eventSource.onerror = function(error) {
    eventSource.close();
    // エラー処理
};

5. スクレイピングタスクの並列実行

from concurrent.futures import ThreadPoolExecutor

executor = ThreadPoolExecutor(max_workers=4)

def run_scraping_task(task_id: str) -> None:
    """Run scraping in background"""
    task = tasks.get(task_id)
    if not task:
        return

    task['status'] = 'running'
    scraper = SuumoScraper()

    for i, url in enumerate(task['urls']):
        task['current_url'] = url
        task['progress'] = i

        try:
            result = scraper.scrape_property(url)
            if result:
                task['results'].append(result)
            else:
                task['errors'].append({
                    'url': url,
                    'error': 'Could not get property info'
                })
        except SuumoScraperError as e:
            task['errors'].append({
                'url': url,
                'error': str(e)
            })

        # 最後のURL以外は待機(スクレイピングマナー)
        if i < len(task['urls']) - 1:
            time.sleep(task['delay'])

    task['status'] = 'completed'
    task['progress'] = len(task['urls'])

ポイント:

  • ThreadPoolExecutorで複数タスクを並列管理
  • 各URL処理間に2秒のディレイを設けてサーバー負荷を軽減
  • エラーが発生しても処理を継続し、最終的にまとめてレポート

実装で工夫した点・注意点

スクレイピングマナー

  • 各URL処理間に2秒の待機時間を設定(設定で1〜10秒に変更可能)
  • 同時リクエスト数を制限
  • 適切なUser-Agentを設定
# routes.py
MIN_DELAY = 1
MAX_DELAY = 10

# 待機時間のバリデーション
try:
    delay = int(delay)
    if delay < MIN_DELAY or delay > MAX_DELAY:
        return jsonify({
            'error': f'Delay must be between {MIN_DELAY} and {MAX_DELAY} seconds'
        }), 400
except (TypeError, ValueError):
    delay = 2  # デフォルト値

エラーハンドリング

カスタム例外を定義して、エラーの種類を明確にしています。

class SuumoScraperError(Exception):
    """Custom exception for scraping errors"""
    pass

class SuumoAuthError(Exception):
    """Custom exception for authentication errors"""
    pass

class ExportError(Exception):
    """Custom exception for export errors"""
    pass

これにより、APIレスポンスで適切なHTTPステータスコードを返せます。

try:
    urls = get_favorites_with_login(email, password, headless=True)
    return jsonify({'urls': urls, 'count': len(urls)})
except SuumoAuthError as e:
    return jsonify({'error': str(e)}), 401  # 認証エラー
except Exception as e:
    return jsonify({'error': 'Failed to get favorites'}), 500  # サーバーエラー

タスクの有効期限管理

長時間放置されたタスクを自動削除してメモリリークを防止します。

from datetime import datetime, timedelta

TASK_EXPIRY_HOURS = 1

def cleanup_old_tasks():
    """Remove tasks older than TASK_EXPIRY_HOURS"""
    expiry_time = datetime.now() - timedelta(hours=TASK_EXPIRY_HOURS)
    with tasks_lock:
        expired = [
            task_id for task_id, task in tasks.items()
            if task.get('completed_at') and
            datetime.fromisoformat(task['completed_at']) < expiry_time
        ]
        for task_id in expired:
            del tasks[task_id]

このスキルを副業に活かすには

クラウドソーシングでの案件例

このツールを作れるレベルになれば、以下のような案件に応募できます。

  1. データ収集案件 – 「競合サイトの価格情報を毎週収集してほしい」
  2. 業務効率化案件 – 「社内システムから情報を自動抽出するツールを作ってほしい」
  3. リサーチ支援案件 – 「特定の条件に合う情報を一覧化してほしい」

ポートフォリオへの活かし方

GitHubにリポジトリを公開する際のポイント:

  • READMEを充実させる – 機能説明、技術スタック、セットアップ方法を明記
  • デモ画面を追加 – GIFやスクリーンショットで動作イメージを伝える
  • コードを整理する – コメント、型ヒント、docstringを適切に追加

注意点

副業でスクレイピング案件を受ける際の注意点:

  • 守秘義務: クライアントの情報や取得データは厳重に管理
  • 著作権: 取得データの権利関係を事前に確認
  • 責任範囲: 法的リスクについてクライアントと認識を合わせる

まとめ

このツールを通じて、以下の技術を実践的に学べます。

  • BeautifulSoup4 によるHTML解析とCSSセレクタの活用
  • Selenium を使ったブラウザ自動化とログイン処理
  • Flask でのAPIエンドポイント設計
  • Server-Sent Events によるリアルタイム通信
  • ThreadPoolExecutor を使った並列処理
  • pandas によるデータ出力

これらのスキルは、副業案件でも即戦力として使えます。実際に動くコードを読みながら、ぜひ自分のプロジェクトにも応用してみてください。

リポジトリ: https://github.com/yamato-snow/2025-12-07_sumo-scraping

次のステップ

このツールを作り終えたら、次は以下に挑戦してみてください。

  1. 他のサイトに応用 – 自分が興味のあるサイトでスクレイピングを試す
  2. 定期実行の仕組みを追加 – cronやタスクスケジューラで自動実行
  3. クラウドにデプロイ – HerokuやAWS Lambdaで常時稼働させる

重要な注意事項

法的リスクについて

スクレイピングは、やり方によっては法律に抵触する可能性があります。本ツールを使用する前に、必ず「作る前に絶対知っておくべきこと」セクションを熟読してください。

  • 不正アクセス禁止法: アクセス制限の回避は違法です
  • 著作権法: 取得データの再配布・商用利用は著作権侵害です
  • 業務妨害罪: サーバーへの過度な負荷は犯罪になり得ます

利用上の注意:

  • 利用規約の遵守: SUUMOの利用規約を必ず確認し、遵守してください
  • robots.txt の確認: https://suumo.jp/robots.txt を確認してください
  • スクレイピングマナー: 各リクエスト間に最低2秒以上の間隔を空けてください
  • HTML構造変更: SUUMOのHTML構造が変更された場合、セレクタの更新が必要になる可能性があります
  • 利用目的: 本ツールは個人的な物件比較・学習目的での使用を想定しています
  • 自己責任: 本ツールの使用によって生じたいかなる損害についても、作者は責任を負いません

このツールを使用してはいけない場合:

  • 商用目的でのデータ収集
  • 大量のデータを網羅的に収集する目的
  • 取得したデータを第三者に提供・販売する目的
  • SUUMOの利用規約に違反する行為
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

コメント

コメントする

目次