""" 藥妝店商品爬蟲 — 康是美 / 寶雅 (91APP) / 屈臣氏 已驗證方法: 康是美: https://shop.cosmed.com.tw/search?q={keyword} 寶雅: https://www.poyabuy.com.tw/search?q={keyword} 兩家都用 91APP 前端,selector: a.product-card__vertical 屈臣氏: 先試直接呼叫 api.watsons.com.tw OAuth token(不用瀏覽器); 失敗才退回 Playwright Firefox 繞過 Cloudflare 取 token """ import re import unicodedata import urllib.parse import concurrent.futures import requests as _req from playwright.sync_api import sync_playwright, TimeoutError as PWTimeout TIMEOUT = 25_000 WATSONS_TIMEOUT = 35_000 UA = ( "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" ) FIREFOX_UA = ( "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:124.0) " "Gecko/20100101 Firefox/124.0" ) def _to_ascii(s: str) -> str: """將帶重音的 Unicode 字母正規化為 ASCII(ü→u、é→e),讓 NARÜKO 視為 NARUKO。""" return ''.join(c for c in unicodedata.normalize('NFD', s) if ord(c) < 128) # 促銷標籤模式 — 這些不是商品名稱,解析時跳過 _PROMO_RE = re.compile( r"^(POYA限定|買\d+送\d+|買一送一|買二送一|下單請選購\d+件|限定|特惠|" r"已售完|即將缺貨|\d+%\s*OFF|組合優惠|限時|新品|爆款|\d+折)$", re.IGNORECASE, ) # 相關性檢查用的英文停用詞 _EN_SKIP = frozenset({'ml', 'spf', 'pa', 'uva', 'uvb', 'and', 'the', 'for', 'plus', 'ultra'}) # 組合商品偵測:「X條/入/件」、「套組」、「A+B」、「雙入」、「年度組」等 _COMBO_RE = re.compile( r'[2-9][條入件支管根]|\d+[條入件支管根]裝?|套組|超值組|試用組|組合[裝包]?|\w\+\w' r'|[兩雙][入條件]|[三四五六七八九][入條件]' # 中文數字組合(雙入、兩條、三件…) r'|年度[組套]?|禮盒[組套]?|加購[組套]?' # 年度組、禮盒組、加購組 r'|[×x\*]\s*[2-9]\d*' # ×4、*2、x3 等乘量寫法(175g*4、175g×2) r'|\d+\s*[×x\*]\s*\d+', # 175g×2 的另一種位置(數字在前) re.IGNORECASE ) # 規格提取:「140g」、「230ml」、「50片」等 _SIZE_RE = re.compile(r'(\d+(?:\.\d+)?)\s*(g|ml|mL|公克|毫升|oz|片)', re.IGNORECASE) def _extract_sizes(text: str) -> set[str]: """提取文字中的規格(正規化,如 '140g'、'230ml')。""" sizes = set() for m in _SIZE_RE.finditer(text): num = m.group(1) unit = m.group(2).lower().replace('公克', 'g').replace('毫升', 'ml').replace('mL', 'ml') sizes.add(f"{num}{unit}") return sizes def _relevance_score(product_name: str, keyword: str) -> int: """ 多層次相關性分數: -1 = 品牌不符(排除) 0 = 品牌符合但無產品特徵詞,或產品型態末字不符 1+ = 產品特徵詞匹配分 +2 = 規格(g/ml)也符合(加在特徵分之上) """ name = product_name name_l = name.lower() # 將 ü/é 等重音字母正規化為 ASCII,讓 NARÜKO 作為完整 token 識別 kw_norm = _to_ascii(keyword) name_l_ascii = _to_ascii(name).lower() zh_terms = re.findall(r'[一-鿿]{2,}', keyword) en_tokens = [t for t in re.findall(r'[A-Za-z][A-Za-z-]*[A-Za-z]|[A-Za-z]{2,}', kw_norm) if t.lower() not in _EN_SKIP] if not zh_terms and not en_tokens: return 1 first_en = kw_norm.find(en_tokens[0]) if en_tokens else len(kw_norm) first_zh = keyword.find(zh_terms[0]) if zh_terms else len(keyword) en_brand_primary = bool(en_tokens) and first_en < first_zh zh_brand_2 = zh_terms[0][:2] if zh_terms else "" # 品牌檢查(英文品牌用 ASCII 正規化後比對;屈臣氏等有時只列中文品名,英文找不到才退而用中文) if en_brand_primary: en_brand = en_tokens[0].lower() if len(en_brand) >= 3: if en_brand not in name_l_ascii: # 英文品牌找不到 → 退而比對中文品牌名(如屈臣氏只有「海倫仙度絲」沒有「Head」) if not zh_brand_2 or zh_brand_2 not in name: return -1 else: if zh_brand_2 and zh_brand_2 not in name: return -1 else: if zh_brand_2 and zh_brand_2 not in name: return -1 # zh_terms[0] 是品牌詞時整個跳過 # 新增:英文品牌在前且有 ≥2 個中文詞 → 第一個必定是中文品牌名(如「高露潔」「海倫仙度絲」) skip_brand = ( not en_brand_primary or (en_tokens and len(en_tokens[0]) < 3) or (zh_terms and len(zh_terms[0]) <= 2) or (en_brand_primary and zh_terms and len(zh_terms) >= 2) ) # 產品型態錨點:取特徵詞中最長的那個的末 2 字,必須出現在商品名稱中。 # 用最長詞(通常是產品類型「全面修復霜」)而非最後一詞(可能是修飾詞「升級版」), # 防止「修復霜」被「面膜 升級版」蒙混通過。 if len(zh_terms) >= 2: feature_start = 1 if skip_brand else 0 ft = zh_terms[feature_start:] if ft: anchor = max(ft, key=len)[-2:] if anchor not in name: return 0 # 滑動視窗 2-gram:只計產品特徵詞,品牌詞全部跳過 # 特例:只有一個中文詞時(如「雅漾亮顏潤色防曬乳」),去掉前 2 字(品牌)後當特徵詞 match_keys: list[str] = [] for idx, term in enumerate(zh_terms): if idx == 0 and skip_brand: if len(zh_terms) > 1: continue # 多個詞時正常跳過品牌詞 # 只有單一長中文詞:去掉前 2 字(品牌部分) term = term[2:] if len(term) > 3 else term for i in range(len(term) - 1): chunk = term[i:i + 2] if chunk not in match_keys: match_keys.append(chunk) # 前段 gram 強制比對:特徵詞 ≥6 個 2-gram 時,前三個 gram 中 # 至少一個必須出現在商品名(辨識起始型態,防止 size_bonus 讓錯誤商品蒙混過門檻)。 # 用「任一」而非「第一個」,容許店家略去詞首的小字(如省略「清爽」→「極效」仍可通過)。 if len(match_keys) >= 6 and not any(g in name for g in match_keys[:3]): return 0 base = sum(1 for k in match_keys if k in name) # 規格加分:base ≥ 1(產品類型已符合)且規格也一致時才加分 kw_sizes = _extract_sizes(keyword) p_sizes = _extract_sizes(name) size_bonus = 2 if base >= 1 and kw_sizes and p_sizes and (kw_sizes & p_sizes) else 0 return base + size_bonus def _min_required_score(keyword: str) -> int: """ keyword 有 ≥2 個不重複特徵 2-gram 時要求 score ≥ 2, 避免「牙膏」「乳液」等通用詞讓不相關商品通過篩選。 """ kw_norm = _to_ascii(keyword) zh_terms = re.findall(r'[一-鿿]{2,}', keyword) en_tokens = [t for t in re.findall(r'[A-Za-z][A-Za-z-]*[A-Za-z]|[A-Za-z]{2,}', kw_norm) if t.lower() not in _EN_SKIP] first_en = kw_norm.find(en_tokens[0]) if en_tokens else len(kw_norm) first_zh = keyword.find(zh_terms[0]) if zh_terms else len(keyword) en_brand_primary = bool(en_tokens) and first_en < first_zh skip_brand = ( not en_brand_primary or (en_tokens and len(en_tokens[0]) < 3) or (zh_terms and len(zh_terms[0]) <= 2) or (en_brand_primary and zh_terms and len(zh_terms) >= 2) ) grams: set[str] = set() for idx, term in enumerate(zh_terms): if idx == 0 and skip_brand: if len(zh_terms) > 1: continue term = term[2:] if len(term) > 3 else term for i in range(len(term) - 1): grams.add(term[i:i + 2]) # 特徵詞越長,要求匹配的門檻越高: # ≥6 grams → 3(防止「去油光霧感防曬乳」混入「清爽極效全護防曬乳」結果) # ≥2 grams → 2 # 其餘 → 1 if len(grams) >= 6: return 3 elif len(grams) >= 2: return 2 else: return 1 def _size_ok(product_name: str, keyword: str) -> bool: """ 規格是否在合理範圍(差距 < 2 倍)。 無規格資訊時回傳 True(不懲罰)。 用於排序:回傳 False 的商品排到後面,但不會被過濾掉。 """ kw_sizes = _extract_sizes(keyword) p_sizes = _extract_sizes(product_name) if not kw_sizes or not p_sizes: return True try: kw_val = min(float(sz.rstrip('gml')) for sz in kw_sizes) p_val = min(float(sz.rstrip('gml')) for sz in p_sizes) return min(kw_val, p_val) <= 0 or max(kw_val, p_val) / min(kw_val, p_val) < 2.0 except (ValueError, ZeroDivisionError): return True def _size_diff(product_name: str, keyword: str) -> float: """回傳商品尺寸與搜尋尺寸的差距(數值越小越優先);無尺寸資訊時回傳 inf。""" kw_sizes = _extract_sizes(keyword) p_sizes = _extract_sizes(product_name) if not kw_sizes or not p_sizes: return float('inf') try: kw_val = min(float(s.rstrip('gml')) for s in kw_sizes) p_val = min(float(s.rstrip('gml')) for s in p_sizes) return abs(kw_val - p_val) except Exception: return float('inf') def _check_relevance(product_name: str, keyword: str) -> bool: """品牌符合且特徵詞匹配達到最低門檻(多特徵詞時要求 ≥2)。""" return _relevance_score(product_name, keyword) >= _min_required_score(keyword) def _parse_91app_card(card) -> tuple[str, str, str]: """ 從 a.product-card__vertical 的 innerText 解析商品名稱、現售價、原價。 91APP 卡片格式:原價(較高)在前,折後價(較低)在後。 回傳 (name, current_price, original_price),無折扣時 original_price 為空字串。 """ text = card.inner_text() lines = [l.strip() for l in text.split("\n") if l.strip()] name = "" for line in lines: if not _PROMO_RE.match(line) and len(line) > 2: name = line break # 找出所有價格,取最小值為現售價、最大值為原價 prices = [] for line in lines: m = re.search(r"NT\$\s*([\d,]+)", line) if m: prices.append(int(m.group(1).replace(",", ""))) if not prices: return name, "", "" current = min(prices) original = max(prices) current_str = f"NT${current}" original_str = f"NT${original}" if original != current else "" return name, current_str, original_str def _scrape_91app(store_name: str, base_url: str, keyword: str) -> dict | None: """通用 91APP 搜尋爬蟲。""" # 搜尋 URL 去掉規格數字(40ml、340g)和組合包數量(2入、3條…) # 讓店家回傳各規格/各入數的結果;排序時 _size_ok 挑規格、_COMBO_RE 挑單入 search_kw = re.sub( r'\s*\d+(?:\.\d+)?\s*(?:g|ml|mL|公克|毫升|oz|片)\b', '', keyword, flags=re.IGNORECASE ) search_kw = re.sub( r'\s*[2-9]\s*[條入件支管根]', '', search_kw ).strip() or keyword # 若英文品牌在前且有多個中文詞(如「CeraVe 適樂膚 長效潤澤修復霜」), # 第一個中文詞是英文品牌的中文翻譯,AND 兩個品牌名讓 91APP 找不到商品 → 移除 _sq_zh = re.findall(r'[一-鿿]{2,}', search_kw) _sq_en = [t for t in re.findall(r'[A-Za-z][A-Za-z-]*[A-Za-z]|[A-Za-z]{2,}', _to_ascii(search_kw)) if t.lower() not in _EN_SKIP] if (_sq_en and len(_sq_zh) >= 2 and search_kw.find(_sq_en[0]) < search_kw.find(_sq_zh[0])): search_kw = search_kw.replace(_sq_zh[0], '', 1).strip() # normalize double spaces after replace search_kw = ' '.join(search_kw.split()) encoded = urllib.parse.quote(search_kw) search_url = f"{base_url}/search?q={encoded}" print(f"[91app] {store_name} search_url={search_url!r}", flush=True) try: with sync_playwright() as pw: browser = pw.chromium.launch(headless=True, args=["--no-sandbox"]) page = browser.new_page(user_agent=UA) try: page.goto(search_url, wait_until="networkidle", timeout=TIMEOUT) except PWTimeout: pass cards = page.query_selector_all("a.product-card__vertical") print(f"[91app] {store_name} cards={len(cards)} min_req={_min_required_score(keyword)}", flush=True) # 收集所有相關商品候選,(score, is_combo, name, price, orig, href) candidates: list[tuple] = [] min_req = _min_required_score(keyword) for card in cards[:8]: href = card.get_attribute("href") or "" name, price, original_price = _parse_91app_card(card) if not name or not price: continue s = _relevance_score(name, keyword) print(f"[91app] {store_name} name={name!r} score={s}", flush=True) if s < min_req: continue combo = bool(_COMBO_RE.search(name)) candidates.append((s, combo, name, price, original_price, href)) browser.close() # 排序:非組合優先 → 規格相符優先(差距 < 2 倍)→ 分數高 → 尺寸最接近 # 規格不符的候選排到後面但不過濾,確保找不到相符規格時仍能回傳結果 if not candidates: return None candidates.sort(key=lambda x: ( x[1], # 非組合優先 0 if _size_ok(x[2], keyword) else 1, # 規格相符優先 -x[0], # 分數高優先 _size_diff(x[2], keyword), # 尺寸差距小優先 )) s, combo, name, price, original_price, href = candidates[0] product_url = f"{base_url}{href}" if href.startswith("/") else href return { "store": store_name, "name": name, "price": price, "original_price": original_price, "url": product_url, "real": True, } except Exception: return None def scrape_cosmed(keyword: str) -> dict | None: return _scrape_91app("康是美", "https://shop.cosmed.com.tw", keyword) def scrape_poya(keyword: str) -> dict | None: return _scrape_91app("寶雅", "https://www.poyabuy.com.tw", keyword) def _parse_watsons_json(data: dict, keyword: str) -> dict | None: """從 Watsons API JSON 解析商品資訊:用 _relevance_score 計分,優先非組合商品。""" products = data.get("products", []) if not products: return None # 過濾:相關性分數 ≥ min_required(多特徵詞時要求 ≥2,防止「牙膏」等通用詞誤選) min_req = _min_required_score(keyword) scored = [] for p in products: s = _relevance_score(p.get("name", ""), keyword) if s >= min_req: combo = bool(_COMBO_RE.search(p.get("name", ""))) scored.append((s, combo, p)) if not scored: return None # 排序:非組合優先 → 分數高 → 尺寸最接近搜尋規格 scored.sort(key=lambda x: (x[1], -x[0], _size_diff(x[2].get("name", ""), keyword))) _, _, matched = scored[0] name = matched.get("name", keyword) price_info = matched.get("price", {}) raw_price = price_info.get("value") if isinstance(price_info, dict) else price_info price = f"NT${int(raw_price)}" if raw_price else "請洽門市" # Watsons API 原價欄位 # strikeThroughPrice 可能是純數字(float)或 dict,需分別處理 # elabOldPrice / basePrice / wasPrice 是 dict with .value def _extract_price_value(v): if isinstance(v, (int, float)) and v: return float(v) if isinstance(v, dict): return v.get("value") return None list_price_info = price_info.get("listPrice", {}) if isinstance(price_info, dict) else {} raw_base = ( _extract_price_value(matched.get("strikeThroughPrice")) or _extract_price_value(matched.get("elabOldPrice")) or _extract_price_value(matched.get("basePrice")) or _extract_price_value(matched.get("wasPrice")) or _extract_price_value(list_price_info) ) original_price = f"NT${int(raw_base)}" if raw_base and raw_base != raw_price else "" code = matched.get("code", "") encoded_kw = urllib.parse.quote(keyword) url = ( f"https://www.watsons.com.tw/p/{code}" if code else f"https://www.watsons.com.tw/search?text={encoded_kw}" ) return { "store": "屈臣氏", "name": name, "price": price, "original_price": original_price, "url": url, "real": True, } def _watsons_api_search(cf, api_url: str, headers: dict, keyword: str, token: str | None) -> dict | None: """用 curl_cffi 呼叫一次 Watsons 搜尋 API(不帶或帶 token)。""" try: h = {**headers} if token: h["Authorization"] = f"bearer {token}" r = cf.get(api_url, headers=h, impersonate="chrome120", timeout=12) if r.ok: return _parse_watsons_json(r.json(), keyword) except Exception: pass return None def _watsons_via_curl(keyword: str) -> dict | None: """ 用 curl_cffi 模擬 Chrome TLS 指紋,繞過 Cloudflare bot 檢測。 漸進式搜尋:完整中文詞 → 退回品牌名;每個搜尋詞都試不帶/帶 token。 """ try: from curl_cffi import requests as cf except ImportError: return None zh_terms = re.findall(r'[一-鿿]{2,}', keyword) # 搜尋詞清單:由精確到寬鬆,中文精確詞優先,英文品牌兜底 search_attempts: list[str] = [] # 1. 中文詞精確搜尋(放最前,最重要) if zh_terms: brand = zh_terms[0] if len(zh_terms) > 1: product = zh_terms[1] search_attempts.append(brand + product) # 全部合併(最精確) for n in (4, 2): # 品牌 + 產品詞前 n 字 if len(product) >= n: combo = brand + product[:n] if combo not in search_attempts: search_attempts.append(combo) if brand not in search_attempts: search_attempts.append(brand) # 品牌名(保底) else: search_attempts.append(keyword) # 2. 若英文品牌開頭(如 NARÜKO、Colgate),英文品牌搜尋作為後備 # 放最後:中文搜尋找不到時才用英文(避免寬泛英文搜尋蓋掉精確中文結果) if keyword and keyword[0].isascii() and keyword[0].isalpha(): kw_ascii = _to_ascii(keyword) _en_m = re.match(r'^([A-Za-z][A-Za-z0-9\-]*(?:\s+[A-Za-z0-9\-]+)*)', kw_ascii) if _en_m: _en_brand = _en_m.group(1).strip() if zh_terms: _combo = _en_brand + zh_terms[0][:4] # e.g. "NARUKO茶樹" if _combo not in search_attempts: search_attempts.append(_combo) if _en_brand not in search_attempts: search_attempts.append(_en_brand) # e.g. "NARUKO"(最後兜底) base_headers = { "Accept": "application/json", "Accept-Language": "zh-TW,zh;q=0.9,en-US;q=0.8", "Origin": "https://www.watsons.com.tw", } # 先嘗試不帶 token 的所有搜尋詞(SAP Hybris 有時允許匿名) for st in search_attempts: kw = urllib.parse.quote(st) api_url = ( f"https://api.watsons.com.tw/api/v2/wtctw/products/search" f"?fields=FULL&query={kw}&pageSize=10¤tPage=0&lang=zh_TW&curr=TWD" ) headers = {**base_headers, "Referer": f"https://www.watsons.com.tw/search?text={kw}"} result = _watsons_api_search(cf, api_url, headers, keyword, token=None) if result: return result # 取 OAuth token(只做一次) token = None first_kw = urllib.parse.quote(search_attempts[0]) oauth_headers = { **base_headers, "Content-Type": "application/x-www-form-urlencoded", "Referer": f"https://www.watsons.com.tw/search?text={first_kw}", } for cid, csec in [ ("mobile", "secret"), ("client", "secret"), ("trusted_client", "secret"), ("mobile", ""), ("wtctw_mobile", "secret"), ]: try: tr = cf.post( "https://api.watsons.com.tw/oauth/token", data={"grant_type": "client_credentials", "client_id": cid, "client_secret": csec}, headers=oauth_headers, impersonate="chrome120", timeout=8, ) if tr.ok: token = tr.json().get("access_token") if token: break except Exception: continue if not token: return None # 帶 token 再試所有搜尋詞 for st in search_attempts: kw = urllib.parse.quote(st) api_url = ( f"https://api.watsons.com.tw/api/v2/wtctw/products/search" f"?fields=FULL&query={kw}&pageSize=10¤tPage=0&lang=zh_TW&curr=TWD" ) headers = {**base_headers, "Referer": f"https://www.watsons.com.tw/search?text={kw}"} result = _watsons_api_search(cf, api_url, headers, keyword, token=token) if result: return result return None def _watsons_via_firefox(keyword: str) -> dict | None: """Playwright Firefox 備援:攔截 OAuth token 後呼叫 API。""" zh_terms = re.findall(r'[一-鿿]{2,}', keyword) # 中文詞優先;英文品牌開頭但找不到中文詞時才用英文品牌 if zh_terms: search_term = "".join(zh_terms[:2]) # 例如 "雅漾清爽極效全護防曬乳" elif keyword and keyword[0].isascii() and keyword[0].isalpha(): kw_ascii = _to_ascii(keyword) _en_m = re.match(r'^([A-Za-z][A-Za-z0-9\-]*(?:\s+[A-Za-z0-9\-]+)*)', kw_ascii) search_term = _en_m.group(1).strip() if _en_m else keyword else: search_term = keyword kw = urllib.parse.quote(search_term) page_url = f"https://www.watsons.com.tw/search?text={kw}" try: with sync_playwright() as pw: browser = pw.firefox.launch(headless=True) context = browser.new_context( user_agent=FIREFOX_UA, locale="zh-TW", extra_http_headers={ "Accept-Language": "zh-TW,zh;q=0.8,en-US;q=0.5,en;q=0.3", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Upgrade-Insecure-Requests": "1", }, ) page = context.new_page() page.add_init_script( "Object.defineProperty(navigator, 'webdriver', {get: () => undefined});" ) access_token = None search_data = None def on_resp(resp): nonlocal access_token, search_data try: if "oauth/token" in resp.url and not access_token: body = resp.json() access_token = body.get("access_token") elif "products/search" in resp.url and not search_data: search_data = resp.json() except Exception: pass page.on("response", on_resp) try: page.goto(page_url, wait_until="networkidle", timeout=WATSONS_TIMEOUT) except Exception: pass context.close() browser.close() # 優先用瀏覽器攔截到的搜尋結果(最準確) if search_data: return _parse_watsons_json(search_data, keyword) # 其次用 token 再呼叫 API if access_token: kw_api = urllib.parse.quote(search_term) api_url = ( f"https://api.watsons.com.tw/api/v2/wtctw/products/search" f"?fields=FULL&query={kw_api}&pageSize=10¤tPage=0&lang=zh_TW&curr=TWD" ) try: resp = _req.get( api_url, headers={ "Authorization": f"bearer {access_token}", "Accept": "application/json", "Accept-Language": "zh-TW,zh;q=0.9", "User-Agent": FIREFOX_UA, "Origin": "https://www.watsons.com.tw", "Referer": f"https://www.watsons.com.tw/search?text={kw}", }, timeout=15, ) if resp.ok: return _parse_watsons_json(resp.json(), keyword) except Exception: pass except Exception: pass return None def scrape_watsons(keyword: str) -> dict | None: """ 屈臣氏搜尋三段式: 1. curl_cffi 模擬 Chrome TLS 指紋(輕量、快速) 2. Playwright Firefox 備援(慢但能攔截 token) """ result = _watsons_via_curl(keyword) if result: return result return _watsons_via_firefox(keyword) def discover_cosmed(keyword: str) -> dict | None: """ 二段式搜尋的第一階段:在康是美以寬鬆條件搜尋,回傳真實架上商品的完整結果 dict。 評分 ≥ 1 即可(品牌符合 + 至少一個特徵 gram),取非組合商品最高分者。 回傳格式與 scrape_cosmed 相同:{"store", "name", "price", "url", "real"} """ base_url = "https://shop.cosmed.com.tw" try: encoded = urllib.parse.quote(keyword) search_url = f"{base_url}/search?q={encoded}" with sync_playwright() as pw: browser = pw.chromium.launch(headless=True, args=["--no-sandbox"]) page = browser.new_page(user_agent=UA) try: page.goto(search_url, wait_until="networkidle", timeout=TIMEOUT) except Exception: pass base_url = "https://shop.cosmed.com.tw" cards = page.query_selector_all("a.product-card__vertical") best_score = -1 best_result = None for card in cards[:8]: href = card.get_attribute("href") or "" name, price, original_price = _parse_91app_card(card) if not name or not price: continue if _COMBO_RE.search(name): continue s = _relevance_score(name, keyword) if s >= 1 and s > best_score: best_score = s product_url = f"{base_url}{href}" if href.startswith("/") else href best_result = { "store": "康是美", "name": name, "price": price, "original_price": original_price, "url": product_url, "real": True, } browser.close() return best_result except Exception: return None def discover_watsons(keyword: str) -> dict | None: """ 快速探索屈臣氏商品名稱(僅走 curl 快速路徑,不啟動 Playwright)。 回傳格式與 scrape_watsons 相同:{"store", "name", "price", "url", "real"} """ return _watsons_via_curl(keyword) def scrape_watsons_and_poya(keyword: str) -> list[dict]: """屈臣氏 + 寶雅平行搜尋,供二段式搜尋的第二階段使用。""" tasks = {"屈臣氏": scrape_watsons, "寶雅": scrape_poya} results = [] with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor: future_map = {executor.submit(fn, keyword): store for store, fn in tasks.items()} try: for future in concurrent.futures.as_completed(future_map, timeout=65): try: data = future.result() if data: results.append(data) except Exception: pass except concurrent.futures.TimeoutError: for future in future_map: if future.done(): try: data = future.result() if data: results.append(data) except Exception: pass order = ["屈臣氏", "寶雅"] results.sort(key=lambda x: order.index(x["store"]) if x["store"] in order else 99) return results def scrape_all_stores(keyword: str) -> list[dict]: """ 並行爬取三家,回傳成功結果(最多 3 筆),依屈臣氏→康是美→寶雅排序。 每筆格式: {"store", "name", "price", "url", "real"} """ print(f"[scraper] keyword={keyword!r} min_req={_min_required_score(keyword)}", flush=True) tasks = { "屈臣氏": scrape_watsons, "康是美": scrape_cosmed, "寶雅": scrape_poya, } results = [] with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor: future_map = {executor.submit(fn, keyword): store for store, fn in tasks.items()} try: for future in concurrent.futures.as_completed(future_map, timeout=65): try: data = future.result() if data: results.append(data) except Exception: pass except concurrent.futures.TimeoutError: for future, store in future_map.items(): if future.done(): try: data = future.result() if data: results.append(data) except Exception: pass order = ["屈臣氏", "康是美", "寶雅"] results.sort(key=lambda x: order.index(x["store"]) if x["store"] in order else 99) return results