RustでHTMLを宣言的にスクレイピングするためのライブラリ「h2s」を書いた

h2s

html-to-struct なので h2s です。

アピールポイント

  • スクレイピングロジックを宣言的に記述できる
  • シンプルかつ柔軟なインターフェース
  • エラー原因が詳細にわかる

詳細は後述します。

使い方

  1. スクレイピング対象の HTML ドキュメントにどういう構造を期待するかを struct として定義する
  2. h2s::parse(html) を呼ぶ
  3. スクレイピング結果が struct に入って返却される
    • 定義した struct と HTML ドキュメントの構造が合わない場合にはエラーが返る

例として、以下のような HTML ドキュメント をスクレイピングしたいとします。

<html lang="en">
  <body>
    <div>
      <h1 class="blog-title">My tech blog</h1>
      <div class="articles">
        <div>
          <h2><a href="https://example.com/1">article1</a></h2>
          <div><span>901</span> Views</div>
          <ul>
            <li>Tag1</li>
            <li>Tag2</li>
          </ul>
        </div>
        <div>
          <h2><a href="https://example.com/2">article2</a></h2>
          <div><span>849</span> Views</div>
          <ul></ul>
        </div>
        <div>
          <h2><a href="https://example.com/3">article3</a></h2>
          <div><span>103</span> Views</div>
          <ul>
            <li>Tag3</li>
          </ul>
        </div>
      </div>
    </div>
  </body>
</html>

HTML ドキュメントに期待する構造の定義を struct で行います。struct の各フィールドにおいて、 attribute で CSS Selector を記述します。 一例としては以下のようになります。

#[derive(FromHtml, Debug, Eq, PartialEq)]
pub struct Page {
    #[h2s(attr = "lang")]
    lang: String,
    #[h2s(select = "div > h1.blog-title")]
    blog_title: String,
    #[h2s(select = ".articles > div")]
    articles: Vec<Article>,
}

#[derive(FromHtml, Debug, Eq, PartialEq)]
pub struct Article {
    #[h2s(select = "h2 > a")]
    title: String,
    #[h2s(select = "div > span")]
    view_count: usize,
    #[h2s(select = "h2 > a", attr = "href")]
    url: String,
    #[h2s(select = "ul > li")]
    tags: Vec<String>,
    #[h2s(select = "ul > li:nth-child(1)")]
    first_tag: Option<String>,
}

その後、h2s::parseを呼ぶとスクレイピングが実行されます。

let page: Page = h2s::parse("(前述したHTMLドキュメント)").unwrap();

結果として、初めに定義した struct にスクレイピングした値が入って返却されます。

// 正しくスクレイピングできていることを確認
assert_eq!(page, Page {
    lang: "en".to_string(),
    blog_title: "My tech blog".to_string(),
    articles: vec![
        Article {
            title: "article1".to_string(),
            url: "https://example.com/1".to_string(),
            view_count: 901,
            tags: vec!["Tag1".to_string(), "Tag2".to_string()],
            first_tag: Some("Tag1".to_string()),
        },
        Article {
            title: "article2".to_string(),
            url: "https://example.com/2".to_string(),
            view_count: 849,
            tags: vec![],
            first_tag: None,
        },
        Article {
            title: "article3".to_string(),
            url: "https://example.com/3".to_string(),
            view_count: 103,
            tags: vec!["Tag3".to_string()],
            first_tag: Some("Tag3".to_string()),
        },
    ]
});

struct のフィールドに指定できるものとしては、文字列や数値型の他にもOptionVec 、また別の struct のネストなど、実際のユースケースで必要となりそうなものは一通りサポートしています。

このライブラリの利点

スクレイピングロジックを宣言的に記述できる

従来の手続き的なスクレイピングの方法だと HTML ドキュメントを走査していくロジックが冗長になりがちで、「HTML ドキュメントにどういう構造を期待しているか」が実際のコードからは読み取りづらいという状況になりやすいです。真面目にやろうとするとエラーハンドリングのような本筋でないロジックも多く混ざってくるため、より煩雑になりがちです。

h2s では「HTML ドキュメントにどういう構造を期待しているか」を定義すればそれがそのまま動くので、手続的な方法に比べてロジックの見通しが良く、書くのも読むのも楽です。

シンプルかつ柔軟なインターフェース

宣言的という特徴とも被る部分はありますが、コード例を一目見ただけで使い方が迷わずわかるくらいのシンプルなライブラリを目指しました。

一方で、ライブラリとして trait を丁寧に定義して公開することを意識しており、それによりユーザー側でライブラリ各所において適切に拡張ができるようにしています。 例えば、struct 定義の末端のフィールドとして、Stringusizeなどの他に自前の構造体や h2s がデフォルトでサポートしていない構造体を指定したい場合は、その構造体に特定の trait を実装することで利用できるようになります(コード例)。

エラー原因が詳細にわかる

h2s と同じアプローチを取っている先行のライブラリに unhtml がありますが、こちらのライブラリでは HTML ドキュメントが期待した構造ではなかった際に、具体的な問題の箇所や原因がわからないという課題がありました(作者も認識してそう)。 このライブラリがしばらくメンテも止まってそうということもあり、せっかくなら自分で書いてみるかというのが h2s を作った元々のモチベーションでもあります。

h2s では HTML ドキュメントの構造が期待と合わずエラーとなった際、「どこがどうマッチしなかったか」がわかるようなメッセージで返却するようにしています。これによりデバッグやエラーの調査が楽になることが期待できます。

