Skip to content

スクレイピング処理フロー詳細

全体フロー

全体フロー

CLI引数

引数デフォルト説明
--type_keywordstrall処理対象: all / normal / reverse
--offsetint0キーワード取得の開始位置
--limitint1000キーワード取得の件数上限
--debugint01でエラー時スクリーンショット取得
--ranking_threshold_for_screenshotint3S3アップロード対象のランク閾値
--retry_failedstr""リトライ対象: all / timeout / recaptcha / error(カンマ区切り可)

SearchRankingProcessAWS.handle(プロセス処理)

SearchRankingProcessAWS.handle

キーワードデータ取得(get_users)

get_users

Playwright検索実行(fetch_search_rankings_aws)

Playwright検索実行

1キーワードの検索・ランキング判定(fetch_search_ranking)

ランキング判定フロー

ランキング判定の優先順位

優先度チェック対象セレクタランク値
1ナレッジパネル*[data-attrid="title"]-1(同一店舗)/ -2(別店舗)
2ローカルパック(通常)地図結果の見出し要素-11 / -12 / -13(1〜3位)
2'ローカルパック(ホテル)image-carousel内の要素-11 / -12 / -13
3マップページ(通常)「すべて表示」クリック後のリスト1〜N位(最大3ページ)
3'マップページ(サービス)「その他のお店やサービス」クリック後1〜N位
3''マップページ(ホテル)ホテル用マップリスト1〜N位
4単一ロケーションマップ地図内の単一結果-11
5静的マップページJavaScriptなしのリスト1〜N位

ランキング値の変換

ランキング値の変換

結果保存処理(process_rankings_data_success)

結果保存処理

エクスポートデータ形式

mappy_search_ranking_exports.data に保存されるJSON配列:

インデックス内容
0Excelタイムスタンプ(日時)
1login_id
2store_code
3gbp_location_id
4location_name_id
5display_name(店舗名)
6accountable_keywords_min
7キーワード数
8〜15キーワード1〜8(空文字で8件に埋める)
16latitude
17longitude
18null
19〜26ランキング1〜8(「圏外_c」で8件に埋める)
27null
28〜33TOP3/10/20の対象フラグと件数
34system_id

アンチ検出対策

対策詳細
UserAgent15種類からランダム選択(Chrome/Firefox/Safari/Edge/Opera、Windows/Mac/Linux)
タイピング1文字ずつ50-200msの遅延 + 文字間100-300msの待機
マウス移動検索前後にランダム座標へ移動
ランダム待機操作間に1-3秒のランダム遅延
リソースブロック画像/CSS/メディア/フォントをブロックして高速化
ジオロケーション店舗の緯度経度をブラウザに設定
自動化フラグ無効化--disable-blink-features=AutomationControlled
Cookie同意処理Googleの同意モーダルを自動処理

Slack通知

レポート通知(実行完了時)

Scraper Report
・システム: scrappy
・モード: Normal / Reverse
・バッチの実行時間: 2026-02-11 09:00:00 - 2026-02-11 09:15:00
・全実行件数: 500件
・タイムアウトエラー: 3件
・reCAPTCHAエラー: 1件
・例外エラー: 0件

エラーアラート通知(閾値超過時)

Scraper Alert
・System: scrappy
・Unexpected Errors Total: 25 (5%)
・Out of Range Total (圏外): 100 (20%)
・Keywords Total: 500
・Unexpected Error Threshold: 20%
・Out of Range Threshold: 20%

データベース設計(ER図)

ER図

テーブル一覧

テーブル説明
mappy_usersユーザー(テナント)マスタ。system_idで環境を識別
mappy_keywords検索キーワード。1ロケーションにつき最大8件。search_status/search_status_reverseで検索結果ステータスを管理
mappy_gbp_locationsGBPロケーション情報。store_code, title, raw_json(緯度経度含む)
mappy_integrated_gbp_locationsユーザーとロケーションの紐付け中間テーブル。keyword_settings(検索地点の緯度経度)を保持
mappy_search_ranking_update_logsバッチ実行ログ。開始/終了時刻、ステータス、type_keyword、オフセット/リミット
mappy_search_ranking_processesユーザー単位の処理ログ。normal/reverse別にステータスを管理
mappy_temp_search_rankingsキーワード別の検索結果一時テーブル。ranking_value, ranking_status を保存
mappy_search_ranking_exportsエクスポート用JSON配列データ。ユーザー×ロケーション単位で結果を格納

