使用nodejs编写自动化脚本,真香!

说到写脚本,最为人熟知的语言必然是shell,再者python,当然现在也出现了很多界面友好,支持可视化拖动编写脚本的软件,如quiker等。但本文要介绍的是nodejs,其用到的语言是JavaScript,本人最近正在学习。nodejs支持通过命令的方式执行JavaScript脚本文件,脱离了浏览器环境,使得编写脚本成为可能。JavaScript语言生态相当丰富,社区活跃,当需要实现某个创意时丰富的生态可助力快速落地。

自动化脚本

一般写脚本把繁琐重复的事情一键完成,配合一定的运行机制,如定时任务调起脚本,使其自动运行,大大减轻工作负担。在工作中可能会写自动化部署项目的脚本,定时监控系统运行的脚本,定时清理文件的脚本等,但是如果个人呢?很多人每天都忙碌于各种app的签到,完成app的日常任务,查看视讯动态等,这些工作要是也能自动化运行且主动通知就好了,仿佛996的生活也出现了一丝惬意。

搭建自动化任务脚手架

要实现各种app的签到、完成日常任务及消息通知等功能最核心的是编写http客户端,http是目前最最广泛的应用协议,http客户端在nodejs的世界里实在太多了,本人选择的是axios,其被广泛使用。

本人参考了一些开源的自动化任务项目,且实践编写,总结了几个自动化任务脚手架的要点:

  • http客户端,用于发送和接收请求
  • 分任务维护Env对象,存放任务个性化参数
  • 分任务设计api文件,便于维护api信息
  • 任务脚本编写模式需高度统一,便于新增和维护
  • 配置文件形式维护任务参数

http客户端

这里用到的是axios框架,是很火的nodejs生态的http客户端工具,一般根据业务封装一个http客户端对象:

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
// 封装axios
import axios from "axios";

axios.defaults.headers["Content-Type"] = 'application/json;charset=utf-8'
const service = axios.create({
baseURL: '',
timeout: 30000
})

service.interceptors.request.use(config => {
// 如果是get请求将config的params域拼接至url中
if (config.method === 'get') {
let url = config.url + '?';
if (config.params && typeof config.params !== "undefined") {
for (let key of Object.keys(config.params)) {
let part = encodeURIComponent(key) + '=';
if (config.params[key]) {
part += encodeURIComponent(config.params[key]) + '&';
url += part;
}
}
}
config.url = url.slice(0, -1);
config.params = {};
}
return config;
}, error => {
console.log(error)
Promise.reject(error)
})

export default service

针对不同任务的app应维护不同的http客户端对象,因为有些app的响应报文是加密的,其解密过程可直接封装在axios对象的响应拦截器中。至于,如何解密,就要自己摸索了。

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
// 封装对需要对响应报文解密的http客户端
import axios from "axios";
import CryptoJS from "crypto-js";

function aesDecrypt(data, aesKey = 'xxxxxxxxxxx') { //解密
if (data.length < 1) {
return '';
}
let key = CryptoJS.enc.Utf8.parse(aesKey);
let decrypt = CryptoJS.AES.decrypt(data, key, {mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7});
let decryptedStr = decrypt.toString(CryptoJS.enc.Utf8);
return decryptedStr;
}


axios.defaults.headers["Content-Type"] = 'application/json;charset=utf-8'
const service = axios.create({
baseURL: '',
timeout: 30000
})

service.interceptors.request.use(config => {
// 如果是get请求将config的params域拼接至url中
if (config.method === 'get') {
let url = config.url + '?';
if (config.params && typeof config.params !== "undefined") {
for (let key of Object.keys(config.params)) {
let part = encodeURIComponent(key) + '=';
if (config.params[key]) {
part += encodeURIComponent(config.params[key]) + '&';
url += part;
}
}
}
config.url = url.slice(0, -1);
config.params = {};
}
return config;
}, error => {
console.log(error)
Promise.reject(error)
})


service.interceptors.response.use(resp => {
if (resp.data.code === 1) {
var serializer_data = aesDecrypt(resp.data.data);
resp.data.data = JSON.parse(serializer_data);
}
return resp.data;
}, error => {
console.log(error)
Promise.reject(error)
})

export default service

Env对象

设计了Env对象的概念,每个任务都有自己的Env对象,用于方便存放全局对象,全局方法,任务个性化参数等等

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
// Env对象的基类,封装共有方法

import {SEPARATOR_LINE} from "./../constants.js";
import {notifyAll} from "./../notifyUtils.js";
import {now} from "./../common.js";

export default class BaseEnv {
constructor(name) {
this.name = name;
this.cookie = '';
this.detailMsg = [];
this.errMsg = [];
}


addDetailMsg(msg) {
this.detailMsg.push(msg);
}

addErrMsg(msg) {
this.errMsg.push(msg);
}


async init() {
console.log('需子类重写');
}

getUserInfo() {
return '需子类重写\n';
}

async send() {
let content = `【当前时间】:${now()}\n`;
content += this.getUserInfo();
content += `【明细】:\n`;
if (this.detailMsg.length > 0) {
content += `${this.detailMsg.join('\n')} \n`
} else {
content += `无明细内容\n`;
}
content += `${SEPARATOR_LINE}【异常】:\n`;
if ((this.errMsg.length > 0)) {
content += `${this.errMsg.join('\n')}`;
} else {
content += `无异常\n`;
}
await notifyAll(this.name, content);
}
}

针对B站的任务封装Env对象

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
import config from "../../config.js";
import {getAccountInfo} from "../../api/bilibili.js";
import BaseEnv from "./BaseEnv.js";

