ブラウザ上のリンク先URLをポップアップ表示するPowerShellスクリプト

PowerShell と WPF を組み合わせて、マウスカーソル下のリンク先 URL をポップアップ表示するスクリプトを作成しました。
対象ブラウザを限定したり、ブロックリストで危険なサイトを赤色表示するなど、実用性の高い機能を盛り込んでいます。

主な機能

  • マウスカーソル下の要素からリンク(URL)を検出して表示
  • Chrome / Edge / Firefox など対象ブラウザを限定可能
  • ブロックリストに一致する URL は背景を赤色で警告
  • PowerShell を STA モード で自動再起動する仕組み付き
  • 起動時に自己テスト用のポップアップを表示可能

事前準備

  1. PowerShell 5.1 以上が必要です。
    (Windows 10 標準搭載の PowerShell でOKです)
  2. ブロックリストファイルを作成します。
    例: C:\myTool\AccessDeniedURL\URL_LIST.txt
# コメントは # から始まる
example.com
https://malicious-site.example

スクリプト本体

以下を .ps1 ファイルとして保存してください。
例: Show-UrlPopup.ps1

#requires -Version 5.1
Add-Type -AssemblyName PresentationCore,PresentationFramework,WindowsBase,UIAutomationClient

# ========= 設定(ここだけ好みに応じて変更) =========
# ① 起動時前面ロック ← 無効化
$LockToForegroundOnStart = $false

# ② プロセス名で限定(必要なものだけ列挙)
$TargetProcessNames = @('chrome','msedge','firefox')  # 例。大文字小文字は無視されます

# ③ タイトル正規表現 ← 無効化
$TargetWindowTitleRegex = $null

# 起動直後の自己テストポップアップを出すか(動作確認用)
$EnableStartupSelfTest = $true

# ブロックリスト(1行1ドメイン or URL。例: blogspot.com / https://example.com)
$BlocklistPath = 'C:\myTool\AccessDeniedURL\URL_LIST.txt'
# ================================================

# ==== STA 自動再起動(非 STA なら自分を STA で再起動して即終了)====
if ([System.Threading.Thread]::CurrentThread.GetApartmentState() -ne 'STA') {
  Write-Host '[INFO] STA で再起動します…' -ForegroundColor Yellow
  try {
    $psExe = (Get-Process -Id $PID).Path
  } catch {
    $psExe = "$env:WINDIR\System32\WindowsPowerShell\v1.0\powershell.exe"
  }

  $scriptPath = $PSCommandPath
  if (-not $scriptPath) { $scriptPath = $MyInvocation.MyCommand.Path }

  if ($scriptPath) {
    function Quote-Arg([string]$s) {
      if ($null -eq $s) { return $null }
      if ($s -match '\s|"') { return '"' + ($s -replace '"','`"') + '"' }
      return $s
    }

    $argList = @('-sta','-NoProfile','-ExecutionPolicy','Bypass','-File', $scriptPath) + $args
    $argStr  = ($argList | ForEach-Object { Quote-Arg $_ } | Where-Object { $_ -ne $null }) -join ' '

    Start-Process -FilePath $psExe -ArgumentList $argStr -WorkingDirectory (Get-Location)
    exit
  } else {
    Write-Warning 'STA が必要ですがスクリプトパスを特定できませんでした。手動で -sta 指定で起動してください。'
  }
}

