Selenium RemoteWebDriver 利用CDP修改User-Agent

  • 2020 年 5 月 23 日
  • 筆記

地球人都知道,如果使用selenium時要修改user-agent可以在啟動瀏覽器時添加配置項,如chromeOptions.addArguments("user-agent=xxx");。但是如何在每次請求的時候動態更改user-agent呢?

經過我的不懈努力,終於在網上找到一個相關的資訊使用python3和selenium4修改chrome的user-agent
這裡面提到了使用driver.execute_cdp_cmd來切換,這讓我了解了一下cdp命令。簡單的來說,cdp命令時chrome支援的一種基於websocket的協議,通過這個協議可以與瀏覽器內核通訊。平常使用的F12瀏覽器開發工具就是基於cdp的。cpd命令可以實現的功能很多,可以參考Chrome DevTools Protocol。再這裡面我找到了一個Network.setUserAgentOverride命令可以修改請求user-agent。
命令的參數如下:

但是,在我的項目中目前使用的selenium-java的版本是3.141.59,這個版本還沒用提供對於cdp命令的支援,前面資訊中提到了是在selenium4中使用使用的cdp命令。於是我又去maven倉庫搜索有沒有selenium4的jar包可以用。
在這裡插入圖片描述
這裡面已經有5個alpha測試的版本了,雖然還不是穩定版本,但是為了實現新功能先試一試,在maven中添加依賴:

<!-- //mvnrepository.com/artifact/org.seleniumhq.selenium/selenium-java -->
<dependency>
    <groupId>org.seleniumhq.selenium</groupId>
    <artifactId>selenium-java</artifactId>
    <version>4.0.0-alpha-5</version>
</dependency>

然後嘗試調用ChromeDriverexecuteCdpCommand方法
在這裡插入圖片描述
可以看到,第一個參數是commandName,對於修改user-agent的需求,這個地方應該填寫Network.setUserAgentOverride,後面是參數的鍵值對,現在只需要填寫userAgent就可以了,其他都是不需要的可選參數。

一般情況下,問題到這裡就解決了,但是在我的項目中卻沒有這麼簡單。因為各種原因,我的項目中使用的是Selenium-Server來提供瀏覽器環境的,也就是說,創建的都是RemoteWebDriver,雖然ChromeDriver是繼承自RemoteWebDriver的,但是cdp命令是chrome瀏覽器獨有的, 因此RemoteWebDriver也就沒有提供相關的支援了。那麼如何在確定RemoteWebDriver調用chrome瀏覽器的情況下提供cpd命令的支援呢?為了實現這個功能還是費了一些時間,因此在這裡把過程記錄下來。
首先看一下ChromeWebDriver是如何實現cdp命令的:

public Map<String, Object> executeCdpCommand(String commandName, Map<String, Object> parameters) {
    Objects.requireNonNull(commandName, "Command name must be set.");
    Objects.requireNonNull(parameters, "Parameters for command must be set.");
    Map<String, Object> toReturn = (Map)this.getExecuteMethod().execute("executeCdpCommand", ImmutableMap.of("cmd", commandName, "params", parameters));
    return ImmutableMap.copyOf(toReturn);
}

在Selenium4中,ChromeDriver繼承自ChromiumDriver,二者其實是一模一樣的。ChromiumDriver提供了cdp命令的支援,利用executeMethod運行命令executeCdpCommand,將要運行的具體命令和參數一併傳入。於是我又開始找這個ExecuteMethod是什麼東西,發現ChromiumWebDriver並沒有對這個參數進行任何設置,因此應該是在ChromiumDriver繼承的RemoteWebDriver來設置的。果然,在RemoteWebDriver中有this.executeMethod = new RemoteExecuteMethod(this);,在ChromiumWebDriver中獲取到的一定也是這個對象。那麼很容易想到,繼承一個RemoteWebDriver並編寫一個方法調用這個executeMethod不就行了嗎?

public class CdpRemoteWebDriver extends RemoteWebDriver {

    public CdpRemoteWebDriver(URL remoteAddress, Capabilities capabilities) {
        super(remoteAddress, capabilities);
    }

    public Map<String, Object> executeCdpCommand(String commandName, Map<String, Object> parameters) {
        Objects.requireNonNull(commandName, "Command name must be set.");
        Objects.requireNonNull(parameters, "Parameters for command must be set.");
        Map<String, Object> toReturn = (Map)this.getExecuteMethod().execute("executeCdpCommand", ImmutableMap.of("cmd", commandName, "params", parameters));
        return ImmutableMap.copyOf(toReturn);
    }
    
}

然後再創建CdpRemoteWebDriver實例,在訪問網頁之前設置user-agent

