feat(server): 归因v2——paid_social渠道与fb+付费medium优先规则

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 22:41:58 +08:00
parent be0b53bce4
commit 52a8a72e3a
4 changed files with 66 additions and 10 deletions
+8 -3
View File
@@ -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")
+15 -3
View File
@@ -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 叠加;无任何来源 → 本身就是来源
+42 -3
View File
@@ -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"
+1 -1
View File
@@ -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"