使いやすさを重視したHTMLスクレイピングライブラリを作った

TL:DR

レポジトリ https://github.com/tanakh/easy-scraper

ドキュメント

背景

このところ訳あってRustでHTMLからデータを抽出するコードを書いていたのですが、 既存のスクレイピングライブラリが(個人的には)どれもいまいち使いやすくないなあと思っていました。

HTMLから望みのデータを取り出すのはいろいろやり方があるかと思いますが、 ツリーを自力でトラバースするのはさすがにあまりにも面倒です。 近頃人気のライブラリを見てみますと、CSSセレクターで目的のノードを選択して、 その周辺のノードをたどるコードを書いて、 欲しい情報を取り出すという感じのものが多いようです。

RustにもHTMLのDOMツリーをCSSセレクターで検索して見つかったノードをイテレーターで返してくれたりする、 scraperというライブラリがあります。

例えば、<li>要素を検索するようなコードだと次のようになります。

use scraper::{Html, Selector};

let html = r#"
    <ul>
        <li>Foo</li>
        <li>Bar</li>
        <li>Baz</li>
    </ul>
"#;

let fragment = Html::parse_fragment(html);
let selector = Selector::parse("li").unwrap();

for element in fragment.select(&selector) {
    assert_eq!("li", element.value().name());
}

一見わかりやすいコードだし、どこに不満があるんだ?という方がいらっしゃるかもしれません。 完成したコードだけ見ると簡単そうに見えますが、これが書いてみようとなると案外面倒で、 まずイテレーターが返してきたノードからどうやって情報を引き出してくるのかというのを調べないといけません。 実際にはドキュメントで ElementRef::select() の返り値になっている Selector 型を調べて、 これがイテレーターなんだから、要素として何を返すか Trait Implementationのところをみて調べりゃいいんだな、となって、 それでその Iterator インスタンスの実装の部分を見てItem = ElementRef なんだなというのを見つけて、 じゃあ次に ElementRef からタグの情報を取り出すには何を呼べばいいのかな、となるので、 また ElementRef のリファレンスに戻ってメソッドのリストとにらめっこして、 うーん ElementRef::value() にそれっぽい説明が書いてあるからこれを呼べばいいのかな? ElementRef::value()Element を返すから、ここからタグ名を取り出すには・・・、 と、上のコードが出てくるにはこういう過程を経ることになるわけです。

それで今度は分かりきってるタグ名じゃなくて、<li>ノードの中のテキストを取り出すにはどうすればいいのかな、 となると、また ElementRef のドキュメントを見返して、ふむふむ ElementRef::text() がそれっぽいな、 じゃあとりあえず使ってみるか、うん正しそう。などというステップを踏むことになるわけです。

しかしながら、こういった<li> タグからデータのリストを取り出すとか、<a href="...">...</a> からURLのリンクを取り出すとか、 そういった1つのDOMノードからデータを引っ張ってくるだけのタスクは、正直そこまで面倒なわけでもありません。 ピンポイントでヒットするCSSセレクターを見つけて、それでDOMを検索して、 そこからデータを抜き出してくるコードをリファレンスを見ながら書けば良いだけなので。

面倒になってくるのは、複数のノードにまたがるひとまとまりの情報を取り出したくなった時です。 と言いますか、Webページからデータを取得したいケースというのは、テーブルのようなローとカラム、 つまり何らかのひとまとまりの構造体のデータのリストを取得したいことのほうが、むしろ普通は多いわけです。

例として、ここははてなブログなので、はてなブックマークのホットエントリを取得するプログラムを考えてみます。

はてなブックマーク - 人気エントリー - テクノロジー

まずやることは、このページを眺めて、どういうデータが取り出せるかな、と考えることです。 ざっと見た感じ、

  • ブックマーク数
  • ページのタイトル
  • ページのURL
  • 日付

あたりが取り出せそうです。