リファクタ後フロー(並列ワーカー方式)

概要

現行の逐次処理をワーカーベースの並列処理にリファクタリングする。キーワード単位でキューイングし、複数ワーカーが競合なく処理を進める。

変更点

項目現行リファクタ後
処理単位ユーザー単位で逐次処理キーワード単位でキューイング
並列度スレッド(normal/reverse)--workers N で任意の並列数
排他制御なし(offset/limitで分割)search_status = 6(PROCESSING)でDB排他
障害復旧手動対応reset_stale_processing で自動復旧
リトライ--retry_failed で全体再実行失敗キーワードのみキューに再投入

新CLI引数

引数デフォルト説明
--workersint1並列ワーカー数

search_statusの状態遷移

ステータス説明
INIT1未処理(キューに投入可能)
SUCCESS2検索成功
TIMEOUT3タイムアウトエラー
RECAPTCHA_ERROR4reCAPTCHA検出
UNEXPECTED_ERROR5例外エラー
PROCESSING6新規追加 — ワーカーが処理中

ワーカー処理フロー

reset_stale_processing(障害復旧)

ワーカーが途中停止した場合、PROCESSING(6)のまま残ったキーワードを自動復旧する。

retry_failed フロー

テスト項目

#テスト内容確認方法
1--workers 1 で単一ワーカー実行全キーワードが順次処理されること
2--workers 3 で並列実行キーワードが重複処理されないこと
3ワーカー途中停止reset_stale_processing で PROCESSING が INIT に戻ること
4--retry_failed all失敗キーワードのみがキューに入ること
5処理完了後の状態確認DB上で search_status = 6 が残っていないこと

ロールバック手順

  • コードを元に戻す
  • UPDATE mappy_keywords SET search_status = 1 WHERE search_status = 6 で復旧
  • DBマイグレーション不要(既存intカラムに新値を使うだけ)

アプリDB連携

スクレイピングDBとアプリDB(Laravel側)は完全に分離されている。以下の2つのAWS Lambda関数でデータを同期する。

対象システム

system_idDB名システム
1gmacMAPPY
2gmac2GCOR
3pipitPIPIT

sync_database(Lambda)

方向: アプリDB → スクレイピングDB

EventBridgeのスケジュールで毎日実行。3つのアプリDB(gmac/gcor/pipit)から以下のテーブルをスクレイピングDBに同期する。

同期テーブル内容
mappy_usersユーザー(テナント)マスタ
mappy_user_settingsユーザー設定
mappy_gbp_locationsGBPロケーション情報
mappy_gbp_locations_store_codesストアコード
mappy_integrated_gbp_locationsユーザー×ロケーション紐付け
mappy_keywords検索キーワード

同期前に既存テーブルのバックアップを取得し、同期完了後にEventBridgeイベントでスクレイピング処理をトリガーする。

syncResultFetchSearch(Lambda)

方向: スクレイピングDB → アプリDB

スクレイピング完了後に実行。結果をsystem_idでフィルタリングし、各アプリDBに振り分けて返却する。

返却テーブル内容
mappy_search_ranking_exportsランキング結果(JSON配列)
mappy_temp_search_rankingsキーワード別検索結果
mappy_search_ranking_processes処理メタデータ
mappy_search_ranking_update_logs更新ログ

S3ログアップロード

実行完了後、デバッグログをS3にアップロード:

s3://{SCRAPER_SCRAPING_BUCKET}/log/debug_aws/{year}/{month}/{day}/{datetime}-{suffix}.log

suffix例: normal_offset_0_limit_500retry_timeout_reverse_offset_500_limit_500

fetch_search_ranking の改善

現行の問題

