LineBOT / scraper.py
Nai-Yun's picture
91app debug log:印出 search_url 和各商品 relevance score
9cabac1
Raw
History Blame Contribute Delete
30.8 kB
"""
藥妝店商品爬蟲 — 康是美 / 寶雅 (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&currentPage=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&currentPage=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&currentPage=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