简介
本文主要记录一次从巴哈姆特自动签到脚本(油猴)适配至可供Surge、QuantumultX运行的开发过程。
背景
其实这个脚本很久之前就想适配Surge了,之前从Greasyfork搜到这个油猴签到脚本,但是该脚本只能在PC中打开巴哈姆特网页时才能被运行,如果不是每天都逛的话,很容易忘记签到,所以决定适配一下Surge。
当时脚本写了一半,前期测试时发现用户鉴权的Cookie有效期只有1天,这对于大多数自动化签到脚本来说都是致命的,意味着需要频繁去抓Cookie造成毫无使用体验。发现这一情况后并没有深入去研究,而且那段时间也比较忙,所以这个脚本一直处于停滞的状态。
最近在整理脚本时偶然发现这个未完成的脚本,想着可不能半途而废,于是决定深入研究一下巴哈姆特的鉴权方式,顺便分享一下整个过程。
过程
签到脚本最重要的就是用户鉴权方式,一般来说大多数可以被写成脚本的签到都是请求头带上Cookie字段完成用户鉴权的过程,但该方法弊端很明显,例如有效期过短造成需要频繁的去抓取新Cookie。
而且除了上述情况之外,也不是所有APP/网页签到都可以写成脚本,例如相关请求被加密或者需要算法二次验证;这类情况也很难被写成自动化脚本。
幸运的是本文所描述的巴哈姆特签到并没有被加密,待解决的主要问题是每次签到开始时如何拿到最新的Cookie以避免签到过程中鉴权失败。
鉴权分析
因为抓取的请求国内无法直连,使用Thor抓包的话需要路由级翻墙,多数用户并没有这个条件。本文为了方便读者实践,将使用Surge进行抓包,抓到以后导入Thor分析(因为有完善的关键字过滤且查看方便)最后使用Anubis进行重放演示。
既然我们无法使用已经生成好的Cookie,那么我们可以尝试抓取生成Cookie的接口,让脚本每一次运行时都去查询新Cookie,自然就解决了有效期问题。
抓包过程:
- 进入巴哈姆特动画疯APP后,退出登录
- 打开Surge,MITM主机名输入*:0表示解密所有域名以及所有端口,并开启抓取流量
- 进入巴哈姆特动画疯APP,输入账号密码登录
- 返回Surge,关闭抓取流量,并禁用*:0解密
抓到请求可以在Surge - 工具 - 已保存的截取数据中找到。
导入Thor:
我们导入Thor后,看到请求并不是很多,所以可以简单搜索一下请求URL关键字,例如我们想抓取登录请求,则尝试搜索login
搜索后有两条符合的请求,可能有的小伙伴就要问了,你怎么知道搜索这个关键字呢?
答案是经验,抓的包多了,很自然就会知道相关请求的关键字,比如现在抓取的是登录请求,login即为登录的意思。如果关键字搜索不到,则只能凭经验逐条分析,没有什么特别好的办法。
回到正题,我们把搜索到的请求进一步查看请求体以及响应体。
吐槽:写这APP的程序员不行阿,怎么说用户密码也得加密一下。
登录请求体中,uid=为用户账号,passwd=为用户密码,以及各种验证码均以明文显示,响应体则是用户基本信息。
我们的需求是获取新Cookie,然后再看一下这个请求的响应头是否返回了Set-Cookie字段
可以看到响应头是有返回Set-Cookie的,但缺少了关键值,猜测可能是请求头中的Cookie已包含所以不返回。
我们右滑该请求,使用Anubis重放,把请求头中Cookie字段里的一些信息删掉,看看是否会返回完整的Set-Cookie
可以看到,删除请求头Cookie中的部分字段进行重放后,响应头的Set-Cookie返回了关键字段,该字段可用于签到的用户鉴权。
然后再进行重放,精简请求头、请求体的参数,看看哪些是必须的。
经过多次重放调试,可以得知:
- 请求头中Cookie里的ckBahamutCsrfToken字段需要与请求体中的bahamutCsrfToken字段对应
- 请求头中Cookie里的ckAPP_VCODE字段需要与请求体中的vcode字段对应
- 除账号密码和以上两个参数不可精简以外,其他都可删除
进一步重放后发现请求体中的vcode字段可以自定义,而bahamutCsrfToken字段不行,
vcode字段我们暂且不管,bahamutCsrfToken字段的话,由于该脚本针对大众,如果多个用户使用同一个内容,可能会出现未知问题,所以得想个办法精简掉(虽然不精简也可以)
经过多次尝试无果后,灵光一现,想到了去Github搜索一下这个字段的关键字:
不得不说Github真是大佬聚集地,点进这个项目后可以看到是一个基于python的巴哈姆特签到项目(瞬间感觉上面抓包抓了个寂寞)
我们进一步查看源码后发现,登录拿Cookie的请求逻辑跟我调试时基本一致,但有几点不同
在他的源码中可以看到请求体并没有携带bahamutCsrfToken等参数,并且发现请求URL用的是v3接口,而我抓的是v4
瞬间豁然开朗,v3接口可以省略掉一些参数,经过重放调试后也可以正常返回Set-Cookie。
至此已经解决登录Cookie问题。
适配脚本
脚本原型为适用于PC浏览器的巴哈姆特签到脚本 (油猴),项目地址:bahamut-auto-sign-script;
该脚本业务逻辑包括巴哈签到、巴哈公会签到、动画疯答题等。
得益于该项目,省去了自行抓包分析签到等各种过程,在此表示感谢。
应用兼容
脚本将适配多平台,脚本的写法需要同时兼容多个客户端,那么我们就需要一个兼容函数让它在不同环境下也能被正确执行.
本文将使用chavy大佬的兼容函数。项目地址:https://github.com/chavyleung/scripts/blob/master/Env.js
该兼容函数兼容大多数Surge、QuantumultX、Loon、Shadowrocket的内部API。
全局变量
写好兼容函数后我们先定义一些全局变量,供所有函数调用.
全局变量1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
const $ = new Env('巴哈姆特');
$.uid = $.getdata('@ND_BAHA.ID') || 'YourUserName';
$.pwd = $.getdata('@ND_BAHA.PW') || 'YourUserPassword';
$.needSignGuild = $.getdata('@ND_BAHA.GUILD') || true;
$.needAnswer = $.getdata('@ND_BAHA.ANSWER') || true;
$.notifyMsg = [];
|
登录函数
根据前面的分析,我们只需写一个登录请求即可拿到响应头中的Set-Cookie,请求成功后,后续客户端会自动设置其他同类域名请求的Cookie头字段,无需手动设置。如果登录失败,则终止执行脚本。
具体实现:
登录函数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
| function BahamutLogin() {
return $.http.post({ url: 'https://api.gamer.com.tw/mobile_app/user/v3/do_login.php', headers: { 'Cookie': 'ckAPP_VCODE=6666' }, body: `uid=${encodeURIComponent($.uid)}&passwd=${encodeURIComponent($.pwd)}&vcode=6666` }) .then((resp) => { const body = JSON.parse(resp.body); if (body.userid) { $.log('', `✅巴哈姆特登录成功`); } else { const failMsg = body.error ? body.error.message : null; throw new Error(`❌登录失败\n❌${body.message||failMsg||'未知'}`); } }) }
|
签到函数(巴哈)
原油猴脚本的业务逻辑为先查询签到Token,再把拿到的Token去请求签到。
造好的轮子适配起来就是舒服,省了抓包;我们直接按照该逻辑去适配。
具体实现:
巴哈姆特签到函数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
| function BahamutSign() { return $.http.get({ url: 'https://www.gamer.com.tw/ajax/get_csrf_token.php', headers: {} }).then(async (resp) => { if (resp.body) { $.log('', '✅获取签到令牌成功'); const sign = await StartSignBahamut(resp.body); $.notifyMsg.push(`主页签到: 成功, 已连续签到${sign}天`); } else { throw new Error('获取签到令牌失败'); } }) .catch(err => { $.notifyMsg.push(`主页签到: ${err.message||err}`); $.log('', `❌巴哈姆特签到失败`, `❌${err.message||err}`); }); }
function StartSignBahamut(token) {
return $.http.post({ url: 'https://www.gamer.com.tw/ajax/signin.php', headers: {}, body: `action=1&token=${token}` }) .then(res => { const body = JSON.parse(res.body); if (body.data) { $.log('', '✅巴哈姆特签到成功', `✅已连续签到${body.data.days}天`); return body.data.days; } else { const failMsg = body.error ? body.error.message : null; throw new Error(failMsg || body.message || '未知'); } }); }
|
第一个函数被调用后,查询签到Token成功后则调用第二个函数;如果这俩任何一环出现错误,则直接跳过该签到,继续运行其他任务。
签到函数(公会)
原油猴脚本的业务逻辑为先查询已加入公会的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 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
| function BahamutGuildSign() { if ($.needSignGuild === false || $.needSignGuild === 'false') { return; } return $.http.get({ url: 'https://api.gamer.com.tw/ajax/common/topBar.php?type=forum', headers: {} }) .then(async (resp) => { const list = (resp.body.replace(/\n/g, '').match(/guild\.php\?g?sn=\d.+?<\/p>/g) || []) .map(n => { return { sn: n.split(/guild\.php\?g?sn=(\d+)/)[1], name: n.split(/<p>(.+?)<\/p>/)[1] } }); if (list.length) { $.log('', `✅获取公会列表成功`); const sign = await Promise.all(list.map(StartSignGuild)); const sucs = sign.filter(n => n === 1).length; const fail = sign.filter(n => n === 0).length; $.notifyMsg.push(`公会签到: ${sucs?`成功${sucs}个`:``}${sucs&&fail?`, `:``}${fail?`失败${fail}个`:``}`); } else { throw new Error('公会列表为空'); } }) .catch(err => { $.notifyMsg.push(`公会签到: ${err.message || err}`); $.log('', `❌巴哈姆特公会签到失败`, `❌${err.message || err}`); }); }
function StartSignGuild(v) {
return $.http.post({ url: 'https://guild.gamer.com.tw/ajax/guildSign.php', headers: {}, body: `sn=${v.sn}` }) .then((res) => { const body = JSON.parse(res.body); $.log('', `🔷<${v.name}>`, `${body.ok?`✅`:`❌`}${body.msg}`); if (body.ok) { return 1; } else { return 0; } }) .catch(e => { $.log('', `🔷<${v.name}>`, `❌签到失败: ${e.message||e}`); return 0; }); }
|
第一个函数被调用后,首先判断用户是否开启公会签到,如果开启则先查询已加入公会的ID,查询成功后再调用第二个函数并发签到。
如果用户选择关闭公会签到或者查询公会ID失败,则直接退出该签到,继续运行其他任务。
答题函数(动画疯)
原油猴脚本的答题逻辑稍微有点复杂,简单概括就是默认手动答题 (浏览器),如果开启自动答题,则先查询题目,查询成功后:
- 从blackxblue的小屋获取今日答案的文章ID
- 如果找到文章ID,则查询该文章内容找到答案
- 如果以上获取失败,则调用moontai0724的题库寻找答案(耗时过长)
所以我们适配时将直接从blackxblue的小屋获取答案,获取失败则跳过该任务
具体实现:
动画疯答题函数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 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94
| function BahamutAnswer() {
if ($.needAnswer === false || $.needAnswer === 'false') { return; } return $.http.get({ url: 'https://ani.gamer.com.tw/ajax/animeGetQuestion.php?t=' + Date.now(), headers: {} }) .then(async (res) => { const r = JSON.parse(res.body); if (r.token) { $.log('', `✅获取动画疯题目成功`, ``, `🔶<${r.game}> ${r.question}`, `1️⃣${r.a1}`, `2️⃣${r.a2}`, `3️⃣${r.a3}`, `4️⃣${r.a4}`); const article = await GetAanswerArticles(); const getAnswer = await StartSearchAnswers(article); const sendAnswer = await StartBahamutAnswer(getAnswer, r.token); $.notifyMsg.push(`动画答题: ${sendAnswer}`); } else { throw new Error(r.msg || `获取题目失败`); } }) .catch(e => { $.notifyMsg.push(`动画答题: ${e.message||e||`失败`}`); $.log('', `❌动画疯答题失败`, `❌${e.message||e}`); }); }
function GetAanswerArticles() { $.log('', `🔶开始获取文章`); return $.http.get({ url: 'https://api.gamer.com.tw/mobile_app/bahamut/v1/home.php?owner=blackXblue&page=1', headers: {} }) .then((res) => { const body = JSON.parse(res.body); const tDate = $.time('MM/dd'); const title = (body.creation || []).filter(t => t.title.includes(tDate)); if (title.length && title[0].sn) { $.log('', `✅获取文章成功 (${title[0].sn})`); return title[0].sn; } else { throw new Error('今日答案未发表'); } }) }
function StartSearchAnswers(id) { $.log('', `🔶开始获取答案`); return $.http.get({ url: 'https://api.gamer.com.tw/mobile_app/bahamut/v1/home_creation_detail.php?sn=' + id, headers: {} }) .then((res) => { const body = JSON.parse(res.body); const answers = body.content.split(/A:(\d)/)[1]; if (answers) { $.log('', `✅获取答案成功 (${answers})`); return answers; } else { throw new Error('提取答案失败'); } }) }
function StartBahamutAnswer(answer, token) {
$.log('', `🔶开始答题`); return $.http.post({ url: 'https://ani.gamer.com.tw/ajax/animeAnsQuestion.php', headers: {}, body: `token=${token}&ans=${answer}&t=${Date.now()}`, }) .then((res) => { const body = JSON.parse(res.body); if (body.ok) { $.log('', `✅${body.gift}`); return body.gift; } else { const failMsg = body.error ? body.error.message : null; throw new Error(body.msg || failMsg || '未知'); } }) }
|
共有四个函数,第一个函数被调用后首先查询题目,如果查询成功:
- 调用第二个函数从blackxblue的小屋查询今日答案的文章ID
- 调用第三个函数查询文章内的答案
- 调用第四个函数则进行答题
如果以上任一环节查询失败,则不会再继续执行其余函数,跳过动画疯答题。
统一调用
写好函数后,如果不进行调用,那么代码是无法被运行的.
在javascript中,代码的执行顺序很重要,我们的需求是先完成一个任务后再进行下一个任务,最后退出脚本.
以下匿名函数将按我们预期的顺序执行.
匿名异步函数1 2 3 4 5 6 7 8 9 10 11 12 13
| (async function() { await BahamutLogin(); await BahamutGuildSign(); await BahamutSign(); await BahamutAnswer(); })().catch((e) => $.notifyMsg.push(e.message || e)) .finally(() => { $.msg(`巴哈姆特`, ``, $.notifyMsg.join('\n'), { 'open-url': 'crazyanime://', 'media-url': 'https://cdn.jsdelivr.net/gh/NobyDa/mini@master/Color/bahamutClear.png' }); $.done(); });
|
至此脚本适配已完成。
配置任务
我们写好脚本后,就可以在Surge或QuantumultX里配置定时任务让它在规定时间内执行该脚本.
以下将用Surge进行演示
编辑Surge配置文件,在[Script]段落放入以下脚本
1
| 巴哈姆特签到 = type=cron,cronexp="0 8 * * *",wake-system=1,timeout=30,script-path=https://raw.githubusercontent.com/NobyDa/Script/master/Bahamut/BahamutDailyBonus.js
|
以上配置将在每天的早上8:00执行。
有几点需要注意:
- 需要进入BoxJs填写账号密码,或者手动填入脚本(全局变量)
- 脚本不建议在凌晨执行(需要获取blackxblue发布的动画疯答案)
- 脚本兼容Surge、QuantumultX、Loon、Shadowrocket 以及 Node.js (需安装got、tough-cookie模块)
后记
虽然油猴版巴哈姆特签到并非鄙人原创,但二次适配起来并不亚于重构整个脚本,脚本的逻辑也需要重新设计。
教学类型文章不可避免会有一些专业性,抓包部分对于初学者来说并不难,脚本部分也包含大量的注释供读者理解执行逻辑。
俗话说:授人以鱼不如授人以渔,授之以渔可解一生之需;
祝大家学成归来。