mirror of
https://github.com/TheFunny/TelegramTwitterMediaBot.git
synced 2026-09-23 23:32:05 +00:00
finish rust rewrite, add docker, drop python
This commit is contained in:
@@ -0,0 +1,394 @@
|
||||
//! Native pixiv app-API client (replaces pixiv3-rs).
|
||||
//!
|
||||
//! Token exchange against `oauth.secure.pixiv.net` and illust detail against
|
||||
//! `app-api.pixiv.net`, deserialized with the kept `model.rs` types.
|
||||
|
||||
use super::interface::Illustration;
|
||||
use super::model::{IllustrationModel, TypeModel, UgoiraMetadataModel};
|
||||
use crate::media::Media;
|
||||
use crate::site::FetchError;
|
||||
use std::env;
|
||||
use std::fmt;
|
||||
use std::io::{Cursor, Read};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::LazyLock;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
const AUTH_TOKEN_URL: &str = "https://oauth.secure.pixiv.net/auth/token";
|
||||
const APP_API_URL: &str = "https://app-api.pixiv.net";
|
||||
const CLIENT_ID: &str = "MOBrBDS8blbauoSck0ZfDbtuzpyT";
|
||||
const CLIENT_SECRET: &str = "lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj";
|
||||
const AUTH_USER_AGENT: &str = "PixivAndroidApp/5.0.234 (Android 11; Pixel 5)";
|
||||
const APP_USER_AGENT: &str = "PixivIOSApp/7.13.3 (iOS 14.6; iPhone13,2)";
|
||||
/// Token refresh safe margin (seconds).
|
||||
const TOKEN_REFRESH_SAFE_MARGIN: u64 = 300;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PixivError {
|
||||
/// No refresh token available (PIXIV_REFRESH_TOKEN unset).
|
||||
NoAuth,
|
||||
Http(reqwest::Error),
|
||||
Json(serde_json::Error),
|
||||
Api(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for PixivError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
PixivError::NoAuth => write!(f, "pixiv: no authentication"),
|
||||
PixivError::Http(e) => write!(f, "pixiv http error: {e}"),
|
||||
PixivError::Json(e) => write!(f, "pixiv json error: {e}"),
|
||||
PixivError::Api(message) => write!(f, "pixiv api error: {message}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PixivError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
PixivError::Http(e) => Some(e),
|
||||
PixivError::Json(e) => Some(e),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for PixivError {
|
||||
fn from(e: reqwest::Error) -> Self {
|
||||
PixivError::Http(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for PixivError {
|
||||
fn from(e: serde_json::Error) -> Self {
|
||||
PixivError::Json(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Native pixiv app-API client.
|
||||
pub struct PixivAPI {
|
||||
refresh_token: String,
|
||||
access_token: tokio::sync::Mutex<Option<(String, SystemTime)>>,
|
||||
}
|
||||
|
||||
impl PixivAPI {
|
||||
pub fn new(refresh_token: String) -> Self {
|
||||
Self {
|
||||
refresh_token,
|
||||
access_token: tokio::sync::Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a valid access token, exchanging the refresh token when none
|
||||
/// is cached or it has expired.
|
||||
pub async fn get_access_token(&self) -> Result<String, PixivError> {
|
||||
let mut guard = self.access_token.lock().await;
|
||||
if let Some((token, expires_at)) = guard.as_ref()
|
||||
&& *expires_at > SystemTime::now()
|
||||
{
|
||||
return Ok(token.clone());
|
||||
}
|
||||
let response = crate::site::CLIENT
|
||||
.post(AUTH_TOKEN_URL)
|
||||
.form(&[
|
||||
("client_id", CLIENT_ID),
|
||||
("client_secret", CLIENT_SECRET),
|
||||
("grant_type", "refresh_token"),
|
||||
("include_policy", "true"),
|
||||
("refresh_token", &self.refresh_token),
|
||||
])
|
||||
.header("User-Agent", AUTH_USER_AGENT)
|
||||
.send()
|
||||
.await?;
|
||||
let json: serde_json::Value = serde_json::from_str(&response.text().await?)?;
|
||||
let access_token = json
|
||||
.get("access_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
let message = json
|
||||
.get("error")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("invalid token response");
|
||||
PixivError::Api(message.to_string())
|
||||
})?
|
||||
.to_string();
|
||||
let expires_in = json
|
||||
.get("expires_in")
|
||||
.and_then(|v| v.as_u64())
|
||||
.filter(|&sec| sec > 0)
|
||||
.unwrap_or(3600);
|
||||
let expires_at = SystemTime::now()
|
||||
+ Duration::from_secs(expires_in.saturating_sub(TOKEN_REFRESH_SAFE_MARGIN));
|
||||
*guard = Some((access_token.clone(), expires_at));
|
||||
Ok(access_token)
|
||||
}
|
||||
|
||||
/// Fetches illust detail from the app API.
|
||||
pub async fn illust_detail(&self, illust_id: u64) -> Result<IllustrationModel, PixivError> {
|
||||
let access_token = self.get_access_token().await?;
|
||||
let response = crate::site::CLIENT
|
||||
.get(format!("{APP_API_URL}/v1/illust/detail?illust_id={illust_id}"))
|
||||
.header("app-os", "ios")
|
||||
.header("app-os-version", "14.6")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.bearer_auth(access_token)
|
||||
.send()
|
||||
.await?;
|
||||
let json: serde_json::Value = serde_json::from_str(&response.text().await?)?;
|
||||
if json.get("error").is_some() {
|
||||
let message = json
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("illust detail failed");
|
||||
return Err(PixivError::Api(message.to_string()));
|
||||
}
|
||||
let illust = json
|
||||
.get("illust")
|
||||
.ok_or_else(|| PixivError::Api("missing illust in response".to_string()))?;
|
||||
Ok(serde_json::from_value(illust.clone())?)
|
||||
}
|
||||
|
||||
pub async fn fetch(&self, illust_id: u64) -> Result<Illustration, FetchError> {
|
||||
let model = self.illust_detail(illust_id).await?;
|
||||
let mut illustration = Illustration::from_model(&model);
|
||||
if matches!(&model.r#type, TypeModel::Ugoira) {
|
||||
// Real ugoira support: download the frame zip and encode an MP4.
|
||||
// Without ffmpeg (or on encode failure) the post stays
|
||||
// unsupported (empty media, like Python).
|
||||
match self.ugoira_video(illust_id).await {
|
||||
Ok(Some((mp4_path, _keep_alive))) => {
|
||||
illustration.media.push(Media::Video {
|
||||
title: None,
|
||||
url: mp4_path,
|
||||
thumbnail_url: model.image_urls.medium.clone(),
|
||||
});
|
||||
illustration._keep_alive = Some(_keep_alive);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => log::error!("ugoira encode failed for {illust_id}: {e}"),
|
||||
}
|
||||
}
|
||||
Ok(illustration)
|
||||
}
|
||||
|
||||
/// Fetches ugoira metadata (frame zip + frame delays) from the app API.
|
||||
pub async fn ugoira_metadata(&self, illust_id: u64) -> Result<UgoiraMetadataModel, PixivError> {
|
||||
let access_token = self.get_access_token().await?;
|
||||
let response = crate::site::CLIENT
|
||||
.get(format!("{APP_API_URL}/v1/ugoira/metadata?illust_id={illust_id}"))
|
||||
.header("app-os", "ios")
|
||||
.header("app-os-version", "14.6")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.bearer_auth(access_token)
|
||||
.send()
|
||||
.await?;
|
||||
let json: serde_json::Value = serde_json::from_str(&response.text().await?)?;
|
||||
if json.get("error").is_some() {
|
||||
let message = json
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("ugoira metadata failed");
|
||||
return Err(PixivError::Api(message.to_string()));
|
||||
}
|
||||
let metadata = json
|
||||
.get("ugoira_metadata")
|
||||
.ok_or_else(|| PixivError::Api("missing ugoira_metadata".to_string()))?;
|
||||
Ok(serde_json::from_value(metadata.clone())?)
|
||||
}
|
||||
|
||||
/// Downloads the frame zip and encodes one MP4 via ffmpeg. Returns the
|
||||
/// MP4 path plus the temp directory that must stay alive until the file
|
||||
/// is uploaded.
|
||||
async fn ugoira_video(
|
||||
&self,
|
||||
illust_id: u64,
|
||||
) -> Result<Option<(String, tempfile::TempDir)>, PixivError> {
|
||||
if !ffmpeg_available() {
|
||||
log_once_ffmpeg_missing();
|
||||
return Ok(None);
|
||||
}
|
||||
let metadata = self.ugoira_metadata(illust_id).await?;
|
||||
if metadata.frames.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let zip_url = metadata
|
||||
.zip_url
|
||||
.clone()
|
||||
.or_else(|| metadata.zip_urls.as_ref().map(|z| z.medium.clone()));
|
||||
let Some(zip_url) = zip_url else {
|
||||
return Ok(None);
|
||||
};
|
||||
let zip_bytes = crate::site::download_media(&zip_url).await.map_err(|e| match e {
|
||||
FetchError::Http(e) => PixivError::Http(e),
|
||||
other => PixivError::Api(format!("frame zip download failed: {other}")),
|
||||
})?;
|
||||
let frame_delays = metadata.frames.iter().map(|f| f.delay).collect::<Vec<_>>();
|
||||
let result = tokio::task::spawn_blocking(
|
||||
move || -> Result<(String, tempfile::TempDir), String> {
|
||||
let frames_dir = tempfile::tempdir().map_err(|e| e.to_string())?;
|
||||
let out_dir = tempfile::tempdir().map_err(|e| e.to_string())?;
|
||||
|
||||
// Extract frames to canonical zero-padded names; pixiv ugoira
|
||||
// frames are uniformly jpg or png per artwork.
|
||||
let mut archive = zip::ZipArchive::new(Cursor::new(zip_bytes))
|
||||
.map_err(|e| format!("unzip: {e}"))?;
|
||||
// pixiv ugoira frames are uniformly jpg or png per artwork; take
|
||||
// the extension from the first entry.
|
||||
let extension = if archive.len() > 0 {
|
||||
let first_name = archive
|
||||
.by_index(0)
|
||||
.map_err(|e| e.to_string())?
|
||||
.name()
|
||||
.to_string();
|
||||
first_name
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.unwrap_or("jpg")
|
||||
.to_string()
|
||||
} else {
|
||||
"jpg".to_string()
|
||||
};
|
||||
let mut count = 0usize;
|
||||
for i in 0..archive.len() {
|
||||
let mut entry = archive.by_index(i).map_err(|e| e.to_string())?;
|
||||
let mut bytes = Vec::new();
|
||||
entry.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
|
||||
let path = frames_dir.path().join(format!("img_{count:05}.{extension}"));
|
||||
std::fs::write(&path, bytes).map_err(|e| e.to_string())?;
|
||||
count += 1;
|
||||
}
|
||||
if count == 0 {
|
||||
return Err("empty frame zip".to_string());
|
||||
}
|
||||
|
||||
// Constant rate from the median frame delay (ms).
|
||||
let mut delays = frame_delays;
|
||||
delays.sort_unstable();
|
||||
let median = delays[delays.len() / 2].max(1);
|
||||
let framerate = 1000.0 / median as f64;
|
||||
|
||||
let output = out_dir.path().join("ugoira.mp4");
|
||||
let status = std::process::Command::new("ffmpeg")
|
||||
.args([
|
||||
"-y",
|
||||
"-framerate",
|
||||
&framerate.to_string(),
|
||||
"-i",
|
||||
&frames_dir.path().join(format!("img_%05d.{extension}")).to_string_lossy(),
|
||||
// libx264 needs even dimensions; pixiv ugoira frames can
|
||||
// be odd-sized (e.g. 277x405).
|
||||
"-vf",
|
||||
"scale=trunc(iw/2)*2:trunc(ih/2)*2",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
&output.to_string_lossy(),
|
||||
])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map_err(|e| format!("ffmpeg spawn failed: {e}"))?;
|
||||
if !status.success() {
|
||||
return Err(format!("ffmpeg exited with {status}"));
|
||||
}
|
||||
Ok((output.to_string_lossy().into_owned(), out_dir))
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("ugoira encode worker panicked");
|
||||
match result {
|
||||
Ok(pair) => Ok(Some(pair)),
|
||||
Err(message) => {
|
||||
log::error!("ugoira encode failed for {illust_id}: {message}");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static FFMPEG_AVAILABLE: LazyLock<bool> = LazyLock::new(|| {
|
||||
std::process::Command::new("ffmpeg")
|
||||
.arg("-version")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
static FFMPEG_MISSING_LOGGED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
fn ffmpeg_available() -> bool {
|
||||
*FFMPEG_AVAILABLE
|
||||
}
|
||||
|
||||
fn log_once_ffmpeg_missing() {
|
||||
if !FFMPEG_MISSING_LOGGED.swap(true, Ordering::Relaxed) {
|
||||
log::warn!("ffmpeg not found; pixiv ugoira posts stay unsupported");
|
||||
}
|
||||
}
|
||||
|
||||
/// pixiv3-rs replacement: `None` when `PIXIV_REFRESH_TOKEN` is unset.
|
||||
static PIXIV_CLIENT: LazyLock<Option<PixivAPI>> = LazyLock::new(|| {
|
||||
env::var("PIXIV_REFRESH_TOKEN").ok().map(PixivAPI::new)
|
||||
});
|
||||
|
||||
/// Set at startup when the login validation fails; pixiv stays disabled until
|
||||
/// the next process start.
|
||||
static DISABLED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub fn enabled() -> bool {
|
||||
!DISABLED.load(Ordering::Relaxed) && env::var("PIXIV_REFRESH_TOKEN").is_ok()
|
||||
}
|
||||
|
||||
/// Permanently disables pixiv until the next process start.
|
||||
pub fn disable() {
|
||||
DISABLED.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Forces the refresh-token → access-token exchange now, surfacing invalid
|
||||
/// tokens and network errors. Called once at bot startup; on failure the bot
|
||||
/// calls [`disable`].
|
||||
pub async fn validate() -> Result<(), PixivError> {
|
||||
match PIXIV_CLIENT.as_ref() {
|
||||
None => Err(PixivError::NoAuth),
|
||||
Some(client) => {
|
||||
client.get_access_token().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn fetch(illust_id: u64) -> Result<Illustration, FetchError> {
|
||||
let client = PIXIV_CLIENT
|
||||
.as_ref()
|
||||
.filter(|_| enabled())
|
||||
.ok_or(FetchError::Pixiv(PixivError::NoAuth))?;
|
||||
client.fetch(illust_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use dotenv::dotenv;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch() {
|
||||
dotenv().ok();
|
||||
let result = fetch(126839080).await;
|
||||
assert!(result.is_ok());
|
||||
println!("{:#?}", result);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_with_bogus_token_fails() {
|
||||
dotenv().ok();
|
||||
// A bogus token must surface as Api error (invalid_grant), not panic.
|
||||
let client = PixivAPI::new("bogus_token_for_testing".to_string());
|
||||
let result = client.get_access_token().await;
|
||||
assert!(matches!(result, Err(PixivError::Api(_))), "got {result:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
use super::model::{IllustrationModel, TypeModel};
|
||||
use crate::media::Media;
|
||||
use crate::site::{FetchError, Fetched};
|
||||
use html_escape::encode_text;
|
||||
use regex::Regex;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
pub static PATTERN: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?:www\.)?pixiv\.net/(?:en/)?(?:(?:i|artworks)/|member_illust\.php\?(?:mode=[a-z_]*&)?illust_id=)(\d+)").unwrap()
|
||||
});
|
||||
|
||||
pub fn enabled() -> bool {
|
||||
super::api::enabled()
|
||||
}
|
||||
|
||||
pub async fn fetch_from_url(url: &str) -> Result<Fetched, FetchError> {
|
||||
let id = PATTERN
|
||||
.captures(url)
|
||||
.and_then(|caps| caps.get(1))
|
||||
.map(|m| m.as_str())
|
||||
.ok_or(FetchError::NotFound)?;
|
||||
let id = id.parse::<u64>().map_err(|_| FetchError::NotFound)?;
|
||||
Ok(super::api::fetch(id).await?.into())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Illustration {
|
||||
id: String,
|
||||
title: String,
|
||||
author: String,
|
||||
author_id: String,
|
||||
tags: Vec<String>,
|
||||
pub(crate) media: Vec<Media>,
|
||||
nsfw: bool,
|
||||
/// Keeps a temp dir (ugoira MP4) alive until the send completes.
|
||||
pub(crate) _keep_alive: Option<tempfile::TempDir>,
|
||||
}
|
||||
|
||||
impl Illustration {
|
||||
fn url(&self) -> String {
|
||||
format!("https://www.pixiv.net/artworks/{}", self.id)
|
||||
}
|
||||
|
||||
fn author_url(&self) -> String {
|
||||
format!("https://www.pixiv.net/users/{}", self.author_id)
|
||||
}
|
||||
|
||||
pub fn caption(&self) -> String {
|
||||
format!(
|
||||
"<a href=\"{url}\">{title}</a> / <a href=\"{author_url}\">{author}</a>\n{tags}",
|
||||
url = self.url(),
|
||||
title = encode_text(&self.title),
|
||||
author_url = self.author_url(),
|
||||
author = encode_text(&self.author),
|
||||
tags = encode_text(
|
||||
&self
|
||||
.tags
|
||||
.iter()
|
||||
.map(|tag| format!("#{tag}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn from_model(model: &IllustrationModel) -> Self {
|
||||
let id = model.id.to_string();
|
||||
let title = model.title.clone();
|
||||
let author = model.user.name.clone();
|
||||
let author_id = model.user.id.to_string();
|
||||
let mut tags: Vec<String> = model.tags.iter().map(|tag| tag.name.clone()).collect();
|
||||
// illust_ai_type: 0 = undefined, 1 = not AI, 2 = AI-generated.
|
||||
// Mark AI works with a leading #AI tag (rendered via the `#{tag}`
|
||||
// caption format).
|
||||
if model.illust_ai_type == 2 {
|
||||
tags.insert(0, "AI".to_string());
|
||||
}
|
||||
let mut media = vec![];
|
||||
if matches!(&model.r#type, TypeModel::Ugoira) {
|
||||
// No static images for ugoira; the fetch path encodes an MP4 via
|
||||
// ffmpeg and appends it as a Video item (api.rs). This fallback
|
||||
// keeps media empty when encoding fails or ffmpeg is missing.
|
||||
} else if model.page_count > 1 {
|
||||
media.extend(model.meta_pages.iter().filter_map(|page| {
|
||||
page.image_urls.original.clone().map(|original| Media::Illustration {
|
||||
title: None,
|
||||
url: original,
|
||||
thumbnail_url: Some(page.image_urls.medium.clone()),
|
||||
fallback_url: Some(page.image_urls.large.clone()),
|
||||
})
|
||||
}));
|
||||
} else if let Some(original) = model
|
||||
.meta_single_page
|
||||
.original_image_url
|
||||
.clone()
|
||||
.or(model.image_urls.original.clone())
|
||||
{
|
||||
media.push(Media::Illustration {
|
||||
title: None,
|
||||
url: original,
|
||||
thumbnail_url: Some(model.image_urls.medium.clone()),
|
||||
fallback_url: Some(model.image_urls.large.clone()),
|
||||
});
|
||||
}
|
||||
let nsfw = model.sanity_level > 5;
|
||||
Self {
|
||||
id,
|
||||
title,
|
||||
author,
|
||||
author_id,
|
||||
tags,
|
||||
media,
|
||||
nsfw,
|
||||
_keep_alive: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Illustration> for Fetched {
|
||||
fn from(illustration: Illustration) -> Self {
|
||||
let url = illustration.url();
|
||||
let author_url = illustration.author_url();
|
||||
let tags = illustration
|
||||
.tags
|
||||
.iter()
|
||||
.map(|tag| format!("#{tag}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let render_data = Some(crate::site::RenderData {
|
||||
url: url.clone(),
|
||||
author: encode_text(&illustration.author).into_owned(),
|
||||
author_url: author_url.clone(),
|
||||
title: encode_text(&illustration.title).into_owned(),
|
||||
tags: encode_text(&tags).into_owned(),
|
||||
});
|
||||
Fetched {
|
||||
source_url: url,
|
||||
caption: illustration.caption(),
|
||||
title: illustration.title.clone(),
|
||||
media: illustration.media,
|
||||
sensitive: illustration.nsfw,
|
||||
render_data,
|
||||
_keep_alive: illustration._keep_alive,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::super::model::IllustrationModel;
|
||||
|
||||
fn illust_json(
|
||||
type_: &str,
|
||||
page_count: u8,
|
||||
single_original: Option<&str>,
|
||||
image_urls_original: Option<&str>,
|
||||
pages: Vec<(Option<&str>, &str, &str)>,
|
||||
ai_type: i32,
|
||||
) -> serde_json::Value {
|
||||
let meta_pages: Vec<serde_json::Value> = pages
|
||||
.into_iter()
|
||||
.map(|(original, medium, large)| {
|
||||
serde_json::json!({
|
||||
"image_urls": {
|
||||
"medium": medium,
|
||||
"large": large,
|
||||
"original": original
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
serde_json::json!({
|
||||
"illust": {
|
||||
"id": 123,
|
||||
"title": "Art <title>",
|
||||
"type": type_,
|
||||
"image_urls": {
|
||||
"medium": "medium.jpg",
|
||||
"large": "large.jpg",
|
||||
"original": image_urls_original
|
||||
},
|
||||
"user": { "id": 456, "name": "Artist" },
|
||||
"tags": [{ "name": "tag1" }, { "name": "tag2" }],
|
||||
"page_count": page_count,
|
||||
"sanity_level": 6,
|
||||
"illust_ai_type": ai_type,
|
||||
"meta_single_page": { "original_image_url": single_original },
|
||||
"meta_pages": meta_pages
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn parse(v: serde_json::Value) -> Illustration {
|
||||
let model: IllustrationModel = serde_json::from_value(v["illust"].clone()).unwrap();
|
||||
Illustration::from_model(&model)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_matches_all_forms() {
|
||||
let cases = [
|
||||
("https://www.pixiv.net/artworks/123456", "123456"),
|
||||
("https://pixiv.net/artworks/123456", "123456"),
|
||||
("https://www.pixiv.net/en/artworks/123456", "123456"),
|
||||
("https://www.pixiv.net/i/123456", "123456"),
|
||||
("https://www.pixiv.net/member_illust.php?mode=medium&illust_id=123456", "123456"),
|
||||
("https://www.pixiv.net/en/member_illust.php?illust_id=123456", "123456"),
|
||||
];
|
||||
for (url, id) in cases {
|
||||
let caps = PATTERN.captures(url).unwrap_or_else(|| panic!("{url}"));
|
||||
assert_eq!(caps.get(1).unwrap().as_str(), id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_rejects_non_artwork_urls() {
|
||||
for url in [
|
||||
"https://www.pixiv.net/users/123",
|
||||
"https://x.com/user/status/123",
|
||||
"https://bsky.app/profile/u/post/3xxxx",
|
||||
] {
|
||||
assert!(!PATTERN.is_match(url), "{url}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ugoira_yields_empty_media() {
|
||||
let v = illust_json("ugoira", 1, Some("https://i.pximg.net/orig.jpg"), None, vec![], 0);
|
||||
let illustration = parse(v);
|
||||
let fetched: Fetched = illustration.into();
|
||||
assert!(fetched.media.is_empty());
|
||||
assert!(fetched.sensitive, "sanity_level 6 > 5");
|
||||
assert_eq!(fetched.title, "Art <title>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_page_with_single_original() {
|
||||
let v = illust_json(
|
||||
"illust",
|
||||
1,
|
||||
Some("https://i.pximg.net/single.jpg"),
|
||||
None,
|
||||
vec![],
|
||||
0,
|
||||
);
|
||||
let fetched: Fetched = parse(v).into();
|
||||
assert_eq!(fetched.media.len(), 1);
|
||||
match &fetched.media[0] {
|
||||
Media::Illustration { url, .. } => {
|
||||
assert_eq!(url, "https://i.pximg.net/single.jpg")
|
||||
}
|
||||
other => panic!("expected Illustration, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_page_falls_back_to_image_urls_original() {
|
||||
let v = illust_json(
|
||||
"illust",
|
||||
1,
|
||||
None,
|
||||
Some("https://i.pximg.net/fallback.jpg"),
|
||||
vec![],
|
||||
0,
|
||||
);
|
||||
let fetched: Fetched = parse(v).into();
|
||||
assert_eq!(fetched.media.len(), 1);
|
||||
match &fetched.media[0] {
|
||||
Media::Illustration { url, .. } => {
|
||||
assert_eq!(url, "https://i.pximg.net/fallback.jpg")
|
||||
}
|
||||
other => panic!("expected Illustration, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_page_without_any_original_is_empty() {
|
||||
let v = illust_json("illust", 1, None, None, vec![], 0);
|
||||
let fetched: Fetched = parse(v).into();
|
||||
assert!(fetched.media.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_page_skips_pages_without_original() {
|
||||
let v = illust_json(
|
||||
"illust",
|
||||
2,
|
||||
None,
|
||||
None,
|
||||
vec![
|
||||
(None, "m1.jpg", "l1.jpg"),
|
||||
(Some("https://i.pximg.net/p2.jpg"), "m2.jpg", "l2.jpg"),
|
||||
],
|
||||
0,
|
||||
);
|
||||
let fetched: Fetched = parse(v).into();
|
||||
assert_eq!(fetched.media.len(), 1);
|
||||
match &fetched.media[0] {
|
||||
Media::Illustration { url, thumbnail_url, fallback_url, .. } => {
|
||||
assert_eq!(url, "https://i.pximg.net/p2.jpg");
|
||||
assert_eq!(thumbnail_url.as_deref(), Some("m2.jpg"));
|
||||
assert_eq!(fallback_url.as_deref(), Some("l2.jpg"));
|
||||
}
|
||||
other => panic!("expected Illustration, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caption_with_escapes_format_and_substitutes() {
|
||||
let v = illust_json("illust", 1, Some("https://i.pximg.net/o.jpg"), None, vec![], 0);
|
||||
let fetched: Fetched = parse(v).into();
|
||||
// Format string is escaped in full, then placeholders substituted.
|
||||
let out = fetched.caption_with("{title} by {author} <script> {tags}");
|
||||
assert!(
|
||||
out.contains("Art <title> by Artist <script> #tag1 #tag2"),
|
||||
"got: {out}"
|
||||
);
|
||||
assert!(!out.contains("<script>"), "no raw HTML injection: {out}");
|
||||
// {url} and {author_url} carry the site's own URLs.
|
||||
let out = fetched.caption_with("{url} {author_url}");
|
||||
assert_eq!(
|
||||
out,
|
||||
"https://www.pixiv.net/artworks/123 https://www.pixiv.net/users/456"
|
||||
);
|
||||
// Empty format falls back to the built-in caption.
|
||||
assert_eq!(fetched.caption_with(""), fetched.caption);
|
||||
assert_eq!(fetched.site_name(), "pixiv");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_work_gets_leading_ai_tag() {
|
||||
// illust_ai_type == 2 is the only AI marker.
|
||||
let v = illust_json("illust", 1, Some("https://i.pximg.net/o.jpg"), None, vec![], 2);
|
||||
let fetched: Fetched = parse(v).into();
|
||||
assert!(
|
||||
fetched.caption.contains("#AI #tag1 #tag2"),
|
||||
"caption: {}",
|
||||
fetched.caption
|
||||
);
|
||||
// The {tags} placeholder reflects the tag array too.
|
||||
assert!(fetched.caption_with("{tags}").starts_with("#AI "), "got: {}", fetched.caption_with("{tags}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_ai_work_has_no_ai_tag() {
|
||||
// 1 = explicitly not AI, 0 = undefined: neither gets the #AI tag.
|
||||
for ai_type in [0, 1] {
|
||||
let v = illust_json("illust", 1, Some("https://i.pximg.net/o.jpg"), None, vec![], ai_type);
|
||||
let fetched: Fetched = parse(v).into();
|
||||
assert!(
|
||||
!fetched.caption.contains("#AI"),
|
||||
"ai_type={ai_type} got: {}",
|
||||
fetched.caption
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caption_escapes_and_links() {
|
||||
let v = illust_json("illust", 1, Some("https://i.pximg.net/o.jpg"), None, vec![], 0);
|
||||
let fetched: Fetched = parse(v).into();
|
||||
assert!(
|
||||
fetched
|
||||
.caption
|
||||
.contains("<a href=\"https://www.pixiv.net/artworks/123\">Art <title></a>"),
|
||||
"caption: {}",
|
||||
fetched.caption
|
||||
);
|
||||
assert!(fetched.caption.contains("#tag1 #tag2"));
|
||||
assert_eq!(
|
||||
fetched.source_url,
|
||||
"https://www.pixiv.net/artworks/123"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod api;
|
||||
mod interface;
|
||||
mod model;
|
||||
|
||||
pub use api::{PixivAPI, PixivError, disable, fetch, validate};
|
||||
pub use interface::{PATTERN, Illustration, enabled, fetch_from_url};
|
||||
@@ -0,0 +1,79 @@
|
||||
// Model set for the native pixiv app-API client (app-api.pixiv.net).
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct IllustrationModel {
|
||||
pub id: u64,
|
||||
pub title: String,
|
||||
pub r#type: TypeModel,
|
||||
pub image_urls: ImageUrlsModel,
|
||||
pub user: UserInfoModel,
|
||||
pub tags: Vec<IllustrationTagModel>,
|
||||
pub page_count: u8,
|
||||
pub sanity_level: u8,
|
||||
/// 0 = undefined (unlabeled), 1 = not AI, 2 = AI-generated.
|
||||
pub illust_ai_type: i32,
|
||||
pub meta_single_page: MetaSinglePageModel,
|
||||
pub meta_pages: Vec<MetaPageModel>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub enum TypeModel {
|
||||
#[serde(rename = "illust")]
|
||||
Illust,
|
||||
#[serde(rename = "manga")]
|
||||
Manga,
|
||||
#[serde(rename = "ugoira")]
|
||||
Ugoira,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct UserInfoModel {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct ImageUrlsModel {
|
||||
pub medium: String,
|
||||
pub large: String,
|
||||
#[serde(default)]
|
||||
pub original: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct IllustrationTagModel {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct MetaSinglePageModel {
|
||||
#[serde(default)]
|
||||
pub original_image_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct MetaPageModel {
|
||||
pub image_urls: ImageUrlsModel,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct UgoiraMetadataModel {
|
||||
/// Older API shape (`zip_url`); newer responses use `zip_urls.medium`.
|
||||
#[serde(default)]
|
||||
pub zip_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub zip_urls: Option<UgoiraZipUrlsModel>,
|
||||
pub frames: Vec<UgoiraFrameModel>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct UgoiraZipUrlsModel {
|
||||
pub medium: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct UgoiraFrameModel {
|
||||
pub delay: u32,
|
||||
}
|
||||
Reference in New Issue
Block a user