fix: 排序作用于组内事件+时间线限高滚动+UTM别名归一消冲突误报

- attribution.py:新增 _canon 归一(小写/去协议与域名后缀/别名表 fb→facebook、百度→baidu 等/仅留字母数字汉字),
  冲突判定改为两路各自归一后比较——同源升级 combined 并在 detail 记 utm_source 原文,仍不同才 conflict
- detail.vue:orderedDays 组内会话/事件同步翻转(desc=组与组内都最新在前,asc=都最早在前);
  .tl-body 限高 calc(100vh - 380px) 卡内滚动,日期组头随内容滚动
- test_attribution.py:+4 用例(fb↔facebook、chatgpt.com↔ChatGPT、google↔facebook 仍冲突、百度↔baidu)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-16 18:39:02 +08:00
parent 50faa73cf9
commit 7f2a746f66
3 changed files with 70 additions and 6 deletions
+23 -1
View File
@@ -19,6 +19,10 @@ from tracker import constants as C
_CTRL_CHARS = re.compile(r"[\x00-\x1f\x7f]")
_GENERIC_TLDS = ("com",) # 引擎 host 落在这些 TLD 时不作为国家 variant 显示
# UTM 标签 ↔ 平台名别名(冲突判定前归一):缩写/中文品牌名 → 标准名;baidu 恒等仅为可读
_UTM_ALIASES = {"fb": "facebook", "ig": "instagram", "tt": "tiktok",
"wa": "whatsapp", "g": "google", "baidu": "baidu", "百度": "baidu"}
def _host(url: str) -> str:
"""提取 referrer host;异常/空 → ""。hostname 自动去端口并小写。"""
@@ -88,6 +92,22 @@ def _in_app_name(ua: str) -> str:
return ""
def _canon(name: str) -> str:
"""平台名/UTM 标签宽松归一(仅用于冲突判定):小写 → 去协议与路径 → 去 www. 前缀
→ 去最后一段域名后缀(chatgpt.com→chatgpt、google.ru→google)→ 别名表 → 仅留
字母数字(含汉字)。fb↔Facebook、百度↔baidu 归一后同源,不再误报 conflict。"""
s = (name or "").lower().strip()
if "://" in s:
s = s.split("://", 1)[1]
s = s.split("/", 1)[0]
if s.startswith("www."):
s = s[4:]
if "." in s:
s = s.rsplit(".", 1)[0]
s = _UTM_ALIASES.get(s, s)
return re.sub(r"[^0-9a-z一-鿿]", "", s)
def _utm_channel(label: str) -> str:
"""UTM 标签尽力归类:已知 AI/引擎/社交标签(裸标签 "baidu"/显示名 "Google" 皆可)
映射到对应渠道,未知 → referral(有名来源,可信度由 label_only 单独表达)。"""
@@ -150,8 +170,10 @@ def classify(*, referrer_url: str = "", utm: dict | None = None,
# UTM 证据叠加:观察结论之上只做印证/并列,不覆盖
if utm_source:
if out["resolution"] == "resolved":
if utm_source.lower() == out["platform"].lower():
if _canon(utm_source) == _canon(out["platform"]):
# 归一后同源 → 互相印证;UTM 原文并记进 detail(顶层展示观察结论)
out["evidence_kind"] = "combined"
out["detail"] = {**out["detail"], "utm_source": utm_source}
else: # 矛盾:观察证据保留在顶层,两路证据各自完整并列进 detail
out["resolution"] = "conflict"
out["detail"] = {**out["detail"],
+32
View File
@@ -113,6 +113,38 @@ def test_utm_contradicting_referrer_is_conflict_with_both_evidences():
assert utm_ev["platform"] == "google" and utm_ev["evidence_kind"] == "utm_label"
# ------------------------------------------- 别名归一(消 conflict 误报)
def test_utm_alias_fb_matches_facebook_referrer():
r = classify(referrer_url="https://facebook.com/l/abc", utm={"utm_source": "fb"})
assert r["resolution"] != "conflict" # fb↔Facebook 归一后同源,不是矛盾
assert r["channel"] == "social" and r["platform"] == "Facebook"
assert r["evidence_kind"] == "combined"
assert r["detail"].get("utm_source") == "fb" # 归一对齐时原文并记进 detail
def test_utm_with_domain_suffix_matches_bare_platform():
r = classify(referrer_url="https://chatgpt.com/c/abc", utm={"utm_source": "chatgpt.com"})
assert r["evidence_kind"] == "combined" # chatgpt.com → chatgpt 与 ChatGPT 同源
assert r["platform"] == "ChatGPT"
assert r["detail"].get("utm_source") == "chatgpt.com"
r2 = classify(referrer_url="https://www.google.com/search?q=x",
utm={"utm_source": "google.ru"}) # 国家域名后缀 → google
assert r2["evidence_kind"] == "combined" and r2["platform"] == "Google"
def test_utm_alias_still_conflict_when_platforms_differ():
r = classify(referrer_url="https://facebook.com/l/abc", utm={"utm_source": "google"})
assert r["resolution"] == "conflict" # 归一后仍不同 → 保持矛盾并列
def test_utm_chinese_alias_baidu_matches():
r = classify(referrer_url="https://www.baidu.com/s?wd=x", utm={"utm_source": "百度"})
assert r["resolution"] != "conflict" # 百度 ↔ baidu.com(平台"百度") 同源
assert r["channel"] == "natural_search" and r["platform"] == "百度"
assert r["evidence_kind"] == "combined"
assert r["detail"].get("utm_source") == "百度"
def test_utm_only_is_label_only():
r = classify(utm={"utm_source": "newsletter"})
assert r["channel"] == "referral" # 未知标签 → 有名 referral,但状态诚实标注