App.ps1

using namespace System.Diagnostics
using namespace System.IO
using namespace System.Net.WebSockets
using namespace System.Text
using namespace System.Threading

Class App {
    static hidden [string] $root

    hidden [Process] $browserProcess
    hidden [hashtable[]] $cdpSessions = @()

    [void] Main([int] $port, [scriptblock] $resultHandler) {
        $this.StartBrowserProcess()
        $this.InvokeTargetChecks($port, $resultHandler)
        $this.StopBrowserProcess()
    }

    static [void] Welcome() {
        try { Clear-Host -ErrorAction Stop } catch { Write-Verbose $_ }
        Write-Host "Console CensorChecker 启动!" -ForegroundColor Red
    }

    hidden [void] StartBrowserProcess() {
        [string] $browserPath = "google-chrome"

        if (-not (Get-Command $browserPath -CommandType Application -ErrorAction Ignore)) {
            while (-not (Test-Path -LiteralPath $browserPath -PathType Leaf)) { $browserPath = (Read-Host "输入 Chromium 内核浏览器路径").Trim("""") }
        }

        $this.browserProcess = Start-Process $browserPath @(
            "--headless"
            "--remote-debugging-port=9222"
            "--user-data-dir=$(Join-Path ([Path]::GetTempPath()) "Console-CensorChecker")"
        ) -PassThru -RedirectStandardError ($global:IsWindows ? "NUL" : "/dev/null") -ErrorAction Stop

        for ([int] $tryCount = 0; $tryCount -lt 10; $tryCount++) {
            Start-Sleep 1

            try {
                $null = Invoke-RestMethod "http://localhost:9222" -OperationTimeoutSeconds 1 -ErrorAction Stop
                return
            }
            catch { continue }
        }

        throw "浏览器调试服务启动超时"
    }

    hidden [void] InvokeTargetChecks([int] $port, [scriptblock] $resultHandler) {
        [string] $targetPath = Join-Path ([App]::root) "Target.txt"

        while (-not (Test-Path -LiteralPath $targetPath -PathType Leaf)) { $targetPath = (Read-Host "输入 Target.txt 文件路径").Trim("""") }

        [string[]] $targets = foreach ($targetLine in Get-Content -LiteralPath $targetPath -ErrorAction Stop) {
            if (($target = $targetLine.Trim())) { $target }
        }

        if ($targets.Count -eq 0) { return }

        [string[]] $formattedTargets = foreach ($target in $targets) {
            [string] $normalizedTarget = $target -replace "\s+"

            if ($normalizedTarget -like "[[]*") {
                $normalizedTarget -match "\]:\d+$" ? $normalizedTarget : "${normalizedTarget}:$port"
            }
            elseif ($normalizedTarget -match "/" -or $normalizedTarget -match "^\d{1,3}(?:\.\d{1,3}){3}-" -or $normalizedTarget -match ":.*:") {
                "[${normalizedTarget}]:$port"
            }
            else {
                $normalizedTarget -match ":\d+$" ? $normalizedTarget : "${normalizedTarget}:$port"
            }
        }

        [hashtable] $targetLookup = @{}
        [hashtable] $seenTargets = @{}

        for ([int] $targetIndex = 0; $targetIndex -lt $targets.Count; $targetIndex++) {
            [string] $targetKey = $targets[$targetIndex]
            if ($seenTargets.ContainsKey($targetKey)) { continue }

            $seenTargets[$targetKey] = $true

            [string] $formattedTarget = $formattedTargets[$targetIndex]
            $targetLookup[$formattedTarget] ??= @()
            $targetLookup[$formattedTarget] += $targetKey
        }

        [string[]] $currentBatch = @()
        [int] $batchCharCount = 0

        [string[][]] $targetBatches = @(
            foreach ($formattedTarget in $formattedTargets) {
                [int] $addedCharCount = $formattedTarget.Length + ($currentBatch.Count -gt 0 ? 1 : 0)

                if ($currentBatch.Count -ge 256 -or $batchCharCount + $addedCharCount -gt 10000) {
                    if ($currentBatch.Count -gt 0) { , $currentBatch }
                    $currentBatch = @()
                    $batchCharCount = 0
                    $addedCharCount = $formattedTarget.Length
                }

                $currentBatch += $formattedTarget
                $batchCharCount += $addedCharCount
            }

            if ($currentBatch.Count -gt 0) { , $currentBatch }
        )

        [string] $providerScript = Get-Content -LiteralPath (Join-Path ([App]::root) "Provider.js") -Raw -ErrorAction Stop

        $this.cdpSessions = @(
            for ([int] $batchIndex = 0; $batchIndex -lt $targetBatches.Count; $batchIndex++) {
                [ClientWebSocket] $webSocket = [ClientWebSocket]::new()
                [CancellationTokenSource] $cancellationTokenSource = [CancellationTokenSource]::new(10000)

                $null = $webSocket.ConnectAsync([uri](Invoke-RestMethod "http://localhost:9222/json/new" -Method Put -OperationTimeoutSeconds 10 -ErrorAction Stop).webSocketDebuggerUrl, $cancellationTokenSource.Token).GetAwaiter().GetResult()
                $cancellationTokenSource.Dispose()

                [hashtable] $cdpSession = @{ WebSocket = $webSocket; CdpId = 0 }
                $null = $this.SendCdpCommand($cdpSession, "Page.enable", @{})
                $null = $this.SendCdpCommand($cdpSession, "Page.addScriptToEvaluateOnNewDocument", @{ source = $providerScript })
                $null = $this.SendCdpCommand($cdpSession, "Page.navigate", @{ url = $this.InvokeJsExpression($cdpSession, "$providerScript; getPageUrl()") })
                $cdpSession
            }
        )

        foreach ($cdpSession in $this.cdpSessions) {
            for ([int] $tryCount = 0; $tryCount -lt 10; $tryCount++) {
                if ($this.InvokeJsExpression($cdpSession, "isPageReady()")) { break }

                Start-Sleep 1
            }

            if ($tryCount -eq 10) { throw "检测页面加载超时" }
        }

        for ([int] $batchIndex = 0; $batchIndex -lt $targetBatches.Count; $batchIndex++) {
            $null = $this.InvokeJsExpression($this.cdpSessions[$batchIndex], "fillTargetTextarea($(ConvertTo-Json ($targetBatches[$batchIndex] -join "`n") -Compress)); clickStartButton()")
        }

        [bool[]] $completedBatches = [bool[]]::new($targetBatches.Count)
        [int] $completedBatchCount = 0
        [datetime] $checkStartTime = Get-Date
        [hashtable] $completedTargets = @{}

        while ($completedBatchCount -lt $targetBatches.Count) {
            Start-Sleep 1

            for ([int] $batchIndex = 0; $batchIndex -lt $targetBatches.Count; $batchIndex++) {
                if ($completedBatches[$batchIndex]) { continue }

                [bool] $batchTimedOut = [int]((Get-Date) - $checkStartTime).TotalSeconds -ge ($targetBatches[$batchIndex].Count + 10)
                [string] $batchResultJson = $this.InvokeJsExpression($this.cdpSessions[$batchIndex], "getResultData($(ConvertTo-Json (-not $batchTimedOut) -Compress))")

                foreach ($batchResult in ([string]::IsNullOrWhiteSpace($batchResultJson) ? @() : @(ConvertFrom-Json $batchResultJson))) {
                    if ($null -eq $batchResult.target -or -not $targetLookup.ContainsKey($batchResult.target) -or $completedTargets.ContainsKey($batchResult.target)) { continue }

                    $completedTargets[$batchResult.target] = $true
                    [int[]] $targetLatencies = @($batchResult.latencies)
                    [int] $targetLatency = [int]::MaxValue

                    if ($targetLatencies.Count -gt 0) {
                        [int] $timeoutCount = 0
                        [int] $totalLatency = 0

                        foreach ($latency in $targetLatencies) {
                            if ($latency -eq -1) { $timeoutCount++ }
                            $totalLatency += ($latency -eq -1 ? 300 : $latency)
                        }

                        $targetLatency = $timeoutCount -ge 3 ? [int]::MaxValue : [int]($totalLatency / $targetLatencies.Count)
                    }

                    foreach ($targetKey in $targetLookup[$batchResult.target]) {
                        $this.InvokeResultHandler($targetKey, $targetLatency, $resultHandler)
                    }
                }

                [bool] $batchCompleted = $true

                foreach ($formattedTarget in $targetBatches[$batchIndex]) {
                    if ($completedTargets.ContainsKey($formattedTarget)) { continue }

                    $batchCompleted = $false
                    break
                }

                if (-not $batchTimedOut -and -not $batchCompleted) { continue }

                if ($batchTimedOut) {
                    foreach ($formattedTarget in $targetBatches[$batchIndex]) {
                        if ($completedTargets.ContainsKey($formattedTarget)) { continue }

                        $completedTargets[$formattedTarget] = $true

                        foreach ($targetKey in $targetLookup[$formattedTarget]) {
                            $this.InvokeResultHandler($targetKey, [int]::MaxValue, $resultHandler)
                        }
                    }
                }

                $completedBatches[$batchIndex] = $true
                $completedBatchCount++
            }
        }
    }

    hidden [void] StopBrowserProcess() {
        foreach ($cdpSession in $this.cdpSessions) {
            try {
                if ($cdpSession.WebSocket.State -eq [WebSocketState]::Open) { $null = $cdpSession.WebSocket.CloseAsync([WebSocketCloseStatus]::NormalClosure, [string]::Empty, [CancellationToken]::None).GetAwaiter().GetResult() }
            }
            catch { continue }
            finally { $cdpSession.WebSocket.Dispose() }
        }

        $this.cdpSessions = @()

        if ($null -eq $this.browserProcess) { return }

        if (-not $this.browserProcess.HasExited) {
            Stop-Process $this.browserProcess -ErrorAction Stop
            Wait-Process -InputObject $this.browserProcess
        }

        $this.browserProcess.Dispose()
        $this.browserProcess = $null
    }

    static [void] WriteCheckResult([PSCustomObject] $result) {
        Write-Host "$($result.Target): $($result.Latency -eq [int]::MaxValue ? "超时" : "$($result.Latency) ms")"
    }

    static [void] Closing() {
        Write-Host "检测结果仅供参考" -ForegroundColor Red
    }

    hidden [PSCustomObject] SendCdpCommand([hashtable] $cdpSession, [string] $method, [hashtable] $params) {
        if ($cdpSession.WebSocket.State -ne [WebSocketState]::Open) { return $null }

        try { $null = $cdpSession.WebSocket.SendAsync([ArraySegment[byte]]::new([Encoding]::UTF8.GetBytes((ConvertTo-Json @{ id = ++$cdpSession.CdpId; method = $method; params = $params } -Compress))), [WebSocketMessageType]::Text, $true, [CancellationToken]::None).GetAwaiter().GetResult() }
        catch { return $null }

        [byte[]] $receiveBuffer = [byte[]]::new(32KB)
        [CancellationTokenSource] $cancellationTokenSource = [CancellationTokenSource]::new(10000)

        for ([int] $tryCount = 0; $tryCount -lt 30; $tryCount++) {
            if ($cdpSession.WebSocket.State -ne [WebSocketState]::Open) { return $null }

            [PSCustomObject] $cdpResponse = $null

            try {
                [byte[]] $responseBytes = @()

                do {
                    [WebSocketReceiveResult] $receiveResult = $cdpSession.WebSocket.ReceiveAsync([ArraySegment[byte]]::new($receiveBuffer), $cancellationTokenSource.Token).GetAwaiter().GetResult()

                    if ($receiveResult.MessageType -eq [WebSocketMessageType]::Close) { return $null }
                    if ($receiveResult.Count -gt 0) { $responseBytes += $receiveBuffer[0..($receiveResult.Count - 1)] }
                }
                while (-not $receiveResult.EndOfMessage)

                if ($responseBytes.Count -eq 0) { continue }

                $cdpResponse = ConvertFrom-Json ([Encoding]::UTF8.GetString($responseBytes))
            }
            catch {
                if ($cancellationTokenSource.IsCancellationRequested) { return $null }
                continue
            }

            if ($cdpResponse.id -eq $cdpSession.CdpId) {
                $cancellationTokenSource.Dispose()
                return $cdpResponse
            }
        }

        $cancellationTokenSource.Dispose()
        return $null
    }

    hidden [object] InvokeJsExpression([hashtable] $cdpSession, [string] $jsExpression) {
        return $this.SendCdpCommand($cdpSession, "Runtime.evaluate", @{ expression = $jsExpression; returnByValue = $true })?.result.result.value
    }

    hidden [void] InvokeResultHandler([string] $target, [int] $latency, [scriptblock] $resultHandler) {
        if ($null -eq $resultHandler) { return }

        & $resultHandler ([PSCustomObject] [ordered] @{ Target = $target; Latency = $latency })
    }
}

[App]::root = $PSScriptRoot