🗂

縛りプレー環境下でのPowerShellとCDPを使用したブラウザの自動操作方法

に公開

はじめに

ライブラリやアプリのダウンロードを禁止するという縛りプレーをおこなう場合にブラウザの自動操作が必要なケースがあります。
かつてはInternet ExplorerのCOMを使用したブラウザの自動操作が行えました。
今日ではInternet Explorerの操作は基本行えなくなっています。

今回はEdgeのChrome DevTools Protocol (CDP)を使用してブラウザの自動操作を行う方法を紹介します。

この記事はライブラリやアプリのダウンロードが禁止されている組織において、PowerShellが組める人間を対象にしています。
また、そういった縛りのない環境においては別の方法を選択したほうが筋がいいです。

環境は以下の通りです。
OS:Windows 11
PSVersion: 5.1.26100.6584

PowerShell+CDPでのEdgeの自動操作

自動操作の内容

サンプルコード

サンプルコード
# 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などのコードが参考になります。
https://github.com/pyppeteer/pyppeteer/blob/7dc91ee5173d3836f77800a3774beeaf2b448c0e/pyppeteer/input.py#L285

文字入力


# 指定の要素にテキストを挿入
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