Chi tiết quy trình xử lý Scraping
Quy trình tổng thể
Tham số CLI
| Tham số | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
--type_keyword | str | all | Đối tượng xử lý: all / normal / reverse |
--offset | int | 0 | Vị trí bắt đầu lấy từ khóa |
--limit | int | 1000 | Số lượng từ khóa tối đa |
--debug | int | 0 | 1 để chụp screenshot khi lỗi |
--ranking_threshold_for_screenshot | int | 3 | Ngưỡng rank để upload S3 |
--retry_failed | str | "" | Đối tượng retry: all / timeout / recaptcha / error (phân cách bằng dấu phẩy) |
SearchRankingProcessAWS.handle (Xử lý process)
Lấy dữ liệu từ khóa (get_users)
Thực thi tìm kiếm Playwright (fetch_search_rankings_aws)
Tìm kiếm & phán đoán xếp hạng 1 từ khóa (fetch_search_ranking)
Thứ tự ưu tiên phán đoán xếp hạng
| Ưu tiên | Đối tượng kiểm tra | Selector | Giá trị rank |
|---|---|---|---|
| 1 | Knowledge Panel | *[data-attrid="title"] | -1 (cùng cửa hàng) / -2 (cửa hàng khác) |
| 2 | Local Pack (thông thường) | Phần tử tiêu đề kết quả bản đồ | -11 / -12 / -13 (vị trí 1~3) |
| 2' | Local Pack (khách sạn) | Phần tử trong image-carousel | -11 / -12 / -13 |
| 3 | Trang bản đồ (thông thường) | Danh sách sau click「すべて表示」 | Vị trí 1~N (tối đa 3 trang) |
| 3' | Trang bản đồ (dịch vụ) | Sau click「その他のお店やサービス」 | Vị trí 1~N |
| 3'' | Trang bản đồ (khách sạn) | Danh sách bản đồ khách sạn | Vị trí 1~N |
| 4 | Bản đồ đơn location | Kết quả đơn trong bản đồ | -11 |
| 5 | Trang bản đồ tĩnh | Danh sách không JavaScript | Vị trí 1~N |
Chuyển đổi giá trị xếp hạng
Xử lý lưu kết quả (process_rankings_data_success)
Biện pháp chống phát hiện
| Biện pháp | Chi tiết |
|---|---|
| UserAgent | Chọn ngẫu nhiên từ 15 loại (Chrome/Firefox/Safari/Edge/Opera, Windows/Mac/Linux) |
| Gõ phím | Gõ từng ký tự với độ trễ 50-200ms + chờ 100-300ms giữa các ký tự |
| Di chuyển chuột | Di chuyển đến tọa độ ngẫu nhiên trước/sau tìm kiếm |
| Chờ ngẫu nhiên | Chờ ngẫu nhiên 1-3 giây giữa các thao tác |
| Chặn tài nguyên | Chặn hình ảnh/CSS/media/font để tăng tốc |
| Geolocation | Cài đặt tọa độ cửa hàng vào trình duyệt |
| Vô hiệu hóa cờ tự động | --disable-blink-features=AutomationControlled |
| Xử lý đồng ý Cookie | Tự động xử lý modal đồng ý của Google |
Thông báo Slack
Thông báo báo cáo (khi hoàn thành)
Scraper Report
・システム: scrappy
・モード: Normal / Reverse
・バッチの実行時間: 2026-02-11 09:00:00 - 2026-02-11 09:15:00
・全実行件数: 500件
・タイムアウトエラー: 3件
・reCAPTCHAエラー: 1件
・例外エラー: 0件Thông báo cảnh báo lỗi (khi vượt ngưỡng)
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%Luồng sau khi Refactor (Phương thức Worker song song)
Tổng quan
Refactor xử lý tuần tự hiện tại thành xử lý song song dựa trên worker. Queue hóa theo đơn vị từ khóa, nhiều worker xử lý đồng thời không xung đột.
Thay đổi
| Mục | Hiện tại | Sau refactor |
|---|---|---|
| Đơn vị xử lý | Tuần tự theo người dùng | Queue hóa theo từ khóa |
| Song song | Thread (normal/reverse) | --workers N tùy chỉnh |
| Loại trừ lẫn nhau | Không (phân chia bằng offset/limit) | search_status = 6 (PROCESSING) loại trừ bằng DB |
| Phục hồi sự cố | Xử lý thủ công | reset_stale_processing phục hồi tự động |
| Retry | --retry_failed chạy lại toàn bộ | Chỉ đưa từ khóa thất bại vào queue |
Tham số CLI mới
| Tham số | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
--workers | int | 1 | Số worker song song |
Chuyển đổi trạng thái search_status
| Trạng thái | Giá trị | Mô tả |
|---|---|---|
| INIT | 1 | Chưa xử lý (có thể đưa vào queue) |
| SUCCESS | 2 | Tìm kiếm thành công |
| TIMEOUT | 3 | Lỗi timeout |
| RECAPTCHA_ERROR | 4 | Phát hiện reCAPTCHA |
| UNEXPECTED_ERROR | 5 | Lỗi exception |
| PROCESSING | 6 | Thêm mới — Worker đang xử lý |
Luồng xử lý Worker
reset_stale_processing (Phục hồi sự cố)
Khi worker bị dừng giữa chừng, tự động phục hồi từ khóa còn ở trạng thái PROCESSING (6).
Luồng retry_failed
Hạng mục test
| # | Nội dung test | Cách xác nhận |
|---|---|---|
| 1 | Thực thi worker đơn với --workers 1 | Tất cả từ khóa được xử lý tuần tự |
| 2 | Thực thi song song với --workers 3 | Từ khóa không bị xử lý trùng lặp |
| 3 | Dừng worker giữa chừng | reset_stale_processing đặt PROCESSING về INIT |
| 4 | --retry_failed all | Chỉ từ khóa thất bại được đưa vào queue |
| 5 | Kiểm tra trạng thái sau hoàn thành | Không còn search_status = 6 trong DB |
Quy trình Rollback
- Hoàn nguyên code
UPDATE mappy_keywords SET search_status = 1 WHERE search_status = 6để phục hồi- Không cần DB migration (chỉ dùng giá trị mới trên cột int hiện có)
Liên kết DB ứng dụng
DB scraping và DB ứng dụng (phía Laravel) được tách biệt hoàn toàn. Đồng bộ dữ liệu qua 2 hàm AWS Lambda sau.
Hệ thống mục tiêu
| system_id | Tên DB | Hệ thống |
|---|---|---|
| 1 | gmac | MAPPY |
| 2 | gmac2 | GCOR |
| 3 | pipit | PIPIT |
sync_database (Lambda)
Hướng: DB ứng dụng → DB scraping
Chạy hàng ngày qua EventBridge schedule. Đồng bộ các bảng sau từ 3 DB ứng dụng (gmac/gcor/pipit) vào DB scraping.
| Bảng đồng bộ | Nội dung |
|---|---|
mappy_users | Master người dùng (tenant) |
mappy_user_settings | Cài đặt người dùng |
mappy_gbp_locations | Thông tin GBP location |
mappy_gbp_locations_store_codes | Store code |
mappy_integrated_gbp_locations | Liên kết người dùng × location |
mappy_keywords | Từ khóa tìm kiếm |
Backup bảng hiện có trước khi đồng bộ, sau khi hoàn thành sẽ trigger xử lý scraping qua EventBridge event.
syncResultFetchSearch (Lambda)
Hướng: DB scraping → DB ứng dụng
Chạy sau khi scraping hoàn thành. Lọc kết quả theo system_id và phân phối về từng DB ứng dụng.
| Bảng trả về | Nội dung |
|---|---|
mappy_search_ranking_exports | Kết quả xếp hạng (mảng JSON) |
mappy_temp_search_rankings | Kết quả tìm kiếm theo từ khóa |
mappy_search_ranking_processes | Metadata xử lý |
mappy_search_ranking_update_logs | Log cập nhật |
Upload log S3
Sau khi hoàn thành, upload debug log lên S3:
s3://{SCRAPER_SCRAPING_BUCKET}/log/debug_aws/{year}/{month}/{day}/{datetime}-{suffix}.logVí dụ suffix: normal_offset_0_limit_500, retry_timeout_reverse_offset_500_limit_500
Cải thiện fetch_search_ranking
Vấn đề hiện tại
| # | Vấn đề | Ảnh hưởng |
|---|---|---|
| 1 | Phán đoán khách sạn phụ thuộc DOM (image-carousel) | Cùng location nhưng kết quả có thể khác nhau tùy từ khóa |
| 2 | 8 hàm phán đoán lồng nhau sâu (tối đa 6 tầng) | Khó đọc, khó bảo trì |
| 3 | Xử lý phân trang bản đồ lặp lại ở 3 nơi | check_search_map_page / check_search_map_page_service / check_search_map_page_static |
| 4 | Kiểm tra reCAPTCHA lặp lại ở tất cả hàm (8 nơi) | Code copy-paste giống nhau |
| 5 | Tuple trả về không thống nhất (4 phần tử vs 5 phần tử) | Chỉ check_search_map_page_static thêm error_count |
Phán đoán ngành nghề khách sạn trước
Tổng quan
Thay đổi phán đoán DOM hiện tại (page.locator("image-carousel").count() > 0) thành phán đoán trước dựa trên primary_category của GBP. Bảng mappy_gbp_locations đã có sẵn primary_category (định dạng gcid) và primary_category_display_name (tên hiển thị).
Danh sách category cơ sở lưu trú
Tất cả category hiển thị layout khách sạn (image-carousel) trên kết quả tìm kiếm Google:
| gcid | Tên tiếng Anh | Tên hiển thị tiếng Nhật |
|---|---|---|
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 | セルフケータリング |
Logic phán đoán
# configs/hotel_categories.py — Tách ra file cấu hình
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パーク", "セルフケータリング", "イン",
# Fallback tiếng Anh
"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:
"""Phán đoán trước ngành nghề khách sạn từ 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 FalseLuồng dữ liệu
- Gọi
is_hotel_business(location)khi lấy thông tin location trongget_users() - Lưu kết quả vào
user_data["is_hotel"] - Truyền
is_hotelquafetch_search_rankings_aws()→fetch_search_ranking() - Các hàm phán đoán chuyển đổi selector dựa trên
is_hotel
Đối phó khi thay đổi category
Google thêm/thay đổi/hợp nhất category không định kỳ (trước đây đã xóa Boutique Hotel, Budget Hotel, Beach Resort...).
| Đối phó | Mô tả |
|---|---|
| Tách ra file cấu hình | Tách HOTEL_CATEGORY_IDS và HOTEL_DISPLAY_KEYWORDS vào configs/hotel_categories.py. Khi thêm category chỉ cần sửa file này |
| Giữ DOM fallback | Khi is_hotel=False nhưng DOM phát hiện image-carousel, vẫn xử lý như layout khách sạn (safety net cho category mới) |
| Log phát hiện fallback | Khi DOM fallback kích hoạt, ghi primary_category vào WARN log để nhắc thêm vào file cấu hình |
| sync_database | primary_category đã được đồng bộ từ DB ứng dụng sang DB scraping, không cần thêm xử lý |
[WARN] Hotel layout detected by DOM but not by category.
primary_category=gcid:new_category_name location_id=123
→ Hãy xem xét thêm vào configs/hotel_categories.pyCải thiện luồng phán đoán
Luồng sau cải thiện
Hợp nhất hàm
| Hiện tại (8 hàm) | Sau cải thiện (4 hàm) | Nội dung thay đổi |
|---|---|---|
check_local_pack() | check_local_pack(is_hotel) | Chuyển selector bằng is_hotel |
check_local_pack_hotel() | (hợp nhất) | |
check_search_map_page() | check_map_page(is_hotel) | Tự động phát hiện nút「さらに表示」, phân trang chung |
check_search_map_page_hotel() | (hợp nhất) | |
check_search_map_page_service() | (hợp nhất) | |
check_map_single_location() | Hợp nhất vào check_map_page | Kiểm tra single location trước khi chuyển trang bản đồ |
check_search_map_page_static() | check_map_page_static() | Fallback cuối cùng (thay đổi nhẹ) |
| — | scan_map_listings() | Mới: Xử lý phân trang chung |
Xử lý phân trang chung (scan_map_listings)
Hợp nhất xử lý scan trang bản đồ lặp lại ở 3 nơi thành 1:
def scan_map_listings(page, location_title, heading_selector, max_pages=3):
"""Xử lý chung scan danh sách trang bản đồ và trả về ranking"""
location_index = 0
for current_page in range(1, max_pages + 1):
# Loại trừ sponsor
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
# Phân trang
if not click_next_page(page):
break
return False, None
def click_next_page(page):
"""Xử lý chung chuyển trang tiếp theo"""
next_selectors = [
("button span", "次へ"), # Cho khách sạn
("a span", "次へ"), # Cho dịch vụ
("#pnnext", None), # Cho bản đồ thường
('a[role="button"]', "もっと見る"), # Cho bản đồ thường (pattern khác)
]
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 FalseThống nhất giá trị trả về
Thống nhất giá trị trả về của tất cả hàm phán đoán thành 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 = 0Tập trung kiểm tra reCAPTCHA
Hiện tại kiểm tra reCAPTCHA ở tất cả 8 hàm, tập trung lại như sau:
| Thời điểm | Vị trí kiểm tra |
|---|---|
| Ngay sau tìm kiếm | 1 lần trong fetch_search_ranking → phát hiện thì return ngay |
| Sau chuyển trang | 1 lần trong check_map_page / check_map_page_static sau khi chuyển trang |
Xóa kiểm tra reCAPTCHA khỏi các hàm phán đoán riêng lẻ (check_knowledge_panel / check_local_pack).