diff --git a/server/tracker/services/attribution.py b/server/tracker/services/attribution.py index 7ae9824..5411a12 100644 --- a/server/tracker/services/attribution.py +++ b/server/tracker/services/attribution.py @@ -19,8 +19,18 @@ from tracker import constants as C _CTRL_CHARS = re.compile(r"[\x00-\x1f\x7f]") _GENERIC_TLDS = ("com",) # 引擎 host 落在这些 TLD 时不作为国家 variant 显示 -# UTM 标签 ↔ 平台名别名(冲突判定前归一):缩写/中文品牌名 → 标准名;baidu 恒等仅为可读 -_UTM_ALIASES = {"fb": "facebook", "ig": "instagram", "tt": "tiktok", +# 广告平台家族归一表(冲突判定前):同一家族的各种写法(缩写/全称/带 ads 后缀/ +# 中文俄文别称)→ 家族名。fbclid 推出的 "Meta Ads" 与 utm_source=fb/ig 同属 meta +# 家族 → 印证而非矛盾。键为去空格后的紧凑形态("meta ads" → "metaads")。 +_AD_FAMILIES = { + "fb": "meta", "facebook": "meta", "fbads": "meta", + "ig": "meta", "instagram": "meta", "meta": "meta", "metaads": "meta", + "google": "google", "googleads": "google", "adwords": "google", + "bing": "bing", "bingads": "bing", "microsoftads": "bing", + "yandex": "yandex", "yandexads": "yandex", "директ": "yandex", +} +# UTM 标签 ↔ 平台名别名(家族表之外的零散别名);baidu 恒等仅为可读 +_UTM_ALIASES = {"tt": "tiktok", "wa": "whatsapp", "g": "google", "baidu": "baidu", "百度": "baidu"} @@ -94,8 +104,10 @@ def _in_app_name(ua: str) -> str: def _canon(name: str) -> str: """平台名/UTM 标签宽松归一(仅用于冲突判定):小写 → 去协议与路径 → 去 www. 前缀 - → 去最后一段域名后缀(chatgpt.com→chatgpt、google.ru→google)→ 别名表 → 仅留 - 字母数字(含汉字)。fb↔Facebook、百度↔baidu 归一后同源,不再误报 conflict。""" + → 去最后一段域名后缀(chatgpt.com→chatgpt、google.ru→google)→ 去空格("meta + ads"→"metaads")→ 家族表(fb/ig↔Meta Ads→meta;adwords→google;директ→yandex) + → 别名表 → 仅留字母数字(含汉字)。同广告家族(meta/google/bing/yandex)归一后 + 同源,不再误报 conflict。""" s = (name or "").lower().strip() if "://" in s: s = s.split("://", 1)[1] @@ -104,6 +116,9 @@ def _canon(name: str) -> str: s = s[4:] if "." in s: s = s.rsplit(".", 1)[0] + s = re.sub(r"\s+", "", s) + if s in _AD_FAMILIES: # 家族命中即返回家族名(已是规范 token) + return _AD_FAMILIES[s] s = _UTM_ALIASES.get(s, s) return re.sub(r"[^0-9a-z一-鿿]", "", s) diff --git a/server/tracker/tests/test_attribution.py b/server/tracker/tests/test_attribution.py index bb85900..8f56eea 100644 --- a/server/tracker/tests/test_attribution.py +++ b/server/tracker/tests/test_attribution.py @@ -145,6 +145,33 @@ def test_utm_chinese_alias_baidu_matches(): assert r["detail"].get("utm_source") == "百度" +# ------------------------------------------- 广告家族归一(付费点击参数 ↔ UTM 同家族) +def test_fbclid_with_utm_fb_same_meta_family_combined(): + r = classify(landing_path="/?fbclid=IwAR0", utm={"utm_source": "fb"}) + assert r["channel"] == "paid_search" and r["platform"] == "Meta Ads" + assert r["resolution"] == "resolved" and r["evidence_kind"] == "combined" + assert r["detail"].get("utm_source") == "fb" # fb ↔ Meta Ads 同 meta 家族 + + +def test_fbclid_with_utm_ig_combined(): + r = classify(landing_path="/?fbclid=IwAR0", utm={"utm_source": "ig"}) + assert r["platform"] == "Meta Ads" # ig 也在 meta 家族 + assert r["resolution"] != "conflict" and r["evidence_kind"] == "combined" + + +def test_gclid_with_utm_google_combined(): + r = classify(landing_path="/offer?gclid=EAIaIQ", utm={"utm_source": "google"}) + assert r["channel"] == "paid_search" and r["platform"] == "Google Ads" + assert r["resolution"] != "conflict" and r["evidence_kind"] == "combined" + + +def test_fbclid_with_utm_tiktok_still_conflict(): + r = classify(landing_path="/?fbclid=IwAR0", utm={"utm_source": "tiktok"}) + assert r["resolution"] == "conflict" # 跨家族(meta vs tiktok)= 真矛盾 + assert r["detail"]["referrer_evidence"]["platform"] == "Meta Ads" + assert r["detail"]["utm_evidence"]["platform"] == "tiktok" + + def test_utm_only_is_label_only(): r = classify(utm={"utm_source": "newsletter"}) assert r["channel"] == "referral" # 未知标签 → 有名 referral,但状态诚实标注