1mod structure;
2mod utils;
3
4use std::{io::Cursor, path::PathBuf};
5
6use chrono::{DateTime, Utc};
7use chrono_tz::{Asia::Shanghai, Tz};
8use image::{DynamicImage, ImageReader};
9use tokio::sync::OnceCell;
10use url::Url;
11
12use self::structure::*;
13use crate::{
14 Category, ChapterInfo, Client, Comment, CommentType, ContentInfo, ContentInfos, Error,
15 FindImageResult, FindTextResult, HTTPClient, LongComment, NovelDB, NovelInfo, Options,
16 ShortComment, Tag, UserInfo, VolumeInfo, VolumeInfos, WordCountRange,
17};
18
19#[must_use]
21pub struct SfacgClient {
22 proxy: Option<Url>,
23 no_proxy: bool,
24 cert_path: Option<PathBuf>,
25
26 client: OnceCell<HTTPClient>,
27 client_rss: OnceCell<HTTPClient>,
28
29 db: OnceCell<NovelDB>,
30}
31
32impl Client for SfacgClient {
33 fn proxy(&mut self, proxy: Url) {
34 self.proxy = Some(proxy);
35 }
36
37 fn no_proxy(&mut self) {
38 self.no_proxy = true;
39 }
40
41 fn cert(&mut self, cert_path: PathBuf) {
42 self.cert_path = Some(cert_path);
43 }
44
45 async fn shutdown(&self) -> Result<(), Error> {
46 self.client().await?.shutdown()
47 }
48
49 async fn add_cookie(&self, cookie_str: &str, url: &Url) -> Result<(), Error> {
50 self.client().await?.add_cookie(cookie_str, url)
51 }
52
53 async fn log_in(&self, username: String, password: Option<String>) -> Result<(), Error> {
54 assert!(!username.is_empty());
55 assert!(password.is_some());
56
57 let password = password.unwrap();
58
59 let response = self
60 .post("/sessions", LogInRequest { username, password })
61 .await?
62 .json::<GenericResponse>()
63 .await?;
64 response.status.check()?;
65
66 Ok(())
67 }
68
69 async fn logged_in(&self) -> Result<bool, Error> {
70 let response = self.get("/user").await?.json::<GenericResponse>().await?;
71
72 if response.status.unauthorized() {
73 Ok(false)
74 } else {
75 response.status.check()?;
76 Ok(true)
77 }
78 }
79
80 async fn user_info(&self) -> Result<UserInfo, Error> {
81 let response = self.get("/user").await?.json::<UserInfoResponse>().await?;
82 response.status.check()?;
83 let data = response.data.unwrap();
84
85 Ok(UserInfo {
86 nickname: data.nick_name.trim().to_string(),
87 avatar: Some(data.avatar),
88 })
89 }
90
91 async fn money(&self) -> Result<u32, Error> {
92 let response = self
93 .get("/user/money")
94 .await?
95 .json::<MoneyResponse>()
96 .await?;
97 response.status.check()?;
98 let data = response.data.unwrap();
99
100 Ok(data.fire_money_remain + data.coupons_remain)
101 }
102
103 async fn sign_in(&self) -> Result<(), Error> {
104 let now: DateTime<Tz> = Utc::now().with_timezone(&Shanghai);
105
106 let response = self
107 .put(
108 "/user/newSignInfo",
109 SignRequest {
110 sign_date: now.format("%Y-%m-%d").to_string(),
111 },
112 )
113 .await?
114 .json::<GenericResponse>()
115 .await?;
116 if response.status.already_signed_in() {
117 tracing::info!("{}", response.status.msg.unwrap().trim())
118 } else {
119 response.status.check()?;
120 }
121
122 Ok(())
123 }
124
125 async fn bookshelf_infos(&self) -> Result<Vec<u32>, Error> {
126 let response = self
127 .get_query("/user/Pockets", BookshelfInfoRequest { expand: "novels" })
128 .await?
129 .json::<BookshelfInfoResponse>()
130 .await?;
131 response.status.check()?;
132 let data = response.data.unwrap();
133
134 let mut result = Vec::with_capacity(32);
135 for info in data {
136 if info.expand.is_some() {
137 let novels = info.expand.unwrap().novels;
138
139 if novels.is_some() {
140 for novel_info in novels.unwrap() {
141 result.push(novel_info.novel_id);
142 }
143 }
144 }
145 }
146
147 Ok(result)
148 }
149
150 async fn novel_info(&self, id: u32) -> Result<Option<NovelInfo>, Error> {
151 assert!(id > 0 && id <= i32::MAX as u32);
152
153 let response = self
154 .get_query(
155 format!("/novels/{id}"),
156 NovelInfoRequest {
157 expand: "intro,typeName,sysTags",
158 },
159 )
160 .await?
161 .json::<NovelInfoResponse>()
162 .await?;
163 if response.status.not_found() {
164 return Ok(None);
165 }
166 response.status.check()?;
167 let data = response.data.unwrap();
168
169 let category = Category {
170 id: Some(data.type_id),
171 parent_id: None,
172 name: data.expand.type_name.trim().to_string(),
173 };
174
175 let novel_info = NovelInfo {
176 id,
177 name: data.novel_name.trim().to_string(),
178 author_name: data.author_name.trim().to_string(),
179 cover_url: Some(data.novel_cover),
180 introduction: super::parse_multi_line(data.expand.intro),
181 word_count: SfacgClient::parse_word_count(data.char_count),
182 is_vip: Some(data.sign_status == "VIP"),
183 is_finished: Some(data.is_finish),
184 create_time: Some(data.add_time),
185 update_time: Some(data.last_update_time),
186 category: Some(category),
187 tags: self.parse_tags(data.expand.sys_tags).await?,
188 };
189
190 Ok(Some(novel_info))
191 }
192
193 async fn comments(
194 &self,
195 id: u32,
196 comment_type: CommentType,
197 need_replies: bool,
198 page: u16,
199 size: u16,
200 ) -> Result<Option<Vec<Comment>>, Error> {
201 assert!(id <= i32::MAX as u32);
202
203 match comment_type {
204 CommentType::Short => self.do_short_comments(id, need_replies, page, size).await,
205 CommentType::Long => self.do_long_comments(id, need_replies, page, size).await,
206 }
207 }
208
209 async fn volume_infos(&self, id: u32) -> Result<Option<VolumeInfos>, Error> {
210 assert!(id <= i32::MAX as u32);
211
212 let response = self
213 .get(format!("/novels/{id}/dirs"))
214 .await?
215 .json::<VolumeInfosResponse>()
216 .await?;
217
218 if response.status.not_available() {
219 return Ok(None);
220 }
221
222 response.status.check()?;
223 let data = response.data.unwrap();
224
225 let mut volumes = VolumeInfos::with_capacity(8);
226 for volume in data.volume_list {
227 let mut volume_info = VolumeInfo {
228 id: volume.volume_id,
229 title: volume.title.trim().to_string(),
230 chapter_infos: Vec::with_capacity(volume.chapter_list.len()),
231 };
232
233 for chapter in volume.chapter_list {
234 let chapter_info = ChapterInfo {
235 novel_id: Some(chapter.novel_id),
236 id: chapter.chap_id,
237 title: chapter.title.trim().to_string(),
238 word_count: Some(chapter.char_count),
239 create_time: Some(chapter.add_time),
240 update_time: chapter.update_time,
241 is_vip: Some(chapter.is_vip),
242 price: Some(chapter.need_fire_money),
243 payment_required: Some(chapter.need_fire_money != 0),
244 is_valid: None,
245 };
246
247 volume_info.chapter_infos.push(chapter_info);
248 }
249
250 volumes.push(volume_info);
251 }
252
253 Ok(Some(volumes))
254 }
255
256 async fn content_infos(&self, info: &ChapterInfo) -> Result<ContentInfos, Error> {
257 let content;
258
259 match self.db().await?.find_text(info).await? {
260 FindTextResult::Ok(str) => {
261 content = str;
262 }
263 other => {
264 let response = self
265 .get_query(
266 format!("/Chaps/{}", info.id),
267 ContentInfosRequest {
268 expand: "content,isContentEncrypted",
269 },
270 )
271 .await?
272 .json::<ContentInfosResponse>()
273 .await?;
274 response.status.check()?;
275 let data = response.data.unwrap();
276
277 if data.expand.is_content_encrypted {
278 content = SfacgClient::convert(data.expand.content);
279 } else {
280 content = data.expand.content;
281 }
282
283 if content.trim().is_empty() {
284 return Err(Error::NovelApi(String::from("Content is empty")));
285 }
286
287 match other {
288 FindTextResult::None => self.db().await?.insert_text(info, &content).await?,
289 FindTextResult::Outdate => self.db().await?.update_text(info, &content).await?,
290 FindTextResult::Ok(_) => (),
291 }
292 }
293 }
294
295 let mut content_infos = ContentInfos::with_capacity(128);
296 for line in content
297 .lines()
298 .map(|line| line.trim())
299 .filter(|line| !line.is_empty())
300 {
301 if line.starts_with("[img") {
302 match SfacgClient::parse_image_url(line) {
303 Ok(url) => content_infos.push(ContentInfo::Image(url)),
304 Err(err) => tracing::error!("{err}"),
305 }
306 } else {
307 content_infos.push(ContentInfo::Text(line.to_string()));
308 }
309 }
310
311 Ok(content_infos)
312 }
313
314 async fn order_chapter(&self, info: &ChapterInfo) -> Result<(), Error> {
315 let response = self
316 .post(
317 &format!("/novels/{}/orderedchaps", info.novel_id.unwrap()),
318 OrderRequest {
319 order_all: false,
320 auto_order: false,
321 chap_ids: vec![info.id],
322 order_type: "readOrder",
323 },
324 )
325 .await?
326 .json::<GenericResponse>()
327 .await?;
328 if response.status.already_ordered() {
329 tracing::info!("{}", response.status.msg.unwrap().trim())
330 } else {
331 response.status.check()?;
332 }
333
334 Ok(())
335 }
336
337 async fn order_novel(&self, id: u32, _: &VolumeInfos) -> Result<(), Error> {
338 assert!(id > 0 && id <= i32::MAX as u32);
339
340 let response = self
341 .post(
342 &format!("/novels/{id}/orderedchaps",),
343 OrderRequest {
344 order_all: true,
345 auto_order: false,
346 chap_ids: vec![],
347 order_type: "readOrder",
348 },
349 )
350 .await?
351 .json::<GenericResponse>()
352 .await?;
353 if response.status.already_ordered() {
354 tracing::info!("{}", response.status.msg.unwrap().trim())
355 } else {
356 response.status.check()?;
357 }
358
359 Ok(())
360 }
361
362 async fn image(&self, url: &Url) -> Result<DynamicImage, Error> {
363 match self.db().await?.find_image(url).await? {
364 FindImageResult::Ok(image) => Ok(image),
365 FindImageResult::None => {
366 let response = self.get_rss(url).await?;
367 let bytes = response.bytes().await?;
368
369 let image = ImageReader::new(Cursor::new(&bytes))
370 .with_guessed_format()?
371 .decode()?;
372
373 self.db().await?.insert_image(url, bytes).await?;
374
375 Ok(image)
376 }
377 }
378 }
379
380 async fn categories(&self) -> Result<&Vec<Category>, Error> {
381 static CATEGORIES: OnceCell<Vec<Category>> = OnceCell::const_new();
382
383 CATEGORIES
384 .get_or_try_init(|| async {
385 let response = self
386 .get("/noveltypes")
387 .await?
388 .json::<CategoryResponse>()
389 .await?;
390 response.status.check()?;
391 let data = response.data.unwrap();
392
393 let mut result = Vec::with_capacity(8);
394 for tag_data in data {
395 result.push(Category {
396 id: Some(tag_data.type_id),
397 parent_id: None,
398 name: tag_data.type_name.trim().to_string(),
399 });
400 }
401
402 result.sort_unstable_by_key(|x| x.id.unwrap());
403
404 Ok(result)
405 })
406 .await
407 }
408
409 async fn tags(&self) -> Result<&Vec<Tag>, Error> {
410 static TAGS: OnceCell<Vec<Tag>> = OnceCell::const_new();
411
412 TAGS.get_or_try_init(|| async {
413 let response = self
414 .get("/novels/0/sysTags")
415 .await?
416 .json::<TagResponse>()
417 .await?;
418 response.status.check()?;
419 let data = response.data.unwrap();
420
421 let mut result = Vec::with_capacity(64);
422 for tag_data in data {
423 result.push(Tag {
424 id: Some(tag_data.sys_tag_id),
425 name: tag_data.tag_name.trim().to_string(),
426 });
427 }
428
429 result.push(Tag {
431 id: Some(74),
432 name: "百合".to_string(),
433 });
434
435 result.sort_unstable_by_key(|x| x.id.unwrap());
436
437 Ok(result)
438 })
439 .await
440 }
441
442 async fn search_infos(
443 &self,
444 option: &Options,
445 page: u16,
446 size: u16,
447 ) -> Result<Option<Vec<u32>>, Error> {
448 assert!(size <= 50, "The maximum number of items per page is 50");
449
450 if option.keyword.is_some() {
451 self.do_search_with_keyword(option, page, size).await
452 } else {
453 self.do_search_without_keyword(option, page, size).await
454 }
455 }
456
457 fn has_this_type_of_comments(comment_type: CommentType) -> bool {
458 match comment_type {
459 CommentType::Short => true,
460 CommentType::Long => true,
461 }
462 }
463}
464
465impl SfacgClient {
466 async fn do_short_comments(
467 &self,
468 id: u32,
469 need_replies: bool,
470 page: u16,
471 size: u16,
472 ) -> Result<Option<Vec<Comment>>, Error> {
473 assert!(size <= 50);
474
475 let response = self
476 .get_query(
477 format!("/novels/{id}/Cmts"),
478 ShortCommentRequest {
479 page,
480 size,
481 r#type: "clear",
482 sort: "smart",
483 },
484 )
485 .await?
486 .json::<CommentResponse>()
487 .await?;
488 response.status.check()?;
489 let data = response.data.unwrap();
490
491 if data.is_empty() {
492 return Ok(None);
493 }
494
495 let mut result = Vec::with_capacity(data.len());
496
497 for comment in data {
498 let Some(content) = super::parse_multi_line(comment.content) else {
499 continue;
500 };
501
502 result.push(Comment::Short(ShortComment {
503 id: comment.comment_id,
504 user: UserInfo {
505 nickname: comment.display_name.trim().to_string(),
506 avatar: Some(comment.avatar),
507 },
508 content,
509 create_time: Some(comment.create_time),
510 like_count: Some(comment.fav_count),
511 replies: if need_replies && comment.reply_num > 0 {
512 self.comment_replies(comment.comment_id, CommentType::Short, comment.reply_num)
513 .await?
514 } else {
515 None
516 },
517 }));
518 }
519
520 Ok(Some(result))
521 }
522
523 async fn do_long_comments(
524 &self,
525 id: u32,
526 need_replies: bool,
527 page: u16,
528 size: u16,
529 ) -> Result<Option<Vec<Comment>>, Error> {
530 assert!(size <= 20);
531
532 let response = self
533 .get_query(
534 format!("/novels/{id}/lcmts"),
535 LongCommentRequest {
536 page,
537 size,
538 charlen: 140,
539 sort: "addtime",
540 },
541 )
542 .await?
543 .json::<CommentResponse>()
544 .await?;
545 response.status.check()?;
546 let data = response.data.unwrap();
547
548 if data.is_empty() {
549 return Ok(None);
550 }
551
552 let mut result = Vec::with_capacity(data.len());
553
554 for comment in data {
555 result.push(Comment::Long(LongComment {
556 id: comment.comment_id,
557 user: UserInfo {
558 nickname: comment.display_name.trim().to_string(),
559 avatar: Some(comment.avatar),
560 },
561 title: comment.title.unwrap().trim().to_string(),
562 content: self.long_comment_content(comment.comment_id).await?,
563 create_time: Some(comment.create_time),
564 like_count: Some(comment.fav_count),
565 replies: if need_replies && comment.reply_num > 0 {
566 self.comment_replies(comment.comment_id, CommentType::Long, comment.reply_num)
567 .await?
568 } else {
569 None
570 },
571 }));
572 }
573
574 Ok(Some(result))
575 }
576
577 async fn long_comment_content(&self, comment_id: u32) -> Result<Vec<String>, Error> {
578 let response = self
579 .get(format!("/lcmts/{comment_id}"))
580 .await?
581 .json::<LongCommentContentResponse>()
582 .await?;
583 response.status.check()?;
584 let data = response.data.unwrap();
585
586 Ok(super::parse_multi_line(data.content).unwrap())
587 }
588
589 async fn comment_replies(
590 &self,
591 comment_id: u32,
592 comment_type: CommentType,
593 total: u16,
594 ) -> Result<Option<Vec<ShortComment>>, Error> {
595 let url = match comment_type {
596 CommentType::Short => format!("/cmts/{}/replys", comment_id),
597 CommentType::Long => format!("/lcmts/{}/replys", comment_id),
598 };
599
600 let mut page = 0;
601 let size = 50;
602 let total_page = if total % size == 0 {
603 total / size
604 } else {
605 total / size + 1
606 };
607 let mut reply_list = Vec::with_capacity(total as usize);
608
609 while page < total_page {
610 let response = self
611 .get_query(&url, ReplyRequest { page, size })
612 .await?
613 .json::<ReplyResponse>()
614 .await?;
615 response.status.check()?;
616 let data = response.data.unwrap();
617
618 for reply in data {
619 let Some(content) = super::parse_multi_line(reply.content) else {
620 continue;
621 };
622
623 reply_list.push(ShortComment {
624 id: reply.reply_id,
625 user: UserInfo {
626 nickname: reply.display_name.trim().to_string(),
627 avatar: Some(reply.avatar),
628 },
629 content,
630 create_time: Some(reply.create_time),
631 like_count: None,
632 replies: None,
633 });
634 }
635
636 page += 1;
637 }
638
639 if reply_list.is_empty() {
640 Ok(None)
641 } else {
642 reply_list.sort_unstable_by_key(|x| x.create_time.unwrap());
643 reply_list.dedup();
644 Ok(Some(reply_list))
645 }
646 }
647
648 async fn do_search_with_keyword(
649 &self,
650 option: &Options,
651 page: u16,
652 size: u16,
653 ) -> Result<Option<Vec<u32>>, Error> {
654 let is_finish = if option.is_finished.is_none() {
658 -1
659 } else if *option.is_finished.as_ref().unwrap() {
660 1
661 } else {
662 0
663 };
664
665 let update_days = if option.update_days.is_none() {
667 -1
668 } else {
669 option.update_days.unwrap() as i8
670 };
671
672 let response = self
673 .get_query(
674 "/search/novels/result/new",
675 SearchRequest {
676 q: option.keyword.as_ref().unwrap().to_string(),
677 is_finish,
678 update_days,
679 systagids: SfacgClient::tag_ids(&option.tags),
680 page,
681 size,
682 sort: "hot",
688 expand: "sysTags",
689 },
690 )
691 .await?
692 .json::<SearchResponse>()
693 .await?;
694 response.status.check()?;
695 let data = response.data.unwrap();
696
697 if data.novels.is_empty() {
698 return Ok(None);
699 }
700
701 let mut result = Vec::new();
702 let sys_tags = self.tags().await?;
703
704 for novel_info in data.novels {
705 let mut tag_ids = vec![];
706
707 for tag in novel_info.expand.sys_tags {
708 if let Some(sys_tag) = sys_tags.iter().find(|x| x.id.unwrap() == tag.sys_tag_id) {
709 tag_ids.push(sys_tag.id.unwrap());
710 }
711 }
712
713 if SfacgClient::match_category(option, novel_info.type_id)
714 && SfacgClient::match_excluded_tags(option, tag_ids)
715 && SfacgClient::match_vip(option, &novel_info.sign_status)
716 && SfacgClient::match_word_count(option, novel_info.char_count)
717 {
718 result.push(novel_info.novel_id);
719 }
720 }
721
722 Ok(Some(result))
723 }
724
725 async fn do_search_without_keyword(
726 &self,
727 option: &Options,
728 page: u16,
729 size: u16,
730 ) -> Result<Option<Vec<u32>>, Error> {
731 let mut category_id = 0;
732 if option.category.is_some() {
733 category_id = option.category.as_ref().unwrap().id.unwrap();
734 }
735
736 let updatedays = if option.update_days.is_none() {
738 -1
739 } else {
740 option.update_days.unwrap() as i8
741 };
742
743 let isfinish = SfacgClient::bool_to_str(&option.is_finished);
744 let isfree = SfacgClient::bool_to_str(&option.is_vip.as_ref().map(|x| !x));
745
746 let systagids = SfacgClient::tag_ids(&option.tags);
747 let notexcludesystagids = SfacgClient::tag_ids(&option.excluded_tags);
748
749 let mut charcountbegin = 0;
750 let mut charcountend = 0;
751
752 if option.word_count.is_some() {
753 match option.word_count.as_ref().unwrap() {
754 WordCountRange::Range(range) => {
755 charcountbegin = range.start;
756 charcountend = range.end;
757 }
758 WordCountRange::RangeFrom(range_from) => charcountbegin = range_from.start,
759 WordCountRange::RangeTo(range_to) => charcountend = range_to.end,
760 }
761 }
762
763 let response = self
764 .get_query(
765 format!("/novels/{category_id}/sysTags/novels"),
766 NovelsRequest {
767 charcountbegin,
768 charcountend,
769 isfinish,
770 isfree,
771 systagids,
772 notexcludesystagids,
773 updatedays,
774 page,
775 size,
776 sort: "viewtimes",
782 },
783 )
784 .await?
785 .json::<NovelsResponse>()
786 .await?;
787 response.status.check()?;
788 let data = response.data.unwrap();
789
790 if data.is_empty() {
791 return Ok(None);
792 }
793
794 let mut result = Vec::new();
795 for novel_data in data {
796 result.push(novel_data.novel_id);
797 }
798
799 Ok(Some(result))
800 }
801
802 fn parse_word_count(word_count: i32) -> Option<u32> {
803 if word_count <= 0 {
805 None
806 } else {
807 Some(word_count as u32)
808 }
809 }
810
811 async fn parse_tags(&self, tag_list: Vec<NovelInfoSysTag>) -> Result<Option<Vec<Tag>>, Error> {
812 let sys_tags = self.tags().await?;
813
814 let mut result = Vec::new();
815 for tag in tag_list {
816 let id = tag.sys_tag_id;
817 let name = tag.tag_name.trim().to_string();
818
819 if sys_tags.iter().any(|sys_tag| sys_tag.id.unwrap() == id) {
821 result.push(Tag { id: Some(id), name });
822 } else {
823 tracing::info!("This tag is not a system tag and is ignored: {name}");
824 }
825 }
826
827 if result.is_empty() {
828 Ok(None)
829 } else {
830 result.sort_unstable_by_key(|x| x.id.unwrap());
831 Ok(Some(result))
832 }
833 }
834
835 fn parse_image_url(line: &str) -> Result<Url, Error> {
836 let begin = line.find("http");
837 let end = line.find("[/img]");
838
839 if begin.is_none() || end.is_none() {
840 return Err(Error::NovelApi(format!(
841 "Image URL format is incorrect: {line}"
842 )));
843 }
844
845 let begin = begin.unwrap();
846 let end = end.unwrap();
847
848 let url = line
849 .chars()
850 .skip(begin)
851 .take(end - begin)
852 .collect::<String>()
853 .trim()
854 .to_string();
855
856 match Url::parse(&url) {
857 Ok(url) => Ok(url),
858 Err(error) => Err(Error::NovelApi(format!(
859 "Image URL parse failed: {error}, content: {line}"
860 ))),
861 }
862 }
863
864 fn bool_to_str(flag: &Option<bool>) -> &'static str {
865 if flag.is_some() {
866 if *flag.as_ref().unwrap() {
867 "is"
868 } else {
869 "not"
870 }
871 } else {
872 "both"
873 }
874 }
875
876 fn tag_ids(tags: &Option<Vec<Tag>>) -> Option<String> {
877 tags.as_ref().map(|tags| {
878 tags.iter()
879 .map(|tag| tag.id.unwrap().to_string())
880 .collect::<Vec<String>>()
881 .join(",")
882 })
883 }
884
885 fn match_vip(option: &Options, sign_status: &str) -> bool {
886 if option.is_vip.is_none() {
887 return true;
888 }
889
890 if *option.is_vip.as_ref().unwrap() {
891 sign_status == "VIP"
892 } else {
893 sign_status != "VIP"
894 }
895 }
896
897 fn match_excluded_tags(option: &Options, tag_ids: Vec<u16>) -> bool {
898 if option.excluded_tags.is_none() {
899 return true;
900 }
901
902 tag_ids.iter().all(|id| {
903 !option
904 .excluded_tags
905 .as_ref()
906 .unwrap()
907 .iter()
908 .any(|tag| tag.id.unwrap() == *id)
909 })
910 }
911
912 fn match_category(option: &Options, category_id: u16) -> bool {
913 if option.category.is_none() {
914 return true;
915 }
916
917 let category = option.category.as_ref().unwrap();
918 category.id.unwrap() == category_id
919 }
920
921 fn match_word_count(option: &Options, word_count: i32) -> bool {
922 if option.word_count.is_none() {
923 return true;
924 }
925
926 if word_count <= 0 {
927 return true;
928 }
929
930 let word_count = word_count as u32;
931 match option.word_count.as_ref().unwrap() {
932 WordCountRange::Range(range) => {
933 if word_count >= range.start && word_count < range.end {
934 return true;
935 }
936 }
937 WordCountRange::RangeFrom(range_from) => {
938 if word_count >= range_from.start {
939 return true;
940 }
941 }
942 WordCountRange::RangeTo(rang_to) => {
943 if word_count < rang_to.end {
944 return true;
945 }
946 }
947 }
948
949 false
950 }
951}