记录一次Surge & QuantumultX 脚本开发过程

记录一次Surge & QuantumultX 脚本开发过程

前言

  1. 本文主要记录一次Surge或QuantumultX的脚本开发过程, 过程包括抓包、分析、调试、以及编写脚本.

  2. 记录内容为哔哩哔哩漫画积分商城自动抢券脚本.

背景

作为一个合格的二次元迷, 漫画当然是少不了🧐

国内日漫大多都被哔哩哔哩漫画拿下版权, 然而里面大多数的日漫都需要氪金或使用漫读券才能看;

最近发现使用签到脚本获得的积分囤得差不多了, 换漫读券又可以省一笔, 无奈老是错过相关商品兑换时间, 一气之写了个脚本让它在规定时间内自动抢券.

过程

第一步: 抓包

本文将使用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重放进一步分析:

  1. 精简参数, 分析url中的device之类的参数是否必须、是否验证Cookie、请求体是否必须, 以减少脚本编写工作量.
  2. 分析各种情况下接口返回不同响应的可能性, 供脚本正确判断.

分析查询积分接口

1
2
原接口
https://manga.bilibili.com/twirp/pointshop.v1.Pointshop/GetUserPoint?device=h5&platform=web

经过各种重放后可得知

  1. 接口可省略url中的参数.
  2. 请求体可省略.
  3. 必须使用POST方法.
  4. 使用请求头中的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

经过各种重放后可得知

  1. 接口可省略url中的参数.
  2. 请求体可省略.
  3. 必须使用POST方法.
  4. 无需用户鉴权.

响应体:

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

经过各种重放后可得知

  1. 接口可省略url中的参数.

  2. 请求体需带有

    1
    2
    3
    4
    5
    {
    "product_id": "商品ID",
    "product_num": "商品兑换数量",
    "point": "总消耗积分数量"
    }
  3. 必须使用POST方法.

  4. 使用请求头中的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请求

兼容函数 (点击左边箭头展开) >folded
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
56
57
58
59
60
61
62
63
64
65
66
67
68
69

// const $ = new nobyda();
// 发送一个通知: $.notify('title', 'subtitle', 'message')
// 持久化读取: $.read('Key')
// POST请求: $.post(url<Object>,callback<Function>)

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();

// 读取兑换商品名, 默认兑换积分商城中的"积分兑换"; 该接口为BoxJs预留, 以便修改
let productName = $.read('BM_ProductName') || '积分兑换';

// 读取兑换数量, 默认兑换最大值; 该接口为BoxJs预留, 以便修改
let productNum = $.read('BM_ProductNum');

// 读取循环抢购次数, 默认100次; 该接口为BoxJs预留, 以便修改
let exchangeNum = $.read('BM_ExchangeNum') || '100';

// 读取哔哩哔哩漫画签到脚本所使用的Cookie
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 //用户鉴权Cookie
}
}
return new Promise((resolve) => { //主函数返回Promise实例对象, 以便后续调用时可以实现顺序执行异步函数
$.post(pointUrl, (error, resp, data) => { //使用post请求查询, 再使用回调函数处理返回的结果
try { //使用try方法捕获可能出现的代码异常
if (error) {
throw new Error(error); //如果请求失败, 例如无法联网, 则抛出一个异常
} else {
const body = JSON.parse(data); //解析响应体json并转化为对象
if (body.code == 0 && body.data) { //如果响应体为预期格式
user.point = parseInt(body.data.point); //把查询的积分赋值到全局变量user中
console.log(`\n当前积分: ${body.data.point}`); //打印日志
} else { //否则抛出一个异常
throw new Error(body.msg || data);
}
}
} catch (e) { //接住try代码块中抛出的异常, 并打印日志
console.log(`\n查询积分: 失败\n出现错误: ${e.message}`);
} finally { //finally语句在try和catch之后无论有无异常都会执行
resolve(); //异步操作成功时调用, 将Promise对象的状态标记为"成功", 表示已完成查询积分
}
})
})
}

查询商品

根据前面的分析, 我们兑换商品时需要相应的商品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) => { //主函数返回Promise实例对象, 以便后续调用时可以实现顺序执行异步函数
$.post(listUrl, (error, resp, data) => { //使用post请求查询, 再使用回调函数处理返回的结果
try { //使用try方法捕获可能出现的代码异常
if (error) {
throw new Error(error); //如果请求失败, 例如无法联网, 则抛出一个异常
} else {
const body = JSON.parse(data); //解析响应体json并转化为对象
if (body.code == 0 && body.data.length >= 1) { //如果接口正常返回商品信息
// 按全局变量所填写的商品名进行过滤, 并把商品信息赋值到全局变量user中
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) { //接住try代码块中抛出的异常并打印日志
console.log(`\n查询商品: ${productName}\n出现错误: ${e.message}`);
} finally { //finally语句在try和catch之后无论有无异常都会执行
resolve(); //异步操作成功时调用, 将Promise对象的状态标记为"成功", 表示已完成查询商品
}
})
})
}

