縛りプレー環境下でのPowerShellとCDPを使用したブラウザの自動操作方法
はじめに
ライブラリやアプリのダウンロードを禁止するという縛りプレーをおこなう場合にブラウザの自動操作が必要なケースがあります。
かつてはInternet ExplorerのCOMを使用したブラウザの自動操作が行えました。
今日ではInternet Explorerの操作は基本行えなくなっています。
今回はEdgeのChrome DevTools Protocol (CDP)を使用してブラウザの自動操作を行う方法を紹介します。
この記事はライブラリやアプリのダウンロードが禁止されている組織において、PowerShellが組める人間を対象にしています。
また、そういった縛りのない環境においては別の方法を選択したほうが筋がいいです。
環境は以下の通りです。
OS:Windows 11
PSVersion: 5.1.26100.6584
PowerShell+CDPでのEdgeの自動操作
自動操作の内容
- zennのトップページを開く
- 検索に「test」と入力してEnterを押す
- 記事の一覧を標準出力する
サンプルコード
サンプルコード
# powershell -File cdp.ps1
# Edgeのパスを取得する
function Get-EdgePath {
$paths = @()
$pf = [Environment]::GetEnvironmentVariable('ProgramFiles')
if ($pf) {
$paths += (Join-Path $pf 'Microsoft\Edge\Application\msedge.exe')
}
$pf86 = [Environment]::GetEnvironmentVariable('ProgramFiles(x86)')
if ($pf86) {
$paths += (Join-Path $pf86 'Microsoft\Edge\Application\msedge.exe')
}
foreach ($p in $paths) {
if (Test-Path $p) {
return $p
}
}
$cmd = Get-Command 'msedge.exe' -ErrorAction SilentlyContinue
if ($cmd -and $cmd.Path) {
return $cmd.Source
}
throw "Microsoft Edge (msedge.exe) が見つかりませんでした。"
}
Add-Type -AssemblyName System.Net.Http
# PowerShell5.1だと不要
# Add-Type -AssemblyName System.Net.WebSockets
$global:cdpSocket = [System.Net.WebSockets.ClientWebSocket]::new()
$global:cdpCts = New-Object System.Threading.CancellationTokenSource
$global:cdpId = 0
# CDPコマンドを送信
function Send-CdpCommand {
param(
[int]$Id,
[string]$Method,
[hashtable]$Params
)
if (-not $Params) { $Params = @{} }
$payload = @{
id = $Id
method = $Method
params = $Params
} | ConvertTo-Json -Depth 5
$bytes = [System.Text.Encoding]::UTF8.GetBytes($payload)
$seg = New-Object System.ArraySegment[byte] (, $bytes)
$global:cdpSocket.SendAsync(
$seg,
[System.Net.WebSockets.WebSocketMessageType]::Text,
$true,
$global:cdpCts.Token
).Wait()
}
# CDPからのメッセージを受信
function Receive-CdpMessageForId {
param([int]$ExpectedId)
# 1 回のメッセージを受けるバッファ
$buffer = New-Object byte[] 8192
while ($true) {
# ← ここがポイント。ArraySegment[byte] の作り方
$segment = New-Object "System.ArraySegment[byte]" -ArgumentList (, $buffer)
$result = $global:cdpSocket.ReceiveAsync(
$segment,
$global:cdpCts.Token
).Result
$json = [System.Text.Encoding]::UTF8.GetString($buffer, 0, $result.Count)
# たまに通知イベントなどで壊れた/期待しない JSON が来るかもしれないので try-catch
try {
$msg = $json | ConvertFrom-Json
} catch {
continue
}
if ($msg.id -eq $ExpectedId) {
return $msg
}
# id が違う通知 (Page.loadEventFired など) は無視して次ループ
}
}
# CDPにコマンドを送信して受信を待つ
function Invoke-Cdp {
param(
[string]$Method,
[hashtable]$Params
)
$global:cdpId++
$id = $global:cdpId
Send-CdpCommand -Id $id -Method $Method -Params $Params
return (Receive-CdpMessageForId -ExpectedId $id)
}
# CDPに接続
function Connect-Cdp {
$targets = Invoke-RestMethod http://localhost:9222/json/list
$target = $targets[0]
$wsUrl = $target.webSocketDebuggerUrl
$uri = [Uri]$wsUrl
$global:cdpSocket.ConnectAsync($uri, $global:cdpCts.Token).Wait()
$global:cdpId = 0
Invoke-Cdp -Method "Page.enable" -Params @{}
Invoke-Cdp -Method "DOM.enable" -Params @{}
}
# 要素の中央の座標を取得
function Get-ElementCenter {
param($model)
$xs = @()
$ys = @()
for ($i = 0; $i -lt $model.content.Count; $i += 2) {
$xs += [double]$model.content[$i]
$ys += [double]$model.content[$i + 1]
}
$cx = ($xs | Measure-Object -Average).Average
$cy = ($ys | Measure-Object -Average).Average
return @{ X = $cx; Y = $cy }
}
# 指定の要素をクリックする
function Invoke-CdpClickBySelector {
param(
[string]$Selector
)
# clickイベントについてはpyppeteerの以下参考
# https://github.com/pyppeteer/pyppeteer/blob/7dc91ee5173d3836f77800a3774beeaf2b448c0e/pyppeteer/input.py#L285
$doc = Invoke-Cdp -Method "DOM.getDocument" -Params @{}
$rootId = $doc.result.root.nodeId
$q = Invoke-Cdp -Method "DOM.querySelector" -Params @{
nodeId = $rootId
selector = $Selector
}
$nodeId = $q.result.nodeId
if (-not $nodeId) {
throw "Selector '$Selector' に一致する要素がありません。"
}
$box = Invoke-Cdp -Method "DOM.getBoxModel" -Params @{ nodeId = $nodeId }
$center = Get-ElementCenter -model $box.result.model
Invoke-Cdp -Method "Input.dispatchMouseEvent" -Params @{
type = "mousePressed"
x = $center.X
y = $center.Y
button = "left"
clickCount = 1
}
Invoke-Cdp -Method "Input.dispatchMouseEvent" -Params @{
type = "mouseReleased"
x = $center.X
y = $center.Y
button = "left"
clickCount = 1
}
}
# 指定の要素が表示されるまで待機
function Wait-CdpElementBySelector {
param(
[string]$Selector,
[int]$TimeoutMs = 10000,
[int]$IntervalMs = 250
)
$elapsed = 0
while ($elapsed -lt $TimeoutMs) {
$doc = Invoke-Cdp -Method "DOM.getDocument" -Params @{}
$rootId = $doc.result.root.nodeId
$q = Invoke-Cdp -Method "DOM.querySelector" -Params @{
nodeId = $rootId
selector = $Selector
}
if ($q.result.nodeId) {
return $true
}
Start-Sleep -Milliseconds $IntervalMs
$elapsed += $IntervalMs
}
throw "Timeout: Selector '$Selector' が見つかりませんでした。"
}
# 指定の要素にテキストを挿入
function Send-CdpText {
param(
[string]$Text
)
# フォーカスされた要素にテキストを挿入
Invoke-Cdp -Method "Input.insertText" -Params @{ text = $Text }
}
# Enter押下
function Send-CdpEnter {
# keyDown
Invoke-Cdp -Method "Input.dispatchKeyEvent" -Params @{
type = "keyDown"
key = "Enter"
code = "Enter"
windowsVirtualKeyCode = 13
nativeVirtualKeyCode = 13
text = "`r"
}
# keyUp
Invoke-Cdp -Method "Input.dispatchKeyEvent" -Params @{
type = "keyUp"
key = "Enter"
code = "Enter"
windowsVirtualKeyCode = 13
nativeVirtualKeyCode = 13
text = "`r"
}
}
# 特定のテキストが表示されるまで待機
function Wait-CdpText {
param(
[string]$Text,
[int]$TimeoutMs = 10000,
[int]$IntervalMs = 300
)
$elapsed = 0
while ($elapsed -lt $TimeoutMs) {
# JS で文字列を検索して返す...入力を信用しないならエスケープしたほうがいい
$js = @"
document.body.innerText.includes("$Text")
"@
$r = Invoke-Cdp -Method "Runtime.evaluate" -Params @{
expression = $js
returnByValue = $true
}
if ($r.result.result.value -eq $true) {
Write-Host "Text '$Text' detected."
return $true
}
Start-Sleep -Milliseconds $IntervalMs
$elapsed += $IntervalMs
}
throw "Timeout waiting for text '$Text'"
}
# 自動操作用のEdgeを起動
$edgePath = Get-EdgePath
$userDataDir = Join-Path $env:TEMP "edge-devtools-profile"
if (-not (Test-Path $userDataDir)) {
New-Item -ItemType Directory -Path $userDataDir | Out-Null
}
$edge = Start-Process -FilePath $edgePath -ArgumentList @(
"--remote-debugging-port=9222",
"--user-data-dir=$userDataDir",
"--no-first-run",
"--new-window"
) -PassThru
Start-Sleep -Seconds 2
# CDPを接続
Connect-Cdp
# STEP 1: navigate https://zenn.dev/
Invoke-Cdp -Method "Page.navigate" -Params @{ url = "https://zenn.dev/" }
# 表示を待つ
Wait-CdpElementBySelector -Selector "#header-search path"
# STEP 2: click 検索アイコン (#header-search)
Invoke-CdpClickBySelector -Selector "#header-search"
# STEP 3: waitForElement strong (人気のトピック)
Wait-CdpText "人気のトピック"
# STEP 4: click 入力フォーム (#input-search-form)
Invoke-CdpClickBySelector -Selector "#input-search-form"
#Start-Sleep -Milliseconds 500
# STEP 5: change value -> "test"
Send-CdpText -Text "test"
Send-CdpEnter
# STEP 6: waitForElement 検索結果でarticleタグを待つ
Wait-CdpElementBySelector -Selector "article"
# JavaScriptを使用して記事の一覧を取得
$js = @'
Array.from(document.querySelectorAll("[class*='ArticleListItem_title']"))
.map(x => x.innerText)
'@
$r = Invoke-Cdp -Method "Runtime.evaluate" -Params @{
expression = $js
returnByValue = $true
}
$r.result.result.value | ForEach-Object {
Write-Host $_
}
# CDP WebSocket を閉じる
$global:cdpSocket.CloseAsync(
[System.Net.WebSockets.WebSocketCloseStatus]::NormalClosure,
"done",
$global:cdpCts.Token
).Wait()
$global:cdpSocket.Dispose()
$global:cdpCts.Dispose()
if ($edge -and !$edge.HasExited) {
$edge.Kill()
}
コードの説明
Edgeの起動とCDPの接続
# CDPに接続
function Connect-Cdp {
$targets = Invoke-RestMethod http://localhost:9222/json/list
$target = $targets[0]
$wsUrl = $target.webSocketDebuggerUrl
$uri = [Uri]$wsUrl
$global:cdpSocket.ConnectAsync($uri, $global:cdpCts.Token).Wait()
$global:cdpId = 0
Invoke-Cdp -Method "Page.enable" -Params @{}
Invoke-Cdp -Method "DOM.enable" -Params @{}
}
# 自動操作用のEdgeを起動
$edgePath = Get-EdgePath
$userDataDir = Join-Path $env:TEMP "edge-devtools-profile"
if (-not (Test-Path $userDataDir)) {
New-Item -ItemType Directory -Path $userDataDir | Out-Null
}
$edge = Start-Process -FilePath $edgePath -ArgumentList @(
"--remote-debugging-port=9222",
"--user-data-dir=$userDataDir",
"--no-first-run",
"--new-window"
) -PassThru
Start-Sleep -Seconds 2
# CDPを接続
Connect-Cdp
このコードではremote-debugging-portを指定してブラウザを起動しています。
Invoke-RestMethodを使用してjson/listのデータを取得します。
このレスポンスでwebSocketDebuggerUrlが取得できるため、System.Net.WebSockets.ClientWebSocketを使用して接続を行います。
タブを大量に開いていると動作しない可能性があります。
CDPの送受信
# CDPコマンドを送信
function Send-CdpCommand {
param(
[int]$Id,
[string]$Method,
[hashtable]$Params
)
if (-not $Params) { $Params = @{} }
$payload = @{
id = $Id
method = $Method
params = $Params
} | ConvertTo-Json -Depth 5
$bytes = [System.Text.Encoding]::UTF8.GetBytes($payload)
$seg = New-Object System.ArraySegment[byte] (, $bytes)
$global:cdpSocket.SendAsync(
$seg,
[System.Net.WebSockets.WebSocketMessageType]::Text,
$true,
$global:cdpCts.Token
).Wait()
}
# CDPからのメッセージを受信
function Receive-CdpMessageForId {
param([int]$ExpectedId)
# 1 回のメッセージを受けるバッファ
$buffer = New-Object byte[] 8192
while ($true) {
# ← ここがポイント。ArraySegment[byte] の作り方
$segment = New-Object "System.ArraySegment[byte]" -ArgumentList (, $buffer)
$result = $global:cdpSocket.ReceiveAsync(
$segment,
$global:cdpCts.Token
).Result
$json = [System.Text.Encoding]::UTF8.GetString($buffer, 0, $result.Count)
# たまに通知イベントなどで壊れた/期待しない JSON が来るかもしれないので try-catch
try {
$msg = $json | ConvertFrom-Json
} catch {
continue
}
if ($msg.id -eq $ExpectedId) {
return $msg
}
# id が違う通知 (Page.loadEventFired など) は無視して次ループ
}
}
# CDPにコマンドを送信して受信を待つ
function Invoke-Cdp {
param(
[string]$Method,
[hashtable]$Params
)
$global:cdpId++
$id = $global:cdpId
Send-CdpCommand -Id $id -Method $Method -Params $Params
return (Receive-CdpMessageForId -ExpectedId $id)
}
Invoke-Cdpを使用してCDPに対してメッセージを送信、その応答を受信します。
Receive-CdpMessageForIdについては簡単な実装になっているため、長いメッセージを受信した場合やメッセージが複数フレームに分割される場合などは動作しない可能性があります。
マウスクリック
# 要素の中央の座標を取得
function Get-ElementCenter {
param($model)
$xs = @()
$ys = @()
for ($i = 0; $i -lt $model.content.Count; $i += 2) {
$xs += [double]$model.content[$i]
$ys += [double]$model.content[$i + 1]
}
$cx = ($xs | Measure-Object -Average).Average
$cy = ($ys | Measure-Object -Average).Average
return @{ X = $cx; Y = $cy }
}
# 指定の要素をクリックする
function Invoke-CdpClickBySelector {
param(
[string]$Selector
)
# clickイベントについてはpyppeteerの以下参考
# https://github.com/pyppeteer/pyppeteer/blob/7dc91ee5173d3836f77800a3774beeaf2b448c0e/pyppeteer/input.py#L285
$doc = Invoke-Cdp -Method "DOM.getDocument" -Params @{}
$rootId = $doc.result.root.nodeId
$q = Invoke-Cdp -Method "DOM.querySelector" -Params @{
nodeId = $rootId
selector = $Selector
}
$nodeId = $q.result.nodeId
if (-not $nodeId) {
throw "Selector '$Selector' に一致する要素がありません。"
}
$box = Invoke-Cdp -Method "DOM.getBoxModel" -Params @{ nodeId = $nodeId }
$center = Get-ElementCenter -model $box.result.model
Invoke-Cdp -Method "Input.dispatchMouseEvent" -Params @{
type = "mousePressed"
x = $center.X
y = $center.Y
button = "left"
clickCount = 1
}
Invoke-Cdp -Method "Input.dispatchMouseEvent" -Params @{
type = "mouseReleased"
x = $center.X
y = $center.Y
button = "left"
clickCount = 1
}
}
Invoke-CdpClickBySelectorで指定の要素の真ん中をクリックします。
- DOM.getDocumentを使用してDOMのノードツリーを取得します。
- DOM.querySelectorで指定のセレクタのDOMノードを選択します。
- DOM.getBoxModelを使用して指定セレクタのノードの座標情報をもとめ、その中央値を出します。
- Input.dispatchMouseEventでmousePressedとmouseReleasedを実行してクリックを再現します。
clickイベントについてはpyppeteerなどのコードが参考になります。
文字入力
# 指定の要素にテキストを挿入
function Send-CdpText {
param(
[string]$Text
)
# フォーカスされた要素にテキストを挿入
Invoke-Cdp -Method "Input.insertText" -Params @{ text = $Text }
}
# Enter押下
function Send-CdpEnter {
# keyDown
Invoke-Cdp -Method "Input.dispatchKeyEvent" -Params @{
type = "keyDown"
key = "Enter"
code = "Enter"
windowsVirtualKeyCode = 13
nativeVirtualKeyCode = 13
text = "`r"
}
# keyUp
Invoke-Cdp -Method "Input.dispatchKeyEvent" -Params @{
type = "keyUp"
key = "Enter"
code = "Enter"
windowsVirtualKeyCode = 13
nativeVirtualKeyCode = 13
text = "`r"
}
}
指定の要素にテキストを挿入する場合はInput.insertTextを使用します。これは全角文字を指定しても動作しました。
Enterキーの押下をする場合はInput.dispatchKeyEventを使用します。
JavaScriptによる内容の列挙
# JavaScriptを使用して記事の一覧を取得
$js = @'
Array.from(document.querySelectorAll("[class*='ArticleListItem_title']"))
.map(x => x.innerText)
'@
$r = Invoke-Cdp -Method "Runtime.evaluate" -Params @{
expression = $js
returnByValue = $true
}
$r.result.result.value | ForEach-Object {
Write-Host $_
}
Runtime.evaluateを使用してJavaScriptを埋めこみ、その結果を取得して表示しています。
まとめ
今回は何もインストールしないWindows11でEdgeのChrome DevTools Protocol (CDP)を使用してブラウザの自動操作を行う方法を確認しました。
今回は簡易実装なため、例外処理や終了処理についていくつか課題はありますが、なにもインストールしなくてもブラウザの自動操作ができることが確認できます。
今回はEdgeでやりましたが、Chromeがインストールされている場合は、そのパスを変更するだけで動作すると思います。
Discussion