The Man Who Fell From The Wrong Side Of Sky:2019年1月5日分

2019/1/5(Sat)

[Windows] PowerShellで生活するために - Invoke-WebRequest コマンドレット編(その2)

前回の続き、実際にコードを交えて解説していく。

以下のPowerShellによるコードは

というスクレイピングの初歩中の初歩ちゅーもの。

$res = Invoke-WebRequest 'https://www.hi-matic.org/' -Method Get
$res.AllElements | Where-Object { $_.tagName -eq 'h1'} | ForEach-Object {
	Write-Host $_.innerText
}

しかし困ったことに実行してもエラーになる

PS C:\Users\tnozaki> C:\Users\tnozaki\unko.ps1
Invoke-WebRequest : 接続が切断されました: 送信時に、予期しないエラーが発生しました。。
At C:\Users\tnozaki\unko.ps1:1 char:8
+ $res = Invoke-WebRequest 'https://www.hi-matic.org/' -Method Get
+        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidOperation: (System.Net.HttpWebRequest:HttpWebRequest) [Invoke-WebRequest], WebException
    + FullyQualifiedErrorId : WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand
PS C:\Users\tnozaki> 

これはPowerShellというか.Net FrameworkがTLSv1.2にデフォルトで未対応なのが原因、よって3行ほどの対策コードを追加する。

