2015/12/04

この記事は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の機能を全部網羅したわけではなく、使用頻度が高そうなものと個人的ハマリポイントがあるところだけです。なので詳しくはリファレンスを見て下さい。というかハマリポイントたぶんまだまだ一杯あると思います。

後編では、とってきた文字列データを「パース」して、扱いやすいデータ形式に変換する方法についてまとめようかと思います。

2009/12/03

以前、[Twitter][WSH]Twitterにポストするという記事を書いたんですが、もっと簡単にできましたので修正版。


sUser = "userid" 'ユーザーID
sPassword = "password" 'パスワード
sURL = "http://twitter.com/statuses/update.json"

Set oHTTP = WScript.CreateObject("Msxml2.XMLHTTP")
Set wshShell=CreateObject("WScript.Shell")

oHTTP.Open "POST", sURL, False, sUser, sPassword
oHTTP.setRequestHeader "Content-Type", "application/x-www-form-urlencoded"
oHTTP.setRequestHeader "X-Twitter-Client", "twitterPost.vbs"
oHTTP.setRequestHeader "X-Twitter-Client-Version", "1.0"
oHTTP.send "status=" & "テストです"

ポイントは、URLエンコードが実は必要なかったというところです。XMLHTTPは呼び出し元の文字コードに関わらず必ず文字列をUTF-8でURLエンコードしてポストするのでした。というわけでこれを使ってください。

元記事:http://blogs.wankuma.com/mutaguchi/archive/2009/12/03/183500.aspx

2008/12/07

レガシASPでサイトを作ってると、Shift-JISなサイトを作るのが基本になると思います。なんでかというと、FileSystemObjectが基本的にShift-JISの読み書きにしか対応しておらず(UTF-16もいけますが)、いまどきのUTF-8を使うのはちょっと面倒です(FSOの代わりにADODB.Streamを使えば行けますけどどうでしょうねー?私はあんまり好きじゃないです)。

ただ、UTF-8な他のWebサイト/サービスと連携する場合はどうしても避けて通れません。そこでレガシASPでShift-JISなページを作る際、UTF-8文字列を扱う上で知っておくべきこと。

1. escape関数を使うとShift-JISでURLエンコードがされる

ASPはだいたいVBScriptで書くと思うんですが、隠し関数であるescape関数を使うとURLエンコードができます。ですが、escape関数は呼び出し元のページコードの文字コードでエンコードします。なのでShift-JISなページで呼び出すとShift-JISのエンコードURLを出力します。(ちなみにWSHで使うとUTF-16のものになる)

JScriptのencodeURIComponent関数はどんな場合でもUTF-8文字列を出力するので、これを使うといいでしょう。使い方はこうです。

Set sc = CreateObject("ScriptControl")
sc.Language = "JScript"
Set js = sc.CodeObject
Response.Write js.encodeURIComponent("文字列") 

逆にShift-JISなページでShift-JISなエンコードURL文字列を取得したい場合は単にescape関数を呼び出せばいいです。
さらに別なケースですがUTF-8なページでShift-JISなエンコードURLを取得したい場合は、こんな関数を使うといいんじゃないでしょうか

2. XMLHTTPでPostメソッドでSendする際は必ずUTF-8でURLエンコードがされる

Set xh = CreateObject("MSXML2.XMLHTTP")
xh.Open "POST", "http://hogehoge/hoge.aspx", False
xh.Send "文字列"

このように何も考えずに書いても、勝手にUTF-8でURLエンコードされてPostされるので大丈夫です。

3. UTF-8なページのHTMLを読み込む際

標準機能だけでやろうと思うとADODB.Streamを使うしかないと思います。
ちなみに読み込むページの文字コードが不明の場合は判定した上で変換する必要がありますが、これはかなり面倒なので、BASP21を使うといいんじゃないでしょうか。

Function GetPageString(strUrl)
 Set bobj = CreateObject("basp21")
 Set oHTTP = CreateObject("Msxml2.XMLHTTP")
 oHTTP.Open "GET", strUrl, False
 oHTTP.Send
 GetPageString = bobj.Kconv (oHTTP.responseBody,4)
End Function

