最近、昨年の医療費控除を受けるために確定申告の準備などをしているのですが、家計簿ソフトで利用しているZaimにガスや水道代が勝手に計上されてくれると便利だな〜と思い、Zaim APIとSeleniumを使って実現してみようと思います。

Zaim Developers Center

東京ガスのCSVファイルをダウンロードする

Rustのheadless-chromeを使って構築します。

とりあえずCargoパッケージ作ってライブラリを追加します。

cargo new sync-zaim
cargo add headless-chrome

追加できました。

使い勝手を知るために、まずはGoogleにアクセスしてスクショをとってみます。

use headless_chrome::{protocol::cdp::Page::CaptureScreenshotFormatOption, Browser};
use std::fs;

fn main() {
    let browser = Browser::default().unwrap();
    let tab = browser.new_tab().unwrap();
    tab.set_default_timeout(std::time::Duration::from_secs(200));

    tab.navigate_to("https://www.google.com/").unwrap();
    tab.wait_until_navigated().unwrap();
    let jpeg_data = tab.capture_screenshot(
        CaptureScreenshotFormatOption::Jpeg,
        None, 
        None,
        true,
    ).unwrap();
    fs::write("screenshot.jpg", jpeg_data).unwrap();
}

実行すると、Googleの画面のスクショが保存されました。

この調子で東京ガスのCSVを取得できるようにしましょう。

コードはpythonですがここを参考にします。

Python(Selenium)で東京ガスのcsvをダウンロード|gadgetking

実行すると少しエラーは出ますが、ファイルダウンロードは可能になりました。

use headless_chrome::{protocol::cdp::Page::CaptureScreenshotFormatOption, Browser};
use headless_chrome::protocol::cdp::types::Method;
use std::sync::Arc;
use std::{env, thread, time, error::Error};
use std::{fs, path};
use serde::Serialize;

const TOKYO_GAS_LOGIN_URL: &str = "https://members.tokyo-gas.co.jp/login.html";
const GAS_ID: &str = "東京ガスのログインID";
const GAS_PASSWD: &str = "東京ガスのログインパスワード";

// https://github.com/rust-headless-chrome/rust-headless-chrome/issues/187
#[derive(Serialize, Debug)]
struct Command {
    behavior: &'static str,
    downloadPath: &'static str,
}

impl Method for Command {
    const NAME: &'static str = "Page.setDownloadBehavior";
    type ReturnObject = serde_json::Value;
}

fn main() -> Result<(), Box<dyn Error>> {
    let browser = Browser::default().unwrap();
    let tab = browser.new_tab().unwrap();
    tab.set_default_timeout(std::time::Duration::from_secs(200));

    // https://github.com/rust-headless-chrome/rust-headless-chrome/issues/280
    tab.enable_request_interception(Arc::new(|transport, session_id, params| {
        println!("request!");
        println!("transport: {:?}", transport);
        println!("session_id: {:?}", session_id);
        println!("params: {:?}", params);
        headless_chrome::browser::tab::RequestPausedDecision::Continue(None)
    }))?;

    tab.navigate_to(TOKYO_GAS_LOGIN_URL).unwrap();
    tab.wait_until_navigated().unwrap();

    let jpeg_data = tab.capture_screenshot(
        CaptureScreenshotFormatOption::Jpeg,
        None, 
        None,
        true,
    ).unwrap();
    fs::write("screenshot.jpg", jpeg_data).unwrap();

    tab.wait_for_element("#loginId").unwrap().type_into(GAS_ID).unwrap();
    tab.wait_for_element("#password").unwrap().type_into(GAS_PASSWD).unwrap();
    tab.wait_for_element("#submit-btn").unwrap().click().unwrap();
    // tab.wait_until_navigated().unwrap();
    thread::sleep(time::Duration::from_secs(5));
    let jpeg_data = tab.capture_screenshot(
        CaptureScreenshotFormatOption::Jpeg,
        None, 
        None,
        true,
    ).unwrap();
    fs::write("screenshot.jpg", jpeg_data).unwrap();

    let path_string = env::current_dir()?.into_os_string().into_string().unwrap();
    // println!("pwd: {}", path_string);
    let static_path_str: &'static str = Box::leak(path_string.into_boxed_str());

    let command = Command {
        behavior: "allow",
        downloadPath: static_path_str,
    };
    tab.call_method(command)?;

    // CSVファイルをダウンロード
    tab.navigate_to("https://members.tokyo-gas.co.jp/api/mieru/chargeAmountCsv.jsp?no=0&target=total")
        .unwrap();
    thread::sleep(time::Duration::from_secs(30));

    Ok(())
}

