tech.guitarrapc.cóm

Technical updates

PowerShellっぽく陸上自衛隊のイラク派遣日報をまとめてダウンロードしてみる

面白い記事があったので、私もやってみます。

blog.daruyanagi.jp

毎度毎度、PowerShellっぽさとは何かなぁ思うのですが、PowerShell実践ガイドブックでもWebサイトのステータス監視などを書いたので、良い題材な気がします。

目次

C# だとどう書くのか

C#ならAngleSharpを使って次のようなコードでダウンロード処理を行うことができます。 私の自宅では、60Mbpsを維持して5分でおわりました。

gist.github.com

PowerShellだとどうなるでしょうか。

元記事の処理

元記事では次のように書いています。

$source = "https://www.asahi.com/articles/ASL4J669JL4JUEHF016.html"
$folder = "C:\Users\Hideto\pdf"

$result = Invoke-WebRequest $source -UseBasicParsing
$urls = $result.Links.href | Get-Unique |  where { $_ -match ".pdf" }

foreach ($url in $urls)
{
    $file = ($url  -split "/")[-1]
    Invoke-WebRequest -Uri $url -OutFile (Join-Path $folder $file)
}

PowerShell 6.0で、ここから何処まで手早く書きつつ、素早く処理できるか考えてみましょう。

必要な処理を抜き出す

必要な処理は、次の4つとわかります。

  • サイトの構造からリンクURLを抜き出す
  • PDFのパスのみを取得する
  • 重複があればはじく
  • ダウンロードする

インライン処理

URLや、パスに関しては、処理を括弧でくくることでインライン処理しつつプロパティにアクセスできます。 変数に保持したいかどうか次第ですが、書き捨てでコンソールで書くならこんなのでもいいでしょう。