# ==== Win32: カーソル座標 / 前面ウィンドウ ====
Add-Type @"
using System;
using System.Runtime.InteropServices;
public static class Native {
  [DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT lpPoint);
  [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
  public struct POINT { public int X; public int Y; }
}
"@

# ==== URLポップアップ(WPF)====
$popupWindow = New-Object System.Windows.Window
$popupWindow.WindowStyle         = 'None'
$popupWindow.AllowsTransparency  = $true
$popupWindow.Topmost             = $true
$popupWindow.ShowInTaskbar       = $false
$popupWindow.SizeToContent       = 'WidthAndHeight'
$popupWindow.Padding             = [System.Windows.Thickness]::new(10)
$popupWindow.Opacity             = 0.96
$popupWindow.IsHitTestVisible    = $false  # クリック透過

# 背景ブラシ(通常/ブロック)
$bgNormal  = [System.Windows.Media.SolidColorBrush]([System.Windows.Media.Color]::FromArgb(200, 32, 32, 32))
$bgBlocked = [System.Windows.Media.SolidColorBrush]([System.Windows.Media.Color]::FromArgb(220, 200, 32, 32))
$popupWindow.Background = $bgNormal

$border = New-Object System.Windows.Controls.Border
$border.CornerRadius    = '8'
$border.BorderBrush     = [System.Windows.Media.SolidColorBrush]([System.Windows.Media.Color]::FromArgb(160,200,200,200))
$border.BorderThickness = '1'
$border.Padding         = '6'

$textBlock = New-Object System.Windows.Controls.TextBlock
$textBlock.TextWrapping = 'Wrap'
$textBlock.MaxWidth     = 800
$textBlock.FontFamily   = 'Consolas, Meiryo, Segoe UI'
$textBlock.FontSize     = 13
$textBlock.Foreground   = [System.Windows.Media.Brushes]::White

$border.Child = $textBlock
$popupWindow.Content = $border

# 初期は非表示 & 一度だけハンドルを起こす(prime)
$null = $popupWindow.Hide()
$popupWindow.Opacity = 0.0
$popupWindow.Left = -10000
$popupWindow.Top  = -10000
$null = $popupWindow.Show()
$popupWindow.UpdateLayout()
$null = $popupWindow.Hide()
$popupWindow.Opacity = 0.96

# ==== ターゲットウィンドウの特定 ====
[int]$LockedTargetHwnd = 0
if ($LockToForegroundOnStart) {
  $h = [Native]::GetForegroundWindow()
  if ($h -ne [IntPtr]::Zero) { $LockedTargetHwnd = $h.ToInt32() }
}

# ==== MSAA 役割定数(LegacyIAccessible.Role)====
# ROLE_SYSTEM_LINK = 0x1E (= 30)
$ROLE_SYSTEM_LINK = 0x1E

# ==== ユーティリティ ====
function Get-ElementUnderCursor {
  $pt = New-Object Native+POINT
  [Native]::GetCursorPos([ref]$pt) | Out-Null
  $sysPt = [System.Windows.Point]::new($pt.X, $pt.Y)
  [System.Windows.Automation.AutomationElement]::FromPoint($sysPt)
}

function Get-HyperlinkAncestor([System.Windows.Automation.AutomationElement]$el, [int]$maxDepth = 8) {
  if (-not $el) { return $null }
  $walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker
  $cur = $el
  $depth = 0
  while ($cur -and $depth -le $maxDepth) {
    try {
      # UIAのControlTypeでハイパーリンクか?
      if ($cur.Current.ControlType -eq [System.Windows.Automation.ControlType]::Hyperlink) { return $cur }
    } catch {}

    # MSAAのロールで「リンク」か?(LegacyIAccessible)
    try {
      $legacy = $cur.GetCurrentPattern([System.Windows.Automation.LegacyIAccessiblePatternIdentifiers]::Pattern)
      if ($legacy) {
        $role = $legacy.Current.Role
        if ($role -eq $script:ROLE_SYSTEM_LINK) { return $cur }
      }
    } catch {}

    $cur = $walker.GetParent($cur)
    $depth++
  }
  return $null
}


function Get-TopLevelWindowElement([System.Windows.Automation.AutomationElement]$el) {
  if (-not $el) { return $null }
  $walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker
  $top = $el
  $max = 64
  while ($max -gt 0) {
    $p = $walker.GetParent($top)
    if (-not $p) { break }
    $top = $p; $max--
  }
  return $top
}

function Is-InTargetWindow([System.Windows.Automation.AutomationElement]$topWinEl) {
  if (-not $topWinEl) { return $false }

  # ① ハンドル固定があれば最優先
  try {
    $hwnd = $topWinEl.Current.NativeWindowHandle
    if ($LockedTargetHwnd -ne 0) {
      if ($hwnd -ne $LockedTargetHwnd) { return $false }
    }
  } catch {}

  # ② プロセス名チェック
  if ($TargetProcessNames -and $TargetProcessNames.Count -gt 0) {
    try {
      $pid = $topWinEl.Current.ProcessId
      $proc = Get-Process -Id $pid -ErrorAction SilentlyContinue
      if ($proc) {
        $name = $proc.ProcessName.ToLowerInvariant()
        if (-not ($TargetProcessNames | ForEach-Object { $_.ToLowerInvariant() } | Where-Object { $_ -eq $name })) {
          return $false
        }
      }
    } catch {}
  }

  # ③ タイトル正規表現
  if ($TargetWindowTitleRegex) {
    try {
      $title = [string]$topWinEl.Current.Name
      if (-not ($title -match $TargetWindowTitleRegex)) { return $false }
    } catch {}
  }

  return $true
}

# ==== ブロックリスト読み込み ====
$BlockedDomains = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::InvariantCultureIgnoreCase)
if (Test-Path -LiteralPath $BlocklistPath) {
  Get-Content -LiteralPath $BlocklistPath -ErrorAction SilentlyContinue | ForEach-Object {
    $t = $_.Trim()
    if (-not $t -or $t.StartsWith('#')) { return }
    # URL形式ならホスト抽出、そうでなければドメインとして扱う
    $hostName = $null
    if ($t -match '^\w+://') {
      try { $u = [Uri]$t } catch {}
      if ($u -and $u.Host) { $hostName = $u.Host }
    }
    if (-not $hostName) { $hostName = $t }
    # 正規化
    $hostName = $hostName.Trim().Trim('.')
    if ($hostName.StartsWith('www.')) { $hostName = $hostName.Substring(4) }
    if ($hostName) { [void]$BlockedDomains.Add($hostName) }
  }
}

function Get-HostFromUrl([string]$url) {
  if ([string]::IsNullOrWhiteSpace($url)) { return $null }
  try {
    # スキームが無い場合は補う
    if ($url -notmatch '^[a-z]+://') { $url = 'https://' + $url }
    $u = [Uri]$url
    return $u.Host
  } catch { return $null }
}

function Is-UrlBlocked([string]$url) {
  $h = Get-HostFromUrl $url
  if (-not $h) { return $false }
  foreach ($d in $BlockedDomains) {
    if ($h.Equals($d, [System.StringComparison]::InvariantCultureIgnoreCase)) { return $true }
    if ($h.EndsWith('.' + $d, [System.StringComparison]::InvariantCultureIgnoreCase)) { return $true }
  }
  return $false
}

# ==== ここからリダイレクト復元ロジックを追加 ====

function Try-Decode-Base64Url {
  param([string]$s)
  if (-not $s) { return $null }
  # URLセーフ Base64 -> 標準 Base64 に変換
  $b = $s -replace '-','+' -replace '_','/'
  switch ($b.Length % 4) {
    2 { $b += '==' }
    3 { $b += '=' }
    1 { return $null }
    default {}
  }
  try {
    $bytes = [Convert]::FromBase64String($b)
    return [System.Text.Encoding]::UTF8.GetString($bytes)
  } catch { return $null }
}

