見出し画像

Edge標準モード自動化のためのTips集

先日紹介したVBAからEdge標準モード自動操作の活用例を紹介します。
VBAで、CDP(Chrome Devtools Protocol)を介してブラウザ自動化をどう進めれば良いか、ヒントになれば幸いです。

前提として、先日の記事のコード(サブルーチン部)を使います。
以下に出てくる「WebSocket_Submit」は、サブルーチン部にあるWebSocket_Submitファンクションプロシージャを呼び出すものとします。また以下に出てくる「ID」は操作対象のWebページを指すIDとします。ID取得過程は今回の本題でないため割愛します。詳しくは先日記事を参照のこと。
今回は、「〇〇したいときはこう書く」的な活用例(Tips集)です。



ページ全般

●ページの移動

'Googleページへ移動
Dim CDP_Command As String
Dim response As String

CDP_Command = "{""id"":1, " & _
  """method"":""Page.navigate"", " & _
  """params"":{""url"":""https://www.google.co.jp/""}}"
response = WebSocket_Submit(CDP_Command, ID)
Debug.Print "ページ移動完了"

CDPのPage.Navigateコマンドを使い、URL指定してページ移動します。
このコマンドを「WebSocket_Submit」ファンクションに渡すことでページ移動します。
「WebSocket_Submit」ファンクション内はブロッキングモードでソケット接続するように作ってあるので、1つの処理が完了するまで次に進みません。このPage.Navigateコマンドを1つ実行すると、コマンド実行(ページ移動移動処理)が完了するまでは自動的に処理がストップします。

●ページを閉じる

'ページを閉じる
Dim CDP_Command As String
Dim response As String

CDP_Command = "{""id"":1, " & _
  """method"":""Page.close""}"
response = WebSocket_Submit(CDP_Command, ID)
Debug.Print "ページ(タブ)を閉じました"

CDPのPage.Closeコマンドを使い、対象ページ(タブ)を閉じます。


ページ移動後の処理

●ページの読み込み待機

ページ移動は完了したものの、移動先のページが重くてロードに時間が掛かるような場合や、何度もコンテンツを読込みする仕様であった場合は、待機処理のコードを書く必要があります。
この場合、ページ移動ではなくページ読込み後のイベントに着目して待機処理を書きます。

'ページ読み込み待機(Page.loadEventFiredイベントが発生するまで待機)
Dim CDP_Command As String
Dim EventName as String
Dim response As String

CDP_Command = "{""id"":1, " & _
  """method"":""Page.enable""}"
EventName = "Page.loadEventFired"
response = WebSocket_Submit(CDP_Command, ID, EventName)
If InStr(response, EventName) > 0 Then
 Debug.Print EventName & "イベントを検知しました"
End If

CDPのPage.Enableコマンドを使い、対象ページで発生するイベントを逐一検出できる状態にしてやります。
ページがブラウザによって完全に読み込まれたときに発生する「Page.loadEventFired」イベントが検出されるまで、イベントをリッスンし続ける(=処理待機し続ける)イメージです。ここでは、CDPのPage.Enableコマンドを使ってページイベントを読み取れる状態にしたうえで、それぞれのイベントの中身をチェックして「Page.loadEventFired」イベントかどうかを確認しています。
第三引数付きで「WebSocket_Submit」を呼び出したときだけ、ノンブロッキングモードでソケット接続するように、サブルーチン処理を作りこみました。なので、第三引数として渡したイベント名が検出されるまで、処理を待機させるようにしています。
※もちろん無限ループを回避するため、タイムアウト時間を設けてあります。

●HTTPリクエスト完了まで待機

'ネットワーク読み込み待機(Network.loadingFinishedイベントが発生するまで待機)
Dim CDP_Command As String
Dim EventName as String
Dim response As String

CDP_Command = "{""id"":1, " & _
  """method"":""Network.enable""}"
EventName = "Network.loadingFinished"
response = WebSocket_Submit(CDP_Command, ID, EventName)
If InStr(response, EventName) > 0 Then
 Debug.Print EventName & "イベントを検知しました"
End If

CDPのNetwork.loadingFinishedコマンドを使い、HTTPリクエストの読込みが完了するまで待機します。
どんな発生イベントを検知するべきかは、自動化の内容や対象Webページによって変わります。開発者ツールのプロトコルモニターを使ってログを取り、発生イベントを細かく追っていくことで判断するのが良いと思います。
※詳細は後述。

●移動後のページ内容によって処理を変える(条件分岐)

ページ移動後にどんな内容が表示されるか分からない時(例えば、目的ページの前に何らかの認証ページに阻まれる可能性があるケース)って、結構あると思うんですよね。そんなときはコレ。

~ここにページ移動の処理を記載~

'移動後のページ内容によって処理を変える(条件分岐)
Dim CDP_Command As String
Dim response As String
Dim TimeOutCnt As Integer
response = ""
Do
 CDP_Command = "{""id"":1, " & _
   """method"":""Target.getTargets""}"
 response = WebSocket_Submit(CDP_Command, ID)
 TimeOutCnt = TimeOutCnt + 1
 If TimeOutCnt > 10 Then Exit Do
 Sleep 1000
