1 はじめに
本家のSeleniumではWebDriver BiDi の開発が現在進行中で、次期Selenium5から正式リリースとのことですが、Chromeではすでに対応済でSelenium4でも利用可能となっています。
2025年7月11日現在のSeleniumVBAでは未実装となっていますが、WebDriver BiDiはWebSocket通信により双方向通信が可能となり、私が待ち望んでいたイベント検知ができますので、以下のサイトを参考にチャレンジしてみました。
参考記事ではVBAを使ってWebSocket通信によりCDP接続を行っていますが、これをWebDriver BiDi接続に切り替えて実験したところ期待どおりのものとなり、WebDriver BiDiの実装は技術的に可能です。
また、同様な記事をnoteにも掲載していますが、SeleniumVBAへの実装手順の説明はこの記事のみとなります。そのサンプルファイルもnoteに掲載していますので、あわせて御参照ください。
2 SeleniumVBAへの実装手順
(1) VBAからWebSocket通信を利用するために必要なWindowsAPI群が格納されたクラスモジュール(先頭にBiDiがついた3つのクラスモジュール)をSeleniumVBAにインポートする。
(2) 標準モジュールで、StartEdge(StartChrome)のあとに以下の記述を入れる。この記述によりWebDriver Bidiが有効に動作する。
caps.SetCapability "webSocketUrl", True
(3) クラスモジュール「WebDriver」内に以下のコードを追加して、必要な情報を変数に格納して設定や取得ができるようにする。
'宣言セクション内に追加
Private id_ As Long
Private context_ As String
Private webSocketUrl_ As String
'「Public Sub OpenBrowser」内に追加
webSocketUrl_ = resp("capabilities")("webSocketUrl")
'以下に設定や取得のコードを追加
Public Property Get GetWebSocketUrl() As String
GetWebSocketUrl = webSocketUrl_
End Property
Public Property Get GetLocalPort() As String
GetLocalPort = Right(driverUrl_, 4)
End Property
Public Property Let LetId(ByVal id As Long)
id_ = id
End Property
Public Property Get GetId() As Long
GetId = id_
End Property
Public Property Let Letcontext(ByVal context As String)
context_ = context
End Property
Public Property Get Getcontext() As String
Getcontext = context_
End Property
【以下標準モジュール】
(4) 以下のコードを実行するとクラスモジュールのコンストラクタでWebSocket通信にアップグレードされる。
Set socket = New BiDiSocketCommunicator
(5) BiDiコマンドでイベント検知をサブスクライブする(idは識別できる一意の数値)。
sendmsg = "{""id"":" & id_ & ",""method"":""session.subscribe"",""params"":{""events"":[""network.beforeRequestSent"",""network.responseCompleted""]}}"
(6) BiDiコマンドでコンテキストIDを取得する。
sendmsg = "{""id"":" & id_ & ",""method"":""browsingContext.getTree"",""params"":{}}"
.Letcontext = WebJsonConverter.ParseJson(socket.SendAndReceive(sendmsg))("result")("contexts")(1)("context")
(7) BiDiコマンドで指定したURLに遷移する。
sendmsg = "{""id"":" & id_ & ",""method"":""browsingContext.navigate"",""params"":{""context"":""" & .Getcontext & """,""url"": """ & URL & """,""wait"":""complete""}}"
socket.SendAndReceive sendmsg, EventName, 1500
(8) まとめ。標準モジュール全体のコードは以下のとおり。
'【メインルーチン】
Public Sub Main()
Dim sendmsg As String
Dim strRes As String
Set driver = SeleniumVBA.New_WebDriver
With driver
'開始する
.StartChrome
'ブラウザの起動時の設定(Chrome/Edge兼用)
Dim caps As SeleniumVBA.WebCapabilities: Set caps = .CreateCapabilities
'/ブラウザのPDFビューアは使わない
caps.SetPreference "plugins.always_open_pdf_externally", True
'/Edgeで案内メッセージを出さない
'caps.AddArguments "--guest"
'/最大化して開く
caps.AddArguments "--start-maximized"
'/拡張機能は使わない
caps.AddArguments "--disable-extensions"
'/Chromeから出るブラウザからの邪魔な案内メッセージを出さない
caps.AddArguments "--propagate-iph-for-testing"
'ページ読み込みをDOMに限定(重いサイトを開く場合)
'caps.SetPageLoadStrategy svbaEager
'自動ソフトウェアによって制御されていますを出さない
'caps.SetOption "excludeSwitches", Array("enable-automation")
'==========================================
'BiDiを有効にする(このプログラムではTrue必須)
caps.SetCapability "webSocketUrl", True
'==========================================
'開く
.OpenBrowser caps
'キーの設定(使わないなら削除可)
Dim keys As SeleniumVBA.WebKeyboard: Set keys = SeleniumVBA.New_WebKeyboard
'アクションチェーンの設定(使わないなら削除可)
Dim actions As SeleniumVBA.WebActionChain: Set actions = .ActionChain
'暗黙的待機時間の最大値を設定(5秒)
.ImplicitMaxWait = 5000
'ページ読込待機時間の最大値を設定(10秒)。
'.PageLoadTimeout = 10000
'==========================================
'BiDiコマンド
'==========================================
id_ = .GetId
Dim socket As BiDiSocketCommunicator: Set socket = New BiDiSocketCommunicator
'BiDiコマンドでイベント検知をサブスクライブ
sendmsg = "{""id"":" & id_ & ",""method"":""session.subscribe"",""params"":{""events"":[""network.beforeRequestSent"",""network.responseCompleted""]}}"
'sendmsg = "{""id"":" & id_ & ",""method"":""session.subscribe"",""params"":{""events"":[""browsingContext"",""network"",""script""]}}"
'sendmsg = "{""id"":" & ID_ & ",""method"":""session.subscribe"",""params"":{""events"":[""browsingContext"",""network"",""log"",""script""]}}"
.LetId = id_ + 1
socket.SendAndReceive sendmsg
'BiDiコマンドでコンテキストIDを取得
id_ = .GetId
sendmsg = "{""id"":" & id_ & ",""method"":""browsingContext.getTree"",""params"":{}}"
.LetId = id_ + 1
.Letcontext = WebJsonConverter.ParseJson(socket.SendAndReceive(sendmsg))("result")("contexts")(1)("context")
'BiDiコマンドでnoteの小説カテゴリページ(ページが動的に読み込まれる)に遷移
Dim URL As String: URL = "https://note.com/topic/novel"
Dim EventName As String: EventName = "network.responseCompleted"
id_ = .GetId
sendmsg = "{""id"":" & id_ & ",""method"":""browsingContext.navigate"",""params"":{""context"":""" & .Getcontext & """,""url"": """ & URL & """,""wait"":""complete""}}"
.LetId = id_ + 1
socket.SendAndReceive sendmsg, EventName, 1500 '第3引数はイベント名、第4引数は待機時間
'==========================================
'通常コマンド
'==========================================
Dim elms_title1 As WebElements '記事の要素のリスト
Dim elms_title2 As WebElements '記事の要素のリスト(待機後)
'【1回目】FindElementsで記事数検索
Set elms_title1 = .FindElements(By.CssSelector, ".a-link.m-largeNoteWrapper__link.fn")
'4秒待機
.Wait 4000
'【2回目】FindElementsで記事数検索
Set elms_title2 = .FindElements(By.CssSelector, ".a-link.m-largeNoteWrapper__link.fn")
'【ページ読込完了の検証】BiDiではcompleteになるまで待機するが、その後に動的読み込みするページには対応できなため、completeを自前で検知して待機処理を行う
If elms_title1.Count <> elms_title2.Count Then
MsgBox EventName & "になるまで待機しましたが" & Chr(10) & "・当初検索記事数" & elms_title1.Count & Chr(10) & "・4秒後検索記事数" & elms_title2.Count & Chr(10) & "のため待機時間が不十分です"
Stop 'イミディエイトウインドウを参照
Else
MsgBox EventName & "になるまで待機しましたが" & Chr(10) & "・当初検索記事数" & elms_title1.Count & Chr(10) & "・4秒後検索記事数" & elms_title2.Count & Chr(10) & "のため期待どおり待機しています。"
Stop 'イミディエイトウインドウを参照
End If
'終了
'.CloseBrowser
.ExecuteCDP "Browser.close"
.Shutdown
End With
End Sub
3 作業した際の所感
(1)動的ページの読込完了待機
上記コードでは、動的ページの読込完了待機の実験をしていますが、動的ページの読込完了を検知できるイベントは見つかりませんでした。
※browsingContext.Loadは(2)のとおり動的ページの検知は不可。
その代替として、「network.beforeRequestSent」のリクエストIDと「network.responseCompleted」のリスエストIDを照合して、すべてのリクエストが完了した時点でページ読込完了と判断して検知しています。
しかし、動的ページの場合は、すべてのリクエスト完了後も「network.beforeRequestSent」が発生することがあるため、リクエスト完了後に指定した秒数をアイドリングして待機するようにしました。
具体的には、リクエスト数が0になっても終了しないで指定ミリ秒分アイドリングしてから終了するようにし、ミリ秒数はSendAndReceiveメソッドで設定できるようにしてあります。
※後日、以下の記事とやっていることが同じであることが判明しました。
設定秒数は長ければ安定しますが、処理スピードとの兼ね合いで実際に動かしながら、決めていく形になるかと思います。
(2)browsingContext.Loadが利用できない理由
リクエスト数が一旦0になった時点で、browsingContext.Loadが発行され、処理が終了してしまうためです。そのため、待機時間設定の対策を行う必要があります。
(3)AIに指示するにあたって
VBAのWebSocketの仕様により、メッセージの受信がないときにWinHttpWebSocketReceiveを行うとハングアップしてしまいますので、レスポンスはイベントの受信後に行うよう、JavaScriptの処理順を指示しました。
(4)JSON形式からの文字列の取り出し
正規表現でも可能ですが、保守性を考えるとSeleniumVBAのクラスモジュール「WebJsonConverter」(@Tim Hall @GCuser99)を利用が圧倒的に便利でしたので、ツールの利用をおすすめします。
4 AI生成によるBiDiコマンド
AIにBiDiのscript.callFunctionメソッドでJavaScriptによるコマンドを生成させると、実用性のあるコードが生成されましたので、その箇所を掲載します。なお、意図した動作はしていますが、解読作業が未了なので今後検証していきます。AI作成のサンプルファイルはこちらにあります。
(1)動的ページの読込完了待機
上記3と同じ内容のものをAIが作成したものです。
'==========================================
'BiDiコマンド
'==========================================
Dim socket As BiDiSocketCommunicator: Set socket = New BiDiSocketCommunicator
Dim safetyMarginMs As Long: safetyMarginMs = 1500 ' 安全マージン(ミリ秒)
' 1. コンテキストIDを取得
id_ = .GetId
sendmsg = "{""id"":" & id_ & ",""method"":""browsingContext.getTree"",""params"":{}}"
.LetId = id_ + 1
.Letcontext = WebJsonConverter.ParseJson(socket.SendAndReceive(sendmsg))("result")("contexts")(1)("context")
' 2. BiDiコマンドでイベント検知をサブスクライブ
id_ = .GetId
sendmsg = "{""id"":" & id_ & ",""method"":""session.subscribe"",""params"":{""events"":[""network.beforeRequestSent"",""network.responseCompleted""]}}"
'sendmsg = "{""id"":" & id_ & ",""method"":""session.subscribe"",""params"":{""events"":[""browsingContext"",""network"",""script""]}}"
'sendmsg = "{""id"":" & ID_ & ",""method"":""session.subscribe"",""params"":{""events"":[""browsingContext"",""network"",""log"",""script""]}}"
.LetId = id_ + 1
socket.SendAndReceive sendmsg
' 3. ページに遷移
Dim URL As String: URL = "https://note.com/topic/novel"
id_ = .GetId
sendmsg = "{""id"":" & id_ & ",""method"":""browsingContext.navigate"",""params"":{""context"":""" & .Getcontext & """,""url"": """ & URL & """,""wait"":""complete""}}"
.LetId = id_ + 1
socket.SendAndReceive sendmsg
' 4. レルムIdを取得
id_ = .GetId
sendmsg = "{""id"":" & id_ & ",""method"":""script.getRealms"",""params"":{""context"":""" & .Getcontext & """}}"
.LetId = id_ + 1
Dim realmId As String
realmId = WebJsonConverter.ParseJson(socket.SendAndReceive(sendmsg))("result")("realms")(1)("realm")
' 5. 動的読み込み完了を待機するJavaScript関数を実行(AIに任せたので解読不能)
id_ = .GetId
sendmsg = "{""id"":" & id_ & ", ""method"":""script.callFunction"", ""params"":{""functionDeclaration"":""function(waitTimeMs) { "
sendmsg = sendmsg & "return new Promise((resolve) => { "
sendmsg = sendmsg & "let pendingRequests = 0; "
sendmsg = sendmsg & "let lastActivityTime = Date.now(); "
sendmsg = sendmsg & "let timeoutId = null; "
sendmsg = sendmsg & "let isCompleted = false; "
sendmsg = sendmsg & "function checkCompletion() { "
sendmsg = sendmsg & "if (isCompleted) return; "
sendmsg = sendmsg & "if (pendingRequests === 0) { "
sendmsg = sendmsg & "clearTimeout(timeoutId); "
sendmsg = sendmsg & "timeoutId = setTimeout(() => { "
sendmsg = sendmsg & "if (pendingRequests === 0 && !isCompleted) { "
sendmsg = sendmsg & "isCompleted = true; "
sendmsg = sendmsg & "resolve('page_load_completed'); "
sendmsg = sendmsg & "} "
sendmsg = sendmsg & "}, waitTimeMs); "
sendmsg = sendmsg & "} "
sendmsg = sendmsg & "} "
sendmsg = sendmsg & "const originalFetch = window.fetch; "
sendmsg = sendmsg & "window.fetch = function(url, options) { "
sendmsg = sendmsg & "pendingRequests++; "
sendmsg = sendmsg & "lastActivityTime = Date.now(); "
sendmsg = sendmsg & "return originalFetch(url, options).finally(() => { "
sendmsg = sendmsg & "pendingRequests--; "
sendmsg = sendmsg & "lastActivityTime = Date.now(); "
sendmsg = sendmsg & "checkCompletion(); "
sendmsg = sendmsg & "}); "
sendmsg = sendmsg & "}; "
sendmsg = sendmsg & "const originalOpen = XMLHttpRequest.prototype.open; "
sendmsg = sendmsg & "XMLHttpRequest.prototype.open = function() { "
sendmsg = sendmsg & "pendingRequests++; "
sendmsg = sendmsg & "lastActivityTime = Date.now(); "
sendmsg = sendmsg & "this.addEventListener('loadend', () => { "
sendmsg = sendmsg & "pendingRequests--; "
sendmsg = sendmsg & "lastActivityTime = Date.now(); "
sendmsg = sendmsg & "checkCompletion(); "
sendmsg = sendmsg & "}); "
sendmsg = sendmsg & "return originalOpen.apply(this, arguments); "
sendmsg = sendmsg & "}; "
sendmsg = sendmsg & "const observer = new MutationObserver(() => { "
sendmsg = sendmsg & "lastActivityTime = Date.now(); "
sendmsg = sendmsg & "}); "
sendmsg = sendmsg & "observer.observe(document.body, { "
sendmsg = sendmsg & "childList: true, "
sendmsg = sendmsg & "subtree: true, "
sendmsg = sendmsg & "attributes: true "
sendmsg = sendmsg & "}); "
sendmsg = sendmsg & "setTimeout(() => { "
sendmsg = sendmsg & "observer.disconnect(); "
sendmsg = sendmsg & "checkCompletion(); "
sendmsg = sendmsg & "}, 100); "
sendmsg = sendmsg & "}); "
sendmsg = sendmsg & "}"", ""arguments"":[{""type"":""number"",""value"":" & safetyMarginMs & "}], ""target"":{""realm"":""" & realmId & """}, ""awaitPromise"":true}}"
.LetId = id_ + 1
' 5. 実行
socket.SendAndReceive sendmsg
(2)セレクトボックス選択後の非同期のイベント完了まで待機
WebDriver BiDiに一番期待していたのがこの処理です。業務システムにおいてセレクトボックスを選択すると料金が自動計算される非同期のイベントが発生するため対応に苦慮していました。未検証ですがAjaxを利用したサンプルサイトを題材にAIに生成させましたら意図したとおりに動作しています。
'==========================================
'BiDiコマンド
'==========================================
Dim socket As BiDiSocketCommunicator: Set socket = New BiDiSocketCommunicator
'BiDiコマンドでイベント検知をサブスクライブ
id_ = .GetId
'sendmsg = "{""id"":" & id_ & ",""method"":""session.subscribe"",""params"":{""events"":[""network.beforeRequestSent"",""network.responseCompleted""]}}"
sendmsg = "{""id"":" & id_ & ",""method"":""session.subscribe"",""params"":{""events"":[""browsingContext"",""network"",""script""]}}"
'sendmsg = "{""id"":" & ID_ & ",""method"":""session.subscribe"",""params"":{""events"":[""browsingContext"",""network"",""log"",""script""]}}"
.LetId = id_ + 1
socket.SendAndReceive sendmsg
'BiDiコマンドでコンテキストIDとスクリプトレルムIDを取得
id_ = .GetId
sendmsg = "{""id"":" & id_ & ",""method"":""browsingContext.getTree"",""params"":{}}"
.LetId = id_ + 1
.Letcontext = WebJsonConverter.ParseJson(socket.SendAndReceive(sendmsg))("result")("contexts")(1)("context")
'BiDiコマンドでページに遷移
Dim URL As String: URL = "http://keylopment.com/faq/2357/?date=20250701"
Dim EventName As String: EventName = "network.responseCompleted"
id_ = .GetId
sendmsg = "{""id"":" & id_ & ",""method"":""browsingContext.navigate"",""params"":{""context"":""" & .Getcontext & """,""url"": """ & URL & """,""wait"":""complete""}}"
.LetId = id_ + 1
socket.SendAndReceive sendmsg
'==========================================
'BiDiコマンドでAjax完了待機
'==========================================
Dim sharedId As String
Dim response As String
id_ = .GetId
Dim strJS As String
'XPathに日本語が含まれるとエラーになるが原因不明
Dim valueToSet As String
valueToSet = "20251101" ' ← "2025年11月に対応する value
' browsingContext.locateNodesでXPathでoselectタグのsharedIdを取得
Dim strXpath As String
strXpath = "//select[@name='calselect']"
sendmsg = "{""id"":" & id_ & ",""method"":""browsingContext.locateNodes"",""params"":{""context"":""" & .Getcontext & """,""locator"":{""type"":""xpath"",""value"":""" & strXpath & """}}}"
response = socket.SendAndReceive(sendmsg)
.LetId = id_ + 1
sharedId = WebJsonConverter.ParseJson(response)("result")("nodes")(1)("sharedId")
'レルムIDを取得する
Dim realmId As String
id_ = .GetId
sendmsg = "{""id"":" & id_ & ",""method"":""script.getRealms"",""params"":{""context"":""" & .Getcontext & """}}"
.LetId = id_ + 1
realmId = WebJsonConverter.ParseJson(socket.SendAndReceive(sendmsg))("result")("realms")(1)("realm")
' sharedId とレルムIdを使ってイベント検知も含めてselectタグのvalue値を選択する(AI生成)
'選択後イベントを起こし、イベント完了まで待機する
id_ = .GetId
'AI生成箇所
sendmsg = "{""id"":" & id_
sendmsg = sendmsg & ", ""method"":""script.callFunction"""
sendmsg = sendmsg & ", ""params"":{""functionDeclaration"":""function(select, targetValue) { "
sendmsg = sendmsg & "return new Promise((resolve) => { "
sendmsg = sendmsg & "let requestCount = 0; "
sendmsg = sendmsg & "let eventProcessed = false; "
sendmsg = sendmsg & "const originalFetch = window.fetch; "
sendmsg = sendmsg & "window.fetch = function(url, options) { "
sendmsg = sendmsg & "requestCount++; "
sendmsg = sendmsg & "return originalFetch(url, options).finally(() => { "
sendmsg = sendmsg & "requestCount--; "
sendmsg = sendmsg & "checkCompletion(); "
sendmsg = sendmsg & "}); "
sendmsg = sendmsg & "}; "
sendmsg = sendmsg & "function checkCompletion() { "
sendmsg = sendmsg & "if (eventProcessed && requestCount === 0) { "
sendmsg = sendmsg & "setTimeout(() => resolve('completed'), 200); "
sendmsg = sendmsg & "} "
sendmsg = sendmsg & "} "
sendmsg = sendmsg & "select.value = targetValue; "
sendmsg = sendmsg & "select.dispatchEvent(new Event('change', { bubbles: true })); "
sendmsg = sendmsg & "setTimeout(() => { "
sendmsg = sendmsg & "eventProcessed = true; "
sendmsg = sendmsg & "checkCompletion(); "
sendmsg = sendmsg & "}, 100); "
sendmsg = sendmsg & "}); "
sendmsg = sendmsg & "}"", ""arguments"":[{""sharedId"":""" & sharedId & """}, {""type"":""string"",""value"":""" & valueToSet & """}]"
sendmsg = sendmsg & ", ""target"":{""realm"":""" & realmId & """}, ""awaitPromise"":true}}"
.LetId = id_ + 1
socket.SendAndReceive sendmsg
5 起動済ブラウザからの操作
以下のコードにより実験したところ、起動済EdgeからでもWebDriver BiDIを有効にでき、イベントが検知できました。
'=================================================
'リモートデバッグによりログイン状態のブラウザを自動操作する。
'ショートカットにより、すでに立ち上げた状態から始める。
'=================================================
'ショートカットのリンク先は以下に設定
'"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" --remote-debugging-port=9222 --user-data-dir="C:\EdgeDebugProfile"
'=================================================
Dim caps As SeleniumVBA.WebCapabilities
Set driver = SeleniumVBA.New_WebDriver
driver.StartEdge
Set caps = driver.CreateCapabilities(initializeFromSettingsFile:=False)
caps.SetDebuggerAddress "localhost:9222"
'BiDiを有効にする(このプログラムではTrue必須)
caps.SetCapability "webSocketUrl", True
driver.OpenBrowser caps
'(以下つづく)
6 おわりに
VBA経由でのWebDriver BiDiのWebSocket通信に関する記事が見当たらず、kabkabkab様の記事を頼りに右往左往で進めてきたのが実態で、至らない部分が多く改良の余地は大いにあります。
実験用ファイルでは、BiDiコマンドがJSON形式であったりと使い勝手が悪く課題は残っていますが、VBA経由のBiDi利用への展望が開けてきたのは確かだと思います。
最後になりましたが、WebSocket通信の設定にあたり、kabkabkab様の懇切丁寧な解説を交えた記事に大いに助けられました。お礼を申し上げます。