if (-not ([Net.ServicePointManager]::SecurityProtocol -band [Net.SecurityProtocolType]::Tls12)) {
	[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
}
$res = Invoke-WebRequest 'https://www.hi-matic.org/' -Method Get
$res.AllElements | Where-Object { $_.tagName -eq 'h1'} | ForEach-Object {
	Write-Host $_.innerText
}

そんで改めて実行すると、エラーは起きないものの今度はタイトルが化けるのですな。

PS C:\Users\tnozaki> C:\Users\tnozaki\unko.ps1
チラシの裏

この原因はInvoke-WebRequestのバグで

という動作をする為だ。

この対策はちょっとめんどくさいのだけども、なるべくコードを短くかつ外部ライブラリなどへの依存なくかつ実用に足るものを書くとすると以下のようなコードになる。

if (-not ([Net.ServicePointManager]::SecurityProtocol -band [Net.SecurityProtocolType]::Tls12)) {
	[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
}
$res = Invoke-WebRequest 'https://www.hi-matic.org/' -Method Get
$content = $res.Content
if ($res.Headers['content-type'] -notmatch "charset *=") {
	$bytes = [System.Text.Encoding]::GetEncoding('ISO-8859-1').GetBytes($content)
	$encoding = 'ISO-8859-1'
	if (($bytes.Length -ge 4) -and ($bytes[0] -eq 0x00) -and ($bytes[1] -eq 0x00) -and ($bytes[2] -eq 0xfe) -and ($bytes[3] -eq 0xff)) {
		$encoding = 'UTF-32BE'
	} elseif (($bytes.Length -ge 4) -and ($bytes[0] -eq 0xff) -and ($bytes[1] -eq 0xfe) -and ($bytes[2] -eq 0x00) -and ($bytes[3] -eq 0x00)) {
		$encoding = 'UTF-32LE'
	} elseif (($bytes.Length -ge 2) -and ($bytes[0] -eq 0xfe) -and ($bytes[1] -eq 0xff)) {
		$encoding = 'UTF-16BE'
	} elseif (($bytes.Length -ge 2) -and ($bytes[0] -eq 0xff) -and ($bytes[1] -eq 0xfe)) {
		$encoding = 'UTF-16LE'
	} elseif (($bytes.Length -ge 3) -and ($bytes[0] -eq 0xef) -and ($bytes[1] -eq 0xbb) -and ($bytes[2] -eq 0xbf)) {
		$encoding = 'UTF-8'
	} else {
		# XXX TODO: consider some non US-ASCII compatible encoding such as UTF-7, HZ, and so on.

		# assume US-ASCII compatible encoding, use DOM to extract meta charaset.
		$res.AllElements | Where-Object { $_.tagName -eq 'meta' } | ForEach-Object {
			if (Get-Member -InputObject $_ -Name 'charset' -Membertype NoteProperty) {
				$encoding = $_.charset
			} elseif ((Get-Member -InputObject $_ -Name 'http-equiv' -Membertype NoteProperty) -and ($_.'http-equiv' -eq 'content-type')) {
				if ($_.content -match 'charset *= *(?<encoding>.+)') {
					$encoding = $Matches.encoding
				}
			}
		}
	}
	$content=[System.Text.Encoding]::GetEncoding($encoding).GetString($bytes)
}
# DOM is immutable, there's no way to fix its wrong encoding.
# anyway, let's create new one via MSHTML Object.
$html = New-Object -ComObject 'HTMLFile'
try {
	$html.write([System.Text.Encoding]::Unicode.GetBytes($content))
} catch {
	# MS Office may affect MSHTML's behavior, use IHTMLDocument2_write instead.
	$html.IHTMLDocument2_write([System.Text.Encoding]::Unicode.GetBytes($content))
}
# Where-Object doesn't work, use Old-School DOM API, 
$html.body.getElementsByTagName('h1') | ForEach-Object {
	Write-Host $_.innerText
}

ポイントは以下の通り。

@応答ヘッダ中からcontent-typeを取得し、charsetが指定されてるかをチェックする

この部分ね、応答ヘッダへのアクセスはInvoke-WebRequestの戻り値(HtmlWebResponseObject型)のHeadersプロパティから可能。

if ($res.Headers["content-type"] -notmatch "charset *=") {
...
}

雑に正規表現でひっかけてるだけなのでもっとちゃんとしたコード書きたい人は勝手に苦労してどうぞ。

ちなみに-match/-notmathは-imatch/-inotmatchと同義、PowerShellはデフォルトはignore caseという世にも恐ろしいアレなので厳密に区別したければ-cmatch/-cnotmatchを使う、馬鹿じゃねぇの。 また文字列クラスにはワイド文字も含まれてるので、例えば「\s」ならSPACE(U+0020)だけでなくIDEOGRAPHIC SPACE(U+3000)なんかにもマッチする。こいつらの落とし穴的な話はまたいずれ正規表現編でも書きますかね。

@ISO-8859-1と解釈されたまま変換されてしまったPowerShellの文字列(UTF-16)をバイト列に戻す

これはこの部分、HTMLドキュメントをDOMなどのオブジェクトでなく文字列で抜くにはHtmlWebResponseObjectのContentプロパティを使う。 そしてiconv(1)的な文字コード変換のコマンドレットは用意されてないので、.Net Frameworkの System.Text.Encodingを使ってUTF-16からバイト列に戻して差し上げる。

$content = $res.Content
…
	$bytes = [System.Text.Encoding]::GetEncoding('ISO-8859-1').GetBytes($content)

@バイト列からBOMによって文字コード判定を行う

今度はこの部分やね

	$encoding = 'ISO-8859-1'
	if (($bytes.Length -ge 4) -and ($bytes[0] -eq 0x00) -and ($bytes[1] -eq 0x00) -and ($bytes[2] -eq 0xfe) -and ($bytes[3] -eq 0xff)) {
		$encoding = 'UTF-32BE'
	} elseif (($bytes.Length -ge 4) -and ($bytes[0] -eq 0xff) -and ($bytes[1] -eq 0xfe) -and ($bytes[2] -eq 0x00) -and ($bytes[3] -eq 0x00)) {
		$encoding = 'UTF-32LE'
	} elseif (($bytes.Length -ge 2) -and ($bytes[0] -eq 0xfe) -and ($bytes[1] -eq 0xff)) {
		$encoding = 'UTF-16BE'
	} elseif (($bytes.Length -ge 2) -and ($bytes[0] -eq 0xff) -and ($bytes[1] -eq 0xfe)) {
		$encoding = 'UTF-16LE'
	} elseif (($bytes.Length -ge 3) -and ($bytes[0] -eq 0xef) -and ($bytes[1] -eq 0xbb) -and ($bytes[2] -eq 0xbf)) {
		$encoding = 'UTF-8'
	} else {
		…
	}

まずBOMで判定可能なのは先頭バイトが

  • 0x00 0x00 0xfe 0xff … UTF-32BE
  • 0xff 0xfe 0x00 0x00 … UTF-32LE
  • 0xfe 0xff … UTF-16BE
  • 0xff 0xfe … UTF-16LE
  • 0xef 0xbb 0xbf … UTF-8 + BOM

かどうかチェックすればよい。

@Metaタグのcharsetから文字コード判定を行う

この部分ね。

	$encoding = 'ISO-8859-1'
…
		# XXX TODO: consider some non US-ASCII compatible encoding such as UTF-7, HZ, and so on.

		# assume US-ASCII compatible encoding, use DOM to extract meta charaset.
		$res.AllElements | Where-Object { $_.tagName -eq 'meta' } | ForEach-Object {
			if (Get-Member -InputObject $_ -Name 'charset' -Membertype NoteProperty) {
				$encoding = $_.charset
			} elseif ((Get-Member -InputObject $_ -Name 'http-equiv' -Membertype NoteProperty) -and ($_.'http-equiv' -eq 'content-type')) {
				if ($_.content -match 'charset *= *(?<encoding>.+)') {
					$encoding = $Matches.encoding
				}
			}
		}

本当はBOMでの判定の後にさらにUTF-7やHZなどのUS-ASCII非互換な文字コードの判定も必要なんだけど、そこまでするメリットがこの2019年という時代に存在するとは思えないので割愛ですな。 ということで極力手を抜くためにUS-ASCII互換と仮定し、HTMLのパースも文字化けしたままでもmeta charsetくらいはぶっこ抜けるのでそのまま応答中のDOMを操作する。

ここで自称関数型のPowerShellっぽくストリームとフィルタを使ってDOMを操作してるけど、旧来のDOM APIの方使ってる人の方が多いよね。書き直すとこう、AllElementsでなくParsedHtmlプロパティを使う。

		$res.ParsedHtml.getElementsByTagName('meta') | ForEach-Object {
			...
		]

なんとこっちの方が短く書ける上に下手すると性能も良かったりする(PowerShellのパイプってクソ遅い)、やっぱり馬鹿じゃねえのこの言語。

@正しい文字コードを指定して再びバイト列から文字列に変換する

ここ、文字列に戻す時もやっぱりSystem.Text.Encodingを使う。

	$content=[System.Text.Encoding]::GetEncoding($encoding).GetString($bytes)

@新たにDOMツリーを作成する。

これ困ったことにHtmlWebResponseObjectはどうやら変更不可かつインスタンス化不可なようで、正しく文字コード変換した結果を戻す方法が無い。 なので、代わりにMSHTML.HTMLFileのDOM APIをCOM Object経由で使う、それならもうVBScriptでええやん…

コードは以下の部分。

# DOM is immutable, there's no way to fix its wrong encoding.
# anyway, let's create new one via MSHTML Object.
$html = New-Object -ComObject 'HTMLFile'
try {
	$html.write([System.Text.Encoding]::Unicode.GetBytes($content))
} catch {
	# MS Office may replace MSHTML, use IHTMLDocument2_write instead.
	$html.IHTMLDocument2_write([System.Text.Encoding]::Unicode.GetBytes($content))
}
# Where-Object does'nt work, use Old-School DOM API, 
$html.body.getElementsByTagName('h1') | ForEach-Object {
	Write-Host $_.innerText
}

謎のtry catchがあるけれど、これ恐ろしいことにMSHTML.HTMLFileにはいくつかのバージョンがあるようで、MS Officeがインストールされている環境だと writeを呼出すと以下のエラーが出てしまう。

PS C:\Users\tnozaki> C:\Users\tnozaki\unko.ps1
Exception calling "write" with "1" argument(s): "種類が一致しません。
"
At C:\Users\tnozaki\unko.ps1:40 char:5
+     $html.write([System.Text.Encoding]::Unicode.GetBytes($content))
+     ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : NotSpecified: (:) [], MethodInvocationException
    + FullyQualifiedErrorId : COMException
 
You cannot call a method on a null-valued expression.
At C:\Users\tnozaki\unko.ps1:46 char:1
+ $html.body.getElementsByTagName('h1') | ForEach-Object {
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidOperation: (:) [], RuntimeException
    + FullyQualifiedErrorId : InvokeMethodOnNull

よってこの例外をcatchして代わりにIHTMLDocument2_writeの方を使うというワークアラウンドが必要なんですわ。

そんで最後の部分、MSHTML.HTMLFileはCOM Objectなので旧来のDOM APIによる操作しかできないので、PowerShellらしいパイプとストリームが使えない。 それならやっぱりVBScript使えばいいんじゃねぇかクソが。

ここまでやってようやく文字化けせずに表示がでる

PS C:\Users\tnozaki> C:\Users\tnozaki\unko.ps1
チラシの裏

@結論

なんでたかがスクレイピングの初歩の初歩でこんだけ苦労するんですかね(しろめ)。