Loop Until response <> ""
Debug.Print "取得した情報は:" & response

If InStr(response, "http://Main/***") > 0 Then
 ~ここに認証ページが現れなかった場合の処理を書く~
ElseIf InStr(response, "http://Authentication***/***") > 0 Then
 ~ここに認証ページが現れた場合の処理を書く~
Else
 Debug.Print "想定外エラー:" & response
 End
End If

まず、CDPのTarget.getTargetsコマンドを使って、対象ページの情報を取得します。取得できる情報は読み込まれたページのURL、キャプション名など。
どんな内容が読み込まれるか分からなくても、いくつか可能性を想定しておくこと位はできると思います。例えば、可能性の1つとして認証ページ(URL:http://Authentication***/***)が現れる想定であれば、上記のようにレスポンス内容に応じて条件分岐しておけば良いです。

●フレームの読み込み待機

'フレーム読み込み待機(Page.frameAttachedイベントが発生するまで待機)
Dim CDP_Command As String
Dim EventName as String
Dim response As String

CDP_Command = "{""id"":1, " & _
  """method"":""Page.enable""}"
EventName = "Page.frameAttached"
response = WebSocket_Submit(CDP_Command, ID, EventName)
If InStr(response, EventName) > 0 Then
 Debug.Print EventName & "イベントを検知しました"
End If

<iframe>タグ内の要素へアクセスしたいときには、このイベント発生をリッスンするのが最適だったりします。



新しいウィンドウ・タブが開かれたとき

●新しく開かれたウィンドウ・タブを操作する

~新しいウィンドウ・タブが開かれる処理をここに書く~

'新しく開かれたウィンドウ・タブを操作する
Dim CDP_Command As String
Dim response As String
Dim TimeOutCnt As Integer
CDP_Command = "{""id"":1, " & _
  """method"":""Target.getTargets""}"
response = WebSocket_Submit(CDP_Command, ID)

Dim reg As Object
Dim TabIDs() As String
Dim NewID As String
Dim nEnd As Long
Dim TrimText As String
Set reg = CreateObject("VBScript.RegExp")
With reg
 .Pattern = """targetId"": ""[^""]+"""
 .IgnoreCase = True '大文字小文字を区別しない
 .Global = True '全体を検索
 ReDim TabIDs(.Execute(response).Count - 1)
 For i = 0 To .Execute(response).Count - 1
  TabIDs(i) = Mid(response, .Execute(response)(i).firstindex + 13, .Execute(response)(i).length - 13)
  nEnd = InStr(.Execute(response)(i).firstindex, response, "}")
  TrimText = Mid(response, .Execute(response)(i).firstindex, nEnd - .Execute(response)(i).firstindex)
  If InStr(TrimText, ""page"") > 0 And ID <> TabIDs(i) Then
   NewID = TabIDs(i)
  End If
 Next i
End With
Set reg = Nothing
Debug.Print "NewID= " & NewID

新しく開かれたウィンドウ・タブの方を操作したいときは、まず新たにページIDを取得する必要があります。アタッチ可能なページ一覧(リモートデバッグポートが有効な状態で開かれているEdgeのページ一覧)を、CDPのTarget.getTargetsコマンドを使って取得します。
取得できたら、type=pageとなっている項目のid値を抽出します。旧ページのID(変数ID)と新ページのID(変数NewID)の2つ存在している状況においては、「If ID <> TabIDs(i) Then」に一致するIDが新しいページのIDです。
新ページのID(変数NewID)が取得できたら、以降はこの変数NewIDを使ってCDPコマンドを送信すればOKです。

●古いウィンドウ・タブを閉じる

~新しいウィンドウ・タブが開かれる処理をここに書く~

~新しいウィンドウ・タブのページIDを取得する処理をここに書く~

'古いウィンドウ・タブを閉じる
Dim CDP_Command As String
Dim response As String