#問題影響
1ホテル判定がDOM依存(image-carouselの有無)同一ロケーションでもキーワードにより判定が揺れる可能性
28つの判定関数が深いネスト(最大6段)可読性・保守性が低い
3マップページのページング処理が3箇所で重複check_search_map_page / check_search_map_page_service / check_search_map_page_static
4reCAPTCHAチェックが全関数で重複(8箇所)同じコードがコピペされている
5戻り値タプルが不統一(4要素 vs 5要素)check_search_map_page_static のみ error_count が追加

ホテル業種の事前判定

概要

現行のDOM判定(page.locator("image-carousel").count() > 0)を、GBPの primary_category による事前判定に変更する。mappy_gbp_locations テーブルには primary_category(gcid形式)と primary_category_display_name(表示名)が既に存在する。

宿泊施設カテゴリ一覧

Google検索結果でホテル用レイアウト(image-carousel)が表示される全カテゴリ:

gcid英語名日本語表示名
gcid:hotelHotelホテル
gcid:motelMotelモーテル
gcid:hostelHostelホステル
gcid:innInn旅館 / イン
gcid:resort_hotelResort hotelリゾートホテル
gcid:lodgeLodgeロッジ
gcid:bed_and_breakfastBed & breakfastペンション / 民宿
gcid:guest_houseGuest houseゲストハウス
gcid:capsule_hotelCapsule hotelカプセルホテル
gcid:love_hotelLove hotelラブホテル
gcid:hot_spring_hotelHot spring hotel温泉ホテル
gcid:extended_stay_hotelExtended stay hotel長期滞在ホテル
gcid:wellness_hotelWellness hotelウェルネスホテル
gcid:cottageCottageコテージ
gcid:chaletChaletシャレー
gcid:holiday_homeHoliday home貸別荘
gcid:camping_cabinCamping cabinキャンピングキャビン
gcid:farmstayFarmstayファームステイ
gcid:pensionPensionペンション
gcid:boarding_houseBoarding house下宿
gcid:youth_hostelYouth hostelユースホステル
gcid:lodgingLodging宿泊施設
gcid:campgroundCampgroundキャンプ場
gcid:rv_parkRV parkRVパーク
gcid:self_catering_accommodationSelf-catering accommodationセルフケータリング

判定ロジック

python
# configs/hotel_categories.py — 設定ファイルとして外部化
HOTEL_CATEGORY_IDS = {
    "gcid:hotel", "gcid:motel", "gcid:hostel", "gcid:inn",
    "gcid:resort_hotel", "gcid:lodge", "gcid:bed_and_breakfast",
    "gcid:guest_house", "gcid:capsule_hotel", "gcid:love_hotel",
    "gcid:hot_spring_hotel", "gcid:extended_stay_hotel",
    "gcid:wellness_hotel", "gcid:cottage", "gcid:chalet",
    "gcid:holiday_home", "gcid:camping_cabin", "gcid:farmstay",
    "gcid:pension", "gcid:boarding_house", "gcid:youth_hostel",
    "gcid:lodging", "gcid:campground", "gcid:rv_park",
    "gcid:self_catering_accommodation",
}

HOTEL_DISPLAY_KEYWORDS = [
    "ホテル", "モーテル", "ホステル", "旅館", "リゾート", "ロッジ",
    "ペンション", "民宿", "ゲストハウス", "カプセル", "ラブホテル",
    "温泉", "コテージ", "シャレー", "貸別荘", "キャビン",
    "ファームステイ", "下宿", "ユースホステル", "宿泊", "ウェルネス",
    "キャンプ", "RVパーク", "セルフケータリング", "イン",
    # 英語フォールバック
    "Hotel", "Motel", "Hostel", "Inn", "Resort", "Lodge",
    "B&B", "Bed & breakfast", "Guest house", "Capsule",
    "Cottage", "Chalet", "Cabin", "Farmstay", "Pension",
    "Boarding house", "Youth hostel", "Lodging", "Campground",
]

def is_hotel_business(location) -> bool:
    """primary_categoryからホテル業種を事前判定"""
    if location.primary_category:
        cat = location.primary_category.strip().lower()
        if cat in {c.lower() for c in HOTEL_CATEGORY_IDS}:
            return True
    if location.primary_category_display_name:
        name = location.primary_category_display_name
        return any(kw in name for kw in HOTEL_DISPLAY_KEYWORDS)
    return False

