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
36pub const POST_LIST_LIMIT_MAX: u16 = 1_000;
38pub const TAGS_LIST_LIMIT_MAX: u16 = 1_000;
43
44pub(crate) const URL_INDEX: &str = "https://rule34.xxx/index.php";
46
47pub(crate) const API_BASE_URL: &str = "https://api.rule34.xxx/index.php";
49
50fn post_id_to_html_post_url(id: NonZeroU64) -> Url {
52 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 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 }
177
178 #[ignore]
179 #[test]
180 fn bad_tags_list() {
181 let tags = [
182 "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}