CDP_Command = "{""id"":1, " & _
  """method"":""Page.close""}"
response = WebSocket_Submit(CDP_Command, ID)
Debug.Print "古いウィンドウ・タブを閉じました"

~以後は、古いページIDは使わずに、新しいページIDを使ってCDPコマンドを実行~

古いウィンドウ・タブを閉じるときは、単純にCDPのPage.closeコマンドを古いページIDに対して実行すればOKです。
一度ページを閉じたら古いページIDは使えなくなるので、以降は新しいページIDを使ってCDPコマンドを実行していってください。


JavaScriptの実行

●任意のJavaScriptの実行①

'JavaScriptの実行①
Dim jsCode As String 
Dim CDP_Command As String
Dim response As String

jsCode = "javascript:window.open('http://****.co.jp:***/***/***','_blank');"
CDP_Command = "{""id"":1, " & _
  """method"":""Runtime.evaluate"", " & _
  """params"":{""expression"":""" & jsCode & """}}"
response = WebSocket_Submit(CDP_Command, ID)
Debug.Print "指定したJavaScriptを実行しました"

CDPのRuntime.evaluateコマンドを使って、指定したJacaScriptを実行します。変数jsCodeに代入した文字列が、実行するJavaScriptです。
どんなJavaScriptを実行すべきかは、WebページのHTMLソースを読んで分析しておきます。このボタンを押したらこんなJavaScriptが発火するなぁ、というのが分かっていれば、直接そのJavaScriptを実行すれば、ボタン押下と同じことを再現できます。

●任意のJavaScriptの実行②

'JavaScriptの実行②
Dim jsCode As String 
Dim CDP_Command As String
Dim response As String
Const USER_ID = "12345"
Const PASSWORD = "abcdef"

jsCode = "var textbox = document.getElementsByTagName('input');" & _
  "for (var i = 0; i < textbox.length; i++) {" & _
  " if (textbox[i].type === 'text') {" & _
  "  textbox[i].value=" & USER_ID & ";" & _
  "  textbox[i+1].value=" & PASSWORD & ";" & _
  "  textbox[i+2].click();" & _
  "  break;" & _
  " }" & _
  "}"
CDP_Command = "{""id"":1, " & _
  """method"":""Runtime.evaluate"", " & _
  """params"":{""expression"":""" & jsCode & """}}"
response = WebSocket_Submit(CDP_Command, ID)
Debug.Print "指定したJavaScriptを実行しました"

任意のJavaScriptを実行するサンプル②です。
認証ページのテキストボックスに、ユーザーIDとパスワードを入力して送信ボタンをクリックする想定です。

●任意のJavaScriptの実行③

'JavaScriptの実行③
Dim jsCode As String 
Dim CDP_Command As String
Dim response As String

jsCode = "var button = document.getElementsByTagName('input');" & _
  "for (var i = 0; i < button.length; i++) {" & _
  " if (button[i].type === 'submit' && button[i].value === 'OK') {" & _
  "  button[i].click();" & _
  "  break;" & _
  " }" & _
  "}"
CDP_Command = "{""id"":1, " & _
  """method"":""Runtime.evaluate"", " & _
  """params"":{""expression"":""" & jsCode & """}}"
response = WebSocket_Submit(CDP_Command, ID)
Debug.Print "指定したJavaScriptを実行しました"

任意のJavaScriptを実行するサンプル③です。
「type=submit」と「value=OK」の2属性に合致する<INPUT>タグを特定してクリックします。ラジオボタンの想定です。

●任意のJavaScriptの実行④

'JavaScriptの実行④
Dim jsCode As String 
Dim CDP_Command As String
Dim response As String

jsCode = "var button = document.getElementsByTagName('select');" & _
  "for (var i = 0; i < button.length; i++) {" & _
  " if (button[i].name === 'ctl12344567') {" & _
  "  button[i].value = 'xyz';" & _
  "  break;" & _
  " }" & _
  "}"
CDP_Command = "{""id"":1, " & _
  """method"":""Runtime.evaluate"", " & _
  """params"":{""expression"":""" & jsCode & """}}"
response = WebSocket_Submit(CDP_Command, ID)
Debug.Print "指定したJavaScriptを実行しました"

任意のJavaScriptを実行するサンプル④です。
「name=ctl12344567」の<SELECT>タグを特定して値をxyzにします。セレクトボタンの想定です。
無限に書けるのでこの位にします…。

●ページが読込まれたら自動実行するJavaScriptを仕込む

'ページが読込まれたら自動的に処理実行するJavaScriptコードを仕込む
Dim jsCode As String 
Dim CDP_Command As String
Dim response As String

jsCode = "function checkReadyState() {" & _
  " if (document.readyState === 'complete') {" & _
  "  var button = document.getElementsByTagName('select');" & _
  "  for (var i = 0; i < button.length; i++) {" & _
  "   if (button[i].name === 'ctl12344567') {" & _
  "    button[i].value = 'xyz';" & _
  "    break;" & _
  "   }" & _
  "  }" & _
  " } else {" & _
  "  window.addEventListener('load', checkReadyState);" & _
  "}" & _
  "checkReadyState();"
CDP_Command = "{""id"":1, " & _
  """method"":""Runtime.evaluate"", " & _
  """params"":{""expression"":""" & jsCode & """}}"
response = WebSocket_Submit(CDP_Command, ID)
Debug.Print "指定したJavaScriptを実行しました"

ページ読み込み完了したらJavaScriptを自動的に実行させたい時の書き方です。CPDによるイベント検知で判断するのでなく、このようにJavaScript自身にページの読み込み状況を判断させることも可能です。window.addEventListenerでJavaScript自身をページに仕込んでいます。
ただし、このJavaScriptだけだと、非同期処理でどんどんブラウザが進んでいってしまうので、都度都度CDPによるイベント検知などで、VBA側でも操作ページの状況を監視する処理が大事です。

●時間差でJavaScriptを実行する

'時間差でJavaScriptを実行する
Dim jsCode As String 
Dim CDP_Command As String
Dim response As String

jsCode = "setTimeout(function() {" & _
  "document.getElementById('abcdef').click(); & _
  "}, 1000);" & _
  "checkReadyState();"
CDP_Command = "{""id"":1, " & _
  """method"":""Runtime.evaluate"", " & _
  """params"":{""expression"":""" & jsCode & """}}"
response = WebSocket_Submit(CDP_Command, ID)
Debug.Print "指定したJavaScriptを実行しました"

setTimeoutを使って時間差でJavaScriptを発火します。
CDPによるイベント検知を確実に行いたい時、わざと時間差をつけてJavaScript実行(ブラウザ操作)することで、リスナー起動のタイミングを調整することが出来ます。


フレームの処理

<iframe>タグには上位ドキュメントと別のドキュメントが読み込まれてHTMLごと入れ子になっているので、そもそもリソースが異なり、そのままでは<iframe>タグ内の要素へアクセスできません。
また<iframe>タグの中と外で参照先のリソースが異なる(クロスオリジン)と、多くの場合でブラウザのセキュリティポリシーに引っ掛かるので上手く扱えません。
そこでポリシー制約の有無に分けて考えます。

●ポリシー制約なしの場合

'JavaScriptの実行(フレーム内の要素へアクセス)
Dim jsCode As String 
Dim CDP_Command As String
Dim response As String

jsCode = "var iframe = document.getElementById('iframeId');" & _
  "iframe.contentWindow.document.getElementById('send').click();"
CDP_Command = "{""id"":1, " & _
  """method"":""Runtime.evaluate"", " & _
  """params"":{""expression"":""" & jsCode & """}}"
response = WebSocket_Submit(CDP_Command, ID)
Debug.Print "指定したJavaScriptを実行しました"

こんな感じで、HTMLIframeエレメントにcontentWindow.documentを繋げると、<iframe>タグ内の要素へアクセスできます。

●ポリシー制約ありの場合

セキュリティ上の理由で、異なるオリジンの<iframe>内へのスクリプトからのアクセスを制限しているパターンは結構多いです。
そんなときは、<iframe>タグ内で参照しているドキュメントと、タグ外のドキュメント(上位ドキュメント)を切り分けて考えます。アクセスしたい要素はタグ内の方に存在するので、まずタグ内で参照しているドキュメントのURLをどうにかして取得しましょう。URLさえ手に入れば、CDPのPage.Navigateコマンド等を使ってそのドキュメントだけをブラウザで開く(別ページの中にフレーム枠として表示するのではなく、ページ全体を使って直接表示する)ことで、ポリシー制約を回避して操作できます。

例1:<iframe>タグが1つだけのとき
 ・<iframe>タグのsrc属性を参照する
 ・<iframe>タグのlocation.hrefプロパティの値を参照する
など。例えばこんな感じ。

'<iframe>タグ内のURL情報を取得して、そのまま画面移動
Dim jsCode As String 
Dim CDP_Command As String
Dim response As String

jsCode = "var iframes = document.getElementsByTagName('iframe');" & _
  "for (var i = 0; i < iframes.length; i++) {" & _
  " if (iframes[i].src.includes('任意のキーワード')) {" & _
  "  window.location.href = iframes[i].src;" & _
  "  break;" & _
  " }" & _
  "}"
CDP_Command = "{""id"":1, " & _
  """method"":""Runtime.evaluate"", " & _
  """params"":{""expression"":""" & jsCode & """}}"
response = WebSocket_Submit(CDP_Command, ID) 

'<iframe>タグ内のドキュメントを直接表示できた
'続けてCDPの任意コマンドを実行


例2:<iframe>タグが複数存在する(入れ子状態)とき
 ・以下手順でURLを取得する。
①CDPのTarget.getTargetsコマンドを使い、アタッチ可能な一覧を取得。
 このうち、type=iframeとある項目のtargetId値を取得。
 ここで取得できるのは、最上位HTMLIframeエレメント(つまり最上位<iframe>タグ)のターゲットID。
②上記①で取得したターゲットIDに対して、CDPのPage.getFrameTreeコマンドを実行。iframeに関する階層情報を一気に取得する。
 目的の情報(アクセスしたい要素)がどの階層のフレームにあるのかを確認し、目的のフレームのURLを取得。ここではフレームのnameやtypeやurl等の関連情報が階層毎に取得できる。
③上記①で取得する以前のターゲットID(type=pageとある項目のtargetId値)に対して、CDPのPage.Navigateコマンドを実行。上記②で取得したURLをブラウザで直接表示する。
④上記③に続けて、CDPの任意コマンドを実行。
 ポリシー制約に干渉されることなく目的の情報へアクセスできる。
例えばこんな感じ。

'<iframe>タグ内のURL情報を取得して、そのまま画面移動
Dim jsCode As String 
Dim response As String

'アタッチ可能なターゲット一覧を取得
CDP_Command = "{""id"":1, " & _
 """method"":""Target.getTargets""}"