function Resolve-RedirectUrl {
  param([string]$redirUrl)
  if ([string]::IsNullOrWhiteSpace($redirUrl)) { return $null }
  try { $uri = [uri]$redirUrl } catch { return $null }

  # クエリパラをハッシュに
  $qs = @{}
  if ($uri.Query -and $uri.Query.Length -gt 1) {
    $pairs = $uri.Query.TrimStart('?').Split('&') | Where-Object { $_ -ne '' }
    foreach ($p in $pairs) {
      $kv = $p.Split('=',2)
      $k = $kv[0]
      $v = if ($kv.Count -ge 2) { $kv[1] } else { '' }
      $qs[$k] = $v
    }
  }

  # 1) 'u' param (Bing の ck/a 等) を優先
  if ($qs.ContainsKey('u')) {
    $raw = [System.Uri]::UnescapeDataString($qs['u'])
    # u が plain URL の場合もあるし base64 の場合もある。base64 っぽい断片を抽出してデコード試行
    $m = [regex]::Match($raw, '[A-Za-z0-9\-_]+={0,2}')
    if ($m.Success) {
      $dec = Try-Decode-Base64Url $m.Value
      if ($dec -and $dec.StartsWith('http', [System.StringComparison]::InvariantCultureIgnoreCase)) {
        return $dec
      }
    }
    if ($raw.StartsWith('http')) { return $raw }
  }

  # 2) q, url, target, to, redirect などの一般的パラ
  foreach ($k in @('q','url','target','to','redirect')) {
    if ($qs.ContainsKey($k)) {
      $val = [System.Uri]::UnescapeDataString($qs[$k])
      if ($val -and $val.StartsWith('http')) { return $val }
      $m2 = [regex]::Match($val, '[A-Za-z0-9\-_]+={0,2}')
      if ($m2.Success) {
        $d2 = Try-Decode-Base64Url $m2.Value
        if ($d2 -and $d2.StartsWith('http')) { return $d2 }
      }
    }
  }

  # 3) パスに base64 が埋まっているケース(簡易探索)
  $m3 = [regex]::Match($uri.AbsoluteUri, '[A-Za-z0-9\-_]{16,}={0,2}')
  if ($m3.Success) {
    $d3 = Try-Decode-Base64Url $m3.Value
    if ($d3 -and $d3.StartsWith('http')) { return $d3 }
  }

  # 4) クエリの全値を unescape して http で始まるものがあれば返す
  foreach ($v in $qs.Values) {
    $uv = [System.Uri]::UnescapeDataString($v)
    if ($uv -and $uv.StartsWith('http')) { return $uv }
  }

  return $null
}

# ==== URL抽出用のRegex ====
$UrlRegexFull   = '(https?://[^\s"''<>]+)'
$UrlRegexDomain = '((?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,})(?:/[^\s"''<>]*)?'

function Add-Candidate([System.Collections.Generic.List[string]]$list, [string]$s) {
  if ($s -and $s.Trim().Length -gt 0) { $list.Add([string]$s) }
}

function Collect-CandidatesFromElement([System.Windows.Automation.AutomationElement]$el) {
  $list = [System.Collections.Generic.List[string]]::new()
  if (-not $el) { return $list }

  try {
    $c = $el.Current
    Add-Candidate $list $c.Name
    $help = $el.GetCurrentPropertyValue([System.Windows.Automation.AutomationElement]::HelpTextProperty)
    Add-Candidate $list ([string]$help)
  } catch {}

  try {
    $legacy = $el.GetCurrentPattern([System.Windows.Automation.LegacyIAccessiblePatternIdentifiers]::Pattern)
    if ($legacy -and $legacy.Current.Value) { Add-Candidate $list ([string]$legacy.Current.Value) }
  } catch {}

  try {
    $valPat = $el.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern)
    if ($valPat -and $valPat.Current.Value) { Add-Candidate $list ([string]$valPat.Current.Value) }
  } catch {}

  try {
    $textPat = $el.GetCurrentPattern([System.Windows.Automation.TextPattern]::Pattern)
    if ($textPat) {
      $rng = $textPat.DocumentRange
      $txt = $rng.GetText(8192)
      Add-Candidate $list $txt
    }
  } catch {}

  return $list
}

function Extract-UrlFromElement([System.Windows.Automation.AutomationElement]$el) {
  if (-not $el) { return $null }

  # 0) まず「リンク祖先」を特定して、そこからURLを最優先で拾う
  $linkEl = Get-HyperlinkAncestor $el 8
  if ($linkEl) {
    # 祖先リンク要素の候補を優先的に集める
    $priorityCands = [System.Collections.Generic.List[string]]::new()

    # a) LegacyIAccessible.Value は href 相当率が高い
    try {
      $legacy = $linkEl.GetCurrentPattern([System.Windows.Automation.LegacyIAccessiblePatternIdentifiers]::Pattern)
      if ($legacy) {
        if ($legacy.Current.Value) { $priorityCands.Add([string]$legacy.Current.Value) }
        # 念のため Name/Description/Help も候補へ
        if ($legacy.Current.Name)        { $priorityCands.Add([string]$legacy.Current.Name) }
        if ($legacy.Current.Description) { $priorityCands.Add([string]$legacy.Current.Description) }
        if ($legacy.Current.Help)        { $priorityCands.Add([string]$legacy.Current.Help) }
      }
    } catch {}

    # b) UIA の Name / HelpText
    try {
      $priorityCands.Add([string]$linkEl.Current.Name)
      $help = $linkEl.GetCurrentPropertyValue([System.Windows.Automation.AutomationElement]::HelpTextProperty)
      if ($help) { $priorityCands.Add([string]$help) }
    } catch {}

    # c) TextPattern(リンクテキスト全体)
    try {
      $tp = $linkEl.GetCurrentPattern([System.Windows.Automation.TextPattern]::Pattern)
      if ($tp) {
        $txt = $tp.DocumentRange.GetText(8192)
        if ($txt) { $priorityCands.Add([string]$txt) }
      }
    } catch {}

    # 優先候補から URL を探す(見つかったら復元を試す)
    foreach ($s in $priorityCands) {
      if ($s) {
        $m = [regex]::Match([string]$s, $UrlRegexFull)
        if ($m.Success) {
          $raw = $m.Value
          $resolved = Resolve-RedirectUrl $raw
          if ($resolved) { return $resolved } else { return $raw }
        }
      }
    }

    # 優先候補からドメイン風文字列を探す(URLが無い場合)
    foreach ($s in $priorityCands) {
      if ($s) {
        $m2 = [regex]::Match([string]$s, $UrlRegexDomain)
        if ($m2.Success) {
          $val = $m2.Value
          if ($val -notmatch '^https?://') { $val = 'https://' + $val }
          return $val
        }
      }
    }
    # 祖先リンクから取れなかった場合は、通常フローへフォールバック
  }

  # 1) 自要素から候補収集(既存ロジック)
  $cands = Collect-CandidatesFromElement $el

  # 2) 親(最大6階層に拡張)
  try {
    $walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker
    $p = $walker.GetParent($el)
    $depth = 0
    while ($p -and $depth -lt 6) {
      foreach ($s in (Collect-CandidatesFromElement $p)) { Add-Candidate $cands $s }
      $p = $walker.GetParent($p); $depth++
    }
  } catch {}

  # 3) 子(直下)
  try {
    $children = $el.FindAll([System.Windows.Automation.TreeScope]::Children,
                             [System.Windows.Automation.Condition]::TrueCondition)
    foreach ($i in 0..($children.Count-1)) {
      $child = $children.Item($i)
      foreach ($s in (Collect-CandidatesFromElement $child)) { Add-Candidate $cands $s }
    }
  } catch {}

  # 4) 判定(完全URL優先→ドメイン風)
  foreach ($s in $cands) {
    $m = [regex]::Match([string]$s, $UrlRegexFull)
    if ($m.Success) {
      $raw = $m.Value
      $resolved = Resolve-RedirectUrl $raw
      if ($resolved) { return $resolved } else { return $raw }
    }
  }
  foreach ($s in $cands) {
    $m2 = [regex]::Match([string]$s, $UrlRegexDomain)
    if ($m2.Success) {
      $val = $m2.Value
      if ($val -notmatch '^https?://') { $val = 'https://' + $val }
      return $val
    }
  }

  return $null
}

