Chapter 08

コマンドラインインターフェースを作る

Shotaro Tsuji
Shotaro Tsuji
2020.12.08に更新

この章ではLinkExtractorCrawlerを結合し、コマンドライン引数で探索を始めるURLと訪問ページ数の上限を与えられるプログラムを作成します。そのために、LinkExtractorAdjacentNodesトレイトを実装します。そして、コマンドライン引数をstructoptクレートでパースして得たパラメタをCrawlerに渡して探索を実行するコードを書きます。

LinkExtractorによるAdjacentNodesトレイトの実装

LinkExtractorAdjacentNodesトレイトを実装します。adjacent_nodes関数の中でget_links関数を呼び出すようにします。

src/lib.rs
impl crawler::AdjacentNodes for LinkExtractor {
    type Node = Url;

    fn adjacent_nodes(&self, v: &Self::Node) -> Vec<Self::Node> {
        self.get_links(v.clone())
            .unwrap()
    }
}

おっと、ここで問題が発生しました。get_linksの戻り値はResult型ですがadjacent_nodesの戻り値はそうではありません。前の章で幅優先探索を実装するときにこのことをすっかり忘れていました。仕方がないのでひとまずunwrapを呼んでお茶を濁すことにしましょう。

また、get_linkUrl型のオブジェクトを受け取ります。adjacent_nodesは参照を受け取るのでcloneを呼んでコピーを渡すようにします。

LinkExtractorCrawlerの結合

それではmain関数を書き換えましょう。LinkExtractorのオブジェクトを作るところまではこれまでと変わりません。LinkExtractorのオブジェクトを作ったら、それへの参照とスタート地点となるURLをCrawlerのコンストラクタに渡します。そして、forの中でCrawlerのオブジェクトを使います。とりあえず10件のページを取得するためにtake(10)を呼び出しておきます。また、ループの中でstd::thread::sleepを呼び出します。とりあえず1件ごとに100ミリ秒スリープすることにしましょう。

src/main.rs
use reqwest::blocking::ClientBuilder;
use url::Url;
use mini_crawler::LinkExtractor;
use mini_crawler::crawler::Crawler;
use std::time::Duration;

fn main() -> eyre::Result<()> {
    env_logger::init();

    let url = std::env::args()
        .nth(1)
        .unwrap_or("https://www.rust-lang.org".to_owned());
    let url = Url::parse(&url)?;
    let client = ClientBuilder::new()
        .build()?;
    let extractor = LinkExtractor::from_client(client);

    let crawler = Crawler::new(&extractor, url);

    let wait = Duration::from_millis(100);

    for url in crawler.take(10) {
        println!("{}", url);
        std::thread::sleep(wait.clone());
    }

    Ok(())
}

さて、cargo runを実行してmini-crawlerを動かしてみましょう。訪問したURLが10件表示されると思います。しかし、スリープさせた時間である100ミリ秒より長い間隔で1件ずつ表示されるでしょう。これはHTMLの解析に時間がかかるのと、それぞれのページを実際に読み込んでリンクの抽出までやっているからです。

この動作を早くしたければリンクの抽出を後回しにするようにCrawlerを作り替えることができるでしょう。しかし、そうすると各ページが持つリンクも取り出したくなったときに、かなり面倒なことになるでしょうからそのような修正はしないことにします。

CLIの作成

さて、コマンドライン引数から探索を始めるページのURLと訪問するページ数の上限を渡せるようにしましょう。

コマンドライン引数をパースするためにstructoptを使います。Cargo.tomlに以下の行を加えましょう。

Cargo.toml
structopt = "0.3"

structoptは手続きマクロによってコマンドライン引数を構造体に自動で変換してくれる非常に便利なクレートです。さっそく、オプションを格納するstruct Optを定義して、deriveアトリビュートを付けてstructoptを使うことを宣言しましょう。

src/main.rs
use structopt::StructOpt;

#[derive(StructOpt)]
struct Opt {
}

必要なオプションは、

  • 探索を始めるページのURL
  • 上限ページ数
    の二つでした。それぞれ、Url構造体、usize型で持つことにしましょう。
src/main.rs
#[derive(StructOpt)]
struct Opt {
    maximum_pages: usize,
    start_page: Url,
}

structoptへの指示は構造体の各フィールドに属性を付けることで行います。上限ページ数は-nオプションで指定したいのでmaximum_pages#[structopt(short="n")]を付けます。

src/main.rs
#[derive(StructOpt)]
struct Opt {
    #[structopt(short="n")]
    maximum_pages: usize,
    start_page: Url,
}

属性を何も付けなければ、位置引数として扱われオプションを付けずに指定できる引数となります。また、文字列からフィールドの型への変換は、FromStrトレイトが実装されていれば自動で行われます。

コマンドライン引数はstruct Optに自動で実装されるfrom_argsメソッドを呼び出すことでパースできます。

src/main.rs
    let opt = Opt::from_args();

さて、コマンドライン引数を使うようにmain関数を書き換えましょう。

src/main.rs
fn main() -> eyre::Result<()> {
    env_logger::init();

    let opt = Opt::from_args();

    let client = ClientBuilder::new()
        .build()?;
    let extractor = LinkExtractor::from_client(client);

    let crawler = Crawler::new(&extractor, opt.start_page);

    let wait = Duration::from_millis(100);

    for url in crawler.take(opt.maximum_pages) {
        println!("{}", url);
        std::thread::sleep(wait.clone());
    }

    Ok(())
}