実行すると以下のようなエラーが出ます。

CSVファイルダウンロードは成功しますが、navigate_toを使用したファイルダウンロード処理には不備があるようです。

thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Navigate failed: net::ERR_ABORTED', src/main.rs:74:10
stack backtrace:
   0: rust_begin_unwind
             at /rustc/eb26296b556cef10fb713a38f3d16b9886080f26/library/std/src/panicking.rs:593:5
   1: core::panicking::panic_fmt
             at /rustc/eb26296b556cef10fb713a38f3d16b9886080f26/library/core/src/panicking.rs:67:14
   2: core::result::unwrap_failed
             at /rustc/eb26296b556cef10fb713a38f3d16b9886080f26/library/core/src/result.rs:1651:5
   3: core::result::Result<T,E>::unwrap
             at /rustc/eb26296b556cef10fb713a38f3d16b9886080f26/library/core/src/result.rs:1076:23
   4: sync_zaim::main
             at ./src/main.rs:73:5
   5: core::ops::function::FnOnce::call_once
             at /rustc/eb26296b556cef10fb713a38f3d16b9886080f26/library/core/src/ops/function.rs:250:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

とりあえずこのエラーは放置で、先にZaim APIとの連携箇所を実装していきましょう。

Zaim APIとの連携

続けてZaim APIと連携する箇所の実装をしていきましょう。

その前にZaim APIの利用登録はしておきましょう。

Zaim Developers Center

その次に使用するライブラリをインストールしておきましょう。

この時点でのCargo.tomlは以下のとおりです。

[dependencies]
base64 = "0.21.4"
chrono = { version = "0.4.30", features = [ "serde" ]}
csv = "1.2.2"
encoding_rs = "0.8.33"
headless_chrome = { version = "1.0.5", features = [ "fetch" ]}
hmac-sha1 = "0.1.3"
openssl = "0.10.57"
rand = "0.8.5"
reqwest = { version = "0.11.20", features = [ "json" ] }
serde = { version = "1.0.188", features = [ "derive" ]}
serde_json = "1.0.106"
serde_urlencoded = "0.7.1"
tokio = { version = "1.32.0", features = [ "rt", "macros"]}
urlencoding = "2.1.3"

そしてこれが実装です。

(1週間くらいかかった。。)

use headless_chrome::Browser;
use rand::distributions::Alphanumeric;
use rand::{thread_rng, Rng};
use serde::Deserialize;

use base64::{engine::general_purpose, Engine as _};
use openssl::hash::MessageDigest;
use openssl::pkey::PKey;
use openssl::sign::{Signer, Verifier};
use std::collections::HashMap;
use std::error::Error;
use std::vec::Vec;
use std::{fs, thread, time};

const CALLBACK_URI: &str = "http://localhost:8080/";
const REQUEST_TOKEN_URI: &str = "https://api.zaim.net/v2/auth/request";
const AUTHORIZE_URL: &str = "https://auth.zaim.net/users/auth";
const ACCESS_TOKEN_URL: &str = "https://api.zaim.net/v2/auth/access";
const CLIENT_KEY: &str = "zaim apiから発行したコンシューマーキー";
const CLIENT_SECRET: &str = "zaim apiから発行したコンシューマーシークレット";
const ZAIM_ID: &str = "zaimで使用しているID(メールアドレス)";
const ZAIM_PASSWORD: &str = "zaimで使用しているパスワード";

#[derive(Debug, Clone, Deserialize)]
pub struct RequestToken {
    oauth_token: String,
    oauth_token_secret: String,
    oauth_callback_confirmed: bool,
}

#[derive(Debug, Clone, Deserialize)]
pub struct AccessToken {
    oauth_token: String,
    oauth_token_secret: String,
}

/// 署名に使用する文字列の連結に使用します
pub fn signature_base(method: String, url_string: String, parameters_string: String) -> String {
    format!("{}&{}&{}", method, url_string, parameters_string)
}

