PowerShell と WPF を組み合わせて、マウスカーソル下のリンク先 URL をポップアップ表示するスクリプトを作成しました。
対象ブラウザを限定したり、ブロックリストで危険なサイトを赤色表示するなど、実用性の高い機能を盛り込んでいます。
主な機能
- マウスカーソル下の要素からリンク(URL)を検出して表示
- Chrome / Edge / Firefox など対象ブラウザを限定可能
- ブロックリストに一致する URL は背景を赤色で警告
- PowerShell を STA モード で自動再起動する仕組み付き
- 起動時に自己テスト用のポップアップを表示可能
事前準備
- PowerShell 5.1 以上が必要です。
(Windows 10 標準搭載の PowerShell でOKです) - ブロックリストファイルを作成します。
例: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 {} } }
動作イメージ
- 通常サイトのリンク → 灰色背景 のポップアップ
- ブロック対象サイトのリンク → 赤色背景 のポップアップ
これにより、リンク先を確認しながら安全にブラウジングできます。
使い方
- 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 確認の補助ツールとして、ぜひ活用してみてください。