libpixiv 0.1.1

pixiv.net API client library
Documentation
use chrono::Local;
use reqwest::{
    header::{HeaderName, AUTHORIZATION, CONTENT_TYPE, USER_AGENT},
    IntoUrl, Method, RequestBuilder, StatusCode,
};
use serde::Deserialize;
use serde_json::Value;
use std::error::Error;
use std::sync::Arc;
use tokio::sync::Mutex;

use crate::PixivAppError;

pub struct PixivAppClient {
    /// bearer token
    pub(crate) access_token: Arc<Mutex<String>>,
    pub(crate) refresh_token: Arc<Mutex<String>>,
    pub(crate) http_client: reqwest::Client,
    pub(crate) host: String,
    pub(crate) platform: params::Platform,
}

pub mod params {
    use std::fmt::{Display, Formatter, Result};

    pub enum Platform {
        IOS,
        Android,
    }

    impl Display for Platform {
        fn fmt(&self, f: &mut Formatter<'_>) -> Result {
            write!(
                f,
                "{}",
                match self {
                    Self::IOS => "ios",
                    Self::Android => "android",
                }
            )
        }
    }

    pub enum Visibility {
        Public,
        Private,
    }

    impl Display for Visibility {
        fn fmt(&self, f: &mut Formatter) -> Result {
            write!(
                f,
                "{}",
                match self {
                    Self::Public => String::from("public"),
                    Self::Private => String::from("private"),
                }
            )
        }
    }

    pub enum SortMode {
        DateAscending,
        DateDescending,
        PopularDescending,
    }

    impl Display for SortMode {
        fn fmt(&self, f: &mut Formatter) -> Result {
            write!(
                f,
                "{}",
                match self {
                    Self::DateAscending => String::from("date_asc"),
                    Self::DateDescending => String::from("date_desc"),
                    Self::PopularDescending => String::from("popular_desc"),
                }
            )
        }
    }

    pub enum SearchMode {
        PartialMatchForTags,
        ExactMatchForTags,
        TitleAndCaption,
    }

    impl Display for SearchMode {
        fn fmt(&self, f: &mut Formatter<'_>) -> Result {
            write!(
                f,
                "{}",
                match self {
                    Self::PartialMatchForTags => String::from("partial_match_for_tags"),
                    Self::ExactMatchForTags => String::from("exact_match_for_tags"),
                    Self::TitleAndCaption => String::from("title_and_caption"),
                }
            )
        }
    }

    pub enum RankingMode {
        Day,
        Week,
        Month,
    }

    impl Display for RankingMode {
        fn fmt(&self, f: &mut Formatter<'_>) -> Result {
            write!(
                f,
                "{}",
                match self {
                    Self::Day => String::from("day"),
                    Self::Week => String::from("week"),
                    Self::Month => String::from("month"),
                }
            )
        }
    }
}

impl PixivAppClient {
    pub fn new(token: String) -> Self {
        Self {
            access_token: Arc::new(Mutex::new(String::new())),
            refresh_token: Arc::new(Mutex::new(token)),
            http_client: reqwest::Client::new(),
            host: String::from("https://app-api.pixiv.net"),
            platform: params::Platform::IOS,
        }
    }

    fn md5(input: &str) -> String {
        let result = md5::compute(input);
        format!("{:02x}", result)
    }

