Zenn
Open8

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

ピン留めされたアイテム
sheeplasheepla

selectクレートとは

select クレートはRustでHTMLドキュメントをパースするためのクレート。他に同様のことができるクレートとして html5everscraperがある。
また、Rust用のWebクローラの実装として spider というものもある。

GitHubリポジトリ

クレートの名前は select だが、リポジトリの名前は select.rs となっている。examplesディレクトリにStackOverflowのページを解析するコードが載っている。

https://github.com/utkarshkukreti/select.rs
https://github.com/utkarshkukreti/select.rs/blob/master/examples/stackoverflow.rs

ドキュメント(Docs.rs)

https://docs.rs/select/latest/select/index.html

sheeplasheepla

プロジェクトの作成

プロジェクトを作成し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"
sheeplasheepla

selectクレートの構成要素

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

基本的な使い方

下記のような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の内容です。" }
sheeplasheepla

Documentの取り扱い

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

https://github.com/utkarshkukreti/select.rs/blob/master/src/document.rs#L31-L43

sheeplasheepla

Nodeの取り扱い

select::node::NodeはHTMLドキュメント内のノードを表す。

https://docs.rs/select/latest/select/node/struct.Node.html

DocumentからNodeを得る

Document::find(predicate)を呼び出すとFind<P> (PはPredicate)が帰ってくる。これは、Nodeのイテレータとして扱えるため、forで回すか、.map().filter()などのイテレータアダプタメソッドで消費することができる。単に最初のノードを取得するには .next()を直接呼び出せばよい。

https://github.com/utkarshkukreti/select.rs/blob/master/src/document.rs#L16-L24
https://github.com/utkarshkukreti/select.rs/blob/master/src/document.rs#L167-L180

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)>: 属性(名前と値のペア)のイテレータを取得する。
sheeplasheepla

Predicateの指定方法

select::predicate::PredicateはDocumentやNode内を探索してマッチさせるための述語を表すトレイトであり、言い換えればHTMLドキュメントに対するクエリ条件を表す。

https://docs.rs/select/latest/select/predicate/trait.Predicate.html

Predicateトレイトを実装した型でノードの種類を指定し、AndOr などの条件演算を使ってそれらを組み合わせることで複雑な条件を構築できる。

  • ノードの種類
    • 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
sheeplasheepla

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

ログインするとコメントできます