RPA-通过浏览器插件实现Web元素的拾取(GoBot)

1,055 阅读4分钟

在开发RPA工作流时,如何获取到网页元素的定位符时很关键的一步,如果能够实现自动生成web元素的选择器,那么将会大大提高工作流的开发效率,本文介绍如何通过浏览器插件的方式实现web元素选择器的自动生成技术。

1. 浏览器插件如何开发


Pasted image 20231218194900.png

开发浏览器最重要的几个概念


1. manifest.json

这是一个Chrome插件最重要也是必不可少的文件,用来配置所有和插件相关的配置,必须放在根目录。其中,manifest_versionnameversion3个是必不可少的,descriptionicons是推荐的。

2. content-scripts

所谓content-scripts,其实就是Chrome插件中向页面注入脚本的一种形式(虽然名为script,其实还可以包括css的),借助content-scripts我们可以实现通过配置的方式轻松向指定页面注入JS和CSS(如果需要动态注入,可以参考下文),最常见的比如:广告屏蔽、页面CSS定制,等等。

3. background

后台是一个常驻的页面,它的生命周期是插件中所有类型页面中最长的,它随着浏览器的打开而打开,随着浏览器的关闭而关闭,所以通常把需要一直运行的、启动就运行的、全局的代码放在background里面。

background的权限非常高,几乎可以调用所有的Chrome扩展API(除了devtools),而且它可以无限制跨域,也就是可以跨域访问任何网站而无需要求对方设置CORS

4. popup

popup是点击browser_action或者page_action图标时打开的一个小窗口网页,焦点离开网页就立即关闭,一般用来做一些临时性的交互。

比较常用的是以上的几个概念。

设计思路


要实现web元素选择器的自动生成,则需要我们的RPA软件能够跟浏览器插件通信,如何与浏览器插件进行通信呢?这里就需要用到浏览器插件的native-messaging(原生消息)能力。 扩展程序可以使用与其他消息传递 API 类似的 API 与原生应用交换消息。支持此功能的原生应用必须注册可与扩展程序进行通信的_原生消息传递主机_。Chrome 会在单独的进程中启动主机,并使用标准输入和标准输出流与其进行通信。

Pasted image 20231218200334.png

整个通信机制如上图所示,注册原生消息传递主机之后,浏览器在打开时会自动启动原生消息传递主机,即上图中的native host,该程序会启动一个websocket服务,同时通过标准输入输出跟浏览器的background.js通信。

当我们在RPA程序想要拾取web元素时,通过websocket协议向nativehost发送消息,并由nativehost转发给浏览器,告诉浏览器我们要进行元素拾取,浏览器收到消息后,通知当前激活的Tab页,监听鼠标的mouse over 和mouse click事件,当鼠标停在网页元素上时,元素会添加hover效果,告诉用户当前激活的元素,当用户点击某个元素时,content.js分析网页结果,计算出当前点击元素的xpath,通过上面的通信链路返回给RPA程序。

下面为核心代码:

原生消息传递主机配置的文件

{
    "allowed_origins": [
        "chrome-extension://knldjmfmopnpolahpmmgbagdohdnhkik/"
    ],
    "description": "My Application",
    "name": "com.my_company.my_application",
    "path": "C:\\Program Files\\My Application\\chrome_native_messaging_host.exe",
    "type": "stdio"
}

register.reg 注册表

Windows Registry Editor Version 5.00
[HKEY_CURRENT_USER\Software\Google\Chrome\NativeMessagingHosts\com.my_company.my_application]
@="C:\\path\\to\\nmh-manifest.json"

background.js 跟 native host 通信

var port = chrome.runtime.connectNative('com.my_company.my_application');
port.onMessage.addListener(function (msg) {
  console.log('Received' + msg);
});
port.onDisconnect.addListener(function () {
  console.log('Disconnected');
});
port.postMessage({text: 'Hello, my_application'});

native host 跟 浏览器通信

func pumpStdout() {
	count := 0
	for {
		buf := make([]byte, 4)
		length, err := os.Stdin.Read(buf)
		if err == nil && length == 4 {
			dataLength := binary.LittleEndian.Uint32(buf)
			data := make([]byte, dataLength)
			if _, err = os.Stdin.Read(data); err == nil {
				log.Logger.Info().Msg("receive from browser:" + string(data))
				if wsConn != nil {
					if err = wsConn.WriteMessage(1, data); err != nil {
						wsConn = nil
					}
				}
			} else {
				log.Logger.Error().AnErr("break", err)
				break
			}
			count = 0
		} else if length == 0 {
			time.Sleep(time.Millisecond * 200)
			count += 1
			if count > 10 {
				log.Logger.Info().Msg("浏览器关闭")
				os.Exit(0)
			}
		}
	}
}

func pumpStdin(conn *websocket.Conn) {
	for {
		if conn != nil {
			_, message, err := conn.ReadMessage()
			log.Logger.Info().Msg("read from control:" + string(message))
			if err != nil {
				conn.Close()
				break
			}
			length := len(message)
			buf := make([]byte, 4)
			binary.LittleEndian.PutUint16(buf, uint16(length))
			if _, err := os.Stdout.Write(buf); err != nil {
				conn.Close()
				break
			}
			if _, err := os.Stdout.Write(message); err != nil {
				conn.Close()
				break
			}
		} else {
			break
		}
	}
}

RPA和native host 通信


func StartPick(ctx context.Context) (string, error) {
	if wsConn == nil {
		conn, _, err := websocket.DefaultDialer.Dial("ws://127.0.0.1:8080/ws", nil)
		if err == nil {
			wsConn = conn
		} else {
			return "", err
		}
	}
	messageId := uuid.New().String()
	sendMessage := make(map[string]string)
	sendMessage["message"] = "start"
	sendMessage["id"] = messageId
	request, err := json.Marshal(sendMessage)
	if err != nil {
		return "", err
	}
	if err := wsConn.WriteMessage(1, request); err != nil {
		wsConn.Close()
		wsConn = nil
		return "", err
	}
	runtime.WindowMinimise(ctx)
	for {
		_, message, err := wsConn.ReadMessage()
		if err != nil {
			return "", nil
		}
		resp := make(map[string]interface{})
		if err = json.Unmarshal(message, &resp); err != nil {
			return "", nil
		}
		if resp["id"] == messageId {
			runtime.WindowMaximise(ctx)
			return string(message), nil
		}
	}
}

func StartCheck(ctx context.Context, frame, selector string) (string, error) {
	if wsConn == nil {
		conn, _, err := websocket.DefaultDialer.Dial("ws://127.0.0.1:8080/ws", nil)
		if err == nil {
			wsConn = conn
		} else {
			return "", err
		}
	}
	messageId := uuid.New().String()
	sendMessage := make(map[string]string)
	sendMessage["message"] = "highlight"
	sendMessage["frame"] = frame
	sendMessage["xpath"] = selector
	sendMessage["id"] = messageId
	request, err := json.Marshal(sendMessage)
	if err != nil {
		return "", err
	}
	if err := wsConn.WriteMessage(1, request); err != nil {
		return "", err
	}
	runtime.WindowMinimise(ctx)
	for {
		_, message, err := wsConn.ReadMessage()
		if err != nil {
			return "", nil
		}
		resp := make(map[string]interface{})
		if err = json.Unmarshal(message, &resp); err != nil {
			return "", nil
		}
		if resp["id"] == messageId {
			runtime.WindowMaximise(ctx)
			return string(message), nil
		}
	}
}