兑换商品

兑换商品分为两个函数, 第一个函数调用后, 将根据前面查询到的数据进行判断, 如果商品有库存并且用户积分大于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) => { //主函数返回Promise实例对象, 以便后续调用时可以实现顺序执行异步函数, 该实例函数带有async关键字, 表示里面有异步操作, 例如可使用await得到异步结果
if (user.list && user.list.remain_amount && user.point >= 100) { //如果商品有库存并且用户积分大于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 //用户鉴权Cookie
},
body: JSON.stringify({ //请求体转成字符串类型
product_id: user.list.id, //兑换的商品id
product_num: num, //兑换的商品数量
point: num * user.list.real_cost //消耗的积分总数 (兑换数量乘单价得到积分总数)
})
};
for (let i = 0; i < parseInt(exchangeNum); i++) { //根据全局变量定义的次数, 暴力循环抢购
// 循环内调用另一个抢购函数, 并传入请求、第几次循环、兑换数量等参数,
// 使用await关键字声明, 表示需要等待每一次的执行结果
const run = await startExchange(exchangeUrl, i, num);
if (run) {
break; //如果函数返回布尔值true, 则跳出循环, 脚本结束
}
}
} else { //商品无库存或用户积分小于100等情况, 则不执行抢购, 脚本结束
console.log(`\n抢购终止: 不具备兑换条件`); //打印日志
}
resolve(); //将主函数的Promise对象状态标记为"成功", 表示已完成抢购任务
})
}

function startExchange(url, item, amount) {
return new Promise((resolve) => { //主函数返回Promise实例对象, 以便后续调用时可以实现顺序执行异步函数
$.post(url, (error, resp, data) => { //使用post请求查询, 再使用回调函数处理返回的结果
try { //使用try方法捕获可能出现的代码异常
if (error) {
throw new Error(error); //如果请求失败, 例如无法联网, 则抛出一个异常
} else {
const body = JSON.parse(data); //解析响应体json并转化为对象
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); //将Promise对象的状态标记为"成功", 然后返回一个布尔值true用于跳出循环
} else {
throw new Error(body.msg || '未知'); //抢购失败则抛出异常
}
}
} catch (e) { //接住try代码块中抛出的异常并打印日志
console.log(`\n抢购失败: 第${item+1}次\n失败原因: ${e.message}`);
resolve(); //将Promise对象的状态标记为"成功", 但不返回任何值, 表示继续循环抢购
}
})
})
}

统一调用

写好函数后, 如果不进行调用, 那么代码是无法被运行的.

在javascript中, 代码的执行顺序很重要, 我们的需求是先同时查询积分和商品, 再抢购, 最后退出脚本.

以下匿名函数将按我们预期的顺序执行.

匿名异步函数
1
2
3
4
5
6
7
8
9
(async function() { // 立即运行的匿名异步函数
// 使用await关键字声明, 表示以同步方式执行异步函数, 可以简单理解为顺序执行
await Promise.all([ //该方法用于将多个实例包装成一个新的实例, 可以简单理解为同时调用函数, 以进一步提高执行速度
GetUserPoint(), //查询积分函数
ListProduct() //查询商品函数
]);
await ExchangeProduct(); //上面的查询都完成后, 则执行抢购
$.done(); //抢购完成后调用Surge、QX内部特有的函数, 用于退出脚本执行
})();

第四步: 配置任务

我们写好脚本后, 就可以在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分别执行一次.

有几点需要注意

  1. 脚本抢购完成后注意禁用, 避免每天无意义的运行

  2. 脚本需要使用哔哩哔哩签到脚本获取Cookie后方可使用.

  3. 默认兑换积分商城中的 “积分兑换”; 可自行修改.

  4. 兑换数量为用户积分可兑换的最大值; 可自行修改.

结语

本文又是一篇专业性极强的文章, 有的同学可能又要头大了; 写这篇文章动机主要是为了让读者了解一个需求的实现过程, 脚本里也有大量注释供读者理解思路.

这个需求说难也不太难, 就是抓几个接口去模拟用户兑换的行为. 当然还是需要有一定的抓包经验和javascript功底才行; 我的javascript功底也就勉勉强强够我写几个简单需求.

虽然本文看起来比较水, 但还是建议初学者照着本文去理解抓包思路, 以及脚本的执行逻辑.

最后, 提前祝大家周末愉快!

记录一次Surge & QuantumultX 脚本开发过程

https://nobyda.github.io/2021/07/16/BilibiliManga_Js_example/

作者

NobyDa

发布于

2021-07-16

更新于

2021-07-17

许可协议

评论