エラーの例を示すため、以下のように先ほどの HTML ドキュメントの一部をコメントアウトした状態で再度スクレイピングを実行してみます。

<html lang="en">
  <body>
    <div>
      <h1 class="blog-title">My tech blog</h1>
      <div class="articles">
        <div>
          <h2><a href="https://example.com/1">article1</a></h2>
          <div><span>901</span> Views</div>
          <ul>
            <li>Tag1</li>
            <li>Tag2</li>
          </ul>
          <p class="modified-date">2020-05-01</p>
        </div>
        <div>
          <h2><a href="https://example.com/2">article2</a></h2>
          <div><span>849</span> Views</div>
          <ul></ul>
          <p class="modified-date">2020-03-30</p>
        </div>
        <div>
          <!-- 一部をコメントアウト -->
          <!-- <h2><a href="https://example.com/3">article3</a></h2> -->
          <div><span>103</span> Views</div>
          <ul>
            <li>Tag3</li>
          </ul>
        </div>
      </div>
    </div>
  </body>
</html>

すると、HTML ドキュメントが期待している構造と合わないため h2s からエラーが返却されます。 どの位置でどういったエラーが起きたかはエラー内部にスタック構造で保持されており、 .to_string() で以下のようなエラーメッセージが得られます。

[articles(.articles > div)]: (index=2): [title(h2 > a)]: expected exactly one element, but no elements found

このエラーは、「articles (.articles > devにマッチする要素) の 3 番目(index=2)の要素において、 title (h2 > aにマッチする要素) が見つからない」と読むことができ、詳細なエラーの原因箇所がわかるようになっています。

そのほか一例としては、要素数が想定と合わない場合などもきちんと検知し、その旨のエラーが出ます。

/// 例: 1つしか存在しない想定の要素が1つ以上見つかった場合
#[derive(FromHtml, Debug, Eq, PartialEq)]
pub struct MyStruct1 {
    #[h2s(select = "h1")]
    h1: usize,
}

let err = h2s::parse::<MyStruct1>("<div><h1>1</h1><h1>2</h1></div>").unwrap_err();

println!("{}", err.to_string());
// => [h1(h1)]: expected exactly one element, but 2 elements found
/// 例: ちょうど3つ存在する想定の要素が2つしか見つからなかった場合
#[derive(FromHtml, Debug, Eq, PartialEq)]
pub struct MyStruct2 {
    #[h2s(select = "h2")]
    h2: [usize; 3],
}

let err = h2s::parse::<MyStruct2>("<div><h2>1</h2><h2>2</h2></div>").unwrap_err();

println!("{}", err.to_string());
// => [h2(h2)]: expected 3 elements, but found 2 elements

その他のこだわり

ユーザビリティにはあんまり影響しなさそうだけど個人的に頑張った点です。

バックエンドの HTML パーサーライブラリを差し替え可能

h2s は HTML ドキュメントを文字列からパースしたり、DOM を走査したりするロジック自体は持っておらず、そこは裏で scraper を利用させてもらっています。ただし、h2s のコアからは scraper には直接依存せず、 特定の trait を実装すれば他のライブラリもバックエンドとして利用できるような構成にしています。

実際差し替える需要とかあるの?と言われると、あんまりなさそうですねとなります。

Generic Associated Types の利用

h2s のコアロジックを書くにあたって「TVec<T>Option<T>を区別せずfn(T) -> Uを適用したい」 みたいな場面が出てきて、それを綺麗に書こうしたところ関数型プログラミングでいうところの Functor に近いものを実装したくなり、そこで GATs が必要になって使いました。

HTML のツリーを走査して struct に当てはめていくという h2s の処理は、Functor をはじめとする関数型プログラミングの概念を持ち込むことでかなりスッキリ書けそうな見通しはあるのですが、現時点では半端にしか活かせていない状況です。 現状の Rust の GATs の表現力では Functor や Monad のような概念を表現するには不十分であることが指摘されており1、実際 h2s で実装したのも Functor もどきの半端なものとなってしまい、それによって本来共通化できそうな部分の処理が共通化できていないといった課題があります。

現時点では GATs を利用したのはかなり趣味の範疇であると言わざるを得ず、結果として GATs が stable になった Rust 1.65 以上を要求するようになってしまったので、デメリットの方がでかいのではないかという気はしています。 今後 GATs がパワーアップするかもしくは別の feature として何らかの改善がなされたら、積極的に h2s にも取り込みたいなと思っています。

今後の課題

  • 並行して複数のエラーが発生した時にも先頭の 1 つしか返せてないので、理想的には全部返せるようにしたいです。
  • 「この中のいずれかの要素が必ず一つ含まれる」みたいな OR の表現を提供できていないので、enumをサポートしてその辺をカバーできると良さそうだなと思っています。
  • Generic な構造体をサポートできたら楽しそうだなと思っています。

まとめ

個人の趣味で運用しているスクレイピングのシステムにて実際に h2s を使ってみているのですが、今のところ気持ちよく使えています。 h2s のソースコード量や public なインターフェース数の少なさの割には柔軟に豊かな振る舞いをしてくれているような感覚があり、結構よくできたんじゃないかなと思っています。 よかったら使ってみてください。

Footnotes

  1. https://zenn.dev/yyu/articles/f60ed5ba1dd9d5