59de51c062
- 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>
184 lines
9.0 KiB
Python
184 lines
9.0 KiB
Python
"""来源归因规则引擎(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
|