response = WebSocket_Submit(CDP_Command, ID)

'type=frameのターゲットIDを取得する
Dim reg As Object
Dim TabIDs() As String
Dim nEnd As Long
Dim TrimText As String
Dim IFrameID As String
Set reg = CreateObject("VBScript.RegExp")
With reg
 .Pattern = """targetId"": ""[^""]+"""
 .IgnoreCase = True '大文字小文字を区別しない
 .Global = True '全体を検索
 ReDim TabIDs(.Execute(response).Count - 1)
 For i = 0 To .Execute(response).Count - 1
  TabIDs(i) = Mid(response, .Execute(response)(i).firstindex + 13, .Execute(response)(i).length - 13)
  nEnd = InStr(.Execute(response)(i).firstindex, response, "}")
  TrimText = Mid(response, .Execute(response)(i).firstindex, nEnd - .Execute(response)(i).firstindex)
  If InStr(TrimText, """iframe""") > 0 And InStr(TrimText, "********") > 0 Then
   IFrameID = TabIDs(i)
  End If
 Next i
End With
Set reg = Nothing

'type=iframeのターゲットIDに対して、Page.getFrameTreeコマンドを実行
CDP_Command = "{""id"":2, " & _
 """method"":""Page.getFrameTree""}"
response = WebSocket_Submit(CDP_Command, IFrameID)

'目的のフレームのURL情報を取得する
Dim URLs() As String
Dim NewURL As String
Set reg = CreateObject("VBScript.RegExp")
With reg
 .Pattern = """url"": ""[^""]+"""
 .IgnoreCase = True '大文字小文字を区別しない
 .Global = True '全体を検索
 ReDim URLs(.Execute(response).Count - 1)
For i = 0 To .Execute(response).Count - 1
 URLs(i) = Mid(response, .Execute(response)(i).firstindex + 8, .Execute(response)(i).length - 8)
 If InStr(URLs(i), "********") > 0 Then
  NewURL = URLs(i)
 End If
Next i
End With
Set reg = Nothing

'iframeが参照しているドキュメントソースをブラウザで直接表示する
CDP_Command = "{""id"":3, " & _
 """method"":""Page.navigate"", " & _
 """params"":{""url"":""" & NewURL & """}}"
response = WebSocket_Submit(CDP_Command, ID)

'<iframe>タグ内のドキュメントを直接表示できた
'続けてCDPの任意コマンドを実行

という感じで、若干まどろっこしいですが問題なく処理できます。


JavaScriptによってダイアログが開いたとき

ユーザー操作に応じて何らかのダイアログが出てくる様なWebページでは、ダイアログを如何に上手く捌けるかが大事なポイントになります。
上手く捌かないとそこで処理が止まってしまいます。

●JavaScriptによって開かれたダイアログの検知

'JavaScriptによって開かれたダイアログ検知(Page.JavascriptDialogOpeningイベントが発生するまで待機)
Dim CDP_Command As String
Dim EventName as String
Dim response As String

CDP_Command = "{""id"":1, " & _
  """method"":""Page.enable""}"
