PowerShellから簡単にスクレイピングするためのツールを作った

**2025/05/06: 大幅なアップデートとともに[更新した記事](https://zenn.dev/kamome283/articles/introduce-angleparse-v04)をZennに作成しました。以下の内容は参考にしないでください。**

PowerShellからHTMLのパースやデータの処理をするためのツール、[AngleParse](https://github.com/kamome283/AngleParse)を作りました。PowerShellに組み込まれている`Invoke-WebRequest`(`iwr`)と組み合わせて使うといい感じにスクレイピングができます。

# 例
こんな感じで[はてブのトップページ](https://b.hatena.ne.jp/)をスクレイピングできます。

```powershell
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への変換なども簡潔に書くことができます。

```powershell
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がインストール済みという方は以下のコマンドでインストール、インポートができます。

```powershell
Install-Module AngleParse
Import-Module AngleParse
```
そうでない方は、`apt`なり`brew`なりで簡単にインストールできるので、クロスプラットフォームなPowerShell 7をぜひ試してみて欲しいです。こういうワンライナー+α程度の処理には本当に使いやすいですよ。[^1]

# 使い方
セレクタを第1引数に、パース対象を第2引数、もしくはパイプラインから流します。

```powershell
$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セレクタを使った場合は以下のようなイメージです。

```html
<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セレクタ
```powershell
'<div><span class="foo">text content here</span></div>' | Select-HtmlContent "div > span.foo"
# "text content here"
```
**文字列**でセレクタを書くとCSSセレクタになります。このセレクタは**DOMを入力とし、DOMを出力**します。CSSセレクタの書き方については[CSSセレクタのチートシート](https://webliker.info/css-selector-cheat-sheet/)などを読んでください。また、ブラウザの開発者ツールを使ってCSSセレクタを取得することもできます。
![スクリーンショット 2020-07-22 19.08.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/291545/a1b384d6-34d2-c8c7-5e8c-83a979c44c4e.png)

### 属性セレクタ
```powershell
'<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が来た場合に異なる動作を行います。

```html:foobar.html
<div class="foo bar">...</div>
```

```powershell
# 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要素
```
これだけの違いです。

### 正規表現セレクタ
```powershell
# 最後の日の部分はキャプチャしていない
'<span>2020/07/22</span>' | Select-HtmlContent ([regex]'(\d{4})/(\d{2})/\d{2}')
# "2020", "07"
```
**`Regex`型の値**は、正規表現セレクタとなります。このセレクタは**DOMもしくは文字列を入力とし、キャプチャした文字列を出力**します。DOMが入力された場合はDOMのTextContentを対象に正規表現のマッチングを行います。属性セレクタと同様に正規表現セレクタも文字列を出力するので、使用後はCSSセレクタや属性セレクタを使うことはできなくなります。

### スクリプトブロックセレクタ
```powershell
'<span>7</span>' | Select-HtmlContent { [int]$_ * 6; [int]$_ * 7 }
# 42, 49 ボックス化されたint型の2要素
```
**`ScriptBlock`型の値**は、スクリプトブロックセレクタとなります。**入力はDOM、文字列だけでなくどんなオブジェクトでも受け取ります。また、出力はどんな型の値もオブジェクトにボックス化して出力されます。** つまり、一度オブジェクトとして出力するとDOMや文字列の入力を必要とするCSSセレクタ、属性セレクタ、正規表現セレクタは使えません。
また、DOMが入力された場合はDOMのTextContentを対象に処理を行います。ここまでDOMについて詳しく説明していませんでしたが、DOMは内部的には[AngleSharp](https://anglesharp.github.io/)の`IElement`というインターフェイスを実装した値を持っています。IElementのメンバーを触りたいという場合には`'([AngleParse.Attr]::Element)`を使ってください。

```powershell
'<div> <span>a</span> <span>b</span> </div>' | 
Select-HtmlContent ([AngleParse.Attr]::Element), { $_.ChildElementCount }
# 2
```

`ForEach-Object`や`Where-Object`などを使うとき同様に、`$_`には入力されたオブジェクトが束縛されています。後はこの値を使ってなんなりと計算して出力してください。

## パイプラインセレクタ
```powershell
'<div><span>abc</span></div>' | Select-HtmlContent "div > span", ([regex]'a(bc)')
# "bc"
```
今まで紹介してきた4つのセレクタと次に紹介するハッシュテーブルセレクタ、これらをカンマをつけて**配列として並べる**とパイプラインセレクタとなります。名前の通り、前のセレクタの出力を次のセレクタの入力にして出力して、また次の...という感じで変換をしていきます。PowerShellに慣れている人は先の例が次のような動作をするとイメージしてください。[^4]

```powershell
'<div><span>abc</span></div>' |
Select-HtmlContent "div > span" |
% { $_ | Select-HtmlContent ([regex]'a(bc)') }
# "bc"
```
PowerShellになじみの無い方は、下手な言葉で説明するより実装を見てもらった方が早いかもしれません。

```csharp:PipelineSelector.cs
public IEnumerable<IResource> Select(IResource resource)
{
    // Aggregate => fold, SelectMany => flatMap
    return selectors.Aggregate(
        new[] {resource} as IEnumerable<IResource>,
        (resources, selector) => resources.SelectMany(selector.Select)
    );
}
```

## ハッシュテーブルセレクタ
```powershell
'<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なり文字列なりオブジェクトなりを、ハッシュテーブル内のそれぞれのセレクタに流して、その結果をそれぞれ対応する名前に束縛したオブジェクト[^5]を出力**します。ここまでのセレクタは入力されたデータをより細かい単位に分割することを目的としていましたが、ハッシュテーブルセレクタを用いるとデータを適切な形でまとめ上げることができます。

## 振り返りと配列の単一化について
ここまでの振り返りとして、冒頭の例をもう一度見てみましょう。

```powershell
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要素の場合、スカラー値に変換されるという[~~罠~~仕様](https://tech.guitarrapc.com/entry/2015/09/05/012733#%E6%A8%99%E6%BA%96%E5%87%BA%E5%8A%9B%E3%81%A7%E3%81%AE%E9%85%8D%E5%88%97%E5%9E%8B%E3%81%AE%E8%A6%81%E7%B4%A0%E3%81%8C%E5%8D%98%E4%BD%93%E3%81%AA%E5%A0%B4%E5%90%88%E3%81%AE%E8%87%AA%E5%8B%95%E7%9A%84%E3%81%AA%E5%9E%8B%E5%A4%89%E6%8F%9B)があります。

```powershell
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](https://anglesharp.github.io/)が全部やってくれています。楽チンですね。また、ライブラリ以外にも、C#のコードをインラインで書くことができるなど、PowerShellでは**強力な.netの力を簡単に使うことができます**。いろいろ嫌われがちなPowerShellですが、あくまで**汎用言語ではなくシェルである**ということを理解し、**向き不向きを考慮した上で使う**となかなかに便利な環境です。これをきっかけに、PowerShellが好きという同志が増えてくれれば嬉しいです。

[^1]: [このツール自身](https://github.com/kamome283/AngleParse)(C#製)を含めて、複雑なものはPowerShellで書きたくないですが...
[^4]: Select-HtmlContentコマンドの出力の際にDOMはTextContentの文字列に変換されるため、ツリーの情報がなくなります。この例は意図的にうまくいくものを選んでいるだけで、多くの場合は意図通りに動作しません。ただ、それはコマンドから出力する際の変換が問題なだけであり、動作のイメージとしては適切なものです。
[^5]: PSCustomObjectというプロパティを自由に追加したり削除したりできるクラスのオブジェクト