注册
web

我是如何将手动的日报完全自动化的☺️☺️☺️

书接上回,上回我们聊了处理重复任务的自动化思维。


其中,我举了用工具自动化公司日报的例子。


今天,我就来详细说说,我到底是怎么做的,以及过程中遇到了哪些问题和挑战。


背景


我们公司使用某第三方系统有一个自定义的数据看板,每天需要向群里发送日报。之前,这项工作由团队成员轮流手动完成:从系统的一个自定义看板复制数据到 Excel,再将表格转为图片,发到群里。


轮到我负责的那一周,我左手边电脑打开系统,右手边打开 Excel,一个个数据复制过去,3.4%、-10%……为避免出错,还要逐一核对。整个过程每天耗时大约 7 到 10 分钟,繁琐又枯燥。


我开始思考:这种重复性工作能不能自动化?


于是,我在群里向大佬们请教,提出了这个问题:


image.png


结果,消息已读,没有一个人回复。


那一刻,我暗下决心:我要自己解决这个问题!


初探


于是乎我打开了改系统,开始研究。


该系统大概长这样, 这是一个自定义看板,后台自定义配置出来的,数据是根据配置的规则算出来的,有十几项,我们是需要从每项取3个数据。加起来复制30-40次。


image.png



  • 手动复制效率低下。
  • 浪费时间。
  • 容易出错,粘错位置了,又得一个个重新对一遍。

所以我第一步是需要把手动复制拿数据的这个过程,利用脚本自动化了。


流程与任务拆解


我们的思路是这样,先脑子里过一下原来的流程,然后一步步自动化原来的流程。


1、原来手动的流程



  1. 手动登录系统
  2. 点击对应面板,一个个复制数据,粘贴到excel里。
  3. 全部复制完,核对完,右键复制为图片
  4. 发送到群里。

2、脚本任务拆解



  1. js逆向登录加密方法,自动化登录,拿到token。
  2. 利用爬虫抓取数据,拿到我需要的。
  3. 利用canvas将数据画成表格,然后转成图片。
  4. 图片传到oss,调用钉钉webhook接口,定时发送到群里

以上我们已经将,手动的流程的任务与自动化需要做的任务一一对应了。


现在我们思路清晰了。


然后我们要做的就是把每个任务逐个攻克即可。


任务分步实现


你不觉得我应该先完成第一个任务——JS 逆向登录加密方法,实现自动化登录并获取 token 吗?


这确实是全自动流程中最核心的一环:没有自动登录获取凭证,后续的数据抓取和操作根本无从谈起。


不过,我初步分析了登录接口,发现参数加密逻辑较复杂,短时间内难以破解。


于是我选择暂时跳过,先手动复制登录凭证,确保后续流程全部打通后再回过头补全自动化登录部分。


1、利用爬虫抓取数据。


首先看板这是个列表,有很多项内容,首先看这个列表怎么来的,服务端渲染还是,调的接口。


然后看能不能完全从页面拿到,我们再考虑抓取方式。


1、如果是服务端渲染的或者数据很快出来的。我们可以考虑抓页面。


2、但是今天这个例子,经过我的研究,我需要的数据,都是异步调接口的,我看还有队列排队逻辑。
说明页面完整展现的时间不稳定,长则几十秒都有可能, 所以我感觉抓页面是不稳定的。


所以我选择抓接口


1.1内容搜索大法


image.png
众多的接口啊,我们怎么找到我要的数据在哪???于是我们利用调试工具,搜索响应内容关键字


例如搜页面中显示的这个标题

image.png


通过内容再network搜索内容 找到了列表接口

image.png


点开看,确实,里边就是这个列表的数据。


但是没有具体是环比、同比,我要的数字。


再次寻找每一项具体数据的获取接口。

再次通过搜索大法找了好久好久.....


找到了通过每项id和过滤条件去获取具体数据的接口


1.2 接口找齐,开始编码


