novel_cli/cmd/
search.rs

1use std::{path::PathBuf, sync::Arc};
2
3use clap::Args;
4use color_eyre::eyre::{self, Result};
5use fluent_templates::Loader;
6use novel_api::{
7    CiweimaoClient, CiyuanjiClient, Client, Options, SfacgClient, Tag, WordCountRange,
8};
9use tokio::sync::Semaphore;
10use url::Url;
11
12use crate::{
13    cmd::{Convert, Source},
14    utils, LANG_ID, LOCALES,
15};
16
17#[must_use]
18#[derive(Args)]
19#[command(arg_required_else_help = true,
20    about = LOCALES.lookup(&LANG_ID, "search_command"))]
21pub struct Search {
22    #[arg(short, long,
23        help = LOCALES.lookup(&LANG_ID, "source"))]
24    pub source: Source,
25
26    #[arg(long, default_value_t = false,
27        help = LOCALES.lookup(&LANG_ID, "show_categories"))]
28    pub show_categories: bool,
29
30    #[arg(long, default_value_t = false,
31        help = LOCALES.lookup(&LANG_ID, "show_tags"))]
32    pub show_tags: bool,
33
34    #[arg(help = LOCALES.lookup(&LANG_ID, "keyword"))]
35    pub keyword: Option<String>,
36
37    #[arg(long, help = LOCALES.lookup(&LANG_ID, "min_word_count"))]
38    pub min_word_count: Option<u32>,
39
40    #[arg(long, help = LOCALES.lookup(&LANG_ID, "max_word_count"))]
41    pub max_word_count: Option<u32>,
42
43    #[arg(long, help = LOCALES.lookup(&LANG_ID, "update_days"))]
44    pub update_days: Option<u8>,
45
46    #[arg(long, help = LOCALES.lookup(&LANG_ID, "is_finished"))]
47    pub is_finished: Option<bool>,
48
49    #[arg(long, help = LOCALES.lookup(&LANG_ID, "is_vip"))]
50    pub is_vip: Option<bool>,
51
52    #[arg(long, help = LOCALES.lookup(&LANG_ID, "category"))]
53    pub category: Option<String>,
54
55    #[arg(long, value_delimiter = ',',
56        help = LOCALES.lookup(&LANG_ID, "tags"))]
57    pub tags: Vec<String>,
58
59    #[arg(long, value_delimiter = ',',
60    help = LOCALES.lookup(&LANG_ID, "excluded_tags"))]
61    pub excluded_tags: Vec<String>,
62
63    #[arg(long, default_value_t = 10, value_parser = clap::value_parser!(u8).range(1..=100),
64      help = LOCALES.lookup(&LANG_ID, "limit"))]
65    pub limit: u8,
66
67    #[arg(short, long, value_enum, value_delimiter = ',',
68        help = LOCALES.lookup(&LANG_ID, "converts"))]
69    pub converts: Vec<Convert>,
70
71    #[arg(long, default_value_t = false,
72        help = LOCALES.lookup(&LANG_ID, "ignore_keyring"))]
73    pub ignore_keyring: bool,
74
75    #[arg(short, long, default_value_t = 8, value_parser = clap::value_parser!(u8).range(1..=8),
76    help = LOCALES.lookup(&LANG_ID, "maximum_concurrency"))]
77    pub maximum_concurrency: u8,
78
79    #[arg(long, num_args = 0..=1, default_missing_value = super::DEFAULT_PROXY,
80        help = LOCALES.lookup(&LANG_ID, "proxy"))]
81    pub proxy: Option<Url>,
82
83    #[arg(long, default_value_t = false,
84        help = LOCALES.lookup(&LANG_ID, "no_proxy"))]
85    pub no_proxy: bool,
86
87    #[arg(long, num_args = 0..=1, default_missing_value = super::default_cert_path(),
88        help = super::cert_help_msg())]
89    pub cert: Option<PathBuf>,
90}
91
92pub async fn execute(config: Search) -> Result<()> {
93    match config.source {
94        Source::Sfacg => {
95            let mut client = SfacgClient::new().await?;
96            super::set_options(&mut client, &config.proxy, &config.no_proxy, &config.cert);
97            do_execute(client, config).await?;
98        }
99        Source::Ciweimao => {
100            let mut client = CiweimaoClient::new().await?;
101            super::set_options(&mut client, &config.proxy, &config.no_proxy, &config.cert);
102            utils::log_in(&client, &config.source, config.ignore_keyring).await?;
103            do_execute(client, config).await?;
104        }
105        Source::Ciyuanji => {
106            let mut client = CiyuanjiClient::new().await?;
107            super::set_options(&mut client, &config.proxy, &config.no_proxy, &config.cert);
108            utils::log_in_without_password(&client).await?;
109            do_execute(client, config).await?;
110        }
111    }
112
113    Ok(())
114}
115
116async fn do_execute<T>(client: T, config: Search) -> Result<()>
117where
118    T: Client + Send + Sync + 'static,
119{
120    let client = Arc::new(client);
121    super::handle_ctrl_c(&client);
122
123    if config.show_categories {
124        let categories = client.categories().await?;
125        println!("{}", vec_to_string(categories)?);
126    } else if config.show_tags {
127        let tags = client.tags().await?;
128        println!("{}", vec_to_string(tags)?);
129    } else {
130        let mut page = 0;
131        let semaphore = Arc::new(Semaphore::new(config.maximum_concurrency as usize));
132
133        let options = create_options(&client, &config).await?;
134        tracing::debug!("{options:#?}");
135
136        let mut novel_infos = Vec::new();
137        loop {
138            let size = u16::clamp(config.limit as u16 - novel_infos.len() as u16, 10, 50);
139
140            let novel_ids = client.search_infos(&options, page, size).await?;
141            page += 1;
142
143            if novel_ids.is_none() {
144                break;
145            }
146
147            let mut handles = Vec::new();
148            for novel_id in novel_ids.unwrap() {
149                let client = Arc::clone(&client);
150                let permit = semaphore.clone().acquire_owned().await.unwrap();
151
152                handles.push(tokio::spawn(async move {
153                    let novel_info = utils::novel_info(&client, novel_id).await?;
154                    drop(permit);
155                    eyre::Ok(novel_info)
156                }));
157            }
158
159            for handle in handles {
160                let novel_info = handle.await??;
161
162                if !novel_infos.contains(&novel_info) {
163                    novel_infos.push(novel_info);
164                }
165            }
166
167            if novel_infos.len() >= config.limit as usize {
168                break;
169            }
170        }
171
172        novel_infos.truncate(config.limit as usize);
173
174        utils::print_novel_infos(novel_infos, &config.converts)?;
175    }
176
177    Ok(())
178}
179
180async fn create_options<T>(client: &Arc<T>, config: &Search) -> Result<Options>
181where
182    T: Client,
183{
184    let mut options = Options {
185        keyword: config.keyword.clone(),
186        is_finished: config.is_finished,
187        is_vip: config.is_vip,
188        update_days: config.update_days,
189        ..Default::default()
190    };
191
192    if config.category.is_some() {
193        let categories = client.categories().await?;
194        let categories_name = config.category.as_ref().unwrap();
195
196        match categories
197            .iter()
198            .find(|category| category.name == *categories_name)
199        {
200            Some(category) => options.category = Some(category.clone()),
201            None => {
202                eyre::bail!(
203                    "The category was not found: `{categories_name}`, all available categories are: `{}`",
204                    vec_to_string(categories)?
205                );
206            }
207        }
208    }
209
210    if !config.tags.is_empty() {
211        options.tags = Some(to_tags(client, &config.tags).await?)
212    }
213
214    if !config.excluded_tags.is_empty() {
215        options.excluded_tags = Some(to_tags(client, &config.excluded_tags).await?)
216    }
217
218    if config.min_word_count.is_some() && config.max_word_count.is_none() {
219        options.word_count = Some(WordCountRange::RangeFrom(config.min_word_count.unwrap()..));
220    } else if config.min_word_count.is_none() && config.max_word_count.is_some() {
221        options.word_count = Some(WordCountRange::RangeTo(..config.max_word_count.unwrap()));
222    } else if config.min_word_count.is_some() && config.max_word_count.is_some() {
223        options.word_count = Some(WordCountRange::Range(
224            config.min_word_count.unwrap()..config.max_word_count.unwrap(),
225        ));
226    }
227
228    Ok(options)
229}
230
231async fn to_tags<T>(client: &Arc<T>, tag_names: &Vec<String>) -> Result<Vec<Tag>>
232where
233    T: Client,
234{
235    let mut result = Vec::new();
236
237    let tags = client.tags().await?;
238    for tag_name in tag_names {
239        match tags.iter().find(|tag| tag.name == *tag_name) {
240            Some(tag) => result.push(tag.clone()),
241            None => {
242                eyre::bail!(
243                    "The tag was not found: `{tag_name}`, all available tags are: `{}`",
244                    vec_to_string(tags)?
245                );
246            }
247        }
248    }
249
250    Ok(result)
251}
252
253fn vec_to_string<T>(vec: &[T]) -> Result<String>
254where
255    T: ToString,
256{
257    let result = vec
258        .iter()
259        .map(|item| item.to_string())
260        .collect::<Vec<String>>()
261        .join("、");
262
263    Ok(result)
264}