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