研究下来。整体逻辑是,先获取面板列表,然后循环列表的每一项,拿着有关联的参数去调详情。


面板的数据列表获取

/**
* 获取重点功能监控面板列表及详情数据
* @returns {Promise<*[]>}
*/

async function queryReportList(dashboard) {
const { id: dashboard_id, common_event_filter } = dashboard

const data = await fetch(
`https://xxx/api/v2/sa/dashboards/${dashboard_id}?is_visit_record=true`,
{
credentials: "include",
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:136.0) Gecko/20100101 Firefox/136.0",
Cookie: Cookie
},
referrer:
`https://xxx/dashboard/?dash_type=lego&id=${dashboard_id}&project=1&product=sensors_analysis`,
method: "GET",
mode: "cors"
}
)
.then(res => res.json())

const result = [];
// 获取控面板的前12个监控项的监控数据。
for (const item of data.items.slice(0, 13)) {
if (item.bookmark) {
// 这里解出来, 调下一个接口要用到。
const data = JSON.parse(item.bookmark.data);
const res = await queryReportByTool({
bookmarkid: item.bookmark.id,
measures: data.measures,
dashboard_id: dashboard_id,
common_event_filter: common_event_filter
});
result.push({
...res,
name: item.bookmark.name
});

console.log(
{
name: item.bookmark.name,
base_number: res.base_number /= 100,
day: res.month_on_month /= 100,
week: res.year_on_year /= 100
}
)
}
}

return result
}

获取每一项具体数据

/**
* 报告列表的报告id去获取具体数据
* @param params
* @returns {Promise<T|*|undefined>}
*/

async function queryReportByTool(params) {
const requestId = Date.now() + ":803371";
const body = {
measures: params.measures,
unit: "day",
by_fields: [],
sampling_factor: null,
from_date: dayjs()
.subtract(14, "day")
.format("YYYY-MM-DD"),
// from_date: "2025-02-28",
to_date: getYesterDay(),
// to_date: "2025-03-13",
detail_and_rollup: true,
enable_detail_follow_rollup_by_values_rank: true,
...
};
try {
const data = await fetch(
`https://xxxx/api/events/compare/report/?bookmarkId=${
params.bookmarkid
}
&async=true&timeout=10&request_id=${requestId}`
,
{
credentials: "include",
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:136.0) Gecko/20100101 Firefox/136.0",
...
Cookie
},
referrer:
"https://xxxx/dashboard/?dash_type=lego&id=692&project=1&product=sensors_analysis",
body: JSON.stringify(body),
method: "POST",
mode: "cors",
timeout: 10000
}
).then(res => res.json());
if (!data || data.isDone === false) {
return await queryReportByTool(params);
} else {
return data;
}
} catch (e) {
return await queryReportByTool(params);
}
}

1.3 数据拿到


执行一下,数据拿到了,找到了我要的几个字段


PS D:\project2\report> node .\index.js
{
name: 'xxx生成失败率',
base_number: 0.0103,
day: -0.3602,
week: -0.16260000000000002
}
...
{
name: 'xxxx生成失败率',
base_number: 0.017,
day: 0,
week: 0.0241
}
2025-03-18.xlsx文件已保存!
default: 27.917s

1.4 小结



表面看似一帆风顺,因为我是以回忆的视角,实则历经坎坷。目标网站的接口之间关系、参数间的关联,皆需细细揣摩、深入研究。



2、生成图片


node-cavas 生成图片。细节我就不讲了,数据都拿到了,用数据生成一张图片那就看你怎么解决了。


3、图片传到oss,调用钉钉webhook接口,定时发送到群里


传图


我是传到了腾讯云cos


const filePath = `/custom/999/${dashboard.worksheetName}-${dayjs().format('YYYYMMDD')}.jpeg`
const uploadRes = await tencentCos.upload(imageBuffer, filePath, true)

发钉钉群


查看钉钉群机器人api文档,以md格式发送图片链接。


