Files
fangke/server/tracker/management/commands/recompute_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

89 lines
4.4 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.
"""历史来源分类全量重算(幂等可重复)。
规则引擎升级(constants.ATTRIBUTION_RULE_VERSION bump)后执行:
python manage.py recompute_attribution # 全部站点
python manage.py recompute_attribution --site site_xxx
数据源约束:重算只能用"落库时保住的信号"——
- 访客首触:最早 page_view 的 properties.referrer/utm(无则档案字段兜底,
referrer_domain 合成 URL 后搜索词已丢失,诚实降级)+ first_landing_path;UA 未存档 → 空。
- 会话入口:referrer_domain 合成 URL + utm + landing_path。
逐条 upsert(first_touch 键 site+visitor+scope;session_entry 键 site+session+scope),
跑两遍计数不变。
"""
from django.core.management.base import BaseCommand
from tracker import constants as C
from tracker.models import Event, Site, SourceClassification, VisitSession, Visitor
from tracker.services.attribution import classify
def _first_touch_signals(visitor):
"""最早一条 page_view 的原始信号优先;旧数据无事件属性时回落访客档案字段。"""
ev = (Event.objects.filter(visitor=visitor, event_name="page_view")
.order_by("occurred_at", "pk").first())
props = (ev.properties or {}) if ev else {}
referrer = props.get("referrer") if isinstance(props.get("referrer"), str) else ""
domain = props.get("referrer_domain") or visitor.first_referrer_domain
if not referrer and domain:
referrer = f"https://{str(domain).strip().strip('/')}"
utm = props.get("utm") if isinstance(props.get("utm"), dict) else None
return referrer, (utm or visitor.first_utm or {}), visitor.first_landing_path
def _upsert(site, visitor, session, scope, result):
vals = {"channel": (result["channel"] or "")[:32],
"platform": (result["platform"] or "")[:64],
"variant": (result["variant"] or "")[:64],
"search_term": (result["search_term"] or "")[:200],
"evidence_kind": (result["evidence_kind"] or "")[:32],
"resolution": (result["resolution"] or "")[:16],
"detail": result["detail"] or {}, "session": session,
"rule_version": C.ATTRIBUTION_RULE_VERSION} # 重算即升到当前规则版本
if session is None:
SourceClassification.objects.update_or_create(
site=site, visitor=visitor, scope=scope, defaults=vals)
else:
SourceClassification.objects.update_or_create(
site=site, session=session, scope=scope,
defaults={**vals, "visitor": visitor})
class Command(BaseCommand):
help = "按当前规则版本全量重算来源分类(first_touch + session_entry,幂等)"
def add_arguments(self, parser):
parser.add_argument("--site", default=None,
help="只重算指定 site_id(缺省全部站点)")
def handle(self, *args, **opts):
sites = Site.objects.all()
if opts["site"]:
sites = sites.filter(site_id=opts["site"])
if not sites.exists():
self.stdout.write(self.style.WARNING(
f"site not found: {opts['site']}"))
return
v_total = s_total = 0
for site in sites:
domains = site.domains or []
for visitor in Visitor.objects.filter(site=site).only("pk", "first_referrer_domain",
"first_utm", "first_landing_path"):
referrer, utm, landing = _first_touch_signals(visitor)
_upsert(site, visitor, None, "first_touch",
classify(referrer_url=referrer, utm=utm,
landing_path=landing or "",
internal_domains=domains))
v_total += 1
for sess in VisitSession.objects.filter(site=site).select_related("visitor"):
referrer = f"https://{sess.referrer_domain.strip().strip('/')}" \
if sess.referrer_domain else ""
_upsert(site, sess.visitor, sess, "session_entry",
classify(referrer_url=referrer, utm=sess.utm or {},
landing_path=sess.landing_path or "",
internal_domains=domains))
s_total += 1
self.stdout.write(
f"recomputed attribution: {v_total} first_touch, {s_total} session_entry "
f"(rule version {C.ATTRIBUTION_RULE_VERSION})")