Chapter 03

リンクを抽出する

Shotaro Tsuji
Shotaro Tsuji
2020.12.08に更新

このチャプターでは、サーバから取得したHTMLのリンクを抽出するコードを書きます。HTMLを解析して<a>要素のhref属性を抜き出すことでリンクの抽出を実装します。この操作を実現するために今回はselectクレートを使います。

Cargo.tomlselectクレートへの依存を追記しておきます。

Cargo.toml
[dependencies]
reqwest = { version = "0.10", features = ["blocking"] }
eyre = "0.6"
select = "0.5"

残念ながらselectクレートにはドキュメントが書かれていません。いろいろなクレートを探していると、ドキュメントが整備されていないクレートに遭遇することが時々あります。しかし、めげずにcargo docが生成したドキュメントをたどって使い方を確認しましょう。

HTML文書の読み込み

ドキュメントのモジュール一覧をみると、documentという名前のモジュールがあります。名前から察するにHTML文書を扱うモジュールなのでしょう。さて、documentモジュールで定義されている構造体を見ると、Documentという名前の構造体があり、"An HTML document" という説明があります。名前からしてこの構造体がHTML文書を保持するのでしょう。

次にDocument構造体の実装一覧を参照します。from_readというReadトレイトを実装したオブジェクトを受け取ってDocument構造体を返す関連関数がありますが、今回はこのメソッドは使いません。その下のトレイト実装の節にFrom<&'a str>トレイトの実装があると書かれています。この関連関数の説明を読むと、"Parses the given &str into a Document" とあります。前のチャプターで書いたコードでは取得したページの内容はStringのオブジェクトとして保持されるので、今回はFromトレイトの実装を使うことにします。したがって、以下のコードでDocument構造体のオブジェクトを作ることができます。

    use select::document::Document;
    let doc = Document::from(body.as_str());

<a>要素の抽出

さて次にやるべきことは、読み込んだHTML文書から<a>要素を抜き出すことです。Document構造体に実装されたfindメソッドを呼び出せば特定の要素だけを抽出することができそうです。findメソッドの宣言は以下のようになっています。

pub fn find<P: Predicate>(&self, predicate: P) -> Find<P>

findメソッドにPredicateトレイトを実装したオブジェクトを渡すとFind構造体のオブジェクトが返ってきます。Predicateトレイトを実装する型を確認しましょう。

Element構造体は使えそうでしょうか。説明を読むと、どんな要素ノードにもマッチすると書いてあるので、これは我々が求めているものではないようです。

Name構造体はどうでしょうか。名前Tを持つ要素ノードにマッチするという説明があるのでこれが使えそうです。この構造体の宣言はpub struct Name<T>(pub T);となっているので、Name("a")とすれば<a>要素を抽出できそうです。ちなみに、NameT&strのときにだけPredicateを実装します

findメソッドの戻り値を確認しておきましょう。findの戻り値はFind構造体でした。トレイト実装の欄を見ると、Node構造体を返すIteratorトレイトを実装しているようです。このNode構造体はHTMLの要素やテキストなどを表すようです。

以上のことから、Documentのオブジェクトのfindメソッドを呼んで、for文を使えば<a>要素だけを抜き出せそうです。

src/main.rs
    use select::predicate::Name;
    for a in doc.find(Name("a")) {
        println!("{:?}", a);
    }

上のコードをsrc/main.rsに追加して実行してみると以下のような出力が得られます。どうやらうまく行っているようです。

Element { name: "a", attrs: [("href", "/"), ("class", "brand")], children: [Text("\n      "), Element { name: "img", attrs: [("class", "v-mid ml0-l"), ("alt", "Rust Logo"), ("src", "/static/images/rust-logo-blk.svg")], children: [] }, Text("\n      \n    ")] }
Element { name: "a", attrs: [("href", "/tools/install")], children: [Text("Install")] }
Element { name: "a", attrs: [("href", "/learn")], children: [Text("Learn")] }
Element { name: "a", attrs: [("href", "https://play.rust-lang.org/")], children: [Text("Playground")] }
Element { name: "a", attrs: [("href", "/tools")], children: [Text("Tools")] }
-- snip --

href属性の取得

最後にa要素からhref属性を取り出すことで、リンクのURLを得ることができます。Node構造体にはattrというメソッドがあります。

pub fn attr(&self, name: &str) -> Option<&'a str>

このメソッドはnameという名前を持つ属性があればその値を、なければNoneを返します。Document構造体のfindメソッドはイテレータを返すので、filter_mapを使えばhref属性の値だけを取り出すことができます。

src/main.rs
    for href in doc.find(Name("a")).filter_map(|a| a.attr("href")) {
        println!("{:?}", href);
    }