async function sendDingTalkMessage(text) {
// const today = dayjs()
// .format("YYYY-MM-DD")
const token = '1a6e1111111' // 大群机器人
const result = await fetch(`https://oapi.dingtalk.com/robot/send?access_token=${token}`, {
method: 'post',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
"msgtype": "markdown",
"markdown": {
"title": "监控日报",
// "text": `#### ${title}-${today} \n > ![screenshot](${imageUrl}) \n`
"text": text
},
"at": {
"isAtAll": true
}
})
}).then(res => res.json())
console.log(result)
if (result.errcode === 0) {
console.log('发送成功')
return true
}
}

到这里可以当做一个脚本每天手动执行一下。 但是还没完全自动化,还差一步


4、js逆向登录加密方法,自动化登录。


就剩下自动登录了。


4.1 为什么要自动化登录?


因为这个系统登录凭证在一定时间内会过期,且不是明文登录的,登录接口参数加密了的。


image.png
看到这你就得去研究他的加密规则了


或者止步于此,手动复制登录凭证,本地执行脚本也是可以。


我如果要在服务器上自动化整个流程,必须得让他自动登录拿到登录凭证。


4.2 逆向步骤


4.2.1找到登录接口

先点页面的登录,找到登录接口,在请求调用栈中随便找个位置先打个断点,然后刷新页面,再次点击登录,嘿,您猜怎么着,断住了!!!


image.png


4.2.2 顺着调用栈找逻辑

顺着调栈给上找逻辑。所有在前端加密的一定是可以模拟的。


找到了调登录的方法。
看了下这就是调store里的方法passport/login。
image.png


从这再往下就比较不容易了,因为你会发现,就有点乱了。进到的都是混淆的一些abcdefg名字的方法。


image.png


但是咱们明确目标就是要找到调用的方法 passport/login的位置。


我尝试了如下



  • 搜索passport/login关键字
  • 搜索接口路径

找了一辈子, 终于找到了调接口的地方


看到这个Me方法,传递了一个 isEncrypted 我猜测就是 是否要加密参数的意思吧。
image.png


别搁Me方法外面蹭了行不行???,赶紧进去看看。


你就给我看这个?
这里面又调了另一个


image.png


咱们接着进到xt.request。


您猜怎么着,还没到,这里又进行了一顿操作之后,调了一个名为P的方法。


image.png


好好好,继续继续。


4.2.3 找到了加密的位置

到了P方法,终于是没给我玩套娃了啊。


在这一步终于是看到了关键字 isEncrypted


看了代码确实是判断isEncrypted加密的。


image.png


看了后发现这是一个RSA+AES结合的加密方式


RAS加密密钥, AES加密登录数据。

加密流程总结:



  1. RSA保护AES密钥的安全传输
  2. AES保护实际登录数据的机密性
  3. 双重加密确保登录信息在传输过程中的安全性

为何不用单一的加密方式?

那么你有没有这样的疑问呢?为什么不单独用rsa直接加密数据呢?岂不简单。


当然不行,是有原因的!


RSA长度限制
RSA加密算法对明文长度有严格限制,具体取决于密钥长度和填充方式‌。以下是不同密钥长度下的最大明文长度(以字节为单位):



  • 1024位密钥‌:最大明文长度约为 ‌117字节‌‌
  • 2048位密钥‌:最大明文长度约为 ‌245字节‌‌
  • 4096位密钥‌:最大明文长度约为 ‌512字节‌‌

所以RSA加密超出长度的会报错的。


所以先生成短密钥,再使用RSA加密AES对称算法的密钥,再用对称密钥加密实际数据‌。这是实际应用中的常见做法,兼顾安全性和效率‌


4.2.4 模拟他的加密过程

大致流程


  1. 把加密逻辑copy啊。
  2. 补环境。
  3. 不断尝试直到通过后端校验。

理解后端如何解密和校验

前面我们说到


RAS加密密钥, AES加密登录数据。


