Files
fangke/server/tracker/tests/test_attribution.py
T
浅念 7f2a746f66 fix: 排序作用于组内事件+时间线限高滚动+UTM别名归一消冲突误报
- attribution.py:新增 _canon 归一(小写/去协议与域名后缀/别名表 fb→facebook、百度→baidu 等/仅留字母数字汉字),
  冲突判定改为两路各自归一后比较——同源升级 combined 并在 detail 记 utm_source 原文,仍不同才 conflict
- detail.vue:orderedDays 组内会话/事件同步翻转(desc=组与组内都最新在前,asc=都最早在前);
  .tl-body 限高 calc(100vh - 380px) 卡内滚动,日期组头随内容滚动
- test_attribution.py:+4 用例(fb↔facebook、chatgpt.com↔ChatGPT、google↔facebook 仍冲突、百度↔baidu)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-16 18:39:02 +08:00

216 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""来源归因规则引擎(tracker/services/attribution.classify)。
纯函数用例(无 DB)+ 重算命令/入库集成为带 db 标记的独立用例。
诚实状态语义:resolved=观察到 referrer 证据;label_only=仅 UTM 自述;
conflict=两证据矛盾并列;unknown=直接访问不猜测。
"""
import pytest
from django.core.management import call_command
from django.utils import timezone
from tracker.models import Site, SourceClassification, VisitSession, Visitor
from tracker.services.attribution import classify
NOW = timezone.now()
# ---------------------------------------------------------------- 引擎纯函数
def test_bing_search_term_extracted():
r = classify(referrer_url="https://www.bing.com/search?q=%E6%8C%96%E6%8E%98%E6%9C%BA&m=1",
landing_path="/pricing")
assert r["channel"] == "natural_search"
assert r["platform"] == "Bing"
assert r["search_term"] == "挖掘机"
assert r["evidence_kind"] == "observed_referrer"
assert r["resolution"] == "resolved"
def test_google_never_fabricates_search_term():
r = classify(referrer_url="https://www.google.com/search?q=cats")
assert r["channel"] == "natural_search" and r["platform"] == "Google"
assert r["search_term"] == "" # Google 不在 SEARCH_TERM_PARAMS 表 = 恒空,绝不虚构
assert r["variant"] == "" # google.com → TLD "com" 不显示
def test_google_country_tld_as_variant():
r = classify(referrer_url="https://www.google.co.ru/search?q=x")
assert r["platform"] == "Google" and r["variant"] == "ru"
def test_baidu_wd_chinese_term_and_word_fallback():
r = classify(referrer_url="https://www.baidu.com/s?wd=挖掘机价格")
assert r["platform"] == "百度" and r["search_term"] == "挖掘机价格"
r2 = classify(referrer_url="https://m.baidu.com/s?word=租赁") # 子域 + word 参数兜底
assert r2["platform"] == "百度" and r2["search_term"] == "租赁"
def test_chatgpt_referral():
r = classify(referrer_url="https://chatgpt.com/c/abc")
assert r["channel"] == "ai_referral" and r["platform"] == "ChatGPT"
assert r["resolution"] == "resolved"
r2 = classify(referrer_url="https://chat.chatgpt.com/") # 子域同样命中
assert r2["channel"] == "ai_referral" and r2["platform"] == "ChatGPT"
def test_social_twitter_short_link():
r = classify(referrer_url="https://t.co/AbCdEf")
assert r["channel"] == "social" and r["platform"] == "Twitter/X"
def test_generic_referral_keeps_host_as_platform():
r = classify(referrer_url="https://blog.example.com/post/1")
assert r["channel"] == "referral" and r["platform"] == "blog.example.com"
assert r["evidence_kind"] == "observed_referrer" and r["resolution"] == "resolved"
def test_paid_click_param_wins_over_natural_search():
r = classify(referrer_url="https://www.bing.com/search?q=x",
landing_path="/offer?gclid=EAIaIQ")
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"
def test_paid_param_in_referrer_url():
r = classify(referrer_url="https://example.com/ad?msclkid=xyz", landing_path="/")
assert r["channel"] == "paid_search" and r["platform"] == "Bing Ads"
def test_micromessenger_ua_without_referrer_is_in_app():
r = classify(ua="Mozilla/5.0 (Linux; Android 14) MicroMessenger/8.0.47")
assert r["channel"] == "in_app" and r["platform"] == "微信"
assert r["resolution"] == "resolved"
def test_in_app_marker_overlays_existing_source_in_detail():
r = classify(referrer_url="https://www.bing.com/search?q=x",
ua="Mozilla/5.0 ... MicroMessenger/8.0.47")
assert r["channel"] == "natural_search" # 来源结论不被 UA 覆盖
assert r["detail"].get("in_app") == "微信" # 应用内浏览器作为 surface 叠加进 detail
def test_internal_referrer_treated_as_no_signal():
r = classify(referrer_url="https://www.a.com/other-page", internal_domains=["a.com"])
assert r["channel"] == "direct" and r["resolution"] == "unknown"
r2 = classify(referrer_url="https://sub.a.com/x", utm={"utm_source": "newsletter"},
internal_domains=["a.com"]) # 本站跳转后仍带 UTM → 仅剩标签证据
assert r2["resolution"] == "label_only" and r2["platform"] == "newsletter"
def test_utm_matching_referrer_upgrades_to_combined():
r = classify(referrer_url="https://www.bing.com/search?q=x", utm={"utm_source": "bing"})
assert r["channel"] == "natural_search" and r["evidence_kind"] == "combined"
def test_utm_contradicting_referrer_is_conflict_with_both_evidences():
r = classify(referrer_url="https://facebook.com/l/abc", utm={"utm_source": "google"})
assert r["channel"] == "social" and r["platform"] == "Facebook" # 观察证据优先于自述标签
assert r["resolution"] == "conflict"
assert r["evidence_kind"] == "observed_referrer"
ref_ev, utm_ev = r["detail"]["referrer_evidence"], r["detail"]["utm_evidence"]
assert ref_ev["platform"] == "Facebook" and ref_ev["channel"] == "social"
assert utm_ev["platform"] == "google" and utm_ev["evidence_kind"] == "utm_label"
# ------------------------------------------- 别名归一(消 conflict 误报)
def test_utm_alias_fb_matches_facebook_referrer():
r = classify(referrer_url="https://facebook.com/l/abc", utm={"utm_source": "fb"})
assert r["resolution"] != "conflict" # fb↔Facebook 归一后同源,不是矛盾
assert r["channel"] == "social" and r["platform"] == "Facebook"
assert r["evidence_kind"] == "combined"
assert r["detail"].get("utm_source") == "fb" # 归一对齐时原文并记进 detail
def test_utm_with_domain_suffix_matches_bare_platform():
r = classify(referrer_url="https://chatgpt.com/c/abc", utm={"utm_source": "chatgpt.com"})
assert r["evidence_kind"] == "combined" # chatgpt.com → chatgpt 与 ChatGPT 同源
assert r["platform"] == "ChatGPT"
assert r["detail"].get("utm_source") == "chatgpt.com"
r2 = classify(referrer_url="https://www.google.com/search?q=x",
utm={"utm_source": "google.ru"}) # 国家域名后缀 → google
assert r2["evidence_kind"] == "combined" and r2["platform"] == "Google"
def test_utm_alias_still_conflict_when_platforms_differ():
r = classify(referrer_url="https://facebook.com/l/abc", utm={"utm_source": "google"})
assert r["resolution"] == "conflict" # 归一后仍不同 → 保持矛盾并列
def test_utm_chinese_alias_baidu_matches():
r = classify(referrer_url="https://www.baidu.com/s?wd=x", utm={"utm_source": "百度"})
assert r["resolution"] != "conflict" # 百度 ↔ baidu.com(平台"百度") 同源
assert r["channel"] == "natural_search" and r["platform"] == "百度"
assert r["evidence_kind"] == "combined"
assert r["detail"].get("utm_source") == "百度"
def test_utm_only_is_label_only():
r = classify(utm={"utm_source": "newsletter"})
assert r["channel"] == "referral" # 未知标签 → 有名 referral,但状态诚实标注
assert r["platform"] == "newsletter"
assert r["resolution"] == "label_only" and r["evidence_kind"] == "utm_label"
r2 = classify(utm={"utm_source": "baidu"}) # 已知引擎标签 → 归入对应渠道(仍 label_only)
assert r2["channel"] == "natural_search" and r2["platform"] == "baidu"
assert r2["resolution"] == "label_only"
def test_empty_everything_is_direct_unknown():
r = classify()
assert r == {"channel": "direct", "platform": "", "variant": "",
"search_term": "", "evidence_kind": "", "resolution": "unknown",
"detail": {}}
def test_search_term_cleaned_and_truncated():
long = "词" * 150
r = classify(referrer_url=f"https://www.bing.com/search?q=%20%20{long}%20")
assert r["search_term"] == "词" * 100 # strip + 截 100
r2 = classify(referrer_url="https://www.bing.com/search?q=a%00b%01c") # 控制字符剔除
assert r2["search_term"] == "abc"
def test_garbage_referrer_url_does_not_crash():
r = classify(referrer_url="http://[invalid", landing_path="/x")
assert r["channel"] in ("referral", "direct") # 不崩,落到兜底分支即可
# ------------------------------------------------------- 入库 + 重算集成
@pytest.mark.django_db
def test_ingest_writes_first_touch_and_session_entry():
from tracker.services.ingest import ingest_batch
site = Site.objects.create(name="s", domains=["a.com"])
payload = {"visitor_id": "v1", "session_id": "s1", "context": {
"referrer_domain": "www.bing.com", "utm": {}, "path": "/landing"},
"events": [{"event_id": "e1", "event_name": "page_view", "occurred_at": NOW,
"path": "/landing", "title": "T",
"properties": {"referrer": "https://www.bing.com/search?q=%E6%8C%96%E6%8E%98%E6%9C%BA"}}]}
ingest_batch(site, payload, ip="114.114.114.114", ua="Mozilla/5.0")
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"
se = SourceClassification.objects.get(site=site, scope="session_entry")
assert se.session.session_id == "s1"
assert se.channel == "natural_search" and se.platform == "Bing"
@pytest.mark.django_db
def test_recompute_attribution_idempotent():
from tracker.services.ingest import ingest_batch
site = Site.objects.create(name="s", domains=["a.com"])
payload = {"visitor_id": "v1", "session_id": "s1", "context": {
"referrer_domain": "www.bing.com", "utm": {}, "path": "/landing"},
"events": [{"event_id": "e1", "event_name": "page_view", "occurred_at": NOW,
"path": "/landing", "title": "T",
"properties": {"referrer": "https://www.bing.com/search?q=ok"}}]}
ingest_batch(site, payload, ip="114.114.114.114", ua="Mozilla/5.0")
SourceClassification.objects.all().delete() # 清掉增量写入,验证重算可全量重建
call_command("recompute_attribution", stdout=None)
n1 = SourceClassification.objects.count()
assert n1 == 2 # first_touch + session_entry 各一
ft = SourceClassification.objects.get(scope="first_touch")
assert ft.channel == "natural_search" and ft.search_term == "ok"
call_command("recompute_attribution", stdout=None) # 第二遍:upsert 不重复不漂移
assert SourceClassification.objects.count() == n1