(Invoke-WebRequest -Uri https://www.asahi.com/articles/ASL4J669JL4JUEHF016.html).Links.href

パイプラインとメソッド形式の選択

さて、PDFに絞る方法ですが、パイプラインの入力をStringクラスのEndsWithメソッドで絞るのが楽でしょう。

(Invoke-WebRequest -Uri https://www.asahi.com/articles/ASL4J669JL4JUEHF016.html).Links.href | 
Where {$_.EndsWith(".pdf")}

コレクション入力を都度処理するときは、パイプラインで書くのも楽ですがメソッド形式で書くという選択もあります。 メソッド形式については過去の記事をどうぞ。

tech.guitarrapc.com

PowerShell実践ガイドブックでは書かなかったのですが、.ForEach().Where() メソッドは、foreach構文同様にコレクションを処理する時に一度メモリにため込むため、パイプラインよりも高速に動作します。ただしメモリに保持するということは、膨大な大きさのコレクションではメモリを大量に使うため対象のサイズに注意が必要です。

(Invoke-WebRequest -Uri https://www.asahi.com/articles/ASL4J669JL4JUEHF016.html).Links.href.Where({$_.EndsWith(".pdf")})

なお、一行が長くなって改行したい場合は、.で改行します。.WhereではなくWhereとなるので気を付けましょう。

(Invoke-WebRequest -Uri https://www.asahi.com/articles/ASL4J669JL4JUEHF016.html).Links.href.
Where({$_.EndsWith(".pdf")})

あるいは{(で改行するのもいいでしょう。

(Invoke-WebRequest -Uri https://www.asahi.com/articles/ASL4J669JL4JUEHF016.html).Links.href.Where({
$_.EndsWith(".pdf")})

メソッド形式で数をフィルタ (Select-Object -First 1 に相当する処理は、Whereメソッドで指定します。

(Invoke-WebRequest -Uri https://www.asahi.com/articles/ASL4J669JL4JUEHF016.html).Links.href.
Where({$_.EndsWith(".pdf")}, "First", 1)

こういう処理を書いている時は、1個だけ試したい、というのはあるあるですからね。

一意に絞る

URLの数を見てみると、1220個ありますが、PDFを末尾に持つURLで絞ると870個です。 しかし、ここには重複したURLが含まれているため、一意(ユニーク)なURLに絞りましょう。 PDFのURLは順不同に並んでいるため、Get-UniqueではなくSort-Object -Uniqueをする必要があります。

(Invoke-WebRequest -Uri https://www.asahi.com/articles/ASL4J669JL4JUEHF016.html).Links.href | 
Where {$_.EndsWith(".pdf")} | 
Sort-Object -Unique

これで、435個に絞られました。

URLからファイル名を取る

元記事では、$file = ($url -split "/")[-1]と書いており/で分割してできた配列の最後*1をとっています。 PowerShellらしいといえばらしいのですが、Split-Pathを使うと配列を意識せず/の最後をとれます。

Split-Path PDFのURLパス -Leaf

PDFファイルのURL、ファイル名まで取れたのを確認してみましょう。 1つだけ試すならこれで。

(Invoke-WebRequest -Uri https://www.asahi.com/articles/ASL4J669JL4JUEHF016.html).Links.href.
Where({$_.EndsWith(".pdf")}, "First", 1).
ForEach({Split-Path $_ -Leaf})

パイプラインならこうです。

(Invoke-WebRequest -Uri https://www.asahi.com/articles/ASL4J669JL4JUEHF016.html).Links.href | 
Where {$_.EndsWith(".pdf") | 
Select-Object -First 1 | 
ForEach {Split-Path $_ -Leaf}

ダウンロードする

1つだけダウンロードするならこうです。

(Invoke-WebRequest -Uri https://www.asahi.com/articles/ASL4J669JL4JUEHF016.html).Links.href.
Where({$_.EndsWith(".pdf")}, "First", 1).
ForEach({Invoke-WebRequest -Uri $_ -OutFile (Split-Path $_ -Leaf)})

全てなら、一意に絞りましょう。

((Invoke-WebRequest -Uri https://www.asahi.com/articles/ASL4J669JL4JUEHF016.html).Links.href.
Where({$_.EndsWith(".pdf")}) | 
Sort-Object -Unique).
ForEach({Invoke-WebRequest -Uri $_ -OutFile (Split-Path $_ -Leaf)})

パイプラインでも、同様に書けます。

(Invoke-WebRequest -Uri https://www.asahi.com/articles/ASL4J669JL4JUEHF016.html).Links.href | 
Where {$_.EndsWith(".pdf")} | 
Sort-Object -Unique | 
ForEach {Invoke-WebRequest -Uri $_ -OutFile (Split-Path $_ -Leaf)}

また、パイプラインの場合は-PipelineVariable を使ってメソッドよりも柔軟に書くことができます。 -PipelineVariableは、自動変数を一度変数に受けて明示的な変数に割り当てることを不要にするので非常に便利です。

(Invoke-WebRequest -Uri https://www.asahi.com/articles/ASL4J669JL4JUEHF016.html).Links.href | 
Where {$_.EndsWith(".pdf")} | 
Sort-Object -Unique -PipelineVariable pdf | 
ForEach{Invoke-WebRequest -Uri $pdf -OutFile (Split-Path $_ -Leaf)}

もちろんこれでダウンロードできるのですが、1つ1つのファイルをダウンロードしたら次に行く同期処理です。 そのため、ダウンロードに時間がかかり435ファイルで6分かかります。

次にこれを高速化してみましょう。

非同期ダウンロードで高速化する

PowerShellの非同期技法は、大きく2つです。 Job機能を使うか、.NETの非同期構文です。

Jobを使った非同期処理

PowerShellらしさ、となるとJob機能ですがオーバーヘッドが大きい処理のため、必ずしも適切ではないケースがあります。今回のような「膨大な数」がまさにそのケースで、こういったときには.NETランタイムを使うと非同期処理がしやすいです。

Jobに変数を渡す時は、スクリプトブロックにparam句を使って -ArgumentList パラメーターを使って渡しますが、実行すると全くダウンロードされないことが分かるでしょう。 これがJobの問題点で、ダウンロードのような処理を大量のジョブでリソース割り当てを行うと大概うまくいきません。 ただの分散処理なら問題ないのですが、ダウンロードは陥りやすいでしょう。

$jobs = (Invoke-WebRequest -Uri https://www.asahi.com/articles/ASL4J669JL4JUEHF016.html).Links.href | 
Where {$_.EndsWith(".pdf")} | 
Sort-Object -Unique | 
ForEach{Start-Job{param($x,$file,$path) cd $path; Invoke-WebRequest -Uri $x -OutFile $file} -ArgumentList $_,(Split-Path $_ -Leaf),$pwd}
Receive-Job $jobs -Wait

Taskを使った非同期処理

.NETのTaskを使ってみましょう。HttpClient経由でダウンロードします。 先ほどのC#の処理からダウンロード部分を抜き出してヒア文字列としたら、Add-Typeでクラスをコンパイル/読み込みします。 あとは、uri一覧をPowerShellで取得してダウンロードを呼び出すだけです。

おおよそC#と同程度の時間で終わります。

gist.github.com

C#側にダウンロード、非同期ロジックを任せることができるので、PowerShellのコードがシンプルなことがわかります。

まとめ

たぶんPowerShellっぽさは、「型を必要になるまで意識せずに」、「適当にコマンドつなげたら書ける」の2点だと思います。 なので、同期処理の場合はワンライナーにしましたし、するのは違和感ないと思います。 一方で、Jobが入った瞬間難しい見た目ですね。.NETランタイム使うのも、唐突に新しい考えが入った印象が強いと思います。 自分が書きやすく、未来の自分が読むのも困らないように書くにはコツがあります。

  • コマンドレットのエイリアスはあまり使わない(Whereや%のような頻出以外は、なるべくフルで)
  • パラメーターを必ず用いる
  • パスやディレクトリを直接触らない

あとは、シェルっぽいやり方として

  • 変数にすることでDRYができるならするが、DRYにならずメンテもしないなら書き捨てる
  • コマンドはパイプラインでつなげていく

見た目が難しくなる = 読み下しが難しくなる要素は、スクリプトブロックなどの「読み手に解釈」を求めるものがあるかもしれません。 渡す順番が処理に影響する、スコープが影響するのは難しいでしょう。

  • スクリプトブロックでparamを使い、-ArgumentListパラメーターで渡す

数が少ないなら同期で十分です。 もし数が多く、非同期で書きたい場合はJobか.NETのTaskを使うといいでしょう。

gist.github.com

参考

PowerShell のStart-Jobに非同期数制御があれば、また話はべつなのですが.... 自宅の回線がネックになってるので、余り速度が速くなりませんでしたが900Mbpsとか超えている環境では顕著に変わるでしょう。

コマンドの長さと、Invoke-WebRequest がパイプラインからの入力を受け付けないのがネックで長いですね。

*1:PowerShellは配列の最後の要素に-1でアクセスできます