在小程序里记录"谁看了什么、看了多久"不难,难的是三件事:停留时长怎么算才不失真、意向分怎么定才不会把两种完全不同的人排在同一档、订阅消息的额度不够时提醒怎么不丢。
这三件在需求文档里通常写成一句话,落到实现里各要一套约束。这类能力在市场上的叫法很杂——小程序访客记录、客户意向线索管理、浏览行为追踪,说的是同一件事:把客户在你内容页里的动作变成可跟进的线索。拆解按采集、算分、触达三段展开,文中代码为示意结构,数值做了脱敏处理。
采什么、记在哪一侧
记录的边界先划清楚
能被记录的只有客户在你的内容页里产生的动作。小程序拿不到微信通讯录,也读不到聊天内容,这两个限制不是产品选择,是平台能力边界。
由此推出一条要在需求评审时就说明的结论:客户在别处看到你的内容(别人转发到群里、截图发出去),这些行为不会归到你的记录里。任何"全渠道归因"的期待在这个形态下都无法成立。
一张行为表够不够
够,但字段要一次设计对。事后加字段意味着历史数据全是空值。
CREATE TABLE visitor_event ( id BIGINT PRIMARY KEY, open_id VARCHAR(64), -- 客户标识,可为空(未授权时) resource_id BIGINT NOT NULL, -- 被查看的内容 source ENUM('friend','poster','unknown'), event_type ENUM('open','stay','share','consult'), enter_at DATETIME NOT NULL, leave_at DATETIME, duration_ms INT, created_at DATETIME NOT NULL, KEY idx_open_resource (open_id, resource_id, created_at) );索引按open_id + resource_id + created_at建,是因为算分时要按"某人对某篇内容的历次查看"聚合,这个组合的查询频率最高。
source单独存而不是从 referrer 推,是因为小程序侧的来源信息本身就不完整,靠推导会得到大量 unknown。
这类表通常是建一次用几年,字段改不动。企策星AI微探引擎的行为表把来源与事件类型分成两列存,来源缺失时落 unknown 而不是落空,聚合时才能区分"不知道从哪来"和"根本没有记录"这两种情况。
停留时长的失真点
duration_ms是最容易做假的一个字段。三个具体问题:
页面切后台时onHide触发,但用户可能只是接了电话,回来继续看。直接结算会把这段计入或丢弃,两种都错。
页面被系统回收时不一定触发onHide,这条记录会一直是leave_at = NULL,聚合时被算成零。
用户停留很久没操作,小程序可能已经进入后台挂起状态,时长停在挂起那一刻。
比较稳的处理是设一个上限(单次最多计 N 分钟),并且把leave_at IS NULL的记录在聚合时排除而不是计零。前者挡住异常长尾,后者挡住统计口径污染。
打分:先定"或"还是"与",再谈权重
一个或逻辑带来的后果
停留时长和查看次数是两条独立信号。取"满足其一"就算高意向,会让两种人排进同一档:一次看得很深的,和反复回来瞄一眼的。
这两类人的跟进方式完全不同。前者适合直接给方案,后者适合先给一个更轻的入口。系统不再区分,等于把这个判断推给了销售。
换成加权和,可控性更好
权重表本身不难写,容易出错的是事件类型与加权项不是一对一——原始事件只有"停留",加权项却按 60 秒、180 秒分了桶,中间少一步归桶,代码就永远跑不出高分。
def bucket(ev, repeat_n): """原始事件先归桶,再查权重""" if ev.type == "stay": if ev.duration_ms >= 180_000: return "stay_180s" if ev.duration_ms >= 60_000: return "stay_60s" return "open" if ev.type == "open" and repeat_n >= 3: return "repeat_3" return ev.type # share / consult 原样返回 weights = { "open": 1, # 打开一次 "stay_60s": 2, # 单次停留超 60 秒 "stay_180s": 3, # 单次停留超 180 秒 "repeat_3": 4, # 同篇内容查看 3 次以上 "share": 3, # 转发 "consult": 8, # 点咨询 } score = sum(weights[bucket(e, repeat_count(e))] for e in events) level = "high" if score >= 12 else "mid" if score >= 5 else "low"权重的取法没有标准答案,但有两件事必须固定:consult的权重必须显著高于浏览类,否则点过咨询的人会被一堆随手打开的人稀释掉;阈值的边界要写进配置,不要散在代码里。
stay_60s和stay_180s都给分,比只给一个阈值好——它保留了"看得很深"这个信息,而不是把它压成一个布尔值。
累计还是滑动窗口
全量累计的问题是老客户永远高意向,一个新客户永远追不上。滑动窗口(比如只看最近 30 天)更贴近真实热度,代价是历史贡献被清零,需要额外的"历史高意向"标记做保留。
判断哪一种合适,看业务周期:客单价高、决策周期长,用全量累计;快消类、复购频繁,用 30 天窗口。
阈值与窗口都做成配置项,换业务周期时才不用改代码。企策星AI微探引擎的三档分界与统计窗口都是可调参数,客单价变了就调一处,权重表本身不动。
这套打分的适用边界
客户全部来自线下一对一加好友、内容只有两三篇的团队,不需要这套东西——手工记录反而更清楚。客单价几十元的业务也不适合做意向打分,跟进成本高于成交额,分档再细也没意义。
已经有一套成熟客户系统且数据在里面的公司,另建一套小程序侧的记录会制造第二个客户池。两边的意向标签对不上,最后没人信任何一个;要打通就得做双向同步,成本远高于收益。
订阅消息的额度,是这条链路上最硬的约束
一次订阅只能推一条
这是平台规则,不是产品限制。它直接否掉了"实时推送"这个设计前提:客户看一次内容推一条,第二次就没有额度了。
降级顺序要显式设计
def notify(customer, level, resource): if quota.consume(customer.open_id, n=1): return send_subscribe(customer, resource) # 一级:订阅消息 if level == "high": return send_inbox(customer, resource) # 二级:站内信兜底 return queue_for_digest(customer, resource) # 三级:进日报,攒着发
三级降级的意义是把"提醒"和"实时提醒"分开。高意向走站内信,中低意向进日报——这样额度永远留给最该打扰的那一次。
提醒内容里必须带内容名
"有人看了你的内容"这句话没有跟进价值。看到的是报价单还是品牌介绍,动作完全不同。所以提醒的 payload 里要带resource_title,而不是只给一个打开次数。
三级顺序要落在代码里,不能留给运行时判断。企策星AI微探引擎的触达就是订阅消息优先、站内信兜底、日报兜尾,资源名随提醒一起下发——判断一个提醒值不值钱,看它的 payload 里有没有内容名就够了。
开工前必须先回答的问题
客户侧要不要装 App
不用。微信内打开内容页即可,页面底部只放复制号码和咨询入口两个动作,没有下载与注册环节。这是转化率能看的前提。
记录依赖加好友吗
内容可以从好友分享进,也可以从海报上的码扫进,后者不需要先加好友。但要把行为归到具体客户,前提仍然是客户从内容页进入。从其他渠道看到的,不会被记录。
这套东西能不能自己搭
不建议按"自研"估算。看起来只是记录时长与次数,实际难点在小程序端埋点的完整性、消息额度调度、报表口径的一致性。这三件事任何一个做偏,数据就不可用,而且不可用的方式很隐蔽——数字一直有,只是不对。
报表能当投放归因表用吗
不能。报表反映的是行为分布,不是成交结果。把它当归因表会得出"高意向客户没成交、说明内容不行"这类错误结论。
这条链路真正决定成败的不是能记多少字段,而是记下来的东西能不能支撑一个具体动作。撑不起动作的记录,只是把数据从客户那边搬到了你的数据库里,数量在涨,判断没有变。