PowerShellからHTMLのパースやデータの処理をするためのツール、AngleParseを作りました。PowerShellに組み込まれているInvoke-WebRequest
(iwr
)と組み合わせて使うといい感じにスクレイピングができます。
例
こんな感じではてブのトップページをスクレイピングできます。
iwr "https://b.hatena.ne.jp/" | Select-HtmlContent "div.entrylist-contents", @{
User = "span.entrylist-contents-users > a > span", { [int]$_ }
Title = "h3.entrylist-contents-title > a"
Uri = "h3.entrylist-contents-title > a", ([AngleParse.Attr]::Href)
Time = "li.entrylist-contents-date", ([regex]'\S+ (\d{2}:\d{2})')
Tags = "a[rel=tag]"
} | select -first 2
# Title : スク水揚げ、シマに活気 南城市奥武島 - 琉球新報 - 沖縄の新...
# User : 507
# Uri : https://ryukyushimpo.jp/news/entry-1160519.html
# Tags : {沖縄, ネタ, 漁業, スク水…}
# Time : 06:13
# Title : 女の裸を生で見たかったんだ
# User : 419
# Uri : https://anond.hatelabo.jp/20200704002540
# Tags : {増田, 性, あとで読む, 人生…}
# Time : 06:24
5ちゃんかな?
このようにCSSセレクタや属性、正規表現にスクリプトブロックを組み合わせることで、宣言的にパースから後処理までを書くことができます。また、PowerShellの豊富なコマンドを組み合わせることで、フィルターやソート、jsonへの変換なども簡潔に書くことができます。
iwr "https://b.hatena.ne.jp/" | Select-HtmlContent "div.entrylist-contents", @{
User = "span.entrylist-contents-users > a > span", { [int]$_ }
Title = "h3.entrylist-contents-title > a"
Uri = "h3.entrylist-contents-title > a", ([AngleParse.Attr]::Href)
Time = "li.entrylist-contents-date", ([regex]'\S+ (\d{2}:\d{2})')
Tags = "a[rel=tag]"
} | ? { @($_.Tags).Count -ge 3 } | sort User -descending | ConvertTo-Json
導入方法
動作はMacOS上のPowerShell 7で確認しています。でも、他のOSのPowerShell 7, PowerShell Core 6でも動くはずです。
PowerShellがインストール済みという方は以下のコマンドでインストール、インポートができます。
Install-Module AngleParse
Import-Module AngleParse
そうでない方は、apt
なりbrew
なりで簡単にインストールできるので、クロスプラットフォームなPowerShell 7をぜひ試してみて欲しいです。こういうワンライナー+α程度の処理には本当に使いやすいですよ。1
使い方
セレクタを第1引数に、パース対象を第2引数、もしくはパイプラインから流します。
$selector = "div.entrylist-contents"
# Invoke-WebRequest(iwr)でWeb上のコンテンツを取得し、流す
Invoke-WebRequest "https://b.hatena.ne.jp/" | Select-HtmlContent $selector
# Get-Content(gc) -Rawでローカルのコンテンツを流す
Get-Content ./foo.html -Raw | Select-HtmlContent $selector
といっても、パース対象については説明することがほとんどありません。上の例みたいな使い方でOKです。なので、セレクタをどのように組み立てるかが使い方の要となります。
セレクタ
はじめに、全てのセレクタは1つの入力から0個以上の複数の出力をするという動作をします。例として、CSSセレクタを使った場合は以下のようなイメージです。
<div>
<span id="a" class="foo">A</span>
<span id="b" class="foo">B</span>
<span id="c" class="foo">C</span>
</div>
という1つのDOMの入力に対し、
"span.foo"というCSSセレクタを使えば
<span id="a" class="foo">A</span>
<span id="b" class="foo">B</span>
<span id="c" class="foo">C</span>
の3つのDOMを出力
"span#a"を使えば
<span id="a" class="foo">A</span>
の1つのDOMを出力
"span.bar"を使えば該当する要素がないので、
0個のDOMを出力
そんなセレクタには6種類あります。その内、基本となるものがCSSセレクタ、属性セレクタ、正規表現セレクタ、スクリプトブロックセレクタの4種類です。他に、セレクタを組み合わせてパイプラインのように処理をしていくパイプラインセレクタと、プロパティ名とセレクタの組み合わせからオブジェクトを作り出すハッシュテーブルセレクタがあります。
基本セレクタ
CSSセレクタ
'<div><span class="foo">text content here</span></div>' | Select-HtmlContent "div > span.foo"
# "text content here"
文字列でセレクタを書くとCSSセレクタになります。このセレクタはDOMを入力とし、DOMを出力します。CSSセレクタの書き方についてはCSSセレクタのチートシートなどを読んでください。また、ブラウザの開発者ツールを使ってCSSセレクタを取得することもできます。
属性セレクタ
'<a href="https://foo.go.jp">bar</a>' | Select-HtmlContent ([AngleParse.Attr]::Href)
# "https://foo.go.jp"
AngleParse.Attr
という列挙型の値を書くと属性セレクタになります。
属性セレクタには以下の11種類があります。
Element
InnerHtml
OuterHtml
TextContent
Id
Class
SplitClasses
Href
Src
Title
Name
この内Element
以外は、DOMを入力し文字列を出力します。Element
に関しては、ここではなくスクリプトブロックセレクタのところで紹介します。気をつけていただきいのが、一度DOMから文字列に変換すると、再びDOMに戻すことはできません。つまり、属性セレクタを使った後には、CSSセレクタや属性セレクタを使うことはできません。
それぞれの属性セレクタの動作についてですが、Class
とSplitClasses
以外は文字通りの動作だと思うので説明を省かせてもらいます。さてClass
とSplitClasses
ですが、複数のクラスを持つDOMが来た場合に異なる動作を行います。
<div class="foo bar">...</div>
# Class
gc ./foobar.html -raw | Select-HtmlContent ([AngleParse.Attr]::Class)
# "foo bar" 出力が1要素
# SplitClasses
gc ./foobar.html -raw | Select-HtmlContent ([AngleParse.Attr]::SplitClasses)
# "foo", "bar" 出力がスペースで分割され2要素
これだけの違いです。
正規表現セレクタ
# 最後の日の部分はキャプチャしていない
'<span>2020/07/22</span>' | Select-HtmlContent ([regex]'(\d{4})/(\d{2})/\d{2}')
# "2020", "07"
Regex
型の値は、正規表現セレクタとなります。このセレクタはDOMもしくは文字列を入力とし、キャプチャした文字列を出力します。DOMが入力された場合はDOMのTextContentを対象に正規表現のマッチングを行います。属性セレクタと同様に正規表現セレクタも文字列を出力するので、使用後はCSSセレクタや属性セレクタを使うことはできなくなります。
スクリプトブロックセレクタ
'<span>7</span>' | Select-HtmlContent { [int]$_ * 6; [int]$_ * 7 }
# 42, 49 ボックス化されたint型の2要素
ScriptBlock
型の値は、スクリプトブロックセレクタとなります。入力はDOM、文字列だけでなくどんなオブジェクトでも受け取ります。また、出力はどんな型の値もオブジェクトにボックス化して出力されます。 つまり、一度オブジェクトとして出力するとDOMや文字列の入力を必要とするCSSセレクタ、属性セレクタ、正規表現セレクタは使えません。
また、DOMが入力された場合はDOMのTextContentを対象に処理を行います。ここまでDOMについて詳しく説明していませんでしたが、DOMは内部的にはAngleSharpのIElement
というインターフェイスを実装した値を持っています。IElementのメンバーを触りたいという場合には'([AngleParse.Attr]::Element)
を使ってください。
'<div> <span>a</span> <span>b</span> </div>' |
Select-HtmlContent ([AngleParse.Attr]::Element), { $_.ChildElementCount }
# 2
ForEach-Object
やWhere-Object
などを使うとき同様に、$_
には入力されたオブジェクトが束縛されています。後はこの値を使ってなんなりと計算して出力してください。
パイプラインセレクタ
'<div><span>abc</span></div>' | Select-HtmlContent "div > span", ([regex]'a(bc)')
# "bc"
今まで紹介してきた4つのセレクタと次に紹介するハッシュテーブルセレクタ、これらをカンマをつけて配列として並べるとパイプラインセレクタとなります。名前の通り、前のセレクタの出力を次のセレクタの入力にして出力して、また次の...という感じで変換をしていきます。PowerShellに慣れている人は先の例が次のような動作をするとイメージしてください。2
'<div><span>abc</span></div>' |
Select-HtmlContent "div > span" |
% { $_ | Select-HtmlContent ([regex]'a(bc)') }
# "bc"
PowerShellになじみの無い方は、下手な言葉で説明するより実装を見てもらった方が早いかもしれません。
public IEnumerable<IResource> Select(IResource resource)
{
// Aggregate => fold, SelectMany => flatMap
return selectors.Aggregate(
new[] {resource} as IEnumerable<IResource>,
(resources, selector) => resources.SelectMany(selector.Select)
);
}
ハッシュテーブルセレクタ
'<div class="a">1a</div><div class="b">2b</div>' |
select-htmlcontent "> div",
@{ Class = ([AngleParse.Attr]::Class);
NumPlus1 = ([regex]'(\d)\w'), { [int]$_ + 1 } }
# Class NumPlus1
# ----- --------
# a 2
# b 3
ハッシュテーブルのキーに名前を、値にセレクタを指定するとハッシュテーブルセレクタとなります。入力されたDOMなり文字列なりオブジェクトなりを、ハッシュテーブル内のそれぞれのセレクタに流して、その結果をそれぞれ対応する名前に束縛したオブジェクト3を出力します。ここまでのセレクタは入力されたデータをより細かい単位に分割することを目的としていましたが、ハッシュテーブルセレクタを用いるとデータを適切な形でまとめ上げることができます。
振り返りと配列の単一化について
ここまでの振り返りとして、冒頭の例をもう一度見てみましょう。
iwr "https://b.hatena.ne.jp/" | Select-HtmlContent "div.entrylist-contents", @{
User = "span.entrylist-contents-users > a > span", { [int]$_ }
Title = "h3.entrylist-contents-title > a"
Uri = "h3.entrylist-contents-title > a", ([AngleParse.Attr]::Href)
Time = "li.entrylist-contents-date", ([regex]'\S+ (\d{2}:\d{2})')
Tags = "a[rel=tag]"
} | select -first 2
# Title : スク水揚げ、シマに活気 南城市奥武島 - 琉球新報 - 沖縄の新...
# User : 507
# Uri : https://ryukyushimpo.jp/news/entry-1160519.html
# Tags : {沖縄, ネタ, 漁業, スク水…}
# Time : 06:13
# Title : 女の裸を生で見たかったんだ
# User : 419
# Uri : https://anond.hatelabo.jp/20200704002540
# Tags : {増田, 性, あとで読む, 人生…}
# Time : 06:24
# 各セレクタを以下のように表す
# CSSセレクタ => CSS
# 属性セレクタ => Attr
# 正規表現セレクタ => Regex
# スクリプトブロックセレクタ => SB
# パイプラインセレクタ => P[Selector, ...]
# ハッシュテーブルセレクタ => @{Key = Selector; ...}
#
# その場合、この例のセレクタの構造は以下のように表せる
# P[CSS, @{
# User = P[CSS, SB];
# Title = CSS;
# Uri = P[CSS, Attr];
# Time = P[CSS, Regex];
# Tags = CSS;
# }]
ここまでの説明を概ね理解いただけたのではないでしょうか。
ただ一点、全てのセレクタは1つの入力から0個以上の複数の値を出力するという説明を思い出していただくと不思議な点がありますよね。**「Tagsは文字列の配列なのに、Titleとかただの文字列なのはなんでなん?1要素の文字列の配列やないとおかしくない?」**となりますよね。実は、PowerShellには標準出力から受け取った配列が1要素の場合、スカラー値に変換されるという罠仕様があります。
1, 2 | % { $_ * 1 }
# 1
# 2
$a = 1, 2 | % { $_ * 2 }; $a.GetType().Name
# Object[]
1, 2 | ? { $_ -eq 1 }
# 1
$b = 1, 2 | ? { $_ -eq 1 }; $b.GetType().Name
# Int32 <= !?!?
「配列が勝手にスカラー値になるとかヤバくね」と思いますよね。ですが、ワンライナー程度のことをする際には便利なことも多く、このツールでも同様の変換を真似した方が使い勝手がいいと判断しました。したがって、セレクターからの出力が1要素の場合は配列ではなくスカラー値に変換するという動作を行います。
後書き
.netの人はAngleParseという名前からティンときたかもしれませんが、面倒なパース部分はAngleSharpが全部やってくれています。楽チンですね。また、ライブラリ以外にも、C#のコードをインラインで書くことができるなど、PowerShellでは強力な.netの力を簡単に使うことができます。いろいろ嫌われがちなPowerShellですが、あくまで汎用言語ではなくシェルであるということを理解し、向き不向きを考慮した上で使うとなかなかに便利な環境です。これをきっかけに、PowerShellが好きという同志が増えてくれれば嬉しいです。