文章

给评论区加一套「相逢七阶」:一个不催更的回头客识别系统

技术栈

本文是《技术栈(共29篇)》目录的第 29 篇。阅读本文前,建议先阅读本文前3篇文章:

阅读本目录下更多其它文章

CONTENT / 正文

博客的评论区是个很奇怪的地方:你分不清谁是路过的,谁是每次都来的老朋友。老朋友留完言就走,不会特意告诉你他第几次来了;新访客留个「写得好」也没人接得住话。

Typecho 本身不带评论等级。插件市场有几个,但都是「积分 + 等级 + 排行榜」那一套,我一个都没用——因为我想要的不是激励读者发言,而是认出熟人。

这两件事看着像,其实相反:激励发言会让你把评论区变成打卡任务;认出熟人只是让老朋友知道「我记得你」。

所以这套东西从第一天就定了三条红线:不排名、不催更、不惩罚。徽章只有装饰作用,不动审核、不限流、不给任何权限。它唯一的工作是,让每次来的人知道自己被记住了。

设计理念

一、按邮箱聚合,不按 cookie

这是第一个也是最关键的决定。

Typecho 的 typecho_comments 表里,每条评论都带 mail 和 ip。直觉上用 cookie 最省事——写个标识,下次读出来算数。但 cookie 会被清、换设备就丢、隐私模式下根本不存在。老朋友换个手机,等级归零,这件事本身就很难解释。

邮箱不一样。mail 是评论者自己填的,他换设备、清缓存、用不同浏览器,只要填的是同一个邮箱,等级就连续。

代价是要按邮箱做一次全表聚合,不能每条评论单独算。这个代价后面会看到怎么兜住。

SELECT k, SUM(n) AS c, COUNT(*) AS a, MIN(f) AS f
FROM (
    SELECT LOWER(TRIM(mail)) AS k,
           LEAST(COUNT(*), 3) AS n,
           MIN(created) AS f
    FROM typecho_comments
    WHERE status = 'approved' AND mail <> ''
    GROUP BY LOWER(TRIM(mail)), FLOOR((created + 28800) / 86400)
) x
GROUP BY k

LOWER(TRIM()) 是因为 Foo@Bar.com 和 foo@bar.com 是同一个人,不归一的话等级会裂成两条。

顺带一提,这个 SQL 有个容易被忽略的细节:日界必须用 PHP 的时区偏移先加再整除(上面的 28800 是东八区)。MySQL 会话时区和 PHP 不一定一致,直接用 DATE() 分组会把深夜的评论划到错误的自然日,活跃天数跟着算虚。

二、三维正交,而不是一个总分

如果只有一个数字,这个数字一定会被当成目标去刷。所以拆成三个互不相关的维度,而且必须同时达标才升级:

维度含义关键约束
计数发过的评论条数每个自然日最多只计 3 条
活跃发过评论的不同天数一天来十次也只算一天
资历第一条评论至今的天数只看最早那条,不看总量

三个维度各自堵死一种刷法:

  • 只看条数 → 一天刷 300 条直接冲级(所以计数封顶 3/天)
  • 只看活跃天 → 每天来点个「1」水天数(所以还要看条数)
  • 只看资历 → 挂 400 天不说话也升级(所以三者取 AND)

封顶那句 LEAST(COUNT(*), 3) 是整个设计里最要紧的一行。它把「爆发刷级」这条路直接堵死了:想升到第五阶,你至少得在 45 个不同的日子里各出现一次。这不是能靠努力达成的,只能靠真的常来。

我早期版本把门槛设成了「7 天内 15 条」这种,被自己否掉了——太短了,认真刷两天就破。现在这套从两周拉到两年,跨度是刻意的。

三、名字先于数字

我不想在昵称旁边放「Lv.3」。数字会让人想去比较,而我想让人读到的是一段关系的描述。

七阶的名字是按中国人交情的说法排的,每一阶配一句寄语:

阶名称寄语
1萍水相逢一次擦肩,也是相逢
2点头之交见得多了,便点头致意
3常来常往门常开,人常来
4渐渐相熟名字记住了,声音也熟了
5久处老友不必寒暄,你来了就好
6千里知己千里之外,心意相通
7莫逆之交相视一笑,莫逆于心

徽章上只显示四字称谓,寄语放在悬浮面板第一行。这样评论列表保持干净,想看的人把鼠标放上去才展开。

门槛表本身就是一组可调参数,单位全部是「天」:

Lv2 14 天≈2周    Lv5 150 天≈5个月
Lv3 30 天≈1个月  Lv6 365 天=1年
Lv4 60 天≈2个月  Lv7 730 天=2年

这里没有任何 /365 或 /30 的换算,365 和 730 就是天数本身。因为跨度要从两周拉到两年,中间得填档,我挑了「2周 / 1月 / 2月 / 5月 / 1年 / 2年」这组人类习惯的说法,而不是精确的 30/60/90/180。

四、博主不参与

博主自己的徽章由 CSS 用 ::after 生成一个「博主」,如果再叠一个等级徽章,读者会分不清哪个是身份、哪个是资历。所以判定 authorId === ownerId 时直接不渲染等级徽章。

实现步骤

第一步:聚合与缓存

全站聚合这条 SQL 是全表扫(typecho_comments 上没有 mail 索引)。个人博客量级——千行上下——可以接受,但不能每页每条评论都跑一遍。

所以做了文件缓存:.cache/level/map.json + .cache/level/map.stamp。

失效靠双条件,缺一不可:

if ($cur === (string) $approved) {          // stamp:approved 总数
    if ($data['expire'] > time()) {          // TTL:600 秒
        return $memo;
    }
}

为什么 stamp 存的是「approved 总数」而不是 MAX(coid)?这是个很隐蔽的坑:管理员在后台把一条待审评论审核通过时,coid 根本不变,MAX(coid) 感知不到这次变化。但 approved 总数会 +1,正好对应「有新评论进入统计」。

TTL 那一半则是必需的补充:资历天数和活跃天数只随时间流逝变化、完全不碰数据库,stamp 再准也拦不住它们过期。所以两个条件是并列的,一个管「数据变了」,一个管「时间走了」。

第二步:渲染位置——必须在 <cite> 内部

徽章最终长这样:

<span class="comment-level comment-level--3" tabindex="0"
      aria-label="常来常往,门常开,人常来">
  常来常往
  <span class="level-pop"> … 进度面板 … </span>
</span>

放在 <cite> 里面不是随手写的,是被手机端的布局逼出来的。

桌面端 .comment-meta-info 是一个正常 flex 容器,徽章放哪都行。但手机端这条规则会把它打散:

@media (max-width: 767px) {
  .comment-meta-info { display: contents; }
}

display: contents 让这个容器不再生成盒子,它的子元素直接变成 .comment-header 的 flex 项。如果徽章挂在 <cite> 外面,它就会被提升成一个独立的 flex 项,跑到昵称之外、跟时间戳并排。

放在 <cite> 里面就安全了——它是 <cite> 的子元素,<cite> 又是 display: contents 容器的子元素,重排时会整体跟着昵称流动、一起换行。

第三步:悬浮进度面板

面板挂在徽章上,position: relative 的锚点就是徽章本身,所以它精确落在徽章正下方。面板内容三块:寄语、头行(昵称 · Lv2「点头之交」 —— 下一阶「常来常往」)、三维进度条。

瓶颈判定用的是「三维里剩余绝对值最大那一维」。比如下一阶要求 15 条 / 8 天 / 30 天,你现在是 6 条 / 3 天 / 20 天,剩余 9 / 5 / 10,资历是瓶颈,面板就在资历那行标一句 ← 当前瓶颈:再等 10 天。

这个判据简单、可解释,也不会因为某一维比例低但绝对量小就误判。满级没有下一阶,改报一句「三条维度均已圆满」。

第四步:两个必须处理的细节

去掉原生 title。 一开始寄语是写在 title 上的,加上面板之后就冲突了:原生 tooltip 位置由浏览器决定、有几百毫秒延迟,会突然压在自绘面板上,两条信息叠在一起。所以寄语挪进面板首行,徽章改成 aria-label + tabindex="0"——键盘和触屏(点按聚焦)同样能呼出面板。

消除悬停缝隙闪烁。 徽章和面板之间留了 6px 空隙,鼠标从徽章移到面板的半路上,徽章:hover 已经失效、面板:hover 还没建立,面板会先隐藏,然后你就再也够不着它了。

解法是让徽章自己长一个透明的 ::after 盖住这 6px:

.level-pop { top: calc(100% + 6px); }
.comment-level::after {
  content: ""; position: absolute;
  left: 0; right: 0; top: 100%; height: 6px;
}

因为这个 ::after 是徽章的后代,指针穿过缝隙时仍然落在徽章的命中区域里,:hover 不中断。

第五步:横向钳位