これは引数にURLを与えるとそのHTMLを文字列として取得します。対象の文字コードが何であってもOKなのがミソ。

4. UTF-8のURLエンコードされたクエリ、あるいはPOSTされたデータを受ける際

これのやり方が分からない!具体的にはトラックバックpingなんかを受け取る際に困ります(さすがにShift-JISでトラックバックpingを送れ!というのはゴーマンだと思います)。私はここだけASP.NETを使って逃げました。どなたかやり方わかります?

追記。Request.BinaryReadしたやつをADODB.Streamにかけたあと&でsplitして=でsplitしてDictionaryに入れてdecodeURIComponentすればいけるかな?

ただし、ここだけASP.NETを使う際にも注意が必要です。まずweb.configの<system.web>セクションに

<globalization
requestEncoding="Shift-JIS" responseEncoding="Shift-JIS" fileEncoding="Shift-JIS"/>

というのを埋め込んで、まずレスポンスエンコーディングをShift-JISにしておきます。IISの設定でもいいですが。

続いてコーディング。Request.QueryStringやRequest.Formは使えないので、Request.InputStreamを使ってごりごり読まないと駄目じゃないかな・・・。なぜかVB.NETですがUTF-8なトラックバックpingをShift-JISなページで受けるサンプルコードを。

Dim str As System.IO.Stream
Dim counter, strLen, strRead As Integer
str = Request.InputStream
strLen = CInt(str.Length)
Dim strArr(strLen) As Byte
strRead = str.Read(strArr, 0, strLen)

Dim Forms As New Dictionary(Of String, String)

For Each item As String In Split(Encoding.UTF8.GetString(strArr),"&")
	If InStr(item, "=") Then
		Dim s As String() = Split(item, "=")
		If s.Length = 2 And Not Forms.ContainsKey(s(0)) Then
			Forms.Add(s(0), HttpUtility.HtmlEncode(HttpUtility.UrlDecode(s(1), Encoding.UTF8)).Trim().Replace(vbNullChar, ""))
		End If
	End If
Next

↑自分でも謎なコードを書いてたのでちょっとマシなのに修正。コンパイル通るかどうかわかりませんが・・・さらにゴミコードが残ってたのでバッサリ切りました。

ただし!これの問題は改行コードが消えることなんです。対処法は見つけていません(勘違いでした)。もっといい方法があったら教えてください。そもそもInputStreamを使わないでRequest.Formとか使いたいんですが、Shift-JISのところにUTF-8が来るとうまくいかないですねぇー。

というわけで長々と書きましたが、Shift-JISにこだわらなければこんなに苦労することはないです。FileSystemObjectがUTF-8を読み書きできないので私はSJISにこだわってるだけです。FSOはWSHからも使いますので・・・

元記事:http://blogs.wankuma.com/mutaguchi/archive/2008/12/07/162931.aspx

2007/11/27

True

どっちか要らない子なんじゃ・・・違いがわからないよー

これだけではなんなのでミクシィから適当にコピペ

----------------------------------------------------------------------------------------------------
 
[powershell]new-service何のために
2007年11月26日19:04

あるのかよくわからんー
新しくサービスを登録するっていうんだけど、そういうのってインストーラーの仕事じゃ・・・
おまけにRemove-Serviceコマンドレットがないから作っても削除できないw
sc.exe delete hoge
としないといけない。
sc delete hogeだとSet-Contentのエイリアスが動いちゃうw
なんかすげー危ないコマンドレットな気がするよ。

----------------------------------------------------------------------------------------------------

VistaにはWin32_LogicalMemoryConfigurationないんだ

http://msdn2.microsoft.com/en-us/library/aa394181.aspx
Windows XP and Windows Server 2003: This class is no longer supported. Use the Win32_OperatingSystem class instead.

ほう

 

----------------------------------------------------------------------------------------------------

http://www.anchorsystems.co.jp/anchor/ashp/netmon/faq.html
ファイヤウォールが WMI 呼び出しをブロックしてしまうためです。 Windows 2003 SP1 と Windows XP では、デフォルトでファイヤウォールが ON になっています。ファイヤウォールに WMI 呼び出しを通過させるようにするには、以下のスクリプトを実行してください。
Set objFirewall = CreateObject("HNetCfg.FwMgr")
Set objPolicy = objFirewall.LocalPolicy.CurrentProfile
Set objAdminSettings = objPolicy.RemoteAdminSettings
objAdminSettings.Enabled = TRUE
これで WMI 呼び出しが許可されます。