    pub async fn refresh_token(&mut self) {
        let time = Local::now().format("%y-%m-%dT%H:%m:%s+00:00");
        let time_str = format!("{}", time);
        let cloned_refresh_token = Arc::clone(&self.refresh_token);
        let cloned_refresh_token_str = &cloned_refresh_token.lock().await;

        let client_id = "MOBrBDS8blbauoSck0ZfDbtuzpyT";
        let client_secret = "lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj";
        let hash_input = format!(
            "{}{}\n",
            &time_str, "28c1fdd170a5204386cb1313c7077b34f83e4aaf4aa829ce78c231e05b0bae2c"
        );
        let hash = PixivAppClient::md5(hash_input.as_str());

        let req = self.http_client
            .post("https://oauth.secure.pixiv.net/auth/token")
            .header(CONTENT_TYPE, "application/x-www-form-urlencoded")
            .header(USER_AGENT, "PixivIOSApp/7.6.2 (iOS 12.2; iPhone9,1)")
            .header(HeaderName::from_lowercase(b"x-client-time").unwrap(), &time_str)
            .header(HeaderName::from_lowercase(b"x-client-hash").unwrap(), hash)
            .body(format!("grant_type=refresh_token&client_id={}&refresh_token={}&client_secret={}&get_secure_url=1", client_id, cloned_refresh_token_str, client_secret))
            .build()
            .expect("failed to build login request");

        let r = match self.http_client.execute(req).await {
            Ok(r) => r.text().await.unwrap(),
            Err(_e) => return,
        };

        if cfg!(debug_assertions) {
            eprintln!("{}", r);
        }

        let d: Value = serde_json::from_str(&r).unwrap();

        assert!(!d["response"]["access_token"].is_null());
        assert!(!d["response"]["refresh_token"].is_null());

        self.access_token = Arc::new(Mutex::new(String::from(
            d["response"]["access_token"].as_str().unwrap(),
        )));
        self.refresh_token = Arc::new(Mutex::new(String::from(
            d["response"]["refresh_token"].as_str().unwrap(),
        )));
    }

    pub(crate) async fn auth_request<U: IntoUrl>(&self, method: Method, url: U) -> RequestBuilder {
        self.http_client
            .request(method, url)
            .header(
                AUTHORIZATION,
                format!("Bearer {}", self.access_token.lock().await),
            )
            .header(HeaderName::from_lowercase(b"app-os").unwrap(), "ios")
            .header(
                HeaderName::from_lowercase(b"app-os-version").unwrap(),
                "12.2",
            )
            .header(HeaderName::from_lowercase(b"app-version").unwrap(), "7.6.2")
            .header(USER_AGENT, "PixivIOSApp/7.6.2 (iOS 12.2; iPhone9,1)")
    }

    pub(crate) async fn process_request<T: for<'de> Deserialize<'de>>(
        &self,
        reqb: RequestBuilder,
    ) -> Result<T, Box<dyn Error + Send + Sync>> {
        Ok(reqb
            .send()
            .await
            .map_or_else(
                |e| Err::<reqwest::Response, Box<dyn Error + Send + Sync>>(Box::new(e)),
                |r| match r.status() {
                    x if StatusCode::is_success(&x) || StatusCode::is_redirection(&x) => Ok(r),
                    x if StatusCode::is_client_error(&x) => Err(Box::new(match x {
                        StatusCode::BAD_REQUEST => PixivAppError::RequestFailed,
                        StatusCode::UNAUTHORIZED => PixivAppError::MissingLogin,
                        StatusCode::NOT_FOUND => PixivAppError::TargetNotFound,
                        StatusCode::TOO_MANY_REQUESTS => PixivAppError::RateLimitReached,
                        s => PixivAppError::UnhandledStatus(s),
                    })),
                    x => Err(Box::new(PixivAppError::UnhandledStatus(x))),
                },
            )?
            .text()
            .await
            .and_then(|r| {
                Ok(async move {
                    let text = r;
                    if cfg!(debug_assertions) {
                        eprintln!("{}", text);
                    }
                    let d = &mut serde_json::Deserializer::from_str(&text);
                    let r = serde_path_to_error::deserialize(d);
                    r
                })
            })
            .map_err(Box::new)?
            .await?)
    }
}

#[cfg(test)]
pub mod tests {
    use super::*;

    pub static USER: &str = "Aio";
    pub static USER_ID: u32 = 25308802;
    pub static ILLUST_ID: u32 = 132610892;

    pub async fn get_client() -> PixivAppClient {
        let mut client = PixivAppClient::new(env!("PIXIV_REFRESH_TOKEN").to_string());
        client.refresh_token().await;
        client
    }
}