From 52a8a72e3aa0c64495e590df448ae055601e9c84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Fri, 18 Sep 2026 22:41:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E5=BD=92=E5=9B=A0v2=E2=80=94?= =?UTF-8?q?=E2=80=94paid=5Fsocial=E6=B8=A0=E9=81=93=E4=B8=8Efb+=E4=BB=98?= =?UTF-8?q?=E8=B4=B9medium=E4=BC=98=E5=85=88=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- server/tracker/constants.py | 11 ++++-- server/tracker/services/attribution.py | 18 ++++++++-- server/tracker/tests/test_attribution.py | 45 ++++++++++++++++++++++-- server/tracker/tests/test_visitors.py | 2 +- 4 files changed, 66 insertions(+), 10 deletions(-) diff --git a/server/tracker/constants.py b/server/tracker/constants.py index 8af2034..a5ef0a1 100644 --- a/server/tracker/constants.py +++ b/server/tracker/constants.py @@ -45,10 +45,12 @@ EXTENDED_ENV_KEYS = { # SDK 扩展环境键白名单(Visitor.env_ } # --------------------------------------------------------------------------- -# 来源归因规则库 v1(tracker/services/attribution.py 消费)。改规则必须 bump 版本号 +# 来源归因规则库 v2(tracker/services/attribution.py 消费)。改规则必须 bump 版本号 # 并跑 recompute_attribution 重算历史——已落库分类带 rule_version 可追溯。 +# v2:新增 paid_social 渠道——fbclid 点击参数与 fb 家族+付费 medium(实测主导模式 +# utm_medium=paid&utm_source=fb)不再混入 paid_search。 # --------------------------------------------------------------------------- -ATTRIBUTION_RULE_VERSION = "v1" +ATTRIBUTION_RULE_VERSION = "v2" AI_REFERRAL_DOMAINS = { # AI 平台 → 显示名 "chatgpt.com": "ChatGPT", "perplexity.ai": "Perplexity", "copilot.microsoft.com": "Copilot", "gemini.google.com": "Gemini", @@ -65,6 +67,9 @@ SEARCH_TERM_PARAMS = { # 引擎 → referrer URL 里的搜索词参数名(白 } # 注:Google 不提供,绝不虚构 PAID_CLICK_PARAMS = {"gclid": "Google Ads", "fbclid": "Meta Ads", "msclkid": "Bing Ads", "yclid": "Yandex Ads"} +PAID_MEDIUMS = {"cpc", "ppc", "paidsearch", "paid", "social", "paid_social", + "social_ads", "paidsocial"} # utm_medium 付费判定集(含社交系——仅 fb 家族配社交 medium 判付费) +SOCIAL_MEDIUMS = {"social", "paid_social", "social_ads", "paidsocial"} # 社交系 medium:非 fb 来源不判付费(防误伤自然社交) SOCIAL_DOMAINS = { "t.co": "Twitter/X", "twitter.com": "Twitter/X", "x.com": "Twitter/X", "facebook.com": "Facebook", "linkedin.com": "LinkedIn", "instagram.com": "Instagram", @@ -73,5 +78,5 @@ SOCIAL_DOMAINS = { } INAPP_UA_MARKERS = {"MicroMessenger": "微信", "Line/": "Line", "FBAN": "Facebook", "FBAV": "Facebook", "Instagram": "Instagram", "WhatsApp": "WhatsApp"} -CHANNELS = ("natural_search", "paid_search", "ai_referral", "social", +CHANNELS = ("natural_search", "paid_search", "paid_social", "ai_referral", "social", "in_app", "referral", "direct", "unknown") diff --git a/server/tracker/services/attribution.py b/server/tracker/services/attribution.py index 5411a12..2a7a5c0 100644 --- a/server/tracker/services/attribution.py +++ b/server/tracker/services/attribution.py @@ -29,6 +29,9 @@ _AD_FAMILIES = { "bing": "bing", "bingads": "bing", "microsoftads": "bing", "yandex": "yandex", "yandexads": "yandex", "директ": "yandex", } +_FB_FAMILY = "meta" # fb 家族名(_AD_FAMILIES 归一结果):fb+付费 medium → paid_social +# 点击参数 → 付费渠道(v2):fbclid 属社交广告(Meta),其余(gclid/msclkid/yclid)搜索广告 +_PAID_PARAM_CHANNELS = {"fbclid": "paid_social"} # UTM 标签 ↔ 平台名别名(家族表之外的零散别名);baidu 恒等仅为可读 _UTM_ALIASES = {"tt": "tiktok", "wa": "whatsapp", "g": "google", "baidu": "baidu", "百度": "baidu"} @@ -157,7 +160,8 @@ def classify(*, referrer_url: str = "", utm: dict | None = None, paid = _paid_param(landing_path, referrer_url) if paid: - out.update(channel="paid_search", platform=paid[1], + out.update(channel=_PAID_PARAM_CHANNELS.get(paid[0], "paid_search"), + platform=paid[1], evidence_kind="observed_referrer", resolution="resolved", detail={"click_param": paid[0]}) elif host: @@ -199,8 +203,16 @@ def classify(*, referrer_url: str = "", utm: dict | None = None, "utm_evidence": { "channel": _utm_channel(utm_source), "platform": utm_source, "evidence_kind": "utm_label"}} - else: # 无 referrer 观察 → 仅标签:按 utm_source 归类显示,状态诚实标注 - out.update(channel=_utm_channel(utm_source), platform=utm_source, + else: # 无 referrer 观察 → 仅标签:按 utm_source/utm_medium 归类显示,状态诚实标注 + medium = str(utm.get("utm_medium") or "").strip().lower() + channel = _utm_channel(utm_source) + if medium in C.PAID_MEDIUMS: + if _canon(utm_source) == _FB_FAMILY: + channel = "paid_social" # fb 家族 + 付费 medium(实测主导 paid+fb) + elif medium not in C.SOCIAL_MEDIUMS: + channel = "paid_search" # 非 fb + 搜索系付费 medium(cpc/ppc/paid…) + # 非 fb + 纯社交 medium 不判付费——防误伤自然社交,保持 _utm_channel 走向 + out.update(channel=channel, platform=utm_source, evidence_kind="utm_label", resolution="label_only") # 应用内浏览器 UA:有来源 → 作为 surface 叠加;无任何来源 → 本身就是来源 diff --git a/server/tracker/tests/test_attribution.py b/server/tracker/tests/test_attribution.py index 8f56eea..0a7f439 100644 --- a/server/tracker/tests/test_attribution.py +++ b/server/tracker/tests/test_attribution.py @@ -69,7 +69,7 @@ def test_paid_click_param_wins_over_natural_search(): assert r["channel"] == "paid_search" and r["platform"] == "Google Ads" # 付费优先于自然 assert r["detail"].get("click_param") == "gclid" r2 = classify(landing_path="/?fbclid=IwAR0") # 仅落地页带点击参数 - assert r2["channel"] == "paid_search" and r2["platform"] == "Meta Ads" + assert r2["channel"] == "paid_social" and r2["platform"] == "Meta Ads" # v2:fbclid → 付费社交 def test_paid_param_in_referrer_url(): @@ -148,13 +148,14 @@ def test_utm_chinese_alias_baidu_matches(): # ------------------------------------------- 广告家族归一(付费点击参数 ↔ 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["channel"] == "paid_social" and r["platform"] == "Meta Ads" # v2:fbclid → 付费社交 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["channel"] == "paid_social" assert r["platform"] == "Meta Ads" # ig 也在 meta 家族 assert r["resolution"] != "conflict" and r["evidence_kind"] == "combined" @@ -202,6 +203,44 @@ def test_garbage_referrer_url_does_not_crash(): assert r["channel"] in ("referral", "direct") # 不崩,落到兜底分支即可 +# ---------------------------------------- paid_social 渠道(归因 v2,spec §3) +def test_fb_paid_medium_priority(): + # 实测主导模式:utm_medium=paid&utm_source=fb → paid_social(优先于强化集) + r = classify(referrer_url="", utm={"utm_source": "fb", "utm_medium": "paid"}, + landing_path="/", ua="", internal_domains=[]) + assert r["channel"] == "paid_social" + + +def test_fb_family_medium_union(): + for m in ("cpc", "social", "paid_social", "social_ads"): + r = classify(referrer_url="", utm={"utm_source": "facebook.com", "utm_medium": m}, + landing_path="/", ua="", internal_domains=[]) + assert r["channel"] == "paid_social", m + + +def test_nonfb_paid_medium_stays_paid_search_label_only(): + r = classify(referrer_url="", utm={"utm_source": "google", "utm_medium": "cpc"}, + landing_path="/", ua="", internal_domains=[]) + assert r["channel"] == "paid_search" and r["resolution"] == "label_only" + + +def test_fbclid_now_paid_social(): + r = classify(referrer_url="", utm={}, landing_path="/?fbclid=IwARxyz", ua="", internal_domains=[]) + assert r["channel"] == "paid_social" + + +def test_gclid_still_paid_search(): + r = classify(referrer_url="", utm={}, landing_path="/?gclid=EAIa", ua="", internal_domains=[]) + assert r["channel"] == "paid_search" + + +def test_nonfb_social_medium_not_paid(): + # twitter&utm_medium=social → 不判付费(有意分歧:防误伤自然社交) + r = classify(referrer_url="", utm={"utm_source": "twitter", "utm_medium": "social"}, + landing_path="/", ua="", internal_domains=[]) + assert r["channel"] != "paid_search" and r["channel"] != "paid_social" + + # ------------------------------------------------------- 入库 + 重算集成 @pytest.mark.django_db def test_ingest_writes_first_touch_and_session_entry(): @@ -216,7 +255,7 @@ def test_ingest_writes_first_touch_and_session_entry(): ft = SourceClassification.objects.get(site=site, visitor__visitor_id="v1", scope="first_touch") assert ft.session is None assert ft.channel == "natural_search" and ft.platform == "Bing" - assert ft.search_term == "挖掘机" and ft.rule_version == "v1" + assert ft.search_term == "挖掘机" and ft.rule_version == "v2" se = SourceClassification.objects.get(site=site, scope="session_entry") assert se.session.session_id == "s1" assert se.channel == "natural_search" and se.platform == "Bing" diff --git a/server/tracker/tests/test_visitors.py b/server/tracker/tests/test_visitors.py index 372a0be..664cca0 100644 --- a/server/tracker/tests/test_visitors.py +++ b/server/tracker/tests/test_visitors.py @@ -408,7 +408,7 @@ def test_detail_attribution_block(client, member_user): ft = attr["first_touch"] assert ft["channel"] == "natural_search" and ft["platform"] == "Bing" assert ft["search_term"] == "挖掘机" and ft["resolution"] == "resolved" - assert ft["evidence_kind"] == "observed_referrer" and ft["rule_version"] == "v1" + assert ft["evidence_kind"] == "observed_referrer" and ft["rule_version"] == "v2" assert ft["computed_at"] and ft["detail"] == {} assert [e["session_id"] for e in attr["session_entries"]] == ["s2", "s1"] # 最近在前 assert attr["session_entries"][0]["channel"] == "ai_referral"