那么后端的校验流程就是:



  1. 私钥解出密钥
  2. 密钥配和iv、salt等再解出被AES加密的账号密码信息.

知道了这些,那么我们需要做的就是正确加密和传递相关信息,如果校验失败,我们就要来回对比差异,找到问题,不断尝试。


在不断尝试下我成功了。


遇到的问题


  • 加密的包的版本跟目标网站用的不一样导致校验失败,后经过漫长的查找找到了一样的版本。
  • 还要注意header里带的字段,都要模拟他加密后的带过去。例如这几个。

    • salt
    • iv等



image.png


最终我抽出来的登录加密方法

var b = require("crypto-js");
var jsencrypt = require("nodejs-jsencrypt/bin/jsencrypt").default;

/**
* js逆向回来的方法,模拟xx登录对参数加密
* @param body xx登录参数
* @param public 公钥
* @returns {{headers: {"aes-salt": string, "aes-iv": string, "aes-passphrase": *, "X-Request-Timestamp": string, "X-Request-Id": string, "X-Request-Sign": *}, body: string}}
*/

function encryptLogin(body, public) {
const W = new jsencrypt();
W.setPublicKey(public)

q = b.enc.Utf8.parse(Math.floor(Math.random() * 1e6) + Date.now()).toString();
// q = "31373432353237383135363835";
var re = W.encrypt(q)
, ie = b.lib.WordArray.random(128 / 8)
, fe = b.lib.WordArray.random(128 / 8)
, ue = b.PBKDF2(q, ie, {
keySize: 128 / 32,
iterations: 100
})
, ye = b.AES.encrypt(JSON.stringify(body), ue, {
iv: fe,
mode: b.mode.CBC,
padding: b.pad.Pkcs7
});

const j = "/api/v2/auth/login?is_global=true"
const Ee = parseInt(Date.now() / 1000).toString()
const he = Ee
const Fe = ye.toString()

var bt = "".concat(Ee, "_").concat(he, "_").concat(j, "_").concat(Fe, "_14skjh");

const res = {
headers: {
"aes-salt": ie.toString(),
"aes-iv": fe.toString(),
"aes-passphrase": re,
"X-Request-Timestamp": Ee,
"X-Request-Sign": b.MD5(bt).toString(),
"X-Request-Id": he,
},
body: ye.toString()
}

return res
}

使用登录方法

登录之后存下来cookie供获取数据的接口使用


async function login(public, loginData) {
// 加密登录信息
const encryptOptions = encryptLogin({
...loginData
}, public)

return await fetch("xxx", {
"headers": {
...encryptOptions.headers
},
"method": "POST",
credentials: "include",
body: encryptOptions.body
}).then(res => {
Cookie = res.headers.get('set-cookie')
return res.json()
})
}

任务分步都实现了(自动化了)。


串联起来这四步,整体就实现了。


随后部署到服务器,配置定时任务每天执行。


效果展示


image.png


总结



  • 先通后补:登录逆向卡壳,先手动Cookie跑通全链,再回填自动化。
  • 逆向不怕乱:混淆代码里断点+全局搜索(接口路径/关键字),总能定位加密点。
  • 加密常RSA+AES:RSA只加密短密钥,AES加密长数据,补环境+对齐Header字段是关键。
  • 贵在坚持:第一天研究无果别灰心,第二天重新上手,灵感与进展常不期而至。

虽然文章写得像一帆风顺,但实则磕磕绊绊——在层层混淆的代码里翻找,第一天方法不对,左冲右突脑壳嗡嗡作响。幸好第二天没放弃,沉下心继续深挖,一步步试错、迭代,终于攻克所有难题。


如果有小伙伴有任何问题或者想跟我探讨细节,欢迎联系!


喜欢的话,点点关注。


作者:浏览器API调用工程师_Taylor
来源:juejin.cn/post/7566913899175051299

0 个评论

要回复文章请先登录注册