export default class BILIBILIEnv extends BaseEnv {
constructor(name) {
super(name);
this.name = name;
this.mid = '';
// 我的昵称
this.uname = '';
// 我的会员等级
this.rank = '';
this.likeUpVideo = false;
this.custCoin = false;
this.maxCustCoinNum = 0;
}


async init() {
this.cookie = config.bilibili.cookie;
this.likeUpVideo = config.bilibili.likeUpVideo;
this.custCoin = config.bilibili.custCoin;
this.maxCustCoinNum = config.bilibili.maxCustCoinNum;
let {data} = await getAccountInfo(this.cookie);
console.log('获取到的数据是:', data);
if (data.data && data.data.uname) {
this.uname = data.data.uname;
this.mid = data.data.mid;
this.rank = data.data.rank;
} else {
throw new Error('cookie已过期!!');
}

}

getUserInfo() {
return `【当前用户】:${this.uname}\n【当前等级】:${this.rank}\n`;
}

}

编写任务的api文件

不同任务的api自然是不同的,无论是url还是各种参数,所以应针对不同任务单独维护:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// bilibili相关的api写在此文件下
import service from "../utils/httpclient/request.js";
import USER_AGENT from '../utils/USER_AGENTS.js'

/**
* bilibili个人用户信息
*/
export function getAccountInfo(cookie) {
const options = {
url: "http://api.bilibili.com/x/member/web/account",
headers: {
Accept: "*/*",
Connection: "keep-alive",
Cookie: cookie,
"User-Agent": USER_AGENT,
"Accept-Language": "zh-cn",
}
};
return service(options);
}

各项api参数如何获取就需要大家自己摸索了,有些api是官方公开的,GitHub上也有大佬分享些自己摸索出来的api,当然自己也可以使用浏览器开发工具抓包获取需要的api,下面举个例子:

获取B站的今日用户经验

  • 登录个人中心-我的记录-经验记录

  • 打开浏览器开发者工具-network,清空,选中Fetch/XHR

  • 刷新页面后观察抓包界面获取请求信息

  • 根据抓包的信息编写api文件,一般重点的是url请求方式、请求头关键带上cookie或者token之类的信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 查询经验值的变动记录
* @param cookie
* @returns {AxiosPromise}
*/
export function queryExpLog(cookie) {
const options = {
url: " https://api.bilibili.com/x/member/web/exp/log?jsonp=jsonp",
headers: {
Accept: "*/*",
Connection: "keep-alive",
Cookie: cookie,
"User-Agent": USER_AGENT,
"Accept-Language": "zh-cn",
}
};
return service(options);
}

编写task脚本

按照任务前任务执行任务后任务异常的模式来编写

1
2
3
4
5
6
7
8
9
let $ = new BILIBILIEnv('bilibili日常任务');
(async () => {
await before();
await execute();
await after();
})().catch(reason => {
$.addErrMsg(reason.stack);
$.send();
});

支持配置文件控制任务参数

配置文件统一管理一些任务的参数,如各种api必备的cookietoken等信息,也支持任务的启用和关闭

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
export default {
"jd": {
"cookie": "pt_pin=xxxxxx;pt_key=xxxxxxx;"
},
"bilibili": {
"cookie": "SESSDATA=xxxxxxx;DedeUserID=xxxxxxx;",
"likeUpVideo": true,
"custCoin": true,
"maxCustCoinNum": 2
},
"fastcat": {
"token": "xxxxxxx"
},
"notify": {
"qywx": {
"open": true,
"token": {
"corpid": "xxxxxxx",
"corpsecret": "xxxxxxx",
"touser": "xxxxxx",
"agentid": "xxxxxxx",
"mediaId": "xxxxxxx",
"sage": "0"
},

}
}
}

技术要点总结

使用nodejs写脚本的过程让我更了解这门技术,感受到其既强大又便利,感受到与Java这类编译型语言不同的编程体验。

上述自动化脚本无非是发送http请求和接受http请求,最最核心的技术是nodejs的异步编程机制,大量使用到es6中的Promise类型。在脚本中最常见的一种场景是:

发送http请求,等待响应结果,如果正常执行下一个http请求,如果异常直接抛异常结束任务。

怎么通过Promise实现呢?下面通过代码来说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 函数返回的是Promise对象
export function getDailyRewardInfo(cookie) {
const options = {
url: "http://api.bilibili.com/x/member/web/exp/reward",
headers: {
Accept: "*/*",
Connection: "keep-alive",
Cookie: cookie,
"User-Agent": USER_AGENT,
"Accept-Language": "zh-cn",
}
};
return service(options);
}
1
2
3
4
5
6
7
8
9
// 异步函数体内使用await参数,await参数后必须接Promise类型
// 其等待Promise落定后才向后执行
async function execute() {
let resp = await getDailyRewardInfo($.cookie);
if (resp.data.code) {
throw new Error('cookie已失效!');
}

}
1
2
3
4
5
6
7
// 即时执行的异步函数调起异步函数,用到了await参数,因异步函数的返回结果必是Promise类型
(async () => {
await execute();
})().catch(reason => {
$.addErrMsg(reason.stack);
$.send();
});

最后

上述自动化脚本已上传至github仓库:automate-scripts

实现的功能有:

  • 哔哩哔哩

    • 一键完成日常任务,包括登录,签到,分享,可获得20经验
    • 点赞关注的up主最新视频:支持参数配置是否开启此功能
    • 投币关注的up主最新视频:支持参数配置是否开启此功能,以及一天最多的投币数量
    • 数据汇总:汇总今日经验值等数据
  • 京东

    • 京东商城的京豆签到
  • 通知

    • 目前仅实现企业微信的通知渠道,本人最常用且仅使用此通知渠道,后续考虑添加更多渠道

欢迎大家提出疑问,如果文中存在的不对的地方,望大家评论告知,希望大家的技术能越来越好,共同进步!!!