Map uaMap = new HashMap(){{
    put("userAgent", "customUserAgent");
}};
((CdpRemoteWebDriver) driver).executeCdpCommand("Network.setUserAgentOverride",
        uaMap
        );
driver.get(url);

運行試一下!

org.openqa.selenium.UnsupportedCommandException: executeCdpCommand
Build info: version: '4.0.0-alpha-5', revision: 'b3a0d621cc'
System info: host: 'DESKTOP-BM176Q1', ip: '192.168.137.1', os.name: 'Windows 10', os.arch: 'amd64', os.version: '10.0', java.version: '1.8.0_161'
Driver info: driver.version: CdpRemoteWebDriver
	at org.openqa.selenium.remote.codec.AbstractHttpCommandCodec.encode(AbstractHttpCommandCodec.java:246)
	at org.openqa.selenium.remote.codec.AbstractHttpCommandCodec.encode(AbstractHttpCommandCodec.java:129)
	at org.openqa.selenium.remote.HttpCommandExecutor.execute(HttpCommandExecutor.java:155)
	at org.openqa.selenium.remote.RemoteWebDriver.execute(RemoteWebDriver.java:582)
	at org.openqa.selenium.remote.RemoteWebDriver.execute(RemoteWebDriver.java:639)
	at org.openqa.selenium.remote.RemoteExecuteMethod.execute(RemoteExecuteMethod.java:36)
	at com.zju.edu.eagle.accessibilitycheck.a11ycheck.executor.impl.CdpRemoteWebDriver.executeCdpCommand(CdpRemoteWebDriver.java:24)

結果不行,說executeCdpCommand是不支援的命令,為什麼一樣的executeMethod結果不一樣呢?定位到錯誤的地方看一下

public HttpRequest encode(Command command) {
    String name = (String)this.aliases.getOrDefault(command.getName(), command.getName());
    AbstractHttpCommandCodec.CommandSpec spec = (AbstractHttpCommandCodec.CommandSpec)this.nameToSpec.get(name);
    if (spec == null) {
        throw new UnsupportedCommandException(command.getName());
    }
    ...
}

運行命令時,先從(AbstractHttpCommandCodec.CommandSpec)this.nameToSpec中獲取命令的相關資訊了,而要運行的executeCdpCommand沒有事先定義,所以就出現異常了。

public AbstractHttpCommandCodec() {
    this.defineCommand("status", get("/status"));
    this.defineCommand("getAllSessions", get("/sessions"));
    this.defineCommand("newSession", post("/session"));
    this.defineCommand("getCapabilities", get("/session/:sessionId"));
    ...
}

這些命令是在AbstractHttpCommandCodec中定義的,而executeCdpCommand不在其中。這說明雖然ChromeDriver和RemoteWebDriver有相同的executeMethod,但後續調用還是涉及到了不同的類,於是我又回頭查看ChromeDriver中的程式碼,發現有這樣一個構造函數

public ChromeDriver(ChromeDriverService service, Capabilities capabilities) {
    super(new ChromiumDriverCommandExecutor(service), capabilities, "goog:chromeOptions");
}

這裡面創建了一個ChromiumDriverCommandExecutor,再點進來看一下

static {
    CHROME_COMMAND_NAME_TO_URL.put("launchApp", new CommandInfo("/session/:sessionId/chromium/launch_app", HttpMethod.POST));
    CHROME_COMMAND_NAME_TO_URL.put("getNetworkConditions", new CommandInfo("/session/:sessionId/chromium/network_conditions", HttpMethod.GET));
    CHROME_COMMAND_NAME_TO_URL.put("setNetworkConditions", new CommandInfo("/session/:sessionId/chromium/network_conditions", HttpMethod.POST));
    CHROME_COMMAND_NAME_TO_URL.put("deleteNetworkConditions", new CommandInfo("/session/:sessionId/chromium/network_conditions", HttpMethod.DELETE));
    CHROME_COMMAND_NAME_TO_URL.put("executeCdpCommand", new CommandInfo("/session/:sessionId/goog/cdp/execute", HttpMethod.POST));
    CHROME_COMMAND_NAME_TO_URL.put("getCastSinks", new CommandInfo("/session/:sessionId/goog/cast/get_sinks", HttpMethod.GET));
    CHROME_COMMAND_NAME_TO_URL.put("selectCastSink", new CommandInfo("/session/:sessionId/goog/cast/set_sink_to_use", HttpMethod.POST));
    CHROME_COMMAND_NAME_TO_URL.put("startCastTabMirroring", new CommandInfo("/session/:sessionId/goog/cast/start_tab_mirroring", HttpMethod.POST));
    CHROME_COMMAND_NAME_TO_URL.put("getCastIssueMessage", new CommandInfo("/session/:sessionId/goog/cast/get_issue_message", HttpMethod.GET));
    CHROME_COMMAND_NAME_TO_URL.put("stopCasting", new CommandInfo("/session/:sessionId/goog/cast/stop_casting", HttpMethod.POST));
    CHROME_COMMAND_NAME_TO_URL.put("setPermission", new CommandInfo("/session/:sessionId/permissions", HttpMethod.POST));
}