/// 引数から認証ヘッダーの構築に必要なパラメータを格納したハッシュマップを返却します
fn parameters_hashmap(
    client_key: &str,
    callback_uri: Option<&str>,
    oauth_token: Option<&str>,
    oauth_verifier: Option<&str>,
) -> HashMap<String, String> {
    let rand_string: Vec<u8> = thread_rng().sample_iter(&Alphanumeric).take(30).collect();

    let nonce = String::from_utf8(rand_string).unwrap();

    let mut map: HashMap<String, String> = HashMap::new();
    map.insert("oauth_version".to_string(), "1.0".to_string());
    map.insert(
        "oauth_signature_method".to_string(),
        "HMAC-SHA1".to_string(),
    );
    map.insert("oauth_nonce".to_string(), nonce);
    map.insert(
        "oauth_timestamp".to_string(),
        chrono::Local::now().timestamp().to_string(),
    );
    map.insert("oauth_consumer_key".to_string(), client_key.to_string());
    if callback_uri.is_some() {
        map.insert(
            "oauth_callback".to_string(),
            urlencoding::encode(callback_uri.unwrap()).to_string(),
        );
    }
    if oauth_token.is_some() {
        map.insert("oauth_token".to_string(), oauth_token.unwrap().to_string());
    }
    if oauth_verifier.is_some() {
        map.insert(
            "oauth_verifier".to_string(),
            oauth_verifier.unwrap().to_string(),
        );
    }

    map
}

/// OAuth1の仕様に沿ってハッシュマップを連結した文字列を返却します
fn parameters_string(parameters: HashMap<String, String>) -> String {
    let mut parameters_vec = parameters
        .iter()
        .map(|(k, v)| format!("{}={}", k, v))
        .collect::<Vec<String>>();

    parameters_vec.sort();
    let parameters_str = parameters_vec.join("&");

    urlencoding::encode(parameters_str.as_str()).to_string()
}

/// HMAC-SHA1による暗号化を行う
fn hmac_sha1_sign(key: &[u8], data: &[u8]) -> Result<Vec<u8>, Box<dyn Error>> {
    let pkey = PKey::hmac(key)?;
    let mut signer = Signer::new(MessageDigest::sha1(), &pkey)?;
    signer.update(data)?;
    let signature = signer.sign_to_vec()?;
    Ok(signature)
}

/// HMAC-SHA1の検証を行う
///
/// 現在のところ使用する予定はありません。
fn hmac_sha1_verify(key: &[u8], data: &[u8], signature: &[u8]) -> Result<bool, Box<dyn Error>> {
    let pkey = PKey::hmac(key)?;
    let mut verifier = Verifier::new(MessageDigest::sha1(), &pkey)?;
    verifier.update(data)?;
    let result = verifier.verify(signature)?;
    Ok(result)
}

/// 署名を返却します
fn get_signature(
    client_secret: &str,
    oauth_token_secret: &str,
    method: &str,
    request_token_uri: &str,
    parameters_hashmap: HashMap<String, String>,
) -> String {
    let signature_base_string = signature_base(
        method.to_string(),
        urlencoding::encode(request_token_uri).to_string(),
        parameters_string(parameters_hashmap),
    );

    let key = format!("{}&{}", client_secret, oauth_token_secret);

    let signature = hmac_sha1_sign(key.as_bytes(), signature_base_string.as_bytes()).unwrap();

    urlencoding::encode(general_purpose::STANDARD.encode(signature).as_str()).to_string()
}

