Rust研究：Webスクレイピング、Excel出力

ちょっとWebスクレイピングする必要があったので scraper クレイトを使ってみました。

常識的に考えて Python でやりそうな局面ですが、今回もあえて Rust でいきます。Rust 修行中なので。

ほぼ需要ないでしょうが自分のためにメモを残します。

# お題

Rust Blog の記事リストを取得し、エクセルファイルに保存したい。

* 記事リストを取得するWebサイトは https://blog.rust-lang.org/ とする。
* 記事リストの項目は、記事の日付・記事のタイトル・記事のURLとする。
* 記事の日付は「西暦/月/日」の形式とする。
* 出力するエクセルファイルの名前は out.xlsx とする

out.xlsx の出力例は以下の通り：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2960096/013932f5-f25c-89d5-3977-fe59d9d94948.png)


# 検討

Rust Blog をブラウザで確認する。

![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2960096/4469cf1d-f04f-ab4c-8285-7ad8754eba38.png)

サイトの表示と取得したい記事リストの項目の対応はこんな感じで考える。

![2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2960096/225a87c8-4a1b-8f75-f740-e110f54d20d9.png)

次にサイトのHTML コードと取得する記事リストの項目の対応を確認する。

![3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2960096/ebd39bfd-e575-7850-99a8-bc5d7ad101a0.png)

記事の日付の西暦、記事の日付の月日、記事のタイトルとリンクの td 要素をうまくとれればよさそう。

* 西暦と月日が別々の要素に配置されているので記事の日付としてこれらをくっつけてやる必要がある
* 月は「月名」（Jan.～Dec.）なのでこれを数字にしてやる必要がある
* 月名と日が &amp;nbsp (No break space) で区切られている。値は "\u{a0}" となることに注意。
* リンクは相対パスになっているので完全な URL にしてやる必要がある

取得対象部分をDOMで考えるとこんな感じ。

![4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2960096/b9f64eaf-2696-6365-533d-183cc2a3dc90.png)

必要なノードを特定するセレクタは次のようにしてみた。

![5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2960096/a4292ba1-d433-b8b4-a9eb-99d5d3341c10.png)

* selector1 はドキュメントのトップから tr 要素を選択するためのセレクタ。
* selector2 ～ 4 はそれぞれ h3 / td / a 要素を上の tr 要素から相対的に選択するためのセレクタ。

その他、動作確認のたびに Rust Blog のサイトにアクセスを発生させるのは無駄なので、ローカルにキャッシュファイルを作成し、そこからからHTMLコードを読み出すようにすることとする。

# コード

以下に main.rs を示す。

