スクレイピング処理フロー詳細
全体フロー
CLI引数
| 引数 | 型 | デフォルト | 説明 |
|---|---|---|---|
--type_keyword | str | all | 処理対象: all / normal / reverse |
--offset | int | 0 | キーワード取得の開始位置 |
--limit | int | 1000 | キーワード取得の件数上限 |
--debug | int | 0 | 1でエラー時スクリーンショット取得 |
--ranking_threshold_for_screenshot | int | 3 | S3アップロード対象のランク閾値 |
--retry_failed | str | "" | リトライ対象: all / timeout / recaptcha / error(カンマ区切り可) |
SearchRankingProcessAWS.handle(プロセス処理)
キーワードデータ取得(get_users)
Playwright検索実行(fetch_search_rankings_aws)
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配列:
| インデックス | 内容 |
|---|---|
| 0 | Excelタイムスタンプ(日時) |
| 1 | login_id |
| 2 | store_code |
| 3 | gbp_location_id |
| 4 | location_name_id |
| 5 | display_name(店舗名) |
| 6 | accountable_keywords_min |
| 7 | キーワード数 |
| 8〜15 | キーワード1〜8(空文字で8件に埋める) |
| 16 | latitude |
| 17 | longitude |
| 18 | null |
| 19〜26 | ランキング1〜8(「圏外_c」で8件に埋める) |
| 27 | null |
| 28〜33 | TOP3/10/20の対象フラグと件数 |
| 34 | system_id |
アンチ検出対策
| 対策 | 詳細 |
|---|---|
| UserAgent | 15種類からランダム選択(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図)
テーブル一覧
| テーブル | 説明 |
|---|---|
| mappy_users | ユーザー(テナント)マスタ。system_idで環境を識別 |
| mappy_keywords | 検索キーワード。1ロケーションにつき最大8件。search_status/search_status_reverseで検索結果ステータスを管理 |
| mappy_gbp_locations | GBPロケーション情報。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引数
| 引数 | 型 | デフォルト | 説明 |
|---|---|---|---|
--workers | int | 1 | 並列ワーカー数 |
search_statusの状態遷移
| ステータス | 値 | 説明 |
|---|---|---|
| INIT | 1 | 未処理(キューに投入可能) |
| SUCCESS | 2 | 検索成功 |
| TIMEOUT | 3 | タイムアウトエラー |
| RECAPTCHA_ERROR | 4 | reCAPTCHA検出 |
| UNEXPECTED_ERROR | 5 | 例外エラー |
| PROCESSING | 6 | 新規追加 — ワーカーが処理中 |
ワーカー処理フロー
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_id | DB名 | システム |
|---|---|---|
| 1 | gmac | MAPPY |
| 2 | gmac2 | GCOR |
| 3 | pipit | PIPIT |
sync_database(Lambda)
方向: アプリDB → スクレイピングDB
EventBridgeのスケジュールで毎日実行。3つのアプリDB(gmac/gcor/pipit)から以下のテーブルをスクレイピングDBに同期する。
| 同期テーブル | 内容 |
|---|---|
mappy_users | ユーザー(テナント)マスタ |
mappy_user_settings | ユーザー設定 |
mappy_gbp_locations | GBPロケーション情報 |
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}.logsuffix例: normal_offset_0_limit_500、retry_timeout_reverse_offset_500_limit_500
fetch_search_ranking の改善
現行の問題
| # | 問題 | 影響 |
|---|---|---|
| 1 | ホテル判定がDOM依存(image-carouselの有無) | 同一ロケーションでもキーワードにより判定が揺れる可能性 |
| 2 | 8つの判定関数が深いネスト(最大6段) | 可読性・保守性が低い |
| 3 | マップページのページング処理が3箇所で重複 | check_search_map_page / check_search_map_page_service / check_search_map_page_static |
| 4 | reCAPTCHAチェックが全関数で重複(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:hotel | Hotel | ホテル |
gcid:motel | Motel | モーテル |
gcid:hostel | Hostel | ホステル |
gcid:inn | Inn | 旅館 / イン |
gcid:resort_hotel | Resort hotel | リゾートホテル |
gcid:lodge | Lodge | ロッジ |
gcid:bed_and_breakfast | Bed & breakfast | ペンション / 民宿 |
gcid:guest_house | Guest house | ゲストハウス |
gcid:capsule_hotel | Capsule hotel | カプセルホテル |
gcid:love_hotel | Love hotel | ラブホテル |
gcid:hot_spring_hotel | Hot spring hotel | 温泉ホテル |
gcid:extended_stay_hotel | Extended stay hotel | 長期滞在ホテル |
gcid:wellness_hotel | Wellness hotel | ウェルネスホテル |
gcid:cottage | Cottage | コテージ |
gcid:chalet | Chalet | シャレー |
gcid:holiday_home | Holiday home | 貸別荘 |
gcid:camping_cabin | Camping cabin | キャンピングキャビン |
gcid:farmstay | Farmstay | ファームステイ |
gcid:pension | Pension | ペンション |
gcid:boarding_house | Boarding house | 下宿 |
gcid:youth_hostel | Youth hostel | ユースホステル |
gcid:lodging | Lodging | 宿泊施設 |
gcid:campground | Campground | キャンプ場 |
gcid:rv_park | RV park | RVパーク |
gcid:self_catering_accommodation | Self-catering accommodation | セルフケータリング |
判定ロジック
# 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データの流れ
get_users()でロケーション情報取得時にis_hotel_business(location)を呼び出しuser_data["is_hotel"]に結果を格納fetch_search_rankings_aws()→fetch_search_ranking()にis_hotelを渡す- 各判定関数が
is_hotelに基づきセレクタを切り替え
カテゴリ変更時の対応
Googleはカテゴリを不定期に追加・変更・統合する(過去に Boutique Hotel, Budget Hotel, Beach Resort 等が廃止された実績あり)。
| 対応 | 説明 |
|---|---|
| 設定ファイル外部化 | HOTEL_CATEGORY_IDS と HOTEL_DISPLAY_KEYWORDS を configs/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つに統合:
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に統一:
@dataclass
class SearchResult:
has_ranking: bool = False
ranking_value: int | None = None
timeout_count: int = 0
recaptcha_count: int = 0
error_count: int = 0reCAPTCHAチェックの集約
現行は全8関数でreCAPTCHAチェックを実施しているが、以下に集約:
| タイミング | チェック箇所 |
|---|---|
| 検索直後 | fetch_search_ranking 内で1回チェック → 検出時は即return |
| ページ遷移後 | check_map_page / check_map_page_static でページ遷移後に1回チェック |
個別判定関数(check_knowledge_panel / check_local_pack)からはreCAPTCHAチェックを削除。