function Show-UrlPopup([string]$url, [int]$x, [int]$y) {
  if ([string]::IsNullOrWhiteSpace($url)) { Hide-UrlPopup; return }

  $display = if ($url.Length -gt 280) { $url.Substring(0,277) + '...' } else { $url }
  $textBlock.Text = $display

  # 背景色の切替(ブロック一致なら赤、そうでなければ通常)
  if (Is-UrlBlocked $url) {
    if ($popupWindow.Background -ne $bgBlocked) { $popupWindow.Background = $bgBlocked }
  } else {
    if ($popupWindow.Background -ne $bgNormal)  { $popupWindow.Background = $bgNormal  }
  }

  $offsetX = 16; $offsetY = 24
  $left = $x + $offsetX
  $top  = $y + $offsetY

  $vsLeft   = [System.Windows.SystemParameters]::VirtualScreenLeft
  $vsTop    = [System.Windows.SystemParameters]::VirtualScreenTop
  $vsWidth  = [System.Windows.SystemParameters]::VirtualScreenWidth
  $vsHeight = [System.Windows.SystemParameters]::VirtualScreenHeight

  $popupWindow.Measure([System.Windows.Size]::new([double]::PositiveInfinity,[double]::PositiveInfinity))
  $sz = $popupWindow.DesiredSize

  if ($left + $sz.Width  -gt $vsLeft + $vsWidth)  { $left = [math]::Max($vsLeft, ($vsLeft + $vsWidth) - $sz.Width  - 8) }
  if ($top  + $sz.Height -gt $vsTop  + $vsHeight) { $top  = [math]::Max($vsTop,  ($vsTop  + $vsHeight) - $sz.Height - 8) }
  if ($left  -lt $vsLeft)  { $left = $vsLeft }
  if ($top   -lt $vsTop)   { $top  = $vsTop  }

  $popupWindow.Left = $left
  $popupWindow.Top  = $top

  if (-not $popupWindow.IsVisible) { $null = $popupWindow.Show() }
}

function Hide-UrlPopup {
  if ($popupWindow.IsVisible) { $null = $popupWindow.Hide() }
}

function Close-UrlPopup {
  try {
    if ($popupWindow) {
      if ($popupWindow.Dispatcher.HasShutdownStarted -or $popupWindow.Dispatcher.HasShutdownFinished) { return }
      $popupWindow.Dispatcher.Invoke({
        try {
          if ($this.IsVisible) { $this.Hide() }
          $this.Close()
        } catch {}
      })
    }
  } catch {}
}

# ==== DispatcherTimer ====
$timer = [System.Windows.Threading.DispatcherTimer]::new()
$timer.Interval = [TimeSpan]::FromMilliseconds(140)

$lastRuntimeId   = $null
$lastShownUrl    = $null
$lastVisibleTick = [Environment]::TickCount
$hideDelayMs     = 250

$timer.Add_Tick({
  try {
    # カーソル位置
    $pt = New-Object Native+POINT
    [Native]::GetCursorPos([ref]$pt) | Out-Null

    $el = Get-ElementUnderCursor
    if ($el) {
      # 最上位ウィンドウを特定し、対象か判定
      $topWin = Get-TopLevelWindowElement $el
      if (-not (Is-InTargetWindow $topWin)) {
        Hide-UrlPopup
        return
      }

      $id  = ($el.GetRuntimeId() -join '.')
      $url = Extract-UrlFromElement $el

      if ($url) {
        Show-UrlPopup -url $url -x $pt.X -y $pt.Y
        $lastShownUrl    = $url
        $lastVisibleTick = [Environment]::TickCount
      } else {
        $elapsed = [Environment]::TickCount - $lastVisibleTick
        if ($elapsed -ge $hideDelayMs) {
          Hide-UrlPopup
          $lastShownUrl = $null
        }
      }

      if ($id -ne $lastRuntimeId) {
        $lastRuntimeId = $id
        $ct  = $el.Current.ControlType.ProgrammaticName
        $nm  = $el.Current.Name
        $ht  = $el.GetCurrentPropertyValue([System.Windows.Automation.AutomationElement]::HelpTextProperty)

        $legacyValue = $null
        try {
          $legacy = $el.GetCurrentPattern([System.Windows.Automation.LegacyIAccessiblePatternIdentifiers]::Pattern)
          if ($legacy) { $legacyValue = $legacy.Current.Value }
        } catch {}

        #Write-Host "Type=$ct`nName=$nm`nHelpText=$ht`nLegacyValue=$legacyValue`nURL=$url`nTopWin='$($topWin.Current.Name)'`n----"
      }
    } else {
      Hide-UrlPopup
      $lastShownUrl = $null
    }
  } catch {
    # 握りつぶし
  }
})

