├── .github └── FUNDING.yml ├── .gitignore ├── Cargo.toml ├── Dockerfile ├── GOODBYE.md ├── LICENSE ├── README.md ├── sample.env ├── src ├── api │ ├── info.rs │ └── mod.rs ├── auth.rs ├── embed.rs ├── helper.rs ├── main.rs ├── oembed.rs ├── pixiv │ ├── mod.rs │ └── model.rs ├── proxy.rs └── state.rs └── templates └── artwork.html /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: hazelthewitch 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | *.bak 4 | commands.txt 5 | .env 6 | .vscode 7 | 8 | *.service -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "phixiv" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | anyhow = "1" 8 | askama = "0.12" 9 | axum = { version = "0.6", features = ["original-uri", "headers", "macros"] } 10 | # bytes = "1.4.0" 11 | dotenvy = "0.15" 12 | http = "0.2" 13 | isbot = "0.1" 14 | itertools = "0.11.0" 15 | reqwest = { version = "0.11", features = ["json", "stream"] } 16 | serde = { version = "1", features = ["derive"] } 17 | serde_json = "1.0" 18 | tokio = { version = "1", features = ["full"] } 19 | tower = "0.4" 20 | tower-http = { version = "0.4", features = ["trace", "normalize-path"] } 21 | tracing = { version = "0.1", features = ["log"] } 22 | # tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "env-filter"] } 23 | # url = "2" 24 | # urlencoding = "2.1.2" 25 | tracing-loki = "0.2" 26 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 27 | url = "2" 28 | urlencoding = "2.1.3" 29 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust:latest as builder 2 | 3 | WORKDIR /usr/src/phixiv 4 | COPY . . 5 | RUN cargo install --path . 6 | 7 | FROM debian:bookworm-slim 8 | 9 | RUN apt-get update && apt-get install -y openssl ca-certificates 10 | 11 | COPY --from=builder /usr/local/cargo/bin/phixiv /usr/local/bin/phixiv 12 | 13 | CMD [ "phixiv" ] -------------------------------------------------------------------------------- /GOODBYE.md: -------------------------------------------------------------------------------- 1 | # Regarding Phixiv 2 | 3 | **UPDATE: hosting has been transfered and the repo going forward can be visited at https://github.com/thelaao/phixiv** 4 | 5 | I will be discontinuing service to phixiv on December 30, 2023. 6 | 7 | This project has become a bit too much for me, the hosting costs are too much to justify for a side project. 8 | It has stopped being a fun programming project and has turned into a nightmare of managing infrastructure and comparing hosting platforms. 9 | The code hosted here will remain available to anyone who wishes to expand on it or host it themselves. 10 | 11 | The main cost of hosting is egress traffic, since pixiv requires I proxy the images phixiv is transfering roughly 20-30 gb of data a day. 12 | 13 | If you are interested in picking up hosting please contact me via Discord at `harlot` or just make an issue in this repo, you'll probably need help getting it running 14 | properly, a couple environment variables to set. 15 | I will gladly change DNS records and eventually transfer the domains over if that is desired. 16 | Currently the registration expires in December 2024. 17 | 18 | I am grateful for everyone who has used this service in the last year and I hope someone else creates a similar project. I hope it was useful. 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Hazel Rella 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # phixiv 2 | 3 | ## [PHIXIV NOW LIVES HERE](https://github.com/thelaao/phixiv) 4 | 5 | [pixiv](https://www.pixiv.net/) embed fixer. If you run into any issues or have any suggestions to make this service better, please [make an issue](https://github.com/HazelTheWitch/phixiv/issues/new). 6 | 7 | ## How to use 8 | 9 | Replace "pixiv" with "phixiv" in the url to embed properly on Discord, etc. Alternatively, if on discord you can also paste the pixiv url and send `s/i/p` after, this will edit the previous message, replacing `pixiv` with `ppxiv` which will also embed properly; please note this will require the link to include the first `i` in your message. 10 | 11 | Additionally, when embedding a post with multiple images, add `/` to the end of the link to embed that image. 12 | 13 | ## Path Formats 14 | 15 | The following are the valid paths for artworks, if there is a format which isn't listed which should be embedded, please [make an issue](https://github.com/HazelTheWitch/phixiv/issues/new). 16 | 17 | ```text 18 | /artworks/:id 19 | /:language/artworks/:id 20 | /artworks/:id/:index 21 | /:language/artworks/:id/:index 22 | /member_illust.php?illust_id=:id 23 | ``` 24 | 25 | A simple API for basic information such as tags and direct image links is provided. 26 | 27 | ```text 28 | /api/info?id=&language= 29 | ``` 30 | -------------------------------------------------------------------------------- /sample.env: -------------------------------------------------------------------------------- 1 | PIXIV_REFRESH_TOKEN= 2 | RUST_LOG=info 3 | BOT_FILTERING=false 4 | LOKI_URL= 5 | ENVIRONMENT=production 6 | PROVIDER_NAME=phixiv 7 | PROVIDER_URL=https://github.com/HazelTheWitch/phixiv 8 | -------------------------------------------------------------------------------- /src/api/info.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use axum::{ 4 | extract::{Host, Query, State}, 5 | Json, 6 | }; 7 | use serde::Deserialize; 8 | use tokio::sync::RwLock; 9 | 10 | use crate::{helper::PhixivError, pixiv::ArtworkListing, state::PhixivState}; 11 | 12 | #[derive(Deserialize)] 13 | pub struct ArtworkInfoPath { 14 | pub language: Option, 15 | pub id: String, 16 | } 17 | 18 | pub(super) async fn artwork_info_handler( 19 | State(state): State>>, 20 | Query(path): Query, 21 | Host(host): Host, 22 | ) -> Result, PhixivError> { 23 | let state = state.read().await; 24 | 25 | Ok(Json( 26 | ArtworkListing::get_listing( 27 | path.language, 28 | path.id, 29 | &state.auth.access_token, 30 | &host, 31 | &state.client, 32 | ) 33 | .await?, 34 | )) 35 | } 36 | -------------------------------------------------------------------------------- /src/api/mod.rs: -------------------------------------------------------------------------------- 1 | mod info; 2 | 3 | use std::sync::Arc; 4 | 5 | use axum::{middleware, routing::get, Router}; 6 | use tokio::sync::RwLock; 7 | 8 | use crate::state::{authorized_middleware, PhixivState}; 9 | 10 | use self::info::artwork_info_handler; 11 | 12 | pub fn api_router(state: Arc>) -> Router>> { 13 | Router::new() 14 | .route("/info", get(artwork_info_handler)) 15 | .layer(middleware::from_fn_with_state( 16 | state.clone(), 17 | authorized_middleware, 18 | )) 19 | } 20 | -------------------------------------------------------------------------------- /src/auth.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashMap, 3 | time::{Duration, Instant}, 4 | }; 5 | 6 | use http::StatusCode; 7 | use reqwest::Client; 8 | use serde::Deserialize; 9 | 10 | use crate::helper; 11 | 12 | /// Token lifetime, actually 3600 seconds, but using 3500 to be safe 13 | const TOKEN_DURATION: u64 = 3500; 14 | 15 | const CLIENT_ID: &str = "MOBrBDS8blbauoSck0ZfDbtuzpyT"; 16 | const CLIENT_SECRET: &str = "lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj"; 17 | 18 | #[derive(Debug, Deserialize)] 19 | struct AuthPayload { 20 | pub response: AuthResponse, 21 | } 22 | 23 | #[derive(Debug, Deserialize)] 24 | struct AuthResponse { 25 | pub access_token: String, 26 | pub refresh_token: String, 27 | } 28 | 29 | /// Pixiv authorization state manager, holds and manages refreshing access tokens for authorization. 30 | #[derive(Clone)] 31 | pub struct PixivAuth { 32 | pub access_token: String, 33 | refresh_token: String, 34 | expires_after: Instant, 35 | } 36 | 37 | impl PixivAuth { 38 | async fn authorize(client: &Client, refresh_token: &String) -> anyhow::Result { 39 | let form_data = HashMap::from([ 40 | ("client_id", CLIENT_ID), 41 | ("client_secret", CLIENT_SECRET), 42 | ("get_secure_url", "1"), 43 | ("refresh_token", refresh_token), 44 | ("grant_type", "refresh_token"), 45 | ]); 46 | 47 | let auth_response = client 48 | .post("https://oauth.secure.pixiv.net/auth/token") 49 | .headers(helper::headers()) 50 | .form(&form_data) 51 | .send() 52 | .await?; 53 | 54 | match auth_response.status() { 55 | StatusCode::OK | StatusCode::MOVED_PERMANENTLY | StatusCode::FOUND => {} 56 | s => { 57 | anyhow::bail!("invalid credentials, status code {s}") 58 | } 59 | } 60 | 61 | Ok(auth_response.json::().await?.response) 62 | } 63 | 64 | pub fn expired(&self) -> bool { 65 | Instant::now() > self.expires_after 66 | } 67 | 68 | pub async fn login(client: &Client, refresh_token: String) -> anyhow::Result { 69 | let response = Self::authorize(client, &refresh_token).await?; 70 | 71 | Ok(Self { 72 | access_token: response.access_token, 73 | refresh_token: response.refresh_token, 74 | expires_after: Instant::now() + Duration::from_secs(TOKEN_DURATION), 75 | }) 76 | } 77 | 78 | pub async fn refresh(&mut self, client: &Client) -> anyhow::Result<()> { 79 | let response = Self::authorize(client, &self.refresh_token).await?; 80 | 81 | self.access_token = response.access_token; 82 | self.refresh_token = response.refresh_token; 83 | self.expires_after = Instant::now() + Duration::from_secs(TOKEN_DURATION); 84 | 85 | Ok(()) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/embed.rs: -------------------------------------------------------------------------------- 1 | use std::{env, sync::Arc}; 2 | 3 | use askama::Template; 4 | use axum::{ 5 | extract::{Host, OriginalUri, Path, Query, State}, 6 | headers::{CacheControl, UserAgent}, 7 | middleware, 8 | response::{Html, IntoResponse, Redirect, Response}, 9 | routing::get, 10 | Router, TypedHeader, 11 | }; 12 | use http::Uri; 13 | use serde::Deserialize; 14 | use tokio::sync::RwLock; 15 | 16 | use crate::{ 17 | helper::PhixivError, 18 | pixiv::{ArtworkListing, ArtworkPath, RawArtworkPath}, 19 | state::{authorized_middleware, PhixivState}, 20 | }; 21 | 22 | async fn artwork_response( 23 | raw_path: RawArtworkPath, 24 | state: Arc>, 25 | host: String, 26 | ) -> anyhow::Result { 27 | let path: ArtworkPath = raw_path.try_into()?; 28 | 29 | let state = state.read().await; 30 | 31 | let listing = ArtworkListing::get_listing( 32 | path.language, 33 | path.id, 34 | &state.auth.access_token, 35 | &host, 36 | &state.client, 37 | ) 38 | .await?; 39 | 40 | let artwork = listing.to_template(path.image_index, host); 41 | 42 | Ok(( 43 | TypedHeader(CacheControl::new().with_no_cache()), 44 | Html(artwork.render()?), 45 | ) 46 | .into_response()) 47 | } 48 | 49 | async fn artwork_handler( 50 | Path(path): Path, 51 | State(state): State>>, 52 | TypedHeader(user_agent): TypedHeader, 53 | Host(host): Host, 54 | ) -> Result { 55 | if let Some(resp) = filter_bots(user_agent, &path) { 56 | return Ok(resp); 57 | } 58 | 59 | Ok(artwork_response(path, state, host).await?) 60 | } 61 | 62 | #[derive(Deserialize)] 63 | struct MemberIllustParams { 64 | pub illust_id: String, 65 | } 66 | 67 | impl From for RawArtworkPath { 68 | fn from(params: MemberIllustParams) -> Self { 69 | Self { 70 | language: None, 71 | id: params.illust_id, 72 | image_index: None, 73 | } 74 | } 75 | } 76 | 77 | async fn member_illust_handler( 78 | Query(params): Query, 79 | State(state): State>>, 80 | TypedHeader(user_agent): TypedHeader, 81 | Host(host): Host, 82 | ) -> Result { 83 | let raw_path: RawArtworkPath = params.into(); 84 | 85 | if let Some(resp) = filter_bots(user_agent, &raw_path) { 86 | return Ok(resp); 87 | } 88 | 89 | Ok(artwork_response(raw_path, state, host).await?) 90 | } 91 | 92 | fn filter_bots(user_agent: UserAgent, raw_path: &RawArtworkPath) -> Option { 93 | if env::var("BOT_FILTERING") 94 | .unwrap_or_else(|_| String::from("false")) 95 | .parse::() 96 | .ok()? 97 | { 98 | let bots = isbot::Bots::default(); 99 | 100 | if !bots.is_bot(user_agent.as_str()) { 101 | let redirect_uri = format!( 102 | "https://www.pixiv.net{}/artworks/{}", 103 | raw_path 104 | .language 105 | .as_ref() 106 | .map(|l| format!("/{l}")) 107 | .unwrap_or_else(|| String::from("")), 108 | raw_path.id 109 | ); 110 | return Some(Redirect::temporary(&redirect_uri).into_response()); 111 | } 112 | } 113 | 114 | None 115 | } 116 | 117 | fn redirect_uri(uri: Uri) -> String { 118 | let Some(path_and_query) = uri.path_and_query() else { 119 | return String::from("https://www.pixiv.net/"); 120 | }; 121 | 122 | Uri::builder() 123 | .scheme("https") 124 | .authority("www.pixiv.net") 125 | .path_and_query(path_and_query.as_str()) 126 | .build() 127 | .unwrap() 128 | .to_string() 129 | } 130 | 131 | async fn redirect_fallback(OriginalUri(uri): OriginalUri) -> Redirect { 132 | Redirect::temporary(&redirect_uri(uri)) 133 | } 134 | 135 | pub fn router( 136 | state: Arc>, 137 | ) -> Router>, axum::body::Body> { 138 | Router::new() 139 | .route("/:language/artworks/:id", get(artwork_handler)) 140 | .route("/:language/artworks/:id/:image_index", get(artwork_handler)) 141 | .route("/artworks/:id", get(artwork_handler)) 142 | .route("/artworks/:id/:image_index", get(artwork_handler)) 143 | .route("/member_illust.php", get(member_illust_handler)) 144 | .fallback(redirect_fallback) 145 | .layer(middleware::from_fn_with_state(state, authorized_middleware)) 146 | } 147 | -------------------------------------------------------------------------------- /src/helper.rs: -------------------------------------------------------------------------------- 1 | use axum::response::{IntoResponse, Response}; 2 | use http::{HeaderMap, HeaderValue, StatusCode}; 3 | 4 | pub fn headers() -> HeaderMap { 5 | let mut headers = HeaderMap::with_capacity(5); 6 | 7 | headers.append("App-Os", "iOS".parse().unwrap()); 8 | headers.append("App-Os-Version", "14.6".parse().unwrap()); 9 | headers.append( 10 | "User-Agent", 11 | "PixivIOSApp/7.13.3 (iOS 14.6; iPhone13,2)".parse().unwrap(), 12 | ); 13 | 14 | headers 15 | } 16 | 17 | pub struct PhixivError(anyhow::Error); 18 | 19 | impl IntoResponse for PhixivError { 20 | fn into_response(self) -> Response { 21 | (StatusCode::INTERNAL_SERVER_ERROR, format!("{:#}", self.0)).into_response() 22 | } 23 | } 24 | 25 | impl From for PhixivError 26 | where 27 | E: Into, 28 | { 29 | fn from(value: E) -> Self { 30 | Self(value.into()) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | pub mod api; 2 | pub mod auth; 3 | pub mod embed; 4 | pub mod helper; 5 | pub mod oembed; 6 | pub mod pixiv; 7 | pub mod proxy; 8 | pub mod state; 9 | 10 | use std::{env, net::SocketAddr, sync::Arc}; 11 | 12 | use api::api_router; 13 | use axum::{response::IntoResponse, routing::get, Json, Router}; 14 | use oembed::oembed_handler; 15 | use proxy::proxy_router; 16 | use serde_json::json; 17 | use state::PhixivState; 18 | use tokio::sync::RwLock; 19 | use tower_http::{ 20 | normalize_path::NormalizePathLayer, 21 | trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer}, 22 | }; 23 | use tracing::Level; 24 | use tracing_subscriber::{ 25 | fmt, prelude::__tracing_subscriber_SubscriberExt, util::SubscriberInitExt, EnvFilter, 26 | }; 27 | 28 | #[tokio::main] 29 | async fn main() -> anyhow::Result<()> { 30 | dotenvy::dotenv().ok(); 31 | 32 | let addr: SocketAddr = format!( 33 | "[::]:{}", 34 | env::var("PORT").unwrap_or_else(|_| String::from("3000")) 35 | ) 36 | .parse()?; 37 | 38 | let tracing_registry = tracing_subscriber::registry() 39 | .with(fmt::layer()) 40 | .with(EnvFilter::from_default_env()); 41 | 42 | if let Ok(loki_url) = env::var("LOKI_URL") { 43 | let (layer, task) = tracing_loki::builder() 44 | .label( 45 | "environment", 46 | env::var("ENVIRONMENT").unwrap_or_else(|_| String::from("development")), 47 | )? 48 | .build_url(url::Url::parse(&loki_url).unwrap())?; 49 | 50 | tokio::spawn(task); 51 | 52 | tracing_registry.with(layer).init(); 53 | } else { 54 | tracing_registry.init(); 55 | } 56 | 57 | tracing::info!("Listening on: {addr}"); 58 | 59 | let state = Arc::new(RwLock::new( 60 | PhixivState::login(env::var("PIXIV_REFRESH_TOKEN")?).await?, 61 | )); 62 | 63 | axum::Server::bind(&addr) 64 | .serve(app(state).into_make_service()) 65 | .with_graceful_shutdown(shutdown_signal()) 66 | .await?; 67 | 68 | Ok(()) 69 | } 70 | 71 | fn app(state: Arc>) -> Router { 72 | Router::new() 73 | .merge(embed::router(state.clone())) 74 | .route("/health", get(health)) 75 | .route("/e", get(oembed_handler)) 76 | .nest("/i", proxy_router(state.clone())) 77 | .nest("/api", api_router(state.clone())) 78 | .layer( 79 | TraceLayer::new_for_http() 80 | .make_span_with(DefaultMakeSpan::new().level(Level::INFO)) 81 | .on_response(DefaultOnResponse::new().level(Level::INFO)), 82 | ) 83 | .layer(NormalizePathLayer::trim_trailing_slash()) 84 | .with_state(state) 85 | } 86 | 87 | async fn shutdown_signal() { 88 | let ctrl_c = async { 89 | tokio::signal::ctrl_c() 90 | .await 91 | .expect("failed to install Ctrl+C handler"); 92 | }; 93 | 94 | #[cfg(unix)] 95 | let terminate = async { 96 | tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) 97 | .expect("failed to install signal handler") 98 | .recv() 99 | .await; 100 | }; 101 | 102 | #[cfg(not(unix))] 103 | let terminate = std::future::pending::<()>(); 104 | 105 | tokio::select! { 106 | _ = ctrl_c => {}, 107 | _ = terminate => {}, 108 | } 109 | } 110 | 111 | async fn health() -> impl IntoResponse { 112 | Json(json!({ "health": "UP" })) 113 | } 114 | -------------------------------------------------------------------------------- /src/oembed.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | 3 | use axum::{extract::Query, Json}; 4 | use serde::{Deserialize, Serialize}; 5 | use urlencoding::encode; 6 | 7 | #[derive(Deserialize)] 8 | pub struct EmbedRequest { 9 | #[serde(rename = "n")] 10 | pub author_name: String, 11 | #[serde(rename = "i")] 12 | pub author_id: Option, 13 | } 14 | 15 | #[derive(Debug, Serialize)] 16 | pub struct EmbedResponse { 17 | version: &'static str, 18 | #[serde(rename = "type")] 19 | embed_type: &'static str, 20 | author_name: String, 21 | author_url: String, 22 | provider_name: String, 23 | provider_url: String, 24 | } 25 | 26 | impl EmbedResponse { 27 | fn new(author_name: String, author_url: String) -> Self { 28 | Self { 29 | version: "1.0", 30 | embed_type: "rich", 31 | author_name, 32 | author_url, 33 | provider_name: env::var("PROVIDER_NAME").unwrap_or_else(|_| String::from("phixiv")), 34 | provider_url: env::var("PROVIDER_URL").unwrap_or_else(|_| String::from("https://github.com/HazelTheWitch/phixiv")), 35 | } 36 | } 37 | } 38 | 39 | pub async fn oembed_handler( 40 | Query(EmbedRequest { 41 | author_name, 42 | author_id, 43 | }): Query, 44 | ) -> Json { 45 | if let Some(author_id) = author_id { 46 | Json(EmbedResponse::new( 47 | author_name, 48 | format!("https://www.pixiv.net/users/{}", encode(&author_id)), 49 | )) 50 | } else { 51 | Json(EmbedResponse::new( 52 | author_name, 53 | String::from("https://www.pixiv.net/"), 54 | )) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/pixiv/mod.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use askama::Template; 4 | use itertools::Itertools; 5 | use reqwest::Client; 6 | use serde::{Deserialize, Serialize}; 7 | 8 | use crate::helper; 9 | 10 | use self::model::{AjaxResponse, AppReponse}; 11 | 12 | mod model; 13 | 14 | const ILLUST_URL: &str = "https://app-api.pixiv.net/v1/illust/detail"; 15 | 16 | #[derive(Deserialize)] 17 | pub struct RawArtworkPath { 18 | pub language: Option, 19 | pub id: String, 20 | pub image_index: Option, 21 | } 22 | 23 | pub struct ArtworkPath { 24 | pub language: Option, 25 | pub id: String, 26 | pub image_index: Option, 27 | } 28 | 29 | impl TryFrom for ArtworkPath { 30 | type Error = anyhow::Error; 31 | 32 | fn try_from(value: RawArtworkPath) -> Result { 33 | let image_index = match value.image_index { 34 | Some(index) => Some(index.parse()?), 35 | None => None, 36 | }; 37 | 38 | Ok(Self { 39 | language: value.language, 40 | id: value.id, 41 | image_index, 42 | }) 43 | } 44 | } 45 | 46 | #[derive(Debug, Serialize, Template)] 47 | #[template(path = "artwork.html")] 48 | pub struct ArtworkTemplate { 49 | pub image_proxy_url: String, 50 | pub title: String, 51 | pub description: String, 52 | pub author_name: String, 53 | pub author_id: String, 54 | pub url: String, 55 | pub alt_text: String, 56 | pub host: String, 57 | } 58 | 59 | #[derive(Serialize)] 60 | /// Representing a listing of artworks, uniquely determined by language and illust_id 61 | pub struct ArtworkListing { 62 | pub image_proxy_urls: Vec, 63 | pub title: String, 64 | pub ai_generated: bool, 65 | pub description: String, 66 | pub tags: Vec, 67 | pub url: String, 68 | pub author_name: String, 69 | pub author_id: String, 70 | } 71 | 72 | async fn app_request( 73 | illust_id: &String, 74 | access_token: &str, 75 | client: &Client, 76 | ) -> anyhow::Result { 77 | let app_params = HashMap::from([("illust_id", illust_id)]); 78 | let mut app_headers = helper::headers(); 79 | app_headers.append("Host", "app-api.pixiv.net".parse()?); 80 | app_headers.append("Authorization", format!("Bearer {access_token}").parse()?); 81 | 82 | Ok(client 83 | .get(ILLUST_URL) 84 | .headers(app_headers) 85 | .query(&app_params) 86 | .send() 87 | .await? 88 | .json() 89 | .await?) 90 | } 91 | 92 | async fn ajax_request( 93 | illust_id: &String, 94 | language: &Option, 95 | client: &Client, 96 | ) -> anyhow::Result { 97 | Ok(client 98 | .get(format!( 99 | "https://www.pixiv.net/ajax/illust/{}?lang={}", 100 | &illust_id, 101 | &language.clone().unwrap_or_else(|| String::from("jp")) 102 | )) 103 | .send() 104 | .await? 105 | .json() 106 | .await?) 107 | } 108 | 109 | impl ArtworkListing { 110 | pub async fn get_listing( 111 | language: Option, 112 | illust_id: String, 113 | access_token: &str, 114 | host: &str, 115 | client: &Client, 116 | ) -> anyhow::Result { 117 | let (app_response, ajax_response) = tokio::try_join!( 118 | app_request(&illust_id, access_token, client), 119 | ajax_request(&illust_id, &language, client), 120 | )?; 121 | 122 | let ai_generated = app_response.illust.illust_ai_type == 2; 123 | 124 | let tags: Vec<_> = ajax_response.body 125 | .tags 126 | .tags 127 | .into_iter() 128 | .map(|tag| { 129 | format!( 130 | "#{}", 131 | if let Some(language) = &language { 132 | if let Some(translation) = tag.translation { 133 | translation.get(language).unwrap_or(&tag.tag).to_string() 134 | } else { 135 | tag.tag 136 | } 137 | } else { 138 | tag.tag 139 | } 140 | ) 141 | }) 142 | .collect(); 143 | 144 | let image_proxy_urls = if app_response.illust.meta_pages.is_empty() { 145 | let url = url::Url::parse(&app_response.illust.image_urls.large)?; 146 | 147 | vec![format!("https://{}/i{}", host, url.path())] 148 | } else { 149 | app_response.illust 150 | .meta_pages 151 | .into_iter() 152 | .map(|mp| { 153 | let url = url::Url::parse(&mp.image_urls.large)?; 154 | 155 | Ok(format!("https://{}/i{}", host, url.path())) 156 | }) 157 | .collect::>>()? 158 | }; 159 | 160 | Ok(Self { 161 | image_proxy_urls, 162 | title: ajax_response.body.title, 163 | ai_generated, 164 | description: ajax_response.body.description, 165 | tags, 166 | url: ajax_response.body.extra_data.meta.canonical, 167 | author_name: ajax_response.body.author_name, 168 | author_id: ajax_response.body.author_id, 169 | }) 170 | } 171 | 172 | pub fn to_template(self, image_index: Option, host: String) -> ArtworkTemplate { 173 | let index = image_index 174 | .unwrap_or(1) 175 | .min(self.image_proxy_urls.len()) 176 | .saturating_sub(1); 177 | 178 | let image_proxy_url = self.image_proxy_urls[index].clone(); 179 | 180 | let tag_string = Itertools::intersperse_with(self.tags.into_iter(), || String::from(", ")) 181 | .collect::(); 182 | 183 | let description = Itertools::intersperse_with( 184 | [ 185 | String::from(if self.ai_generated { 186 | "AI Generated\n" 187 | } else { 188 | "" 189 | }), 190 | self.description, 191 | tag_string.clone(), 192 | ] 193 | .into_iter() 194 | .filter(|s| !s.is_empty()), 195 | || String::from("\n"), 196 | ) 197 | .collect::(); 198 | 199 | ArtworkTemplate { 200 | image_proxy_url, 201 | title: self.title, 202 | description, 203 | author_name: self.author_name, 204 | author_id: self.author_id, 205 | url: self.url, 206 | alt_text: tag_string, 207 | host, 208 | } 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/pixiv/model.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use serde::Deserialize; 4 | 5 | #[derive(Debug, Deserialize)] 6 | pub(super) struct AppReponse { 7 | pub illust: IllustrationResponse, 8 | } 9 | 10 | #[derive(Debug, Deserialize)] 11 | pub(super) struct IllustrationResponse { 12 | pub image_urls: ImageUrls, 13 | pub meta_pages: Vec, 14 | pub illust_ai_type: u8, 15 | } 16 | 17 | #[derive(Debug, Deserialize)] 18 | pub(super) struct MetaPage { 19 | pub image_urls: MetaPageImageUrls, 20 | } 21 | 22 | #[derive(Debug, Deserialize)] 23 | pub(super) struct MetaPageImageUrls { 24 | pub large: String, 25 | } 26 | 27 | #[derive(Debug, Deserialize)] 28 | pub(super) struct ImageUrls { 29 | pub large: String, 30 | } 31 | 32 | #[derive(Debug, Deserialize)] 33 | pub(super) struct AjaxResponse { 34 | pub body: AjaxBody, 35 | } 36 | 37 | #[derive(Debug, Deserialize)] 38 | pub(super) struct AjaxBody { 39 | pub title: String, 40 | pub description: String, 41 | pub tags: Tags, 42 | #[serde(rename = "userId")] 43 | pub author_id: String, 44 | #[serde(rename = "userName")] 45 | pub author_name: String, 46 | #[serde(rename = "extraData")] 47 | pub extra_data: AjaxExtraData, 48 | } 49 | 50 | #[derive(Debug, Deserialize)] 51 | pub(super) struct Tags { 52 | pub tags: Vec, 53 | } 54 | 55 | #[derive(Debug, Deserialize)] 56 | pub(super) struct Tag { 57 | pub tag: String, 58 | pub translation: Option>, 59 | } 60 | 61 | #[derive(Debug, Deserialize)] 62 | pub(super) struct AjaxExtraData { 63 | pub meta: AjaxMeta, 64 | } 65 | 66 | #[derive(Debug, Deserialize)] 67 | pub(super) struct AjaxMeta { 68 | pub canonical: String, 69 | } 70 | -------------------------------------------------------------------------------- /src/proxy.rs: -------------------------------------------------------------------------------- 1 | use std::{sync::Arc, time::Duration}; 2 | 3 | use axum::{ 4 | body::StreamBody, 5 | extract::{Path, State}, 6 | headers::CacheControl, 7 | middleware, 8 | response::IntoResponse, 9 | routing::get, 10 | Router, TypedHeader, 11 | }; 12 | use tokio::sync::RwLock; 13 | 14 | use crate::{ 15 | helper::{self, PhixivError}, 16 | state::{authorized_middleware, PhixivState}, 17 | }; 18 | 19 | async fn proxy_handler( 20 | State(state): State>>, 21 | Path(path): Path, 22 | ) -> Result { 23 | let state = state.read().await; 24 | 25 | let url = format!("https://i.pximg.net/{path}"); 26 | 27 | let mut headers = helper::headers(); 28 | headers.append("Referer", "https://www.pixiv.net/".parse()?); 29 | headers.append( 30 | "Authorization", 31 | format!("Bearer {}", state.auth.access_token).parse()?, 32 | ); 33 | 34 | let response = state.client.get(&url).headers(headers).send().await?; 35 | 36 | Ok(( 37 | TypedHeader( 38 | CacheControl::new() 39 | .with_max_age(Duration::from_secs(60 * 60 * 24)) 40 | .with_public(), 41 | ), 42 | StreamBody::new(response.bytes_stream()), 43 | )) 44 | } 45 | 46 | pub fn proxy_router(state: Arc>) -> Router>> { 47 | Router::new() 48 | .route("/*path", get(proxy_handler)) 49 | .layer(middleware::from_fn_with_state(state, authorized_middleware)) 50 | } 51 | -------------------------------------------------------------------------------- /src/state.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use axum::{extract::State, middleware::Next, response::Response}; 4 | use http::Request; 5 | use reqwest::Client; 6 | use tokio::sync::RwLock; 7 | 8 | use crate::{auth::PixivAuth, helper::PhixivError}; 9 | 10 | #[derive(Clone)] 11 | pub struct PhixivState { 12 | pub auth: PixivAuth, 13 | pub client: Client, 14 | } 15 | 16 | impl PhixivState { 17 | pub async fn login(refresh_token: String) -> anyhow::Result { 18 | let client = Client::new(); 19 | 20 | let auth = PixivAuth::login(&client, refresh_token).await?; 21 | 22 | Ok(Self { auth, client }) 23 | } 24 | 25 | async fn refresh(&mut self) -> anyhow::Result<()> { 26 | self.auth.refresh(&self.client).await 27 | } 28 | } 29 | 30 | pub async fn authorized_middleware( 31 | State(state): State>>, 32 | request: Request, 33 | next: Next, 34 | ) -> Result { 35 | if state.read().await.auth.expired() { 36 | let mut state = state.write().await; 37 | state.refresh().await?; 38 | } 39 | 40 | Ok(next.run(request).await) 41 | } 42 | -------------------------------------------------------------------------------- /templates/artwork.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ title }} 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | You should have been redirected, here is a link to the original post. 18 | 21 | 22 | --------------------------------------------------------------------------------