rule34/
client.rs

1mod note_list_query_builder;
2mod post_list_query_builder;
3mod tag_list_query_builder;
4
5pub use self::{
6    note_list_query_builder::NotesListQueryBuilder,
7    post_list_query_builder::PostListQueryBuilder,
8    tag_list_query_builder::TagListQueryBuilder,
9};
10#[cfg(feature = "scrape")]
11use crate::HtmlPost;
12use crate::{
13    DeletedImageList,
14    Error,
15};
16use reqwest::header::{
17    HeaderMap,
18    HeaderValue,
19};
20#[cfg(feature = "scrape")]
21use scraper::Html;
22use std::{
23    num::NonZeroU64,
24    sync::{
25        Arc,
26        Mutex,
27    },
28    time::Duration,
29};
30use url::Url;
31
32// Default Header values
33static USER_AGENT_VALUE: HeaderValue = HeaderValue::from_static("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4514.0 Safari/537.36");
34static REFERER_VALUE: HeaderValue = HeaderValue::from_static("https://rule34.xxx/");
35static ACCEPT_LANGUAGE_VALUE: HeaderValue = HeaderValue::from_static("en,en-US;q=0,5");
36static ACCEPT_VALUE: HeaderValue = HeaderValue::from_static("*/*");
37
38#[derive(Debug)]
39struct AuthState {
40    user_id: u64,
41    api_key: String,
42}
43
44/// A Rule34 Client
45#[derive(Debug, Clone)]
46pub struct Client {
47    /// The inner http client.
48    ///
49    /// This probably shouldn't be used by you.
50    pub client: reqwest::Client,
51
52    /// The auth state.
53    auth_state: Arc<Mutex<Option<AuthState>>>,
54}
55
56impl Client {
57    /// Make a new [`Client`].
58    pub fn new() -> Self {
59        let mut default_headers = HeaderMap::new();
60        default_headers.insert(reqwest::header::USER_AGENT, USER_AGENT_VALUE.clone());
61        default_headers.insert(
62            reqwest::header::ACCEPT_LANGUAGE,
63            ACCEPT_LANGUAGE_VALUE.clone(),
64        );
65        default_headers.insert(reqwest::header::ACCEPT, ACCEPT_VALUE.clone());
66        default_headers.insert(reqwest::header::REFERER, REFERER_VALUE.clone());
67
68        let client = reqwest::Client::builder()
69            .default_headers(default_headers)
70            .connect_timeout(Duration::from_secs(10))
71            .build()
72            .expect("failed to build rule34 client");
73
74        Client {
75            client,
76            auth_state: Arc::new(Mutex::new(None)),
77        }
78    }
79
80    /// Set the authentication.
81    pub fn set_auth(&self, user_id: u64, api_key: &str) {
82        let mut auth_state = self
83            .auth_state
84            .lock()
85            .unwrap_or_else(|error| error.into_inner());
86
87        *auth_state = Some(AuthState {
88            user_id,
89            api_key: api_key.into(),
90        });
91    }
92
93    /// Get the auth state.
94    fn get_auth(&self) -> std::sync::MutexGuard<'_, Option<AuthState>> {
95        self.auth_state
96            .lock()
97            .unwrap_or_else(|error| error.into_inner())
98    }
99
100    /// Send a GET web request to a `url` and get the result as a [`String`].
101    async fn get_text(&self, url: &str) -> Result<String, Error> {
102        Ok(self
103            .client
104            .get(url)
105            .timeout(Duration::from_secs(90))
106            .send()
107            .await?
108            .error_for_status()?
109            .text()
110            .await?)
111    }
112
113    /// Send a GET web request to a `uri` and get the result as [`Html`],
114    /// then use the given func to process it.
115    #[cfg(feature = "scrape")]
116    async fn get_html<F, T>(&self, uri: &str, f: F) -> Result<T, Error>
117    where
118        F: FnOnce(Html) -> T + Send + 'static,
119        T: Send + 'static,
120    {
121        let text = self.get_text(uri).await?;
122        let ret =
123            tokio::task::spawn_blocking(move || f(Html::parse_document(text.as_str()))).await?;
124        Ok(ret)
125    }
126
127    /// Send a GET web request to a `uri` and get the result as xml, deserializing it to the given type.
128    async fn get_xml<T>(&self, uri: &str) -> Result<T, Error>
129    where
130        T: serde::de::DeserializeOwned + Send + 'static,
131    {
132        let text = self.get_text(uri).await?;
133        let ret = tokio::task::spawn_blocking(move || quick_xml::de::from_str(&text)).await??;
134        Ok(ret)
135    }
136
137    /// Create a builder to list posts from rule34.
138    pub fn list_posts(&self) -> PostListQueryBuilder<'_> {
139        PostListQueryBuilder::new(self)
140    }
141
142    /// Get a [`HtmlPost`] by `id`.
143    #[cfg(feature = "scrape")]
144    pub async fn get_html_post(&self, id: NonZeroU64) -> Result<HtmlPost, Error> {
145        let url = crate::post_id_to_html_post_url(id);
146        let ret = self
147            .get_html(url.as_str(), |html| HtmlPost::from_html(&html))
148            .await??;
149
150        Ok(ret)
151    }
152
153    /// Get a list of deleted images.
154    ///
155    /// Only include ids over `last_id`. Use `None` for no limit.
156    ///
157    /// # Warning
158    /// Due to current technical limitations,
159    /// this function is not very memory efficient depending on `last_id`.
160    /// This will require buffering ~30MB into memory.
161    /// You should probably limit its use with a semaphore or similar.
162    pub async fn list_deleted_images(
163        &self,
164        last_id: Option<NonZeroU64>,
165    ) -> Result<DeletedImageList, Error> {
166        let mut url = Url::parse_with_params(
167            crate::API_BASE_URL,
168            &[
169                ("page", "dapi"),
170                ("s", "post"),
171                ("q", "index"),
172                ("deleted", "show"),
173            ],
174        )?;
175        if let Some(last_id) = last_id {
176            let mut last_id_buf = itoa::Buffer::new();
177            url.query_pairs_mut()
178                .append_pair("last_id", last_id_buf.format(last_id.get()));
179        }
180
181        {
182            let auth = self.get_auth();
183            let auth = auth.as_ref().ok_or(Error::MissingAuth)?;
184
185            let mut query_pairs_mut = url.query_pairs_mut();
186
187            query_pairs_mut.append_pair("user_id", itoa::Buffer::new().format(auth.user_id));
188            query_pairs_mut.append_pair("api_key", &auth.api_key);
189        }
190
191        // Parse on a threadpool since the full returned string is currently around 30 megabytes in size,
192        // and we need to run in under a few milliseconds.
193        // We need to buffer this all in memory though, since `quick_xml` does not provide a streaming api.
194        self.get_xml(url.as_str()).await
195    }
196
197    /// Get a builder to list tags.
198    pub fn list_tags(&self) -> TagListQueryBuilder<'_> {
199        TagListQueryBuilder::new(self)
200    }
201
202    /// Get a builder to list notes.
203    ///
204    /// This is undocumented.
205    pub fn list_notes(&self) -> NotesListQueryBuilder<'_> {
206        NotesListQueryBuilder::new(self)
207    }
208}
209
210impl Default for Client {
211    fn default() -> Self {
212        Self::new()
213    }
214}