書接上回,上回我們聊了處理重複任務的自動化思維。
其中,我舉了用工具自動化公司日報的例子。
今天,我就來詳細說說,我到底是怎麼做的,以及過程中遇到了哪些問題和挑戰。
我們公司使用某第三方系統有一個自訂的數據看板,每天需要向群裡發送日報。之前,這項工作由團隊成員輪流手動完成:從系統的自訂看板複製數據到 Excel,再將表格轉為圖片,發到群裡。
輪到我負責的那一週,我左手邊電腦打開系統,右手邊打開 Excel,一個個數據複製過去,3.4%、-10%……為避免出錯,還要逐一核對。整個過程每天耗時大約 7 到 10 分鐘,繁瑣又枯燥。
我開始思考:這種重複性工作能不能自動化?
於是,我在群裡向大佬們請教,提出了這個問題:

結果,消息已讀,沒有一個人回覆。
那一刻,我暗下決心:我要自己解決這個問題!
於是乎我打開了該系統,開始研究。
該系統大概長這樣,這是一個自訂看板,後台自訂配置出來的,數據是根據配置的規則算出來的,有十幾個項目,我們是需要從每項取 3 個數據。加起來複製 30-40 次。

所以我第一步是需要把手動複製拿數據的這個過程,利用腳本自動化了。
我們的思路是這樣,先腦子裡過一下原來的流程,然後一步步自動化原來的流程。
以上我們已經將,手動的流程的任務與自動化需要做的任務一一對應了。
現在我們思路清晰了。
然後我們要做的就是把每個任務逐個攻克即可。
你不覺得我應該先完成第一個任務——JS 逆向登錄加密方法,實現自動化登錄並獲取 token 嗎?
這確實是全自動流程中最核心的一環:沒有自動登錄獲取憑證,後續的數據抓取和操作根本無從談起。
不過,我初步分析了登錄接口,發現參數加密邏輯較複雜,短時間內難以破解。
於是我選擇暫時跳過,先手動複製登錄憑證,確保後續流程全部打通後再回過頭補全自動化登錄部分。
首先看板這是一個列表,有很多項目,首先看這個列表怎麼來的,伺服器端渲染還是,調的接口。
然後看能不能完全從頁面拿到,我們再考慮抓取方式。
所以我選擇抓接口。

眾多接口,我們怎麼找到我要的數據在哪,於是我們利用調試工具,搜尋響應內容關鍵字。


點開看,確實,裡邊就是這個列表的數據。
但是沒有具體是環比、同比,我要的數字。
再次通過搜尋大法找了好久.....
找到了通過每項 id 和過濾條件去獲取具體數據的接口。
研究下來。整體邏輯是,先獲取面板列表,然後循環列表的每一項,拿著有關聯的參數去調詳情。
/**
* 獲取重點功能監控面板列表及詳情數據
* @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=fotorglobalproduct&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=fotorglobalproduct&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);
}
}
執行一下,數據拿到了,找到了我要的幾個字段。
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
node-cavas 生成圖片。
const filePath = `/custom/999/${dashboard.worksheetName}-${dayjs().format('YYYYMMDD')}.jpeg`
const uploadRes = await tencentCos.upload(imageBuffer, filePath, true)
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 >  \n`
"text": text
},
"at": {
"isAtAll": true
}
})
}).then(res => res.json())
console.log(result)
if (result.errcode === 0) {
console.log('發送成功')
return true
}
}
就剩下自動登錄了。
因為這個系統登錄憑證在一定時間內會過期,且不是明文登錄的,登錄接口參數加密了的。

看到這你就得去研究他的加密規則了。
或者止步於此,手動複製登錄憑證,本地執行腳本也是可以。
我如果要在伺服器上自動化整個流程,必須得讓他自動登錄拿到登錄憑證。
先點頁面的登錄,找到登錄接口,在請求調用棧中隨便找個位置先打個斷點,然後刷新頁面,再次點擊登錄,嘿,您猜怎麼著,斷住了!!!

順著調栈給上找邏輯。所有在前端加密的一定是可以模擬的。
找到了調登錄的方法。
看了下這就是調 store 裡的方法 passport/login。

從這再往下就比較不容易了,因為你會發現,就有點亂了。進到的都是混淆的一些 abcdefg 名字的方法。

但是咱們明確目標就是要找到調用的方法 passport/login 的位置。
我嘗試了如下:
找了一輩子, 終於找到了調接口的地方。
看到這個 Me 方法,傳遞了一個 isEncrypted 我猜測就是 是否要加密參數的意思吧。

別搁 Me 方法外面蹭了行不行???趕緊進去看看。
你就給我看這個?
這裡面又調了另一個。

咱們接著進到 xt.request。
您猜怎麼著,還沒到,這裡又進行了一頓操作之後,調了一個名為 P 的方法。

好好好,繼續繼續。
到了 P 方法,終於是沒給我玩套娃了啊。
在這一步終於是看到了關鍵字 isEncrypted。
看了代碼確實是判斷 isEncrypted 加密的。

看了後發現這是一個RSA+AES結合的加密方式。
加密流程總結:
那麼你有沒有這樣的疑問呢?為什麼不單獨用 rsa 直接加密數據呢?豈不簡單。
當然不行,是有原因的!
RSA長度限制
RSA加密算法對明文長度有嚴格限制,具體取決於密鑰長度和填充方式。以下是不同密鑰長度下的最大明文長度(以字節為單位):
所以 RSA 加密超出長度的會報錯的。
所以先生成短密鑰,再使用 RSA 加密 AES 對稱算法的密鑰,再用對稱密鑰加密實際數據。這是實際應用中的常見做法,兼顧安全性和效率。
所以你需要怎麼做?
前面我們說到。
RAS加密密鑰, AES加密登錄數據。
那麼後端的校驗流程就是:
知道了這些,那麼我們需要做的就是正確加密和傳遞相關信息,如果校驗失敗,我們就要來回對比差異,找到問題,不斷嘗試。

真的是一個不斷嘗試,不斷解決問題的過程。
最終我抽出來的方法
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
}
串聯起來這四步,整體就實現了。
隨後部署到伺服器,配置定時任務每天執行。
效果展示

雖然文章寫得像一帆風順,但實則磕磕絆絆——在層層混淆的代碼裡翻找,第一天方法不對,左衝右突腦殼嗡嗡作響。幸好第二天沒放棄,沉下心繼續深挖,一步步試錯、迭代,終於攻克所有難題。
如果有小夥伴想跟我探討細節,歡迎聯繫!
喜歡的話,點點關注。