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