驱动和浏览器

官网写的很详细,主要记录些踩坑的地方:

Selenium的运行需要浏览器驱动,用浏览器驱动连接和启动对应的浏览器实例
如果你的电脑上安装了谷歌、edge或者火狐的浏览器(一般会安装在默认的用户目录),Seleniu启动方法会默认从这些路径中扫描并找寻驱动连接和启动它
但是Selenium本来是一个测试用的,我们怎么可以用机器上安装好的自用的浏览器呢?这会污染我们的测试环境,我们要保证环境的隔离
以Google浏览器为例,下面是一个相对完善的demo:

/**  
 * 初始化谷歌浏览器  
 */  
public void initChrome() {  
    String userProfile = RPAConfig.envPath + File.separator + "browser\\User Data";  
    //设置 ChromeDriver 路径  
    String chromeDriverPath = RPAConfig.envPath + File.separator + "browser\\drivers\\chromedriver.exe";  
    // 设置 Chrome 可执行文件路径  
    String chromePath = RPAConfig.envPath + File.separator + "browser\\chrome-win64\\chrome.exe";  
    //设置远程调试地址和端口  
    String ipAddress = "localhost";  
    int port = 9889;  
    String debuggerAddress = ipAddress + ":" + port;  
    //是否初始化  
    boolean isInitialized = true;  
    ChromeOptions options = new ChromeOptions();  
    if (FileUtil.exist(chromeDriverPath) && FileUtil.exist(chromePath) && FileUtil.exist(userProfile)) {  
        System.setProperty("webdriver.chrome.driver", chromeDriverPath);  
        options.setBinary(chromePath);  
    } else {  
        log.warn("路径下内置的谷歌浏览器驱动不存在!正在尝试使用WebDriverManager获取驱动...");  
        WebDriverManager.chromedriver().setup();  
    }  
    if (CmdUtil.isPortInUse(9889)) {  
        isInitialized = false;  
        try {  
            log.info("端口9889被占用,尝试使用已启动的Chrome浏览器...");  
            options.setExperimentalOption("debuggerAddress", debuggerAddress);  
        } catch (Exception e) {  
            log.info("尝试使用已启动的Chrome浏览器失败,正在尝试关闭占用端口的Chrome进程...");  
            CmdUtil.closeProcessOnPort(port);  
            log.info("占用端口的Chrome进程已关闭,正在尝试重新启动Chrome浏览器...");  
            isInitialized = true;  
        }  
    }  
    if (isInitialized) {  
        // 添加其他 ChromeOptions 设置  
        options.addArguments("--start-maximized"); // 最大化窗口  
        // options.addArguments("--headless"); // 无头模式,如果需要(更容易被检测)  
        options.addArguments("--disable-blink-features=AutomationControlled");//开发者模式可以减少一些网站对自动化脚本的检测。  
        //options.addArguments("--disable-gpu"); // 禁用GPU加速  
        options.addArguments("--remote-allow-origins=*"); // 解决 403 出错问题,允许远程连接  
        options.addArguments("--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.150 Safari/537.36");  
        options.addArguments("user-data-dir=" + userProfile);  
        options.addArguments("--remote-debugging-port=9889"); // 设置远程调试端口  
    }  
    // 创建 ChromeDriver 实例  
    WebDriver originalDriver = new ChromeDriver(options);  
    driver = new EventFiringWebDriver(originalDriver);  
    // 注册自定义监听器  
    driver.register(new CustomEventListener());  
    actions = new Actions(driver);  
    js = (JavascriptExecutor) driver;  
    initBrowserZoom();  
}

