diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index add3cfb..4090331 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,6 +4,7 @@ ================================================================================ 【已完成】 + - V10.584 補 PChome Nick 去重與 stale recovery 單品窄門:`Nick` 先去 HTML / 行銷星號 / 重複品名,避免 `29g`、`100ml` 被同一商品副標重複計數成 `component_count_conflict`;同步新增 NIVEA 妮維雅霜 100ml、Schick 舒綺敏感肌除毛刀片 3 入、TS6 沁涼潔淨慕斯 100g 的具名 exact total-price alignment。IBL 沐浴精+洗髮精 vs 洗髮精仍保留 identity review,唇釉色號/目錄款與 Paula's Choice 效期/金蓋差異仍不自動寫正式價差。 - V10.583 補 Paula's Choice 身體乳 PChome Nick 具名 alignment:`2%水楊酸身體乳210ml二入` 可和 PChome `Nick` 補出的 `水楊酸身體乳雙入組 / 210ml x2` 對齊,進 `exact / total_price / price_alert_exact`;但 `118ml二入組(金蓋限定版)` 對上 PChome 效期品仍保留 `manual_review / identity_review`,不泛用放寬中文入數。 - V10.582 補 PChome 比價通知專業分級與 Nick 副標身份證據:NemoTron 價格決策信封現在保留 `momo_price`、`competitor_price`、`candidate_gap_pct` 與 `sales_7d_delta_pct`,EventRouter / Telegram 模板會把 `match_type / price_basis / alert_tier` 翻成「直接價格威脅、單位價覆核、身份覆核、壓制告警」與操作邊界;PChome crawler 會保留 `Nick` 副標為 `match_name` 給 matcher 使用,UI/DB 顯示仍維持原品名,讓容量、入數、濃度資訊可參與比對。 - V10.581 將 V10.580 的重複單品組安全線接進 PChome retryable revalidation 窄門:只允許 `true_low_confidence`、舊診斷為 `match_type=exact / price_basis=manual_review`、無阻擋原因且命中具名安全商品線(Bioneo 150ml x2、Cetaphil 150ml x2、Avene 300ml x4、Schick 2+1 入、KOSE 雪肌精 500ml x2 禮盒)的候選進小批次重評;仍由最新版 matcher 最終判斷是否寫入正式 `competitor_prices`。 diff --git a/config.py b/config.py index ff1e8dc..5c408d6 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.583" +SYSTEM_VERSION = "V10.584" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/memory/current_execution_queue_20260524.md b/docs/memory/current_execution_queue_20260524.md index 5d5819b..65fbbfb 100644 --- a/docs/memory/current_execution_queue_20260524.md +++ b/docs/memory/current_execution_queue_20260524.md @@ -109,6 +109,7 @@ - 2026-06-04 起,`V10.581` 將重複單品組安全線接進 retryable revalidation:只收 `true_low_confidence` 中舊診斷為 `match_type=exact / price_basis=manual_review`、無 commercial / variant / count / bundle 等阻擋,且命中具名安全商品線的候選;最後仍由最新版 matcher 與 overwrite protection 決定是否寫正式比價。 - 2026-06-04 起,`V10.582` 補 PChome 比價通知專業分級與 Nick 副標身份證據:NemoTron 決策信封保留 MOMO / PChome 價格、價差與 7 日業績變化;Telegram decision envelope 將 `exact / total_price / price_alert_exact` 等工程路徑翻成直接價格威脅、單位價覆核、身份覆核或壓制告警,並把「單位價/身份未確認不得用總價直接告警」寫進操作邊界。PChome `Nick` 副標會以 `match_name` 參與 matcher,比價可用到容量、入數、濃度資訊,但不改 UI/DB 正式顯示品名。 - 2026-06-04 起,`V10.583` 補 Paula's Choice 身體乳 PChome Nick 具名 alignment:`2%水楊酸身體乳210ml二入` 可和 PChome `Nick` 補出的 `水楊酸身體乳雙入組 / 210ml x2` 對齊並進 safe total-price;此版不泛用放寬中文入數,`118ml二入組(金蓋限定版)` 對上 PChome 效期品仍維持 manual review。 +- 2026-06-04 起,`V10.584` 補 PChome Nick 清洗與 stale recovery 單品窄門:Nick 先去 HTML、行銷星號與重複品名,避免同一商品副標讓規格被重複計數;新增 NIVEA 妮維雅霜 100ml、Schick 舒綺敏感肌除毛刀片 3 入、TS6 沁涼潔淨慕斯 100g 具名 exact total-price alignment。IBL 沐浴/洗髮用途落差、唇色目錄款、效期/限定版差異仍留 review。 - 2026-06-04 起,`V10.578` 修正 Code Review deterministic scan 的 timeout 判定,多行 `requests.*(... timeout=...)` 不再被誤報為未設定 timeout。 - 2026-06-04 起,`V10.577` Code Review OpenClaw 會在 explicit Ollama host generate 前先做短 `/api/version` preflight;GCP-A 不通時快速跳 GCP-B,避免 15 秒 timeout 後才降級,且仍不呼叫 Gemini / 111。 - 2026-06-04 起,`V10.576` 修正 GCP-only Ollama retry:caller 禁用 111 fallback 時,resolver 若回到 111 會改試 GCP-A/GCP-B allowlist,不再讓 Hermes / Code Review 類任務因 resolver 快取到 111 而 `all 0 hosts failed`。 diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index 4dc934a..5d671c2 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-06-01:PChome 比價新鮮度操作閉環 +- **V10.584 PChome Nick 去重 + stale recovery 單品窄門**: PChome `Nick` 進 matcher 前會去除 HTML 標籤、星號行銷文與重複品名,避免同一個 `29g / 100ml` 被副標重複計數後誤判 `component_count_conflict`。依 10 筆正式 stale recovery 診斷,新增 NIVEA 妮維雅霜 100ml、Schick 舒綺敏感肌除毛刀片 3 入、TS6 沁涼潔淨慕斯 100g 的具名 exact total-price alignment;IBL 沐浴精+洗髮精 vs 洗髮精、唇釉色號目錄款、Paula's Choice 效期/金蓋差異仍維持 identity review。 - **V10.583 Paula's Choice 身體乳 PChome Nick 具名 alignment**: matcher 新增 Paula's Choice `2%水楊酸身體乳210ml二入` 的窄範圍 alignment,讓 PChome `Nick` 補出的 `水楊酸身體乳雙入組 / 210ml x2` 可和 MOMO 品名對齊並進 `exact / total_price / price_alert_exact`。此版不泛用放寬中文入數;`118ml二入組(金蓋限定版)` 對上 PChome 效期品仍維持 `manual_review / identity_review`,不寫正式價差。 - **V10.582 PChome 比價通知專業分級 + Nick 副標身份證據**: NemoTron 價格決策信封補齊 `momo_price`、`competitor_price`、`candidate_gap_pct` 與 `sales_7d_delta_pct`,避免 EventRouter / Telegram 模板拿不到核心價格事實。價格決策模板新增「通知分級」,將 `match_type / price_basis / alert_tier` 翻成直接價格威脅、單位價覆核、身份覆核或壓制告警,並同步顯示操作邊界;單位價或 identity 未確認時明確禁止以總價直接判定價格威脅。PChome crawler 另保留 `Nick` 副標為 `match_name` 給 matcher 使用,UI/DB 顯示仍用原品名,讓容量、入數、濃度資訊可參與比對。 - **V10.581 重複單品組 retryable revalidation 接線**: 將 V10.580 的安全 matcher 線接到 `run_retryable_candidate_revalidation()`,但只收 `true_low_confidence` 中舊診斷已是 `match_type=exact / price_basis=manual_review`、沒有 review block,且命中具名安全商品線的候選。這讓 Bioneo / Cetaphil / Avene / Schick / KOSE 等重複單品組可以小批次重評並由最新版 matcher 決定是否寫入正式 `competitor_prices`,仍不開放泛用高分掃描。 diff --git a/services/marketplace_product_matcher.py b/services/marketplace_product_matcher.py index daa87de..b0efa60 100644 --- a/services/marketplace_product_matcher.py +++ b/services/marketplace_product_matcher.py @@ -526,6 +526,7 @@ FOCUSED_IDENTITY_VARIANT_REVIEW_BYPASS_REASONS = { "ts6_lubricant_100g_3pack", "ts6_peach_bright_gel_45g_3pack", "ts6_white_wash_peach_gel_kit", + "ts6_cooling_clean_mousse_100g", "vaseline_baby_jelly_368g_3pack", } @@ -601,7 +602,10 @@ FOCUSED_IDENTITY_TOTAL_PRICE_REASONS = { "ts6_lubricant_100g_3pack", "ts6_peach_bright_gel_45g_3pack", "ts6_white_wash_peach_gel_kit", + "ts6_cooling_clean_mousse_100g", "vaseline_baby_jelly_368g_3pack", + "nivea_creme_100ml", + "schick_womens_sensitive_blade_3pack", } SEARCH_BROAD_ANCHORS = { @@ -1665,6 +1669,15 @@ def _has_paulas_choice_body_lotion_210ml_2pack_alignment( return has_two_pack(left) and has_two_pack(right) +def _has_nivea_creme_100ml_alignment(left: ProductIdentity, right: ProductIdentity) -> bool: + brand_tokens = left.brand_tokens | right.brand_tokens + if not ({"nivea", "妮維雅"} & brand_tokens): + return False + if not _has_shared_volume(left, right, 100): + return False + return all("妮維雅霜" in item.searchable_name and "隨身版" in item.searchable_name for item in (left, right)) + + def _has_refill_pack(identity: ProductIdentity) -> bool: text = identity.normalized_name return bool( @@ -2332,8 +2345,11 @@ def score_marketplace_match( if brand_reason: reasons.append(brand_reason) reasons.extend(spec_reasons) - if left.product_type and right.product_type and left.product_type != right.product_type: + nivea_creme_100ml_alignment = _has_nivea_creme_100ml_alignment(left, right) + if left.product_type and right.product_type and left.product_type != right.product_type and not nivea_creme_100ml_alignment: reasons.append("type_conflict") + if nivea_creme_100ml_alignment: + reasons.append("nivea_creme_100ml_type_alignment") model_line_conflict = _has_model_line_conflict(left, right) if model_line_conflict: reasons.append("model_line_conflict") @@ -2548,7 +2564,7 @@ def score_marketplace_match( hard_veto = True if chinese_name_score < 0.16 and token_score < 0.72: hard_veto = True - if left.product_type and right.product_type and left.product_type != right.product_type: + if left.product_type and right.product_type and left.product_type != right.product_type and not nivea_creme_100ml_alignment: hard_veto = True if sun_protection_line_conflict: hard_veto = True @@ -4562,6 +4578,14 @@ def _has_focused_low_score_exact_identity_line(left: ProductIdentity, right: Pro and _has_exact_count_alignment(left, right) ): return "ts6_white_mousse_180g_3pack" + if ( + {"ts6", "護一生"} & (left.brand_tokens & right.brand_tokens) + and "沁涼潔淨慕斯" in left_text + and "沁涼潔淨慕斯" in right_text + and _has_shared_weight(left, right, 100) + and _has_exact_count_alignment(left, right) + ): + return "ts6_cooling_clean_mousse_100g" if ( {"ts6", "護一生"} & (left.brand_tokens & right.brand_tokens) and "蜜愛潤滑液" in left_text @@ -4659,6 +4683,15 @@ def _has_focused_low_score_exact_identity_line(left: ProductIdentity, right: Pro and _has_shared_volume(left, right, 473) ): return "cetaphil_long_lotion_473ml" + if ( + {"nivea", "妮維雅"} & (left.brand_tokens & right.brand_tokens) + and "妮維雅霜" in left_text + and "妮維雅霜" in right_text + and "隨身版" in left_text + and "隨身版" in right_text + and _has_shared_volume(left, right, 100) + ): + return "nivea_creme_100ml" if ( "nailmatic" in (left.brand_tokens & right.brand_tokens) and "小精靈" in left_text @@ -4801,6 +4834,17 @@ def _has_focused_low_score_exact_identity_line(left: ProductIdentity, right: Pro and "唇頰兩用" in pair_text ): return "benefit_lip_tint" + if ( + {"schick", "舒適牌"} & (left.brand_tokens & right.brand_tokens) + and "舒綺" in left_text + and "舒綺" in right_text + and "除毛刀片" in left_text + and "除毛刀片" in right_text + and "敏感肌" in left_text + and "敏感肌" in right_text + and _has_shared_count(left, right, 3, "入") + ): + return "schick_womens_sensitive_blade_3pack" if ( {"herb24", "草本"} & (left.brand_tokens & right.brand_tokens) and "晨霧純精油擴香儀ii" in left_text diff --git a/services/pchome_crawler.py b/services/pchome_crawler.py index f8cc43f..47d294d 100644 --- a/services/pchome_crawler.py +++ b/services/pchome_crawler.py @@ -11,6 +11,7 @@ API 參考: - 圖片 URL: https://img.pchome.com.tw/cs{Pic.B} """ +import html import re import time import logging @@ -49,14 +50,34 @@ class PChomeProduct: return data +def _clean_subtitle_text(value: str) -> str: + """Normalize PChome Nick text before using it as identity evidence.""" + text = html.unescape(str(value or "")) + text = re.sub(r"<\s*br\s*/?\s*>", " ", text, flags=re.I) + text = re.sub(r"<[^>]+>", " ", text) + text = re.sub(r"[★☆][^★☆]{0,80}[★☆]", " ", text) + return re.sub(r"\s+", " ", text).strip() + + +def _compact_identity_text(value: str) -> str: + return re.sub(r"[^0-9a-zA-Z\u4e00-\u9fff]+", "", str(value or "").lower()) + + def _build_match_name(name: str, subtitle: str) -> str: """Build an identity-rich title without duplicating the PChome display name.""" display_name = str(name or '').strip() - nick = str(subtitle or '').strip() + nick = _clean_subtitle_text(subtitle) if not nick or nick == display_name: return display_name - if display_name and nick.startswith(display_name): + display_compact = _compact_identity_text(display_name) + nick_compact = _compact_identity_text(nick) + if display_name and ( + nick.startswith(display_name) + or (display_compact and nick_compact.startswith(display_compact)) + ): return nick + if display_compact and display_compact == nick_compact: + return display_name return f"{display_name} {nick}".strip() @@ -348,7 +369,7 @@ class PChomeCrawler: image_url = f"{self.IMAGE_BASE_URL}{pic_path}" if pic_path else '' name = data.get('Name', '') or '' - subtitle = data.get('Nick', '') or '' + subtitle = _clean_subtitle_text(data.get('Nick', '') or '') return PChomeProduct( product_id=product_id, diff --git a/tests/test_marketplace_product_matcher.py b/tests/test_marketplace_product_matcher.py index b5fb4f2..e4d4268 100644 --- a/tests/test_marketplace_product_matcher.py +++ b/tests/test_marketplace_product_matcher.py @@ -1425,6 +1425,62 @@ def test_marketplace_matcher_keeps_expiry_or_variant_pack_in_manual_review(): assert "safe_multi_component_exact_total_price" not in diagnostics.reasons +def test_marketplace_matcher_promotes_nivea_creme_100ml_type_alignment(): + from services.marketplace_product_matcher import score_marketplace_match + + diagnostics = score_marketplace_match( + "【NIVEA 妮維雅】妮維雅霜100ml 隨身版(小藍罐 身體乳霜 臉部身體適用)", + "NIVEA 妮維雅 妮維雅霜100ml 隨身版(小藍罐/身體乳霜/臉部身體適用)", + ) + + assert diagnostics.hard_veto is False + assert diagnostics.match_type == "exact" + assert diagnostics.price_basis == "total_price" + assert diagnostics.alert_tier == "price_alert_exact" + assert "nivea_creme_100ml_type_alignment" in diagnostics.reasons + assert "focused_exact_identity_nivea_creme_100ml" in diagnostics.reasons + + +def test_marketplace_matcher_promotes_recent_stale_recovery_single_spec_lines(): + from services.marketplace_product_matcher import score_marketplace_match + + cases = [ + ( + "【LAURA MERCIER 蘿拉蜜思】煥顏透明蜜粉29g超殺組(冠軍蜜粉/定妝散粉)", + "【Laura Mercier 蘿拉蜜思】煥顏透明蜜粉 29g 專櫃公司貨 Translucent Loose Setting Powder", + ), + ( + "【TS6 護一生】沁涼潔淨慕斯100g(私密清潔)", + "TS6護一生 沁涼潔淨慕斯100g", + ), + ( + "【Schick 舒適牌】舒綺仕女 除毛刀片 刀片3入(敏感肌)", + "【Schick 舒適牌】舒綺仕女除毛刀片(敏感肌用)3入", + ), + ] + + for momo_name, competitor_name in cases: + diagnostics = score_marketplace_match(momo_name, competitor_name) + assert diagnostics.hard_veto is False + assert diagnostics.match_type == "exact" + assert diagnostics.price_basis == "total_price" + assert diagnostics.alert_tier == "price_alert_exact" + + +def test_marketplace_matcher_keeps_baby_shampoo_bath_gap_in_identity_review(): + from services.marketplace_product_matcher import score_marketplace_match + + diagnostics = score_marketplace_match( + "【IBL 依必朗】依必朗 Angel Baby 嬰兒 沐浴精 洗髮精 600ml", + "依必朗Angel Baby嬰兒洗髮精600ml", + ) + + assert diagnostics.hard_veto is False + assert diagnostics.match_type == "comparable" + assert diagnostics.price_basis == "manual_review" + assert diagnostics.alert_tier == "identity_review" + + def test_marketplace_matcher_promotes_focused_lip_care_exact_lines_to_total_price(): from services.marketplace_product_matcher import score_marketplace_match diff --git a/tests/test_pchome_crawler_search.py b/tests/test_pchome_crawler_search.py index 598b10e..670eeb9 100644 --- a/tests/test_pchome_crawler_search.py +++ b/tests/test_pchome_crawler_search.py @@ -137,6 +137,32 @@ def test_pchome_match_name_combines_non_duplicate_nick(): ) +def test_pchome_match_name_deduplicates_normalized_nick_prefix(): + from services.pchome_crawler import _build_match_name + + match_name = _build_match_name( + "【Laura Mercier 蘿拉蜜思】 煥顏透明蜜粉 29g", + "【Laura Mercier 蘿拉蜜思】煥顏透明蜜粉 29g 專櫃公司貨", + ) + + assert match_name == "【Laura Mercier 蘿拉蜜思】煥顏透明蜜粉 29g 專櫃公司貨" + assert match_name.count("29g") == 1 + + +def test_pchome_match_name_strips_html_marketing_noise(): + from services.pchome_crawler import _build_match_name + + match_name = _build_match_name( + "TS6護一生沁涼潔淨慕斯100g", + '★降溫限定。92%滿意★
TS6護一生 沁涼潔淨慕斯100g', + ) + + assert "