rule34/
lib.rs

1mod client;
2mod error;
3mod search_query_builder;
4mod types;
5mod util;
6
7#[cfg(feature = "scrape")]
8pub use crate::types::HtmlPost;
9pub use crate::{
10    client::{
11        Client,
12        NotesListQueryBuilder,
13        PostListQueryBuilder,
14        TagListQueryBuilder,
15    },
16    error::Error,
17    search_query_builder::SearchQueryBuilder,
18    types::{
19        DeletedImageList,
20        Note,
21        NoteList,
22        Post,
23        PostList,
24        PostStatus,
25        Rating,
26        Tag,
27        TagKind,
28        TagList,
29    },
30};
31#[cfg(feature = "scrape")]
32pub use scraper::Html;
33use std::num::NonZeroU64;
34pub use url::Url;
35
36/// The maximum number of responses per post list request
37pub const POST_LIST_LIMIT_MAX: u16 = 1_000;
38/// The maximum number of responses per tags list request.
39///
40/// This is undocumented.
41/// The documented limit is 100.
42pub const TAGS_LIST_LIMIT_MAX: u16 = 1_000;
43
44// URL constants
45pub(crate) const URL_INDEX: &str = "https://rule34.xxx/index.php";
46
47/// The base Api Url
48pub(crate) const API_BASE_URL: &str = "https://api.rule34.xxx/index.php";
49
50/// Turn a post id into a post url
51fn post_id_to_html_post_url(id: NonZeroU64) -> Url {
52    // It shouldn't be possible to make this function fail for any valid id.
53    Url::parse_with_params(
54        crate::URL_INDEX,
55        &[
56            ("id", itoa::Buffer::new().format(id.get())),
57            ("page", "post"),
58            ("s", "view"),
59        ],
60    )
61    .unwrap()
62}
63
64#[cfg(test)]
65mod test {
66    use super::*;
67    use std::sync::LazyLock;
68
69    #[derive(serde::Deserialize)]
70    struct Config {
71        user_id: u64,
72        api_key: String,
73    }
74
75    impl Config {
76        fn load() -> Self {
77            let raw =
78                std::fs::read_to_string("./config.json").expect("failed to read \"config.json\"");
79            let config: Config = serde_json::from_str(&raw).expect("failed to parse config");
80
81            config
82        }
83    }
84
85    static CONFIG: LazyLock<Config> = LazyLock::new(Config::load);
86
87    static RUNTIME: LazyLock<tokio::runtime::Runtime> =
88        LazyLock::new(|| tokio::runtime::Runtime::new().expect("failed to init runtime"));
89
90    static CLIENT: LazyLock<Client> = LazyLock::new(|| {
91        let client = Client::new();
92        client.set_auth(CONFIG.user_id, &CONFIG.api_key);
93        client
94    });
95
96    #[ignore]
97    #[test]
98    fn search() {
99        let res = RUNTIME
100            .block_on(CLIENT.list_posts().tags(Some("rust")).execute())
101            .expect("failed to search rule34 for \"rust\"");
102        dbg!(&res);
103        assert!(!res.posts.is_empty());
104    }
105
106    async fn get_top_post(query: &str) {
107        let response = CLIENT
108            .list_posts()
109            .tags(Some(query))
110            .limit(Some(crate::POST_LIST_LIMIT_MAX))
111            .execute()
112            .await
113            .unwrap_or_else(|error| panic!("failed to search rule34 for \"{query}\": {error}"));
114        assert!(!response.posts.is_empty(), "no posts for \"{query}\"");
115
116        dbg!(&response);
117
118        #[cfg(feature = "scrape")]
119        {
120            let first = response.posts.first().expect("missing first entry");
121            let post = CLIENT
122                .get_html_post(first.id)
123                .await
124                .expect("failed to get first post");
125            dbg!(post);
126        }
127    }
128
129    #[ignore]
130    #[test]
131    fn it_works() {
132        let list = [
133            "rust",
134            "fbi",
135            "gif",
136            "corna",
137            "sledge",
138            "roadhog",
139            "deep_space_waifu",
140            "aokuro",
141        ];
142
143        RUNTIME.block_on(async move {
144            for item in list {
145                get_top_post(item).await;
146            }
147        });
148    }
149
150    #[ignore]
151    #[test]
152    fn deleted_images_list() {
153        // Just choose a high-ish post id here and update to keep the download limited
154        let min = Some(NonZeroU64::new(826_550).unwrap());
155
156        let result = RUNTIME
157            .block_on(CLIENT.list_deleted_images(min))
158            .expect("failed to get deleted images");
159        dbg!(result);
160    }
161
162    #[ignore]
163    #[test]
164    fn tags_list() {
165        let result = RUNTIME
166            .block_on(
167                CLIENT
168                    .list_tags()
169                    .limit(Some(crate::TAGS_LIST_LIMIT_MAX))
170                    .order(Some("name"))
171                    .execute(),
172            )
173            .expect("failed to list tags");
174        assert!(!result.tags.is_empty());
175        // dbg!(result);
176    }
177
178    #[ignore]
179    #[test]
180    fn bad_tags_list() {
181        let tags = [
182            // TODO: I think this tag was deleted
183            // "swallow_(pokémon_move)",
184            "akoúo̱_(rwby)",
185            "miló_(rwby)",
186            "las_tres_niñas_(company)",
187            "ooparts♥love",
188            "almáriel",
189            "kingdom_hearts_union_χ_[cross]",
190            "gen¹³",
191            "nancy’s_face_is_deeper_in_carrie’s_ass",
192            "…",
193            "cleaning_&_clearing_(blue_archive)",
194            "watashi_ga_suki_nara_\"suki\"_tte_itte!",
195            "<3",
196            ">_<",
197            "dr—worm",
198            "master_hen'tai",
199            "ne-α_parasite",
200            "ne-α_type",
201            "lützow_(azur_lane)",
202            "ä",
203        ];
204
205        RUNTIME.block_on(async move {
206            for expected_tag_name in tags {
207                let tags = CLIENT
208                    .list_tags()
209                    .name(Some(expected_tag_name))
210                    .execute()
211                    .await
212                    .expect("failed to get tag")
213                    .tags;
214                let tags_len = tags.len();
215
216                assert!(
217                    tags_len == 1,
218                    "failed to get tags for \"{expected_tag_name}\", tags does not have one tag, it has {tags_len} tags"
219                );
220                let tag = tags.first().expect("tag list is empty");
221                let actual_tag_name = &*tag.name;
222
223                assert!(
224                    actual_tag_name == expected_tag_name,
225                    "\"{actual_tag_name}\" != \"{expected_tag_name}\""
226                );
227            }
228        });
229    }
230
231    #[ignore]
232    #[test]
233    fn notes_list() {
234        let result = RUNTIME
235            .block_on(CLIENT.list_notes().execute())
236            .expect("failed to list notes");
237        assert!(!result.notes.is_empty());
238        dbg!(result);
239    }
240
241    #[ignore]
242    #[test]
243    fn source() {
244        let response_1 = RUNTIME
245            .block_on(CLIENT.list_posts().id(NonZeroU64::new(1)).execute())
246            .expect("failed to get post 1");
247        let post_1 = response_1.posts.first().expect("missing post");
248        assert!(post_1.id.get() == 1);
249        assert!(post_1.source.is_none());
250
251        let response_3 = RUNTIME
252            .block_on(CLIENT.list_posts().id(NonZeroU64::new(3)).execute())
253            .expect("failed to get post 3");
254        let post_3 = response_3.posts.first().expect("missing post");
255        assert!(post_3.id.get() == 3);
256        assert!(post_3.source.as_deref() == Some("https://www.pixiv.net/en/artworks/12972758"));
257    }
258}