面板锚在徽章上,代价是横向起点跟着徽章走。一个长昵称会把徽章一路推到卡片右侧,面板再往右铺就戳出视口了。

而绝对定位元素的右侧溢出会算进文档的可滚动区域——页面会凭空多出一条横向滚动条。CSS 里没法知道「徽章离视口右缘还有多远」,只能上 JS:

var rect = pop.getBoundingClientRect();
var vw = document.documentElement.clientWidth;
var shift = 0;
if (rect.right > vw - 8) shift = -(rect.right - (vw - 8));
if (rect.left + shift < 8) shift += 8 - (rect.left + shift);
if (shift !== 0) pop.style.left = shift + 'px';

实测一个 14 字长昵称,面板 left 从 357px 被拉到 21px。

踩过的坑

这一节比实现本身更有价值,因为都是「测试全绿但功能是坏的」那类。

1. 缓存命中了,但没回填。 聚合结果写进了缓存文件,却忘了写回函数内的 $memo。结果首次取数永远返回空,徽章要等第二次请求才出现。第一次测试全过——因为测试跑两遍。

2. 测试夹具漏了一层 <ol>。 嵌套评论的真实标记是 .comment-children 里再包一层 <ol class="comment-list">(由核心 Archive::threadedComments() 的 before 输出)。夹具里漏了这层,HTML 解析器遇到「<div> 里直接放 <li>」会先闭合外层 <li>,把子评论重挂成兄弟节点——于是 .comment-children 变空,嵌套楼层的断言「查不到元素」,静默跳过,假绿。

站点的标记本身没问题,是测试写错了。

3. chromium 的窗口宽度有 500px 下限。 视觉测试一开始用 --window-size=390,1400 测手机档,实测发现新旧两种 headless 模式下 innerWidth 恒为 500。也就是说 390 和 320 两档一直在用 500px 冒充,断言全绿却什么都没测。

改成走 CDP 的 Emulation.setDeviceMetricsOverride,并且加一条 real_viewport 断言,要求探测到的 innerWidth 必须等于期望值——否则测试本身就不可信。

