File size: 30,807 Bytes
7d26fcb
 
 
 
 
 
7fee15b
 
7d26fcb
 
 
2c67d71
7d26fcb
 
7fee15b
7d26fcb
 
 
 
 
 
 
 
 
7fee15b
 
 
 
7d26fcb
2c67d71
 
 
 
 
7d26fcb
 
 
 
 
 
 
bf40615
 
a5269fb
 
b71c23f
 
 
 
 
a5269fb
 
65379ba
 
 
 
 
 
 
 
 
 
 
 
bf40615
 
6c489f5
bf40615
65379ba
 
2c67d71
65379ba
 
bf40615
 
 
2c67d71
 
 
bf40615
2c67d71
bf40615
 
6c489f5
2c67d71
bf40615
 
 
c74c07a
bf40615
 
 
2c67d71
c74c07a
 
 
bf40615
 
6c489f5
bf40615
 
6c489f5
2c67d71
c3788d8
88c4caf
2c67d71
 
 
c3788d8
88c4caf
1b9550e
 
 
2c67d71
1b9550e
 
 
 
 
 
88c4caf
b4cfc62
bf40615
88c4caf
 
b4cfc62
 
 
 
dedc81e
bf40615
88c4caf
bf40615
2d68305
cd99552
 
 
 
2d68305
 
65379ba
 
 
 
 
 
 
 
bf40615
 
dedc81e
 
 
 
 
2c67d71
dedc81e
2c67d71
dedc81e
2c67d71
dedc81e
 
88c4caf
 
 
 
c3788d8
88c4caf
dedc81e
88c4caf
 
b4cfc62
 
 
dedc81e
88c4caf
c74c07a
 
 
 
 
 
 
 
 
 
dedc81e
 
a2b2f5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eb51763
 
 
 
 
 
 
 
 
 
 
 
 
 
6c489f5
dedc81e
 
bf40615
7d26fcb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a61458a
 
8507d5b
 
 
a61458a
 
 
 
8507d5b
acf6f8b
 
 
 
 
 
 
 
9cabac1
 
8507d5b
7d26fcb
9cabac1
7d26fcb
 
 
 
 
 
 
 
 
 
 
9cabac1
6c489f5
 
dedc81e
cd99552
bf40615
 
6c489f5
 
 
9cabac1
dedc81e
6c489f5
 
 
7d26fcb
a2b2f5a
 
6c489f5
 
a2b2f5a
 
 
 
 
 
6c489f5
 
 
 
 
 
 
 
 
 
7d26fcb
 
 
 
 
 
 
 
 
 
 
 
39b4de1
6c489f5
39b4de1
 
 
 
dedc81e
 
6c489f5
 
 
dedc81e
6c489f5
 
b4582d5
6c489f5
39b4de1
 
eb51763
 
6c489f5
4e819c7
39b4de1
 
 
 
 
6883b50
 
 
 
 
 
 
 
 
8f9622e
 
6883b50
 
 
 
 
 
221634c
39b4de1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9ac379b
 
 
 
 
 
 
 
 
 
 
 
 
 
39b4de1
 
 
9ac379b
39b4de1
 
 
 
 
 
 
 
a2cbd65
9ac379b
2c67d71
a2cbd65
9ac379b
4e819c7
9ac379b
4e819c7
 
 
 
 
 
 
 
 
9ac379b
 
 
a2cbd65
 
 
 
 
 
 
 
 
 
 
 
 
 
39b4de1
 
 
 
 
 
9ac379b
 
 
 
 
 
 
 
 
 
 
39b4de1
9ac379b
39b4de1
9ac379b
 
 
 
 
 
39b4de1
7fee15b
 
 
39b4de1
7fee15b
 
 
39b4de1
9ac379b
39b4de1
 
 
7fee15b
 
39b4de1
 
7fee15b
39b4de1
7fee15b
39b4de1
 
 
 
 
9ac379b
 
 
 
 
 
39b4de1
9ac379b
 
 
 
39b4de1
7fee15b
9262b56
7d26fcb
39b4de1
 
 
a2cbd65
 
 
 
2c67d71
 
a2cbd65
2c67d71
a2cbd65
39b4de1
 
 
7d26fcb
505abcf
9262b56
505abcf
9262b56
505abcf
 
9262b56
505abcf
 
 
 
 
9262b56
 
 
505abcf
7d26fcb
39b4de1
7d26fcb
 
39b4de1
7d26fcb
 
 
 
39b4de1
 
7d26fcb
 
 
 
 
 
 
 
 
505abcf
 
7fee15b
39b4de1
 
 
7d26fcb
39b4de1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d26fcb
39b4de1
7fee15b
39b4de1
7fee15b
 
 
 
39b4de1
 
 
7fee15b
39b4de1
 
 
 
7fee15b
7d26fcb
d66dcac
f5bd8ad
d66dcac
 
 
f5bd8ad
 
 
 
 
 
 
 
 
 
 
 
108601d
f5bd8ad
 
d66dcac
 
108601d
 
f5bd8ad
 
 
 
 
 
 
108601d
d66dcac
 
 
 
108601d
 
d66dcac
 
f5bd8ad
d66dcac
f5bd8ad
 
 
 
5d7ce2b
 
 
 
 
 
 
 
d66dcac
 
 
 
26c0c7d
d66dcac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d26fcb
 
 
 
 
dedc81e
7d26fcb
 
 
 
 
 
26c0c7d
7d26fcb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
"""
藥妝店商品爬蟲 — 康是美 / 寶雅 (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