Telegram 在线时间统计 发布于 2023/08/14主页
最近糊了一个类似 Github 贡献墙的 Telegram 在线时间统计。目前已经作为公共服务推出了:@Online_Stats_Bot
效果可以直接打开链接查看。
后端
Telegram 会主动推送账号联系人的在线状态更新,接收更新并存储即可。存储后端我选择了 Elasticsearch。
为了计算在线时长,我选择的是计算 online 事件和 offline 事件之间的时间。对于无法闭合的落单事件,则默认这个事件发生的时刻在线了1秒钟。
数据结构
为了把数据塞进 URL 参数(并且避免了后端的需要),数据应被压缩的尽可能短,这样才方便链接的分享且看着好看。
首先来看原始数据:[{"timestamp": 1691971200, "online_seconds": 300}, {"timestamp": 1691974800, "online_seconds": 100}, ...]
有一个很明显的问题就是这里面其实这个 key 是不必要的,key 的长度甚至比数据本身还要长,可以改成元组:((1691971200, 300), (1691974800, 100), ...)
再观察这个数据,还会发现其实时间戳和秒数也包含了大量冗余信息。时间戳只需要精确到小时,而且时间戳是连续的,因此并无必要每一个时间戳都单独表示一次,只需要有一个起始时间时间戳,并且第0个小时、第1个小时这样就可以了。此外,由于展示时只需精确到分钟,数据中的秒数精度显得多余。
稍加优化,并且把 value 的单位改为分钟,我们得到了:(1691971200, (5, 2, ...))
如果我们要把这样的数据传进 URL 参数,实际上也是可以接受的:https://xxx.yyy?offset=1691971200&data=300,100,...
不过,显然还有更好的方法。URL 参数作为字符却只拿来传数字实在是亏麻了。我们可以把数据打包成二进制然后传 base64 编码后的二进制。
由于 value 是一个小时内的分钟,因此其范围肯定在0-60内。最接近这一范围的是 uint6,其范围为 0-63。由于0实际上表示在线了0-30s,所以还需要用 63 来表示这一小时为离线。还有两个数字没有用上,为了进一步节省空间,将62定为这一小时及下两个小时共三个小时均为离线;61为这以小时及下四个小时共五个小时均为离线。
编程语言的标准数据类型并没有 uint6 这种东西,这与 CPU 有关。我们可以把4个 uint6 组合成3个 uint8。按照大端序,则有 [AAAAAABB][BBBBCCCC][CCDDDDDD],其中ABCD代表四个 uint6 所占空间(6 bits),而中括号则代表 uint8 的边界。由于 24%4=0,(在不考虑61-62的情况下)一天的数据总是能不需要 padding 而组合成18个 uint8。而这也是为什么要把62定为代表下两个小时,61定为下四个小时。因为62可以省去2个 uint6,而一个61可以省去4个uint6,这样对齐就不需要 padding。如果有62落单,那么落单的62实际上并没有省空间,因为最后转换的时候还需要 padding 回去。
下面是由 ChatGPT 写的转换代码(以及 ChatGPT 写的代码展开栏):
function uint6sToBase64(uint6Array) {
if (uint6Array.length % 4 !== 0) {
throw new Error("The length of uint6Array should be a multiple of 4.");
}
let uint8Array = new Uint8Array(uint6Array.length / 4 * 3);
for (let i = 0, j = 0; i < uint6Array.length; i += 4, j += 3) {
let n = (uint6Array[i] << 18) | (uint6Array[i + 1] << 12) | (uint6Array[i + 2] << 6) | uint6Array[i + 3];
uint8Array[j] = (n >> 16) & 0xFF;
uint8Array[j + 1] = (n >> 8) & 0xFF;
uint8Array[j + 2] = n & 0xFF;
}
let binaryString = Array.from(uint8Array).map(byte => String.fromCharCode(byte)).join('');
return btoa(binaryString);
}
function base64ToUint6s(base64Str) {
let binaryString = atob(base64Str);
let uint8Array = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
uint8Array[i] = binaryString.charCodeAt(i);
}
let uint6Array = [];
for (let i = 0, j = 0; i < uint8Array.length; i += 3, j += 4) {
let n = (uint8Array[i] << 16) | (uint8Array[i + 1] << 8) | uint8Array[i + 2];
uint6Array[j] = (n >> 18) & 0x3F;
uint6Array[j + 1] = (n >> 12) & 0x3F;
uint6Array[j + 2] = (n >> 6) & 0x3F;
uint6Array[j + 3] = n & 0x3F;
}
return uint6Array;
}
function testConversion() {
let originalData = [];
for (let i = 0; i < 64; i++) {
originalData.push(Math.floor(Math.random() * 64));
}
console.log("Original Data:", originalData);
let base64Str = uint6sToBase64(originalData);
console.log("Converted to Base64:", base64Str);
let recoveredData = base64ToUint6s(base64Str);
console.log("Recovered Data:", recoveredData);
for (let i = 0; i < originalData.length; i++) {
if (originalData[i] !== recoveredData[i]) {
console.error("Mismatch detected at position", i);
return;
}
}
console.log("Test passed successfully!");
}
testConversion();