データの流れ

  1. get_users() でロケーション情報取得時に is_hotel_business(location) を呼び出し
  2. user_data["is_hotel"] に結果を格納
  3. fetch_search_rankings_aws()fetch_search_ranking()is_hotel を渡す
  4. 各判定関数が is_hotel に基づきセレクタを切り替え

カテゴリ変更時の対応

Googleはカテゴリを不定期に追加・変更・統合する(過去に Boutique Hotel, Budget Hotel, Beach Resort 等が廃止された実績あり)。

対応説明
設定ファイル外部化HOTEL_CATEGORY_IDSHOTEL_DISPLAY_KEYWORDSconfigs/hotel_categories.py に切り出し。カテゴリ追加時はこのファイルのみ変更
DOMフォールバック維持is_hotel=False でも実行時にDOMで image-carousel を検出した場合はホテルレイアウトとして処理(新カテゴリ追加に対するセーフティネット)
フォールバック検知ログDOMフォールバック発動時に primary_category をWARNログに出力し、設定ファイルへの追加を促す
sync_databaseアプリDBからスクレイピングDBへ primary_category は既に同期済みのため追加対応不要
[WARN] Hotel layout detected by DOM but not by category.
       primary_category=gcid:new_category_name location_id=123
       → configs/hotel_categories.py への追加を検討してください

判定フロー改善

改善後のフロー

関数統合

現行(8関数)改善後(4関数)変更内容
check_local_pack()check_local_pack(is_hotel)is_hotel でセレクタ切替
check_local_pack_hotel()(統合)
check_search_map_page()check_map_page(is_hotel)「さらに表示」ボタンを自動検出、共通ページング
check_search_map_page_hotel()(統合)
check_search_map_page_service()(統合)
check_map_single_location()check_map_page 内に統合マップページ遷移前に単一ロケーションチェック
check_search_map_page_static()check_map_page_static()最終フォールバック(変更軽微)
scan_map_listings()新規: 共通ページング処理

共通ページング処理(scan_map_listings)

3箇所で重複していたマップページスキャン処理を1つに統合:

python
def scan_map_listings(page, location_title, heading_selector, max_pages=3):
    """マップページの一覧をスキャンし、ランキングを返す共通処理

    check_search_map_page / check_search_map_page_service /
    check_search_map_page_static で重複していた処理を統合
    """
    location_index = 0
    for current_page in range(1, max_pages + 1):
        # スポンサー除外
        sponsors_count = page.locator("span").filter(has_text="スポンサー").count()
        headings = page.locator(heading_selector).all()[sponsors_count:]
        for heading in headings:
            location_index += 1
            if heading.count() > 0 and location_title == heading.inner_text():
                return True, location_index
        # ページネーション
        if not click_next_page(page):
            break
    return False, None


def click_next_page(page):
    """次ページへ遷移する共通処理"""
    next_selectors = [
        ("button span", "次へ"),      # ホテル用
        ("a span", "次へ"),            # サービス用
        ("#pnnext", None),             # 通常マップ用
        ('a[role="button"]', "もっと見る"),  # 通常マップ用(別パターン)
    ]
    for selector, text in next_selectors:
        locator = page.locator(selector)
        if text:
            locator = locator.filter(has_text=text)
        if locator.count() > 0:
            locator.first.click()
            page.wait_for_load_state("domcontentloaded", timeout=6000)
            return True
    return False

戻り値の統一

全判定関数の戻り値を SearchResult dataclassに統一:

python
@dataclass
class SearchResult:
    has_ranking: bool = False
    ranking_value: int | None = None
    timeout_count: int = 0
    recaptcha_count: int = 0
    error_count: int = 0

reCAPTCHAチェックの集約

現行は全8関数でreCAPTCHAチェックを実施しているが、以下に集約:

タイミングチェック箇所
検索直後fetch_search_ranking 内で1回チェック → 検出時は即return
ページ遷移後check_map_page / check_map_page_static でページ遷移後に1回チェック

個別判定関数(check_knowledge_panel / check_local_pack)からはreCAPTCHAチェックを削除。