diff --git a/config.py b/config.py index ff0150e..7533862 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.588" +SYSTEM_VERSION = "V10.590" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/services/competitor_intel_repository.py b/services/competitor_intel_repository.py index 93e55f0..2db0405 100644 --- a/services/competitor_intel_repository.py +++ b/services/competitor_intel_repository.py @@ -265,6 +265,9 @@ DIFFERENCE_DIMENSION_LABELS = { "unknown_scent_variant_conflict": "香味未明確對齊", "nail_polish_color_name_conflict": "指彩色號不同", "nail_polish_model_code_conflict": "指彩型號不同", + "cetaphil_moisturizer_type_alignment": "舒特膚乳霜/潤膚霜同款型別對齊", + "focused_exact_identity_cetaphil_long_moisturizing_cream_250g": "舒特膚長效潤膚霜 250g 同款", + "focused_exact_identity_cetaphil_ad_repair_cream_227g": "舒特膚 AD 修護舒敏乳霜 227g 同款", "saugella_variant_conflict": "私密清潔款式不同", "lactacyd_variant_conflict": "私密清潔款式不同", "refill_pack_conflict": "補充包/正裝差異", diff --git a/services/marketplace_product_matcher.py b/services/marketplace_product_matcher.py index b0efa60..272b91e 100644 --- a/services/marketplace_product_matcher.py +++ b/services/marketplace_product_matcher.py @@ -514,6 +514,8 @@ FOCUSED_IDENTITY_VARIANT_REVIEW_BYPASS_REASONS = { "yes_nail_tool_exact_model_size", "cetaphil_long_lotion_237ml", "cetaphil_long_lotion_473ml", + "cetaphil_long_moisturizing_cream_250g", + "cetaphil_ad_repair_cream_227g", "clarins_double_serum_eye_20ml", "lab52_paw_patrol_baby_toothbrush_2pack", "derma_baby_wash_150ml", @@ -590,6 +592,8 @@ FOCUSED_IDENTITY_TOTAL_PRICE_REASONS = { "so_natural_fixx_setting_spray_120ml_plain", "cetaphil_long_lotion_237ml", "cetaphil_long_lotion_473ml", + "cetaphil_long_moisturizing_cream_250g", + "cetaphil_ad_repair_cream_227g", "clarins_double_serum_eye_20ml", "lab52_paw_patrol_baby_toothbrush_2pack", "derma_baby_wash_150ml", @@ -1678,6 +1682,22 @@ def _has_nivea_creme_100ml_alignment(left: ProductIdentity, right: ProductIdenti return all("妮維雅霜" in item.searchable_name and "隨身版" in item.searchable_name for item in (left, right)) +def _has_cetaphil_moisturizer_type_alignment(left: ProductIdentity, right: ProductIdentity) -> bool: + """Treat Cetaphil moisturizer wording variants as the same type only on exact named lines.""" + if not ({"cetaphil", "舒特膚"} & (left.brand_tokens & right.brand_tokens)): + return False + if {left.product_type, right.product_type} != {"乳液", "面霜"}: + return False + + left_text = left.searchable_name + right_text = right.searchable_name + if all("長效潤膚霜" in item for item in (left_text, right_text)): + return _has_shared_weight(left, right, 250) + if all("益膚康修護舒敏乳霜" in item for item in (left_text, right_text)): + return _has_shared_weight(left, right, 227) + return False + + def _has_refill_pack(identity: ProductIdentity) -> bool: text = identity.normalized_name return bool( @@ -2336,8 +2356,15 @@ def score_marketplace_match( spec_score, spec_conflict, spec_reasons = _spec_score(left, right) sequence_score = SequenceMatcher(None, left.searchable_name, right.searchable_name).ratio() chinese_name_score = _chinese_bigram_score(left, right) + nivea_creme_100ml_alignment = _has_nivea_creme_100ml_alignment(left, right) + cetaphil_moisturizer_type_alignment = _has_cetaphil_moisturizer_type_alignment(left, right) + type_aligned = ( + left.product_type == right.product_type + or nivea_creme_100ml_alignment + or cetaphil_moisturizer_type_alignment + ) if left.product_type and right.product_type: - type_score = 1.0 if left.product_type == right.product_type else 0.0 + type_score = 1.0 if type_aligned else 0.0 else: type_score = 0.55 @@ -2345,11 +2372,12 @@ def score_marketplace_match( if brand_reason: reasons.append(brand_reason) reasons.extend(spec_reasons) - 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: + if left.product_type and right.product_type and left.product_type != right.product_type and not type_aligned: reasons.append("type_conflict") if nivea_creme_100ml_alignment: reasons.append("nivea_creme_100ml_type_alignment") + if cetaphil_moisturizer_type_alignment: + reasons.append("cetaphil_moisturizer_type_alignment") model_line_conflict = _has_model_line_conflict(left, right) if model_line_conflict: reasons.append("model_line_conflict") @@ -2564,7 +2592,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 and not nivea_creme_100ml_alignment: + if left.product_type and right.product_type and left.product_type != right.product_type and not type_aligned: hard_veto = True if sun_protection_line_conflict: hard_veto = True @@ -4683,6 +4711,20 @@ 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 ( + {"cetaphil", "舒特膚"} & (left.brand_tokens & right.brand_tokens) + and "長效潤膚霜" in left_text + and "長效潤膚霜" in right_text + and _has_shared_weight(left, right, 250) + ): + return "cetaphil_long_moisturizing_cream_250g" + if ( + {"cetaphil", "舒特膚"} & (left.brand_tokens & right.brand_tokens) + and "益膚康修護舒敏乳霜" in left_text + and "益膚康修護舒敏乳霜" in right_text + and _has_shared_weight(left, right, 227) + ): + return "cetaphil_ad_repair_cream_227g" if ( {"nivea", "妮維雅"} & (left.brand_tokens & right.brand_tokens) and "妮維雅霜" in left_text diff --git a/services/pchome_crawler.py b/services/pchome_crawler.py index 47d294d..6774926 100644 --- a/services/pchome_crawler.py +++ b/services/pchome_crawler.py @@ -63,6 +63,17 @@ def _compact_identity_text(value: str) -> str: return re.sub(r"[^0-9a-zA-Z\u4e00-\u9fff]+", "", str(value or "").lower()) +def _remove_display_name_from_subtitle(display_name: str, subtitle: str) -> str: + """Remove one repeated display title from Nick while keeping useful promo/spec text.""" + cleaned = str(subtitle or "").strip() + title = str(display_name or "").strip() + if not cleaned or not title: + return cleaned + if title in cleaned: + cleaned = cleaned.replace(title, " ", 1) + return re.sub(r"\s+", " ", cleaned).strip() + + 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() @@ -78,6 +89,11 @@ def _build_match_name(name: str, subtitle: str) -> str: return nick if display_compact and display_compact == nick_compact: return display_name + if display_name and display_compact and display_compact in nick_compact: + reduced_nick = _remove_display_name_from_subtitle(display_name, nick) + if reduced_nick: + return f"{display_name} {reduced_nick}".strip() + return display_name return f"{display_name} {nick}".strip() diff --git a/templates/dashboard_v2.html b/templates/dashboard_v2.html index 9ef0a48..c422d13 100644 --- a/templates/dashboard_v2.html +++ b/templates/dashboard_v2.html @@ -49,9 +49,9 @@
{{ overview.review_queue_count | default(0) | number_format }}
重算待覆核{{ overview.rescore_accepted_count | default(0) | number_format }} + 身份採用{{ overview.catalog_identity_review_count | default(0) | number_format }} 需單位價{{ overview.unit_comparable_count | default(0) | number_format }} 待補抓{{ overview.pending_match_count | default(0) | number_format }} - 新鮮{{ overview.fresh_match_rate | default(0) }}%
@@ -123,8 +123,13 @@ 單位價覆核 {{ overview.unit_comparable_count | default(0) | number_format }} 筆需判斷容量、入數、組合價 - + 04 + 身份採用待核 + {{ overview.catalog_identity_review_count | default(0) | number_format }} 筆高信心同款,確認後可寫入正式比價 + + + 05 採用/否決候選 {{ overview.rescore_accepted_count | default(0) | number_format }} 筆近門檻候選等待確認 diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py index ec9cff0..f0b54e4 100644 --- a/tests/test_frontend_v2_assets.py +++ b/tests/test_frontend_v2_assets.py @@ -127,6 +127,7 @@ def test_campaign_v2_product_table_keeps_real_operations_columns(): def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data(): route_source = (ROOT / "routes/dashboard_routes.py").read_text(encoding="utf-8") dashboard = (ROOT / "templates/dashboard_v2.html").read_text(encoding="utf-8") + dashboard_css = (ROOT / "web/static/css/page-dashboard-v2.css").read_text(encoding="utf-8") assert "template_name = 'dashboard_v2.html'" in route_source assert "template_name = 'dashboard.html' if request.args.get('ui') == 'legacy' else 'dashboard_v2.html'" not in route_source @@ -192,6 +193,7 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data(): assert "決策支援覆蓋率" in dashboard assert "overview.decision_support_rate" in dashboard assert "overview.catalog_comparable_count" in dashboard + assert "overview.catalog_identity_review_count" in dashboard assert "比價決策焦點" in dashboard assert "overview.match_rate" in dashboard assert "overview.stale_match_count" in dashboard @@ -202,6 +204,9 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data(): assert "overview.review_queue" in dashboard assert "需單位價 {{ overview.unit_comparable_count" in dashboard assert "重算待覆核 {{ overview.rescore_accepted_count" in dashboard + assert "review_status='catalog_identity_review'" in dashboard + assert "身份採用待核" in dashboard + assert "grid-template-columns: repeat(5, minmax(0, 1fr))" in dashboard_css assert "{% if review_total_is_estimated %}約 {% endif %}" in dashboard assert "filter='ai_picks'" in dashboard assert "filter='pchome_review'" in dashboard diff --git a/tests/test_marketplace_product_matcher.py b/tests/test_marketplace_product_matcher.py index e4d4268..8a641f0 100644 --- a/tests/test_marketplace_product_matcher.py +++ b/tests/test_marketplace_product_matcher.py @@ -1597,6 +1597,16 @@ def test_marketplace_matcher_promotes_focused_manual_gate_exact_lines_to_total_p "舒特膚 長效潤膚乳 473ml", "focused_exact_identity_cetaphil_long_lotion_473ml", ), + ( + "【Cetaphil 舒特膚】官方直營 長效潤膚霜 250g(臉部身體乳霜/敏感肌/保濕/B3/B5/乾燥粗糙)", + "【Cetaphil 舒特膚】長效潤膚霜250g", + "focused_exact_identity_cetaphil_long_moisturizing_cream_250g", + ), + ( + "【Cetaphil 舒特膚】官方直營 AD益膚康修護舒敏乳霜 227g(臉部身體乳霜/益膚保濕)", + "Cetaphil舒特膚 AD益膚康修護舒敏乳霜227g", + "focused_exact_identity_cetaphil_ad_repair_cream_227g", + ), ] for momo_name, competitor_name, expected_reason in cases: @@ -1606,6 +1616,7 @@ def test_marketplace_matcher_promotes_focused_manual_gate_exact_lines_to_total_p assert diagnostics.price_basis == "total_price" assert diagnostics.alert_tier == "price_alert_exact" assert "variant_selection_review" not in diagnostics.reasons + assert "type_conflict" not in diagnostics.reasons if expected_reason: assert expected_reason in diagnostics.reasons diff --git a/tests/test_pchome_crawler_search.py b/tests/test_pchome_crawler_search.py index 879e453..6b859b6 100644 --- a/tests/test_pchome_crawler_search.py +++ b/tests/test_pchome_crawler_search.py @@ -149,6 +149,24 @@ def test_pchome_match_name_deduplicates_normalized_nick_prefix(): assert match_name.count("29g") == 1 +def test_pchome_match_name_deduplicates_marketing_prefix_before_title(): + from services.pchome_crawler import _build_match_name + + ad_cream = _build_match_name( + "Cetaphil舒特膚 AD益膚康修護舒敏乳霜227g", + "《即期特賣》Cetaphil舒特膚 AD益膚康修護舒敏乳霜227g", + ) + moisturizing_cream = _build_match_name( + "【Cetaphil 舒特膚】長效潤膚霜250g", + "48小時長效保濕升級版 【Cetaphil 舒特膚】長效潤膚霜250g", + ) + + assert ad_cream == "Cetaphil舒特膚 AD益膚康修護舒敏乳霜227g 《即期特賣》" + assert ad_cream.count("227g") == 1 + assert moisturizing_cream == "【Cetaphil 舒特膚】長效潤膚霜250g 48小時長效保濕升級版" + assert moisturizing_cream.count("250g") == 1 + + def test_pchome_match_name_strips_html_marketing_noise(): from services.pchome_crawler import _build_match_name diff --git a/web/static/css/page-dashboard-v2.css b/web/static/css/page-dashboard-v2.css index 9a2eb0c..a9df943 100644 --- a/web/static/css/page-dashboard-v2.css +++ b/web/static/css/page-dashboard-v2.css @@ -366,7 +366,7 @@ .dashboard-decision-lanes { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; min-width: 0; } @@ -1364,7 +1364,7 @@ } .dashboard-decision-lanes { - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); } .dashboard-ai-summary-grid {