```rust
/*
  Rust Blog の記事リストをエクセルに保存するサンプル

  Build and run:
    cargo add reqwest --features="blocking"
    cargo add scraper
    cargo add thiserror
    cargo add url
    cargo add xlsxwriter
    cargo run

  Respect for Mr. John Doe
  https://qiita.com/YoshiTheQiita/items/f66828d61293c75a4585
  
*/

const RUST_BLOG_URL:&str = "https://blog.rust-lang.org/";

const CACHE_FILE: &str = "cache_file";
const OUT_FILE:&str = "out.xlsx";

const NBSP: &str = "\u{a0}";
const MONTHS:[&str;12] = ["Jan.","Feb.","Mar.","Apr.","May",
    "June","July","Aug.","Sept.","Oct.","Nov.","Dec."];


use std::io::Write;

fn main() {
    if let Err(e) = run() {
        eprintln!("{:?}", e)
    }
}

fn run () -> Result<(), MyError> {

    // ベースURLの用意（相対パスで与えられる記事のリンクをURLに変換するのに利用）
    let base_url = url::Url::parse(RUST_BLOG_URL)?;

    // セレクタの定義
    let selector1 = scraper::Selector::parse("table.post-list tr").unwrap();    
    let selector2 = scraper::Selector::parse("td.bn > h3").unwrap();
    let selector3 = scraper::Selector::parse("td.tr").unwrap();
    let selector4 = scraper::Selector::parse("td.bn > a").unwrap();

    // キャッシュファイルの用意
    prepare_cache_file()?;

    // キャッシュファイルの読み込み
    let content = std::fs::read_to_string(CACHE_FILE)?;

    // キャッシュファイルが空のとき
    if content.len() < 1 {
        return Err(MyError::EmptyContentError);
    }

    // 出力先エクセルの用意
    let wb = xlsxwriter::workbook::Workbook::new(OUT_FILE)?;
    let mut sh = wb.add_worksheet(None)?;
    let mut i:xlsxwriter::worksheet::WorksheetRow = 0;

    let mut year:usize = 0;

    // キャッシュファイルの中身を HTML としてパース
    let document = scraper::Html::parse_document(&content);

    // セレクタで該当する tr 要素を抽出し、それぞれについて処理する
    let trs = document.select(&selector1);
    for tr in trs {
        let mut tmp = String::new();

        // tr 要素の下の h3 要素より「西暦」を取得する
        for h3 in tr.select(&selector2) {
            tmp = text2str(&mut h3.text());
        }
        if tmp != "" {
            year = parse_year(tmp)?;
            continue;
        }

        let mut month:usize = 0;
        let mut day:usize = 0;
        let mut article_url = String::new();
        let mut article_title = String::new();

        // tr 要素の下の td 要素より「月」と「日」を取得する
        for td in tr.select(&selector3) {
            let date = text2str(&mut td.text());
            (month, day) = parse_date(date)?;
        }

        // tr 要素の下の a 要素より「記事タイトル」と「記事URL」を取得する
        for a in tr.select(&selector4) {
            article_title = text2str(&mut a.text());
            let a = a.value();
            if let Some(h) = a.attr("href") {
                article_url = base_url.join(h)?.to_string();
            }
        }

        // どれか一つでも項目を取得できなかった場合は次へ
        if year == 0 || month == 0 || day == 0 || article_title == "" || article_url == "" {
            continue;
        }

        if cfg!(debug_assertions) {
            println!("{y}/{m}/{d}, {t}, {u}",
                y = year,
                m = month,
                d = day,
                t = article_title,
                u = article_url);
        }

        // エクセルに出力
        sh.write_string(i, 0, &format!("{}/{}/{}", year, month, day), None)?;
        sh.write_string(i, 1, &article_title, None)?;
        sh.write_string(i, 2, &article_url, None)?;
        i += 1;
    }

    // エクセルをクローズ
    wb.close()?;
    eprintln!("# saved in {}", OUT_FILE);

    Ok(())
}

// 西暦を取得する関数
fn parse_year(s:String) -> Result<usize, MyError> {
    if !s.starts_with("Posts in ") {
        return Err(MyError::UnknownYearError);
    }
    // 9文字目以降を西暦を示す数字としてパースする
    Ok(s[9..].parse()?)
}

// 日付（月・日）を取得する関数
fn parse_date(s:String) -> Result<(usize, usize), MyError> {
    let p:Vec<&str> = s.split(NBSP).collect();
    if p.len()<2 {
        return Err(MyError::UnknownDateError);
    }
    Ok((conv_month(p[0])?, p[1].parse()?))
}

// 月名を数字に変換する関数
fn conv_month(s:&str) -> Result<usize, MyError> {
    for (i, m) in MONTHS.iter().enumerate() {
        if s == *m {
            return Ok(i+1);
        }
    }
    Err(MyError::UnknownDateError)
}

// テキストの文字列を返す関数
fn text2str(t:&mut scraper::element_ref::Text) -> String {
    //String::from(t.next().unwrap_or("")) でもいいけどダサいと感じる
    let r:Vec<&str> = t.collect();
    r.join("")
}

// キャッシュファイルを用意する関数（何度もサイトにアクセスさせない工夫）
fn prepare_cache_file() -> Result<(), MyError> {
    // キャッシュファイルがないときのみダウンロードする
    if !file_exists(CACHE_FILE) {
        eprintln!("# downloading...");
        let body = reqwest::blocking::get(RUST_BLOG_URL)?.text()?;

        eprintln!("# saving in {}...", CACHE_FILE);
        let mut w = std::fs::File::create(CACHE_FILE)?;
        write!(w, "{}", body)?;
        w.flush()?;
    }
    Ok(())
}

// ファイルの有無をチェックする関数
fn file_exists(file_path: &str) -> bool {
    std::path::Path::new(file_path).is_file()
}

// 複数のエラーをまとめるエラー型
#[derive(thiserror::Error, Debug)]
enum MyError {
    #[error("UnknownYearError")]
    UnknownYearError,

    #[error("UnknownDateError")]
    UnknownDateError,

    #[error("EmptyContentError")]
    EmptyContentError,

    #[error("ReqwestError({0})")]
    ReqwestError (#[from] reqwest::Error),

    #[error("IOError({0})")]
    IOError (#[from] std::io::Error),

    #[error("ParseUrlError({0})")]
    ParseUrlError (#[from] url::ParseError),

    #[error("ParseIntError({0})")]
    ParseIntError (#[from] std::num::ParseIntError),

    #[error("XlsxError({0})")]
    XlsxError (#[from] xlsxwriter::XlsxError),
}
```

以上。