我们做了哪些自定义操作呢?

  • 通过Java的API设置系统属性:指定本地谷歌驱动路径
    System.setProperty("webdriver.chrome.driver", chromeDriverPath);  
    
  • 获取谷歌配置选项对象:
    	  ChromeOptions options = new ChromeOptions();  
    	```
    	- 配置要启动的<font color="#ff0000">谷歌浏览器路径</font>
    	  ```java
    		  options.setBinary(chromePath); 
    		```
    	- 以及配置谷歌浏览器的相关<font color="#ff0000">启动参数</font>
    		```Java
    		ChromeOptions options = new ChromeOptions();  
    		options.addArguments("--start-maximized"); // 最大化窗口  
    		// options.addArguments("--headless"); // 无头模式,如果需要(更容易被检测)  
    		options.addArguments("--disable-blink-features=AutomationControlled");//开发者模式可以减少一些网站对自动化脚本的检测。  
    		//options.addArguments("--disable-gpu"); // 禁用GPU加速  
    		options.addArguments("--remote-allow-origins=*"); // 解决 403 出错问题,允许远程连接  
    		options.addArguments("--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.150 Safari/537.36");  
    		options.addArguments("user-data-dir=" + userProfile);  
    		options.addArguments("--remote-debugging-port=9889"); // 设置远程调试端口  
    		```
    关于启动参数,其中为了保证环境隔离有一个比较重要的参数是`user-data-dir=`,这意味这浏览器启动后读取哪一个用户的数据。如果不指定该参数,每次启动浏览器都会创建一份全新的数据,这意味着每次启动设置的浏览器配置都将被清空!
    **我们要保证环境隔离,但有时候比如我的需求又需要保留一份固定、预设好的浏览器配置**,那么我就必须指定一份配置保证配置好的数据在每次启动都加载同一份浏览器配置数据
    

自动管理WebDriver版本

GitHub开源的项目,能够根据浏览器版本自动寻找和配置对应版本驱动,完成系统参数配置,避免驱动寻找和下载的麻烦。当然了根据需求场景而定,大部分时候我们应该都是手动管理并对应版本,除了只是单纯当做自动化工具那自然是越方便越好

使用方法很简单,先导入Maven坐标,刷新依赖(注意下Scope)

<dependency>
	<groupId>io.github.bonigarcia</groupId>
	<artifactId>webdrivermanager</artifactId>
	<version>5.6.3</version>
	<!--<scope>test</scope>-->
</dependency>

在获取WebDriver对象之前执行如下代码:

WebDriverManager.chromedriver().setup();
//解下来初始化和获取对应浏览器的WebDriver对象

浏览器驱动对象

一切皆对象,要使用Selenium操纵浏览器自然也需要先把这个代表某个浏览器的对象创建出来,其实说的不对,应该说是浏览器驱动对象。
为什么呢?因为我们实际上是操控浏览器驱动,然后浏览器驱动代我们指挥浏览器!
其实在第一部分,我们先获取谷歌参数对象

ChromeOptions options = new ChromeOptions();  

然后添加了各种启动参数、路径信息,当对象信息填充完毕后,我们最终创建了WebDriver对象,这也就是我们要操控的浏览器驱动对象

WebDriver driver = new ChromeDriver();

基于这个对象,我们已经可以实现大部分的浏览器自动化行为
但这并不够,Selenium还为我们提供了JavascriptExecutorActions两个WebDriver的扩展类,获取方法如下:

/*js执行器*/
private JavascriptExecutor js;

/*actions类 链式网页操作*/
private Actions actions;

actions = new Actions(driver);  
js = (JavascriptExecutor) driver;

它们分别用于在浏览器执行JS代码和模拟真正的人工行为状态
JavascriptExecutor的基础用法非常简单,如下所示调用executeScript方法传入JS代码字符串即可

/**
 * 打开新的标签页
 */
public void newTab() {
	js.executeScript("window.open();");
	switchToNewTab();
}

Actions类究竟有啥玄妙?他又能干啥,怎么就模拟人的行为状态了呢?
其实我们可以直接使用WebDriver操纵浏览器点击、输入等操作,但这样其实很生硬,相当于直接调用驱动API直接往里边写值,很容易就会被识别出来
Actions相当于加入了动作的过程,比如拖动这个动作:

    /**
     * 拖动元素到指定位置
     *
     * @param elementLocator 拖动元素的定位表达式
     * @param xOffset
     * @param yOffset
     */
    public void dragElementToOffset(By elementLocator, int xOffset, int yOffset) {
        // 定位要拖动的元素
        WebElement element = driver.findElement(elementLocator);

        // 执行长按拖动操作
        actions.clickAndHold(element)
                .moveByOffset(xOffset, yOffset)
                .release()
                .perform();
    }

主要体现在各种动作的状态切换和时长可自定义,细节更饱满

  • release:压下
  • perform:释放

模拟用户行为的最佳实践

有一些网站会加入防爬虫机制,其中不排除可能包括Selenium。那么它们是根据什么判断的呢?只要知道这一点,我们是不是就可以针对性改造了呢?是的,其实黑科和信息安全人员之间的攻防战大概也是如此吧

网页标识

首先,是网站页面标识,Selenium操控的页面上会有一个navigator.webdriver属性,这是一些网站识别的依据,而事实上这个标志是谷歌浏览器故意的!
但如果只是这样的话,我们可以自定义监听器,在页面加载是时隐藏navigator.webdriver 属性

/**
 * CustomEventListener 自定义网页元素监听器
 *
 * @author Liang Zhaoyuan
 * @version 2024/02/07 21:58
 **/
@Slf4j
public class CustomEventListener extends AbstractWebDriverEventListener {
    /**
     * 模拟正常浏览器访问户行为,隐藏 navigator.webdriver 属性
     * @param url
     * @param driver
     */
    @Override
    public void afterNavigateTo(String url, WebDriver driver) {
        try {
            log.info("afterNavigateTo: {}", url);
            JavascriptExecutor jsExecutor = (JavascriptExecutor) driver;
            jsExecutor.executeScript("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})");
        } catch (Exception e) {
            log.warn("隐藏 navigator.webdriver 属性 出现异常: {}", e.getMessage());
            throw new RuntimeException(e);
        }
    }
}

当然了,定义的监听器会直接生效吗?不会,监听器要生效就必须给它绑定浏览器驱动对象,事实上我们应该在创建浏览器驱动对象的时候我们要定义增强版的EventFiringWebDriver对象:通过EventFiringWebDriver构造方法进行构造,类似装饰器模式,该对象拥有注册方法,把自定义的监听器注册进去即可

// 创建 ChromeDriver 实例
WebDriver originalDriver = new ChromeDriver(options);
driver = new EventFiringWebDriver(originalDriver);
// 注册自定义监听器
driver.register(new CustomEventListener());

启动参数优化

  • 不要使用无头模式
  • 设置合理的UA标识
  • 开启开发者模式
  • 不要开放远程调试
// 添加其他 ChromeOptions 设置
// options.addArguments("--headless"); // 注释无头模式,因为更容易被检测
options.addArguments("--disable-blink-features=AutomationControlled");//开发者模式可以减少一些网站对自动化脚本的检测。
options.addArguments("--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.150 Safari/537.36");

iframe网页嵌套解决方案

一些网站可能会使用到iframe标签嵌套网页,如果你用浏览器网页渲染看到的源码想要直接去定位,那必须告诉你这是个美好的想法,事实告诉你想都别想
每一个iframe嵌套的网页都相当于一个新的网页对象,我们要想操控iframe嵌套网页中的内容就必须先进入到对应的网页容器中,如图所示,若我们想进入iframe3内进行元素操作时,将进行如下操作(分为三个步骤):
image.png

比较好的解决方案就是把进入网站某一块iframe的操作封装起来,比如蓝奏云上传

    /**
     * 切换到上传页面iframe
     */
    private void switchUploadFrame() {
        // 建议:重新回到顶层操作元素(不易出错)
        browser.switchToDefaultContent();
        //// 进入一级iframe
        browser.switchToFrame(By.xpath("/html//iframe[@id='mainframe']"));
        //// 进入二级iframe
        browser.switchToFrame(By.xpath("/html//iframe[@id='mainframe']"));
    }

其中SwitchToFrame也是封装好的方法,他的底层是driver.switchTo().frame();

/**  
 * 切换到指定的Frame  
 * * @param by 定位选择器  
 * @return  
 */  
public boolean switchToFrame(By by) {  
    try {  
        WebElement FrameElement = getElement(by);  
        driver.switchTo().frame(FrameElement);  
        log.info("Frame切换成功");  
        return true;  
    } catch (Exception e) {  
        log.error("iframe切换失败,原因:%s", e.getMessage());  
        return false;  
    }  
}

而除了元素定位iframe,Selenium还提供了根据iframe序号等方法进入指定iframe
但是无论如何,记住:进去之后记得出来!

Selenium IDE辅助

这是Selenium官方提供的浏览器插件,能够自动录制我们的网页操作,并支持一键导出支持的Java、Python、js等语言脚本。通过它我们快速完成一系列操作的基础代码,后期我们再针对性微调即可。

上传、下载方案

Selenium是针对浏览器自动化,提供了大量操控网页的能力,但抱歉的是它并不提供下载管理的能力,因为浏览器下载弹窗属于系统级窗口,他没有操控系统窗体的能力。
解决方案无非两种:

  • 研究谷歌浏览器的API,编写下载接管的相关插件,给测试用浏览器装上对应插件,借助浏览器插件的能力并与其通信完成下载接管
  • 通过系统级别能力比如句柄操控系统窗体,完善点击行为的自动化,对于Java语言来说就是JNA方案
    我的rpa程序模版就是基于JNA操控句柄实现的下载,就简单记录下吧:
    Tip

    不得不提,Java在这一块的领域上的确比不上Python,Python就有相关的图形化句柄捕抓库,而Java没有。当然这一块的底层还是得依靠VB、C#这样的语言。下面提到的两款工具都是其他语言编写的

先通过一些可视化句柄工具获取到系统的上传和另存为窗口的句柄,比如:

Tip

其中JNAUtils是我自行封装好的JNA相关操作工具类,具体如何实现可以参考JNA

参考文件下载代码:

/**  
 * 寻找并等待title为 "另存为" 的浏览器窗体句柄,指定文件保存路径,点击保存  
 *  
 * @param savePath  
 * @param sceanTime  
 * @return boolean  
 */public static boolean specifyPathAndSaveFile(String savePath, int sceanTime) {  
    long startTime = System.currentTimeMillis();  
    //是否执行成功  
    final boolean[] saveed = {false};  
    //是否设置输入框内容失败  
    boolean setError = false;  
    final boolean[] runOver = {false};  
    WinDef.HWND handle = waitGetWinRootElement(null, "另存为", sceanTime);  
    if (handle != null) {  
        if (setWinEditValue(handle, savePath)) {  
            log.info("第一次文件路径写入成功...");  
        } else {  
            long countTime = System.currentTimeMillis();  
            WinDef.HWND edit = waitGetWinElementInDesktop("Edit", null, 5);  
            if (edit != null) {  
                JNAUtils.simulateClick(edit);  
                combinationClickKeys(KeyEvent.VK_CONTROL,KeyEvent.VK_A);  
                JNAUtils.simulateTextInput(edit, savePath);  
            }else{  
                setError = true;  
            }  
            while (true) {  
                sleep(10);  
                if (System.currentTimeMillis() - countTime > 5 * 1000) {  
                    setError = true;  
                    break;  
                }  
                String winHWNDValue = getWinHWNDValue(edit);  
                if (savePath.equals(winHWNDValue)) {  
                    setError = false;  
                    log.info("文件路径写入成功:" + winHWNDValue);  
                    break;  
                } else {  
                    log.info("文件路径写入失败:" + winHWNDValue);  
                }  
            }  
        }  
        sleep(500);  
        WinDef.HWND finalHandle = handle;  
        ThreadPool.getSingleExecutorService().execute(new Runnable() {  
            @Override  
            public void run() {  
                if (clickWinButton(finalHandle, "保存(&S)")) {  
                    saveed[0] = true;  
                    log.info("保存按钮点击成功...");  
                } else {  
                    //doOneClickEnterKey();  
                    keyEnter();  
                    log.info("保存按钮点击失败...触发回车键");  
                }  
                runOver[0] = true;  
            }  
        });  
        ThreadPool.getSingleExecutorService().execute(new Runnable() {  
            @Override  
            public void run() {  
                WinDef.HWND handle = waitGetWinRootElement("#32770", "确认另存为", 3);  
                if (handle != null) {  
                    if (clickWinButton(handle, "是(&Y)")) {  
                        saveed[0] = true;  
                        log.info("保存确认按钮点击成功...");  
                    } else {  
                        log.info("保存按钮点击失败...");  
                    }  
                    runOver[0] = true;  
                }  
            }  
        });  
    }  
  
    while (!(saveed[0] || runOver[0])) {  
        if (System.currentTimeMillis() - startTime > sceanTime * 1000) {  
            break;  
        }  
        sleep(50);  
    }  
  
    if (saveed[0] && !setError) {  
        System.out.println("保存完成,保存路径:" + savePath);  
        return true;  
    } else {  
        System.out.println("保存失败,保存路径:" + savePath);  
        return false;  
    }  
}

参考文件上传代码:

/**  
 * 文件上传 指定绝对路径  
 *  
 * @param uploadFilePath 文件绝对路径  
 * @param sceanTime      扫描时间  
 * @return boolean  
 */public static boolean uploadFileByPath(String uploadFilePath, int sceanTime) {  
    boolean saveed = false;  
    WinDef.HWND handle = waitGetWinRootElement(null, "打开", sceanTime);  
    if (handle != null) {  
        WinDef.HWND edit = JNAUtils.findHandleByClassName("Edit", 10, TimeUnit.SECONDS, null);  
        if (edit != null) {  
            if (JNAUtils.simulateTextInput(edit, uploadFilePath)) {  
                log.info("上传文件地址添加成功");  
            }else{  
                log.info("上传文件地址添加失败");  
            }  
            sleep(1000);  
        }  
        //WinDef.HWND finalHandle = handle;  
        WinDef.HWND saveButton = JNAUtils.findHandleByClassName("Button", 10, TimeUnit.SECONDS, "打开(&O)");  
        if (saveButton != null) {  
            JNAUtils.simulateClick(saveButton);  
        }  
        System.out.println("上传的文件路径为:" + uploadFilePath);  
        saveed = true;  
    }  
    return saveed;  
}