4. 配色锁死十六进制,不用主题 CSS 变量。 主题变量在明暗模式下会翻转明度(--color-400 亮色是 #707070,暗色变成 #c0c0c0)。徽章如果用变量,亮色模式下的白字在暗色模式会压到浅灰上,直接不可读。所以七阶底色写死:

.comment-level--1 { background: #7a7a7a; color: #fff; }
.comment-level--4 { background: #eab308; color: #101010; }  /* 黄底必须配深字 */
.comment-level--7 { background: linear-gradient(135deg, #b8555f, #7a4fa3); color: #fff; }

5. 注释里写字面量的注释结束符。 把「为什么」写进代码时,注释块里出现了 /** */ 这几个字符,其中的 */ 提前闭合了注释,后面的内容全变成 PHP 代码,php -l 直接语法错误。

效果

徽章是一枚四字胶囊,七阶从灰到暖色再到彩色渐变:

阶底色字色
1 萍水相逢#7a7a7a白
2 点头之交#9c6b3f白
3 常来常往#64757f白
4 渐渐相熟#eab308深
5 久处老友#e0873a深
6 千里知己#2f6b96白
7 莫逆之交linear-gradient(135deg,#b8555f,#7a4fa3)白

前三阶是低饱和的中性色,存在感刻意压得很低;第四、五阶转暖(黄→橙),是「熟了」的视觉提示;第六阶回到冷色但更饱和;第七阶是唯一的渐变。

悬浮面板长这样(以 Lv2 为例,瓶颈是资历):

见得多了,便点头致意
小明 · Lv2「点头之交」 —— 下一阶「常来常往」
计数  6/15   ████████░░░░░░░░
活跃  3/8    ████░░░░░░░░░░░░
资历  20/30  █████████████░░░  ← 当前瓶颈:再等 10 天

验证

这套东西写了两层测试,都在 CLI 里跑:

  • 门槛判定:37 项,覆盖七阶 AND 关系、反刷(潜水 400 天只 3 条仍 Lv1、爆发 300 条但仅 10 天仍 Lv1)、大小写与空格归一、缓存 stamp 失效、满级文案、昵称转义
  • 响应式渲染:5 档视口(1440 / 834 / 767 / 390 / 320)× 25 项 = 125 项,覆盖徽章不出界、不折行、始终在 <cite> 内、面板不出视口、失焦收起

视觉测试那层不是普通的 DOM 断言,它会真的把每个徽章 focus() 一遍,读 getComputedStyle 和 getBoundingClientRect——因为 nowrap 是否生效、display: contents 重排后徽章还在不在昵称里、面板有没有戳出视口,这些只有真实的布局引擎说了算。

顺带把主题头部注释的解析规则也钉进了不变量测试。Typecho 用 Plugin::parseInfo() 读 index.php 的文档注释,@package/@author/@version 被删掉不会报错,只会让后台那几行静默变空、标题回退成目录名。现在 110 项不变量里有 5 项专门守着它。

想改的话

所有门槛都在 theme_comment_level_defs() 里,一行一阶:

2 => ['name' => '点头之交', 'motto' => '见得多了,便点头致意', 'c' => 5, 'a' => 3, 't' => 14],

c / a / t 分别是计数、活跃天、资历天。改动只影响文案和门槛,不用碰任何 SQL 或 JS。

不过这组数字现在是盲调的。真上线之后建议拿实际评论分布校一遍——个人博客的评论密度差别很大,日更站和月更站的「45 个活跃日」完全是两个概念,定高了没人够得着,这套东西就白做了。

AFTER / 附记
COMMENTS / 评论

添加新评论

已有 16 条评论

  1. 老
    老罗常来常往门常开,人常来老罗 · Lv3「常来常往」 —— 下一阶「渐渐相熟」计数16/40← 当前瓶颈:再评 24 条活跃14/22资历190/60
    来看看,这个有没有的下载啊?
    1. 似
      似水流年
      没有啊,我直接修改的源文件,不是插件之类的。需要的话,我使用AI把相关的代码摘出来。
  2. d
    dousha萍水相逢一次擦肩,也是相逢dousha · Lv1「萍水相逢」 —— 下一阶「点头之交」计数2/5← 当前瓶颈:再评 3 条活跃2/3资历566/14
    用 `cite` 元素感觉有些破坏 HTML 语义啊.. 一定要用 `cite` 而不能使用 `div` 或者 `section` 之类不携带特殊语义的容器么?(只是单纯好奇)
    1. 似
      似水流年
      这个我还真没有考虑过,我重新看看,谢谢提醒。
  3. 全
    全局变量常来常往门常开,人常来全局变量 · Lv3「常来常往」 —— 下一阶「渐渐相熟」计数21/40← 当前瓶颈:再评 19 条活跃20/22资历329/60
    我记得去年有个博主,每次来评论的邮箱都不一样,有时候网址也不一样。我那时候做评论数统计的功能对于他来说相当鸡肋。
    1. 似
      似水流年
      谁啊,这么特立独行,那我这套也不适合。
  4. 摸
    摸鱼大王常来常往门常开,人常来摸鱼大王 · Lv3「常来常往」 —— 下一阶「渐渐相熟」计数15/40← 当前瓶颈:再评 25 条活跃10/22资历76/60
    我看看什么等级
    1. 似
      似水流年
      已经跻身中间阶层了。
  5. 扶
    扶苏常来常往门常开,人常来扶苏 · Lv3「常来常往」 —— 下一阶「渐渐相熟」计数26/40← 当前瓶颈:再评 14 条活跃22/22资历688/60
    你们都在弄级别表~我就不搞了,我之前弄了访问页面,也挺好~
    1. 似
      似水流年
      这个有没有都无所谓,主要还是内容和与大家的互动。
  6. 刘
    刘郎常来常往门常开,人常来刘郎 · Lv3「常来常往」 —— 下一阶「渐渐相熟」计数25/40← 当前瓶颈:再评 15 条活跃23/22资历697/60
    我现在自己用的这套 我个人感觉就非常不错 至少是我个人感觉还不错的 一目了然
    1. 似
      似水流年
      大家都有自己的特色。
  7. 博
    博客大全萍水相逢一次擦肩,也是相逢博客大全 · Lv1「萍水相逢」 —— 下一阶「点头之交」计数1/5活跃1/3资历0/14← 当前瓶颈:再等 14 天
    这个“相逢七阶”的思路很打动人,不靠积分和排行榜,而是安静地认出那些常来的老朋友,感觉评论区多了一份温度。
    1. 似
      似水流年
      是哩,主打一个佛系评论。😂
  8. V
    Vind常来常往门常开,人常来Vind · Lv3「常来常往」 —— 下一阶「渐渐相熟」计数19/40← 当前瓶颈:再评 21 条活跃12/22资历249/60
    加上相濡以沫,生死与共,等你投胎?
    1. 似
      似水流年
      这不合适吧,有点太暧昧太找打了。🤣