Feature #178
已結束重構搜尋引擎為 GraphQL API 分頁、修正獨立衛浴誤判、強化 UI 互動體驗
概述
h1. 背景
上一版(e8be719)為初始版本,僅支援基本文字搜尋與滾動載入。本次迭代針對以下問題進行大幅重構:
- 獨立衛浴判斷錯誤(Bug)— "Hello Verona Rooms" 等混合房型飯店被 roomfacility=38 伺服器端篩選直接排除;GraphQL 的 nbBathrooms 欄位不可靠(僅回傳 1 個 unitConfiguration,且值經常為 0)
- 搜尋引擎效率不足 — 滾動載入速度慢、無法精確控制分頁
- UI 互動不便 — 無法複製飯店名稱/地址、無地圖視覺化、缺少 Google 評分整合
變更範圍
┌─────────────┬─────────────┬────────────────────────────────────┐
│ 檔案 │ 異動統計 │ 說明 │
├─────────────┼─────────────┼────────────────────────────────────┤
│ scraper.py │ +612 / - 127│ GraphQL 引擎重構 + 衛浴判斷修正 │
├─────────────┼─────────────┼────────────────────────────────────┤
│ main.py │ +196 / - 17 │ 管線強化:去重、早期淘汰、多層過濾 │
├─────────────┼─────────────┼────────────────────────────────────┤
│ app.py │ +237 / - 17 │ UI 增強:複製表格、地圖、新篩選項 │
├─────────────┼─────────────┼────────────────────────────────────┤
│ maps_api.py │ +38 │ 新增 Google 評分查詢 │
├─────────────┼─────────────┼────────────────────────────────────┤
│ config.py │ +1 │ 新增 ENABLE_PRIVATE_BATH 設定 │
└─────────────┴─────────────┴────────────────────────────────────┘
一、搜尋引擎重構(scraper.py)
1.1 GraphQL API 分頁
- 攔截 POST /dml/graphql(operationName: FullSearch)取得 query + variables 模板
- 透過 page.evaluate(fetch) 在瀏覽器 context 內分頁呼叫,繞過 CORS
- URL offset 參數已棄用,改用 GraphQL variables.input.pagination.offset
- GraphQL 直接提供:座標、地址、價格、評分、評論數、房型面積、免費取消政策
- 保留滾動載入作為 fallback(_scrape_with_scroll)
1.2 座標搜尋(Plan B)
- 偵測輸入為座標格式時,使用 latitude= + longitude= URL 參數
- ss 參數為純文字搜尋,座標放入 ss 會回傳錯誤城市的結果
1.3 去重機制
- GraphQL 層:依 hotel ID 即時去重(all_hotels dict)
- 管線層:依 hotel ID → URL 路徑 → 飯店名稱三級 fallback
二、獨立衛浴判斷修正(scraper.py + main.py)— Bug Fix
問題根因(經 6 支測試腳本驗證):
┌───────────────────────────────────┬───────────────────────────────────────────────────────────────────────────────┐
│ 方法 │ 問題 │
├───────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ roomfacility=38(伺服器端篩選) │ 過度篩選:混合房型飯店(有私人+共用)被完全排除(約 6% 結果) │
├───────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ nbBathrooms(GraphQL 欄位) │ 不可靠:僅回傳 1 個 unitConfiguration,值經常為 0 │
├───────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ page.content().lower() 關鍵字搜尋 │ 誤判:搜到 <script> 標籤內的樣板文字(如 FAQ 區的「有附私人衛浴的客房嗎?」) │
├───────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ 「私人」單詞比對 │ 假陽性:「私人停車場」「私人保險箱」等非衛浴設施也會匹配 │
└───────────────────────────────────┴───────────────────────────────────────────────────────────────────────────────┘
修正方案:
- 移除 roomfacility=38(_build_nflt())— 不再在 URL 層面篩選
- 移除 nbBathrooms 邏輯(_parse_graphql_results())— private_bathroom 設為 None,交由詳細頁判斷
- 改寫 _confirm_private_bathroom() — 使用 JavaScript TreeWalker API 遍歷 DOM 可見文字節點:
- 排除 SCRIPT / STYLE / NOSCRIPT 標籤
- 使用複合關鍵字(修飾詞+衛浴詞),避免單詞假陽性
正向關鍵字(有任一即判定 True):
| 繁體中文 | English |
| 私人衛浴、私人浴室 | private bathroom |
| 獨立衛浴、獨立浴室 | ensuite / en suite / en-suite |
| 房內衛浴、房內浴室 | attached bathroom |
| 專屬衛浴、專屬浴室 | in-room bathroom |
| 附設衛浴、附設浴室 | |
負向關鍵字(僅有負向時判定 False):
| 繁體中文 | English |
| 共用衛浴/浴室 | shared bathroom |
| 共享衛浴/浴室 | communal bathroom |
| 公共衛浴/浴室 | common bathroom |
4. 所有飯店都爬詳細頁(main.py)— hotels_needing_detail 改為包含所有有 URL 的飯店
- 移除 is None 保護(main.py)— private_bathroom 一律使用詳細頁結果覆寫
判斷邏輯: 有正向 → True;僅有負向 → False;無關鍵字 → False
三、詳細頁面資料補充(scraper.py)
- 房間面積:支援 m² 與 ft²(自動轉換)
- 地址提取:4 種 DOM selector + JSON-LD fallback
- 座標提取:5 種策略(JSON-LD → b_map_center → data-atlas-latlng → staticmap URL → meta tags)
- 評分/評論數:JSON-LD → DOM selector → HTML regex fallback
四、管線強化(main.py)
- 早期淘汰:在詳細頁爬取前,依評分 + 評論數先過濾,減少爬取量
- 六層過濾鏈:獨立衛浴 → 評分 → 評論數 → 步行上限 → 大眾運輸 → 自駕
- Google 評分整合:可選啟用,透過 Find Place + Place Details API
- 進度回呼:progress_callback(stage, message) 供 Streamlit UI 即時顯示
五、Streamlit UI 增強(app.py)
- 目的地預覽地圖:輸入目的地後即時顯示地圖(Folium)
- 可點擊複製表格:飯店名稱、地址欄位點擊即複製到剪貼簿
- 雙檢視模式:「點擊複製」tab + 「排序檢視」tab
- 飯店結果地圖:搜尋結果在 Folium 地圖上標記,自動調整視角
- 新篩選選項:獨立衛浴 toggle、Google 評分 toggle、最低評論數
- 日誌顯示改進:固定高度容器 + 自動捲到底部
六、Google Maps API 擴充(maps_api.py)
- 新增 get_google_rating(hotel_name, address) 方法
- 流程:Find Place(取得 place_id)→ Place Details(取得 rating + user_ratings_total)
- 需啟用 Places API(Legacy)
測試驗證
以下測試腳本已執行並產出 JSON 結果(不納入 commit):
┌───────────────────────────────┬───────────────────────────────────────────────────────────┐
│ 腳本 │ 目的 │
├───────────────────────────────┼───────────────────────────────────────────────────────────┤
│ test_roomfacility38.py │ 驗證 roomfacility=38 會過濾掉 6% 混合房型 │
├───────────────────────────────┼───────────────────────────────────────────────────────────┤
│ test_unit_configs.py │ 驗證 GraphQL 只回傳 1 個 unitConfig、nbBathrooms 不可靠 │
├───────────────────────────────┼───────────────────────────────────────────────────────────┤
│ test_facility38_debug.py │ 驗證 genericfacilityhighlight 在 page.content() 中不存在 │
├───────────────────────────────┼───────────────────────────────────────────────────────────┤
│ test_bath_keywords_collect.py │ 收集 Verona 飯店衛浴關鍵字 │
├───────────────────────────────┼───────────────────────────────────────────────────────────┤
│ test_bath_keywords_broad.py │ 跨城市(巴黎/倫敦/東京/曼谷/台北/愛丁堡)多語系關鍵字收集 │
├───────────────────────────────┼───────────────────────────────────────────────────────────┤
│ test_facility38_verify.py │ 驗證 id:38 + rt-name-link 方案不可行 │
└───────────────────────────────┴───────────────────────────────────────────────────────────┘
SC 是由 Sashiba Chou 於 23 天 前更新
- 完成日期 設定為 2026-03-12
- 被分派者 設定為 Sashiba Chou
- 完成比例 從 0 變更為 100
- 預估工時 設定為 12:00 小時
SC 是由 Sashiba Chou 於 23 天 前更新
- 狀態 從 New 變更為 Closed