Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb3cdf5322 | ||
|
|
eb39513960 | ||
|
|
30ec5b1bcc | ||
|
|
33ef5251d5 | ||
|
|
8df9afabbb |
Generated
+997
-45
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "web-scraper"
|
name = "web-scraper"
|
||||||
version = "0.1.1"
|
version = "0.1.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -8,9 +8,11 @@ anydoc = "0.1"
|
|||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
crossterm = "0.29"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
htmd = "0.1"
|
htmd = "0.1"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
|
ratatui = "0.30"
|
||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
md-5 = "0.10"
|
md-5 = "0.10"
|
||||||
reqwest = "0.12"
|
reqwest = "0.12"
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# web-scraper-rust
|
||||||
|
|
||||||
|
A polite, single-binary Rust web scraper that crawls pages, converts HTML to Markdown, and tries its best to turn Office documents into something readable too.
|
||||||
|
|
||||||
|
It respects `robots.txt`. It waits between requests. It hashes long URLs into filenames because filesystems have feelings too. It does not do cookies, JavaScript rendering, or anything fancy. If you need a headless browser, this is not your tool.
|
||||||
|
|
||||||
|
## What It Does
|
||||||
|
|
||||||
|
- Crawls internal links from a starting URL (BFS queue)
|
||||||
|
- Converts HTML pages to Markdown via `htmd`
|
||||||
|
- Detects and converts documents (PDF, DOCX, XLSX, PPTX, ODT, RTF, EPUB, CSV...) to Markdown via `anydoc`
|
||||||
|
- Respects `robots.txt` with wildcard pattern matching
|
||||||
|
- Deduplicates URLs, normalizes fragments and trailing query params
|
||||||
|
- Configurable request delay, retry with exponential backoff
|
||||||
|
- File type filtering via `--include-types` / `--exclude-types`
|
||||||
|
- Path scoping (stay within a subdirectory) or full-site crawl
|
||||||
|
- Subdomain crawling opt-in
|
||||||
|
- Single-page mode (grab one page, don't crawl further)
|
||||||
|
- Structured error logging to JSONL, kept in a separate `_logs` directory
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Thing | Choice | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| Language | Rust 2024 | Single static binary. No runtime. No GC. No regrets. |
|
||||||
|
| HTTP client | reqwest + tokio | Async, fast, well-maintained. |
|
||||||
|
| HTML parsing | scraper | CSS selectors on server-side HTML. |
|
||||||
|
| HTML→MD | htmd | Strips nav, footer, script tags. Produces clean Markdown. |
|
||||||
|
| Document conversion | anydoc | Detects format from bytes, converts to Markdown. Falls back to raw. |
|
||||||
|
| CLI | clap | Derive macros, typed args, good help text. |
|
||||||
|
| Logging | env_logger | Simple, sufficient. |
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Scrape a single page, save Markdown
|
||||||
|
web-scraper https://example.com/page --single
|
||||||
|
|
||||||
|
# Crawl entire site, only PDFs and HTML
|
||||||
|
web-scraper https://example.com --types pdf,html
|
||||||
|
|
||||||
|
# Crawl a subdirectory only, exclude images
|
||||||
|
web-scraper https://example.com/docs --exclude-types png,jpg,gif
|
||||||
|
|
||||||
|
# Include subdomains, custom delay
|
||||||
|
web-scraper https://example.com --subdomains --delay-ms 500
|
||||||
|
```
|
||||||
|
|
||||||
|
Output goes to `./<domain>_scraped/`, logs to `./<domain>_scraped_logs/`.
|
||||||
|
|
||||||
|
## CLI Flags
|
||||||
|
|
||||||
|
```
|
||||||
|
START_URL Starting URL to scrape
|
||||||
|
-o, --output DIR Output directory (default: <domain>_scraped)
|
||||||
|
--subdomains Also crawl subdomains of the base host
|
||||||
|
--single Only scrape the start URL, don't crawl
|
||||||
|
--no-scope Crawl any path on the same host
|
||||||
|
--no-doc-conversion Save documents as-is, skip conversion
|
||||||
|
--delay-ms N Delay between requests in ms (default: 1000)
|
||||||
|
--types a,b,c Only save these file types (HTML always crawled)
|
||||||
|
--exclude-types a,b Skip these file types
|
||||||
|
```
|
||||||
|
|
||||||
|
`--types` and `--exclude-types` are mutually exclusive. Both still crawl HTML pages for links regardless.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just build # clippy + fmt + release build
|
||||||
|
# or
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
Release profile is optimized for size: `opt-level = "z"`, LTO, single codegen unit, stripped.
|
||||||
|
|
||||||
|
## Error Logs
|
||||||
|
|
||||||
|
Errors are logged to `scrape_errors.jsonl` in the logs directory. Each entry includes timestamp, URL, error type, message, and HTTP status code if applicable. The log file is cleared at the start of each run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Built to scrape things. Respects robots.txt. Does not require a DevOps team.**
|
||||||
+165
-69
@@ -8,16 +8,18 @@ mod extractor;
|
|||||||
mod fetcher;
|
mod fetcher;
|
||||||
mod robots;
|
mod robots;
|
||||||
mod scraper;
|
mod scraper;
|
||||||
|
mod tui;
|
||||||
mod url_utils;
|
mod url_utils;
|
||||||
|
|
||||||
use crate::robots::fetch_robots;
|
use crate::robots::fetch_robots;
|
||||||
use crate::scraper::Scraper;
|
use crate::scraper::Scraper;
|
||||||
|
use crate::tui::TuiConfig;
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
@@ -26,16 +28,17 @@ use url::Url;
|
|||||||
#[command(author = "FLÓ")]
|
#[command(author = "FLÓ")]
|
||||||
#[command(version = "0.2.0")]
|
#[command(version = "0.2.0")]
|
||||||
#[command(about = "Web scraper for Faroese public sector sites")]
|
#[command(about = "Web scraper for Faroese public sector sites")]
|
||||||
|
#[allow(clippy::struct_excessive_bools)]
|
||||||
struct Args {
|
struct Args {
|
||||||
#[arg(help = "Starting URL to scrape")]
|
#[arg(help = "Starting URL to scrape")]
|
||||||
start_url: String,
|
start_url: Option<String>,
|
||||||
|
|
||||||
#[arg(
|
#[arg(
|
||||||
long,
|
long = "interactive",
|
||||||
default_value = "1000",
|
short = 'i',
|
||||||
help = "Delay in milliseconds between requests (default: 1000)"
|
help = "Launch interactive TUI configuration instead of CLI"
|
||||||
)]
|
)]
|
||||||
delay_ms: u64,
|
interactive: bool,
|
||||||
|
|
||||||
#[arg(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
@@ -49,9 +52,19 @@ struct Args {
|
|||||||
)]
|
)]
|
||||||
single: bool,
|
single: bool,
|
||||||
|
|
||||||
|
#[arg(long, help = "Disable path scoping — crawl any path on the same host")]
|
||||||
|
no_scope: bool,
|
||||||
|
|
||||||
#[arg(long, help = "Save documents as-is, skip Markdown conversion")]
|
#[arg(long, help = "Save documents as-is, skip Markdown conversion")]
|
||||||
no_doc_conversion: bool,
|
no_doc_conversion: bool,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
default_value = "1000",
|
||||||
|
help = "Delay in milliseconds between requests (default: 1000)"
|
||||||
|
)]
|
||||||
|
delay_ms: u64,
|
||||||
|
|
||||||
#[arg(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
help = "Comma-separated extensions to include (e.g. pdf,docx,html). \
|
help = "Comma-separated extensions to include (e.g. pdf,docx,html). \
|
||||||
@@ -81,31 +94,85 @@ fn parse_ext_list(s: &str) -> HashSet<String> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn main() -> Result<()> {
|
fn print_config(
|
||||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
start_url: &str,
|
||||||
|
base_host: &str,
|
||||||
|
subdomains: bool,
|
||||||
|
output_dir: &Path,
|
||||||
|
logs_dir: &Path,
|
||||||
|
convert_docs: bool,
|
||||||
|
single: bool,
|
||||||
|
delay_ms: u64,
|
||||||
|
include_types: Option<&HashSet<String>>,
|
||||||
|
exclude_types: Option<&HashSet<String>>,
|
||||||
|
scope_path: Option<&String>,
|
||||||
|
) {
|
||||||
|
log::info!("Scraping: {start_url}");
|
||||||
|
log::info!("Base host: {base_host}");
|
||||||
|
log::info!("Subdomain crawling: {}", if subdomains { "enabled" } else { "disabled" });
|
||||||
|
log::info!("Output dir: {}", output_dir.display());
|
||||||
|
log::info!("Logs dir: {}", logs_dir.display());
|
||||||
|
log::info!("Document conversion: {}", if convert_docs { "enabled" } else { "disabled" });
|
||||||
|
log::info!("Single page mode: {}", if single { "enabled" } else { "disabled" });
|
||||||
|
log::info!("Request delay: {delay_ms} ms");
|
||||||
|
|
||||||
let args = Args::parse();
|
match (include_types, exclude_types) {
|
||||||
|
(Some(t), _) => log::info!("Type filter: include {t:?}"),
|
||||||
|
(_, Some(t)) => log::info!("Type filter: exclude {t:?}"),
|
||||||
|
_ => log::info!("Type filter: none (all types)"),
|
||||||
|
}
|
||||||
|
|
||||||
let start_url = args.start_url;
|
match scope_path {
|
||||||
let convert_docs = !args.no_doc_conversion;
|
Some(s) => log::info!("Path scope: {s} (only this path and deeper)"),
|
||||||
let delay_ms = args.delay_ms;
|
None => log::info!("Path scope: none (full site)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let url = Url::parse(&start_url)?;
|
fn build_config_from_args(args: &Args) -> Result<(String, TuiConfig)> {
|
||||||
|
let url_str = args.start_url.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("START_URL is required when not using --interactive"))?;
|
||||||
|
|
||||||
let base_host = url
|
let tui_config = TuiConfig {
|
||||||
.host_str()
|
start_url: url_str.clone(),
|
||||||
.ok_or_else(|| anyhow::anyhow!("Could not parse hostname from {start_url}"))?
|
output_dir: args.output.clone().unwrap_or_default(),
|
||||||
|
subdomains: args.subdomains,
|
||||||
|
single: args.single,
|
||||||
|
no_scope: args.no_scope,
|
||||||
|
no_doc_conversion: args.no_doc_conversion,
|
||||||
|
delay_ms: args.delay_ms,
|
||||||
|
types: args.types.clone(),
|
||||||
|
exclude_types: args.exclude_types.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((url_str.clone(), tui_config))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
#[allow(clippy::fn_params_excessive_bools)]
|
||||||
|
async fn run_scraper(
|
||||||
|
start_url: &str,
|
||||||
|
subdomains: bool,
|
||||||
|
single: bool,
|
||||||
|
no_scope: bool,
|
||||||
|
no_doc_conversion: bool,
|
||||||
|
delay_ms: u64,
|
||||||
|
types: Option<&str>,
|
||||||
|
exclude_types: Option<&str>,
|
||||||
|
output: Option<&str>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let url = Url::parse(start_url)?;
|
||||||
|
|
||||||
|
let base_host = url.host_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Could not parse hostname"))?
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let base_domain = url_utils::derive_base_domain(&url)
|
let base_domain = url_utils::derive_base_domain(&url)
|
||||||
.ok_or_else(|| anyhow::anyhow!("Could not parse hostname from {start_url}"))?;
|
.ok_or_else(|| anyhow::anyhow!("Could not parse base domain"))?;
|
||||||
|
|
||||||
let output_dir = if let Some(p) = &args.output {
|
let output_dir = match output {
|
||||||
PathBuf::from(p)
|
Some(p) if !p.is_empty() => PathBuf::from(p),
|
||||||
} else {
|
_ => PathBuf::from(format!("{}_scraped", base_domain.replace('.', "_"))),
|
||||||
let default_name = format!("{}_scraped", base_domain.replace('.', "_"));
|
|
||||||
PathBuf::from(default_name)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let logs_dir = {
|
let logs_dir = {
|
||||||
@@ -114,8 +181,7 @@ async fn main() -> Result<()> {
|
|||||||
|n| n.to_string_lossy().into_owned(),
|
|n| n.to_string_lossy().into_owned(),
|
||||||
);
|
);
|
||||||
let logs_name = format!("{output_name}_logs");
|
let logs_name = format!("{output_name}_logs");
|
||||||
output_dir
|
output_dir.parent()
|
||||||
.parent()
|
|
||||||
.map_or_else(|| PathBuf::from(&logs_name), |p| p.join(&logs_name))
|
.map_or_else(|| PathBuf::from(&logs_name), |p| p.join(&logs_name))
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,56 +191,33 @@ async fn main() -> Result<()> {
|
|||||||
let log_path = logs_dir.join("scrape_errors.jsonl");
|
let log_path = logs_dir.join("scrape_errors.jsonl");
|
||||||
fs::remove_file(&log_path).ok();
|
fs::remove_file(&log_path).ok();
|
||||||
|
|
||||||
let (include_types, exclude_types) = match (&args.types, &args.exclude_types) {
|
let (include_types, exclude_types) = match (types, exclude_types) {
|
||||||
(Some(_), Some(_)) => {
|
(Some(_), Some(_)) => bail!("--types and --exclude-types are mutually exclusive"),
|
||||||
bail!("--types and --exclude-types are mutually exclusive");
|
|
||||||
}
|
|
||||||
(Some(t), None) => (Some(parse_ext_list(t)), None),
|
(Some(t), None) => (Some(parse_ext_list(t)), None),
|
||||||
(None, Some(t)) => (None, Some(parse_ext_list(t))),
|
(None, Some(t)) => (None, Some(parse_ext_list(t))),
|
||||||
(None, None) => (None, None),
|
(None, None) => (None, None),
|
||||||
};
|
};
|
||||||
|
|
||||||
let scope_path = {
|
let scope_path = if no_scope {
|
||||||
let path = url.path().trim_end_matches('/');
|
|
||||||
if path.is_empty() {
|
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(path.to_string())
|
let path = url.path().trim_end_matches('/');
|
||||||
}
|
if path.is_empty() { None } else { Some(path.to_string()) }
|
||||||
};
|
};
|
||||||
|
|
||||||
log::info!("Scraping: {start_url}");
|
print_config(
|
||||||
log::info!("Request delay: {delay_ms} ms");
|
start_url,
|
||||||
log::info!("Base host: {base_host}");
|
&base_host,
|
||||||
log::info!(
|
subdomains,
|
||||||
"Subdomain crawling: {}",
|
&output_dir,
|
||||||
if args.subdomains {
|
&logs_dir,
|
||||||
"enabled"
|
!no_doc_conversion,
|
||||||
} else {
|
single,
|
||||||
"disabled"
|
delay_ms,
|
||||||
}
|
include_types.as_ref(),
|
||||||
|
exclude_types.as_ref(),
|
||||||
|
scope_path.as_ref(),
|
||||||
);
|
);
|
||||||
log::info!("Output dir: {}", output_dir.display());
|
|
||||||
log::info!("Logs dir: {}", logs_dir.display());
|
|
||||||
log::info!(
|
|
||||||
"Document conversion: {}",
|
|
||||||
if convert_docs { "enabled" } else { "disabled" }
|
|
||||||
);
|
|
||||||
log::info!(
|
|
||||||
"Single page mode: {}",
|
|
||||||
if args.single { "enabled" } else { "disabled" }
|
|
||||||
);
|
|
||||||
|
|
||||||
match (&include_types, &exclude_types) {
|
|
||||||
(Some(t), _) => log::info!("Type filter: include {t:?}"),
|
|
||||||
(_, Some(t)) => log::info!("Type filter: exclude {t:?}"),
|
|
||||||
_ => log::info!("Type filter: none (all types)"),
|
|
||||||
}
|
|
||||||
|
|
||||||
match &scope_path {
|
|
||||||
Some(s) => log::info!("Path scope: {s} (only this path and deeper)"),
|
|
||||||
None => log::info!("Path scope: none (full site)"),
|
|
||||||
}
|
|
||||||
|
|
||||||
let client = Client::builder()
|
let client = Client::builder()
|
||||||
.timeout(Duration::from_secs(config::TIMEOUT_SECS))
|
.timeout(Duration::from_secs(config::TIMEOUT_SECS))
|
||||||
@@ -191,19 +234,72 @@ async fn main() -> Result<()> {
|
|||||||
log_path.clone(),
|
log_path.clone(),
|
||||||
base_host,
|
base_host,
|
||||||
robots,
|
robots,
|
||||||
convert_docs,
|
!no_doc_conversion,
|
||||||
fetcher,
|
fetcher,
|
||||||
include_types,
|
include_types,
|
||||||
exclude_types,
|
exclude_types,
|
||||||
scope_path,
|
scope_path,
|
||||||
args.single,
|
single,
|
||||||
args.subdomains,
|
subdomains,
|
||||||
delay_ms,
|
delay_ms,
|
||||||
);
|
);
|
||||||
let (count, errors) = scraper.run(&url).await;
|
|
||||||
|
|
||||||
|
let (count, errors) = scraper.run(&url).await;
|
||||||
log::info!("\nDone. Fetched {count} URLs. Errors: {errors}.");
|
log::info!("\nDone. Fetched {count} URLs. Errors: {errors}.");
|
||||||
log::info!("Error log: {}", log_path.display());
|
log::info!("Error log: {}", log_path.display());
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||||
|
|
||||||
|
let args = Args::parse();
|
||||||
|
|
||||||
|
let cli_command;
|
||||||
|
|
||||||
|
if args.interactive {
|
||||||
|
log::info!("Launching interactive TUI...");
|
||||||
|
|
||||||
|
if let Some(config) = tui::run_tui()? {
|
||||||
|
cli_command = config.build_command();
|
||||||
|
log::info!("Running: {cli_command}");
|
||||||
|
|
||||||
|
run_scraper(
|
||||||
|
&config.start_url,
|
||||||
|
config.subdomains,
|
||||||
|
config.single,
|
||||||
|
config.no_scope,
|
||||||
|
config.no_doc_conversion,
|
||||||
|
config.delay_ms,
|
||||||
|
config.types.as_deref(),
|
||||||
|
config.exclude_types.as_deref(),
|
||||||
|
if config.output_dir.is_empty() { None } else { Some(&config.output_dir) },
|
||||||
|
).await?;
|
||||||
|
} else {
|
||||||
|
log::info!("Cancelled by user.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let (start_url, tui_config) = build_config_from_args(&args)?;
|
||||||
|
cli_command = tui_config.build_command();
|
||||||
|
log::info!("Running: {cli_command}");
|
||||||
|
|
||||||
|
run_scraper(
|
||||||
|
&start_url,
|
||||||
|
args.subdomains,
|
||||||
|
args.single,
|
||||||
|
args.no_scope,
|
||||||
|
args.no_doc_conversion,
|
||||||
|
args.delay_ms,
|
||||||
|
args.types.as_deref(),
|
||||||
|
args.exclude_types.as_deref(),
|
||||||
|
args.output.as_deref(),
|
||||||
|
).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("Command used: {cli_command}");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
+627
@@ -0,0 +1,627 @@
|
|||||||
|
#![allow(clippy::struct_excessive_bools)]
|
||||||
|
|
||||||
|
use crossterm::{
|
||||||
|
event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
|
||||||
|
execute,
|
||||||
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
|
};
|
||||||
|
use ratatui::{
|
||||||
|
backend::CrosstermBackend,
|
||||||
|
layout::{Alignment, Constraint, Direction, Layout},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Paragraph},
|
||||||
|
Frame, Terminal,
|
||||||
|
};
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TuiConfig {
|
||||||
|
pub start_url: String,
|
||||||
|
pub output_dir: String,
|
||||||
|
pub subdomains: bool,
|
||||||
|
pub single: bool,
|
||||||
|
pub no_scope: bool,
|
||||||
|
pub no_doc_conversion: bool,
|
||||||
|
pub delay_ms: u64,
|
||||||
|
pub types: Option<String>,
|
||||||
|
pub exclude_types: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TuiConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
start_url: String::new(),
|
||||||
|
output_dir: String::new(),
|
||||||
|
subdomains: false,
|
||||||
|
single: false,
|
||||||
|
no_scope: false,
|
||||||
|
no_doc_conversion: false,
|
||||||
|
delay_ms: 1000,
|
||||||
|
types: None,
|
||||||
|
exclude_types: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TuiConfig {
|
||||||
|
pub fn to_cli_args(&self) -> Vec<String> {
|
||||||
|
let mut args = vec![self.start_url.clone()];
|
||||||
|
|
||||||
|
if !self.output_dir.is_empty() {
|
||||||
|
args.extend(["-o".to_string(), self.output_dir.clone()]);
|
||||||
|
}
|
||||||
|
if self.subdomains {
|
||||||
|
args.push("--subdomains".to_string());
|
||||||
|
}
|
||||||
|
if self.single {
|
||||||
|
args.push("--single".to_string());
|
||||||
|
}
|
||||||
|
if self.no_scope {
|
||||||
|
args.push("--no-scope".to_string());
|
||||||
|
}
|
||||||
|
if self.no_doc_conversion {
|
||||||
|
args.push("--no-doc-conversion".to_string());
|
||||||
|
}
|
||||||
|
if self.delay_ms != 1000 {
|
||||||
|
args.extend(["--delay-ms".to_string(), self.delay_ms.to_string()]);
|
||||||
|
}
|
||||||
|
if let Some(ref t) = self.types {
|
||||||
|
args.extend(["--types".to_string(), t.clone()]);
|
||||||
|
}
|
||||||
|
if let Some(ref t) = self.exclude_types {
|
||||||
|
args.extend(["--exclude-types".to_string(), t.clone()]);
|
||||||
|
}
|
||||||
|
args
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_command(&self) -> String {
|
||||||
|
format!("web-scraper {}", self.to_cli_args().join(" "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_comma_list(input: &str) -> Result<(), String> {
|
||||||
|
let trimmed = input.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let items: Vec<&str> = trimmed.split(',').map(str::trim).filter(|s| !s.is_empty()).collect();
|
||||||
|
if items.is_empty() {
|
||||||
|
return Err("List cannot be empty".to_string());
|
||||||
|
}
|
||||||
|
for item in &items {
|
||||||
|
let check = item.strip_prefix('.').unwrap_or(item);
|
||||||
|
if check.is_empty() || !check.chars().all(char::is_alphanumeric) {
|
||||||
|
return Err(format!("Invalid item '{item}': use extensions like 'pdf' or '.pdf'"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum FieldIndex {
|
||||||
|
StartUrl,
|
||||||
|
OutputDir,
|
||||||
|
Subdomains,
|
||||||
|
Single,
|
||||||
|
NoScope,
|
||||||
|
NoDocConversion,
|
||||||
|
DelayMs,
|
||||||
|
TypesInclude,
|
||||||
|
TypesExclude,
|
||||||
|
Submit,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FieldIndex {
|
||||||
|
fn next(self) -> Self {
|
||||||
|
match self {
|
||||||
|
FieldIndex::StartUrl => FieldIndex::OutputDir,
|
||||||
|
FieldIndex::OutputDir => FieldIndex::Subdomains,
|
||||||
|
FieldIndex::Subdomains => FieldIndex::Single,
|
||||||
|
FieldIndex::Single => FieldIndex::NoScope,
|
||||||
|
FieldIndex::NoScope => FieldIndex::NoDocConversion,
|
||||||
|
FieldIndex::NoDocConversion => FieldIndex::DelayMs,
|
||||||
|
FieldIndex::DelayMs => FieldIndex::TypesInclude,
|
||||||
|
FieldIndex::TypesInclude => FieldIndex::TypesExclude,
|
||||||
|
FieldIndex::TypesExclude => FieldIndex::Submit,
|
||||||
|
FieldIndex::Submit => FieldIndex::StartUrl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prev(self) -> Self {
|
||||||
|
match self {
|
||||||
|
FieldIndex::StartUrl => FieldIndex::Submit,
|
||||||
|
FieldIndex::OutputDir => FieldIndex::StartUrl,
|
||||||
|
FieldIndex::Subdomains => FieldIndex::OutputDir,
|
||||||
|
FieldIndex::Single => FieldIndex::Subdomains,
|
||||||
|
FieldIndex::NoScope => FieldIndex::Single,
|
||||||
|
FieldIndex::NoDocConversion => FieldIndex::NoScope,
|
||||||
|
FieldIndex::DelayMs => FieldIndex::NoDocConversion,
|
||||||
|
FieldIndex::TypesInclude => FieldIndex::DelayMs,
|
||||||
|
FieldIndex::TypesExclude => FieldIndex::TypesInclude,
|
||||||
|
FieldIndex::Submit => FieldIndex::TypesExclude,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
FieldIndex::StartUrl => "Start URL",
|
||||||
|
FieldIndex::OutputDir => "Output directory",
|
||||||
|
FieldIndex::Subdomains => "Include subdomains?",
|
||||||
|
FieldIndex::Single => "Single page mode?",
|
||||||
|
FieldIndex::NoScope => "No path scoping?",
|
||||||
|
FieldIndex::NoDocConversion => "Skip document conversion?",
|
||||||
|
FieldIndex::DelayMs => "Delay (ms)",
|
||||||
|
FieldIndex::TypesInclude => "Include types",
|
||||||
|
FieldIndex::TypesExclude => "Exclude types",
|
||||||
|
FieldIndex::Submit => "Submit",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hint(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
FieldIndex::StartUrl => "https://example.com/docs",
|
||||||
|
FieldIndex::OutputDir => "<domain>_scraped",
|
||||||
|
FieldIndex::Subdomains
|
||||||
|
| FieldIndex::Single
|
||||||
|
| FieldIndex::NoScope
|
||||||
|
| FieldIndex::NoDocConversion => "[y/N]",
|
||||||
|
FieldIndex::DelayMs => "1000",
|
||||||
|
FieldIndex::TypesInclude => ".pdf,docx,.html",
|
||||||
|
FieldIndex::TypesExclude => "png,jpg,.gif",
|
||||||
|
FieldIndex::Submit => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_text_field(self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
FieldIndex::StartUrl
|
||||||
|
| FieldIndex::OutputDir
|
||||||
|
| FieldIndex::DelayMs
|
||||||
|
| FieldIndex::TypesInclude
|
||||||
|
| FieldIndex::TypesExclude
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_boolean_field(self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
FieldIndex::Subdomains
|
||||||
|
| FieldIndex::Single
|
||||||
|
| FieldIndex::NoScope
|
||||||
|
| FieldIndex::NoDocConversion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subtitle(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
FieldIndex::DelayMs => "[enter positive integer]",
|
||||||
|
FieldIndex::TypesInclude | FieldIndex::TypesExclude => "[comma-separated, dots optional]",
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_FIELDS: [FieldIndex; 10] = [
|
||||||
|
FieldIndex::StartUrl,
|
||||||
|
FieldIndex::OutputDir,
|
||||||
|
FieldIndex::Subdomains,
|
||||||
|
FieldIndex::Single,
|
||||||
|
FieldIndex::NoScope,
|
||||||
|
FieldIndex::NoDocConversion,
|
||||||
|
FieldIndex::DelayMs,
|
||||||
|
FieldIndex::TypesInclude,
|
||||||
|
FieldIndex::TypesExclude,
|
||||||
|
FieldIndex::Submit,
|
||||||
|
];
|
||||||
|
|
||||||
|
enum Action {
|
||||||
|
Continue,
|
||||||
|
Cancel,
|
||||||
|
Submit,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_tui() -> io::Result<Option<TuiConfig>> {
|
||||||
|
enable_raw_mode()?;
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen)?;
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
let mut config = TuiConfig::default();
|
||||||
|
let mut current_field = FieldIndex::StartUrl;
|
||||||
|
let mut text_input = String::new();
|
||||||
|
let mut errors: Vec<(FieldIndex, String)> = Vec::new();
|
||||||
|
|
||||||
|
load_field_into_input(&mut text_input, &config, current_field);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
terminal.draw(|frame| ui(frame, &config, current_field, &errors))?;
|
||||||
|
|
||||||
|
if event::poll(std::time::Duration::from_millis(50))?
|
||||||
|
&& let Event::Key(key) = event::read()?
|
||||||
|
&& key.kind == KeyEventKind::Press
|
||||||
|
{
|
||||||
|
let action = handle_key_event(
|
||||||
|
&mut config,
|
||||||
|
&mut current_field,
|
||||||
|
&mut text_input,
|
||||||
|
&mut errors,
|
||||||
|
key.code,
|
||||||
|
key.modifiers,
|
||||||
|
);
|
||||||
|
match action {
|
||||||
|
Action::Continue => {}
|
||||||
|
Action::Cancel => {
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||||
|
terminal.show_cursor()?;
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Action::Submit => {
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||||
|
terminal.show_cursor()?;
|
||||||
|
return Ok(Some(config));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
|
fn ui(frame: &mut Frame, config: &TuiConfig, current_field: FieldIndex, errors: &[(FieldIndex, String)]) {
|
||||||
|
let title_line = Line::from(Span::styled(
|
||||||
|
"Interactive Web Scraper Configuration",
|
||||||
|
Style::default().fg(Color::LightCyan).add_modifier(Modifier::BOLD),
|
||||||
|
));
|
||||||
|
|
||||||
|
let controls_lines: Vec<Line> = vec![
|
||||||
|
Line::from(Span::styled("Controls:", Style::default().fg(Color::DarkGray))),
|
||||||
|
Line::from(" Enter : Next field / Toggle boolean / Submit"),
|
||||||
|
Line::from(" Tab : Next field"),
|
||||||
|
Line::from(" Shift+Tab: Previous field"),
|
||||||
|
Line::from(" Ctrl+C : Exit without running"),
|
||||||
|
Line::from(" Esc : Exit without running"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let cmd_preview = config.build_command();
|
||||||
|
let cmd_lines: Vec<Line> = vec![
|
||||||
|
Line::from(Span::styled(
|
||||||
|
"Command preview:",
|
||||||
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
||||||
|
)),
|
||||||
|
Line::from(Span::styled(format!(" {cmd_preview}"), Style::default().fg(Color::Yellow))),
|
||||||
|
];
|
||||||
|
|
||||||
|
let status_text = if config.start_url.is_empty() {
|
||||||
|
"Waiting for Start URL..."
|
||||||
|
} else {
|
||||||
|
"Ready. Press Enter on Submit to run."
|
||||||
|
};
|
||||||
|
let status_style = if config.start_url.is_empty() {
|
||||||
|
Style::default().fg(Color::Yellow)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Green)
|
||||||
|
};
|
||||||
|
let status_line = Line::from(Span::styled(status_text, status_style));
|
||||||
|
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.margin(1)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Min(10),
|
||||||
|
Constraint::Length(6),
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
|
.split(frame.area());
|
||||||
|
|
||||||
|
frame.render_widget(Paragraph::new(title_line), chunks[0]);
|
||||||
|
|
||||||
|
let field_chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints(std::iter::repeat_n(Constraint::Length(2), ALL_FIELDS.len()).collect::<Vec<_>>())
|
||||||
|
.split(chunks[1]);
|
||||||
|
|
||||||
|
for (i, &field) in ALL_FIELDS.iter().enumerate() {
|
||||||
|
render_field(frame, field_chunks[i], config, current_field, field, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
frame.render_widget(Paragraph::new(controls_lines), chunks[2]);
|
||||||
|
frame.render_widget(Paragraph::new(cmd_lines), chunks[3]);
|
||||||
|
|
||||||
|
let error_line = if let Some((_, err)) = errors.iter().find(|(f, _)| *f == FieldIndex::Submit) {
|
||||||
|
Line::from(Span::styled(format!("[ERR: {err}]"), Style::default().fg(Color::Red)))
|
||||||
|
} else {
|
||||||
|
status_line
|
||||||
|
};
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(error_line).alignment(Alignment::Center),
|
||||||
|
chunks[4],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_field(frame: &mut Frame, area: ratatui::layout::Rect, config: &TuiConfig, current_field: FieldIndex, field: FieldIndex, errors: &[(FieldIndex, String)]) {
|
||||||
|
let value_text = get_field_display(config, field);
|
||||||
|
let hint = field.hint();
|
||||||
|
let is_active = current_field == field;
|
||||||
|
|
||||||
|
let display_value = if !value_text.is_empty() {
|
||||||
|
value_text.as_str()
|
||||||
|
} else if !hint.is_empty() {
|
||||||
|
hint
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
|
||||||
|
let bool_state = if field.is_boolean_field() {
|
||||||
|
format!(" [{}]", if get_bool_field(config, field) { "enabled" } else { "disabled" })
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let prefix = if is_active { ">" } else { " " };
|
||||||
|
let label = format!("{:30} ", format!("{}:", field.label()));
|
||||||
|
let subtitle = field.subtitle();
|
||||||
|
|
||||||
|
let value_style = if is_active {
|
||||||
|
Style::default()
|
||||||
|
} else if display_value.is_empty() || display_value == hint {
|
||||||
|
Style::default().fg(Color::DarkGray)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut spans = vec![
|
||||||
|
Span::styled(
|
||||||
|
format!("{prefix} {label}"),
|
||||||
|
if is_active {
|
||||||
|
Style::default().add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Span::styled(display_value.to_string(), value_style),
|
||||||
|
Span::styled(bool_state, Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::styled(format!(" {subtitle}"), Style::default().fg(Color::DarkGray)),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some((_, err)) = errors.iter().find(|(f, _)| *f == field) {
|
||||||
|
spans.push(Span::styled(format!(" [ERR: {err}]"), Style::default().fg(Color::Red)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let line = Line::from(spans);
|
||||||
|
let block = if is_active {
|
||||||
|
Block::default().style(Style::default().bg(Color::DarkGray))
|
||||||
|
} else {
|
||||||
|
Block::default()
|
||||||
|
};
|
||||||
|
let widget = Paragraph::new(line).block(block);
|
||||||
|
frame.render_widget(widget, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_bool_field(config: &TuiConfig, field: FieldIndex) -> bool {
|
||||||
|
match field {
|
||||||
|
FieldIndex::Subdomains => config.subdomains,
|
||||||
|
FieldIndex::Single => config.single,
|
||||||
|
FieldIndex::NoScope => config.no_scope,
|
||||||
|
FieldIndex::NoDocConversion => config.no_doc_conversion,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_field_display(config: &TuiConfig, field: FieldIndex) -> String {
|
||||||
|
match field {
|
||||||
|
FieldIndex::StartUrl => config.start_url.clone(),
|
||||||
|
FieldIndex::OutputDir => {
|
||||||
|
if config.output_dir.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
config.output_dir.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FieldIndex::DelayMs => config.delay_ms.to_string(),
|
||||||
|
FieldIndex::TypesInclude => config.types.clone().unwrap_or_default(),
|
||||||
|
FieldIndex::TypesExclude => config.exclude_types.clone().unwrap_or_default(),
|
||||||
|
_ => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_field_into_input(text_input: &mut String, config: &TuiConfig, field: FieldIndex) {
|
||||||
|
*text_input = match field {
|
||||||
|
FieldIndex::StartUrl => config.start_url.clone(),
|
||||||
|
FieldIndex::OutputDir => config.output_dir.clone(),
|
||||||
|
FieldIndex::DelayMs => config.delay_ms.to_string(),
|
||||||
|
FieldIndex::TypesInclude => config.types.clone().unwrap_or_default(),
|
||||||
|
FieldIndex::TypesExclude => config.exclude_types.clone().unwrap_or_default(),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_input_to_field(config: &mut TuiConfig, field: FieldIndex, text_input: &str) {
|
||||||
|
match field {
|
||||||
|
FieldIndex::StartUrl => {
|
||||||
|
config.start_url = text_input.to_string();
|
||||||
|
}
|
||||||
|
FieldIndex::OutputDir => {
|
||||||
|
config.output_dir = text_input.to_string();
|
||||||
|
}
|
||||||
|
FieldIndex::DelayMs => {
|
||||||
|
if let Ok(val) = text_input.trim().parse::<u64>() {
|
||||||
|
config.delay_ms = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FieldIndex::TypesInclude => {
|
||||||
|
let trimmed = text_input.trim();
|
||||||
|
config.types = if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed.to_string())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
FieldIndex::TypesExclude => {
|
||||||
|
let trimmed = text_input.trim();
|
||||||
|
config.exclude_types = if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed.to_string())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_key_event(
|
||||||
|
config: &mut TuiConfig,
|
||||||
|
current_field: &mut FieldIndex,
|
||||||
|
text_input: &mut String,
|
||||||
|
errors: &mut Vec<(FieldIndex, String)>,
|
||||||
|
code: KeyCode,
|
||||||
|
modifiers: KeyModifiers,
|
||||||
|
) -> Action {
|
||||||
|
match code {
|
||||||
|
KeyCode::Esc
|
||||||
|
| KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
Action::Cancel
|
||||||
|
}
|
||||||
|
KeyCode::Tab => {
|
||||||
|
save_input_to_field(config, *current_field, text_input);
|
||||||
|
*current_field = current_field.next();
|
||||||
|
load_field_into_input(text_input, config, *current_field);
|
||||||
|
errors.clear();
|
||||||
|
Action::Continue
|
||||||
|
}
|
||||||
|
KeyCode::BackTab => {
|
||||||
|
save_input_to_field(config, *current_field, text_input);
|
||||||
|
*current_field = current_field.prev();
|
||||||
|
load_field_into_input(text_input, config, *current_field);
|
||||||
|
errors.clear();
|
||||||
|
Action::Continue
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
process_enter(config, current_field, text_input, errors)
|
||||||
|
}
|
||||||
|
KeyCode::Char(ch) if current_field.is_text_field() => {
|
||||||
|
text_input.push(ch);
|
||||||
|
Action::Continue
|
||||||
|
}
|
||||||
|
KeyCode::Backspace if current_field.is_text_field() => {
|
||||||
|
text_input.pop();
|
||||||
|
Action::Continue
|
||||||
|
}
|
||||||
|
KeyCode::Delete if current_field.is_text_field() => {
|
||||||
|
text_input.clear();
|
||||||
|
Action::Continue
|
||||||
|
}
|
||||||
|
_ => Action::Continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_enter(
|
||||||
|
config: &mut TuiConfig,
|
||||||
|
current_field: &mut FieldIndex,
|
||||||
|
text_input: &mut String,
|
||||||
|
errors: &mut Vec<(FieldIndex, String)>,
|
||||||
|
) -> Action {
|
||||||
|
match *current_field {
|
||||||
|
FieldIndex::Subdomains => {
|
||||||
|
config.subdomains = !config.subdomains;
|
||||||
|
Action::Continue
|
||||||
|
}
|
||||||
|
FieldIndex::Single => {
|
||||||
|
config.single = !config.single;
|
||||||
|
Action::Continue
|
||||||
|
}
|
||||||
|
FieldIndex::NoScope => {
|
||||||
|
config.no_scope = !config.no_scope;
|
||||||
|
Action::Continue
|
||||||
|
}
|
||||||
|
FieldIndex::NoDocConversion => {
|
||||||
|
config.no_doc_conversion = !config.no_doc_conversion;
|
||||||
|
Action::Continue
|
||||||
|
}
|
||||||
|
FieldIndex::StartUrl => {
|
||||||
|
save_input_to_field(config, *current_field, text_input);
|
||||||
|
if config.start_url.trim().is_empty() {
|
||||||
|
errors.retain(|(f, _)| *f != FieldIndex::StartUrl);
|
||||||
|
errors.push((FieldIndex::StartUrl, "Required".into()));
|
||||||
|
} else {
|
||||||
|
errors.clear();
|
||||||
|
*current_field = current_field.next();
|
||||||
|
load_field_into_input(text_input, config, *current_field);
|
||||||
|
}
|
||||||
|
Action::Continue
|
||||||
|
}
|
||||||
|
FieldIndex::OutputDir | FieldIndex::DelayMs => {
|
||||||
|
save_input_to_field(config, *current_field, text_input);
|
||||||
|
errors.clear();
|
||||||
|
*current_field = current_field.next();
|
||||||
|
load_field_into_input(text_input, config, *current_field);
|
||||||
|
Action::Continue
|
||||||
|
}
|
||||||
|
FieldIndex::TypesInclude => {
|
||||||
|
save_input_to_field(config, *current_field, text_input);
|
||||||
|
let val = config.types.as_deref().unwrap_or("");
|
||||||
|
match validate_comma_list(val) {
|
||||||
|
Ok(()) => {
|
||||||
|
errors.clear();
|
||||||
|
*current_field = current_field.next();
|
||||||
|
load_field_into_input(text_input, config, *current_field);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
errors.retain(|(f, _)| *f != FieldIndex::TypesInclude);
|
||||||
|
errors.push((FieldIndex::TypesInclude, e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action::Continue
|
||||||
|
}
|
||||||
|
FieldIndex::TypesExclude => {
|
||||||
|
save_input_to_field(config, *current_field, text_input);
|
||||||
|
let val = config.exclude_types.as_deref().unwrap_or("");
|
||||||
|
match validate_comma_list(val) {
|
||||||
|
Ok(()) => {
|
||||||
|
errors.clear();
|
||||||
|
*current_field = current_field.next();
|
||||||
|
load_field_into_input(text_input, config, *current_field);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
errors.retain(|(f, _)| *f != FieldIndex::TypesExclude);
|
||||||
|
errors.push((FieldIndex::TypesExclude, e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action::Continue
|
||||||
|
}
|
||||||
|
FieldIndex::Submit => {
|
||||||
|
if config.start_url.trim().is_empty() {
|
||||||
|
errors.retain(|(f, _)| *f != FieldIndex::StartUrl);
|
||||||
|
errors.push((FieldIndex::StartUrl, "Required".into()));
|
||||||
|
return Action::Continue;
|
||||||
|
}
|
||||||
|
if let Some(ref t) = config.types
|
||||||
|
&& let Err(e) = validate_comma_list(t)
|
||||||
|
{
|
||||||
|
errors.retain(|(f, _)| *f != FieldIndex::TypesInclude);
|
||||||
|
errors.push((FieldIndex::TypesInclude, e));
|
||||||
|
return Action::Continue;
|
||||||
|
}
|
||||||
|
if let Some(ref t) = config.exclude_types
|
||||||
|
&& let Err(e) = validate_comma_list(t)
|
||||||
|
{
|
||||||
|
errors.retain(|(f, _)| *f != FieldIndex::TypesExclude);
|
||||||
|
errors.push((FieldIndex::TypesExclude, e));
|
||||||
|
return Action::Continue;
|
||||||
|
}
|
||||||
|
if config.types.is_some() && config.exclude_types.is_some() {
|
||||||
|
errors.push((
|
||||||
|
FieldIndex::Submit,
|
||||||
|
"types and exclude-types are mutually exclusive".into(),
|
||||||
|
));
|
||||||
|
return Action::Continue;
|
||||||
|
}
|
||||||
|
Action::Submit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user