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
+15 -5
View File
@@ -90,7 +90,7 @@
<ElRadioButton :value="30">近 30 天</ElRadioButton>
<ElRadioButton :value="90">近 90 天</ElRadioButton>
</ElRadioGroup>
<!-- 正序/倒序真实切换:倒序=日期组最新在前;正序=整体按时间正序 -->
<!-- 正序/倒序真实切换:倒序=日期组与组内事件都最新在前;正序=都最早在前 -->
<div class="vd-order-toggle" role="group" aria-label="时间线排序">
<button
type="button"
@@ -640,7 +640,7 @@
// ---------- 跨天时间线 ----------
const activeTab = ref<'sessions' | 'raw'>('sessions')
const days = ref(30)
/** 排序:desc=日期组最新在前(现行为);asc=整体按时间正序(最早日期组在前) */
/** 排序:desc=日期组最新在前+组内事件最新在前;asc=都最早在前 */
const order = ref<'desc' | 'asc'>('desc')
const timeline = ref<TimelineResp | null>(null)
const timelineLoading = ref(false)
@@ -662,10 +662,17 @@
}
}
/** 展示用日期组:倒序原样(后端倒序),正序整体翻转 */
/** 展示用日期组:后端=日期组倒序+组内正序,前端按口径组装(浅拷贝,不动原数据)。
* desc=日期组最新在前+组内(会话/事件)最新在前;asc=日期组最早在前+组内最早在前 */
const orderedDays = computed<TimelineDay[]>(() => {
const list = timeline.value?.days ?? []
return order.value === 'desc' ? list : [...list].reverse()
if (order.value === 'asc') return [...list].reverse()
return list.map((day) => ({
...day,
sessions: [...day.sessions]
.reverse()
.map((s) => ({ ...s, events: [...s.events].reverse() }))
}))
})
/** ISO 日期(YYYY-MM-DD)→ 「09月16日」 */
@@ -1113,8 +1120,11 @@
padding-bottom: 12px;
}
// 时间线滚动容器:长轨迹卡内滚动(日期组头随内容滚动,不做 sticky)
.tl-body {
min-height: 200px;
max-height: calc(100vh - 380px);
min-height: 320px;
overflow-y: auto;
padding: 4px 20px 8px;
}
+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,但状态诚实标注