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 @@