什么是RPA?
RPA(Robotic Process Automation)即"机器人流程自动化",是一种利用软件机器人(或"机器人")来自动化高度重复性、规则性强的业务流程的技术。RPA 通常用于企业环境中,以提高工作效率、减少人工错误、节省时间和成本。 简而言之,网站或软件当中一切有规律的操作都可以抽象编写成RPA运行,摆脱人工繁琐的操作。
实现前提
条件
- 如何在界面当中找到想要的元素的位置和内容?
- 如何触发鼠标键盘事件进行相应的操作?
- 如何保证异步网络请求 或异步渲染的元素已经完成了?
条件实现(按条件序号对应)
RPA目标为网站时
- 通过查找dom;
- 通过触发new event()、通过cdp触发浏览器级别的鼠标键盘事件;
- 通过cdp监听网站网络请求、使用代理服务器;通过轮询查找dom来确定元素是否存在;一般需要结合上述判断。
RPA目标为软件时
- 通过无障碍api(又称辅助接口技术如UI Automation)、ocr文字识别确定内容和相对坐标、图像像素匹配确定相对坐标。
- 通过无障碍api的方法事件、通过操作系统层面的鼠标键盘事件如robotjs、nut-js等库。
- 通过代理、抓包、软件日志来获取网络请求状态;通过轮询查找dom来确定元素是否存在;一般需要结合上述判断。
- (可选)API挂钩和DLL注入,类似ce修改器那种。
网站自动化操作方案
方式1:Electron注入js
原理
通过electron打开一个BrowserView 来进入目标站点 ,使用executeJavaScriptAPI来向目标站点注入要执行的js脚本,实现流程化的注入脚本来达到自动化操作网站的需求。
流程基本思路
- 创建一个BrowserView并挂载目标网站URL(puppet视图窗口);
- 另开一个BrowserView来显示UI视图控制脚本执行流程(control视图窗口);
- 在control视图窗口 当中通过ipc事件通讯传要执行的js脚本至主进程,让主进程使用executeJavaScript注入脚本到control视图窗口
如注入以下代码至puppet视图窗口来实现输入和点击操作:
js
(function(){
const input = document.querySelector('input[type="search"]');
input.value = 'juejin';
// 在操作一些使用框架的网站时,因为框架的事件监听机制,直接修改value不会触发事件,需要手动触发
input.dispatchEvent(new Event('input'));
const btn = document.querySelector('.seach-icon-container');
btn.click();
})()
上述代码做的事情很简单,获取输入框给它的value属性赋值 ------ 使用dispatchEvent触发框架value的值绑定 ------ 获取搜索按钮触发它的点击事件
限制
使用此方式来完成一些自动化操作网站的需求可以说是最简单的方式了,但实际复杂场景当中可能会遇到一些难以绕开或者增加心智负担的情况,以下仅列出部分场景:
- 需要以js的方式手动的给输入框赋值 ,并且在某些情况下(如vue做的网站)还需要手动触发相对应的Event事件
input.dispatchEvent(new Event('input'))
; 向select赋值的时候,需要手动尝试获取value和label的对应关系才能赋正确的值并触发Dom.dispatchEvent(new Event('change'))
。 - 在需要触发键盘如enter来检索的时候,需要手动模拟键盘事件 如
Dom.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 13, key: 'Enter', code: 'Enter' }))
;但有时候我们并不知道网站的开发者使用的是keydown还是keyup甚至是已经弃用的keypress事件,这时候就需要我们在控制台的事件监听器来查看相应的触发事件加上手动的尝试才能知道具体需要触发什么键盘事件。 - 如果需要点击网站弹出的alert、confirm等bom级别的弹窗,那么这种方式则无法做到(如果有知道的可以分享给我~),因为js没有任何api可以操作已经弹出的弹窗,而且弹窗会阻塞住js脚本的运行。但是可以绕过此限制,如注入脚本的时候把 window.confirm API覆盖成一个只返回true、false的函数来模拟我们的点击功能。
- 无法聚焦元素,当窗口焦点不在傀儡网站上时,无法通过dom.focus()来触发聚焦事件。
- 当目标网站为SSR渲染时,为了防止刷新导致的注入脚本执行失败,需要细化各个脚本步骤。
- 很难获取到页面当中接口的响应体内容。
- 等等等
可能需要的配合
监听网络请求
- 如需要获取依赖服务器数据才能渲染的元素,如果过早的获取元素可能获取不到,如动态下拉框。
- 如需要刷新页面、重定向页面后才能进行的操作。
- 如需要保存、提交后才能进行的操作。
为了解决以上问题,除了麻瓜式的延迟之外;可以通过electron提供的browserView.webContents.session.webRequest
当中的onBeforeRequest、onCompleted、onErrorOccurred来得知当前页面请求的完成状态;配合轮询判定特定的元素是否存在的方式,可以实现获取异步结果渲染的情景,如:何时成功跳转到了下一页、获取select当中的异步option等。
加入部分CDP操作
什么是cdp?
CDP 是指 Chrome DevTools Protocol(Chrome 开发者工具协议)。这是一种通信协议,允许工具和服务与 Chrome 浏览器或其他基于 Chromium 的浏览器(如 Microsoft Edge)进行交互。通过 CDP,可以控制和监视浏览器的行为,这包括但不限于页面渲染、JavaScript 执行、网络请求等。
为什么需要cdp?
- 为了解决纯js脚本和electron api当中无法实现的功能点;如上文中的无法操作confirm/alert的问题、获取网络接口响应体的问题、无法聚焦元素的问题。
- 可以简化脚本的操作逻辑,减少部分心智负担;如上文中的实现键盘输入和鼠标点击功能时,只能手动使用js脚本来触发某些可能不知道的点击事件或键盘事件的问题。
如何在electron当中使用?
在electron当中可以通过webContents.debugger的方式来使用cdp的功能。如以下 伪 代码一样可以实现上面的例子:
js
ipcMain.handle('cdpTest', async (event, { browserWindowId }) => {
const thisBw = browserWindowId ? BrowserWindow.fromId(browserWindowId) : null;
if (thisBw) {
const webContents = thisBw.webContents;
const cdp = webContents.debugger;
cdp.attach('1.3');
// 获取元素的位置
async function getElementPosition(selector) {
return webContents.executeJavaScript(`
(function() {
const rect = document.querySelector('${selector}').getBoundingClientRect();
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
})();
`);
}
// 模拟鼠标点击
async function clickElement(selector) {
const position = await getElementPosition(selector);
await cdp.sendCommand('Input.dispatchMouseEvent', {
type: 'mousePressed',
x: position.x,
y: position.y,
button: 'left',
clickCount: 1,
});
await cdp.sendCommand('Input.dispatchMouseEvent', {
type: 'mouseReleased',
x: position.x,
y: position.y,
button: 'left',
clickCount: 1,
});
}
// 模拟键盘输入
async function typeText(text) {
for (let i = 0; i < text.length; i++) {
await cdp.sendCommand('Input.dispatchKeyEvent', {
type: 'char',
text: text[i],
});
}
}
// 执行操作
await clickElement('input[type="search"]'); // 点击输入框
await typeText('juejin'); // 输入文字
await clickElement('.seach-icon-container'); // 点击搜索按钮
}
});
上述代码做的事情为:通过注入js获取输入框和搜索按钮在网站当中的相对xy位置 ------ 使用cdp触发浏览器级别的键盘事件进行输入文字 ------ 使用cdp触发浏览器级别的鼠标事件进行点击搜索按钮
方式2:使用Puppeteer、Playwright等CDP框架(推荐)
在上述方式1当中结尾所存在的限制中,极大的增加了rpa开发者的心智负担。理想情况下的情景中,我们并不需要去了解一个网站当中的某些事件是怎么触发的,不需要去在给select赋值的时候担心value是否正确的问题,不需要去覆盖原生的api,也不需要去写cdp代码来实现那些注入js无法实现的功能。 我们只需要从用户的角度来考虑正常应该做什么操作,如点击、键盘输入、跳转页面就行;来模拟真正的用户的操作逻辑即可,无须关注网站内部的代码运行逻辑。
如何使用?
还是以实现上面的例子来举例:
js
const { exec } = require("child_process");
const puppeteer = require("puppeteer");
const net = require("net");
// 打开 Chrome 浏览器
function launchChrome() {
const chromePath =
'"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"';
const debuggingPort =
"--remote-debugging-port=9222 --user-data-dir=C:MyChromeDevUserData";
const command = `${chromePath} ${debuggingPort}`;
exec(command, (err, stdout, stderr) => {
if (err) {
console.error(`执行的错误: ${err}`);
return;
}
console.log(`stdout: ${stdout}`);
console.error(`stderr: ${stderr}`);
});
}
// 检查端口是否开放(浏览器是否就绪)
function checkPort(port, callback, maxRetries = 10) {
let retries = 0;
function attemptConnection() {
const client = new net.Socket();
retries++;
client.once("error", function (err) {
if (err.code === "ECONNREFUSED") {
if (retries === 1) {
console.log("端口关闭,正在启动浏览器...");
launchChrome();
setTimeout(attemptConnection, 1000);
} else if (retries < maxRetries) {
console.log(`连接被拒绝,重试中... (${retries}/${maxRetries})`);
setTimeout(attemptConnection, 1000);
} else {
console.error("达到最大重试次数,浏览器可能启动失败");
}
}
});
client.once("connect", function () {
console.log("端口开放,浏览器已就绪");
client.end();
callback();
});
client.connect({ port: port });
}
attemptConnection();
}
// 执行 Puppeteer 脚本
async function runPuppeteerScript() {
const browser = await puppeteer.connect({
browserURL: "http://localhost:9222",
});
const page = await browser.newPage();
page.setViewport({ width: 1000, height: 1080 });
// 进入页面
await page.goto("https://juejin.cn/", {
waitUntil: ["networkidle0"], //等待网络空闲时,再执行下一步
});
await page.waitForSelector(".seach-icon-container", { timeout: 3000 }); //等待搜索按钮加载完成
await page.type("input[type='search']", "juejin", { delay: 0 }); //输入搜索内容
await Promise.all([
page.waitForNavigation({ waitUntil: "networkidle0" }),
await page.click(".seach-icon-container"), //点击搜索按钮
]); // 点击搜索按钮并等待页面加载完成
console.log("搜索完成");
// ...
}
// 检查端口,然后运行 Puppeteer 脚本
checkPort(9222, runPuppeteerScript);
以上代码其实真正核心的业务代码就这几行:
进入目标页面、等待目标页面网络请求加载完成、等待指定元素渲染完成、触发cdp浏览器级别的键盘输入、 触发cdp浏览器级别的鼠标点击、等待页面网络请求加载完成。
运行后,其所能实现的效果和方式一相比一般无二:
使用cdp框架来实现这些功能可以极大的减少我们开发者的心智负担,我们只需要使用框架提供的各种封装好的cdp方法和其它辅助方法就可以完美的实现文章开头的rpa的三个前提条件 ,也不需要费尽心思的绕过或实现方式一当中的那些限制。
其它例子:如获取网易云音乐歌词、评论、截图 并保存
更多的如spa爬虫等玩法都是可以很方便的实现的。
桌面软件自动化操作方案
桌面软件的rpa自动化控制相比于网站就复杂的多了,让我们回顾一下软件rpa实现的三个前提条件的解决方案:
如何确定想要操作的元素的位置和内容?
无障碍api
此方法一般作为首选,但使用时需要注意需要操作的目标软件是否支持无障碍api操作,一般受制于该软件的开发者所使用的框架以及开发者对无障碍的支持程度。本文不对此技术做demo演示,有兴趣的可以参考github的项目:github.com/yinkaisheng...
ocr文字识别
通过一些开源库可以很容易的实现查找某个文字在图片当中的位置或者获取图片当中的文字的功能;其准确率和使用的库所训练的模型的精度有关,本次不过分的考虑准确率、重复的字符等那些边界问题。
本次使用 tesseract.js 库来实现获取以下图片当中的文件传输助手所在在相对位置的功能,更多用法详见github。
js
const Tesseract = require('tesseract.js');
const path = require('path');
async function recognizeTextFromImage(imagePath) {
const result = await Tesseract.recognize(imagePath, 'chi_sim');
return result;
}
// 使用 OCR 识别文字
async function captureAndRecognizeText(tImg) {
const ocrResult = await recognizeTextFromImage(tImg);
//找到 加入黑名单 的坐标
const { text, words, symbols } = ocrResult.data;
console.log(text); // 输出识别的文字
let allCoordinate = [];
console.log('------------', symbols.length);
for (const symbol of symbols) {
console.log('!!!', symbol.text);
allCoordinate.push({ ...symbol.bbox, text: symbol.text });
}
return {
text,
allCoordinate,
};
}
const imagePath = path.join(__dirname, '../a.png');
const tText = '文件传输助手';
captureAndRecognizeText(imagePath, tText).then(res => {
console.log('res', res);
const midText = tText.split('')[Math.floor(tText.length / 2)];
const midCoordinate = res.allCoordinate.find(item => {
return item.text === midText;
});
console.log('midText', midText);
console.log('midCoordinate', midCoordinate);
});
module.exports = captureAndRecognizeText;
使用node运行以上代码得到的结果为:
由结果得知 输 这个字符相对于此图片的位置为174*109。
计算机视觉
利用机器视觉开源库可以很容易的实现判断某个图片是否在某个图片当中的功能;其准确率和使用的库所训练的模型的精度有关,本次不过分的考虑准确率、重复的图片像素等那些边界问题。
本次使用 opencv4nodejs 库来实现获取文件传输助手的图标在此图片当中的相对位置和相似度的功能,更多用法详见github。
js
const cv = require('@u4/opencv4nodejs');
const path = require('path');
async function findImagePosition(mainImagePath, toMatchImagePath, threshold = 0.5) {
const mainImage = await cv.imreadAsync(mainImagePath); //读取主图片
const toMatchImage = await cv.imreadAsync(toMatchImagePath); //读取目标图片
// 使用模板匹配算法,查找目标图片在主图片中的位置
const matched = mainImage.matchTemplate(toMatchImage, cv.TM_CCOEFF_NORMED);
// 获取最大匹配值的位置
const minMax = matched.minMaxLoc();
// 如果最大匹配值大于或等于阈值(相似度),则认为匹配成功
if (minMax.maxVal >= threshold) {
return {
matched: true,
position: minMax.maxLoc,
confidence: minMax.maxVal,
};
} else {
return {
matched: false,
position: null,
confidence: minMax.maxVal,
};
}
}
const imagePath = path.join(__dirname, './main.png');
const tImagePath = path.join(__dirname, './target.png');
// 使用示例
findImagePosition(imagePath, tImagePath)
.then(result => {
if (result.matched) {
console.log('Matched position:', result.position, 'Confidence:', result.confidence);
} else {
console.log('No match found. Confidence:', result.confidence);
}
})
.catch(err => {
console.error('Error:', err);
});
module.exports = findImagePosition;
使用node运行以上代码得到的结果为:
由结果得知此图标相对于此图片的位置为y: 106, x: 75。相似度为Confidence: 0.9999992251396179(最大值为1)
如何触发鼠标键盘事件?
无障碍api
robotjs、nut-js等库
依靠这些库可以很轻松的实现截取屏幕、点击滚动鼠标键盘等功能。
截取屏幕
ini
const robotjs = require('robotjs');
const Jimp = require('jimp');
const doScreenCapture = async ({ x = 0, y = 0, w = 100, h = 100 } = {}) => {
// 截取整个屏幕 (位图)
const screenCapture = robotjs.screen.capture(x, y, w, h); // BGRA 4通道
// 创建新图像
const image = new Jimp(w, h);
let pos = 0;
// 将屏幕捕获的像素数据填充到图像中
image.scan(0, 0, image.bitmap.width, image.bitmap.height, function (x, y, idx) {
// 注意:这里颜色通道的顺序已经调整为符合 Jimp 的预期, 即 RGBA
image.bitmap.data[idx + 0] = screenCapture.image[pos + 2]; // 红色 (原来是蓝色)
image.bitmap.data[idx + 1] = screenCapture.image[pos + 1]; // 绿色
image.bitmap.data[idx + 2] = screenCapture.image[pos + 0]; // 蓝色 (原来是红色)
image.bitmap.data[idx + 3] = screenCapture.image[pos + 3]; // alpha
pos += 4;
});
// 保存图像到当前目录
await image.writeAsync('screenshot.png');
console.log('屏幕截图已保存为 screenshot.png');
};
const screenSize = robotjs.getScreenSize(); // 获取屏幕尺寸
console.log('屏幕尺寸:', screenSize);
doScreenCapture({
x: 0,
y: 0,
w: screenSize.width / 2,
h: screenSize.height,
});
module.exports = doScreenCapture;
移动鼠标
javascript
const robotjs = require('robotjs');
// 设置每隔五分钟移动鼠标
setInterval(async () => {
try {
// 获取当前鼠标位置
const currentPosition = robotjs.getMousePos();
// 计算新位置(例如,向右移动10像素)
const newPosition = { x: currentPosition.x + 10, y: currentPosition.y };
// 移动鼠标
robotjs.moveMouse(newPosition.x, newPosition.y);
// 返回到原始位置
setTimeout(async () => {
robotjs.moveMouse(currentPosition.x, currentPosition.y);
}, 1000);
} catch (e) {
console.error(e);
}
}, 10000); // 五分钟(300000毫秒)
更多例子自己尝试~
交互示例
以下示例将利用上面介绍的桌面端rpa技术思路当中的图像识别和robotjs库来实现自动发信息的功能。 目的:找到目标软件当中的文件传输助手发送hello rpa。 步骤:
- 获取软件所在位置(本示例为了简单直接手动移动目标软件到屏幕左上角方便截图;实际当中可以通过electron之类的框架库获取其它软件的视图位置)
- 截取软件所在位置的屏幕
- 获取文件传输助手在截图当中的位置
- 点击这个位置进入聊天页、激活聊天页输入框
- 输入文本、点击发送按钮
ini
const findImagePosition = require('../../openCv-test/index.js');
const getScreen = require('../../robotjs-test/captureScreen/index.js');
const path = require('path');
const robotjs = require('robotjs');
const { mouse } = require('@nut-tree/nut-js');
const doMyTest = async () => {
//截图左上角四分之一屏幕
const screenSize = robotjs.getScreenSize(); // 获取屏幕尺寸
const tImagePath = path.join(__dirname, './target.png'); //目标图片
//opencv识别图片
//如果识别不到,鼠标滚动后再识别,重复50次后还是识别不到,就报错
let result = {};
for (let i = 0; i < 50; i++) {
await getScreen({ x: 0, y: 0, w: screenSize.width / 2, h: screenSize.height / 2 });
const imagePath = path.join(__dirname, './screenshot.png');
result = await findImagePosition(imagePath, tImagePath, 0.8);
console.log(result.matched, result.confidence);
if (result.matched) {
break;
} else {
//鼠标滚动(screenSize.height - 40) / 2
robotjs.moveMouse(200, 300);
// robotjs.scrollMouse(0, (screenSize.height - 40) / 2);
await mouse.scrollDown((screenSize.height - 40) / 2);
}
}
const { matched, position, confidence } = result;
if (matched) {
const { x, y } = position;
robotjs.moveMouse(x + 10, y);
robotjs.mouseClick();
//激活输入框
robotjs.keyTap('tab');
robotjs.keyTap('backspace');
//输入 你好rpa
robotjs.typeString('hello rpa');
//control + enter 发送
// robotjs.keyTap('enter', ['control']);
}
};
doMyTest();
运行效果