次にHTMLのソースを眺めて、1つのエントリに相当しそうな部分を見つけてきます。

...
<div class="entrylist-contents-main">
    <h3 class="entrylist-contents-title">
        <a href="https://internet.watch.impress.co.jp/docs/yajiuma/1234496.html"
            title="5年にわたって放置の末に……はてブ発の「本気の」RSSリーダー、正式に開発中止を表明【やじうまWatch】 - INTERNET Watch" target="_blank"
            rel="noopener" class="js-keyboard-openable"
            data-gtm-click-label="entry-info-title">5年にわたって放置の末に……はてブ発の「本気の」RSSリーダー、...</a>
    </h3>
    <span class="entrylist-contents-users">
        <a href="/entry/s/internet.watch.impress.co.jp/docs/yajiuma/1234496.html" title="すべてのブックマークを見る"
            class="js-keyboard-entry-page-openable" data-gtm-click-label="entry-info-users"><span>391</span> users</a>
    </span>
    <div class="entrylist-contents-body">
        <a href="/entry/s/internet.watch.impress.co.jp/docs/yajiuma/1234496.html" title="すべてのブックマークを見る">
            <p class="entrylist-contents-description" data-gtm-click-label="entry-info-description-href">
            </p>
            <p class="entrylist-contents-thumb">
                <span
                    style="background-image:url('https://cdn-ak-scissors.b.st-hatena.com/image/square/ac87668f76a1e166e3d223c0717bba427111632c/height=280;version=1;width=400/https%3A%2F%2Finternet.watch.impress.co.jp%2Fimg%2Fiw%2Flist%2F1234%2F496%2Fyajiuma-watch_4.png');"
                    data-gtm-click-label="entry-info-thumbnail"></span>
            </p>
        </a>
    </div>
    <div class="entrylist-contents-detail">
        <ul class="entrylist-contents-meta">
            <li class="entrylist-contents-category">
                <a href="/hotentry/it" data-gtm-click-label="entry-info-category">テクノロジー</a>
            </li>
            <li class="entrylist-contents-date">2020/02/12 06:05</li>
        </ul>
    ....
</div>
...

ここからデータを取り出すコードを書いていきます。

まず適当にデータ構造の定義とHTMLをGETしてくるコードを書きます。

#[derive(Debug)]
struct HotEntry {
    url: String,
    title: String,
    users: String,
    date: String,
}

fn hatebu_hotentry() -> Result<Vec<HotEntry>> {
    // はてなブックマークはUser Agent設定しないとちゃんとしたコンテンツが取れなかったので指定
    let client = reqwest::blocking::Client::builder()
        .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100")
        .build()?;
    // 目的のページをStringとして取得
    let doc = client
        .get("https://b.hatena.ne.jp/hotentry/it")
        .send()?
        .text()?;
    // HTMLをパーズ
    let doc = Html::parse_document(&doc);

    ....
}

次に、目的のノードを選択できるCSSセレクターを考えます。

1つのエントリーは<div class="entrylist-contents-main"> に囲まれた部分に入ってそうなので、 これをCSSセレクターで選択することにします。

for node in doc.select(&Selector::parse("div.entrylist-contents-main").unwrap()) {
    ...
}

このノードに対してHotEntry型を返す関数を書きます。 もしかしたらCSSセレクターで間違って選択されたノードがあるかもしれないので、 そこも考慮して Option<HotEntry> を返す関数にしておきます。

let f = || -> Option<HotEntry> {
    ...
};

まず最初の子ノードにURLとタイトルがあるので、それを取り出します。

分かりやすくノイズを取り除くと、最初の子ノードの、最初の子ノードのhreftitleのところにあることがわかります。 <a>タグに囲まれたテキストにもタイトルが入っていますが、こちらは文字数が短いので、 titleのところにあるものを抜き出したほうが良さそうです。

<div class="entrylist-contents-main">
    <h3 class="...">
        <a href="{{url}}" title="{{title}}">...</a>
    </h3>
    ...

