2015/12/10
PowerShellでスクレイピング 後編 HTMLをパースする
この記事はPowerShell Advent Calendar 2015の10日目の記事です。
はじめに
前編では、Invoke-WebRequestコマンドレットやWebClientクラスを用いて、WebページからHTMLの文字列を取得するところまで説明しました。
後編の今回は、取得したHTML文字列をパースして、オブジェクトとして利用可能しやすい形に変換する話です。
IEエンジンによるHTMLパース(DOM)
前編でも触れましたが、Invoke-WebRequestコマンドレットは、レスポンス文字列を取得すると同時に、HTMLをパース(構文解析)し、結果をオブジェクトとして構造化してくれます。
実はこのHTMLパース、内部的にInternet Explorerのエンジンを呼び出すことで実現されています。(ちなみに後で説明しますが、-UseBasicParsingパラメータを付与すると、IEエンジンを使わずごく基本的なパースのみ行うようになります。)
Invoke-WebRequestコマンドレットの出力であるHtmlWebResponseObjectオブジェクトのParsedHtmlプロパティを経由することで、HTMLパースされたオブジェクトを、DOM(Document Object Model)に従ってアクセスすることができます。(-UseBasicParsing指定時は不可)
HTMLのtable要素を切り出し、table各行を1オブジェクト、各セルをプロパティとして、オブジェクト配列化する例を以下に示します。
$response = Invoke-WebRequest http://winscript.jp/powershell/301 # DOMを利用して1つ目のtable要素を取得 $table = $response.ParsedHtml.getElementsByTagName("table")| select -First 1 # tableの1行目をプロパティ名として取得 $properties = ($table.rows| select -first 1).Cells| foreach {$_.innerText} # tableの残りの行に対して、各セルのinnerTextをプロパティ値としてオブジェクト化 $objs = foreach($row in ($table.rows| select -skip 1)) { $row.Cells| foreach -Begin { $index = 0 $obj = [ordered]@{} } -Process { $obj += @{$properties[$index] = $_.innerText} $index++ } -End { [pscustomobject]$obj } } $objs| Format-List
ところで前編で軽く触れましたが、IEエンジンによるパースは、Invoke-WebRequestコマンドレットを用いずとも、以下のようにして直接IEのCOMインターフェースを呼ぶことで利用可能です。
$client = New-Object System.Net.WebClient $content = $client.DownloadString("http://winscript.jp/powershell/301") $parsedHtml = New-Object -com "HTMLFILE" $parsedHtml.IHTMLDocument2_write($content) $parsedHtml.Close() $table = $parsedHtml.getElementsByTagName("table")| select -First 1 # 以下同様…
というより実際に試すと直接IEエンジンを呼び出す方がずっと速いです。理由はよく分かりませんが…。
HTML要素コレクションの取得
Invoke-WebRequestコマンドレットを用いると、DOMとは別に、すべての要素(AllElementsプロパティ)、input要素(InputFieldsプロパティ)、img要素(Imagesプロパティ)、a要素(Linksプロパティ)、script要素(Scriptsプロパティ)を含むコレクションを、HtmlWebResponseObjectオブジェクトの対応するプロパティからそれぞれ取得することができます。
コレクションに含まれる各要素は、innerText(タグ内の文字列)、innerHTML(タグ内のHTML)、tagName(タグ名)等のプロパティが共通して利用可能です。また要素の属性(たとえばa要素ならリンク先を示すhref属性)に、プロパティとしてアクセス可能となります。
以下はBingでWeb検索した結果から、ページタイトルとURLを抜き出す例です。HtmlWebResponseObjectのLinksプロパティでa要素の配列を取ってきて、次に検索結果では無いっぽいURLを、hrefプロパティの値を見てwhereで除外し、最後にinnerTextプロパティとhrefプロパティをTitle、Urlとリネームしてから値を出力しています。泥臭い処理が混じってますが、この泥臭さがスクレイピングなのかもなぁと思います。
$searchWord = "PowerShell 配列" $notSearchResults = "/","#","javascript:","http://go.microsoft.com/" $response = Invoke-WebRequest "https://www.bing.com/search?q=$([Uri]::EscapeDataString($searchWord))" $response.Links | where { $href = $_.href !($notSearchResults|? {$href.StartsWith($_)}) }| select @{L = "Title"; E = "innerText"}, @{L = "Url"; E = "href"}| Format-List
form要素についてもほぼ同様にFormsプロパティからコレクションを取得できますが、このコレクションにはFormObjectという特別なオブジェクトが含まれます。FormObjectのFieldsプロパティは、Key=パラメータ名、Value=パラメータ値が格納された連想配列となっています。この連想配列は書き替えが可能なので、前編で説明した、ログオンを要するWebサイト等で用いると便利かと思います。
以下に、HtmlWebResponseObjectオブジェクトのプロパティをまとめます。(×印は使用不可を表す)
プロパティ名 | 説明 | -UseBasicParsing 指定時 |
AllElements | 本文に含まれるすべての要素のコレクション | × |
Forms | フォーム(form要素)のコレクション | × |
InputFields | 入力フィールド(input要素)のコレクション | |
Images | 画像(img要素)のコレクション | |
Links | リンク(a要素)のコレクション | |
Scripts | スクリプト(script要素)のコレクション | × |
このように、一部のプロパティについては-UseBasicParsing指定時でも利用可能です。サーバーOS等でIEエンジンが利用できない場合には-UseBasicParsingパラメータが必須となりますが、その場合でも最低限のパースはしてくれるわけです。
HTML要素のコレクションを利用する方法は、DOMを使う方法に比べると自由度は少ないですが、「ページから画像のリストを取得したい」等の処理は簡便に行うことができます。
その他のHTMLパース手法
最後に、Invoke-WebRequestコマンドレットとIEエンジン以外のHTMLパース手法について軽くご紹介します。
XMLとしてパース(XHTML限定)
XHTMLというのはごくかいつまんで言うと、HTMLをXMLで定義したものです。XHTMLはXMLなので、XMLとしてパースして用いることができます。
PowerShellは[xml](XmlDocument)型アクセラレータと型アダプタにより、XML要素への簡便なアクセス手段を提供しています。以下のように、[xml]型アクセラレータを用い、取得したXHTML文字列を[xml]型に変換すると、以降は型アダプタの機能により、ドット演算子で要素を辿っていくことができます。
$client = New-Object System.Net.WebClient $content = $client.DownloadString("XHTMLなページ") $xml = [xml]$content $xml.html.body.h2.'#text'
ただ世の中のWebページ上のXHTML文書が、すべてXML文書としてvalidなものであるかと言われると、現実はかなり厳しいです。そしてXML文書としてエラーがある場合は、型アクセラレータの処理は容赦なく失敗します。なのでこの手法は「使えたら強いが、大抵使えない」レベルのものと思って頂ければいいと思います。
SgmlReader
標準機能にこだわらなければ、.NET製のHTMLパーサーを使うのが楽かと思います。SgmlReaderは通常のHTML文書(当然、XHTMLに限らず)をXmlDocumentへとパースしてくれるので、PowerShellと相性が良いのではないかと思います。
以下にサンプルを載せておきます。
Add-Type -Path .\SgmlReaderDll.dll function Get-HTMLDocument { param([uri]$Uri) $sgmlReader = New-Object Sgml.SgmlReader -Property @{ Href = $Uri.AbsoluteUri CaseFolding = [Sgml.CaseFolding]::ToLower } $doc = New-Object System.Xml.XmlDocument $doc.Load($sgmlReader) $doc } $xml = Get-HTMLDocument http://winscript.jp/ $xml.html.body.div|? id -eq outer|% div|? id -eq main|% {$_.p.innerText}
ぎたぱそ氏も以前SgmlReaderを取り上げておられるので、そちらも参考にして下さい。:Html Agility Pack と SgmlReader を使って PowerShell でスクレイピングしてみる - tech.guitarrapc.cóm
正規表現等で自前パース
これまではHTMLパースを既存のコマンドやライブラリを用いて行ってきましたが、対象のHTMLが非常にシンプルである場合とか、HTMLですらなく単なるテキストの場合だとか、対象ページは分量が多いものの必要箇所はごくわずかで、かつピンポイントに取得可能な場合等々は、むしろ自前でパースするコードを書いた方が手っ取り早いこともあります。
例えばYAMAHAのルーターで、管理Webのシステム情報レポートからグローバルIPアドレスを取ってくる、みたいなことは、
$response = Invoke-WebRequest http://サーバー/detail/status.html -UseBasicParsing -Credential $credential if($response.Content -match "PP IP Address Local\: (.+)\,") { $ipAddress = $Matches[1] }
のようなコードで十分かと思います。
ConvertFrom-String
これはまだ検証してないんですが、PowerShell5.0の新機能、Auto-Generated Example-Driven Parsingの実装であるConvertFrom-Stringコマンドレットを用いて、HTMLパースができないかな、と考えています。
ConvertFrom-Stringについては過去記事参照:[v5] Auto-Generated Example-Driven Parsing について - PowerShell Scripting Weblog
まとめ
前後編に渡って、PowerShellでのWebスクレイピングの手法について解説しました。スクレイピングはWeb APIが用意されていない場合の苦肉の策ですが、背に腹は代えられない場合というのは稀によくあると思います。そういうときに今回の記事が参考になれば幸いです。
次回あたりには、Web APIがちゃんと用意されてる場合に、PowerShellから利用する話をやろうかと思います。
2011/05/14
PowerShellでJScript.NETを利用してJSONをパースする
PowerShellでJSONをパースする方法はいくつかあると思います。
1. System.Runtime.Serialization.Json.JsonReaderWriterFactoryクラスを用いる
これは.NET Framework 3.5から追加されたクラスで、JSONデータを読み書きするXMLReader/Writerを提供します。すなわちJSONをパースしてXMLに変換することが可能です。XMLはPowerShellから簡単に扱えるので有用な方法と言えるでしょう。
PowerShellからの使用方法についてはこちらの記事が参考になります。:JSON Serialization/Deserialization in PowerShell | Keith Hill's Blog
2. 頑張って自力でパースする
.NET 3.5が入っていない環境では1の方法が使えないので別の方法を考える必要があります。JSONはテキストデータなので、頑張って自力でパースすることもできなくはないでしょう。
PowerShellでやっている例はこちらになります。:Convert between PowerShell and JSON - Home("Source Code"のリンクをたどっていくとソースがあります)
3. ScriptControl+JScriptを用いる
もう少し簡便な方法はないかなと思っていろいろ考えたんですが、PowerShellではScriptControlを用いるとJScriptやVBScriptを実行することができます。そしてJSONはJavaScriptで扱うことを想定しているだけあって、JScriptではeval()するだけでJSONをオブジェクトに変換することができます。そこで実際にやってみたのですが…
$json=@' {"items": [ { "code":25, "name":"ハードディスク2TB", "price":7000 }, { "code":56, "name":"メモリ8GB", "price":8000 }, { "code":137, "name":"23インチ液晶ディスプレイ", "price":35000 } ] } '@ $sc=new-object -com ScriptControl $sc.Language = "JScript" $jscode="function parseJSON(json){return eval('(' +json + ')').toString();}" $sc.AddCode($jscode) $jsobj=$sc.CodeObject.parseJSON($json) $jsobj
このコードを実行すると、確かにJSONがパースされ、結果が$jsobjという変数に格納されるのですが、残念ながらPowerShellはJScriptのオブジェクト(JScriptTypeInfo)を展開することができないようなのです。
JScriptTypeInfoオブジェクトはVBScriptでは扱うことができるので、まずJScriptでパースし、その結果オブジェクトをVBScriptに渡し、オブジェクトはScripting.Dictionaryオブジェクトに変換し、配列はVBScriptの配列(Safe Array)に変換し、その結果オブジェクトをPowerShellに戻すという方法を考えました。PowerShellはCOMオブジェクトやSafe Arrayは扱えるので理屈の上ではうまくいきます。(参考までに、ASPでこの方法を実際にコードにしてる方がいらっしゃいました。:ASPでJSONパーサーを書いてみた - ゆるゆると)
しかしこの方法は当初の目的「簡便にJSONをパースする」からだいぶ離れてしまっています。
4. JScript.NETを用いる
そうだ、JScriptが駄目ならJScript.NETを使えばいいじゃない。JScript.NETなら結果は.NETのオブジェクトで返るしPowerShellでも読めるだろう、ということで、この前このブログで紹介したAdd-Typeコマンドレットを使ってJScript.NETのコードを実行する方法を利用してやってみました。
($jsonの値は先ほどのスクリプトのを使います)
$code=@" static function parseJSON(json) { return eval('(' +json + ')'); } "@ $JSONUtil = (Add-Type -Language JScript -MemberDefinition $code -Name "JSONUtil" -PassThru)[1] $jsobj = $JSONUtil::parseJSON($json) # $jsobjはJSObject $jsobj["items"][1]["name"] #「メモリ8GB」と表示される $items=$jsobj["items"] # $itemsはJSArrayObject $items|%{$items[$_]["name"]} # 名前が列挙される
という感じでうまくいきました。
ここで$jsobjに格納されているのはMicrosoft.JScript.JSObjectクラスのオブジェクトです。このクラスのItemプロパティ(引数付きプロパティ、PowerShellではParameterizedPropertyと呼ばれる)にプロパティ名を引数として渡すと、その値が返却されます。PowerShellでは引数付きの既定プロパティはC#のインデクサと同様の構文で値が参照できるので、$jsobj[“items”]のように[]でアクセス可能です。これは$jsobj.Item(“items”)としても同様の結果が得られます(プロパティなのに()で値を取るところはVB風味?)。
配列の列挙ですが、JSObjectと、オブジェクトが配列の場合はその派生クラスであるJSArrayObjectクラスになりますが、これらはIEnumerableインターフェースを実装しているのでforeachで列挙が可能です。しかしここで列挙されるのはあくまでkey、すなわちプロパティ名の方です。値が列挙されるわけではありません。ご存じのとおり、JavaScriptの配列、連想配列、オブジェクトは同じものであり、配列の場合はkeyが配列インデックスの数字に相当します。そのため配列をforeachしても「0,1,2…」という数字が列挙されるだけです。
なので配列を列挙する場合は、この例のように、一旦JSArrayObjectを変数で受けて、それに対してforeachし、列挙した要素(インデックスの数字)をJSArrayObject.Itemプロパティの引数に与えることで、JS配列要素の値を取得してやる必要があると思います。
1のXMLを経由する方法のように、JSONをドット演算子でプロパティアクセスできないのは残念ですが、.NET 3.5が入っていない(がPowerShell 2.0は入ってる)環境では、それほど手間をかけずJSONを扱えるという点でそれなりに有用ではないでしょうか。
JSObjectもXMLみたいに型アダプタがあればプロパティアクセスできるようになるでしょうし、Add-Memberコマンドレットを駆使してJSObjectに動的にプロパティを追加する関数を書くのもいいかもしれません。が、そこまでいくとやはりお手軽からはかけ離れてしまうので今回はこの辺にとどめておきましょう。
元記事:http://blogs.wankuma.com/mutaguchi/archive/2011/05/14/199047.aspxCopyright © 2005-2016 Daisuke Mutaguchi All rights reserved
mailto: mutaguchi at roy.hi-ho.ne.jp