猜测、实现 B 站在看人数

猜测

找到接口

浏览器打开一个 B 站视频,比如 《黑神话:悟空》最终预告 | 8月20日,重走西游_黑神话悟空 (bilibili.com) ,打开 F12 开发者工具,经过观察,发现每 30 秒就会有一个如下的请求:

https://api.bilibili.com/x/player/online/total?aid=1056417986&cid=1641689875&bvid=BV1oH4y1c7Kk&ts=57523354

{
    "code": 0,
    "message": "0",
    "ttl": 1,
    "data": {
        "total": "239",
        "count": "182",
        "show_switch": {
            "total": true,
            "count": true
        },
        "abtest": {
            "group": "b"
        }
    }
}

返回值中的 data.total 就是在看人数,如下:

image-20240907171726923

参数

请求有 4 个参数:

aid=1056417986
cid=1641689875
bvid=BV1oH4y1c7Kk
ts=57523354

aid、bvid 是稿件的编号,cid 是视频的编号,一个稿件可能有多个视频。通过三者可定位到唯一的视频。

ts 从命名上来看应该是时间戳,比如 57523353、57523354 ,但显然太短了,应该是经过处理的,最后发现是时间戳(秒)除以 30 向上取整的结果:

calcTs = function(date) {
    // 时间戳(秒)
    const timestamp_second = date.getTime() / 1000;
    // 除以 30 向上取整
    const ts = Math.ceil(timestamp_second / 30);
    console.log(ts)
    return ts;
}

下图是两个请求的参数以及请求的时间:

image-20240907172308166

image-20240907172326531

在浏览器控制台验证猜想,通过 calcTs 函数可计算出 ts,与请求参数完全吻合:

image-20240907172656593

总结

B 站的实现思路应该是:aid、bvid、cid 作为唯一编号,以 30 秒为一个时间窗口进行统计,在这 30s 中的请求都会使窗口值加 1,每次累加完后返回最新值即可。

但同时还发现在多个标签页中打开同一个视频时,比如 5 个标签页,一开始在看人数都是 1,等一会在看人数才会陆续变成 5。也就是说返回的不是最新值,因为如果返回最新值的话,5 个标签页的在看人数应该分别是 1 2 3 4 5

猜测应该是同时存在两个 30 秒时间窗口,这里称为当前窗口( currentWindow ,也就是 ts 对应的 30s 窗口) 和上一个窗口(previousWindowts - 1 对应的 30s 窗口),每次都累加到 currentWindow,但返回 previousWindow

这样就能解释为什么一开始在看人数都是 1,等一会在看人数才会陆续变成 5 了。打开视频时,previousWindow 不存在,所以返回了 1;同时创建 currentWindow 并从 1 累加到 5。这样等 30s 后下一个定时任务时,currentWindow 就变成了 previousWindow,5 个标签页都会返回 5,在看人数就都陆续变成 5 了。

实现

后端可以使用 Redis 实现,最简单的办法是使用 string 结构,以 aid、bvid、cid、ts 作为 key,给 key 设置大于 60s 的过期时间,每次请求时使用 incr 自增即可。但这样会导致 Redis 找那个有大量的 key,不好维护。

可以使用 hash 结构,以 ts 为 key,以 aid、bvid、cid 为 field,窗口值为 value。这样 Redis 中只会有 ts、ts - 1 两个 key。如果必要的话,也可以根据 field 的值将其 hash 分区到 2 * N 个 key 中。

TotalService

package com.example.demo3;

import lombok.SneakyThrows;
import org.redisson.api.*;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.GetMapping;

import java.time.Duration;
import java.util.concurrent.ExecutionException;

@Service
public class TotalService {

    private final RedissonClient redisson;

    public TotalService(RedissonClient redisson) {
        this.redisson = redisson;
    }

    @SneakyThrows({ExecutionException.class, InterruptedException.class})
    @GetMapping
    public Integer total(String aid, String bvid, String cid, Long ts) {
        RBatch batch = redisson.createBatch(BatchOptions.defaults());
        // currentWindow
        // 以时间戳作为 key
        RMapAsyncString, Integer> currentWindow = batch.getMap(ts.toString());
        // 以 aid, bvid, cid 作为 currentWindow 的 key
        String field = field(aid, bvid, cid);
        // 自增 + 1
        currentWindow.addAndGetAsync(field, 1);
        // 过期时间必须大于 60s
        currentWindow.expireIfNotSetAsync(Duration.ofSeconds(70));

        // previousWindow
        RMapAsyncString, Integer> previousWindow = batch.getMap(String.valueOf(ts - 1));
        RFutureInteger> totalFuture = previousWindow.getAsync(field);
        batch.execute();

        Integer total = totalFuture.get();
        // 如果 previousWindow 不存在,则返回 1
        if (total == null || total == 0) {
            return 1;
        }
        return total;
    }

    private String field(String aid, String bvid, String cid) {
        return aid + ":" + bvid + ":" + cid;
    }
}

TotalController

@RestController
@RequestMapping("/x/player/online/total")
public class TotalController {

    private final TotalService totalService;

    public TotalController(TotalService totalService) {
        this.totalService = totalService;
    }

    @CrossOrigin(originPatterns = "*")
    @GetMapping
    public Integer total(@RequestParam("aid") String aid, @RequestParam("bvid") String bvid,
                         @RequestParam("cid") String cid, @RequestParam("ts") Long ts) {
        return totalService.total(aid, bvid, cid, ts);
    }
}

test.html

DOCTYPE html>
html lang="en">
head>
    meta charset="UTF-8">
    title>Titletitle>
head>
body>
div>
    div>
        aid input id="aid" type="text" value="113071355923972">
        bvid input id="bvid" type="text" value="BV1giHnexEiD">
        cid input id="cid" type="text" value="25714427593">
    div>
    div>
        在看:span id="total">0span>
    div>
div>
body>
script type="text/javascript">
    const elem_aid = document.getElementById("aid");
    const elem_bvid_elem = document.getElementById("bvid");
    const elem_cid_elem = document.getElementById("cid");
    const elem_total = document.getElementById("total");

    refreshTotal().then(() => {
        // 30 秒执行一次
        setInterval(function () {
            refreshTotal();
        }, 30000)
    });

    async function refreshTotal() {
        const aid = elem_aid.value;
        const bvid = elem_bvid_elem.value;
        const cid = elem_cid_elem.value;
        const ts = calcTs(new Date());
        const url = `http://localhost:8080/x/player/online/total?aid=${aid}&cid=${cid}&bvid=${bvid}&ts=${ts}`;
        const response = await fetch(url);
        const total = await response.json();
        console.log(total);
        elem_total.innerHTML = total;
    }

    function calcTs(date) {
        // 时间戳(秒)
        const timestamp_second = date.getTime() / 1000;
        // 除以 30 向上取整
        const ts = Math.ceil(timestamp_second / 30);
        console.log(ts)
        return ts;
    }
script>
html>

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。