EventName = "Page.JavascriptDialogOpening"
response = WebSocket_Submit(CDP_Command, ID, EventName)
If InStr(response, EventName) > 0 Then
 Debug.Print EventName & "イベントを検知しました"
 ~ここにダイアログを検出した場合の処理を書く~
Else
 ~ここにダイアログを検出しなかった場合の処理を書く~
End If

「Page.JavascriptDialogOpening」イベントを検知したかどうかで、ダイアログの有無を判定します。
CDPのPage.Enableコマンドを使ってイベントをリッスンし続け、このイベントが検出さるかどうかを判断します。
なお、JavaScriptによって開かれるダイアログの最大数は常に「1つ」です。同時に複数のJavaScriptによるダイアログが出現することはありません。
また、JavaScriptによるダイアログはWebページの最前面に表示されます。

ちなみに、「Page.JavascriptDialogOpening」イベントを検出したときのレスポンス中身を見ると、ダイアログの情報(alert/confirm/prompt/beforeunload等)を確認できます。

●JavaScriptによって開かれたダイアログの処理方法①

'JavaScriptによって開かれたダイアログのOKボタン押下①
Dim CDP_Command As String
Dim EventName as String
Dim response As String

CDP_Command = "{""id"":1, " & _
  """method"":""Page.handleJavaScriptDialog""}"
  """params"":{""accept"":true}}"
response = WebSocket_Submit(CDP_Command, ID)
Debug.Print "ダイアログのOKボタン押下完了"

CDPのPage.handleJavaScriptDialogコマンドを使って、ダイアログを受け入れます。上記例ではtype=confirmのダイアログを受け入れる想定のコートです。詳しくはレファレンス参照のこと。

●JavaScriptによって開かれたダイアログの処理方法②