ファイアウォール嫌いー

リモートでGet-WMIObjectするときにひつよう

----------------------------------------------------------------------------------------------------

[PowerShell]Get-Serviceしょぼすぎ
2007年11月26日00:55

Get-Serviceの戻り値が.NETのSystem.ServiceProcess.ServiceControllerなんすけど、Descriptionプロパティとかないねんな。
でもSet-ServiceでDescriptionを設定できたりする。どうやってちゃんと設定できたかを確認するかはget-wmiobject win32_serviceで調べるらしいwなんだこの中途半端な実装は。
ServiceControllerオブジェクトに対しps1xmlファイルでDescriptionやStartModeをScriptPropertyにして実装しとけよーと思った。せっかく拡張できるんだからさ。

----------------------------------------------------------------------------------------------------
Select-String使えん・・・
2007年11月25日00:10

PS C:\script> select-string "aa" *.ps1
attrib.ps1:7: # Get-Item?R?}???h???b?g??p???AAttributes?v???p?e?B??B
文字化けしとるがな
Shift-JISのファイルも検索・表示できないとはかなり終わってますね
せめて文字コードを指定できるようにしてくれー

UTF8はいけます
 
.NET Frameworkには文字コード判別のクラスとかないのかな・・・
前探してなかった気もする
文字コードを判別する: .NET Tips: C#, VB.NET, Visual Studio
http://dobon.net/vb/dotnet/string/detectcode.html
こういうごり押しが必要なのねー

----------------------------------------------------------------------------------------------------

あと&{スクリプトブロック} は、C#の{}空ブロックと同じことができるらしいー

要するに変数がその中でのみ使われてスコープ抜けたら破棄されるという

これを応用すればtrap文でtry catchみたいなこともできるらしいー

詳しくはPowerShellインアクションを買おう!w

元記事:http://blogs.wankuma.com/mutaguchi/archive/2007/11/27/110583.aspx

2007/01/08

PowerShellにおいて、リダイレクト(> or >>)を使ってテキストファイルにテキストを書き込むと、Unicodeで書き込まれます。

が、Set-ContentやAdd-Contentコマンドレットを使うと、Shift-JIS/US-ASCIIで書き込まれます。

ということは、両者を併用して同じファイルに書き込むことは出来ません。(たぶん)

回避策は、リダイレクトのコマンドレットバージョンとして、Out-Fileコマンドレットがありますが、パラメータに-encoding defaultと指定すると、Shift-JIS/US-ASCIIで書き込まれます。これならAdd-Contentコマンドレットと文字コードを合わせられます。

が、面倒ですね…。リダイレクトで出力されるデフォルト文字コードを指定できればいいんですが…。

元記事:http://blogs.wankuma.com/mutaguchi/archive/2007/01/08/54993.aspx

2006/06/28

元記事はhttp://winscript.s41.xrea.com/mt/archives/2005/09/post_3.htmlです。

文字コードを変換するサンプルスクリプトです。例では引数に与えたShift_JISのテキストファイルをUTF-8に変換します。.NET Frameworkのオブジェクトを自由自在に呼び出せる便利さを味わってください。

$sr = new-object System.IO.StreamReader (
   $args[0], 
   [System.Text.Encoding]::GetEncoding("sjis"))

$sw = new-object System.IO.StreamWriter (
   ([System.IO.Path]::GetFileNameWithoutExtension($args[0]) +
   "_utf8" +  [System.IO.Path]::GetExtension($args[0])),
   [System.Text.Encoding]::GetEncoding("utf-8"))

while (($line = $sr.ReadLine()) -ne $null){
	$sw.WriteLine($line)
}
$sr.Close()
$sw.Close()
元記事:http://blogs.wankuma.com/mutaguchi/archive/2006/06/28/31470.aspx

Copyright © 2005-2016 Daisuke Mutaguchi All rights reserved

mailto: mutaguchi at roy.hi-ho.ne.jp

Awards

Books

Twitter