/// リクエストトークンの取得処理を行います
async fn get_request_token(
    client_key: &str,
    client_secret: &str,
    callback_uri: &str,
    request_token_url: &str,
) -> Result<RequestToken, Box<dyn std::error::Error>> {
    let parameters_hashmap = parameters_hashmap(client_key, Some(callback_uri), None, None);

    let signature = get_signature(
        client_secret,
        "",     // リクエストトークンの発行時は空文字でOK
        "POST", // ここはPOST、GET、OPTIONが設定可能だけど、基本的にはPOSTを使う。
        // あとここのHTTPメソッドと実際にリクエストする際のメソッドは合わせないとダメ!!
        // 私はここで2日くらい悩んでいた。。。
        request_token_url,
        parameters_hashmap.clone(),
    );

    let mut oauth_paramters = parameters_hashmap
        .iter()
        .map(|(k, v)| format!(r#"{}="{}""#, k, v))
        .collect::<Vec<String>>();

    oauth_paramters.push(format!(r#"oauth_signature="{}""#, signature));
    let authorization_value = format!("OAuth {}", oauth_paramters.join(", "));

    let client = reqwest::Client::new();
    let res = client
        .post(request_token_url)
        .header(reqwest::header::AUTHORIZATION, authorization_value)
        .send()
        .await?;

    Ok(serde_urlencoded::from_str(res.text().await?.as_str()).expect("Failed to deserialize."))
}

/// アクセストークンの取得処理を行います
///
/// 渡されたリクエストトークンからheadless_chromeを使用して認証URIにアクセスし、認証処理を行います
/// その後アクセストークンの発行を行います
async fn get_access_token(
    request_token: RequestToken,
    authorize_uri: &str,
    access_token_uri: &str,
    client_key: &str,
    client_secret: &str,
) -> Result<AccessToken, Box<dyn std::error::Error>> {
    if request_token.oauth_callback_confirmed {
        let authorize_url = format!(
            "{}?oauth_token={}",
            authorize_uri, request_token.oauth_token
        );

        let browser = Browser::default().unwrap();
        let tab = browser.new_tab().unwrap();
        tab.set_default_timeout(std::time::Duration::from_secs(200));

        tab.navigate_to(authorize_url.as_str()).unwrap();
        tab.wait_until_navigated().unwrap();

        tab.wait_for_element("#UserEmail")
            .unwrap()
            .type_into(ZAIM_ID)
            .unwrap();
        tab.wait_for_element("#UserPassword")
            .unwrap()
            .type_into(ZAIM_PASSWORD)
            .unwrap();
        tab.wait_for_element("input[name=agree]")
            .unwrap()
            .click()
            .unwrap();

        thread::sleep(time::Duration::from_secs(1));
    }

    let p_hashmap = parameters_hashmap(
        client_key,
        None,
        Some(request_token.oauth_token.as_str()),
        Some(request_token.oauth_token.as_str()),
    );

    let signature = get_signature(
        client_secret,
        request_token.oauth_token_secret.as_str(),
        "POST",
        access_token_uri,
        p_hashmap.clone(),
    );

    let mut oauth_paramters = p_hashmap
        .iter()
        .map(|(k, v)| format!(r#"{}="{}""#, k, v))
        .collect::<Vec<String>>();

    oauth_paramters.push(format!(r#"oauth_signature="{}""#, signature));
    let authorization_value = format!("OAuth {}", oauth_paramters.join(", "));

    let client = reqwest::Client::new();
    let res = client
        .post(access_token_uri)
        .header(reqwest::header::AUTHORIZATION, authorization_value)
        .send()
        .await?;

    Ok(serde_urlencoded::from_str(res.text().await?.as_str()).expect("Failed to deserialize."))
}

/// アクセストークンから認証ヘッダーを作成します
fn authorization_header(
    access_token: AccessToken,
    url: &str,
    client_key: &str,
    client_secret: &str,
) -> String {
    let mut p_hashmap = parameters_hashmap(
        client_key,
        None,
        Some(access_token.oauth_token.as_str()),
        None,
    );

    let signature = get_signature(
        client_secret,
        access_token.oauth_token_secret.as_str(),
        "GET",
        url,
        p_hashmap.clone(),
    );

    let mut oauth_paramters = p_hashmap
        .iter()
        .map(|(k, v)| format!(r#"{}="{}""#, k, v))
        .collect::<Vec<String>>();

    oauth_paramters.push(format!(r#"oauth_signature="{}""#, signature));
    format!("OAuth {}", oauth_paramters.join(", "))
}

#[derive(Debug, Clone, Deserialize)]
struct CategoryResponse {
    categories: Vec<Category>,
    requested: u32,
}

pub fn deserialize_date<'de, D: serde::Deserializer<'de>>(
    deserializer: D,
) -> Result<chrono::NaiveDateTime, D::Error> {
    let s = String::deserialize(deserializer)?;
    chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S").map_err(serde::de::Error::custom)
}

#[derive(Debug, Clone, Deserialize)]
struct Category {
    id: u32,
    mode: String,
    name: String,
    sort: u16,
    active: u16,
    #[serde(deserialize_with = "deserialize_date")]
    modified: chrono::NaiveDateTime,
    parent_category_id: u32,
    local_id: u32,
}

async fn get_category(
    access_token: AccessToken,
    client_key: &str,
    client_secret: &str,
) -> Result<CategoryResponse, Box<dyn std::error::Error>> {
    let get_category_url = "https://api.zaim.net/v2/home/category";

    let authorization_value =
        authorization_header(access_token, get_category_url, client_key, client_secret);

    let client = reqwest::Client::new();
    let res = client
        .get(get_category_url)
        .header(reqwest::header::AUTHORIZATION, authorization_value)
        .send()
        .await?;

    println!("category status: {}", res.status());

    let category_json: CategoryResponse = res.json().await?;

    Ok(category_json)
}

これでZaimが用意する各エンドポイントにアクセスできるようになります!

まとめ

Zaimの実装でかなり手間取ってしまいました。。

(OAuthに関する知識が抜けていた)

参考にした記事を貼っておきます。

Bash とLinux にあるコマンドでOAuth 1.0 の認証の流れを理解する #Linux - Qiita

Zaim APIとPythonを用いて、ZaimのデータをCSV出力する