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>
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
"""来源归因规则引擎(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
|
||||
@@ -3,7 +3,7 @@ from datetime import timedelta
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
from tracker.models import Event, Site, VisitSession, Visitor
|
||||
from tracker.models import Event, Site, SourceClassification, VisitSession, Visitor
|
||||
from tracker.services.stats import refresh_daily
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -104,6 +104,33 @@ def test_overview_trend_uses_dailystat_history(client, member_user):
|
||||
assert d["trend"][-1]["pv"] == 0 # 今天无事件,实时覆盖为 0
|
||||
|
||||
|
||||
def test_overview_channels_aggregation(client, member_user):
|
||||
"""channels:当天会话按 session_entry 分类的 channel 聚合(与 sources 并存,互不影响)。"""
|
||||
_auth(client, member_user)
|
||||
site = Site.objects.create(name="s", domains=["a.com"])
|
||||
v1 = _visitor(site, "v1", last_seen=timezone.now())
|
||||
v2 = _visitor(site, "v2", last_seen=timezone.now())
|
||||
s1 = _session(site, v1, "s1", referrer="www.bing.com")
|
||||
s2 = _session(site, v1, "s2", referrer="www.bing.com")
|
||||
s3 = _session(site, v2, "s3", referrer="chatgpt.com")
|
||||
s4 = _session(site, v2, "s4", referrer="") # 直接访问
|
||||
SourceClassification.objects.create(site=site, visitor=v1, session=s1,
|
||||
scope="session_entry", channel="natural_search", platform="Bing")
|
||||
SourceClassification.objects.create(site=site, visitor=v1, session=s2,
|
||||
scope="session_entry", channel="natural_search", platform="Bing")
|
||||
SourceClassification.objects.create(site=site, visitor=v2, session=s3,
|
||||
scope="session_entry", channel="ai_referral", platform="ChatGPT")
|
||||
SourceClassification.objects.create(site=site, visitor=v2, session=s4,
|
||||
scope="session_entry", channel="direct", resolution="unknown")
|
||||
_events(site, v1, s1, 2, "/", prefix="a")
|
||||
|
||||
d = client.get("/api/admin/dashboard/overview").json()["data"]
|
||||
assert d["channels"] == [{"channel": "natural_search", "sessions": 2},
|
||||
{"channel": "ai_referral", "sessions": 1},
|
||||
{"channel": "direct", "sessions": 1}] # 倒序 top8,平局按名稳定
|
||||
assert d["sources"][0] == {"source": "www.bing.com", "sessions": 2} # 原口径保留
|
||||
|
||||
|
||||
def test_overview_site_filter_and_404(client, member_user):
|
||||
_auth(client, member_user)
|
||||
site_a = Site.objects.create(name="a", domains=["a.com"])
|
||||
|
||||
@@ -142,3 +142,23 @@ def test_context_timezone_persisted_to_visitor(db):
|
||||
b["context"]["timezone"] = "Asia/Shanghai"
|
||||
ingest_batch(site, b)
|
||||
assert Visitor.objects.get(visitor_id="v1").timezone_name == "Asia/Shanghai"
|
||||
|
||||
# 15. 归因分类 best-effort:引擎抛异常不影响采集入库(first_touch/session_entry 双路)
|
||||
def test_attribution_failure_never_breaks_ingest(db, monkeypatch):
|
||||
from tracker.services import ingest as ingest_mod
|
||||
site = Site.objects.create(name="s", domains=["a.com"])
|
||||
monkeypatch.setattr(ingest_mod, "classify", lambda **kw: (_ for _ in ()).throw(RuntimeError("boom")))
|
||||
r = ingest_batch(site, _batch([_ev(1)]))
|
||||
assert r["accepted"] == 1 and Event.objects.count() == 1
|
||||
assert Visitor.objects.count() == 1 and VisitSession.objects.count() == 1
|
||||
|
||||
# 16. 首触分类用本批第一条 page_view 的完整 referrer(搜索词可恢复),非 ctx 裸域名
|
||||
def test_first_touch_classification_uses_full_pageview_referrer(db):
|
||||
from tracker.models import SourceClassification
|
||||
site = Site.objects.create(name="s", domains=["a.com"])
|
||||
b = _batch([_ev(1, properties={"referrer": "https://m.baidu.com/s?wd=挖掘机"})])
|
||||
b["context"]["utm"] = {} # 默认 fixture 带 utm_source=google,与百度矛盾 → conflict
|
||||
ingest_batch(site, b)
|
||||
ft = SourceClassification.objects.get(scope="first_touch")
|
||||
assert ft.channel == "natural_search" and ft.platform == "百度"
|
||||
assert ft.search_term == "挖掘机" and ft.resolution == "resolved"
|
||||
|
||||
@@ -37,6 +37,21 @@ def test_sanitize_url_drops_sensitive_params():
|
||||
assert "email=" not in u and "token=" not in u and "utm_source=google" in u
|
||||
assert "monkey=1" in u # fullmatch:含 key 子串的正常参数不误伤
|
||||
|
||||
|
||||
def test_pageview_referrer_url_sanitized():
|
||||
"""page_view 的 properties.referrer 是完整 URL:敏感参数整键剔除后写回。"""
|
||||
ev = _ev(properties={"referrer": "https://www.bing.com/search?q=x&email=a@b.com&token=t"})
|
||||
assert validate_event(ev) is None
|
||||
assert ev["properties"]["referrer"] == "https://www.bing.com/search?q=x"
|
||||
|
||||
|
||||
def test_pageview_referrer_truncated_after_reencode():
|
||||
"""清洗重编码(中文 → %XX 九倍膨胀)可能超出原长:出门统一截 MAX_STR_LEN=500。"""
|
||||
ev = _ev(properties={"referrer": "https://www.bing.com/search?q=" + "词" * 200})
|
||||
assert validate_event(ev) is None # 原文 227 字符过长度门
|
||||
ref = ev["properties"]["referrer"]
|
||||
assert len(ref) == 500 and ref.startswith("https://www.bing.com/search?q=%E8%AF%8D")
|
||||
|
||||
def test_nested_sensitive_key_rejected():
|
||||
assert validate_event(_ev(properties={"utm": {"utm_a": 1, "user_password": "x"}})) == "sensitive_field" # utm 在白名单内,嵌套敏感键仍须命中
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from datetime import timedelta
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
from tracker.models import Event, Site, VisitSession, Visitor
|
||||
from tracker.models import Event, Site, SourceClassification, VisitSession, Visitor
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
User = get_user_model()
|
||||
@@ -77,7 +77,8 @@ def test_list_fields_and_default_bot_excluded(client, member_user):
|
||||
assert set(row) == {"visitor_id", "site_id", "site_name", "country", "province", "city",
|
||||
"device_type", "os", "browser", "language", "first_seen_at",
|
||||
"last_seen_at", "visit_count", "active_days", "session_count",
|
||||
"total_stay_ms", "first_source", "last_source", "online"}
|
||||
"total_stay_ms", "first_source", "last_source", "online",
|
||||
"channel", "platform", "variant", "search_term", "resolution"}
|
||||
assert row["country"] == "中国" and row["os"] == "Windows" and row["browser"] == "Chrome"
|
||||
assert row["session_count"] == 2
|
||||
assert row["total_stay_ms"] == 150000 # 120000+30000,有效停留口径
|
||||
@@ -319,3 +320,63 @@ def test_timeline_orphan_events_without_session(client, member_user):
|
||||
sess = r.json()["data"]["days"][0]["sessions"][0]
|
||||
assert sess["session_id"] is None and sess["started_at"] is None
|
||||
assert [e["event_name"] for e in sess["events"]] == ["sdk_loaded", "page_stay"]
|
||||
|
||||
|
||||
def _classify(site, visitor, scope, channel, platform="", session=None, **kw):
|
||||
return SourceClassification.objects.create(
|
||||
site=site, visitor=visitor, session=session, scope=scope,
|
||||
channel=channel, platform=platform, **kw)
|
||||
|
||||
|
||||
def test_list_attribution_fields_and_channel_filter(client, member_user):
|
||||
_auth(client, member_user)
|
||||
site = Site.objects.create(name="s", domains=["a.com"])
|
||||
v1 = _visitor(site, "v1", last_seen=timezone.now())
|
||||
v2 = _visitor(site, "v2", last_seen=timezone.now() - timedelta(hours=1))
|
||||
v3 = _visitor(site, "v3", last_seen=timezone.now() - timedelta(hours=2)) # 无分类行
|
||||
_classify(site, v1, "first_touch", "natural_search", "Bing",
|
||||
search_term="挖掘机", evidence_kind="observed_referrer",
|
||||
resolution="resolved")
|
||||
_classify(site, v2, "first_touch", "ai_referral", "ChatGPT",
|
||||
evidence_kind="observed_referrer", resolution="resolved")
|
||||
|
||||
d = client.get("/api/admin/visitors").json()["data"]
|
||||
by_id = {x["visitor_id"]: x for x in d["list"]}
|
||||
assert by_id["v1"]["channel"] == "natural_search"
|
||||
assert by_id["v1"]["platform"] == "Bing" and by_id["v1"]["search_term"] == "挖掘机"
|
||||
assert by_id["v1"]["variant"] == "" and by_id["v1"]["resolution"] == "resolved"
|
||||
assert by_id["v3"]["channel"] == "" # 无分类行 → 空串(前端回落 first_source 展示)
|
||||
|
||||
d = client.get("/api/admin/visitors", {"channel": "natural_search"}).json()["data"]
|
||||
assert d["total"] == 1 and [x["visitor_id"] for x in d["list"]] == ["v1"]
|
||||
d = client.get("/api/admin/visitors", {"channel": "direct"}).json()["data"]
|
||||
assert d["total"] == 0 # 无分类访客不落进任何渠道桶
|
||||
d = client.get("/api/admin/visitors", {"channel": "ai_referral"}).json()["data"]
|
||||
assert [x["visitor_id"] for x in d["list"]] == ["v2"]
|
||||
|
||||
|
||||
def test_detail_attribution_block(client, member_user):
|
||||
_auth(client, member_user)
|
||||
site = Site.objects.create(name="s", domains=["a.com"])
|
||||
v = _visitor(site, "v1")
|
||||
s1 = _session(site, v, "s1", at=NOW - timedelta(hours=2),
|
||||
referrer_domain="www.bing.com")
|
||||
s2 = _session(site, v, "s2", at=NOW, referrer_domain="chatgpt.com")
|
||||
_classify(site, v, "first_touch", "natural_search", "Bing",
|
||||
search_term="挖掘机", evidence_kind="observed_referrer",
|
||||
resolution="resolved")
|
||||
_classify(site, v, "session_entry", "natural_search", "Bing",
|
||||
session=s1, evidence_kind="observed_referrer", resolution="resolved")
|
||||
_classify(site, v, "session_entry", "ai_referral", "ChatGPT",
|
||||
session=s2, evidence_kind="observed_referrer", resolution="resolved")
|
||||
|
||||
r = client.get("/api/admin/visitors/v1")
|
||||
assert r.status_code == 200
|
||||
attr = r.json()["data"]["attribution"]
|
||||
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["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"
|
||||
|
||||
Reference in New Issue
Block a user