$timer.Start()

# ==== 起動自己テスト(任意)====
if ($EnableStartupSelfTest) {
  try {
    $textBlock.Text = 'TEST: https://example.com'
    $popupWindow.Background = $bgNormal
    $popupWindow.Left = [System.Windows.SystemParameters]::WorkArea.Left + 60
    $popupWindow.Top  = [System.Windows.SystemParameters]::WorkArea.Top + 60
    $null = $popupWindow.Show()
    Start-Sleep -Milliseconds 1200
    $null = $popupWindow.Hide()
    $textBlock.Text = ''
  } catch {}
}

# ==== 終了ハンドリング ====
try {
  $global:__UiAuto_ccHandler = [ConsoleCancelEventHandler]{ param($s,$e)
    try { $timer.Stop() } catch {}
    try { Close-UrlPopup } catch {}
  }
  [Console]::add_CancelKeyPress($global:__UiAuto_ccHandler) | Out-Null
} catch {}

$global:__UiAuto_exitSub = Register-EngineEvent -SourceIdentifier PowerShell.Exiting -Action {
  try {
    $t = $ExecutionContext.SessionState.PSVariable.GetValue('timer')
    if ($t) { $t.Stop() }
  } catch {}
  try {
    $w = $ExecutionContext.SessionState.PSVariable.GetValue('popupWindow')
    if ($w -and -not $w.Dispatcher.HasShutdownStarted) {
      $w.Dispatcher.Invoke({ try { if ($this.IsVisible) { $this.Hide() }; $this.Close() } catch {} })
    }
  } catch {}
}

# ConsoleHost では Dispatcher を自前で回す(ISE は既に回っている)
if ($Host.Name -ne 'Windows PowerShell ISE Host') {
  try {
    [System.Windows.Threading.Dispatcher]::Run()
  } finally {
    try { $timer.Stop() } catch {}
    try { Close-UrlPopup } catch {}
    try {
      if ($global:__UiAuto_ccHandler) { [Console]::remove_CancelKeyPress($global:__UiAuto_ccHandler) }
    } catch {}
    try {
      if ($global:__UiAuto_exitSub) {
        Unregister-Event -SourceIdentifier PowerShell.Exiting -ErrorAction SilentlyContinue
        if ($global:__UiAuto_exitSub.Action -and $global:__UiAuto_exitSub.Action.Job) {
          Remove-Job $global:__UiAuto_exitSub.Action.Job -Force -ErrorAction SilentlyContinue
        }
      }
    } catch {}
    try { Get-EventSubscriber | Unregister-Event -ErrorAction SilentlyContinue } catch {}
    try { Get-Job | Remove-Job -Force -ErrorAction SilentlyContinue } catch {}
  }
}

動作イメージ

  • 通常サイトのリンク → 灰色背景 のポップアップ
  • ブロック対象サイトのリンク → 赤色背景 のポップアップ

これにより、リンク先を確認しながら安全にブラウジングできます。

使い方

  1. PowerShell で以下を実行:
powershell -sta -ExecutionPolicy Bypass -File .\Show-UrlPopup.ps1

-sta は自動で付与されるので、通常の実行でも動作します。

対象ブラウザを操作して、リンクにカーソルを合わせるとポップアップが表示されます。

検証用コード

# Requires -Version 5.1
param([int]$IntervalMs = 120)

# ===== STA でなければ自動再起動 =====
if ([Threading.Thread]::CurrentThread.ApartmentState -ne 'STA') {
    $argsList = @('-sta','-NoProfile','-ExecutionPolicy','Bypass','-File', $PSCommandPath)
    if ($PSBoundParameters.Count) { $PSBoundParameters.GetEnumerator() | % { $argsList += @("-$($_.Key)","$($_.Value)") } }
    Start-Process -FilePath (Get-Command powershell).Source -ArgumentList $argsList | Out-Null
    exit
}

# ===== 依存アセンブリ =====
Add-Type -AssemblyName UIAutomationClient
Add-Type -AssemblyName UIAutomationTypes
Add-Type -AssemblyName PresentationCore,PresentationFramework,WindowsBase
Add-Type @"
using System;
using System.Runtime.InteropServices;
public struct POINT { public int X; public int Y; }
public static class Native {
  [DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT pt);
  [DllImport("user32.dll")] public static extern short GetAsyncKeyState(int vKey);
}
"@ | Out-Null

# ===== 可用性チェック: LegacyIAccessible* が無い環境に対応 =====
$LegacyIdsType = [Type]::GetType(
  "System.Windows.Automation.LegacyIAccessiblePatternIdentifiers, UIAutomationTypes",
  $false
)

# ===== 設定 =====
$BlocklistPath = 'C:\myTool\AccessDeniedURL\URL_LIST.txt'
$hideDelayMs   = 1200
$lastVisibleTick = 0

# ===== ブロックリスト読み込み =====
$BlockedDomains = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::InvariantCultureIgnoreCase)
if (Test-Path -LiteralPath $BlocklistPath) {
  Get-Content -LiteralPath $BlocklistPath -ErrorAction SilentlyContinue | %{
    $t = $_.Trim(); if (-not $t -or $t.StartsWith('#')) { return }

    # !!! ここを $host → $hostName に変更($Host 自動変数との衝突回避) !!!
    $hostName = $null

    if ($t -match '^\w+://') {
      try { $u = [Uri]$t } catch {}
      if ($u -and $u.Host){ $hostName = $u.Host }
    }
    if (-not $hostName){ $hostName = $t }

    $hostName = $hostName.Trim().Trim('.')
    if ($hostName.StartsWith('www.')){ $hostName = $hostName.Substring(4) }

    if ($hostName){ [void]$BlockedDomains.Add($hostName) }
  }
}