上記①の方法でうまくいかない時は、無理やり突破します。
JavaScriptによるダイアログはWebページの最前面に表示されることを利用して、TabキーとEnterキーを送り込んでボタン押下します。

'ウィンドウ情報取得用API
Private Declare PtrSafe Function GetParent Lib "user32" (ByVal hWnd As LongPtr) As LongPtr
Private Declare PtrSafe Function FindWindow Lib "user32" Alias "FindWindowA" (ByVal lpClassName As String, ByVal IpWindowName As String) As LongPtr
Private Declare PtrSafe Function GetNextWindow Lib "user32" Alias "GetWindow" (ByVal hWnd As LongPtr, ByVal wFlag As LongPtr) As LongPtr
Private Declare PtrSafe Function IsWindowVisible Lib "user32" (ByVal hWnd As LongPtr) As LongPtr
Private Declare PtrSafe Function GetWindowThreadProcessId Lib "user32" (ByVal hWnd As LongPtr, ByRef lpdwProcessId As LongPtr) As LongPtr
Private Declare PtrSafe Function OpenProcess Lib "kernel32" (ByVal dwDesiredAccess As Long, ByVal bInheritHandle As Long, ByVal dwProcessId As LongPtr) As LongPtr
Private Declare PtrSafe Function QueryFullProcessImageName Lib "kernel32" Alias "QueryFullProcessImageNameA" (ByVal hProcess As LongPtr, ByVal dwFlags As Long, ByVal lpExeName As String, ByRef lpdwSize As Long) As Long
Private Declare PtrSafe Function CloseHandle Lib "kernel32" (ByVal hObject As LongPtr) As Long
Private Declare PtrSafe Function GetClassName Lib "user32" Alias "GetClassNameA" (ByVal hWnd As LongPtr, ByVal lpClassName As String, ByVal nMaxCount As LongPtr) As LongPtr
Private Const PROCESS_QUERY_INFORMATION = &H400
Private Const GW_HWNDNEXT = &H2
'------------------------
'キーコード送信用API
Private Declare PtrSafe Function PostMessage Lib "user32" Alias "PostMessageA" (ByVal hWnd As LongPtr, ByVal wMsg As LongPtr, ByVal wparam As LongPtr, ByVal lParam As LongPtr) As LongPtr
Private Declare PtrSafe Function MapVirtualKey Lib "user32" Alias "MapVirtualKeyA" (ByVal wCode As LongPtr, ByVal wMapType As LongPtr) As LongPtr
'------------------------ 

'メイン処理
'JavaScriptによって開かれたダイアログのOKボタン押下②
Dim hWnd as LongPtr
hWnd = getEdgeWindowHandle 'サブルーチンを呼び出す
Const VK_RETURN = &HD 'Enterキー
Const VK_TAB = &H9 'Tabキー
Const WM_KEYDOWN = &H100 'キーDownコード
Dim lParam As LongPtr
lParam = 1 + MapVirtualKey(VK_RETURN, 0) * (2 ^ 16)
PostMessage hWnd, WM_KEYDOWN, VK_RETURN, lParam
Debug.Print "Enterキー送信"

