🦀 RustでHTMLドキュメントをパースする(selectクレート)


プロジェクトの作成
プロジェクトを作成しselect
クレートを追加する。また、エラーハンドリング用に eyre
クレートも追加しておく。
cargo init select_rs_practice
cargo add select
cargo add eyre
今回利用したバージョンは次の通り。
[package]
name = "select_rs_practice"
version = "0.1.0"
edition = "2021"
[dependencies]
eyre = "0.6.12"
select = "0.6.0"

selectクレートの構成要素
-
select::document::Document
: パースされたHTMLドキュメントを表す -
select::node::Node
: HTMLドキュメントを構成する各ノードを表す -
select::selection::Selection
: マッチした結果を表す -
select::predicate::*
: マッチさせるための条件(述語)を表す

基本的な使い方
下記のようなHTMLファイルが testdata/index.html
にあったとき、記事のタイトルとコンテンツを構造体 Article
のベクタに格納し返却するコードは次のようになる。
select_rs_practice
├── Cargo.lock
├── Cargo.toml
├── src
│ └── main.rs
├── target
└── testdata
└── index.html←ここに配置
<!--
testdata/index.html
-->
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>サンプルページ</title>
</head>
<body>
<div class="article">
<h2 class="title">記事タイトル1</h2>
<p class="content">これは記事1の内容です。</p>
</div>
<div class="article">
<h2 class="title">記事タイトル2</h2>
<p class="content">これは記事2の内容です。</p>
</div>
<div class="article">
<h2 class="title">記事タイトル3</h2>
<p class="content">これは記事3の内容です。</p>
</div>
</body>
</html>
use select::document::Document;
use select::predicate::{Class, Name, Predicate};
use std::fs::File;
use std::path::Path;
#[derive(Debug)]
struct Article {
pub title: String,
pub content: String,
}
fn main() -> eyre::Result<()> {
let articles = parse_html_file(Path::new("testdata/index.html"))?;
for article in articles {
println!("{:?}", article);
}
Ok(())
}
fn parse_html_file(path: &Path) -> eyre::Result<Vec<Article>> {
let file = File::open(path)
.map_err(|e| eyre::eyre!("failed to open HTML file {}: {}", path.to_string_lossy(), e))?;
let articles = parse_html(file)?;
Ok(articles)
}
fn parse_html<R: std::io::Read>(reader: R) -> eyre::Result<Vec<Article>> {
let document = Document::from_read(reader)
.map_err(|e| eyre::eyre!("failed to parse HTML document: {}", e))?;
let mut articles = Vec::new();
for node in document.find(Name("div").and(Class("article"))) {
let title = node
.find(Name("h2").and(Class("title")))
.next()
.map(|n| n.text())
.unwrap_or_default();
let content = node
.find(Name("p").and(Class("content")))
.next()
.map(|n| n.text())
.unwrap_or_default();
articles.push(Article { title, content });
}
Ok(articles)
}
実行すると出力は次のようになる。
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.03s
Running `target/debug/select_rs_practice`
Article { title: "記事タイトル1", content: "これは記事1の内容です。" }
Article { title: "記事タイトル2", content: "これは記事2の内容です。" }
Article { title: "記事タイトル3", content: "これは記事3の内容です。" }

