pub mod models;
use std::{collections::HashMap, error::Error, fmt::Display, str::FromStr};
use chrono::{SecondsFormat, Utc};
use md5::compute;
use models::{
bookmark_detail::BookmarkDetail,
illust::Illust,
illust_page::IllustPage,
tag::{Tags, TrendTags},
user_detail::UserDetail,
user_illusts::UserIllusts,
user_page::UserPage,
};
use reqwest::{
self,
header::{HeaderMap, CONTENT_TYPE, IF_MODIFIED_SINCE, REFERER},
Client, StatusCode, Url,
};
use serde::Deserialize;
use serde_json::Value;
use time::Instant;
const HASH_SALT: &'static str = "28c1fdd170a5204386cb1313c7077b34f83e4aaf4aa829ce78c231e05b0bae2c";
const BASE_API_URL_HOST: &'static str = "app-api.pixiv.net";
const ACCEPT_LANGUAGE: &'static str = "zh-CN";
#[derive(PartialEq, Default, Eq, Hash, Debug, Copy, Clone)]
pub enum Publicity {
Public,
Private,
#[default]
All,
}
#[derive(PartialEq)]
pub enum IllustType {
Illust,
Manga,
}
#[derive(PartialEq)]
pub enum RankingType {
Day,
Week,
Month,
DayMale,
DayFemale,
WeekOriginal,
WeekRookie,
DayR18,
DayMaleR18,
DayFemaleR18,
WeekR18,
WeekR18g,
}
impl Display for RankingType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
RankingType::Day => "day",
RankingType::Week => "week",
RankingType::Month => "month",
RankingType::DayMale => "day_male",
RankingType::DayFemale => "day_female",
RankingType::WeekOriginal => "week_original",
RankingType::WeekRookie => "week_rookie",
RankingType::DayR18 => "day_r18",
RankingType::DayMaleR18 => "day_male_r18",
RankingType::DayFemaleR18 => "day_female_r18",
RankingType::WeekR18 => "week_r18",
RankingType::WeekR18g => "week_r18g",
})
}
}
impl Display for IllustType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
IllustType::Illust => "illust",
IllustType::Manga => "manga",
})
}
}
impl Display for Publicity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Publicity::Public => "public",
Publicity::Private => "private",
Publicity::All => "all",
})
}
}
#[derive(Clone, Debug)]
pub struct PixivClient {
client: reqwest::Client,
base_url: Url,
refresh_token: String,
auth_token: Option<String>,
last_refresh: Instant,
}
#[derive(Debug)]
pub struct AuthError {
reason: String,
}
#[derive(Debug)]
pub struct DownloadError {
reason: String,
}
impl PixivClient {
pub fn from_refresh_token(token: &str) -> Self {
let time = Self::get_iso_date();
let mut headers = HeaderMap::new();
headers.insert("X-Client-Time", time.parse().unwrap());
headers.insert(
"X-Client-Hash",
Self::get_hash(&(time + HASH_SALT)).parse().unwrap(),
);
headers.insert(
"User-Agent",
"PixivAndroidApp/5.0.155 (Android 10.0; Pixel C)"
.parse()
.unwrap(),
);
headers.insert("Accept-Language", ACCEPT_LANGUAGE.parse().unwrap());
headers.insert("App-OS", "Android".parse().unwrap());
headers.insert("App-OS-Version", "Android 10.0".parse().unwrap());
headers.insert("App-Version", "5.0.166".parse().unwrap());
headers.insert("Host", BASE_API_URL_HOST.parse().unwrap());
let client_builder = Client::builder()
.danger_accept_invalid_certs(true)
.default_headers(headers);
let client = client_builder.build().unwrap();
PixivClient {
client,
base_url: Url::from_str("https://210.140.131.199").unwrap(),
refresh_token: token.to_string(),
auth_token: None,
last_refresh: Instant::now(),
}
}
fn get_iso_date() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Millis, false)
}
fn get_hash(s: &str) -> String {
format!("{:?}", compute(s.as_bytes()))
}
pub async fn refresh_if_needed(&mut self) -> Result<(), AuthError> {
if self.need_refresh() {
self.refresh_auth().await?
}
Ok(())
}
pub fn need_refresh(&self) -> bool {
if self.last_refresh.elapsed().whole_hours() >= 1 || self.auth_token.is_none() {
true
} else {
false
}
}
pub async fn refresh_auth(&mut self) -> Result<(), AuthError> {
const CLIENT_ID: &str = "MOBrBDS8blbauoSck0ZfDbtuzpyT";
const CLIENT_SECRET: &str = "lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj";
let mut data = HashMap::new();
data.insert("client_id", CLIENT_ID);
data.insert("client_secret", CLIENT_SECRET);
data.insert("get_secure_url", "1");
data.insert("grant_type", "refresh_token");
data.insert("refresh_token", &self.refresh_token);
let client = reqwest::Client::new();
let Ok(res) = client
.post("https://oauth.secure.pixiv.net/auth/token")
.form(&data)
.send()
.await else {
return Err(AuthError { reason: "Bad request!".to_string() })
};
match res.status() {
StatusCode::OK | StatusCode::MOVED_PERMANENTLY | StatusCode::FOUND => {
}
s => {
return Err(AuthError {
reason: format!("Login failed. Check your refresh token. Response: {:?}", s),
})
}
}
let mut json_response: Value = res.json().await.unwrap();
match json_response["response"]["access_token"].take() {
Value::String(s) => {
self.auth_token = Some(s);
}
_ => panic!("Failed to get access token."),
}
self.last_refresh = Instant::now();
Ok(())
}
pub async fn get_recommend(&self) -> Result<IllustPage, reqwest::Error> {
let url = self
.base_url
.join("/v1/illust/recommended?filter=for_ios&include_ranking_label=true")
.unwrap();
let resp = self
.client
.get(url)
.header(
"Authorization",
format!("Bearer {}", self.auth_token.as_ref().unwrap()),
)
.send()
.await?;
Ok(resp.json().await?)
}
pub async fn get_illust_page_by_url(&self, url: &str) -> Result<IllustPage, reqwest::Error> {
let resp = self
.client
.get(url)
.header(
"Authorization",
format!("Bearer {}", self.auth_token.as_ref().unwrap()),
)
.send()
.await?;
Ok(resp.json().await?)
}
pub async fn get_user_recommended(&self) -> Result<UserPage, reqwest::Error> {
let url = self
.base_url
.join("/v1/user/recommended?filter=for_android")
.unwrap();
let resp = self
.client
.get(url)
.header(
"Authorization",
format!("Bearer {}", self.auth_token.as_ref().unwrap()),
)
.send()
.await?;
Ok(resp.json().await?)
}
pub async fn get_user(&self, user_id: u32) -> Result<UserDetail, reqwest::Error> {
let mut url = self.base_url.join("/v1/user/detail").unwrap();
url.set_query(Some(&format!("user_id={user_id}&filter=for_android")));
let resp = self
.client
.get(url)
.header(
"Authorization",
format!("Bearer {}", self.auth_token.as_ref().unwrap()),
)
.send()
.await?;
Ok(resp.json().await?)
}
pub async fn get_user_ilusts(
&self,
user_id: u32,
illust_type: IllustType,
) -> Result<UserIllusts, reqwest::Error> {
let mut url = self.base_url.join("v1/user/illusts").unwrap();
url.set_query(Some(&format!(
"user_id={user_id}&type={illust_type}&filter=for_android"
)));
let resp = self
.client
.get(url)
.header(
"Authorization",
format!("Bearer {}", self.auth_token.as_ref().unwrap()),
)
.send()
.await?;
Ok(resp.json().await?)
}
pub async fn get_user_ilusts_offset(
&self,
user_id: u32,
illust_type: IllustType,
offset: u32,
) -> Result<UserIllusts, reqwest::Error> {
let mut url = self.base_url.join("v1/user/illusts").unwrap();
url.set_query(Some(&format!(
"user_id={user_id}&type={illust_type}&offset={offset}&filter=for_android"
)));
let resp = self
.client
.get(url)
.header(
"Authorization",
format!("Bearer {}", self.auth_token.as_ref().unwrap()),
)
.send()
.await?;
Ok(resp.json().await?)
}
pub async fn post_like_illust(
&self,
illust_id: u32,
restrict: Publicity,
tags: Vec<&str>,
) -> Result<bool, reqwest::Error> {
let url = self.base_url.join("/v2/illust/bookmark/add").unwrap();
let mut form = HashMap::new();
form.insert("illust_id", illust_id.to_string());
form.insert("restrict", restrict.to_string());
if !tags.is_empty() {
let tag_string = tags
.into_iter()
.map(|s| s.trim())
.collect::<Vec<&str>>()
.join(" ");
form.insert("tags[]", tag_string);
}
let resp = self
.client
.post(url)
.header(
"Authorization",
format!("Bearer {}", self.auth_token.as_ref().unwrap()),
)
.form(&form)
.send()
.await?;
if resp.text().await? == "{}" {
return Ok(true);
}
Ok(false)
}
pub async fn post_unlike_illust(&self, illust_id: u32) -> Result<bool, reqwest::Error> {
let url = self.base_url.join("v1/illust/bookmark/delete").unwrap();
let mut form = HashMap::new();
form.insert("illust_id", illust_id.to_string());
let resp = self
.client
.post(url)
.header(
"Authorization",
format!("Bearer {}", self.auth_token.as_ref().unwrap()),
)
.form(&form)
.send()
.await?;
if resp.text().await? == "{}" {
return Ok(true);
}
Ok(false)
}
pub async fn get_unlike_illust(&self, illust_id: u32) -> Result<Illust, reqwest::Error> {
let mut url = self.base_url.join("/v1/illust/bookmark/delete").unwrap();
url.set_query(Some(&format!("illust_id={illust_id}")));
let resp = self
.client
.get(url)
.header(
"Authorization",
format!("Bearer {}", self.auth_token.as_ref().unwrap()),
)
.send()
.await?;
Ok(resp.json().await?)
}
pub async fn get_illust_ranking(
&self,
mode: RankingType,
date: &str,
) -> Result<IllustPage, reqwest::Error> {
let mut url = self.base_url.join("/v1/illust/ranking").unwrap();
url.set_query(Some(&format!("mode={mode}&date={date}&filter=for_android")));
let resp = self
.client
.get(url)
.header(
"Authorization",
format!("Bearer {}", self.auth_token.as_ref().unwrap()),
)
.send()
.await?;
Ok(resp.json().await?)
}
pub async fn get_bookmarks_illust(
&self,
user_id: u32,
restrict: Publicity,
tag: Option<&str>,
) -> Result<IllustPage, reqwest::Error> {
let mut url = self.base_url.join("/v1/user/bookmarks/illust").unwrap();
let mut query = format!("user_id={user_id}&restrict={restrict}");
if let Some(tag) = tag {
query += &format!("tag={}", tag);
}
url.set_query(Some(&query));
let resp = self
.client
.get(url)
.header(
"Authorization",
format!("Bearer {}", self.auth_token.as_ref().unwrap()),
)
.send()
.await?;
Ok(resp.json().await?)
}
pub async fn post_unfollow_user(&self, user_id: u32) -> Result<bool, reqwest::Error> {
let url = self.base_url.join("/v1/user/follow/delete").unwrap();
let mut form = HashMap::new();
form.insert("user_id", user_id.to_string());
let resp = self
.client
.post(url)
.header(
"Authorization",
format!("Bearer {}", self.auth_token.as_ref().unwrap()),
)
.form(&form)
.send()
.await?;
if resp.text().await? == "{}" {
return Ok(true);
}
Ok(false)
}
pub async fn post_follow_user(
&self,
user_id: u32,
restrict: Publicity,
) -> Result<bool, reqwest::Error> {
let url = self.base_url.join("/v1/user/follow/delete").unwrap();
let mut form = HashMap::new();
form.insert("user_id", user_id.to_string());
form.insert("restrict", restrict.to_string());
let resp = self
.client
.post(url)
.header(
"Authorization",
format!("Bearer {}", self.auth_token.as_ref().unwrap()),
)
.form(&form)
.send()
.await?;
if resp.text().await? == "{}" {
return Ok(true);
}
Ok(false)
}
pub async fn get_follow_user(
&self,
user_id: u32,
restrict: Publicity,
) -> Result<UserPage, reqwest::Error> {
let mut url = self.base_url.join("/v1/user/follower").unwrap();
url.set_query(Some(&format!(
"restrict={restrict}&user_id={user_id}&filter=for_android"
)));
let resp = self
.client
.get(url)
.header(
"Authorization",
format!("Bearer {}", self.auth_token.as_ref().unwrap()),
)
.send()
.await?;
Ok(resp.json().await?)
}
pub async fn get_follow_illusts(
&self,
restrict: Publicity,
) -> Result<IllustPage, reqwest::Error> {
let mut url = self.base_url.join("/v2/illust/follow").unwrap();
url.set_query(Some(&format!("restrict={restrict}")));
let resp = self
.client
.get(url)
.header(
"Authorization",
format!("Bearer {}", self.auth_token.as_ref().unwrap()),
)
.send()
.await?;
Ok(resp.json().await?)
}
pub async fn get_user_following(
&self,
user_id: u32,
restrict: Publicity,
) -> Result<UserPage, reqwest::Error> {
let mut url = self.base_url.join("/v1/user/following").unwrap();
url.set_query(Some(&format!(
"restrict={restrict}&user_id={user_id}&filter=for_android"
)));
let resp = self
.client
.get(url)
.header(
"Authorization",
format!("Bearer {}", self.auth_token.as_ref().unwrap()),
)
.send()
.await?;
Ok(resp.json().await?)
}
pub async fn get_search_auto_complete_keywords(
&self,
word: &str,
) -> Result<Tags, reqwest::Error> {
let mut url = self.base_url.join("/v2/search/autocomplete").unwrap();
url.set_query(Some(&format!(
"word={word}&merge_plain_keyword_results=true"
)));
let resp = self
.client
.get(url)
.header(
"Authorization",
format!("Bearer {}", self.auth_token.as_ref().unwrap()),
)
.send()
.await?;
Ok(resp.json().await?)
}
pub async fn get_illust_trend_tags(&self) -> Result<TrendTags, reqwest::Error> {
let url = self
.base_url
.join("/v1/trending-tags/illust?filter=for_android")
.unwrap();
let resp = self
.client
.get(url)
.header(
"Authorization",
format!("Bearer {}", self.auth_token.as_ref().unwrap()),
)
.send()
.await?;
Ok(resp.json().await?)
}
pub async fn get_search_illust(
&self,
word: &str,
sort: Option<&str>,
search_target: Option<&str>,
start_date: Option<&str>,
end_date: Option<&str>,
bookmark_num: Option<u32>,
) -> Result<IllustPage, reqwest::Error> {
let mut url = self.base_url.join("/v1/search/illust").unwrap();
let mut query = format!("word={word}&filter=for_android&merge_plain_keyword_results=true");
if let Some(sort) = sort {
query += &format!("sort={}", sort);
}
if let Some(search_target) = search_target {
query += &format!("search_target={}", search_target);
}
if let Some(start_date) = start_date {
query += &format!("start_date={}", start_date);
}
if let Some(end_date) = end_date {
query += &format!("end_date={}", end_date);
}
if let Some(bookmark_num) = bookmark_num {
query += &format!("bookmark_num={}", bookmark_num);
}
url.set_query(Some(&query));
let resp = self
.client
.get(url)
.header(
"Authorization",
format!("Bearer {}", self.auth_token.as_ref().unwrap()),
)
.send()
.await?;
Ok(resp.json().await?)
}
pub async fn get_search_user(&self, word: &str) -> Result<UserPage, reqwest::Error> {
let mut url = self.base_url.join("/v1/search/user").unwrap();
url.set_query(Some(&format!("word={word}&filter=for_android")));
let resp = self
.client
.get(url)
.header(
"Authorization",
format!("Bearer {}", self.auth_token.as_ref().unwrap()),
)
.send()
.await?;
Ok(resp.json().await?)
}
pub async fn get_search_autocomplete(&self, word: &str) -> Result<Tags, reqwest::Error> {
let mut url = self.base_url.join("/v2/search/autocomplete").unwrap();
url.set_query(Some(&format!(
"word={word}&merge_plain_keyword_results=true"
)));
let resp = self
.client
.get(url)
.header(
"Authorization",
format!("Bearer {}", self.auth_token.as_ref().unwrap()),
)
.send()
.await?;
Ok(resp.json().await?)
}
pub async fn get_illust_related(&self, illust_id: u32) -> Result<IllustPage, reqwest::Error> {
let mut url = self.base_url.join("/v2/illust/related").unwrap();
url.set_query(Some(&format!("illust_id={illust_id}&filter=for_android")));
let resp = self
.client
.get(url)
.header(
"Authorization",
format!("Bearer {}", self.auth_token.as_ref().unwrap()),
)
.send()
.await?;
Ok(resp.json().await?)
}
pub async fn get_illust_bookmark_detail(
&self,
illust_id: u32,
) -> Result<BookmarkDetail, reqwest::Error> {
let mut url = self.base_url.join("v2/illust/bookmark/detail").unwrap();
url.set_query(Some(&format!("illust_id={illust_id}")));
let resp = self
.client
.get(url)
.header(
"Authorization",
format!("Bearer {}", self.auth_token.as_ref().unwrap()),
)
.send()
.await?;
#[derive(Deserialize)]
struct A {
bookmark_detail: BookmarkDetail,
}
let a: A = resp.json().await?;
Ok(a.bookmark_detail)
}
pub async fn get_illust_detail(&self, illust_id: u32) -> Result<Illust, reqwest::Error> {
let mut url = self.base_url.join("/v1/illust/detail").unwrap();
url.set_query(Some(&format!("illust_id={illust_id}&filter=for_android")));
let resp = self
.client
.get(url)
.header(
"Authorization",
format!("Bearer {}", self.auth_token.as_ref().unwrap()),
)
.send()
.await?;
#[derive(Deserialize)]
struct A {
illust: Illust,
}
let a: A = resp.json().await?;
Ok(a.illust)
}
pub async fn download_image(image_url: &str) -> anyhow::Result<bytes::Bytes> {
let image_url = image_url.replace("i.pximg.net", "i-cf.pximg.net");
let client = reqwest::Client::new();
let resp = client
.get(image_url)
.header(REFERER, "https://www.pixiv.net/")
.header(IF_MODIFIED_SINCE, "Anything date")
.send()
.await?;
let Some(content_type) = resp.headers().get(CONTENT_TYPE) else {
return Err(DownloadError {
reason: "Response don't contains Content-Type header!".to_string()
})?;
};
let content_type = content_type.to_str().unwrap();
if content_type.contains("image/jpeg") || content_type.contains("image/png") {
Ok(resp.bytes().await?)
} else {
Err(DownloadError {
reason: "Don't image in jpeg or png formats!".to_string(),
})?
}
}
}
impl Error for AuthError {
fn description(&self) -> &str {
"An error occurred while trying to authenticate."
}
}
impl std::fmt::Display for AuthError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(
f,
"An error occurred while trying to authenticate. Reason: {:?}",
self.reason
)
}
}
impl Error for DownloadError {
fn description(&self) -> &str {
"An error occurred while trying to download image."
}
}
impl std::fmt::Display for DownloadError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(
f,
"An error occurred while trying to download image. Reason: {:?}",
self.reason
)
}
}
#[cfg(test)]
mod tests {
use crate::PixivClient;
#[test]
fn test() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let mut client = PixivClient::from_refresh_token(env!("REFRESH_TOKEN"));
client.refresh_auth().await.unwrap();
let resp = client.get_recommend().await.unwrap();
println!("{:#?}", resp);
});
}
}