'------------------------ 
'サブルーチン部(起動中のEdge上位ウィンドウハンドルを返す)
Private Function getEdgeWindowHandle() As LongPtr
 'プロセス名「msedge.exe」のウィンドウを配列WindowInfo()へ格納
 Dim hWnd As LongPtr
 Dim pId As LongPtr
 Dim hProcess As LongPtr
 Dim lpExeName As String * 255
 Dim lpdwSize As Long
 Dim ret As Long
 Dim ProcessName As String
 Dim WindowInfo() As Variant
 Dim Num As Long
 Dim strClassName As String * 255
 hWnd = FindWindow(vbNullString, vbNullString)
 ReDim WindowInfo(3, 0)
 Do
  If IsWindowVisible(hWnd) <> 0 Then
   GetWindowThreadProcessId hWnd, pId 'pId=プロセスID
   hProcess = OpenProcess(PROCESS_QUERY_INFORMATION, False, pId)
   If hProcess <> 0 Then
    lpdwSize = Len(lpExeName)
    ret = QueryFullProcessImageName(hProcess, 0, lpExeName, lpdwSize)
    If ret <> 0 Then
     ProcessName = Mid$(lpExeName, InStrRev(lpExeName, "\") + 1) 'プロセス名
     If InStr(ProcessName, "msedge.exe") > 0 Then
      ReDim Preserve WindowInfo(3, Num + 1)
      WindowInfo(1, Num) = ProcessName
      GetClassName hWnd, strClassName, Len(strClassName)
      WindowInfo(2, Num) = Left(strClassName, InStr(strClassName, vbNullChar) - 1) 'クラス名
      WindowInfo(3, Num) = hWnd
      Num = Num + 1
     End If
    End If
    CloseHandle hProcess
   End If
  End If
  hWnd = GetNextWindow(hWnd, GW_HWNDNEXT)
 Loop Until hWnd = 0
 '上位ハンドルを返す
 For i = 0 To UBound(WindowInfo, 2) - 1
  If GetParent(WindowInfo(3, i)) = 0 Then
   hWnd = WindowInfo(3, i)
  End If
 Next
 getEdgeWindowHandle = hWnd
End Function

特筆すべきは、事故率の低さ。
ブラウザで開かれた対象Webページが非アクティブであっても、ウィンドウハンドルを特定して正確にEnterキーを送り込みます。詳しくはこの記事を参照のこと。
Tabキーを送り込みたい時は、PostMessage関数の第二引数を変えることで調整可能です。


スクレイピングしたいとき(データの収集・抽出)

ここでは,、Webページ上にある情報を拾ってVBA側に処理結果を戻す方法を紹介します。CDP(Chrome Devtools Protocol)を介してコマンド(命令文)をブラウザへ送信している都合上、IEモード操作時のように単にDOM操作して戻り値を得る訳にはいきません。ただし一工夫すればデータの収集・抽出が可能です。

●JavaScriptで要素を特定したのち、処理結果をCDPレスポンスで受け取る

Dim jsCode As String 
Dim CDP_Command As String
Dim response As String
Dim jsCode As String

jsCode = "var result = '';" & _
       " var elements = document.getElementsByTagName('input');" & _
       " for (var i = 0; i < elements.length; i++) {" & _
       "   if (elements[i].type === 'submit') {" & _
       "     result = elements[i].value;" & _
       "     break;" & _
       "   }" & _
       " }" & _
       " result;"
CDP_Command = "{""id"":1, " & _
  """method"":""Runtime.evaluate"", " & _
  """params"":{""expression"":""" & jsCode & """}}"
response = WebSocket_Submit(CDP_Command, ID) 

'処理結果を含むレスポンスが返ってきた。
'続けて余計な部分をカット
Dim reg As Object
Dim str As String
Set reg = CreateObject("VBScript.RegExp")
With reg
  .Pattern = """value"":""[^""]+"""
  nEnd = InStr(.Execute(response)(0).firstindex, response, "}")
  str = Mid(response, .Execute(response)(0).firstindex + 10, nEnd - .Execute(response)(0).firstindex - 11)
End With

'Unicodeエスケープを含む文字列をデコード
With reg
  .Global = True
  .MultiLine = True
  .IgnoreCase = True
  .Pattern = "\\u([0-9A-Fa-f]{4})"
End With
Dim matches As Object
Dim match As Variant
Dim unicodeChar As String
Dim unicodeValue As Long
Set matches = reg.Execute(str)
For Each match In matches
 unicodeChar = match.SubMatches(0)
 unicodeValue = Val("&H" & unicodeChar)
 str = Replace(str, match.Value, ChrW(unicodeValue))
Next
Set reg = Nothing
Debug.Print str

変数jsCodeにあるように、欲しい要素の情報を特定して処理結果(変数result)を返すまでをJavaScript側で行います。
これをCDPのRuntime.evaluateコマンドで実行すると、変数resultの値を含むレスポンスが返ってくるので、目的の情報のみを文字列トリミングしてやればOKです。
正規表現で変数resultの部分だけを抽出し、文字コードを整えてやれば完成。直観的で分かりやすいですね。

●outerHTMLをCDPレスポンスで受け取り、HTMLドキュメントへパースする

Dim jsCode As String 
Dim CDP_Command As String
Dim response As String

'HTML全体(を含むレスポンス)を取得する
jsCode = "jsCode = "document.documentElement.outerHTML;"
CDP_Command = "{""id"":1, " & _
  """method"":""Runtime.evaluate"", " & _
  """params"":{""expression"":""" & jsCode & """}}"
response = WebSocket_Submit(CDP_Command, ID) 

'HTML部分のみを抽出
Dim reg As Object
Dim str As String
Set reg = CreateObject("VBScript.RegExp")
With reg
 .Global = False
 .Pattern = """value"":""[^""]+"""
 str = Mid(response, .Execute(response)(0).firstindex + 10, Len(response) - .Execute(response)(0).firstindex - 11 - 3)
End With

'Unicodeエスケープ(\uXXXX形式)をデコード
With reg
 .Global = True
 .MultiLine = True
 .IgnoreCase = True
 .Pattern = "\\u([0-9A-Fa-f]{4})"
End With
Dim matches As Object
Dim match As Object
Dim unicodeChar As String
Dim unicodeValue As Long
Set matches = reg.Execute(str)
For Each match In matches
 unicodeChar = match.SubMatches(0)
 unicodeValue = CLng("&H" & unicodeChar)
 str = Replace(str, match.Value, ChrW("&H" & unicodeChar))
