use clap::Parser;
use std::path::PathBuf;
use url::Url;
#[derive(Debug, Parser)]
#[command(
name = "doc-scraper",
about = "Export GitBook docs as clean markdown.",
long_about = "Export GitBook docs as clean markdown.\n\
\n\
Hits GitBook's hidden `text/markdown` endpoint directly — one HTTP\n\
request per page, no HTML parsing, no browser. Produces a clean\n\
<output>/<url-path>/<slug>.md mirror tree plus auto-generated\n\
`llms.txt`, `llms-full.txt`, `AGENTS.md`, and a sectioned\n\
`index.md` table of contents.\n\
\n\
Run with no arguments for the full flag reference; pass a URL to\n\
start scraping. Examples:\n\
\n\
doc-scraper https://docs.strata.markets\n\
doc-scraper https://docs.pareto.credit --filter Tranching\n\
doc-scraper https://docs.example.com --flat -o ./scratch/",
version
)]
pub struct Cli {
pub url: Url,
#[arg(short, long, env = "GITBOOK_SCRAPER_OUTPUT_DIR")]
pub output: Option<PathBuf>,
#[arg(long, default_value_t = false)]
pub flat: bool,
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
pub toc: bool,
#[arg(long, env = "GITBOOK_SCRAPER_LLMS_TXT")]
pub llms_txt: Option<PathBuf>,
#[arg(long, default_value_t = false)]
pub no_llms_txt: bool,
#[arg(long, value_name = "TITLE")]
pub filter: Vec<String>,
#[arg(long, env = "GITBOOK_SCRAPER_DELAY", default_value_t = 0.3)]
pub delay: f64,
#[arg(long, default_value_t = 3)]
pub retries: u32,
#[arg(long, default_value_t = 20)]
pub timeout: u64,
#[arg(long, default_value_t = 20)]
pub concurrency: usize,
#[arg(long, default_value_t = false)]
pub legacy: bool,
#[arg(long, default_value_t = false)]
pub overwrite: bool,
#[arg(short, long, default_value_t = false)]
pub verbose: bool,
#[arg(short, long, default_value_t = false)]
pub quiet: bool,
#[arg(
long,
env = "GITBOOK_SCRAPER_USER_AGENT",
default_value = "doc-scraper-rs/0.1"
)]
pub user_agent: String,
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn parse(args: &[&str]) -> Cli {
Cli::try_parse_from(args).expect("parse failed")
}
#[test]
fn url_is_positional() {
let c = parse(&["doc-scraper", "https://docs.strata.markets/"]);
assert_eq!(c.url.as_str(), "https://docs.strata.markets/");
}
#[test]
fn defaults_match_spec() {
let c = parse(&["doc-scraper", "https://x/"]);
assert_eq!(c.delay, 0.3);
assert_eq!(c.retries, 3);
assert_eq!(c.timeout, 20);
assert_eq!(c.concurrency, 20);
assert_eq!(c.user_agent, "doc-scraper-rs/0.1");
assert!(!c.flat);
assert!(!c.legacy);
assert!(!c.overwrite);
}
#[test]
fn flat_flag_overrides_default() {
let c = parse(&["doc-scraper", "https://x/", "--flat"]);
assert!(c.flat);
}
#[test]
fn env_var_delay_is_picked_up() {
let c = parse(&["doc-scraper", "https://x/", "--delay", "1.5"]);
assert_eq!(c.delay, 1.5);
}
#[test]
fn filter_is_repeatable() {
let c = parse(&[
"doc-scraper",
"https://x/",
"--filter",
"Tranche",
"--filter",
"Audits",
]);
assert_eq!(c.filter, vec!["Tranche".to_string(), "Audits".to_string()]);
}
#[test]
fn missing_url_produces_missing_required_argument_error() {
let err = Cli::try_parse_from(["doc-scraper"]).unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
}
#[test]
fn invalid_url_produces_value_validation_error() {
let err = Cli::try_parse_from(["doc-scraper", "not-a-url"]).unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
}
}