# ===== ユーティリティ =====
function Test-EscapePressed { (([Native]::GetAsyncKeyState(0x1B) -band 0x8000) -ne 0) }
function Get-CursorPoint  { $pt = New-Object POINT; [Native]::GetCursorPos([ref]$pt) | Out-Null; [Windows.Point]::new($pt.X,$pt.Y) }
function Get-HostFromUrl([string]$url){ if([string]::IsNullOrWhiteSpace($url)){return $null}; try{ if($url -notmatch '^[a-z]+://'){ $url='https://'+$url }; ([Uri]$url).Host }catch{ $null } }
function Is-UrlBlocked([string]$url){
  $h=Get-HostFromUrl $url; if(-not $h){ return $false }
  foreach($d in $BlockedDomains){
    if ($h.Equals($d,[StringComparison]::InvariantCultureIgnoreCase)) {return $true}
    if ($h.EndsWith('.'+$d,[StringComparison]::InvariantCultureIgnoreCase)) {return $true}
  }; $false
}

function Get-BingRealUrlFromString {
  param([string]$Url)
  if ([string]::IsNullOrWhiteSpace($Url)) { return $null }
  try { $uri=[Uri]$Url } catch { return $Url }
  Add-Type -AssemblyName System.Web | Out-Null
  $qs=[System.Web.HttpUtility]::ParseQueryString($uri.Query)
  $v=$qs["u"]
  if (-not $v) { return $Url }
  $v = $v -replace '^a1',''
  try{
    if ($v -match '^[A-Za-z0-9\-_]+=*$') {
      $tmp=$v.Replace('-','+').Replace('_','/')
      switch ($tmp.Length % 4) { 2{$tmp+='=='} 3{$tmp+='='} }
      $bytes=[Convert]::FromBase64String($tmp)
      $decoded=[Text.Encoding]::UTF8.GetString($bytes)
      if ($decoded -match '^\w+://') { return $decoded }
    }
  }catch{}
  return $v
}

# ===== 文字列(=URLっぽい値)を引き出す =====
function Get-ElementTextLikeValue {
  param([System.Windows.Automation.AutomationElement]$El)
  if (-not $El){
    write-host "-not El"
    return $null
  }

  # 1) ValuePattern
  $vp=$null
  if ($El.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$vp)) {
    $v=$vp.Current.Value; 
    write-host "ValuePattern: $v"
    if ($v) { return $v }
  }
  $v=$El.GetCurrentPropertyValue([System.Windows.Automation.ValuePattern]::ValueProperty)
  if ($v -and $v -ne [System.Windows.Automation.Automation]::NotSupported) {
    write-host "NotSupported: $v"
    return $v
  }

  # 2) LegacyIAccessible (存在すれば)
  if ($LegacyIdsType) {
    $valProp = $LegacyIdsType::ValueProperty
    $v = $El.GetCurrentPropertyValue($valProp)
    if ($v -and $v -ne [System.Windows.Automation.Automation]::NotSupported) {
        write-host "LegacyIAccessible: $v"
        return $v
    }
  }

  # 3) Name / HelpText
  if ($El.Current.Name) { return $El.Current.Name }
  $help=$El.GetCurrentPropertyValue([System.Windows.Automation.AutomationElement]::HelpTextProperty)
  if ($help -and $help -ne [System.Windows.Automation.Automation]::NotSupported) {
     write-host "Name / HelpText: $help"
    return $help
  }

  $null
}

# ===== WPF ポップアップ =====
$popupWindow = New-Object System.Windows.Window
$popupWindow.WindowStyle='None'
$popupWindow.AllowsTransparency=$true
$popupWindow.Topmost=$true
$popupWindow.ShowInTaskbar=$false
$popupWindow.SizeToContent='WidthAndHeight'
$popupWindow.Padding=[Windows.Thickness]::new(10)
$popupWindow.Opacity=0.96
$popupWindow.IsHitTestVisible=$false

$bgNormal  = [Windows.Media.SolidColorBrush]([Windows.Media.Color]::FromArgb(200, 32, 32, 32))
$bgBlocked = [Windows.Media.SolidColorBrush]([Windows.Media.Color]::FromArgb(220,200, 32, 32))
$popupWindow.Background=$bgNormal

$border = New-Object Windows.Controls.Border
$border.CornerRadius='8'
$border.BorderBrush=[Windows.Media.SolidColorBrush]([Windows.Media.Color]::FromArgb(160,200,200,200))
$border.BorderThickness='1'
$border.Padding='6'

$textBlock = New-Object Windows.Controls.TextBlock
$textBlock.TextWrapping='Wrap'
$textBlock.MaxWidth=800
$textBlock.FontFamily='Consolas, Meiryo, Segoe UI'
$textBlock.FontSize=13
$textBlock.Foreground=[Windows.Media.Brushes]::White

$border.Child=$textBlock
$popupWindow.Content=$border

function Show-UrlPopup([string]$url,[int]$x,[int]$y){
  if([string]::IsNullOrWhiteSpace($url)){ Hide-UrlPopup; return }
  $display = if($url.Length -gt 280){ $url.Substring(0,277) + '...' } else { $url }
  $textBlock.Text = $display
  $popupWindow.Background = (if (Is-UrlBlocked $url) { $bgBlocked } else { $bgNormal })

  $offsetX=16; $offsetY=24
  $left=$x+$offsetX; $top=$y+$offsetY

  $vsLeft=[Windows.SystemParameters]::VirtualScreenLeft
  $vsTop =[Windows.SystemParameters]::VirtualScreenTop
  $vsW   =[Windows.SystemParameters]::VirtualScreenWidth
  $vsH   =[Windows.SystemParameters]::VirtualScreenHeight

  $popupWindow.Measure([Windows.Size]::new([double]::PositiveInfinity,[double]::PositiveInfinity))
  $sz=$popupWindow.DesiredSize

  if($left+$sz.Width  -gt $vsLeft+$vsW){ $left=[math]::Max($vsLeft,($vsLeft+$vsW)-$sz.Width-8) }
  if($top +$sz.Height -gt $vsTop +$vsH){ $top =[math]::Max($vsTop ,($vsTop +$vsH)-$sz.Height-8) }
  if($left -lt $vsLeft){ $left=$vsLeft }
  if($top  -lt $vsTop ){ $top =$vsTop  }

  $popupWindow.Left=$left; $popupWindow.Top=$top
  if(-not $popupWindow.IsVisible){ $null=$popupWindow.Show() }
}