これをscraperAPIで書いていきます。

// 子ノードのイテレーター
let it = node.children();
// 次のelementまでスキップ(空白文字のテキストノードが混じっているので)
let mut it = it.skip_while(|r| !r.value().is_element());

// 最初の子ノード(<h3>)を取得
let node = it.next()?;
// その最初のelementノードを取り出す
let node = node
    .children()
    .skip_while(|r| !r.value().is_element())
    .next()?
    .value()
    .as_element()?;
// タイトルとURL取得
let title = node.attr("title")?;
let url = node.attr("href")?;

多分Rustの型合わせのために必要以上にややこしくなっているのでぱっと見よくわからないと思いますが、 書いてる方ももっとよく分からないので大丈夫です。

まず、HTMLには普通空白文字のテキストノードが頻出するので(今回のにもしっかり含まれているので)、 これをきちんと飛ばしてやらないといけません。 上のコードではイテレーターでnext()を呼ぶ前にいちいちskip_while()で読み飛ばしています。

children()が返すものはego-treeというscraperが内部的に使っているツリーライブラリのノードになっていて、 そこからではscraper固有のDOM要素にアクセスするAPIが使えないので、 value()でノードの値を取り直して、Elementノードとして取り出して、 みたいな正直よく分からない手続きを踏んでようやく欲しい値が取り出せます。

次にユーザー数を取り出します。同様に、次の子要素の子要素の、今度は文字列要素を取り出します。

let mut it = it.skip_while(|r| !r.value().is_element());
let node = it.next()?;
let users = ElementRef::wrap(
    node.children()
        .skip_while(|r| !r.value().is_element())
        .next()?,
)?
.text()
.collect::<Vec<&str>>()
.concat();

子要素の文字列要素を列挙するための型合わせが非常に面倒で、 しばらくドキュメントとにらめっこすることになります。

最後の日付ですが、

<div class="entrylist-contents-body">
    ...
</div>
<div class="entrylist-contents-detail">
    <ul class="...">
        <li class="...">...</li>
        <li class="...">{{date}}</li>
    </ul>
    ...
</div>

次の次の子ノードの、子ノードの、二つ目の子ノードに入っています。

// 一個読み飛ばし(サムネイルとかが入ってるノード)
let mut it = it.skip_while(|r| !r.value().is_element());
let _ = it.next()?;
// 日付が含まれるノード
let mut it = it.skip_while(|r| !r.value().is_element());
let node = it.next()?;
let node = ElementRef::wrap(
    node.children()
        .skip_while(|r| !r.value().is_element())
        .next()?,
)?;
let node = ElementRef::wrap(
    node.children()
        .skip_while(|r| !r.value().is_element())
        .skip(1)
        .skip_while(|r| !r.value().is_element())
        .next()?,
)?;
let date = node.inner_html();

これでほしいデータがそろったので、ようやく完成です。 全体のコードは次のようになります。

fn hatebu_hotentry() -> Result<Vec<HotEntry>> {
    let client = reqwest::blocking::Client::builder()
        .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100")
        .build()?;
    let doc = client
        .get("https://b.hatena.ne.jp/hotentry/it")
        .send()?
        .text()?;

    let doc = Html::parse_document(&doc);

    let mut entries = vec![];

    for node in doc.select(&Selector::parse("div.entrylist-contents-main").unwrap()) {
        let entry = (|| -> Option<HotEntry> {
            let it = node.children();
            let mut it = it.skip_while(|r| !r.value().is_element());

            let node = it.next()?;
            let node = node
                .children()
                .skip_while(|r| !r.value().is_element())
                .next()?
                .value()
                .as_element()?;
            let title = node.attr("title")?;
            let url = node.attr("href")?;

            let mut it = it.skip_while(|r| !r.value().is_element());
            let node = it.next()?;
            let users = ElementRef::wrap(
                node.children()
                    .skip_while(|r| !r.value().is_element())
                    .next()?,
            )?
            .text()
            .collect::<Vec<&str>>()
            .concat();

            let mut it = it.skip_while(|r| !r.value().is_element());
            let _ = it.next()?;
            let mut it = it.skip_while(|r| !r.value().is_element());
            let node = it.next()?;
            let node = ElementRef::wrap(
                node.children()
                    .skip_while(|r| !r.value().is_element())
                    .next()?,
            )?;
            let node = ElementRef::wrap(
                node.children()
                    .skip_while(|r| !r.value().is_element())
                    .skip(1)
                    .skip_while(|r| !r.value().is_element())
                    .next()?,
            )?;
            let date = node.inner_html();

            Some(HotEntry {
                url: url.to_owned(),
                title: title.to_owned(),
                users,
                date,
            })
        })();

        if let Some(entry) = entry {
            entries.push(entry);
        }
    }

    Ok(entries)
}

