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 "