cargo runを実行すると以下のメッセージが表示されます。

error: The following required arguments were not provided:
    <start-page>
    -n <maximum-pages>

USAGE:
    mini-crawler <start-page> -n <maximum-pages>

For more information try --help

cargo run -- --helpを実行してみましょう。

mini-crawler 0.1.0

USAGE:
    mini-crawler <start-page> -n <maximum-pages>

FLAGS:
    -h, --help       Prints help information
    -V, --version    Prints version information

OPTIONS:
    -n <maximum-pages>

ARGS:
    <start-page>

ヘルプメッセージが表示されていますが説明がついていません。ドキュメントコメントを書くことで引数の説明が生成されるようになります。

src/main.rs
/// A toy web crawler
#[derive(StructOpt)]
struct Opt {
    /// Maximum number of pages to be crawled
    #[structopt(short="n")]
    maximum_pages: usize,
    /// URL where this program starts crawling
    start_page: Url,
}

ドキュメントコメントを書いてからcargo run -- --helpを実行するとメッセージが次のように変化します。これで引数の説明が表示されるようになりました。

mini-crawler 0.1.0
A toy web crawler

USAGE:
    mini-crawler <start-page> -n <maximum-pages>

FLAGS:
    -h, --help       Prints help information
    -V, --version    Prints version information

OPTIONS:
    -n <maximum-pages>        Maximum number of pages to be crawled

ARGS:
    <start-page>    URL where this program starts crawling

それではmini-crawlerを実行してみましょう。

% RUST_LOG=info cargo run -- -n 100 https://docs.rs/reqwest/0.10.9/reqwest/
    Finished dev [unoptimized + debuginfo] target(s) in 0.10s
     Running `target/debug/mini-crawler -n 100 'https://docs.rs/reqwest/0.10.9/reqwest/'`
[2020-12-04T07:23:18Z INFO  mini_crawler] GET "https://docs.rs/reqwest/0.10.9/reqwest/"
[2020-12-04T07:23:19Z INFO  mini_crawler] Retrieved 200 OK "https://docs.rs/reqwest/0.10.9/reqwest/"
https://docs.rs/reqwest/0.10.9/reqwest/
[2020-12-04T07:23:19Z INFO  mini_crawler] GET "https://docs.rs/"
[2020-12-04T07:23:19Z INFO  mini_crawler] Retrieved 200 OK "https://docs.rs/"
https://docs.rs/
[2020-12-04T07:23:20Z INFO  mini_crawler] GET "https://docs.rs/about"
[2020-12-04T07:23:20Z INFO  mini_crawler] Retrieved 200 OK "https://docs.rs/about"
https://docs.rs/about
[2020-12-04T07:23:20Z INFO  mini_crawler] GET "https://docs.rs/about/badges"
[2020-12-04T07:23:20Z INFO  mini_crawler] Retrieved 200 OK "https://docs.rs/about/badges"
https://docs.rs/about/badges
[2020-12-04T07:23:20Z INFO  mini_crawler] GET "https://docs.rs/about/builds"
[2020-12-04T07:23:20Z INFO  mini_crawler] Retrieved 200 OK "https://docs.rs/about/builds"
https://docs.rs/about/builds
-- snip --
[2020-12-04T07:23:25Z INFO  mini_crawler] GET "https://rust-lang-nursery.github.io/rust-cookbook/"
[2020-12-04T07:23:25Z INFO  mini_crawler] Retrieved 200 OK "https://rust-lang-nursery.github.io/rust-cookbook/"
https://rust-lang-nursery.github.io/rust-cookbook/
[2020-12-04T07:23:26Z INFO  mini_crawler] GET "https://crates.io/"
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: ServerError(reqwest::Error { kind: Status(403), url: Url { scheme: "https", host: Some(Domain("crates.io")), port: None, path: "/", query: None, fragment: None } })', src/lib.rs:79:14
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

おっと、unwrapの呼び出しでパニックしてしまいましたね。このunwrapLinkExtractorAdjacentNodesを実装する際に書いたものです。最後にこの部分の処理を修正してmini-crawlerを完成させましょう。

最後の修正

今回はリンクの抽出でエラーが起きた場合は、エラーを無視して空のVecを返してしまうようにします。ただし、発生したエラーはログに残すようにしておきます。エラーログはエラーの発生原因も表示するようにしました。

src/lib.rs
impl crawler::AdjacentNodes for LinkExtractor {
    type Node = Url;

    fn adjacent_nodes(&self, v: &Self::Node) -> Vec<Self::Node> {
        match self.get_links(v.clone()) {
            Ok(links) => links,
            Err(e) => {
                use std::error::Error;
                log::warn!("Error occurred: {}", e);

                let mut e = e.source();
                loop {
                    if let Some(err) = e {
                        log::warn!("Error source: {}", err);
                        e = err.source();
                    } else {
                        break;
                    }
                }

                vec![]
            },
        }
    }
}

これで

% RUST_LOG=warn cargo run --release -- -n 200 https://docs.rs/reqwest/0.10.9/reqwest/

を実行すれば訪問したURLが表示されることでしょう。

以上の例ではreqwestのドキュメントのURLをmini-crawlerに与えていましたが、cargo docが生成するドキュメントは意外にたくさんのリンクを含んでいるので、例えばmdbookのマニュアルはそれほど大きくないので、こちらの方が例としてよかったかもしれません。

% cargo run --release -- -n 100 https://rust-lang.github.io/mdBook/format/summary.html

以上でmini-crawlerは完成です。

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