Rustを書いてリングフィットアドベンチャーを手に入れた話

Rustを書いてリングフィットアドベンチャーを手に入れた話

こんばんは。Rustのコードを書いたおかげで本日我が家にリングフィットアドベンチャーが届きました 🤤

20200419追記

GitLabにpushしました https://gitlab.com/morifuji/ring-fit-adventure-scrayping

何をしたのか

去年にswitchを購入してすっかりゲーム(主にインディーゲーム)にハマっていましたが、そのおかげで全く運動をしないようになりました。もう少し運動した方が良いと考えたところにswitchのリングフィットアドベンチャー を発見しました。

これだ欲しい!と思ったもののネットでも近くのJohsinでも売り切れ、しかも日中は仕事をしているのでネットに張り付いているわけにもいかず、購入できる気配が全くなかったです。

となるとスクレイピングする手段しかなかったので

  1. 販売サイトをスクレイピング
  2. 「品切れ」の文字があるかどうかチェック
  3. なければslackに通知

と言う仕組みをRustで書いてみました。スクレイピングのグレーなところに配慮して、スクレイピング頻度は1時間に1度にしています。

結果

稼働を開始してから2週間後の4/1…

/img/2020-04-19.png

通知キター

ついにキター🎉🎉🎉🎉🎉

急いで販売サイトにログインして購入しました。購入が殺到しているのかめちゃくちゃサイトが重かったです。

そして昨日4/17。無事にコントローラーが届きました🥳

/img/2020-04-19.JPG

結構デカい

いやぁRustってほんとにすごい🙏

所感

  • テストコードも書いてみた
    • テストコードとテスト対象コードを同一ファイルで記述できるので、気軽に書くことができた
    • Rustはコンパイルできればほぼ実行時にエラーがでないイメージなので、コンパイル時に見つけ出すことができない外部に依存しているコードの部分のみをテストすることで信頼性があがる
  • scraper簡単
  • 対してreqwestは毎回使うたびに苦戦する
  • rustのコード変更時のDockerfileのビルド時間を大幅に削減できた
    • せっかくなのでcargo-build-deps使わずにやってみた
    • 外部パッケージのダウンロード・ビルドが初回のみで済むので 5分 → 10秒ぐらい
  • 岡崎市立中央図書館事件ちゃんと読んだ。1秒に1回でもアウトって厳しい

ソースコード

Dockerfile

FROM rust:latest

# # キャッシュ
# RUN cargo install cargo-build-deps

# リクエストを送るURL
ARG SLACK_WEBHOOK_URL

RUN mkdir /var/app
WORKDIR /var/app

ENV USER root

RUN cargo new temp

WORKDIR /var/app/temp

COPY Cargo.toml Cargo.lock ./

RUN cargo build --release

RUN rm -rf ./src

# Rustのコードのみの変更時はここまでキャッシュが効く
COPY . .

CMD ["cargo", "run", "--release"]

src/bin/main.rs

use reqwest;
use scraper::{ Html, Selector };
use std::collections::HashMap;

const SEARCH_TEXT:&str = "品切れ";
const SELECTOR:&str = ".item-cart-add-area__add-button";
// リングフィットアドベンチャー
const URL:&str = "https://store.nintendo.co.jp/item/HAC_Q_AL3PA.html";
// テスト:動物の森→在庫あり
// const URL:&str = "https://store.nintendo.co.jp/item/HAC_J_ACBAA_32.html";

fn main() {
    let html = fetch_html(URL).expect("CAN'T FETCH!!!!!");
    let is_sold_out = is_contain(html, SELECTOR.to_owned(), SEARCH_TEXT.to_owned());

    if !is_sold_out {
        send_slack(format!(":tada: 品切れではなさそう {}", URL));
    }
}


/**
 * HTMLを取得
 */
fn fetch_html(url: &str) -> Result<String, reqwest::Error> {
    let mut response = reqwest::Client::new()
        .get(url)
        .header(reqwest::header::USER_AGENT, "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.122 Safari/537.36")
        .header("accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9")
        .header("accept-encoding", "gzip, deflate, br")
        .send()?;
    response.text()
}

/**
 * 指定の文字がhtmlに含まれているか
 */
fn is_contain(html: String, selector: String, search_text: String) -> bool {
    let document = Html::parse_document(&html);
    let selector = Selector::parse(&selector).unwrap();

    let mut selected = document.select(&selector);


    let is_contain = selected.any(|item| {
        item.html().contains(&search_text)
    });

    is_contain
}

/**
 * slackのwebhookURLにPOST
 */
fn send_slack(content: String) {
    let url = std::env::var("SLACK_WEBHOOK_URL").expect("CAN'T GET `SLACK_WEBHOOK_URL` ENV");

    let mut map = HashMap::new();
    map.insert("text", content);

    let mut response = reqwest::Client::new()
        .post(&url)
        .json(&map)
        .send();
}


/**
 * 自分のブログでテスト
 */
#[test]
fn test_with_myblog() {
    let html = fetch_html("http://blog.morifuji-is.ninja/").expect("CAN'T FETCH!!!!!");
    let res = is_contain(html, ".post-title".to_string(), "Rust".to_string());

    assert_eq!(res, true);
}

/**
 * slackに送信テスト
 */
#[test]
fn test_slack() {
    send_slack("TEST".to_string());
}

See also