1mod structure;
2mod utils;
3
4use std::io::Cursor;
5use std::path::PathBuf;
6use std::sync::RwLock;
7
8use chrono::{Duration, Local, NaiveDateTime, TimeZone};
9use chrono_tz::Asia::Shanghai;
10use image::{DynamicImage, ImageReader};
11use serde::{Deserialize, Serialize};
12use tokio::sync::OnceCell;
13use url::Url;
14
15use self::structure::*;
16use crate::{
17 Category, ChapterInfo, Client, Comment, CommentType, ContentInfo, ContentInfos, Error,
18 FindImageResult, FindTextResult, HTTPClient, NovelDB, NovelInfo, Options, ShortComment, Tag,
19 UserInfo, VolumeInfo, VolumeInfos, WordCountRange,
20};
21
22#[must_use]
23#[derive(Serialize, Deserialize)]
24pub(crate) struct Config {
25 token: String,
26}
27
28#[must_use]
30pub struct CiyuanjiClient {
31 proxy: Option<Url>,
32 no_proxy: bool,
33 cert_path: Option<PathBuf>,
34
35 client: OnceCell<HTTPClient>,
36 client_rss: OnceCell<HTTPClient>,
37
38 db: OnceCell<NovelDB>,
39
40 config: RwLock<Option<Config>>,
41}
42
43impl Client for CiyuanjiClient {
44 fn proxy(&mut self, proxy: Url) {
45 self.proxy = Some(proxy);
46 }
47
48 fn no_proxy(&mut self) {
49 self.no_proxy = true;
50 }
51
52 fn cert(&mut self, cert_path: PathBuf) {
53 self.cert_path = Some(cert_path);
54 }
55
56 async fn shutdown(&self) -> Result<(), Error> {
57 self.client().await?.save_cookies()?;
58 self.do_shutdown()?;
59 Ok(())
60 }
61
62 async fn add_cookie(&self, cookie_str: &str, url: &Url) -> Result<(), Error> {
63 self.client().await?.add_cookie(cookie_str, url)
64 }
65
66 async fn log_in(&self, username: String, password: Option<String>) -> Result<(), Error> {
67 assert!(!username.is_empty());
68 assert!(password.is_none());
69
70 let response: GenericResponse = self
71 .post(
72 "/login/getPhoneCode",
73 PhoneCodeRequest {
74 phone: username.clone(),
75 sms_type: "1",
77 },
78 )
79 .await?;
80 utils::check_response_success(response.code, response.msg, response.ok)?;
81
82 let response: LoginResponse = self
83 .post(
84 "/login/phone",
85 LoginRequest {
86 phone: username,
87 phone_code: crate::input("Please enter SMS verification code")?,
88 },
89 )
90 .await?;
91 utils::check_response_success(response.code, response.msg, response.ok)?;
92
93 self.save_token(Config {
94 token: response.data.unwrap().user_info.unwrap().token,
95 });
96
97 Ok(())
98 }
99
100 async fn logged_in(&self) -> Result<bool, Error> {
101 if !self.has_token() {
102 return Ok(false);
103 }
104
105 let response: GenericResponse = self.get("/user/getUserInfo").await?;
106
107 if response.code == CiyuanjiClient::FAILED {
108 Ok(false)
109 } else {
110 utils::check_response_success(response.code, response.msg, response.ok)?;
111 Ok(true)
112 }
113 }
114
115 async fn user_info(&self) -> Result<UserInfo, Error> {
116 let response: UserInfoResponse = self.get("/user/getUserInfo").await?;
117 utils::check_response_success(response.code, response.msg, response.ok)?;
118 let cm_user = response.data.unwrap().cm_user.unwrap();
119
120 let user_info = UserInfo {
121 nickname: cm_user.nick_name.trim().to_string(),
122 avatar: Some(cm_user.img_url),
123 };
124
125 Ok(user_info)
126 }
127
128 async fn money(&self) -> Result<u32, Error> {
129 let response: MoneyResponse = self.get("/account/getAccountByUser").await?;
130 utils::check_response_success(response.code, response.msg, response.ok)?;
131 let account_info = response.data.unwrap().account_info.unwrap();
132
133 Ok(account_info.currency_balance + account_info.coupon_balance)
134 }
135
136 async fn sign_in(&self) -> Result<(), Error> {
137 let response: GenericResponse = self.post("/sign/sign", EmptyRequest {}).await?;
138 if utils::check_already_signed_in(&response.code, &response.msg) {
139 tracing::info!("{}", CiyuanjiClient::ALREADY_SIGNED_IN_MSG);
140 } else {
141 utils::check_response_success(response.code, response.msg, response.ok)?;
142 }
143
144 Ok(())
145 }
146
147 async fn bookshelf_infos(&self) -> Result<Vec<u32>, Error> {
148 let response: BookSelfResponse = self
149 .get_query(
150 "/bookrack/getUserBookRackList",
151 BookSelfRequest {
152 page_no: 1,
153 page_size: 9999,
154 rank_type: 1,
157 },
158 )
159 .await?;
160 utils::check_response_success(response.code, response.msg, response.ok)?;
161
162 let mut result = Vec::new();
163
164 for item in response.data.unwrap().book_rack_list.unwrap() {
165 result.push(item.book_id);
166 }
167
168 Ok(result)
169 }
170
171 async fn novel_info(&self, id: u32) -> Result<Option<NovelInfo>, Error> {
172 assert!(id > 0);
173
174 let response: BookDetailResponse = self
175 .get_query(
176 "/book/getBookDetail",
177 BookDetailRequest {
178 book_id: id.to_string(),
179 },
180 )
181 .await?;
182 utils::check_response_success(response.code, response.msg, response.ok)?;
183 let data = response.data.unwrap();
184
185 if data.book.is_none() {
186 return Ok(None);
187 }
188
189 let book = data.book.unwrap();
190
191 if book.book_id == 0 {
193 return Ok(None);
194 }
195
196 let category = if book.second_classify.is_some() {
197 Some(Category {
198 id: Some(book.second_classify.unwrap()),
199 parent_id: Some(book.first_classify.unwrap()),
200 name: format!(
201 "{}-{}",
202 book.first_classify_name.unwrap().trim(),
203 book.second_classify_name.unwrap().trim()
204 ),
205 })
206 } else if book.first_classify.is_some() {
207 Some(Category {
208 id: Some(book.first_classify.unwrap()),
209 parent_id: None,
210 name: book.first_classify_name.unwrap().trim().to_string(),
211 })
212 } else {
213 None
214 };
215
216 let novel_info = NovelInfo {
217 id,
218 name: book.book_name.unwrap().trim().to_string(),
219 author_name: book.author_name.unwrap().trim().to_string(),
220 cover_url: book.img_url,
221 introduction: super::parse_multi_line(book.notes.unwrap()),
222 word_count: CiyuanjiClient::parse_word_count(book.word_count),
223 is_vip: Some(book.is_vip.unwrap() == "1"),
224 is_finished: Some(book.end_state.unwrap() == "1"),
225 create_time: None,
226 update_time: book.latest_update_time,
227 category,
228 tags: self.parse_tags(book.tag_list.unwrap()).await?,
229 };
230
231 Ok(Some(novel_info))
232 }
233
234 async fn comments(
235 &self,
236 id: u32,
237 comment_type: CommentType,
238 need_replies: bool,
239 page: u16,
240 size: u16,
241 ) -> Result<Option<Vec<Comment>>, Error> {
242 assert!(matches!(comment_type, CommentType::Short));
243
244 let response: PostListResponse = self
245 .get_query(
246 "/apppost/getAppPostListByCircleId",
247 PostListRequest {
248 circle_id: id.to_string(),
249 circle_type: 1,
250 page_no: page + 1,
251 page_size: size,
252 rank_type: 2,
255 },
256 )
257 .await?;
258 utils::check_response_success(response.code, response.msg, response.ok)?;
259 let data = response.data.unwrap().list.unwrap();
260
261 if data.is_empty() {
262 return Ok(None);
263 }
264
265 let mut result = Vec::with_capacity(data.len());
266
267 for post in data {
268 if need_replies {
269 if let Some(post) = self.post_detail(post.post_id).await? {
270 result.push(Comment::Short(post));
271 }
272 } else {
273 let Some(content) = super::parse_multi_line(post.post_content) else {
274 continue;
275 };
276
277 result.push(Comment::Short(ShortComment {
278 id: post.post_id,
279 user: UserInfo {
280 nickname: post.nick_name.trim().to_string(),
281 avatar: Some(post.user_img_url),
282 },
283 content,
284 create_time: Some(post.create_time),
285 like_count: Some(post.thumb_num),
286 replies: None,
287 }));
288 }
289 }
290
291 Ok(Some(result))
292 }
293
294 async fn volume_infos(&self, id: u32) -> Result<Option<VolumeInfos>, Error> {
295 let response: ChapterListResponse = self
296 .get_query(
297 "/chapter/getChapterListByBookId",
298 VolumeInfosRequest {
299 sort_type: "1",
302 page_no: "1",
303 page_size: "9999",
304 book_id: id.to_string(),
305 },
306 )
307 .await?;
308 utils::check_response_success(response.code, response.msg, response.ok)?;
309
310 let mut volumes = VolumeInfos::new();
311
312 let mut last_volume_id = 0;
313 let book_chapter = response.data.unwrap().book_chapter.unwrap();
314
315 let book_id = book_chapter.book_id;
316
317 if book_chapter.chapter_list.is_some() {
318 for chapter in book_chapter.chapter_list.unwrap() {
319 let volume_title = chapter.title.unwrap_or_default().trim().to_string();
320
321 if chapter.volume_id != last_volume_id {
322 last_volume_id = chapter.volume_id;
323
324 volumes.push(VolumeInfo {
325 id: chapter.volume_id,
326 title: volume_title.clone(),
327 chapter_infos: Vec::new(),
328 });
329 }
330
331 let last_volume_title = &mut volumes.last_mut().unwrap().title;
332 if last_volume_title.is_empty() && !volume_title.is_empty() {
333 *last_volume_title = volume_title;
334 }
335
336 let chapter_info = ChapterInfo {
337 novel_id: Some(book_id),
338 id: chapter.chapter_id,
339 title: chapter.chapter_name.trim().to_string(),
340 is_vip: Some(chapter.is_fee == "1"),
341 price: Some(chapter.price.parse::<f64>().unwrap() as u16),
343 payment_required: Some(
344 chapter.is_fee == "1" && chapter.is_buy == "0",
345 ),
346 is_valid: None,
347 word_count: Some(chapter.word_count),
348 create_time: Some(chapter.publish_time),
349 update_time: None,
350 };
351
352 volumes.last_mut().unwrap().chapter_infos.push(chapter_info);
353 }
354 }
355
356 Ok(Some(volumes))
357 }
358
359 async fn content_infos(&self, info: &ChapterInfo) -> Result<ContentInfos, Error> {
360 let mut content;
361
362 match self.db().await?.find_text(info).await? {
363 FindTextResult::Ok(str) => {
364 content = str;
365 }
366 other => {
367 let response: ContentResponse = self
368 .get_query(
369 "/chapter/getChapterContent",
370 ContentRequest {
371 book_id: info.novel_id.unwrap().to_string(),
372 chapter_id: info.id.to_string(),
373 },
374 )
375 .await?;
376 utils::check_response_success(response.code, response.msg, response.ok)?;
377 let chapter = response.data.unwrap().chapter.unwrap();
378
379 content = crate::des_ecb_base64_decrypt(
380 CiyuanjiClient::DES_KEY,
381 chapter.content.replace('\n', ""),
382 )?;
383
384 if content.trim().is_empty() {
385 return Err(Error::NovelApi(String::from("Content is empty")));
386 }
387
388 if chapter.img_list.as_ref().is_some_and(|x| !x.is_empty()) {
389 let mut content_lines: Vec<_> =
390 content.lines().map(|x| x.to_string()).collect();
391
392 for img in chapter.img_list.as_ref().unwrap() {
393 let image_str = format!("[img]{}[/img]", img.img_url);
394
395 if img.paragraph_index > content_lines.len() {
397 tracing::warn!(
398 "The paragraph index of the image is greater than the number of paragraphs: {} > {}, in {}({}), image will be inserted at the end",
399 img.paragraph_index,
400 content_lines.len(),
401 info.title,
402 info.id
403 );
404
405 content_lines.push(image_str);
406 } else {
407 content_lines.insert(img.paragraph_index, image_str);
408 }
409 }
410
411 content = content_lines.join("\n");
412 }
413
414 match other {
415 FindTextResult::None => self.db().await?.insert_text(info, &content).await?,
416 FindTextResult::Outdate => self.db().await?.update_text(info, &content).await?,
417 FindTextResult::Ok(_) => (),
418 }
419 }
420 }
421
422 let mut content_infos = ContentInfos::new();
423 for line in content
424 .lines()
425 .map(|line| line.trim())
426 .filter(|line| !line.is_empty())
427 {
428 if line.starts_with("[img") {
429 if let Some(url) = CiyuanjiClient::parse_image_url(line) {
430 content_infos.push(ContentInfo::Image(url));
431 }
432 } else {
433 content_infos.push(ContentInfo::Text(line.to_string()));
434 }
435 }
436
437 Ok(content_infos)
438 }
439
440 async fn order_chapter(&self, info: &ChapterInfo) -> Result<(), Error> {
441 let response: GenericResponse = self
442 .post(
443 "/order/consume",
444 OrderChapterRequest {
445 view_type: "2",
447 consume_type: "1",
449 book_id: info.novel_id.unwrap().to_string(),
450 product_id: info.id.to_string(),
451 buy_count: "1",
452 },
453 )
454 .await?;
455 if utils::check_already_ordered(&response.code, &response.msg) {
456 tracing::info!("{}", CiyuanjiClient::ALREADY_ORDERED_MSG);
457 } else {
458 utils::check_response_success(response.code, response.msg, response.ok)?;
459 }
460
461 Ok(())
462 }
463
464 async fn order_novel(&self, id: u32, _: &VolumeInfos) -> Result<(), Error> {
465 assert!(id > 0);
466
467 let response: GenericResponse = self
468 .post(
469 "/order/consume",
470 OrderNovelRequest {
471 view_type: "2",
473 consume_type: "1",
475 book_id: id.to_string(),
476 is_all_unpaid: "1",
477 },
478 )
479 .await?;
480 if utils::check_already_ordered(&response.code, &response.msg) {
481 tracing::info!("{}", CiyuanjiClient::ALREADY_ORDERED_MSG);
482 } else {
483 utils::check_response_success(response.code, response.msg, response.ok)?;
484 }
485
486 Ok(())
487 }
488
489 async fn image(&self, url: &Url) -> Result<DynamicImage, Error> {
490 match self.db().await?.find_image(url).await? {
491 FindImageResult::Ok(image) => Ok(image),
492 FindImageResult::None => {
493 let response = self.get_rss(url).await?;
494 let bytes = response.bytes().await?;
495
496 let image = ImageReader::new(Cursor::new(&bytes))
497 .with_guessed_format()?
498 .decode()?;
499
500 self.db().await?.insert_image(url, bytes).await?;
501
502 Ok(image)
503 }
504 }
505 }
506
507 async fn categories(&self) -> Result<&Vec<Category>, Error> {
508 static CATEGORIES: OnceCell<Vec<Category>> = OnceCell::const_new();
509
510 CATEGORIES
511 .get_or_try_init(|| async {
512 let mut result = Vec::with_capacity(32);
513
514 self.get_categories("1", &mut result).await?;
518 self.get_categories("4", &mut result).await?;
519
520 result.sort_unstable_by_key(|x| x.id.unwrap());
521 result.dedup();
522 Ok(result)
523 })
524 .await
525 }
526
527 async fn tags(&self) -> Result<&Vec<Tag>, Error> {
528 static TAGS: OnceCell<Vec<Tag>> = OnceCell::const_new();
529
530 TAGS.get_or_try_init(|| async {
531 let mut result = Vec::with_capacity(64);
532
533 self.get_tags(1, &mut result).await?;
534 self.get_tags(4, &mut result).await?;
535
536 result.push(Tag {
537 id: Some(17),
538 name: String::from("无限流"),
539 });
540 result.push(Tag {
541 id: Some(19),
542 name: String::from("后宫"),
543 });
544 result.push(Tag {
545 id: Some(26),
546 name: String::from("变身"),
547 });
548 result.push(Tag {
549 id: Some(30),
550 name: String::from("百合"),
551 });
552 result.push(Tag {
553 id: Some(96),
554 name: String::from("变百"),
555 });
556 result.push(Tag {
557 id: Some(127),
558 name: String::from("性转"),
559 });
560 result.push(Tag {
561 id: Some(249),
562 name: String::from("吸血鬼"),
563 });
564 result.push(Tag {
565 id: Some(570),
566 name: String::from("纯百"),
567 });
568 result.push(Tag {
569 id: Some(1431),
570 name: String::from("复仇"),
571 });
572 result.push(Tag {
573 id: Some(1512),
574 name: String::from("魔幻"),
575 });
576 result.push(Tag {
577 id: Some(5793),
578 name: String::from("少女"),
579 });
580
581 result.sort_unstable_by_key(|x| x.id.unwrap());
582 result.dedup();
583 Ok(result)
584 })
585 .await
586 }
587
588 async fn search_infos(
589 &self,
590 option: &Options,
591 page: u16,
592 size: u16,
593 ) -> Result<Option<Vec<u32>>, Error> {
594 if option.keyword.is_some() {
595 self.do_search_with_keyword(option, page, size).await
596 } else {
597 self.do_search_without_keyword(option, page, size).await
598 }
599 }
600
601 fn has_this_type_of_comments(comment_type: CommentType) -> bool {
602 match comment_type {
603 CommentType::Short => true,
604 CommentType::Long => false,
605 }
606 }
607}
608
609impl CiyuanjiClient {
610 pub async fn post_detail(&self, post_id: u32) -> Result<Option<ShortComment>, Error> {
611 let response: PostDetailResponse = self
612 .get_query(
613 "/apppost/getApppostDetail",
614 PostDetailRequest {
615 post_id,
616 page_no: 1,
617 page_size: 9999,
618 rank_type: 2,
621 },
622 )
623 .await?;
624 utils::check_response_success(response.code, response.msg, response.ok)?;
625
626 let post_detail = response.data.unwrap();
627 let Some(content) = super::parse_multi_line(post_detail.post_content) else {
628 return Ok(None);
629 };
630
631 let replies = if !post_detail.list.is_empty() {
632 let mut replies = Vec::with_capacity(post_detail.list.len());
633
634 for reply in post_detail.list {
635 let Some(content) = super::parse_multi_line(reply.reply_content) else {
636 continue;
637 };
638
639 replies.push(ShortComment {
640 id: reply.reply_id,
641 user: UserInfo {
642 nickname: reply.nick_name.trim().to_string(),
643 avatar: Some(reply.user_img_url),
644 },
645 content,
646 create_time: Some(reply.create_time),
647 like_count: Some(reply.thumb_num),
648 replies: if reply.reply_num == 0 {
649 None
650 } else {
651 self.reply_list(reply.reply_id, reply.reply_num).await?
652 },
653 });
654 }
655
656 if replies.is_empty() {
657 None
658 } else {
659 replies.sort_unstable_by_key(|x| x.create_time.unwrap());
660 replies.dedup();
661 Some(replies)
662 }
663 } else {
664 None
665 };
666
667 Ok(Some(ShortComment {
668 id: post_id,
669 user: UserInfo {
670 nickname: post_detail.nick_name.trim().to_string(),
671 avatar: Some(post_detail.user_img_url),
672 },
673 content,
674 create_time: Some(post_detail.create_time),
675 like_count: Some(post_detail.thumb_num),
676 replies,
677 }))
678 }
679
680 pub async fn reply_list(
681 &self,
682 reply_id: u32,
683 reply_num: u16,
684 ) -> Result<Option<Vec<ShortComment>>, Error> {
685 let response: ReplyListResponse = self
686 .get_query(
687 "/reply/getReplyList",
688 ReplyListRequest {
689 reply_id,
690 page_size: reply_num,
691 },
692 )
693 .await?;
694 utils::check_response_success(response.code, response.msg, response.ok)?;
695
696 let mut result = Vec::with_capacity(reply_num as usize);
697
698 for reply in response.data.unwrap().list.unwrap() {
699 let Some(mut content) = super::parse_multi_line(reply.reply_content) else {
700 continue;
701 };
702
703 if reply.last_nick_name.is_some() {
704 content
705 .first_mut()
706 .unwrap()
707 .insert_str(0, &format!("回复@{} ", reply.last_nick_name.unwrap()));
708 }
709
710 result.push(ShortComment {
711 id: reply.reply_id,
712 user: UserInfo {
713 nickname: reply.nick_name.trim().to_string(),
714 avatar: Some(reply.user_img_url),
715 },
716 content,
717 create_time: Some(reply.create_time),
718 like_count: Some(reply.thumb_num),
719 replies: None,
720 });
721 }
722
723 if result.is_empty() {
724 Ok(None)
725 } else {
726 result.sort_unstable_by_key(|x| x.create_time.unwrap());
727 result.dedup();
728 Ok(Some(result))
729 }
730 }
731
732 async fn do_search_with_keyword(
733 &self,
734 option: &Options,
735 page: u16,
736 size: u16,
737 ) -> Result<Option<Vec<u32>>, Error> {
738 let (start_word, end_word) = CiyuanjiClient::to_word(option);
739 let (first_classify, _) = CiyuanjiClient::to_classify_ids(option);
740
741 let response: SearchBookListResponse = self
742 .get_query(
743 "/book/searchBookList",
744 SearchBookListRequest {
745 page_no: page + 1,
746 page_size: size,
747 rank_type: "0",
752 keyword: option.keyword.as_ref().unwrap().to_string(),
753 is_fee: CiyuanjiClient::to_is_fee(option),
754 end_state: CiyuanjiClient::to_end_state(option),
755 start_word,
756 end_word,
757 classify_ids: first_classify,
758 },
759 )
760 .await?;
761 utils::check_response_success(response.code, response.msg, response.ok)?;
762 let es_book_list = response.data.unwrap().es_book_list.unwrap();
763
764 if es_book_list.is_empty() {
765 return Ok(None);
766 }
767
768 let mut result = Vec::new();
769 let sys_tags = self.tags().await?;
770
771 for novel_info in es_book_list {
772 let mut tag_ids = Vec::new();
773
774 if novel_info.tag_name.is_some() {
775 let tag_names: Vec<_> = novel_info
776 .tag_name
777 .unwrap()
778 .split(',')
779 .map(|x| x.trim().to_string())
780 .filter(|x| !x.is_empty())
781 .collect();
782
783 for tag_name in tag_names {
784 if let Some(tag) = sys_tags.iter().find(|x| x.name == tag_name) {
785 tag_ids.push(tag.id.unwrap());
786 }
787 }
788 }
789
790 if CiyuanjiClient::match_update_days(option, novel_info.latest_update_time)
791 && CiyuanjiClient::match_tags(option, &tag_ids)
792 && CiyuanjiClient::match_excluded_tags(option, &tag_ids)
793 && CiyuanjiClient::match_category(
794 option,
795 novel_info.first_classify,
796 novel_info.second_classify,
797 )
798 {
799 result.push(novel_info.book_id);
800 }
801 }
802
803 Ok(Some(result))
804 }
805
806 async fn do_search_without_keyword(
807 &self,
808 option: &Options,
809 page: u16,
810 size: u16,
811 ) -> Result<Option<Vec<u32>>, Error> {
812 let (start_word, end_word) = CiyuanjiClient::to_word(option);
813 let (first_classify, second_classify) = CiyuanjiClient::to_classify_ids(option);
814
815 let response: BookListResponse = self
816 .get_query(
817 "/book/getBookListByParams",
818 BookListRequest {
819 page_no: page + 1,
820 page_size: size,
821 rank_type: "1",
827 first_classify,
828 second_classify,
829 start_word,
830 end_word,
831 is_fee: CiyuanjiClient::to_is_fee(option),
832 end_state: CiyuanjiClient::to_end_state(option),
833 },
834 )
835 .await?;
836 utils::check_response_success(response.code, response.msg, response.ok)?;
837 let book_list = response.data.unwrap().book_list.unwrap();
838
839 if book_list.is_empty() {
840 return Ok(None);
841 }
842
843 let mut result = Vec::new();
844 for novel_info in book_list {
845 let mut tag_ids = Vec::new();
846 if novel_info.tag_list.is_some() {
847 for tags in novel_info.tag_list.unwrap() {
848 tag_ids.push(tags.tag_id);
849 }
850 }
851
852 if CiyuanjiClient::match_update_days(option, novel_info.latest_update_time)
853 && CiyuanjiClient::match_tags(option, &tag_ids)
854 && CiyuanjiClient::match_excluded_tags(option, &tag_ids)
855 {
856 result.push(novel_info.book_id);
857 }
858 }
859
860 Ok(Some(result))
861 }
862
863 fn to_end_state(option: &Options) -> Option<String> {
864 option.is_finished.map(|x| {
865 if x {
866 String::from("1")
867 } else {
868 String::from("2")
869 }
870 })
871 }
872
873 fn to_is_fee(option: &Options) -> Option<String> {
874 option.is_vip.map(|x| {
875 if x {
876 String::from("1")
877 } else {
878 String::from("0")
879 }
880 })
881 }
882
883 fn to_word(option: &Options) -> (Option<String>, Option<String>) {
884 let mut start_word = None;
885 let mut end_word = None;
886
887 if option.word_count.is_some() {
888 match option.word_count.as_ref().unwrap() {
889 WordCountRange::Range(range) => {
890 start_word = Some(range.start.to_string());
891 end_word = Some(range.end.to_string());
892 }
893 WordCountRange::RangeFrom(range_from) => {
894 start_word = Some(range_from.start.to_string())
895 }
896 WordCountRange::RangeTo(range_to) => end_word = Some(range_to.end.to_string()),
897 }
898 }
899
900 (start_word, end_word)
901 }
902
903 fn to_classify_ids(option: &Options) -> (Option<String>, Option<String>) {
904 let mut first_classify = None;
905 let mut second_classify = None;
906
907 if option.category.is_some() {
908 let category = option.category.as_ref().unwrap();
909
910 if category.parent_id.is_some() {
911 first_classify = category.parent_id.map(|x| x.to_string());
912 second_classify = category.id.map(|x| x.to_string());
913 } else {
914 first_classify = category.id.map(|x| x.to_string());
915 }
916 }
917
918 (first_classify, second_classify)
919 }
920
921 fn match_update_days(option: &Options, update_time: Option<NaiveDateTime>) -> bool {
922 if option.update_days.is_none() || update_time.is_none() {
923 return true;
924 }
925
926 let other_time = Shanghai.from_local_datetime(&update_time.unwrap()).unwrap()
927 + Duration::try_days(*option.update_days.as_ref().unwrap() as i64).unwrap();
928
929 Local::now() <= other_time
930 }
931
932 fn match_category(
933 option: &Options,
934 first_classify: Option<u16>,
935 second_classify: Option<u16>,
936 ) -> bool {
937 if option.category.is_none() {
938 return true;
939 }
940
941 let category = option.category.as_ref().unwrap();
942
943 if category.parent_id.is_some() {
944 category.id == second_classify && category.parent_id == first_classify
945 } else {
946 category.id == first_classify
947 }
948 }
949
950 fn match_tags(option: &Options, tag_ids: &[u16]) -> bool {
951 if option.tags.is_none() {
952 return true;
953 }
954
955 option
956 .tags
957 .as_ref()
958 .unwrap()
959 .iter()
960 .all(|tag| tag_ids.contains(tag.id.as_ref().unwrap()))
961 }
962
963 fn match_excluded_tags(option: &Options, tag_ids: &[u16]) -> bool {
964 if option.excluded_tags.is_none() {
965 return true;
966 }
967
968 tag_ids.iter().all(|id| {
969 !option
970 .excluded_tags
971 .as_ref()
972 .unwrap()
973 .iter()
974 .any(|tag| tag.id.unwrap() == *id)
975 })
976 }
977
978 fn parse_word_count(word_count: i32) -> Option<u32> {
979 if word_count <= 0 {
981 None
982 } else {
983 Some(word_count as u32)
984 }
985 }
986
987 async fn parse_tags(&self, tag_list: Vec<BookTag>) -> Result<Option<Vec<Tag>>, Error> {
988 let sys_tags = self.tags().await?;
989
990 let mut result = Vec::new();
991 for tag in tag_list {
992 let name = tag.tag_name.trim().to_string();
993
994 if sys_tags.iter().any(|item| item.name == name) {
996 result.push(Tag {
997 id: Some(tag.tag_id),
998 name,
999 });
1000 } else {
1001 tracing::info!(
1002 "This tag is not a system tag and is ignored: {name}({})",
1003 tag.tag_id
1004 );
1005 }
1006 }
1007
1008 if result.is_empty() {
1009 Ok(None)
1010 } else {
1011 result.sort_unstable_by_key(|x| x.id.unwrap());
1012 Ok(Some(result))
1013 }
1014 }
1015
1016 fn parse_image_url(line: &str) -> Option<Url> {
1017 let begin = line.find("http").unwrap();
1018 let end = line.find("[/img]").unwrap();
1019
1020 let url = line
1021 .chars()
1022 .skip(begin)
1023 .take(end - begin)
1024 .collect::<String>()
1025 .trim()
1026 .to_string();
1027
1028 match Url::parse(&url) {
1029 Ok(url) => Some(url),
1030 Err(error) => {
1031 tracing::error!("Image URL parse failed: {error}, content: {line}");
1032 None
1033 }
1034 }
1035 }
1036
1037 async fn get_tags(&self, book_type: u16, result: &mut Vec<Tag>) -> Result<(), Error> {
1038 let response: TagsResponse = self
1039 .get_query(
1040 "/tag/getAppTagList",
1041 TagsRequest {
1042 page_no: 1,
1043 page_size: 99,
1044 book_type,
1045 },
1046 )
1047 .await?;
1048 utils::check_response_success(response.code, response.msg, response.ok)?;
1049
1050 for tag in response.data.unwrap().list.unwrap() {
1051 result.push(Tag {
1052 id: Some(tag.tag_id),
1053 name: tag.tag_name.trim().to_string(),
1054 });
1055 }
1056
1057 Ok(())
1058 }
1059
1060 async fn get_categories(
1061 &self,
1062 book_type: &'static str,
1063 result: &mut Vec<Category>,
1064 ) -> Result<(), Error> {
1065 let response: CategoryResponse = self
1066 .get_query(
1067 "/classify/getBookClassifyListByParams",
1068 CategoryRequest {
1069 page_no: 1,
1070 page_size: 99,
1071 book_type,
1072 },
1073 )
1074 .await?;
1075 utils::check_response_success(response.code, response.msg, response.ok)?;
1076
1077 for category in response.data.unwrap().classify_list.unwrap() {
1078 let basic_id = category.classify_id;
1079 let basic_name = category.classify_name.trim().to_string();
1080
1081 for child_category in category.child_list {
1082 result.push(Category {
1083 id: Some(child_category.classify_id),
1084 parent_id: Some(basic_id),
1085 name: format!("{basic_name}-{}", child_category.classify_name.trim()),
1086 });
1087 }
1088
1089 result.push(Category {
1090 id: Some(basic_id),
1091 parent_id: None,
1092 name: basic_name,
1093 });
1094 }
1095
1096 Ok(())
1097 }
1098}