2017/12/10
PowerShellで利用するテキストデータ形式の比較
この記事はPowerShell Advent Calendar 2017の10日目です。
PowerShellはオブジェクトを扱うシェルですが、別にテキストデータを扱えない訳ではありません。むしろ、PowerShellで取得したデータをテキストファイルとして保存したり、スクリプトで用いるデータをテキストファイルで保存しておくことは日常的に行われることだと思います。
ただし、PowerShellで扱うデータはオブジェクトであり、テキストファイルは文字通り文字列であることから、コマンドレットを用いる等、何らかの手段で変換が必要になります。また、テキストデータ形式にも様々な種類があり、それぞれメリット、デメリットが存在します。今回の記事では、PowerShellで用いるデータを保持しておく際のテキストデータ形式について比較をしてみます。
プレーンテキスト
プレーンテキスト、すなわち書式なしのテキストファイルです。もっともシンプルな使い方をする場合、文字列配列の各要素に含まれる文字列が、テキストファイルの1行と対応します。
書き出し
$lines | Set-Content -LiteralPath file.txt -Encoding UTF8
$linesは文字列変数です。
特に理由がなければ文字コードはUTF-8で良いと思います。
追記
$lines | Add-Content -LiteralPath file.txt -Encoding UTF8
Add-Contentは実行のたびにファイルを開いて、書き込んでから閉じるという動作をするので、1行ずつforeachで実行するのはNGです。
読み込み
$lines = @(Get-Content -LiteralPath file.txt -Encoding UTF8)
メリット
- 文字列配列をテキストファイルに書き出すのは多分これが一番楽だと思います。
- 書き出したデータは人間にも読みやすい。 編集もしやすい。
デメリット
- 文字列だけを保存しておきたいというケースがそもそも少ない。
CSV
コンマ等の特別な文字で区切り、1行あたりに複数のデータを保存できる形式です。
PowerShellのコマンドレットで扱う場合、オブジェクトが持つプロパティがヘッダ列名に対応します。各行にオブジェクト配列1要素のプロパティ値が、カンマ区切りで保持されます。
書き出し
$objects | Export-Csv -LiteralPath file.csv -NoTypeInformation -Encoding Default
$objectsは任意のオブジェクト配列です。必要であればSelect-Objectコマンドレットを併用して、プロパティを絞り込みます。
文字コードはExcelでそのまま読み込み/書き出しができるDefault(日本語環境ではShift_JIS)がお勧めです。(最近のExcel2016ならUTF8も一応読めますが)
追記
$objects | Export-Csv -LiteralPath file.csv -Append -NoTypeInformation -Encoding Default
読み込み
$objects = Import-Csv -LiteralPath file.csv -Encoding Default
メリット
- オブジェクトのプロパティ値が、すべて数値あるいは文字列で表現できる値を持つ場合に最も適合する。
- 人間にも読みやすく、ある程度は編集もできる。
- Excelで開ける。
デメリット
- オブジェクトのプロパティが、数値と文字列以外のオブジェクトである場合、すなわち、階層構造を持つデータの保存には適さない。
- 数値も文字列として読み込まれてしまうので、数値として扱いたい場合は変換が必要になる。
- Export-CsvとImport-Csvで扱うCSVファイルはヘッダが必須。つまり、ヘッダなしのCSVファイルが既にあって、それを読み書きするという用途には適さない。(できなくはないが)
- 書き出し時の列順を制御することができない。つまり、PowerShellで書き出したCSVを、列順が固定であるとの想定である他のプログラムで読み込むことは基本NG。
- 書き出し時、1つ目の要素に存在しないプロパティは、2つ目以降では存在しないものとして扱われる。同種のオブジェクトで構成される配列なら通常は問題ないのだが、要素によって動的に追加されるプロパティがあったりなかったりすると厄介。(ADでありがち)
JSON
JavaScriptのような表記でデータを保持するデータ形式です。データの受け渡しに様々な言語で利用できます。Web APIでもよく利用されます。
PowerShellではv3からJSONを扱うコマンドレットが提供されています。
書き出し
$objects | ConvertTo-Json | Set-Content -LiteralPath file.json -Encoding UTF8
読み込み
$objects = Get-Content -LiteralPath file.json -Encoding UTF8 -Raw | ConvertFrom-Json
メリット
- CSVと異なり、階層構造を持ったデータでも扱える。
- CSVと異なり、数値は数値型のまま読み書き可能。 (整数値はint、小数値はdecimal)
- 人間にもまぁまぁ読めるし、頑張れば編集できなくもない。
デメリット
- -Depthパラメータによりプロパティを展開する階層の深さを指定はできるが、プロパティに応じて深さ指定を変化させるというようなことはできない。基本的には、自分で構築したPSCustomObjectを使うか、JSON化する前に自分で元オブジェクトを整形しておく必要がある。
- 直接ファイルに書き出し、追記、ファイルから読み込みするコマンドレットはない。
- 実は細かい話をしだすと色々と罠があります…。
CLIXML
PowerShellではPSリモーティング等、プロセス間でオブジェクトのやり取りを行う際に、CLIXML形式を介してシリアライズ/デシリアライズが実行されます。シリアライズ対象によっては、完全に元のクラスのオブジェクトに復元されます。(復元されないオブジェクトにはクラス名にDeserialized.との接頭辞が付与され、プロパティ値のみ復元される)
ユーザーもコマンドレットを用いて、任意のデータをCLIXML形式でシリアライズし、XMLファイルとして保存することができます。
書き出し
$objects | Export-Clixml -LiteralPath file.xml
読み込み
$objects = Import-Clixml -LiteralPath file.xml
メリット
- 元のオブジェクトの構造、プロパティ値と型情報を含めてほぼ完全にテキストファイルに保存できる。
- 復元したオブジェクトはプロパティ値を参照できるのはもちろん、オブジェクト全体が完全にデシリアライズされ、元の型に戻った場合には、メソッドを実行することも可能。
- 例え元の型に戻らず、Deserialized.との接頭辞が付いた状態でも、コンソールに表示する場合は元の型のフォーマットが使われるので見やすい。
デメリット
- すべてのオブジェクトが元の型に戻せるわけではない。戻せるかどうかは確認が必要。
- 人間が読み書きするようなものではない。
ちなみに、ConvertTo-Xmlという似たようなコマンドレットがありますが、出力形式はCLIXMLではない上、復元の手段もなく、かといって別に読みやすいXMLというわけでもなく、正直何のために使うのかよく分かりません(適切なxsltでも用意すればいいのかな?)。まだConvertTo-Htmlの方が使えそうです。
psd1
psd1は「PowerShellデータファイル」で、モジュールマニフェストやローカライズデータに使われるファイル形式です。スクリプトファイルの1種ですが、数値や文字列リテラル、配列、連想配列、コメントなど基本的な言語要素のみ使用可能です。PowerShell 5.0以降ではImport-PowerShellDataFileコマンドレットを用いて、任意のpsd1ファイルのデータを読み込み、変数に格納することが可能です。
書き出し
書き出し用のコマンドレットはありません。
読み込み
例えば以下のような内容をbackup_setting.psd1として保存しておきます。ルート要素は必ず連想配列にします。
@{ Directories = @( @{ From = "C:\test1" # コピー元 To = "D:\backup\test1" # コピー先 Exclude = @("*.exe", "*.dll") Recurse = $true }, @{ From = "C:\test2" To = "D:\backup\test2" Exclude = @("*.exe") LimitSize = 50MB }, @{ From = "C:\test3" To = "D:\backup\test4" } ) Start = "0:00" }
なお、dataセクションで全体を括ってもいいですが、psd1で許容される言語要素はdataセクションより更に制限がきついので、敢えてしなくてもいいんじゃないかと思います。
このファイルは以下のように読み込めます。
$setting = Import-PowerShellDataFile -LiteralPath backup_setting.psd1
$settingには連想配列が格納され、以下のように値が参照できます。
$setting.Directories | foreach {Copy-Item -Path $_.From -Destination $_.To}
メリット
- PowerShellの構文でデータを記述できる。
- 通常のps1ファイルを呼び出すのとは異なり、式の評価やコマンド実行などはされない分、セキュアである。
- 配列と連想配列の組み合わせにより、JSONライクな階層構造を持てる。型情報も保持される。
- JSONとは違い、コメントが入れられる。
デメリット
- 記述できるデータはプリミティブなものだけ。
- スクリプトから書き出すためのコマンドレットがない。こういうアプローチで頑張ればできるかも?
- 利用できるのはPowerShell 5.0以降のみ。一応、下位バージョンでやる方法はあります。
まとめ
PowerShellで扱うデータをテキストファイルとして保存する際には、各テキストデータ形式の特性を理解し、メリット、デメリットを踏まえて選定する必要があります。
また、当然ながらテキストファイルに保持することが不適切なデータもありますので、そこは注意してください。(画像データを敢えてBase64とかでエンコードしてテキストファイル化する意味があるのか、とかですね)
個人的には…
ちょっとした作業ログ等を記録しておきたい→プレーンテキスト
.NETオブジェクトの一部のプロパティだけ抜き出してファイル化したい→CSV
自分で構築したPSCustomObjectをファイル化したい→JSON
.NETオブジェクト全体をファイル化したい→CLIXML
スクリプトで使う設定データを用意したい→psd1
みたいな感じでなんとなく使い分けていると思います。psd1はまだ採用例はないですが…。
今回はビルトインのコマンドレットで扱えるもののみ取り上げましたが、他にもyaml等のテキストデータ形式が存在し、有志によるモジュールを用いて扱うことが可能です。
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から利用する話をやろうかと思います。
2015/12/04
PowerShellでスクレイピング 前編 文字列を取得する
この記事はPowerShell Advent Calendar 2015の4日目の記事です。
はじめに
今回はPowerShellでWebページのスクレイピングをする際の、ちょっとしたノウハウ集を前後編に分けて紹介したいと思います。
スクレイピングというのは、Webページから文字列を取ってきて、スクリプトから利用可能な形に加工する処理です。昨今は多くのWebサイトやサービスでWeb APIが公開されていて、スクレイピングをせずとも比較的簡単にデータを取得できます。PowerShellだとInvoke-RestMethodコマンドレット等が使えます(その話はまた次回とかにやります)。
しかし現実には、APIが公開されていない等の理由で、HTMLを取ってきて自前で解釈せざるを得ないケースが多々あります。さて、PowerShellではどうやりましょうか、というのが今回の話。様々な方々によってもう色々と語られている分野ではあるのですが、結構細かいハマりどころがあるのでちょっとまとめてみようと思いました。
前編ではまず、Webページからの文字列の取得方法ついてまとめます。
なお、スクレイピングには技術的な問題以外の、微妙な問題(著作権の問題とか、Webサイトへの攻撃と見なされる可能性とか)を含むものなので、その辺りは各自どうかご留意ください。この辺りの話はPowerShellに限った問題ではないので、ここでは詳説いたしません。参考記事:Webスクレイピングの注意事項一覧 - Qiita
Invoke-WebRequestコマンドレットで文字列を取得する
PowerShellでのスクレイピング、基本は何はなくともInvoke-WebRequestコマンドレットです。ただしこのコマンドレットはPowerShell 3.0で追加されたものなので、2.0環境にはないことに注意です。その場合は.NETのWebClientクラス等を使う方法があり、後で述べます。
基本は、
$response = Invoke-WebRequest -Uri "http://winscript.jp/"
のように、Invoke-WebRequestコマンドレットを実行する、だけです。「-Uri」は省略可能です。
このとき$responseにはHtmlWebResponseObjectオブジェクトが格納されています。このうち、指定URLのWebページに含まれているHTMLなどの文字列データは、Contentプロパティに格納されます。つまり、$response.Content に欲しいデータが格納されているので、あとはそれをよしなに利用すればいいわけです。
実はInvoke-WebRequestは、文字列データを取得すると同時に、HTMLの場合はパースしてタグの構造をオブジェクト化までしてくれます。が、それについては次回。
なお、Invoke-WebRequestコマンドレットでは文字列を取得する他、バイナリデータをダウンロードしてファイルとして保存する機能もあります。それについては過去記事をご参照ください。
リクエストにパラメータを付与する(GET)
GETメソッドを用いてクエリを指定する場合、要はhttps://www.google.co.jp/search?q=PowerShell のようなURLのデータを取得する場合は、Invoke-WebRequest "https://www.google.co.jp/search?q=PowerShell" のようにQueryStringを含んだURLをそのまま指定するだけでOKです。
ただし、動的にクエリを組み立てる場合は、URIエンコード(URIエスケープ)を考慮する必要があります。もっとも簡単なのは
$searchWord = "PowerShell 配列" $response = Invoke-WebRequest "https://www.google.co.jp/search?q=$([Uri]::EscapeDataString($searchWord))"
のように、Uri.EscapeDataStringメソッドを使う方法かと思います。
リクエストにパラメータを付与する(POST)
POSTメソッドでリクエストボディにパラメータを付与するには、Invoke-WebRequestコマンドレットの-Methodパラメータに"Post"を指定し、-Bodyパラメータにリクエストボディに付与するデータを連想配列で指定します。
たとえばブログのトラックバックを手動で撃つにはこんな感じでいけます。
$body = @{title="テスト";url="http://example.com/";excerpt="テスト";blog_name="test"} Invoke-WebRequest http://ご自分のブログのトラックバックpingURL -Method POST -Body $body
なお、リクエストボディに含めるパラメータの各値(連想配列の値)は、自動でURIエンコードしてくれます。
(12/16追記)
また、-Bodyには連想配列のみならず、任意の文字列(URIエンコード要)やバイト配列(バイナリを送信する場合)を指定することも可能です。
標準認証が必要なページを取得する
ページの取得に標準認証が必要な場合は、-Credentialパラメータにユーザー名とパスワードを指定したPSCredentialオブジェクトを指定すればOKです。
セキュリティのことは取りあえず置いておき、簡易的にスクリプトに生パスワードを直書きしてもいいかな、という場合には以下のように書くことができます。
$userName = "user" $password = "pass" $credential = New-Object PSCredential $userName, (ConvertTo-SecureString $password -AsPlainText -Force) Invoke-WebRequest 認証が必要なページのURL -Credential $credential
しかしこの方法はもちろんお勧めできないので、スクリプトとして保存する場合は通常はパスワードを暗号化しておきます。
まず、Get-Credential ユーザー名 | Export-Clixml cred.xmlを、スクリプトを実行するコンピュータ上で、スクリプトを実行するアカウントと同じアカウントで実行します。パスワードを入力するダイアログが出るので、Webサイトにログオンする際のパスワードを入力します。すると、ユーザー名と暗号化されたパスワードがcred.xmlに出力されます。
スクリプトからは
$credential = Import-Clixml cred.xml Invoke-WebRequest 認証が必要なページのURL -Credential $credential
のようにすると、cred.xmlからユーザー名と復号したパスワードを、そのまま-Credentialパラメータに渡すことが可能です。
なおcred.xmlに含まれる暗号化パスワードは、ConvertFrom-SecureStringコマンドレットと同様、Windows Data Protection API(DPAPI)を用いてWindowsアカウントのパスワードをキーに利用して暗号化されているので、他のユーザーが復号することはできません。
ちなみに同一スクリプトファイルに暗号化パスワードを含めておくこともできなくはないです。過去記事参照。あと本当は資格情報マネージャーを使うのがいいんですが、…略。参考:PowerShell で Windows の 資格情報マネージャー を利用する (Jenkins などでの Git Credentialなど) - tech.guitarrapc.com
セッション情報を引き継ぐ
多くのWebアプリケーションは、同一クライアントからの連続したアクセスを、セッションという単位で管理します。
サーバーはクライアント(普通はWebブラウザ)の初回アクセス時にセッションIDを含むcookieを返し、クライアントからの2回目のアクセス時に、サーバーはcookieにセッションIDが含まれているかどうかを確認し、同一クライアントからのアクセスかどうかを判断するわけです。(ざっくりした説明ですが)
WebブラウザではなくInvoke-WebRequestを使ったアクセスでも同様に、以下のようにすれば受けとったcookie等のセッション情報を次回アクセスに引き継ぐことができます。
$url = "https://ログオンが必要なサイト" $body = @{リクエストボディ(例えばユーザー名とかパスワードとか)} $response = Invoke-WebRequest $url -SessionVariable sv -Method POST -Body $body Invoke-WebRequest $url -WebSession $sv
初回アクセス時に-SessionVariableパラメータに指定した変数名(sv)の変数($sv)にはWebRequestSessionオブジェクトが格納されます。この中に、サーバーから受け取ったcookie等の情報が格納されています。
次回アクセス時には、-WebSessionパラメータに、初回アクセス時に得られたWebRequestSessionオブジェクト($sv)を指定します。
さて、実際のWebアプリケーションではcookie以外にも、Formのhiddenフィールドの値などもセッション管理に用いていることがあります。その場合は、初回アクセスのレスポンスからFormに含まれるinput type="hidden"なフィールドを抽出し、次回アクセスのリクエストボディに含ませる必要が出てきます。この辺りの話は後編で述べるパースが必須になってくる(し、長くなる)ので今回は詳説しません。Invoke-WebRequestコマンドレットのリファレンスのExample2に、Facebookにログオンする例なんてのがあるので、そちらで雰囲気をつかんでください。(今でも動作するかは確認してないですが)
エラートラップ
さて、Invoke-WebRequestは、タイムアウトになった、名前解決ができなかった、ページが無かった(404エラー)等々、正常にWebページを取得できなかった場合は、System.Net.WebExceptionというエラーを出します。
コマンドレットの出すエラー(Errorストリームに出力されるErrorRecord)は、try...catchステートメントでは捕捉できない、というのが原則ですが、Invoke-WebRequestコマンドレットのエラーは一般的なコマンドレットと異なり、普通の.NETの例外(System.Net.WebException)なので、try...catchステートメントでエラートラップを行います。
とは言え、Invoke-WebRequestコマンドレットの仕様上、エラートラップをして適切な処理を行うのは非常にめんどいです。何故かというと、Invoke-WebRequestがエラーを出した時点で、HtmlWebResponseObjectオブジェクトの出力は行われないので、このオブジェクトから得られる様々な情報(レスポンス文字列、ステータスコード等々)が取得できないからです。
じゃあどうすればいいのかという話なんですけど、どうもWebExceptionオブジェクトのResponseプロパティを見るしかないようです。具体的にはこんな感じ。
try { $response = Invoke-WebRequest http://存在しないページなど } catch [System.Net.WebException] { # HTTPステータスコード取得 $statusCode = $_.Exception.Response.StatusCode.value__ # レスポンス文字列取得 $stream = $_.Exception.Response.GetResponseStream() $reader = New-Object System.IO.StreamReader $stream $reader.BaseStream.Position = 0 $reader.DiscardBufferedData() $responseBody = $reader.ReadToEnd() }
せっかくInvoke-WebRequestコマンドレットは、生のレスポンスを利用しやすくHtmlWebResponseObjectという形で返してくれるのに、エラー発生時はその恩恵を受けることができず、泥臭い処理が必要になります。これはかなりいけてないですし、どうせここまで書かないといけないのであれば最初からWebClientクラスを使った方がいいと思います。
httpsで無効な証明書が使われている場合
(12/16追記)
Invoke-WebRequestコマンドレット(およびWebClient)では、httpsで始まるURLからもダウンロード可能ですが、サイトで用いられている証明書に問題がある場合(期限が切れている、暗号化形式に問題がある、いわゆるオレオレ証明書である等)には、「要求は中止されました。SSL/TLSセキュリティで保護されているチャネルを作成できませんでした」というエラーが出てしまいます。
これを回避するには、Invoke-WebRequestコマンドレット実行前に、
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = {$true}
という1文を記述しておきます。
ただし、証明書に問題があるということは、その通信相手が正当かどうか、通信内容が正しく秘匿されているかどうか、保証がされなくなるということですから、その点は念頭においてください。
文字化けの問題
Invoke-WebRequestコマンドレットのもう一つの悩ましい問題、それは文字コードです。実はInvoke-WebRequestコマンドレットには、Webページの文字コードを指定する方法がありません。(多分)
ではレスポンス文字列の文字コードがどのように決まるかというと、サーバーが返すレスポンスヘッダのContent-Typeフィールドで指定されているcharsetです。具体的には、$response.Headers["Content-Type"]の値が例えば"text/html; charset=UTF-8"であれば、$response.Contentの文字コードはUTF-8になります。
このときページ(HTML)を記述している文字コードと、レスポンスヘッダで指定されている文字コードが一致すれば全く問題はないのですが、異なる場合は容赦なく文字化けします。
異なる場合だけでなく、レスポンスヘッダのContent-Typeフィールドに文字コードの指定がない場合はASCIIと見なされるので、日本語のページの場合はやはり文字化けします。
この問題を回避する方法は、私はまだ見つけていません。よって文字化けが起きる場合は、諦めてWebClientを使って文字コードを指定するようにしています…。
WebClientを用いる
以上で述べてきたとおり、Invoke-WebRequestコマンドレットは、ページをさくっと取得して、さくっとパースするのには重宝するのですが、細かい所で融通が利かない印象があります。
そこで細かい処理が必要な場合(と、PowerShell 2.0環境)は、素直にWebClientクラスを用いるのがいいと思います。今回WebClientの使い方も入れようかと思いましたが、長くなったので詳しくは省略します。
基本は以下のような感じでDownloadStringメソッドを使って文字列を取得します。文字コードも指定できます。
$client = New-Object System.Net.WebClient $client.Encoding = [System.Text.Encoding]::UTF8 $content = $client.DownloadString("http://アドレス")
なお、WebClientを用いた場合でも、Invoke-WebRequestと同等のHTMLパースを行う方法は存在するので、それは次回に。
おわりに
今回はまず、Webページから文字列データを取得する部分にフォーカスしてみました。といっても、Invoke-WebRequestの機能を全部網羅したわけではなく、使用頻度が高そうなものと個人的ハマリポイントがあるところだけです。なので詳しくはリファレンスを見て下さい。というかハマリポイントたぶんまだまだ一杯あると思います。
後編では、とってきた文字列データを「パース」して、扱いやすいデータ形式に変換する方法についてまとめようかと思います。
2015/11/09
JapanesePhoneticAnalyzerを使ってPowerShellで形態素解析(後編)
分かち書きとは
中編で作ったGet-JpYomi関数は、JapanesePhoneticAnalyzerクラスの読み仮名取得機能にフォーカスを当てたラッパー関数でした。
今回は、JapanesePhoneticAnalyzerクラスの最大の使用目的と思われる、「分かち書き」を目的とした関数を作成します。分かち書きとは、文章を文節単位で分割することと考えて頂いて良いかと思います。
前々回作ったGet-JpWordは単語単位の分割を行うものでしたが、読みやすさや発音のしやすさを目的として文章を分割表記する場合は、単語単位では細かすぎると言えます。
よって単語単位ではなく、文を意味のあるまとまりとして区切ることのできる最小の単位である、文節単位で分割する方法を考えてみます。
Split-JpText関数
前編で作ったGet-JpWord関数をラップし、分かち書きに特化した関数Split-JpTextを作成しました。まずは以下にコードを示します。
function Split-JpText { param( [parameter(ValueFromPipeline=$true)] [PSObject[]] $InputObject, [ValidateSet("Text", "Yomi", "Detail")] [string] $Format = "Text", [ValidateSet("ByWord", "ByPhrase")] [string] $SplitMode = "ByPhrase", [string] $Separator = " ", [switch] $ToArray ) begin { if($Format -eq "Detail"){$ToArray = $true} } process { foreach($o in $InputObject) { $o.ToString() | Get-JpWord | foreach -Begin { $phrases = @() $phrase = $null } -Process { if($_.IsPhraseStart) { if($phrase){$phrases += $phrase} $phrase = New-Object psobject | Add-Member -MemberType ScriptProperty -Name Text -Value { -join $this.Words.DisplayText} -PassThru | Add-Member -MemberType ScriptProperty -Name Yomi -Value { -join $this.Words.YomiText} -PassThru | Add-Member -MemberType NoteProperty -Name Words -Value @() -PassThru } $phrase.Words += $_ } -End { if($phrase){$phrases += $phrase} if($SplitMode -eq "ByPhrase") { $out = switch($Format) { "Text" {$phrases.Text} "Yomi" {$phrases.Yomi} "Detail" {$phrases} } } else { $out = switch($Format) { "Text" {$phrases.Words.DisplayText} "Yomi" {$phrases.Words.YomiText} "Detail" {$phrases.Words} } } if($ToArray) { $out } else { $out -join $Separator } } } } }
パラメータの説明
パラメータ名 | 型 | 説明 |
InputObject | 任意の型 | 入力テキスト。文字列以外の型の場合は文字列に変換して評価される。パイプライン入力可能。 |
Format | string | Text(デフォルト):文字列のみ出力する。 Yomi:文字列ではなく読みを出力する。 Detail:文節の文字列、読み、各文節に含まれる単語の配列を含んだオブジェクトの配列を出力する。(SplitMode=ByPhraseの時のみ) |
SplitMode | string | ByPhrase(デフォルト):文を文節単位で分割する。 ByWord:文を単語単位で分割する。 |
Separator | string | 分割文字を指定。デフォルトは" "(半角スペース)。(Format=DetailもしくはToArray指定時には無効) |
ToArray | switch | 指定すると、単一の文字列ではなく、文字列の配列を出力する。 |
使用法
- 分かち書き(文節)
例:Split-JpText "今日はいい天気ですね。"
出力:今日は いい 天気ですね 。 - 分割文字指定
例:Split-JpText "今日はいい天気ですね。" -Separator /
出力:今日は/いい/天気ですね/。 - 分かち書き(単語)
例:Split-JpText "今日はいい天気ですね。" -SplitMode ByWord
出力:今日 は いい 天気 です ね 。 - 文節単位で読み仮名を表示
例: Split-JpText "今日はいい天気ですね。" -Separator / -Format Yomi
出力:きょうは/いい/てんきですね/。 - 分かち書きした文節を文字列配列として変数に格納
例:$phrases = Split-JpText "今日はいい天気ですね。" -ToArray
解説
ちょっと長めの関数ですが、ポイントはJapanesePhoneticAnalyzerクラスのGetWordsメソッドが返すJapanesePhonemeオブジェクトのIsPhraseStartプロパティです。
IsPhraseStartプロパティは、当該単語(Phoneme)が文節(Phrase)の開始部分にあたる単語であればTrueを返します。すなわち、JapanesePhonemeコレクションを文頭から文末まで列挙していったとき、IsPhraseStartプロパティがFalseからTrueに変わる部分が文節の境界になるわけです。
Split-JpText関数では、単語を列挙していき、文頭もしくは文節の境界に遭遇すると、文節に含まれる文字列(Textプロパティ)とその読み(Yomiプロパティ)と単語の配列(Wordsプロパティ)を格納するオブジェクトを新たに作成し、$phrase変数に代入します。一方で$phrase変数に元々入っていたオブジェクトは、$phrases配列に追加します。
$phraseオブジェクトのWordsプロパティには、列挙中の単語を都度、追加していきます。
なお、$phraseオブジェクトのTextプロパティとYomiプロパティはスクリプトプロパティとして定義しておき、必要時に値を取得するようにしてあります。
まとめ
3回に渡って、JapanesePhoneticAnalyzerクラスの使用法を具体的なラッパー関数を作成して紹介しました。
個人的には、PowerShellからなら中編で挙げた、読みの取得が一番使いでがあるかな?と思いました。今回取り上げた分かち書きは、意外と応用例が思いつきませんでした。
前編のGet-JpWord関数を使って、何らかの文書群の単語リストをあらかじめインデックスとして出力しておき、単語検索コマンドを実装するのも面白そうですね。
ただ、残念ながら品詞情報が取れないので、JapanesePhoneticAnalyzerをmecabとかの形態素解析エンジンの代替にするのはちょっと厳しいかもしれないです。まあ、標準機能のみでちょっとしたものを作れるのは大きいかと思います。何か日本語の文章を解析する必要があるときには使ってみてはいかがでしょうか。
2015/10/17
JapanesePhoneticAnalyzerを使ってPowerShellで形態素解析(前編)
はじめに
がりっち氏がWindows10 UWPに日本語解析のAPIが備わっていた件 | garicchi.com というエントリを上げていました。
実はWin10に限らず、Win8.1 / Server 2012R2以降であれば、Windows ランタイム(WinRT)のWindows.Globalization名前空間に含まれるJapanesePhoneticAnalyzerクラスを用いた形態素解析ができます。
形態素解析とは要するに文字列を単語(正確には形態素という、文字列の最小構成要素)ごとに分割し、それぞれの単語の品詞を判別する処理になります。(JapanesePhoneticAnalyzerクラスだと分割までで、品詞の情報は取得できない?ようですが…)
またJapanesePhoneticAnalyzerでは分割した単語の読み仮名を取得することができます。
WinRTならPowerShellからも使えるんじゃないかなーと思ってやったらできたので、紹介します。
WinRTのPowerShellからの利用法
WinRTについての説明は他サイト様に譲りますが、要はWindowsストアアプリやUWP(ユニバーサルWindowsプラットフォーム)アプリを動作させる実行環境とAPI群です。
じゃあデスクトップアプリであるPowerShellは関係ないのかというとそうではなくて、例えばストアアプリのサイドローディングを行うAppxモジュールというものがあります。
つまりWinRTは従来のデスクトップアプリからの相互運用もできるようになっています。(すべてのコンポーネントではない)
このあたりの話は、荒井さんの記事が参考になるかと思います。:特集:デスクトップでもWinRT活用:開発者が知っておくべき、ライブラリとしてのWindowsランタイム (1/5) - @IT
PowerShellからWinRTを利用するには.NET Frameworkに含まれるクラスを利用するのと基本は同じです。
ただし注意点としては、クラス名を指定する時は、クラスの「アセンブリ修飾名」を指定する必要があります。
今回の例ではJapanesePhoneticAnalyzerクラスを使いますが、アセンブリ修飾名はWindows.Globalization.JapanesePhoneticAnalyzer, Windows.Globalization, Version=255.255.255.255, Culture=neutral, PublicKeyToken=null, ContentType=WindowsRuntime
となります。
このうち必須となるのは
Windows.Globalization.JapanesePhoneticAnalyzer:クラスの完全修飾名
Windows.Globalization:クラスの含まれる名前空間
ContentType=WindowsRuntime:WinRTのコンポーネントであること
の3つだけのようです。
つまり、PowerShellからは
[Windows.Globalization.JapanesePhoneticAnalyzer, Windows.Globalization, ContentType=WindowsRuntime]
とすればクラスを参照することができます。
ちなみに任意の型のアセンブリ修飾名を知るには、[型名].AssemblyQualifiedName のようにすればOKです。
なお、WinRTにはPowerShellからも利用価値の高いクラスが他にも色々あるようです。ぎたぱそ氏が認証系のクラスについて書かれていますので、参考にしてみてください。:PowerShell も Windows Store Apps 同様に Windows.Security.Credentials namespace を使って認証情報を管理できるようにしてみる - tech.guitarrapc.com
単語を分割する
早速、形態素解析をやってみましょう。具体的にはJapanesePhoneticAnalyzerのGetWordsメソッドを呼び出すだけです。
すべての基本になるので軽く関数としてラップしておきます。
function Get-JpWord { param( [parameter(ValueFromPipeline=$true)] [ValidateLength(1,99)] [string[]] $Text, [switch] $MonoRuby ) process { foreach($t in $Text) { [Windows.Globalization.JapanesePhoneticAnalyzer, Windows.Globalization, ContentType=WindowsRuntime]::GetWords($t, $MonoRuby) } } }
GetWordsメソッドはスタティックメソッドなので、::演算子で呼び出します。戻り値はIReadOnlyList<JapanesePhoneme>というコレクションです。
GetWordsメソッドの第2引数にTrueを指定すると、漢字の含まれた単語をルビの振れる最小単位にまで分割する、Mono Rubyモードが有効になります。
なお、GetWordsメソッドはどうも文字数制限があるようです。だいたい100文字を超えると何も出力しない感じです。この制限値はリファレンスに書いてないようなので詳細不明ですが、一応関数では99文字までという制限を入れておきました。
例えば、Get-JpWord "最近急に寒くなってきました。" のようにすると結果は以下のように表示されます。
DisplayText IsPhraseStart YomiText ----------- ------------- -------- 最近 True さいきん 急に True きゅうに 寒くな True さむくな って False って き True き ました False ました 。 True 。
出力されるJapanesePhonemeオブジェクトは3つのプロパティを持ちます。
DisplayText | 分割された単語 |
IsPhraseStart | 単語が文節の開始であるかどうか |
YomiText | 単語の読み仮名 |
このように、入力した文章を単語単位に分割し、それぞれの単語の読み仮名を取得することができます。ただこれだけだと、「で、どうしろと」という感じなので、この出力結果を利用する、より実用的な関数を書いていきましょう。
長くなったので次回に続く。
2015/08/10
PowerShellでもnameof演算子みたいなことがしたい?
C#6.0のnameof演算子(じんぐるさんによる解説、岩永さんによる解説)が羨ましかったので、PowerShellでも似たようなことができるようにしてみました。
function nameof { param([scriptblock]$s) $element=@($s.Ast.EndBlock.Statements.PipelineElements)[0] if($element -is [System.Management.Automation.Language.CommandExpressionAst]) { switch($element.Expression) { {$_ -is [System.Management.Automation.Language.TypeExpressionAst]} {$_.TypeName.Name} {$_ -is [System.Management.Automation.Language.MemberExpressionAst]} {$_.Member.Value} {$_ -is [System.Management.Automation.Language.VariableExpressionAst]} {$_.VariablePath.UserPath} } } elseif($element -is [System.Management.Automation.Language.CommandAst]) { $element.CommandElements[0].Value } } nameof{$PSHOME} # 変数名 : PSHOME nameof{$PSHOME.Length} # プロパティ名 : Length nameof{[System.Diagnostics.Process]} # クラス名 : System.Diagnostics.Process nameof{[string]::Empty} # フィールド名 : Empty nameof{[DayOfWeek]::Friday} # 列挙体メンバー名 : Friday nameof{Get-Command} # コマンド名 : Get-Command
原理的には、変数やプロパティ等をスクリプトブロックに格納し、生成されるAST(抽象構文木、abstract syntax tree)を解析して、含まれる変数名やプロパティ名を抽出しています。(なので、PowerShell 3.0以上でないと動作しないと思います)
そもそも、どういうシチュエーションで使うの?という話ですが、実はあえとすさんのPowerShell コマンドを C# で書くときに便利な拡張メソッド - 鷲ノ巣という記事を見て、じゃあPSでコマンド(高度な関数)を書く時にも同じことが出来るといいかな?と思ったのがきっかけです。
function Get-Test { [CmdletBinding()] param([int]$Number) if($PSBoundParameters.ContainsKey((nameof{$Number}))) { "-$(nameof{$Number})パラメータが指定された" } }
こういう風に、"Number"という文字列をコード中に書かずに、-Numberパラメータ指定の有無を確認できるようになる、というわけです。
(この例の場合だと、Get-Test -Number 12 のようにすると、if文の中身が実行されます。)
ただ作ってはみたものの、使う意味がどれほどあるのか疑問に思えてきました。一応、ISEでは変数名やメンバ名に入力補完が効くので、実際の変数名を文字列で手打ちしなくて済むというメリットはなきにしもあらず、ですか。
しかし所詮は動的言語なので、存在しない変数名やメンバ名を入れても実行前にエラーは出ないですからね。(Set-StrictModeによるストリクトモードは編集時ではなくあくまで実行時(正確には変数やメンバを参照した瞬間)にエラーを出すためのもの)
それとISEのリファクタリング機能は弱い(というか無い)ので、リファクタリングに追従できるという本家nameof演算子に存在するメリットは、現状のところISEを使っている限りは享受できません。
PowerShell 5.0からの新要素、Script Analyzerによる静的解析を組み合わせればあるいは意味が出てくるのかもしれないですが、まだ確認できてないです。
というわけで、書いてはみたもののなんか微妙ですが、せっかくなんで公開しときます。
2014/12/01
[v5] Auto-Generated Example-Driven Parsing について
はじめに
この記事はPowerShell Advent Calendar 2014の1日目の記事です。
次期バージョンのPowerShell 5.0について、そろそろ情報が出回ってきました。現在のところWindows Management Framework 5.0 Preview November 2014、もしくはWindows 10 Technical PreviewやWindows Server Technical Previewに同梱のもので試すことができます。
v5での新機能、改善点は多岐に上ります。OneGet / PowerShellGet / クラス定義 / DSC機能増強 / ODataエンドポイントのコマンドレット化 / zipファイル / シンボリックリンク 等々。詳しくは、リリースノートが一番充実しているかと思います。日本語だとぎたぱそ氏の記事がまとまっているかと思ます。
さて、ここまで挙げた新機能や改善点は、とても順当でまっとうな進化点なのですが、v5にはちょっと異彩を放つ新機能がしれっと追加されています。それが、Auto-Generated Example-Driven Parsing です。
Auto-Generated Example-Driven Parsing とは
CSV、JSON、XMLのような既知のフォーマットではないが、何らかの法則性のあるテキストデータがあるとします。そんなテキストデータは(不幸なことに)割と世の中にあふれていますが、そのままでは(人が読む以外には)利用できないので、データとして扱うには、解析し、レコード(プロパティ:プロパティ値)として再構築する必要があります。
しかしながらフォーマットが既知のものではないため、既存のパーサーを使って解析することはできません。
従来のアプローチだと、このようなデータに対しては、まずユーザー(人間)がデータの法則性を読み取り、その法則をコンピュータに分かる表現(コードや正規表現など)に変換してやる必要がありました。
Auto-Generated Example-Driven Parsing とは、事前にユーザーがテキストデータの一部分のみを取り出し、各項目に対してプロパティ名を指示したデータ(テンプレート)を用意しておくと、元のテキストデータとユーザーが用意したテンプレートから法則性を解析し、元のテキストデータ全体を自動的にテキストデータからオブジェクトに変換してくれる機能です。
Auto-Generated Example-Driven Parsing とはもともとMicrosoft Research で研究されているFlashExtract というデータ解析手法の PowerShell コマンドレット(ConvertFrom-String)による実装になります。ConvertFrom-StringData じゃないですよ。全然別物です。これうっかりしてるとスルーしてしまいそうです。
具体例
たとえば、こんなデータがあったとします。
山内 佳乃 (やまうち よしの) 生年月日...1982/1/27 (32歳)、女性 田畑 真帆 (たばた まほ) 生年月日...1966/4/14 (48歳)、女性 三好 一樹 (みよし かずき) 生年月日...1972/7/10 (42歳)、男性 酒井 幸平 (さかい こうへい) 生年月日...1954/3/1 (60歳)、男性 藤島 恵子 (ふじしま けいこ) 生年月日...1969/5/4 (45歳)、女性 加藤 美優 (かとう みゅう) 生年月日...1986/12/8 (27歳)、女性 金谷 康文 (かなや やすふみ) 生年月日...1983/10/7 (31歳)、男性 岸本 紗季 (きしもと さき) 生年月日...1984/5/16 (30歳)、女性 永野 ケンイチ (ながの けんいち) 生年月日...1961/7/8 (53歳)、男性 小関 三郎 (こぜき さぶろう) 生年月日...1975/1/22 (39歳)、男性 山岸 光 (やまぎし ひかる) 生年月日...1939/2/13 (75歳)、女性 黒谷 恵麻 (くろたに えま) 生年月日...1949/2/13 (65歳)、女性
名前や生年月日が書かれたデータで、一応、法則性はあるようです。が、これをまともにパースしようと思うと、2行ごとに切り出して、正規表現を書いて…と、ちょっと面倒ですね。
ちなみにこのダミーデータ作成にはなんちゃって個人情報を使わせていただきました。CSVで出力した後、以下のようなスクリプトでわざわざ醜く変形しました。
Import-Csv -Encoding Default -Path dummy.cgi|%{"$($_.名前) ($($_.ふりがな))`n生年月日...$($_.誕生日) ($($_.年齢)歳)、$($_.性別)性"}|set-content -Encoding UTF8 -Path dummy.txt
さて、このテキストデータに対し、Auto-Generated Example-Driven Parsingで用いるテンプレートを書いてやりましょう。たとえば、以下のように適当に3件(ここでは3〜5個目のレコード)抜き出して、プロパティ名をつけてやります。赤字が、手動で元データに付与した文字列です。
{Name*:三好 一樹} ({Furigana:みよし かずき}) 生年月日...{BirthDay:1972/7/10} ({Age:42}歳)、{Sexuality:男}性 {Name*:酒井 幸平} ({Furigana:さかい こうへい}) 生年月日...{BirthDay:1954/3/1} ({Age:60}歳)、{Sexuality:男}性 {Name*:藤島 恵子} ({Furigana:ふじしま けいこ}) 生年月日...{BirthDay:1969/5/4} ({Age:45}歳)、{Sexuality:女}性
みて頂ければ分かると思いますが、基本は、各データ項目に対して、{プロパティ名:データ}のように指定してやるだけです。主キーとなるデータ項目にはプロパティ名の後に「*」をつけてやります。こうやって作ったテンプレートをtemplate.txtと名前を付けて保存しましょう。
元データとテンプレートが揃ったので、あとは以下のようにしてConvertFrom-Stringコマンドレットを実行するだけです。
テンプレートを元に、元テキストに含まれるすべてのデータが、プロパティ値を持ったオブジェクトデータに変換されていることが分かるかと思います。これちょっとすごくないですか?
まとめ
Auto-Generated Example-Driven Parsingは個人的には、非常に面白い機能だと感じています。コンピュータに対して、「手本見せるよ、これはこう、これはこう。わかった? じゃ、あとは同じようにまとめといてね!」というのができるようになったわけで、ちょっと未来を感じました。
研究所レベルの研究成果を、製品として実装した初の例が、PowerShellだったというのも面白味を感じます。
ただ、CSVでもJSONでもXMLでもない、わけのわからない謎フォーマットで保存されたテキストデータを解析しなきゃならない事態というのは、そもそも不幸な状況であることも、また事実かと思います。
ConvertFrom-Stringは、そんな訳の分からないものを撲滅して、今度こそまともなフォーマットのデータに変換して保存するための、最終兵器のようなものかもしれません。
なお、Auto-Generated Example-Driven Parsingでは他にもプロパティに型を指定したり、部分的に正規表現を用いたり、階層構造を持つデータにも対応してたりと、かなり色々なことができるようになっています。ぜひ、v5環境を整えて、ConvertFrom-Stringを試してみてください。
さてさて、PowerShell Advent Calendar 2014、今年は参加者が少なく、完走はかなり危ぶまれますが、できるところまで行きたいですね! これをお読みのあなたの記事が読みたいです! ぜひ、ご参加いただけると幸いです。
2014/04/15
本当は怖いPowerShell その1 暗黙の型変換の闇
PowerShellはゆるふわな言語ですが、そのゆるふわさがたまによく牙を剥きます。今日はそんなお話。
あえとすさんがこんなツイートをされていました。
$x = 'A' + @('B', 'C') ってしたとき、何故 $x.Length -eq 4 で $x[2] -eq ' ' なのだろうか。 #PowerShell
? アエトス・トリスメギストス (@aetos382) April 14, 2014
直観的には、$xには'A', 'B', 'C'の3要素が格納された配列となるのでLengthは3、$x[2]は最後の要素である'C'が入っていそうです。
さて、何故だかわかりますか。シンキングタイム3分。
…では解説です。
まず、'A' + @('B', 'C')というのは実は3要素の配列を返さず、単一の文字列を返します。というのもPowerShellは+, -等の二項演算子を利用する際、左辺と右辺の型が異なる場合は、まず右辺の型を左辺の型に暗黙の型変換を行ってから演算を行います。この場合だと右辺は@('B', 'C')なので文字列配列(厳密にはobject[])、左辺は文字列型なので、文字列配列が文字列に型変換されるわけです。
さて、ここで配列→文字列の型変換がどうやって行われるかという話なのですが、まず配列要素がそれぞれ文字列型に変換されます。この変換は型によってそれぞれ挙動が違いますが、特にPowerShell上で定義がない場合はToString()されたものが返されます。今回のは配列要素が元々文字列なので変換はありません。
次に、文字列同士がユーザー定義$OFSに格納されている文字列で連結されます。$OFSはデフォルトではnull(定義なし)なのですが、nullの場合は" "(半角スペース)として扱われます。
※ちなみにOFSとはOutput Field Separatorの略です。awkとかPerlとかにも同様の変数があり、PowerShellのはそれらを参考にしたものと思います。
よって、@('B', 'C')が文字列に変換されると、'B'と'C'が$OFSのデフォルトの" "で連結され、'B C'となります。変換の後+演算子が実行されて、'A'と'B C'が連結されるので、'AB C'となります。この値が$xに格納されるわけです。
$xには配列ではなく単一の文字列が格納されているので、Lengthプロパティはstringクラスのものが参照されるので、文字数を返却します。$xの中身はA,B,半角スペース,Cの4文字なので$x.Lengthは4になります。
また文字列変数に数値でのインデックスアクセスをすると、該当文字位置に格納されたchar型の文字が返されるので、$x[2]は$xに格納された3番目の文字(インデックスは0から始まるので)、' '(半角スペース)を返すわけですね。
これであえとすさんの疑問は解消したわけですが、じゃあ本来の目的である、「単一の値と配列を連結して配列を得る」にはどうするか、というと…
@('A', 'B', 'C') が欲しければ @('A') + @('B', 'C') とすればよい。 ('A`)ヴァー
? アエトス・トリスメギストス (@aetos382) April 14, 2014
となるわけです。こうやって非配列値をあらかじめ@()により要素数1の配列にしておくと、+演算子の左辺と右辺がどちらも配列型となるため型変換は行われず、配列同士の+演算、すなわち配列の連結処理が行われるわけですね。
その1とありますがその2があるかは不明。なお、闇が沢山あるのは事実です。('A`)ヴァー
2013/03/29
Twitterの「全ツイート履歴」を分析してみよう
はじめに
Twitterブログ: 日本の皆さんにも「全ツイート履歴」が使えるようになりました の記事のとおり、自分の全ツイートデータをダウンロードする機能がTwitterで利用可能になっています。
ダウンロードされるzipファイルには、ツイートを表示するためのHTML、JavaScriptファイルのほか、CSV形式のデータ(tweets.csv)も含まれています。CSVファイルの処理といえばPowerShellが得意とするところです。このファイルを読み込んで、PowerShellで自分のツイートを分析してみましょう。
準備
具体的にダウンロードする方法は上記記事を参考にしていただいて、まずはダウンロードしたzipファイルからtweets.csvを解凍し、PowerShellのカレントディレクトリをtweets.csvのあるフォルダに移動させておいてください。
毎回CSVを読み込むと時間がかかるので、まず以下のようにしてImport-CsvコマンドレットによりCSVファイルを読み込み、変数にオブジェクトとして入れておきます。
$tweets = Import-Csv tweets.csv
なお私の総ツイート数は4万ほどで、tweets.csvは10MB程です。これくらいの容量だとそのままでもまずまずまともな速度で分析が可能ですが、何十万ツイートもしていらっしゃるTwitter廃人マニアの方は、適宜ファイルを分割するなどして対処願います。
CSVファイルのヘッダ行は
"tweet_id","in_reply_to_status_id","in_reply_to_user_id","retweeted_status_id","retweeted_status_user_id","timestamp","source","text","expanded_urls"
となっています。Import-Csvコマンドレットはデフォルトでは1行目を出力オブジェクトのプロパティ名とするので、データ行の1行がtweet_idプロパティ等を持つオブジェクトとして読み込まれ、$tweets変数にはそのオブジェクトの配列が格納されることになります。
ツイート抽出/検索
一番最初のツイートを表示
PS> $tweets | select -Last 1 tweet_id : 948090786 in_reply_to_status_id : in_reply_to_user_id : retweeted_status_id : retweeted_status_user_id : timestamp : 2008-10-06 10:54:10 +0000 source : web text : はぐれメタルがあらわれた! expanded_urls :
Select-Objectコマンドレット(エイリアスselect)はオブジェクトの絞り込みに使います。このCSVファイルではツイートの並び順がタイムスタンプの降順なので、最初のツイートは一番最後の行となります。
直近5ツイート表示
PS> $tweets | select -First 5 | fl timestamp,text timestamp : 2013-03-21 17:02:23 +0000 text : Need for Speedがなんか懐かしい。初めて買ったPCに体験版がバンドルさ れてた記憶がある。 timestamp : 2013-03-21 17:01:23 +0000 text : そいえばEAのシムシティ不具合お詫び無料DL特典、何選ぼうかなあ。シム シティ4あるけど英語版という噂だし2013やった後につらいもんがありそう 。 timestamp : 2013-03-21 16:45:09 +0000 text : というわけでシムシティ大好きなんで、私の街を返してください… ...
Format-Listコマンドレット(エイリアスfl)を使うと必要なプロパティ値のみ抽出してリスト形式で表示できます。
文字列で検索
PS> $tweets | where {$_.text -match "眠い"} | fl timestamp,text timestamp : 2013-03-05 10:46:39 +0000 text : 眠いのってもしかしてアレルギールの副作用かも。蕁麻疹がひどいときし か飲んでないんだけどねえ timestamp : 2013-03-05 05:42:18 +0000 text : なんでこんなに眠いのかな timestamp : 2013-03-04 07:44:18 +0000 text : 眠いなあ ...
Where-Objectコマンドレット(エイリアスwhere)を使うとオブジェクト配列のうち特定条件のもののみ抽出できます。ここではツイート本文(textプロパティ)に"眠い"という文字列が含まれているものを抽出しています。どんだけ眠いんですか私は…
2009年のツイートのみ表示
PS> $tweets | select @{L = "timestamp"; E = {Get-Date $_.timestamp}},text | where {$_.timestamp.Year -eq 2009} | sort timestamp | fl timestamp,text timestamp : 2009/01/01 0:01:08 text : あけおめ! timestamp : 2009/01/01 0:16:31 text : 2chとついったー強いなーmixiしんでた timestamp : 2009/01/01 13:37:50 text : 家族でおせちをたべた。おいしかった ...
もちろん本文に含まれる文字列以外にも、timestamp(ツイート時刻)で抽出するなどもできます。ここではtimestampがGMTで分かりづらく、かつ文字列のため扱いづらいので、Select-Objectに集計プロパティを指定してDateTime型に変換しています。Format-ListやSelect-Objectに指定する集計プロパティの書式は、@{L="ラベル";E={値を返すスクリプトブロック}}のように連想配列で指定します。LはLabel、EはExpressionのように省略せずに指定してもOKです。
集計プロパティはあんまり解説を見かけないですけども、オブジェクトを処理するコマンドレットの多くで利用可能できわめて重要なので覚えておくと良いと思います。
よるほ成功ツイート
PS> $tweets | where {(Get-Date $_.timestamp).ToString("HH:mm:ss") -eq "00:00:00"} | fl @{L = "timestamp"; E = {Get-Date $_.timestamp}},text
応用でこんなんもできます。0:00:00ちょうどのツイートを抽出します。私はかつてよるほ成功したことがないので結果は何も返ってきませんけど。
ツイート中のURLリストを作る
PS> $tweets | where {$_.expanded_urls} | select -expand expanded_urls http://ja.wikipedia.org/wiki/%E5%B2%A1%E7%B4%A0%E4%B8%96 http://htn.to/4oxXDN http://guitarrapc.wordpress.com ...
whereによる抽出を応用するとこういうこともできます。なお、expanded_urls列は本文中のURLが複数含まれているとそれらは,で区切られるため、可変長の行となります。Import-Csvコマンドレットはこのような可変長なCSVに対応していないので、複数URLがあっても最初の1つのみ取得します。それとexpanded_urlsが追加されたのはt.coによるURL短縮が始まってからなので、昔のツイートにこの値は含まれていません。
ツイート数統計
月別ツイート数表示
PS> $tweets | group @{E = {(Get-Date $_.timestamp).ToString("yyyy/MM")}} -NoElement Count Name ----- ---- 432 2013/03 413 2013/02 248 2013/01 741 2012/12 497 2012/11 791 2012/10 659 2012/09 ...
ツイート分析と言えばやはりツイート数統計を取ることから始まるでしょう。統計を取るにはGroup-Objectコマンドレット(エイリアスgroup)が使えます。ここでもグループ化キーとして集計プロパティを指定してやります。ツイートの「年/月」を文字列化し、それが同じツイートでグループ化することで、月別ツイート数の統計が表示できるわけです。
時間帯別ツイート数表示
PS> $tweets | group @{E = {Get-Date $_.timestamp | select -expand Hour}} -NoElement | sort @{E = {[int]$_.Name}} Count Name ----- ---- 2369 0 1630 1 1137 2 ... 2270 23
やり方としては先ほどのとほぼ同じです。Select-Object -ExpandPropertyはパイプライン入力でオブジェクトのプロパティ値を取得できるのでよく使います。ちなみにPowerShell 3.0だと「$obj|foreach プロパティ名」でも取れますね。
Sort-Objectコマンドレット(エイリアスsort)でもソートキーとして集計プロパティを指定できます。ここではNameプロパティ(グループ化キーの値)をintに変換したものをキーにソートしています。
曜日別ツイート数表示
PS> $tweets | group @{E = {Get-Date $_.timestamp | select -expand DayOfWeek}} -NoElement | sort @{E = {[DayOfWeek]$_.Name}} Count Name ----- ---- 4939 Sunday 5164 Monday 5463 Tuesday 5164 Wednesday 5563 Thursday 5992 Friday 6331 Saturday
これもやり方としてはほぼ同じ。ソートキーはDayOfWeek列挙体にキャストしてちゃんと曜日順に並ぶようにしてます。
ツイート数計測
総ツイート数
PS> $tweets | measure Count : 38616 Average : Sum : Maximum : Minimum : Property :
ここからはツイート数の計測をしていきます。単純にツイート総数を取るだけならMeasure-Objectコマンドレット(エイリアスmeasure)を使うだけでOKです。Averageなどは対応するスイッチパラメータ(-Averageなど)を指定すると計測されますが、この場合は元オブジェクトが数値ではないのでエラーになります。
ツイート文字数分析
PS> Add-Type -AssemblyName System.Web PS> $tweets | where {!$_.retweeted_status_id} | select @{L = "TextLength"; E = { [System.Web.HttpUtility]::HtmlDecode($_.text).Length}} | measure -Sum -Maximum -Minimum -Average -Property TextLength Count : 37718 Average : 48.7322233416406 Sum : 1838082 Maximum : 140 Minimum : 1 Property : TextLength
ツイート文字数を計測するとき、元のオブジェクトにはツイート文字数を返すプロパティはないので、Select-ObjectコマンドレットにTextLengthという集計プロパティを指定して新たに作ってしまいます。
Measure-Objectコマンドレットは-Propertyパラメータにより対象オブジェクトのどのプロパティ値を計測するか指定できます。そしてスイッチパラメータを全部有効にすることで、平均、合計、最大、最小値を計測しています。私の総ツイート文字数は183万です。
なお、リツイートの場合はretweeted_status_idにリツイート元のツイートIDが入るので、このIDがあるものはWhere-Objectで除外してます。またツイート本文の<や&などはHTMLエンコードされたものがtext列に格納されているので、HttpUtilityを使ってデコードしてから文字数をカウントしています。
通常ツイートとRTの比率
PS> $tweets | foreach { $TweetCount = 0; $RTCount = 0 } { if($_.retweeted_status_id){ $RTCount++ }else{ $TweetCount++ } } { New-Object psobject @{ AllCount = $tweets.Length; TweetCount = $TweetCount; RTCount = $RTCount; RTRatio = $RTCount/$tweets.Length } } Name Value ---- ----- RTCount 898 TweetCount 37718 AllCount 38616 RTRatio 0.023254609488295
Measure-Objectコマンドレットは計測方法を指定することはできないので、独自の計測を行う場合はこんな感じでコードめいたものを書く必要が出てくるかと思います。RT率たったの2%か…ゴミめ…
ForEach-Object(エイリアスforeach)は1個のスクリプトブロックをパラメータに指定するとprocessブロック相当の列挙部分を実行しますが、このように3個指定すると、それぞれbegin(初期化処理)、process、end(終了処理)ブロックに割り振られます。
ここではbeginブロックで変数初期化、processブロックで通常ツイートとリツイートを加算、endブロックで計測値をPSObjectに格納して出力してます。ちなみにPowerShell 3.0ではカスタムオブジェクトを作る場合は「[pscustomobject]@{連想配列}」で書くほうが楽です。
お前は今まで寒いと言った回数を覚えているのか
PS> $tweets | foreach {$count = 0} { $count += ($_.text -split "寒い").Length - 1} {$count} 137
覚えてないから数えます。137回か。
数値だけを出力するならこんな感じでシンプルに書けますね。
ランキング
クライアントランキング
PS> $tweets | group @{E = {$_.source -replace "<.+?>"}} -NoElement | sort Count -Descending Count Name ----- ---- 12927 Janetter 11333 web 5230 Azurea for Windows 3060 TweetDeck 1694 Hatena 866 twicca 667 twigadge ...
ここからはいろんなランキングを取得してみます。まずはツイートに使ったTwitterクライアントのランキング。ここでもGroup-Objectを使っています。クライアント名はクライアント配布URLがaタグで含まれているのでそれを-replace演算子で削ったものをグループ化キーとしています。ランキングなので最後はCountで降順ソート。
リプライしたユーザーランキング
PS> $tweets | where {$_.in_reply_to_user_id} | select @{L = "user"; E = {if($_.text -match "^(@[a-zA-Z0-9_]+)"){$matches[1]}}} | group user -NoElement | sort Count -Descending Count Name ----- ---- 807 @xxxxxxxxxx 417 @xxxxxxxxxx 333 @xxxxxxxxxx ...
ランキング系はどれもgroup→sort Countのパターンになるかと思います。リプライツイートはin_reply_to_user_id列にリプライしたユーザーIDが含まれるのでまずはそれでフィルタし、ユーザー名はツイート本文から取ります。ユーザー名は-match演算子を使って正規表現で抽出します。$matches自動変数は連想配列で、[0]にマッチ全体が、[1],[2],...にはサブ式のキャプチャが入ります。ちなみにサブ式に名前を付けてるとキー名が数値ではなくサブ式名となります。
ハッシュタグランキング
PS> $tweets | foreach {[regex]::Matches($_.text, "(#\S+)") | % {$_.Captures} |% {$_.Value}} | group -NoElement | where Count -gt 1 | sort Count -Descending Count Name ----- ---- 199 #zanmai 75 #nowplaying 68 #nhk 63 #techedj2009 ...
ハッシュタグも同様のアプローチで取れますが、ハッシュタグは1ツイートに複数あることがあり、-match演算子だと複数のマッチは取れないので[regex]を使って取得しています。
おわりに
PowerShellのオブジェクト処理用コマンドレットを用いると、CSVデータの分析ができます。普通はログファイル等を解析するのに使うわけですが、こういう身近なデータを扱ってみるのも面白いんじゃないでしょうか。きっとPowerShellの勉強にもなると思います。
2013/01/20
FizzBuzz再び
PowerShellでFizzBuzz問題をいかに短く書くかというのは、人類にとっての太古からの命題であり、色々な方がチャレンジしています。
以下は国内でのチャレンジを、 日時、チャレンジャー名、コード文字数(半角スペース消去後)、初出アドレス で時系列にまとめたものです。
2007/11/06 牟田口 89文字 リンク
2007/11/07 囚人さん 86文字 リンク
2007/11/07 よこけんさん 75文字 リンク
2007/11/13 よこけんさん 57文字 リンク
2013/01/19 guitarrapcさん 57文字 リンク
私の現在の最短コードはこれです。PowerShell 3.0でしか動きませんが、51文字です。
1..100|%{($t="fizz"*!($_%3)+"buzz"*!($_%5))+$_[$t]}
PowerShell 2.0でも動くバージョンは以下。54文字です。
1..100|%{($t="fizz"*!($_%3)+"buzz"*!($_%5))+@($_)[$t]}
きっと解説は不要だと思いますが蛇足を承知で少しばかり。
$_%3 は、剰余を求める演算子%を使っているので、$_が3の倍数のとき0を返します。
!(0)とすると、0はboolに型変換され$falseとなり、その論理否定なので!(0)は$trueになります。
”fizz”*$true とすると右辺はintに型変換されるので”fizz”*1が評価され、”fizz”を返します。PowerShellでは「文字列*整数値」で文字列を整数値回繰り返した文字列を返すことを利用しています。
同じことを”buzz”に対しても行い、結果を+で連結します。このとき、”fizz”か”buzz”か”fizzbuzz”か””(空の文字列)のいずれかを返します。得られた値を@とします。
($t=@) とすると$tに@の値を入れつつ、@の値を返します。
@($_)[$t] とすると、$tが””(空の文字列)の場合は型変換され@($_)[0]が評価されます。よって、$tが””のときは@($_)の0番目の要素、$_、すなわち元の数値が取り出されます。最後に””と元の数値を+で連結したものが出力されるので、結果として数値のみが出力されます。
$tが”fizz”か”buzz”か”fizzbuzz”の場合は@($_)[$t]は配列の範囲外なので$nullを返します。よって$t+$null、すなわち$tの文字列がそのまま出力されます。
PowerShell 3.0だと非配列変数でも[]演算子を使用することができます。よって$_[0]は$_と等しく、$_[文字列]は$nullです。これによって@($_)のように配列化する必要がなく、3文字短縮できたわけです。
Copyright © 2005-2016 Daisuke Mutaguchi All rights reserved
mailto: mutaguchi at roy.hi-ho.ne.jp