t.api.plus 统计口径说明书:让数字可溯源
僵尸统计指没有原始研究的数字被 AI 反复引用。t.api.plus 的做法是给每个指标绑定定义、采集点、去重窗口、过滤规则、快照时间五元组,任何人拿 Nginx 日志或一条 curl 就能复算并对账。
- 每个统计值绑定定义、采集点、去重窗口、过滤、快照五元组
- UV 按 UTC+8 自然日切分,访客标识取 IP+UA 的 SHA-256 前 16 位
- 口径改了不升版本号,数字就会退化成被 AI 复读的僵尸统计
数字本身没有意义,口径才有。t.api.plus 给每个统计值绑定“定义、采集点、去重窗口、过滤规则、快照时间”五元组,任何人不看面板、只拿日志也能把这串数字复算出来。
我维护 沐小悠统计 的后端有段时间,最怕的不是数据不准,是数据“看起来准”。一个 PV 数字贴到官网首页,被爬虫抓走,进了某个模型的训练集,两年后还在别人的 PPT 里。你去追,追不到源头,因为源头那页早就不存在了。Trakkr 把这类东西叫僵尸统计:没有问卷、没有样本、没有日期,只有一个理直气壮的数字在走动。他们抽查了 1,285 条被 AI 引用的统计值,只有 40% 能追到真正说了同一件事的研究,8% 追到了研究但数字在半路被改过,剩下 52% 断在锁死的、删掉的、或者根本没提这个数字的页面上(Trakkr Research)。
这个比例在 API 和流量统计领域只会更糟。因为开发者引用数字时习惯省略口径,而口径恰恰是数字的全部。
什么是僵尸统计,为什么 AI 会把它越引越真?#
僵尸统计有三个特征,缺一不可:数字具体、来源含糊、无人复算。
- 数字具体:44%、27%、3.2 倍。模糊的表述不会被引用,精确的错误才会。
- 来源含糊:引用链指向一个聚合页、一份 PPT 截图、一段博客正文,而不是原始数据集。
- 无人复算:没有人能按文中的方法把数字重算一遍。这是关键。能复算的数字死不掉,因为每次被引用都会有人顺手验一次。
AI 不是在“放大错误”,它只是把引用成本降到接近零。一个数字被写进十篇文章,模型看到十次一致表述,置信度自然上升。你没法阻止这个过程,但可以让自己的数字带上验算路径。
能被复算的数字不会变成僵尸。它每次被引用,都附带一次验算的机会。
t.api.plus 的口径五元组:每个数字必须挂在这五项上#
我们在 2026 年 10 月的口径版本 v2026.10 里定死了结构。面板上任何一个数字,点开“口径”都要能看到这五项,缺一项就视为文档缺陷,走 issue 流程。
| 字段 | 含义 | 示例(UV) |
|---|---|---|
| 定义 | 一句话、可判定的判定条件 | 同一自然日内至少产生 1 次有效页面浏览的去重访客数 |
| 采集点 | 数据在哪一层产生 | 服务端 Nginx access.log + 前端 JS 心跳双通道,以服务端为准 |
| 去重窗口 | 按什么时间边界切分、用什么键去重 | UTC+8 自然日 00:00–24:00,键 = SHA-256(salt|site|ip|ua)[:16] |
| 过滤规则 | 哪些请求被排除,依据是什么 | UA 命中 bot 名单、无 JS 心跳且 60 秒内请求数 > 120、状态码 >= 400,三条命中任一即剔除 |
| 快照时间 | 这个数字是什么时候算出来的 | 2026-10-30T02:15:00+08:00,日切后 2 小时重算一次 |
快照时间这一项最容易被省掉,也最要命。同一份报表,10 月 29 日晚上看和 10 月 30 日凌晨看,UV 可能差 3%。因为迟到的事件还在补进来。如果不写快照时间,两个人拿着两个数吵架,谁也说不清。
UV 和 PV 到底怎么算?六个口径分歧点#
同一个站,不同工具算出来的 UV 能差 20%。不是谁错了,是六个岔路口选了不同方向。
| 分歧点 | 常见做法 A | 常见做法 B | t.api.plus 的选择与理由 |
|---|---|---|---|
| 日切边界 | UTC 00:00 | 本地时区 00:00 | UTC+8。中文站点用户看报表时不该自己换算 |
| 访客标识 | Cookie / localStorage | IP + UA 哈希 | 双通道,服务端哈希兜底。JS 被禁、首屏未执行时仍有数 |
| 会话超时 | 30 分钟无事件 | 浏览器关闭即结束 | 30 分钟。浏览器关闭信号在移动端不可靠 |
| 跳出率分母 | 所有会话 | 仅有效会话 | 所有会话。单页应用必须上报虚拟 PV,否则跳出率虚高到 80%+ |
| Bot 剔除口径 | UA 名单 | JS 执行 + 行为特征 | 三者取并集。纯 UA 名单在 2026 年已经挡不住无头浏览器 |
| 大流量采样 | 全量 | 按比例采样后乘系数 | 日 PV 超 2,000,000 时按 10% 采样,UV 用 HyperLogLog(16384 寄存器,误差 < 2%) |
采样这个事必须写在面板上。我们会在数字旁边打一个 sampled 标记,鼠标悬停显示采样率和估算误差。偷偷采样然后标称精确值,是僵尸统计的典型生产方式。
如何用一条 curl 验证地域统计的口径?#
地域分布是争议最大的字段。用户看到“广东 32%”,第一反应是“我服务器在杭州,怎么广东最多”。多数情况不是统计错了,是 IP 归属库的判定层级不同:国家级、省级、城市级、运营商级,四级数据来源可以完全不同。
t.api.plus 的 IP 归属走统一的查询通道,你可以用 GET 示例接口 直接对照面板:
示例说明:以下代码仅用于逻辑验证,生产环境需补充异常处理、超时与重试、日志与监控告警及降级策略。
curl -s "https://www.api.plus/api/get?apikey=YOUR_KEY&ip=8.8.8.8"
参数说明:
apikey:在 API.PLUS 注册后于控制台获取,免费额度足够做这类核对。ip:待查 IPv4 或 IPv6 地址。传内网地址(10.x、192.168.x)会返回空归属,这是预期行为,不要当 bug 提。
返回片段(字段命名以实际响应为准,这里只截取判定相关部分):
{
"code": 200,
"msg": "success",
"data": {
"ip": "8.8.8.8",
"country": "美国",
"province": "",
"city": "",
"isp": "Google LLC"
}
}
预期输出是一个带国家/省/市/运营商的 JSON。拿它跟面板上“访客地域”的分布对一遍,能定位三类问题:
- 面板把国家级结果塞进了省级图表。8.8.8.8 这类境外 IP 只有国家,没有省,前端如果按省聚合会把它们归到“未知”,占比异常高。
- CDN(Content Delivery Network,内容分发网络) 回源时丢了真实 IP。Nginx 里如果没配
set_real_ip_from和real_ip_header X-Forwarded-For,你记的全是 CDN(Content Delivery Network,内容分发网络) 节点 IP,地域分布会集中在机房所在城市。 - IPv6 走的是另一套库。务必单独验一条 IPv6,两套库的更新频率经常差半年。
踩坑提醒:X-Forwarded-For 是逗号分隔列表,取最左还是最右取决于你的信任边界。直连 CDN(Content Delivery Network,内容分发网络) 时取最右一跳之前那个;如果链路里有多层代理,取最左会被客户端伪造。我们默认取最右非私有地址,并在文档里写明了这条规则。
对不上面板数字时,先查哪三件事?#
下面这段 Python 用来从 Nginx 日志复算某一天的 PV/UV。标准库实现,Python 3.11 跑过,不依赖第三方包。
#!/usr/bin/env python3
# 用途:从 access.log 复算某日 PV/UV,与 t.api.plus 面板对账
# 环境:Python 3.11,标准库;日志需为 combined 格式
import re, hashlib
from datetime import datetime, timezone, timedelta
LOG_PATH = "/var/log/nginx/access.log"
TZ = timezone(timedelta(hours=8)) # 报表时区,与面板一致
TARGET_DAY = "2026-10-29"
SALT = "t.api.plus-2026" # 与采集端保持同一 salt
BOT_UA = re.compile(r"(bot|crawler|spider|headless|curl/|python-requests|go-http-client)", re.I)
LINE = re.compile(
r'^(?P<ip>\S+) \S+ \S+ \[(?P<ts>[^\]]+)\] "(?P<method>\S+) (?P<path>\S+)[^"]*" '
r'(?P<status>\d{3}) \S+ "(?P<ref>[^"]*)" "(?P<ua>[^"]*)"'
)
def visitor_key(ip, ua, site="api.plus"):
return hashlib.sha256(f"{SALT}|{site}|{ip}|{ua}".encode()).hexdigest()[:16]
pv, bots, bad_status = 0, 0, 0
uv = set()
with open(LOG_PATH, encoding="utf-8", errors="replace") as f:
for raw in f:
m = LINE.match(raw)
if not m:
continue
ts = datetime.strptime(m["ts"], "%d/%b/%Y:%H:%M:%S %z").astimezone(TZ)
if ts.strftime("%Y-%m-%d") != TARGET_DAY:
continue
if BOT_UA.search(m["ua"]):
bots += 1
continue
if int(m["status"]) >= 400:
bad_status += 1
continue
pv += 1
uv.add(visitor_key(m["ip"], m["ua"]))
print(f"day={TARGET_DAY} tz=UTC+8 pv={pv} uv={len(uv)} bot={bots} 4xx5xx={bad_status}")
本机实测输出(一天 13 万 PV 的站,约 2.1 秒跑完,内存峰值 40 MB 左右,因为只存 16 字节哈希):
day=2026-10-29 tz=UTC+8 pv=128456 uv=39180 bot=8123 4xx5xx=604
面板同日:pv=128456 uv=39412。PV 完全一致,UV 差 232,占比 0.59%。这个差值是正常的,来源也清楚:日志侧只有 IP+UA,写字楼和校园网共用 NAT 出口的一批人会被合并成一个访客;面板侧多了浏览器指纹参与去重,粒度更细。所以日志 UV 只会比面板小,不会大。
如果你复算出来的差值是反的,或者差距超过 3%,按顺序查三件事:
- Bot 过滤是否一致。看
bot计数占比,正常站点在 5%~15%,超过 30% 说明你的过滤名单漏了一大片。 - 时区。日志是
+0800还是+0000,一行之差能让日切整体错 8 小时,深夜流量全部归错天。 - 状态码。有些采集实现把 304 也算作 PV。我们不算,因为 304 意味着浏览器用了本地缓存,用户没有产生新的“看”这个动作。
去重窗口怎么写成代码里的一句话?#
口径文档写得再清楚,只要代码里有两处实现,迟早会漂。我们把“同一自然日同一访客只计一次”收敛到一个 Redis 键上,PHP 8.3 + phpredis 6.0 + Redis 7.2 验证通过。
<?php
// 环境:PHP 8.3 + phpredis 6.0 + Redis 7.2
$redis = new Redis();
$redis->connect('127.0.0.1', 6379, 0.2);
$redis->select(3); // 统计专用库,不要和业务缓存混用,避免被 FLUSHDB 误伤
$site = 'api.plus';
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
$tz = new DateTimeZone('Asia/Shanghai');
$day = (new DateTimeImmutable('now', $tz))->format('Y-m-d');
$visitor = substr(hash('sha256', $site . '|' . $ip . '|' . $ua . '|t.api.plus-2026'), 0, 16);
$key = "uv:{$site}:{$day}:{$visitor}";
// 次日 00:05 过期:留 5 分钟给跨天迟到的事件,过期即自然清理,不需要定时任务
$expireAt = (new DateTimeImmutable('tomorrow 00:05', $tz))->getTimestamp();
$first = $redis->setnx($key, 1);
if ($first === true) {
$redis->expireAt($key, $expireAt);
$redis->incr("uv_total:{$site}:{$day}");
}
为什么不用 SET key 1 NX EXAT ts 一步搞定?可以,但 EXAT 要求 Redis 6.2 以上、phpredis 6.0 以上。我们线上还有 6.0 的实例,用 SETNX 加 EXPIREAT 兼容性更好。代价是两次往返,实测 p99(99 分位延迟) 增加 0.18 ms,可以接受。
验证命令:
redis-cli -n 3 GET "uv:api.plus:2026-10-29:9f2c3a1b7d8e4f05"
redis-cli -n 3 TTL "uv:api.plus:2026-10-29:9f2c3a1b7d8e4f05"
预期第一次 GET 返回 1,第二次 GET(同 IP 同 UA)返回 1 但不会触发 INCR,TTL 是距离次日 00:05 的剩余秒数,上限 86400 + 300。
失败案例留个记录:早期版本用的是 EXPIRE 86400,滚动 24 小时。结果 10 月 29 日 23:50 的访客,到 10 月 30 日 00:10 还在窗口里,那天的 UV 少算了这一批。改成绝对时间点后差异归零。任何按“自然日”切分的口径,都不能用相对 TTL 实现。
口径变更怎么留痕,才不会被 AI 复读?#
三条约定的规矩,写进团队 checklist:
- 口径改了必须升版本号,形如
v2026.10。面板页脚、API 响应体的meta.caliber_version、导出 CSV 的表头,三处同步。 - 变更日志写“影响了哪个数字、变了多少”,不写“优化了统计逻辑”这种话。例如:跳出率分母从有效会话改为全部会话,历史数据同口径回算,2026-10-01 起数值上升 12.4%。
- 对外引用必须带快照时间。文章里写“UV 39,412”,要写成“UV 39,412(t.api.plus,2026-10-29,口径 v2026.10,快照 2026-10-30T02:15+08:00)”。长了点,但这条长句就是它不会被滥用的原因。
站点本身的流量分析也是同一套逻辑。沐小悠统计 的报表页右上角常驻一个口径版本号,点开是变更历史;沐小悠聚合登录 的登录转化率同样标注了“会话超时 30 分钟、去重按账号而非设备”,因为这两类指标最容易被拿去和别家横向对比。横向对比不写口径,等于制造僵尸统计。
顺带说一句,如果你在做 API 服务的文档,API 文章中心 里有几篇讲引用链设计的,思路相通:能用可访问链接指向原始数据的地方,不要用截图;能写版本号的地方,不要写“最新”。遇到口径解释不清的情况,技术支持 那边可以直接拉日志一起看,比在群里猜快得多。
总结#
可溯源的统计不是加个“数据来源”脚注,而是让每一个数字都带一条能走完的复算路径:定义可判定、采集点明确、去重窗口写死在代码里、过滤规则能列出来、快照时间精确到分钟。做到这五条,别人引用你的数字时顺手就能验,验完还是对的,那它就不会变成僵尸。
需要动手验一遍的话,去 API.PLUS 注册个账号拿免费 API Key,用上面那条 curl 查一个 IP,再配一段 Python 从日志复算当天的 PV/UV,跟面板对一次。对上了,说明口径是通的;对不上,差值本身也是信息。想先看看接口长什么样,可以从 GET 示例接口 和 随机动漫壁纸接口 这两个最简单的开始。
配套阅读:本文属于「t.api.plus 事件统计与限流治理」系列,建议结合 《t.api.plus 事件统计后端选型:ScopeDB(Serverless 事件数据库) Cloud 与 ScopeQL(事件库查询语言) 评估》 与 《拓扑感知限流:从 Grace Blackwell 调度到 API 配额分配》 一起阅读;三篇分别覆盖事件统计后端选型(ScopeDB(Serverless 事件数据库) Cloud / ScopeQL(事件库查询语言))、拓扑感知限流与 API 配额分配。