function Hide-UrlPopup { if($popupWindow.IsVisible){ $null=$popupWindow.Hide() } }

# ===== メインループ =====
Write-Host "Move your mouse... (Esc to exit)  Polling: ${IntervalMs}ms" -ForegroundColor Cyan
while ($true) {
  if (Test-EscapePressed) { break }

  $url = $null
  try {
    $pt = Get-CursorPoint
    $el = [System.Windows.Automation.AutomationElement]::FromPoint($pt)
    
    # まず ValuePattern / Legacy / Name から文字列を引く
    $s = Get-ElementTextLikeValue -El $el
    # 文字列が URL らしければ URL として採用
    if ($s -and ($s -match '^[a-z]+://|^[\w\.\-]+\.[A-Za-z]{2,}(/|$)')) {
      $url = $s
    }
    # Bing リダイレクトっぽいものは生 URL へ復号
    if ($s -and $s -match '(^https?://www\.bing\.com/)|([?&]u=)') {
      $decoded = Get-BingRealUrlFromString $s
      if ($decoded) { $s = $decoded }
      return $s
    }


    if ($url) {
      Show-UrlPopup -url $url -x $pt.X -y $pt.Y
      $lastVisibleTick = [Environment]::TickCount
    } else {
      if([Environment]::TickCount - $lastVisibleTick -ge $hideDelayMs) { Hide-UrlPopup }
    }
  }
  catch {
    # 要素消失等の瞬間例外は握りつぶし
  }

  Start-Sleep -Milliseconds $IntervalMs
}
Hide-UrlPopup
Write-Host "Stopped." -ForegroundColor Yellow
# Requires -Version 5.1
param(
    [switch]$Watch,            # 付けると ESC まで監視ループ
    [int]$PollMs = 120         # 監視時のポーリング間隔
)

# ===== STA でなければ自動再起動 =====
if ([Threading.Thread]::CurrentThread.ApartmentState -ne 'STA') {
    $argsList = @('-sta','-NoProfile','-ExecutionPolicy','Bypass','-File', $PSCommandPath)
    if ($PSBoundParameters.Count) {
        $PSBoundParameters.GetEnumerator() | ForEach-Object {
            if ($_.Value -is [switch]) {
                if ($_.Value.IsPresent) { $argsList += "-$($_.Key)" }
            } else {
                $argsList += @("-$($_.Key)","$($_.Value)")
            }
        }
    }
    Start-Process -FilePath (Get-Command powershell).Source -ArgumentList $argsList | Out-Null
    exit
}

# ===== 依存アセンブリ =====
Add-Type -AssemblyName UIAutomationClient
Add-Type -AssemblyName UIAutomationTypes
Add-Type -AssemblyName PresentationCore,PresentationFramework,WindowsBase

# ===== 定数・補助 =====
$ct = [System.Windows.Automation.ControlType]
$ae = [System.Windows.Automation.AutomationElement]
$condTrue = [System.Windows.Automation.Condition]::TrueCondition

function Get-CursorPoint {
    Add-Type -Namespace Ux -Name Native -MemberDefinition @"
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
public struct POINT { public int X; public int Y; }
[System.Runtime.InteropServices.DllImport("user32.dll")]
public static extern bool GetCursorPos(out POINT pt);
"@
    $pt = New-Object Ux.Native+POINT
    [void][Ux.Native]::GetCursorPos([ref]$pt)
    # FromPoint は画面座標(ピクセル)
    New-Object System.Windows.Point($pt.X, $pt.Y)
}

function Get-ElementFromCursor {
    $p = Get-CursorPoint
    try { return [System.Windows.Automation.AutomationElement]::FromPoint($p) }
    catch { return $null }
}

function Find-Ancestor {
    param(
        [Parameter(Mandatory)] [System.Windows.Automation.AutomationElement]$Element,
        [ScriptBlock]$Predicate
    )
    $cur = $Element
    while ($cur) {
        if ($Predicate -and (& $Predicate $cur)) { return $cur }
        $cur = $cur.GetCachedParent()
        if (-not $cur) {
            $cur = $cur.GetCurrentPropertyValue([System.Windows.Automation.AutomationElement]::NativeWindowHandleProperty) | Out-Null
            # 親の取り方を安全に:TreeWalker.RawViewWalker
            $cur = [System.Windows.Automation.TreeWalker]::RawViewWalker.GetParent($Element)
        }
        $Element = $cur
    }
    return $null
}

function Test-LooksLikeUrl {
    param([string]$s)
    if (-not $s) { return $false }
    return ($s -match '^(https?|file|ftp)://') -or ($s -match '^[\w-]+\.[\w\.-]+(/|$)')
}

function TryGet-UAValue {
    param([System.Windows.Automation.AutomationElement]$el)
    try {
        $p = $el.GetCurrentPattern([System.Windows.Automation.LegacyIAccessiblePattern]::Pattern)
        if ($p) {
            $val = $p.Current.Value
            if (Test-LooksLikeUrl $val) { return $val }
            $desc = $p.Current.Description
            if (Test-LooksLikeUrl $desc) { return $desc }
        }
    } catch {}
    return $null
}

