Files
fangke/server/tracker/tests/test_attribution.py
T
浅念 59de51c062 feat: 来源归因规则引擎(渠道/AI/搜索词/诚实状态/历史重算)
- constants 规则库 v1:AI/引擎/社交域名表、搜索词参数白名单、付费点击参数、
  应用内 UA 标记、CHANNELS 枚举;page_view 白名单开 nav_type/prerendering
- services/attribution.classify 纯函数:付费>AI>引擎>社交>referral 分层,
  UTM 证据叠加(combined/conflict/label_only),应用内 UA 叠加/兜底,
  引擎 TLD 国家 variant,搜索词白名单提取(Google 恒空不虚构)
- SourceClassification 派生表(迁移 0004):first_touch/session_entry 双 scope,
  rule_version 可追溯
- ingest 增量 best-effort:首访客/新会话时分类落库(savepoint+静默,
  失败不影响采集);validation 对 page_view referrer 完整 URL 参数清洗+截500
- recompute_attribution 命令:全量/按站幂等重算
- API:访客列表归因五字段+channel 筛选、详情 attribution 块(含最近5会话入口)、
  dashboard channels 聚合(sources 原口径保留);TS VisitorListParams 加 channel
- 测试 97 → 124(引擎20 + 集成/接口7)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-16 17:03:15 +08:00

184 lines
9.0 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"
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