使用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客户端对象:

// 封装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对象的响应拦截器中。至于,如何解密,就要自己摸索了。

// 封装对需要对响应报文解密的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对象,用于方便存放全局对象,全局方法,任务个性化参数等等

// 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对象

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还是各种参数,所以应针对不同任务单独维护:

// 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站的今日用户经验

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

node.js自动化 nodejs自动化打包脚本是什么_node.js自动化

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

node.js自动化 nodejs自动化打包脚本是什么_node.js自动化_02

  • 刷新页面后观察抓包界面获取请求信息
  • 根据抓包的信息编写api文件,一般重点的是url请求方式、请求头关键带上cookie或者token之类的信息
/**
 * 查询经验值的变动记录
 * @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脚本

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

let $ = new BILIBILIEnv('bilibili日常任务');
(async () => {
    await before();
    await execute();
    await after();
})().catch(reason => {
    $.addErrMsg(reason.stack);
    $.send();
});

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

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

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实现呢?下面通过代码来说明:

// 函数返回的是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);
}
// 异步函数体内使用await参数,await参数后必须接Promise类型
// 其等待Promise落定后才向后执行
async function execute() {
    let resp = await getDailyRewardInfo($.cookie);
    if (resp.data.code) {
        throw new Error('cookie已失效!');
    }
    
}
// 即时执行的异步函数调起异步函数,用到了await参数,因异步函数的返回结果必是Promise类型
(async () => {
    await execute();
})().catch(reason => {
    $.addErrMsg(reason.stack);
    $.send();
});

最后

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

实现的功能有:

  • 哔哩哔哩
  • 一键完成日常任务,包括登录,签到,分享,可获得20经验
  • 点赞关注的up主最新视频:支持参数配置是否开启此功能
  • 投币关注的up主最新视频:支持参数配置是否开启此功能,以及一天最多的投币数量
  • 数据汇总:汇总今日经验值等数据
  • 京东
  • 京东商城的京豆签到
  • 通知
  • 目前仅实现企业微信的通知渠道,本人最常用且仅使用此通知渠道,后续考虑添加更多渠道

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