先ほど追加したコードを上のコードに置き換えてプログラムを実行すれば、以下のような結果が得られるはずです。

"/"
"/tools/install"
"/learn"
"https://play.rust-lang.org/"
"/tools"
"/governance"
"/community"
"https://blog.rust-lang.org/"
"/learn/get-started"
-- snip --

リンクを絶対URLに変換する

ここまでの作業でHTML文書からリンクを抽出することができました。しかし、抽出したリンクをreqwest::blocking::getに渡すことはできません。上の結果を見ても分かる通り、同じサイト内にあるページへのリンクは相対URLとして記述されているので、これを絶対URLに変換する必要があります。

今回はURLの扱いを簡単に行うためにurlクレートを使います。
urlクレートのドキュメントを読むと、Url::parse関連関数を使うとURLのパースができるようなので、ひとまず抽出したリンクをパースして表示するコードを書いてみます。

src/main.rs
    for href in doc.find(Name("a")).filter_map(|a| a.attr("href")) {
        println!("{:?}", Url::parse(href));
    }

src/main.rsfor文を上のように書き換えて実行すると次のような出力が得られるはずです。絶対URLになっているものはパースが成功して、相対URLになっているものはParseError::RelativeUrlWithoutBaseが返ってきます。

Err(RelativeUrlWithoutBase)
Err(RelativeUrlWithoutBase)
Err(RelativeUrlWithoutBase)
Ok(Url { scheme: "https", host: Some(Domain("play.rust-lang.org")), port: None, path: "/", query: None, fragment: None })
Err(RelativeUrlWithoutBase)
Err(RelativeUrlWithoutBase)
Err(RelativeUrlWithoutBase)
Ok(Url { scheme: "https", host: Some(Domain("blog.rust-lang.org")), port: None, path: "/", query: None, fragment: None })
Err(RelativeUrlWithoutBase)
Ok(Url { scheme: "https", host: Some(Domain("blog.rust-lang.org")), port: None, path: "/2020/11/19/Rust-1.48.html", query: None, fragment: None })
-- snip --

URLをパースした結果に応じて以下の通りに処理を分けましょう。

  • 絶対URLだった場合はそのまま表示する。
  • 相対URLだった場合は絶対URLに変換して表示する。
  • それ以外の場合は無視する。

for文の中には次のようなmatch文を書くことになります。

src/main.rs
	use url::ParseError as UrlParseError;
        match Url::parse(href) {
            Ok(url) => { println!("{}", url); },
            Err(UrlParseError::RelativeUrlWithoutBase) => {
	        // `href`を絶対URLに変換する。
            },
            Err(e) => {},
        }

相対URLを絶対URLに変換するのにjoinメソッドが使えます。joinメソッドはselfをベースURLとしてinputを結合したURLを返します。

ここでベースURLとして最初にreqwest::blocking::getに渡したURLを使いたくなりますが、リダイレクトが発生する可能性に注意しなければなりません。リダイレクト後のURLはreqwestクレートのResponse構造体のurlメソッドで取得することができます。

まずはレスポンスからリダイレクト後のURLを取り出すコードを書きます。レスポンスのボディを取り出すtextメソッドがResponseオブジェクトを消費してしまうことに注意します。ボディを取り出す前にURLを取り出してクローンしておきましょう。

src/main.rs
    let response = reqwest::blocking::get("https://www.rust-lang.org")?;
    let base_url = response.url().clone();
    let body = response.text()?;
    let doc = Document::from(body.as_str());

<a>要素を走査するループの中では相対URLをベースURLにjoinして表示します。

src/main.rs
    for href in doc.find(Name("a")).filter_map(|a| a.attr("href")) {
        match Url::parse(href) {
            Ok(url) => { println!("{}", url); },
            Err(UrlParseError::RelativeUrlWithoutBase) => {
                let url = base_url.join(href)?;
                println!("{}", url);
            },
            Err(e) => { println!("Error: {}", e); },
        }
    }

これで取得したウェブページから抽出したリンクを絶対URLで表示できるようになりました。cargo runを実行すると以下のような表示がされるはずです。

https://www.rust-lang.org/
https://www.rust-lang.org/tools/install
https://www.rust-lang.org/learn
https://play.rust-lang.org/
https://www.rust-lang.org/tools
https://www.rust-lang.org/governance
https://www.rust-lang.org/community
https://blog.rust-lang.org/
https://www.rust-lang.org/learn/get-started
https://blog.rust-lang.org/2020/11/19/Rust-1.48.html
https://blog.rust-lang.org/2018/03/12/roadmap.html
--snip--

この章で作成したファイル