Next
Set reg = Nothing
str = Replace(str, "\""", """")

'文字列をHTMLドキュメントへパースして、DOM操作により欲しい情報を特定
Dim tempObj As Object
Dim Elem As Object
Set tempObj = CreateObject("htmlfile")
tempObj.body.innerHTML = str
On Error Resume Next
For Each Elem In tempObj.getElementsByTagName("input")
 If Elem.Class = "gNO89b" Then
  Debug.Print Elem.Value
  Exit For
 End If
Next
On Error GoTo 0
Set tempObj = Nothing

'レスポンスをテキスト出力(確認用)
'Open CreateObject("WScript.Shell").SpecialFolders("Desktop") & "\ResponseText.txt" For Output As #1 
'Print #1 , str
'Close #1 

まずJavaScript側でHTMLドキュメント全体のHTMLソースコードを取得します。(スクリプトを実行した時点でブラウザ画面に表示されているコンテンツ情報が丸ごとゲットできるので、きちんとページ読み込み待機した上で実行すれば、動的生成された値も含めて全て取得できます。)
ただし注意点として、文字列データとはいえ、返ってくるCDPレスポンスのデータ量が膨大になります。WebSocket通信のレスポンス受信(Sub①-B部)のバッファーを十分なサイズに調整する必要があるのでそこだけ忘れずに。本文のサンプルコードでは「Buffer = Space(4096)」と書いていますが、数値の部分を適宜もっと大きな値に(409600など)に変更しておかないと、折角返ってきたCDPレスポンスが途中で途切れてしまいます。レスポンスをテキスト出力する等して、最後の</html>まで取得できている事を一度確認した方が良いです。
この方法なら、業務ページでよく使われている(?)カスタムエレメント内の情報も丸ごと引っ張ってこれます。SalesForceのような大規模Webサービスで見かけますが、通常の方法だと中々情報取得できませんからね。シャドウDOMでカプセル化されていると流石に辛いですが…。

取得した文字列を適宜加工(HTML部分のみを抽出し変数strヘ代入)し、HTMLドキュメントへパースしてやれば、あとは自由に要素へアクセスして情報取得できます。


その他デバッグ作業

●公式レファレンス

公式レファレンスはこちら。
Chrome DevTools Protocol - Debugger domain
全てのコマンドを試した訳ではありませんが、Chromiumベースのエンジンを積んでいるEdgeであれば、概ねここに載っているコマンドは使えると思っています。
分からなくなったら、まずはこちらを参照してください。

●WebページのDOM要素を検証(デバッグ出力)

検証のためにページ内の特定要素をデバッグ出力したい状況では、JavaScriptのAlert関数を使うのが便利です。
ブラウザの最前面に実行結果をポップアップ表示します。

'JavaScriptの実行(デバッグ出力)
Dim jsCode As String 
Dim CDP_Command As String
Dim response As String

'【例1】
jsCode = "alert(document.title);"
'【例2】
jsCode = "var textbox = document.getElementsByTagName('input');" & _
  "for (var i = 0; i < textbox.length; i++) {" & _
  " if (textbox[i].type === 'text') {" & _
  "  alert(textbox[i].value);" & _
  "  break;" & _
  " }" & _
  "}"
'【例3】
jsCode = "Array.from(document.getElementsByTagName('input')).forEach(a => alert(a.value));"

CDP_Command = "{""id"":1, " & _
  """method"":""Runtime.evaluate"", " & _
  """params"":{""expression"":""" & jsCode & """}}"
response = WebSocket_Submit(CDP_Command, ID)

こんな感じでalert関数を使うと、VBEのイミディエイトウィンドウや、開発者ツールのコンソールタブの代わりに出力結果が確認でき、手っ取り早いです。

●Webページの分析(発生イベントを調査)

発生イベントの調査には、開発者ツールのプロトコルモニターを使います。
ログを取りながら実際に手作業で処理を進めていって、どんなイベントが発生しているかを細かく追っていきます。

開発者ツールのプロトコルモニター

手作業で見ていくのが一番確実ですが、ただまあ時間が掛かります。

あとは、iphoneの動画撮影を使って検証するアナログな方法も紹介します。
私は横着するタイプなのでよく使いますが、まずブラウザとVBEを画面に半分づつ分割表示した状態状態で、iphoneの動画撮影をしながらマクロを実行します。実行したらすぐにブラウザを選択してF12キーを押して開発者ツールを開き、プロトコルモニターを起動してログを取りながらマクロ実行の様子を見守ります。
VBEのイミディエイトウィンドウに経過状況を逐一出力するようなコードを書いておけば、「この処理のタイミングでブラウザはこう動くのかぁ」というのが一目で分かり、かつプロトコルモニターで発生イベントも確認できます。
問題発生個所(デバッグ箇所)に近づいてきたら、プロトコルモニターのクリアボタンでログを一旦クリアして、余計なログを削除しましょう。こうすることで、問題発生個所だけのログを残せるので、デバッグ作業に集中しやすいです。
またiphoneのカメラ画質って意外と良いので、撮影映像を拡大しても十分に文字が判別できます。かつiphoneで撮影した動画は、指で任意のシーンを選択できて一時停止も簡単なので、デバッグ作業が捗ります。

その他、気付いたことがあればここに追記していきます。

この記事が気に入ったらサポートをしてみませんか?