Documentの取り扱い
select::document::Document
はパースされたHTMLドキュメントを表す。Document
は from_read
メソッドを実装しておりstd::io::Read
トレイトを実装した値(例えば、ファイルstd::io::File
や標準入力 std::io::Stdin
)を引数に渡すことができる。
また、Document
はFrom<&str>
も実装しているため Document::from(r#"<p>Hello</p>"#)
のように文字列を渡すこともできる。

Nodeの取り扱い
select::node::Node
はHTMLドキュメント内のノードを表す。
DocumentからNodeを得る
Document::find(predicate)
を呼び出すとFind<P>
(P
はPredicate)が帰ってくる。これは、Node
のイテレータとして扱えるため、for
で回すか、.map()
や .filter()
などのイテレータアダプタメソッドで消費することができる。単に最初のノードを取得するには .next()
を直接呼び出せばよい。
Nodeから別のノードを探索する
使用頻度の高そうなメソッドを抜粋すると次のようになる。
-
pub fn find<P: Predicate>(&self, predicate: P) -> Find<'a, P>
: 子ノードを探索する -
pub fn parent(&self) -> Option<Node<'a>>
: 親のノードを取得する -
pub fn prev(&self) -> Option<Node<'a>>
: 前のノードを取得する -
pub fn next(&self) -> Option<Node<'a>>
: 次のノードを取得する -
pub fn first_child(&self) -> Option<Node<'a>>
: 最初の子ノードを取得する -
pub fn last_child(&self) -> Option<Node<'a>>
: 最後の子ノードを取得する
Nodeから値を取り出す
こちらも同様に抜粋すると次のようになる。
-
pub fn name(&self) -> Option<&'a str>
: タグの名前を取り出す -
pub fn text(&self) -> String
: テキストを取り出す(String
) -
pub fn as_text(&self) -> Option<&'a str>
: テキストを取り出す(Option<&str>
) -
pub fn html(&self) -> String
: HTMLを取り出す -
pub fn inner_html(&self) -> String
: 内部のHTMLを取り出す -
pub fn attr(&self, name: &str) -> Option<&'a str>
: 特定の名前の属性の値を取り出す -
pub fn attrs(&self) -> impl Iterator<Item = (&'a str, &'a str)>
: 属性(名前と値のペア)のイテレータを取得する。

Predicateの指定方法
select::predicate::Predicate
はDocumentやNode内を探索してマッチさせるための述語を表すトレイトであり、言い換えればHTMLドキュメントに対するクエリ条件を表す。
Predicateトレイトを実装した型でノードの種類を指定し、And
や Or
などの条件演算を使ってそれらを組み合わせることで複雑な条件を構築できる。
-
ノードの種類
-
Any
: 任意のノードに一致する -
Class
: 特定のクラスを含む要素ノードに一致する -
Comment
: 任意のコメントノードに一致する -
Element
: 任意の要素ノードに一致する -
Name
: 特定の名前を持つ要素ノードに一致する -
Attr
: 特定の属性を持つ要素ノードに一致する -
Text
: 任意のテキストノードに一致する
-
-
条件演算
-
Not(T)
: 条件Tが一致しない -
Or(A, B)
: 条件AまたはB のいずれかが対象ノードに一致する -
And(A, B)
: 条件AとBの両方が対象ノードに一致する -
Child(A, B)
: 条件Aに一致するノードの直系の子ノードがBに一致する -
Descendant(A, B)
: 条件Aに一致するノードに対するすべての子孫ノードのうちBに一致
-
例えば、次のような条件でドキュメントから値を取り出すには次のようになる。
条件: 次のいずれかを満たすノード
- 名前が
p
またはspan
であるがクラスの値がfooter
であるものは除外する- IDが
list
であるタグの子孫に存在して名前がli
であるもの
use select::document::Document;
use select::predicate::{And, Attr, Descendant, Name, Not, Or, Text};
fn main() {
let html = r#"
<html>
<body>
<div id="main">
<p class="content">Example text</p>
<p class="content">Another example</p>
</div>
<div id="secondary">
<span class="highlight">Highlighted text</span>
<span>Non-highlighted text</span>
</div>
<div id="list">
<div>
<div>
<ul>
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</ul>
</div>
</div>
</div>
<p class="footer">Footer text</p>
</body>
</html>
"#;
let document = Document::from(html);
let predicate = Or(
And(Or(Name("p"), Name("span")), Not(Attr("class", "footer"))),
Descendant(Attr("id", "list"), Name("li")),
);
for node in document.find(predicate) {
println!("{}", node.text());
}
}
実行結果は次のようになる。
Example text
Another example
Highlighted text
Non-highlighted text
Item 1
Item 2
Item 3

CSSセレクタをパースしてPredicateを構築すればJavaScriptのdocument.querySelectorAll()
のように文字列からクエリを作れそうだけど...どうやればいいのか