前言
本文主要记录一次Surge或QuantumultX的脚本开发过程, 过程包括抓包、分析、调试、以及编写脚本.
记录内容为哔哩哔哩漫画积分商城自动抢券脚本.
背景
作为一个合格的二次元迷, 漫画当然是少不了🧐
国内日漫大多都被哔哩哔哩漫画拿下版权, 然而里面大多数的日漫都需要氪金或使用漫读券才能看;
最近发现使用签到脚本获得的积分囤得差不多了, 换漫读券又可以省一笔, 无奈老是错过相关商品兑换时间, 一气之写了个脚本让它在规定时间内自动抢券.
过程
第一步: 抓包
本文将使用Thor进行抓包, 并使用Anubis进行重放演示, 简单分析哔哩哔哩漫画的网络请求.
我们进入哔哩哔哩漫画APP后, 打开Thor开启抓包, 返回APP积分商城随便兑换一个东西, 再返回Thor关闭抓包.
Thor有着完备的关键字过滤, 刚刚兑换了75积分的商品, 我们可以尝试搜索请求体和响应体内的关键字, 看看是否有结果.
搜索后一个名为Pointshop/Exchange的请求直接映入眼下, 翻译成中文大意为 店铺/兑换, 很直观.
我们进一步查看该请求的请求体, 以及响应体.
可以很直观的看到请求体中的各种参数, product_id 表示兑换的商品, product_num 表示兑换的数量, point 表示消耗的积分.
响应体中code为0表示兑换成功, expire_day表示有效期, remain_amount表示该商品库存.
我们把该请求使用anubis重放, 看看该接口是否有效.
可以看到重放后该接口是可用的, 商品剩余数量相应减少, 返回app查看账号积分也减少了75并收到商品.
之后我们看一下请求体中的product_id的商品id是怎么来的, 返回Thor之前抓到的包, 筛选器搜索1048关键字.
搜索后有一个叫ListProduct的请求, 翻译成中文大意为商品清单, 我们点开响应体可以看到一个商品名为”小智怪谈”的商品, 商品id为1048, 商品库存为3796, 正是我之前所兑换的商品.
该接口使用anubis重放后并没有什么问题;
最后还差一个查询账户积分的接口, 我们使用Thor筛选器搜索响应关键字, 开启抓包之前我的账户有3172积分, 则尝试搜索3172
可以看到有一个叫GetUserPoint的请求, 翻译成中文大意为获取用户积分, 点开响应后我们可以看到查询到的账户积分.
第二步: 分析
我们抓到接口后使用anubis重放进一步分析:
- 精简参数, 分析url中的device之类的参数是否必须、是否验证Cookie、请求体是否必须, 以减少脚本编写工作量.
- 分析各种情况下接口返回不同响应的可能性, 供脚本正确判断.
分析查询积分接口
1 2
| 原接口 https://manga.bilibili.com/twirp/pointshop.v1.Pointshop/GetUserPoint?device=h5&platform=web
|
经过各种重放后可得知
- 接口可省略url中的参数.
- 请求体可省略.
- 必须使用POST方法.
- 使用请求头中的Cookie字段作为用户鉴权, 一些非必要字段也可省略.
带有效Cookie响应体内容为
1 2 3 4 5 6 7
| { "code": 0, "msg": "", "data": { "point": "用户实际积分数量" } }
|
Cookie失效后响应体内容为
1 2 3 4 5 6 7
| { "code": 0, "msg": "", "data": { "point": "0" } }
|
分析查询商品接口
1 2
| 原接口 https://manga.bilibili.com/twirp/pointshop.v1.Pointshop/ListProduct?device=h5&platform=web
|
经过各种重放后可得知
- 接口可省略url中的参数.
- 请求体可省略.
- 必须使用POST方法.
- 无需用户鉴权.
响应体:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| { "code": 0, "msg": "", "data": [{ "id": 195, "type": 7, "title": "积分兑换", "image": "", "amount": 15999, "cost": 200, "real_cost": 100, "remain_amount": 0, "comic_id": 0, "limits": [], "discount": 0, "product_type": 1, "pendant_url": "", "pendant_expire": 0, "exchange_limit": 0, "address_deadline": "0001-01-01T00:00:00Z", "act_type": 0, "has_exchanged": false, "main_coupon_deadline": "0001-01-01T00:00:00Z", "deadline": "", "point": "0" }] }
|
以上非实际响应, 其他商品过多, 已省略.
分析兑换商品接口
1 2
| 原接口 https://manga.bilibili.com/twirp/pointshop.v1.Pointshop/Exchange?device=h5&platform=web
|
经过各种重放后可得知
接口可省略url中的参数.
请求体需带有
1 2 3 4 5
| { "product_id": "商品ID", "product_num": "商品兑换数量", "point": "总消耗积分数量" }
|
必须使用POST方法.
使用请求头中的Cookie字段作为用户鉴权, 一些非必要字段也可省略.
兑换成功响应体内容为
1 2 3 4 5 6 7 8 9 10
| { "code": 0, "msg": "", "data": { "id": "商品使用ID", "expire_day": "商品过期剩余天", "remain_amount": "商品库存", "deadline": "0001-01-01T00:00:00Z" } }
|
第三步: 编写脚本
什么是函数
函数是 JavaScript 中的基本组件之一, 一个函数是 JavaScript过程中执行一组任务或计算值的语句.
本文的抢券脚本将定义各种函数以方便统一调用.
查看 JavaScript 函数详细参考文档 了解更多.
应用兼容
由于该脚本针对多平台, 脚本的写法需要同时兼容Surge或QuanX之类的客户端, 那么我们就需要写一个兼容函数让它在不同环境下也能被正确执行.
以下函数兼容Surge、QX、Loon中的部分API, 包括持久化读取、通知、POST请求
兼容函数 (点击左边箭头展开) >folded1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
|
function nobyda() { const isSurge = typeof $httpClient != "undefined"; const isQuanX = typeof $task != "undefined"; const isNode = typeof require == "function"; const node = (() => { if (isNode) { const request = require('request'); return { request } } else { return null; } })() const adapterStatus = (response) => { if (response) { if (response.status) { response["statusCode"] = response.status } else if (response.statusCode) { response["status"] = response.statusCode } } return response } this.read = (key) => { if (isQuanX) return $prefs.valueForKey(key) if (isSurge) return $persistentStore.read(key) } this.notify = (title, subtitle, message) => { if (isQuanX) $notify(title, subtitle, message) if (isSurge) $notification.post(title, subtitle, message) if (isNode) console.log(`${title}\n${subtitle}\n${message}`) } this.post = (options, callback) => { options.headers['User-Agent'] = 'User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 13_6_1 like Mac OS X) AppleWebKit/609.3.5.0.2 (KHTML, like Gecko) Mobile/17G80 BiliApp/822 mobi_app/ios_comic channel/AppStore BiliComic/822' if (isQuanX) { if (typeof options == "string") options = { url: options } options["method"] = "POST" $task.fetch(options).then(response => { callback(null, adapterStatus(response), response.body) }, reason => callback(reason.error, null, null)) } if (isSurge) { options.headers['X-Surge-Skip-Scripting'] = false $httpClient.post(options, (error, response, body) => { callback(error, adapterStatus(response), body) }) } if (isNode) { node.request.post(options, (error, response, body) => { callback(error, adapterStatus(response), body) }) } } this.done = () => { if (isQuanX || isSurge) { $done() } } };
|
全局变量
写好兼容函数后我们先定义一些全局变量, 供所有函数调用.
全局变量1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| let $ = new nobyda();
let productName = $.read('BM_ProductName') || '积分兑换';
let productNum = $.read('BM_ProductNum');
let exchangeNum = $.read('BM_ExchangeNum') || '100';
let cookie = $.read('CookieBM');
let user = {};
|
查询积分
脚本开始时首先要做的是查询账号里的积分是否符合兑换要求.
该函数调用时, 将执行查询积分, 如果查询用户积分成功, 则将查询到的积分赋值到全局变量中, 供其他函数读取.
查询积分函数1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| function GetUserPoint() { const pointUrl = { url: 'https://manga.bilibili.com/twirp/pointshop.v1.Pointshop/GetUserPoint', headers: { 'Cookie': cookie } } return new Promise((resolve) => { $.post(pointUrl, (error, resp, data) => { try { if (error) { throw new Error(error); } else { const body = JSON.parse(data); if (body.code == 0 && body.data) { user.point = parseInt(body.data.point); console.log(`\n当前积分: ${body.data.point}`); } else { throw new Error(body.msg || data); } } } catch (e) { console.log(`\n查询积分: 失败\n出现错误: ${e.message}`); } finally { resolve(); } }) }) }
|
查询商品
根据前面的分析, 我们兑换商品时需要相应的商品ID.
该函数调用时, 将执行查询操作, 并根据全局变量所定义的商品名进行过滤, 过滤后的内容仅包含相关商品的基本信息, 例如商品ID、兑换价格、库存等, 再把过滤后的内容赋值到全局变量中, 供其他函数读取.
查询商品函数1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| function ListProduct() { const listUrl = { url: 'https://manga.bilibili.com/twirp/pointshop.v1.Pointshop/ListProduct', headers: {} } return new Promise((resolve) => { $.post(listUrl, (error, resp, data) => { try { if (error) { throw new Error(error); } else { const body = JSON.parse(data); if (body.code == 0 && body.data.length >= 1) { user.list = body.data.filter(t => t.title == productName).pop(); if (!user.list) { throw new Error('请检查商品名'); } else { console.log(`\n查询商品: ${productName}\n商品库存: ${user.list.remain_amount}`) } } else { throw new Error('无商品列表'); } } } catch (e) { console.log(`\n查询商品: ${productName}\n出现错误: ${e.message}`); } finally { resolve(); } }) }) }
|
兑换商品
兑换商品分为两个函数, 第一个函数调用后, 将根据前面查询到的数据进行判断, 如果商品有库存并且用户积分大于100, 则根据全局变量所定义的循环次数, 暴力调用第二个”请求”函数 (默认循环100次)
兑换商品函数1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
| function ExchangeProduct() { return new Promise(async (resolve) => { if (user.list && user.list.remain_amount && user.point >= 100) { const num = parseInt(productNum || (user.point / user.list.real_cost)); const exchangeUrl = { url: 'https://manga.bilibili.com/twirp/pointshop.v1.Pointshop/Exchange', headers: { 'Content-Type': 'application/json', 'Cookie': cookie }, body: JSON.stringify({ product_id: user.list.id, product_num: num, point: num * user.list.real_cost }) }; for (let i = 0; i < parseInt(exchangeNum); i++) { const run = await startExchange(exchangeUrl, i, num); if (run) { break; } } } else { console.log(`\n抢购终止: 不具备兑换条件`); } resolve(); }) }
function startExchange(url, item, amount) { return new Promise((resolve) => { $.post(url, (error, resp, data) => { try { if (error) { throw new Error(error); } else { const body = JSON.parse(data); if (body.code == 0) { console.log(`\n抢购成功: 第${item+1}次\n抢购数量: ${amount}\n消耗积分: ${amount * user.list.real_cost}`); $.notify('哔哩哔哩漫画抢券', '', `"${productName}"抢购成功, 数量: ${amount}, 消耗积分: ${amount * user.list.real_cost}`); resolve(true); } else { throw new Error(body.msg || '未知'); } } } catch (e) { console.log(`\n抢购失败: 第${item+1}次\n失败原因: ${e.message}`); resolve(); } }) }) }
|
统一调用
写好函数后, 如果不进行调用, 那么代码是无法被运行的.
在javascript中, 代码的执行顺序很重要, 我们的需求是先同时查询积分和商品, 再抢购, 最后退出脚本.
以下匿名函数将按我们预期的顺序执行.
匿名异步函数1 2 3 4 5 6 7 8 9
| (async function() { await Promise.all([ GetUserPoint(), ListProduct() ]); await ExchangeProduct(); $.done(); })();
|
第四步: 配置任务
我们写好脚本后, 就可以在Surge或QuantumultX里配置定时任务让它在规定时间内执行该脚本.
以下将用Surge进行演示
编辑Surge配置文件, 在[Script]段落放入以下脚本
1
| 哔哩哔哩漫画抢券 = type=cron,cronexp="10,20,30 0 12 * * *",wake-system=1,timeout=60,script-path=https://raw.githubusercontent.com/NobyDa/Script/master/Bilibili-DailyBonus/ExchangePoints.js
|
因为积分商城刷新时间在每天中午12点, 则以上配置将在每天中午12:00:10、12:00:20、12:00:30分别执行一次.
有几点需要注意
脚本抢购完成后注意禁用, 避免每天无意义的运行
脚本需要使用哔哩哔哩签到脚本获取Cookie后方可使用.
默认兑换积分商城中的 “积分兑换”; 可自行修改.
兑换数量为用户积分可兑换的最大值; 可自行修改.
结语
本文又是一篇专业性极强的文章, 有的同学可能又要头大了; 写这篇文章动机主要是为了让读者了解一个需求的实现过程, 脚本里也有大量注释供读者理解思路.
这个需求说难也不太难, 就是抓几个接口去模拟用户兑换的行为. 当然还是需要有一定的抓包经验和javascript功底才行; 我的javascript功底也就勉勉强强够我写几个简单需求.
虽然本文看起来比较水, 但还是建议初学者照着本文去理解抓包思路, 以及脚本的执行逻辑.
最后, 提前祝大家周末愉快!