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>
89 lines
4.4 KiB
Python
89 lines
4.4 KiB
Python
"""历史来源分类全量重算(幂等可重复)。
|
||
|
||
规则引擎升级(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})")
|