実行してみます。

$ cargo run
...
[
    HotEntry {
        url: "https://internet.watch.impress.co.jp/docs/yajiuma/1234496.html",
        title: "5年にわたって放置の末に……はてブ発の「本気の」RSSリーダー、正式に開発中止を表明【やじうまWatch】 - INTERNET Watch",
        users: "407 users",
        date: "2020/02/12 06:05",
    },
    HotEntry {
        url: "https://anond.hatelabo.jp/20200211125801",
        title: "どうしてもっと個人パソコンでできることって増えなかったんだろうな",
        users: "214 users",
        date: "2020/02/12 11:00",
    },
    HotEntry {
        url: "https://www.pieceofcake.co.jp/n/naefe7919ceeb",
        title: "決死の覚悟でのぞんだnoteのドメイン移行。検索流入急落からの復活劇|株式会社ピースオブケイク",
        users: "174 users",
        date: "2020/02/12 11:27",
    },
    HotEntry {
        url: "https://nikkan-spa.jp/1639354",
        title: "「AVモザイク除去」できるAIに業界が震撼、人気AV女優も被害に… | 日刊SPA!",
        users: "489 users",
        date: "2020/02/11 18:16",
    },
    ...

欲しい結果が得られました。

もしかしたらこのライブラリ的にはノードをいじくってデータを集めるのではなくて、 CSSセレクターで選択したノードをさらにCSSセレクターで検索して欲しい要素を抽出するのが正解なのかもしれませんが、 そういうのでは兄弟ノード間の位置関係を固定したりとかそういうのが非常にやりづらかったりしますし、 また単一ノードからのデータの抽出をAPIを調べながらやるというのは結局避けられないと思います。

問題点

というわけで、CSSセレクターベースのスクレイピングライブラリで スクレイピングをやっていたのですが、やっぱりしんどいのです。

しんどい点を列挙してみると、

  • 選択したノードからデータを取り出す方法を調べるのが面倒。ライブラリごとにノードの表現が違ったりして、ノードの表現やコンテンツにアクセスするためのAPIがしばしば複雑になりがち。ツリーライブラリを別に使っていたりすると、そちらの生データがよこされたり、別ライブラリ間でドキュメントを行き来したり、しばしばかなり面倒
  • 複数のノードから1まとまりのデータを取り出すのが面倒。子ノード、兄弟ノードのトラバース、ごみノードの読み飛ばし、等々。想定するノードの相対的な位置関係がトラバースするコードと対応することになってしまうので、元のデータに揺れがあったり、ちゃんとバリデーションしたりするのも地味に面倒
  • コードを見てもどういう構造のHTMLからデータを取り出しているのかがよくわからない。これは可読性や、後々のコードの修正、あるいはページのHTML構造がちょっと変わって、スクレイピングのコードを微修正したいようなときにつらい

このような感じになるでしょうか。ああ、これらをすべて解消した、使いやすいライブラリがあったらいいのになあ・・・。

コンセプト・アプローチ

という発明の母から、新しいライブラリを書いてみました。

https://github.com/tanakh/easy-scraper

ドキュメント

コンセプトとしては、とにかく、既存のライブラリで使いづらいと思ったことをすべて回避して、とにかく使いやすくすることに重点を置いています。ライブラリが提供するのは(今のところ)Patternという型一つとnewmatchesのメソッド2つしかありません。

ツリーマッチングベース

なにが良くないのかなあと考えておりますと、 構造をマッチさせたいのにセレクターを使っているのが良くないんじゃないのかという考えになったので、 CSSセレクターのような方法でノードをマッチさせるのではなく、ツリー自体でマッチングさせることにしました。 つまり、クエリはHTMLそのものです。

例えば、こんな感じでパターンを書けます。

<ul>
    <li>{{foo}}</li>
</ul>

これは<ul> の子要素にある<li>の中のテキストにマッチするようなパターンを表します。パターン中には{{}}で囲んだプレースホルダを書けます。 マッチ結果は連想配列で返されて、この名前がキーになります。

let pat = Pattern::new(r#"
<ul>
    <li>{{foo}}</li>
</ul>
"#)?;

パターンを文字列として渡して、Patternオブジェクトを作ります。このパターンに対してmatchesというメソッドを読んでやればマッチ結果が得られます。

let ms = pat.matches(r#"
<!DOCTYPE html>
<html lang="en">
    <body>
        <ul>
            <li>1</li>
            <li>2</li>
            <li>3</li>
        </ul>
    </body>
</html>
"#);

こういうドキュメントをマッチさせたとします。

[
    { "foo": "1" },
    { "foo": "2" },
    { "foo": "3" },
]

すると、このような3通りの結果が返ります。 マッチの結果は、連想配列の配列(Vec<BTreeMap<String, String>>)で返されます。 全マッチ結果と、各マッチにおけるプレースホルダに対する文字列の連想配列です。 マッチングのルールは、 (基本的には)「パターンと一致する任意のドキュメントツリーの部分集合」にマッチするようにしています。

<ul>
    <li>1</li>
</ul>

も、

<ul>
    <li>2</li>
</ul>

も、

<ul>
    <li>3</li>
</ul>

も、ドキュメントの部分集合になっているので、これらがマッチするということです。

親子ノード

パターンでの親子関係は、元のドキュメントで直接の親子関係になくても部分集合になるので、 先ほどのパターンは、

<ul>
    <div>
        <li>1</li>
        <li>2</li>
    </div>
    <div>
        <div>
            <li>3</li>
        </div>
    </div>
</ul>

など深く入り組んでいても、同様にマッチします。

兄弟ノード

パターンはドキュメントの部分集合にマッチすると書きましたが、 本当に無条件でドキュメントのすべての部分集合をマッチさせると、 探索が爆発して遅くなってしまったりすることがあるのに加えて、 直感に反するケースまでヒットしてしまって、 マッチ結果にノイズが増えてしまうという問題点も出てきます。

例えば、

<div>
    <div>{{name}}</div>
    <div>{{age}}</div>
</div>

というパターンがあったとして、

<div>
    <div>
        <div>Taro</div>
        <div>10</div>
    </div>
    <div>
        <div>Jiro</div>
        <div>20</div>
    </div>
</div>

こういうドキュメントにマッチさせたとします。すると、

<div>
    <div>Taro</div>
    <div>10</div>
</div>

こういう部分木にはもちろんヒットするのですが、

<div>

        <div>Taro</div>

        <div>20</div>

</div>

実はこういうのも部分集合になってしまうので、(おそらく)予期しないマッチが多発してしまいます。 これを排除するためには、こういう構造にヒットしないようにパターンを何とかするしかなくて、結構悩ましい問題になってしまいます。

兄弟ノードがすぐ後ろにあることを指定するような記法を導入したらいいのかな?などと考えたりもしましたが、 特別な記法をあまり導入するのは学習の負担になるし、 そもそも多くのケースでは直接隣り合ってる兄弟ノードを指定したいはずなのでは? という仮定のもとに、マッチングの方に制限をつけることにしました。

兄弟ノードのマッチングにおいては、

  • (特別な指定がなければ)隣り合う必要がある
  • 共通の親の、直接の子である必要がある

という制約を付けることにしました。

この結果、いろいろ試してみた感じでは、書いたときの意図に対して、 おおむね過不足のないマッチができるようになりました。 例えば、このようなドキュメントに対して、

<body>
    <ul>
        <li>1</li>
        <li>2</li>
        <li>3</li>
    </ul>
</body>

このパターンだと、

<ul>
    <li>{{foo}}</li>
    <li>{{bar}}</li>
</ul>

得られるマッチは

[
    { "foo": "1", "bar": "2" },
    { "foo": "2", "bar": "3" },
]

の2通りになるということです。

{ "foo": "1", "bar": "3" },

は、間にノードが挟まるので、含まれません。

スキップパターン

しかし、やはり兄弟ノード間で間に任意のノードを挟んでもマッチするようなものを作りたいケースもあったので、 こちらを指定できる記法を導入することにしました。

<ul>
    <li>{{foo}}</li>
    ...
    <li>{{bar}}</li>
</ul>

... というパターンは、0個以上の任意のノードにマッチします。なので、このパターンに対するマッチは

[
    { "foo": "1", "bar": "2" },
    { "foo": "1", "bar": "3" },
    { "foo": "2", "bar": "3" },
]

の3通りになります。

なお、このスキップパターンを挟んでいても、パターン上の兄弟ノードが同一の親の直接の子である必要があるという制約は変わりません。

attribute パターン

タグのattributeにもパターンを書けます。

<div class="hoge">
    {{foo}}
</div>

この場合だと、hogeclassに含む<div>ノードにマッチします。 CSSセレクターのクラス指定などと同じように、 attributeのスペース区切り単語が部分集合として含まれているものにマッチします。 なので、このパターンは、

<div class="hoge moge">
    Hello
</div>

こういうドキュメントにマッチします。

attributeのところにもプレースホルダーをかけます。

<a href="{{url}}">{{title}}</a>

これでリンクのURLとタイトルを抽出するパターンになります。

<a href="https://www.google.com">Google</a>
<a href="https://www.yahoo.com">Yahoo</a>

これに対するマッチが、

[
    { "url": "https://www.google.com", "title": "Google" },
    { "url": "https://www.yahoo.com", "title": "Yahoo" },
]

これになります。

部分テキストパターン

テキストノードには文字列とプレースホルダを自由に混在させられます。

<ul>
    <li>A: {{a}}, B: {{b}}</li>
</ul>

このパターンは

<ul>
    <li>A: 1, B: 2</li>
    <li>A: 3, B: 4</li>
    <li>A: 5, B: 6</li>
</ul>

これに対して、

[
    { "a": "1",  "b": "2" },
    { "a": "3",  "b": "4" },
    { "a": "5",  "b": "6" },
]

こういうマッチを返します。

部分木全マッチパターン

あまりシンタックスを増やしたくないというのが設計思想にはあるんですが、 部分木全体にマッチさせるパターンはあったほうが良さそうなのでつけてみました。 {{<name>:*}} のように書きます。

<div>{{body:*}}</div>

このパターンが、

<body>
    Hello
    <span>Rust</span>
    World
</body>

このドキュメントに対して、

[
    { "body": "Hello<span>Rust</span>World" }
]

こういうマッチを返します。

空白の扱い

空白は基本的に無視します。 空白しかないテキストノードは存在しないことになります。 コメントもなかったことになります。 この辺はこれでいいのか正直よく分かってなかったりしますが、 今のところはあんまり問題なさそうな雰囲気です。

例:はてなブックマーク

さて、このライブラリを用いて、先ほどのscraperを用いて作ったはてなブックマークのホットエントリ抽出プログラムを作ってみます。 エントリのHTML片を再掲します。

...
<div class="entrylist-contents-main">
    <h3 class="entrylist-contents-title">
        <a href="https://internet.watch.impress.co.jp/docs/yajiuma/1234496.html"
            title="5年にわたって放置の末に……はてブ発の「本気の」RSSリーダー、正式に開発中止を表明【やじうまWatch】 - INTERNET Watch" target="_blank"
            rel="noopener" class="js-keyboard-openable"
            data-gtm-click-label="entry-info-title">5年にわたって放置の末に……はてブ発の「本気の」RSSリーダー、...</a>
    </h3>
    <span class="entrylist-contents-users">
        <a href="/entry/s/internet.watch.impress.co.jp/docs/yajiuma/1234496.html" title="すべてのブックマークを見る"
            class="js-keyboard-entry-page-openable" data-gtm-click-label="entry-info-users"><span>391</span> users</a>
    </span>
    <div class="entrylist-contents-body">
        <a href="/entry/s/internet.watch.impress.co.jp/docs/yajiuma/1234496.html" title="すべてのブックマークを見る">
            <p class="entrylist-contents-description" data-gtm-click-label="entry-info-description-href">
            </p>
            <p class="entrylist-contents-thumb">
                <span
                    style="background-image:url('https://cdn-ak-scissors.b.st-hatena.com/image/square/ac87668f76a1e166e3d223c0717bba427111632c/height=280;version=1;width=400/https%3A%2F%2Finternet.watch.impress.co.jp%2Fimg%2Fiw%2Flist%2F1234%2F496%2Fyajiuma-watch_4.png');"
                    data-gtm-click-label="entry-info-thumbnail"></span>
            </p>
        </a>
    </div>
    <div class="entrylist-contents-detail">
        <ul class="entrylist-contents-meta">
            <li class="entrylist-contents-category">
                <a href="/hotentry/it" data-gtm-click-label="entry-info-category">テクノロジー</a>
            </li>
            <li class="entrylist-contents-date">2020/02/12 06:05</li>
        </ul>
    ....
</div>
...

ここから欲しいデータを取り出すには、まずは適当にそれっぽいところをプレースホルダに変えていきます。

<div class="entrylist-contents-main">
    <h3 class="entrylist-contents-title">
        <a href="{{url}}"
            title="{{title}}" target="_blank"
            rel="noopener" class="js-keyboard-openable"
            data-gtm-click-label="entry-info-title">5年にわたって放置の末に……はてブ発の「本気の」RSSリーダー、...</a>
    </h3>
    <span class="entrylist-contents-users">
        <a href="/entry/s/internet.watch.impress.co.jp/docs/yajiuma/1234496.html" title="すべてのブックマークを見る"
            class="js-keyboard-entry-page-openable" data-gtm-click-label="entry-info-users"><span>{{users}}</span> users</a>
    </span>
    <div class="entrylist-contents-body">
        <a href="/entry/s/internet.watch.impress.co.jp/docs/yajiuma/1234496.html" title="すべてのブックマークを見る">
            <p class="entrylist-contents-description" data-gtm-click-label="entry-info-description-href">
            </p>
            <p class="entrylist-contents-thumb">
                <span
                    style="background-image:url('https://cdn-ak-scissors.b.st-hatena.com/image/square/ac87668f76a1e166e3d223c0717bba427111632c/height=280;version=1;width=400/https%3A%2F%2Finternet.watch.impress.co.jp%2Fimg%2Fiw%2Flist%2F1234%2F496%2Fyajiuma-watch_4.png');"
                    data-gtm-click-label="entry-info-thumbnail"></span>
            </p>
        </a>
    </div>
    <div class="entrylist-contents-detail">
        <ul class="entrylist-contents-meta">
            <li class="entrylist-contents-category">
                <a href="/hotentry/it" data-gtm-click-label="entry-info-category">テクノロジー</a>
            </li>
            <li class="entrylist-contents-date">{{date}}</li>
        </ul>
    ....
</div>

ここから、一意性を失わなさそうな範囲で、冗長な部分を削っていきます。 削れば削るほどHTMLの構造の揺れや変更には強くなると思います。

<div class="entrylist-contents-main">
    <h3 class="entrylist-contents-title">
        <a href="{{url}}" title="{{title}}"></a>
    </h3>
    <span class="entrylist-contents-users">
        <a><span>{{users}}</span> users</a>
    </span>
    ...
    <div class="entrylist-contents-detail">
        <ul class="entrylist-contents-meta">
            <li class="entrylist-contents-date">{{date}}</li>
        </ul>
    </div>
</div>

こうなりました。これにHTMLクライアントでGETしたHTML文字列をぶち込みます。

fn hatebu_hotentry() -> Result<Vec<HotEntry>> {
    let client = reqwest::blocking::Client::builder()
        .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100")
        .build()?;
    let doc = client
        .get("https://b.hatena.ne.jp/hotentry/it")
        .send()?
        .text()?;

    // パターン構築!
    let pat = Pattern::new(
        r#"
<div class="entrylist-contents-main">
    <h3 class="entrylist-contents-title">
        <a href="{{url}}" title="{{title}}"></a>
    </h3>
    <span class="entrylist-contents-users">
        <a><span>{{users}}</span> users</a>
    </span>
    ...
    <div class="entrylist-contents-detail">
        <ul class="entrylist-contents-meta">
            <li class="entrylist-contents-date">{{date}}</li>
        </ul>
    </div>
</div>
"#,
    )?;

    // マッチ!
    let result = pat.matches(&doc);

    // マッチ結果のVec<BTreeMap<String, String>> を Vec<HotEntry> に変換して返す
    Ok(result
        .into_iter()
        .map(|m| HotEntry {
            url: m["url"].to_owned(),
            title: m["title"].to_owned(),
            users: m["users"].to_owned(),
            date: m["date"].to_owned(),
        })
        .collect())
}

実行してみます。

$ cargo run
...
[
    HotEntry {
        url: "https://internet.watch.impress.co.jp/docs/yajiuma/1234496.html",
        title: "5年にわたって放置の末に……はてブ発の「本気の」RSSリーダー、正式に開発中止を表明【やじうまWatch】 - INTERNET Watch",
        users: "407",
        date: "2020/02/12 06:05",
    },
    HotEntry {
        url: "https://anond.hatelabo.jp/20200211125801",
        title: "どうしてもっと個人パソコンでできることって増えなかったんだろうな",
        users: "214",
        date: "2020/02/12 11:00",
    },
    HotEntry {
        url: "https://www.pieceofcake.co.jp/n/naefe7919ceeb",
        title: "決死の覚悟でのぞんだnoteのドメイン移行。検索流入急落からの復活劇|株式会社ピースオブケイク",
        users: "174",
        date: "2020/02/12 11:27",
    },
    HotEntry {
        url: "https://nikkan-spa.jp/1639354",
        title: "「AVモザイク除去」できるAIに業界が震撼、人気AV女優も被害に… | 日刊SPA!",
        users: "489",
        date: "2020/02/11 18:16",
    },
    ...

正しく取れています。

もっと興味のある方は、 レポジトリのexamplesに、 いくつかの例があるので、是非ご覧下さい。

まとめ

というわけで、とにかく簡単に使えるHTMLスクレイピングライブラリを目指してライブラリを作ってみました。 自分が既存のライブラリを使った際に感じたつらい部分をどうすればなくせるかを考えてみて、 それなりに良い感じの物ができたんではないかなあと思っております。

構文やマッチのルールなどはまだ完全に詰められている感じではありませんが、 皆さんよろしければ使ってみてください。