Dai0v0
1270 words
6 minutes
记一次懒加载接口逆向

前言#

前几天的碧蓝档案吧因为游戏和雀魂联动,麻辣人集体破防,到B站官号下面团建,截止 4/10 有了至少 220k 条评论。有群友说想把评论爬下来做数据分析,我也试了一下,顺便做个记录。

分析#

初见#

打开开发者工具,接口很好找:

https://api.bilibili.com/x/v2/reply/wbi/main?oid=915271360576487425&type=17&mode=3&pagination_str=%7B%22offset%22:%22%22%7D&plat=1&seek_rpid=0&web_location=1315875&w_rid=f3e31489b0f4d5dfe6c538261136d1a9&wts=1712838746

先是试了改字段里最像分页的 pagination_str ,但是。。。

{"code":-403,"message":"访问权限不足","ttl":1}

其他的字段 wts 像时间戳,web_locationw_rid 不知道什么意思。然后多翻了几页,

https://api.bilibili.com/x/v2/reply/wbi/main?oid=915271360576487425&type=17&mode=3&pagination_str=%7B%22offset%22:%22%7B%5C%22type%5C%22:1,%5C%22direction%5C%22:1,%5C%22session_id%5C%22:%5C%221753946872420510%5C%22,%5C%22data%5C%22:%7B%7D%7D%22%7D&plat=1&web_location=1315875&w_rid=381681a3d5b8cd76f54aab36b4476fe0&wts=1712839178

web_location 一直没有变化,最有可能的是 w_rid,感觉是某种加密,简单搜索了一下就看到 bilibili-API-collect: WBI 签名 (伟大 无需多盐),具体说明见链接,以下是 python 实现

from functools import reduce
from hashlib import md5
import urllib.parse
import time
import requests

mixinKeyEncTab = [
    46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
    33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40,
    61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11,
    36, 20, 34, 44, 52
]

def getMixinKey(orig: str):
    '对 imgKey 和 subKey 进行字符顺序打乱编码'
    return reduce(lambda s, i: s + orig[i], mixinKeyEncTab, '')[:32]

def encWbi(params: dict, img_key: str, sub_key: str):
    '为请求参数进行 wbi 签名'
    mixin_key = getMixinKey(img_key + sub_key)
    curr_time = round(time.time())
    params['wts'] = curr_time                                   # 添加 wts 字段
    params = dict(sorted(params.items()))                       # 按照 key 重排参数
    # 过滤 value 中的 "!'()*" 字符
    params = {
        k : ''.join(filter(lambda chr: chr not in "!'()*", str(v)))
        for k, v 
        in params.items()
    }
    query = urllib.parse.urlencode(params)                      # 序列化参数
    wbi_sign = md5((query + mixin_key).encode()).hexdigest()    # 计算 w_rid
    params['w_rid'] = wbi_sign
    return params

signed_params = encWbi(
    params={
        'foo': '114',
        'bar': '514',
        'baz': 1919810
    },
    img_key=img_key, # TODO
    sub_key=sub_key  # TODO
)
query = urllib.parse.urlencode(signed_params)
print(query)

加密算法现在知道了,加密需要 img_keysub_key,仓库里说有的接口是请求得到,有的是写死的。不过遗憾的是这个仓库里关于动态评论区的 API 只是标记了 Wbi 签名,暂时还没有更新具体字段。看来只能自己逆向了

逆向开始#

小手一翻,找到一个命令很有嫌疑的函数

进去看看,还套了一层,Ctrl+F 搜一下

alt text alt text

σ ゚∀ ゚)σ 見つけた,密钥估计就是这两个了,然后是具体的查询字段

wbiImgKey: "839c8b697b0d44dc80e9a604592bb432",
wbiSubKey: "02cd020b04d64aacad6b3a08d06f8eb0"
{
    oid: Vt,            // 目标评论区 id
    type: Zt,           // 评论区类型代码,17表示动态
    mode: Wt,           // 排序方式,默认为 3,0 3:仅按热度,1:按热度+按时间,2:仅按时间
    pagination_str: Qt, // 分页,第一页是 {"offset":""},具体不明
    plat: Ft,           // 平台代码,网页端是 1
    seek_rpid: Yt,      // jumpReplyId
    web_location: 1315875
}

还需要找的是 seek_rpidjumpReplyId)和 pagination_strnextPagination) 的具体内容,总是先打个断点看看

seek_rpid#

翻了几页看了 seek_rpid 的内容,都是 0 或 undefined 这类值,(虽然后来我也没有找到具体含义)所以我大胆猜测这个值直接填 0!

alt text

pagination_str#

这个字段的形式如下:

nextPagination: JSON.stringify({ // 第一次
    offset: ""
})

nextPagination: JSON.stringify({ // 之后的每一次
    offset: {
        "type": 1,
        "direction": 1,
        "session_id": "1753726982348946",
        "data": {}
    }
})

然后又过了很久很久。。。我到处打断点,找到一个 cn,他的值就是 nextPagination,胜利之道就在其中

alt text

关键在第二行的代码:

return (Vn = (mn = (pn = qt.value) == null ? void 0 : pn.cursor) == null ? void 0 : mn.pagination_reply) == null ? void 0 : Vn.next_offset

分析之后得到最后返回值来自:qt.value.cursor.pagination_reply.next_offset

那么 qt 又是什么呢?又是一顿断点调试,找到一个 qt = computed(()=>rt.state.apiData.replyList.res.data)

哈哈,觉得眼熟?😃
这样的场景,此时此刻正在世界各地上演✋😮🖐️

不错,这个 res.datacursor.pagination_reply.next_offset 长的和接口返回的 json 中的字段一样,回去看一下

alt text

这不就有了嘛,现在可以填写最后的字段了

signed_params = encWbi(
    params={
        "oid": 915271360576487425, 
        "type": 17, 
        "mode": 2, 
        "pagination_str": "{\"offset\":\"\"}", # 每次迭代替换 pagination
        "plat": 1, 
        "seek_rpid": 0, 
        "web_location": 1315875
    },
    img_key="839c8b697b0d44dc80e9a604592bb432",
    sub_key="02cd020b04d64aacad6b3a08d06f8eb0"
)
base_url = "https://api.bilibili.com/x/v2/reply/wbi/main?"
query = urllib.parse.urlencode(signed_params)
print(base_url + query)

爬取#

理论存在,实践开始

写个爬虫爬取 base_url + query,每次得到响应之后用结果的 next_offset 替换 pagination_str 就可以继续爬下一页了。

编写过程中的注意点:

  1. 游客身份无法获得除第一页以外的内容,所以需要在 header 传入 Cookie
  2. 经过亲身实践,这个接口没有限制访问频率(不然 220k 条评论我真的爬不完了(╥﹏╥)),所以不用设置随机睡眠时间(虽然道德角度来说最好还是写一下
  3. 这个接口中的每个评论虽然有 replies 字段,但只显示 2-4 条,也就是没有点击「展开更多评论」按钮前的信息,获取楼中楼得再爬一次:
    • 接口是:
    https://api.bilibili.com/x/v2/reply/reply?oid={oid}&type=17&root={rpid}&ps=10&pn={page_number}
    • 这个接口虽然找到的时候也有 wbi 签名的字段,但是去掉也不影响访问
    • 而且这个评论获取不是懒加载的,可以用多线程加速(

最后在 1h34m 的等待后,我获得了 223k 评论数据。至于怎么分析,那就是后话了~

alt text

好,以上。

记一次懒加载接口逆向
https://u1805.github.io/posts/bilibili-lazy-loading-comment-crawler/
Author
Dai0v0
Published at
2024-04-11