| """ |
| 藥妝店商品爬蟲 — 康是美 / 寶雅 (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'}) |
| |
| _COMBO_RE = re.compile( |
| r'[2-9][條入件支管根]|\d+[條入件支管根]裝?|套組|超值組|試用組|組合[裝包]?|\w\+\w' |
| r'|[兩雙][入條件]|[三四五六七八九][入條件]' |
| r'|年度[組套]?|禮盒[組套]?|加購[組套]?' |
| r'|[×x\*]\s*[2-9]\d*' |
| r'|\d+\s*[×x\*]\s*\d+', |
| re.IGNORECASE |
| ) |
| |
| _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() |
| |
| 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 "" |
| |
| if en_brand_primary: |
| en_brand = en_tokens[0].lower() |
| if len(en_brand) >= 3: |
| if en_brand not in name_l_ascii: |
| |
| 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 |
| |
| |
| 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) |
| ) |
| |
| |
| |
| 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 |
| |
| |
| match_keys: list[str] = [] |
| 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): |
| chunk = term[i:i + 2] |
| if chunk not in match_keys: |
| match_keys.append(chunk) |
|
|
| |
| |
| |
| 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) |
|
|
| |
| 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]) |
| |
| |
| |
| |
| 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 搜尋爬蟲。""" |
| |
| |
| 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 |
| |
| |
| _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() |
| |
| 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) |
| |
| 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() |
| |
| |
| 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_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 "請洽門市" |
|
|
| |
| |
| |
| 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] = [] |
|
|
| |
| 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): |
| 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) |
|
|
| |
| |
| 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] |
| if _combo not in search_attempts: |
| search_attempts.append(_combo) |
| if _en_brand not in search_attempts: |
| search_attempts.append(_en_brand) |
|
|
| base_headers = { |
| "Accept": "application/json", |
| "Accept-Language": "zh-TW,zh;q=0.9,en-US;q=0.8", |
| "Origin": "https://www.watsons.com.tw", |
| } |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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) |
|
|
| |
| 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 |
|
|