function TryGet-UAProps {
    param([System.Windows.Automation.AutomationElement]$el)
    $cands = @(
        $el.Current.HelpText,
        $el.Current.ItemStatus,
        $el.Current.Name
    ) | Where-Object { $_ } 
    foreach ($c in $cands) {
        # 複数語の中に URL が混ざっている場合を抽出
        if ($c -match '(https?://\S+)' ) { return $Matches[1].TrimEnd('…', '.', ',', '、', '。') }
        if (Test-LooksLikeUrl $c) { return $c }
    }
    return $null
}

function Get-TopLevelWindow($el) {
    $walker = [System.Windows.Automation.TreeWalker]::RawViewWalker
    $cur = $el
    while ($cur) {
        $parent = $walker.GetParent($cur)
        if (-not $parent) { break }
        $cur = $parent
    }
    return $cur
}

function TryGet-StatusBarUrl {
    param([System.Windows.Automation.AutomationElement]$anyElementInWindow)
    $win = Get-TopLevelWindow $anyElementInWindow
    if (-not $win) { return $null }

    # 下部パネル/ステータスバーっぽい要素を走査して URL らしきテキストを拾う
    $walker = [System.Windows.Automation.TreeWalker]::RawViewWalker
    $queue = New-Object System.Collections.Generic.Queue[System.Windows.Automation.AutomationElement]
    $queue.Enqueue($win)

    while ($queue.Count) {
        $n = $queue.Dequeue()
        try {
            $name = $n.Current.Name
            $lct  = $n.Current.LocalizedControlType
            if ($name -and ($lct -in @('ステータスバー','パネル','ペイン','テキスト','テーブル','リスト'))) {
                $hit = TryGet-UAProps $n
                if ($hit) { return $hit }
            }
        } catch {}

        # 子ノード列挙
        try {
            $child = $walker.GetFirstChild($n)
            while ($child) {
                $queue.Enqueue($child)
                $child = $walker.GetNextSibling($child)
            }
        } catch {}
    }
    return $null
}

function Decode-BingRedirect {
    param([string]$url)
    if (-not $url) { return $null }
    # 典型: https://www.bing.com/ck/a?!...&u=https%3a%2f%2fexample.com%2fpath%3Fa%3d1&...
    try {
        $uri = [Uri]$url
        $qs  = [System.Web.HttpUtility]::ParseQueryString($uri.Query)
        foreach ($k in @('u','url','r','RU')) {
            $v = $qs[$k]
            if ($v) {
                $decoded = [System.Uri]::UnescapeDataString($v)
                # さらに多重エンコードされている場合の軽い救済
                while ($decoded -match '%[0-9A-Fa-f]{2}') {
                    $next = [System.Uri]::UnescapeDataString($decoded)
                    if ($next -eq $decoded) { break }
                    $decoded = $next
                }
                if (Test-LooksLikeUrl $decoded) { return $decoded }
            }
        }
    } catch {}
    return $url
}

function Get-HrefFromCursor {
    # 1) カーソル下の要素
    $el = Get-ElementFromCursor
    if (-not $el) { return [pscustomobject]@{ Source='none'; Url=$null; Element=$null } }

    # 2) 自身→祖先へ:リンクらしい要素を探す
    $walker = [System.Windows.Automation.TreeWalker]::RawViewWalker
    $cur = $el
    $linkEl = $null
    for ($i=0; $i -lt 8 -and $cur; $i++) {
        try {
            if ($cur.Current.ControlType -eq $ct::Hyperlink) { $linkEl = $cur; break }
            # Chromium は Hyperlink でないことも多いので、Name に "http" などが見えたら候補に
            if (Test-LooksLikeUrl $cur.Current.Name) { $linkEl = $cur; break }
        } catch {}
        $cur = $walker.GetParent($cur)
    }
    if (-not $linkEl) { $linkEl = $el }

    # 3) 直接パターン/プロパティから URL 抽出
    $url = TryGet-UAValue $linkEl
    if (-not $url) { $url = TryGet-UAProps $linkEl }

    # 4) だめならウィンドウのステータスバー等から拾う
    if (-not $url) { $url = TryGet-StatusBarUrl $linkEl }

    # 5) Bing リダイレクト復元
    $final = Decode-BingRedirect $url

    [pscustomobject]@{
        Source  = if ($url) { if ($url -eq $final) { 'uia' } else { 'bing-decoded' } } else { 'none' }
        Url     = $final
        Element = $linkEl
    }
}

function Test-BlockedUrl {
    param([string]$url, [string[]]$BlockedHosts = @('example.org','bad.site'))
    if (-not $url) { return $false }
    try {
        $u = [Uri]$url
        return $BlockedHosts -contains $u.Host
    } catch { return $false }
}

# ===== 使い方 =====
if ($Watch) {
    Write-Host "Move your mouse over a link... (ESC to exit). Poll: $PollMs ms"
    $esc = $false
    $last = $null
    while (-not $esc) {
        Start-Sleep -Milliseconds $PollMs
        if ([Console]::KeyAvailable -and [Console]::ReadKey($true).Key -eq 'Escape') { $esc = $true; break }
        $res = Get-HrefFromCursor
        if ($res.Url -and $res.Url -ne $last) {
            $last = $res.Url
            $blocked = Test-BlockedUrl $res.Url
            Write-Host ("[{0}] {1}{2}" -f $res.Source, $res.Url, $(if($blocked){'  (BLOCKED)'}))
        }
    }
} else {
    $res = Get-HrefFromCursor
    if ($res.Url) {
        $blocked = Test-BlockedUrl $res.Url
        "{0}`n{1}" -f $res.Source, $res.Url
        if ($blocked) { Write-Warning "This URL is blocked by policy." }
    } else {
        Write-Warning "URL を見つけられませんでした(要素がリンクでない / ブラウザが href を UIA に公開していない可能性)。"
    }
}

まとめ

今回のスクリプトは、PowerShell で UIAutomation を駆使した「リンク先確認ツール」です。
ブラウジング時に「このリンク、怪しくない?」と思ったときに役立つでしょう。

セキュリティ対策や URL 確認の補助ツールとして、ぜひ活用してみてください。

スポンサーリンク

-IT関連
-,