可以看到這裡面也定義了一些命令,executeCdpCommand也在其中。而RemoteWebDriver沒有這個命令的資訊,自然也就無法執行了。經過進一步查看源碼,我發現ChromiumDriverCommandExecutorHttpCommandExecutor的子類,HttpCommandExecutor是RemoteWebDriver中真正的命令執行者。

ChromeWebDriver能夠提供自定義的CommandExecutor來增加額外命令,自然我們自己繼承的類也可以。在HttpCommandExecutor中有這樣一個構造函數HttpCommandExecutor(Map<String, CommandInfo> additionalCommands, URL addressOfRemoteServer),只要把添加的命令的鍵值對傳入,就可以支援額外的命令了。

最終版本的程式碼如下

public class CdpRemoteWebDriver extends RemoteWebDriver {

    private static final HashMap<String, CommandInfo> CHROME_COMMAND_NAME_TO_URL = new HashMap();

    public CdpRemoteWebDriver(URL remoteAddress, Capabilities capabilities) {
        super((CommandExecutor)(new HttpCommandExecutor(ImmutableMap.copyOf(CHROME_COMMAND_NAME_TO_URL), remoteAddress)), capabilities);
    }

    public Map<String, Object> executeCdpCommand(String commandName, Map<String, Object> parameters) {
        Objects.requireNonNull(commandName, "Command name must be set.");
        Objects.requireNonNull(parameters, "Parameters for command must be set.");
        Map<String, Object> toReturn = (Map)this.getExecuteMethod().execute("executeCdpCommand", ImmutableMap.of("cmd", commandName, "params", parameters));
        return ImmutableMap.copyOf(toReturn);
    }

    static {
        CHROME_COMMAND_NAME_TO_URL.put("launchApp", new CommandInfo("/session/:sessionId/chromium/launch_app", HttpMethod.POST));
        CHROME_COMMAND_NAME_TO_URL.put("getNetworkConditions", new CommandInfo("/session/:sessionId/chromium/network_conditions", HttpMethod.GET));
        CHROME_COMMAND_NAME_TO_URL.put("setNetworkConditions", new CommandInfo("/session/:sessionId/chromium/network_conditions", HttpMethod.POST));
        CHROME_COMMAND_NAME_TO_URL.put("deleteNetworkConditions", new CommandInfo("/session/:sessionId/chromium/network_conditions", HttpMethod.DELETE));
        CHROME_COMMAND_NAME_TO_URL.put("executeCdpCommand", new CommandInfo("/session/:sessionId/goog/cdp/execute", HttpMethod.POST));
        CHROME_COMMAND_NAME_TO_URL.put("getCastSinks", new CommandInfo("/session/:sessionId/goog/cast/get_sinks", HttpMethod.GET));
        CHROME_COMMAND_NAME_TO_URL.put("selectCastSink", new CommandInfo("/session/:sessionId/goog/cast/set_sink_to_use", HttpMethod.POST));
        CHROME_COMMAND_NAME_TO_URL.put("startCastTabMirroring", new CommandInfo("/session/:sessionId/goog/cast/start_tab_mirroring", HttpMethod.POST));
        CHROME_COMMAND_NAME_TO_URL.put("getCastIssueMessage", new CommandInfo("/session/:sessionId/goog/cast/get_issue_message", HttpMethod.GET));
        CHROME_COMMAND_NAME_TO_URL.put("stopCasting", new CommandInfo("/session/:sessionId/goog/cast/stop_casting", HttpMethod.POST));
        CHROME_COMMAND_NAME_TO_URL.put("setPermission", new CommandInfo("/session/:sessionId/permissions", HttpMethod.POST));
    }
    
}

再測試一下效果
在這裡插入圖片描述
可以看到user-agent已經被成功替換了。

總結一下解決問題的流程

  1. 繼承RemoteWebDriver類
  2. 參考ChromeDriver實現executeCdpCommand方法
  3. 參考ChromeDriver創建自定義的commandExecutor增加命令

事實上,因為cdp命令是chrome瀏覽器提供的支援,與selenium無關,在selenium4中只是內置了這個命令的參數和地址,調用的原理與原來支援的方法是一樣的。在自己實現的CdpRemoteWebDriver中已經自己添加了參數,並不需要將依賴升級到4.0.0就可以調用cdp命令了。