use clap::Parser;
use flq::extract::Filter;
use flq::{crawl, process, Opts, Page};
use std::fs;
use std::io::{BufRead, Write};
use std::process::exit;
#[derive(Parser)]
#[command(name = "flq", version, about)]
struct Args {
urls: Vec<String>,
#[arg(short, long)]
output: Option<String>,
#[arg(short = 'H', long = "header")]
headers: Vec<String>,
#[arg(
short = 'A',
long = "user-agent",
default_value = "Mozilla/5.0 (compatible; flq/0.2)"
)]
agent: String,
#[arg(short = 'm', long = "max-time", default_value_t = 30, value_parser = human_u64)]
max_time: u64,
#[arg(short = 'L', long, default_value_t = true)]
location: bool,
#[arg(short = 'I', long)]
head: bool,
#[arg(short = 'b', long)]
cookie: Option<String>,
#[arg(short, long)]
silent: bool,
#[arg(long)]
raw: bool,
#[arg(short = 'f', long)]
filter: Option<String>,
#[arg(short = 'g', long)]
grep: Option<String>,
#[arg(long)]
select: Option<String>,
#[arg(short = 'j', long)]
json: bool,
#[arg(long)]
no_meta: bool,
#[arg(long, value_parser = human_usize)]
max_tokens: Option<usize>,
#[arg(long, default_value_t = 0)]
depth: u8,
#[arg(long, default_value_t = 20, value_parser = human_usize)]
max_pages: usize,
#[arg(long)]
pipe: bool,
#[arg(long)]
cache: bool,
#[arg(long, default_value_t = 3600, value_parser = human_u64)]
cache_ttl: u64,
#[arg(long)]
diff: bool,
#[arg(long)]
mcp: bool,
}
fn human_u64(s: &str) -> Result<u64, String> {
let s = s.trim();
if s.is_empty() {
return Err("empty number".into());
}
let (num, mult): (&str, u64) = match s.as_bytes()[s.len() - 1] {
b'k' | b'K' => (&s[..s.len() - 1], 1_000),
b'm' | b'M' => (&s[..s.len() - 1], 1_000_000),
b'g' | b'G' => (&s[..s.len() - 1], 1_000_000_000),
_ => (s, 1),
};
let num = num.trim();
if let Ok(n) = num.parse::<u64>() {
return Ok(n.saturating_mul(mult));
}
let f: f64 = num.parse().map_err(|_| format!("invalid number: {s}"))?;
if !f.is_finite() || f < 0.0 {
return Err(format!("invalid number: {s}"));
}
Ok((f * mult as f64) as u64)
}
fn human_usize(s: &str) -> Result<usize, String> {
human_u64(s).map(|n| n as usize)
}
fn main() {
let args = Args::parse();
if args.mcp {
tokio::runtime::Runtime::new()
.expect("failed to start tokio")
.block_on(async {
if let Err(e) = flq::mcp::run().await {
eprintln!("flq mcp: {e}");
exit(1);
}
});
return;
}
let filters = match &args.filter {
Some(s) => match Filter::parse(s) {
Ok(f) => f,
Err(e) => {
eprintln!("flq: {e}");
exit(1);
}
},
None => Vec::new(),
};
let no_meta = if args.filter.is_some() && !filters.contains(&Filter::Meta) {
true
} else {
args.no_meta
};
let filters: Vec<Filter> = filters.into_iter().filter(|f| *f != Filter::Meta).collect();
let opts = Opts {
headers: args.headers,
agent: args.agent,
max_time: args.max_time,
cookie: args.cookie,
select: args.select,
filters,
grep: args.grep,
no_meta,
max_tokens: args.max_tokens,
raw: args.raw,
cache: args.cache,
cache_ttl: args.cache_ttl,
diff: args.diff,
};
if args.pipe {
run_pipe(&opts);
return;
}
if args.urls.is_empty() {
eprintln!("flq: no URL given");
exit(1);
}
if args.head {
run_head(&args.urls, &opts, args.silent);
return;
}
let results: Vec<Result<Page, String>> = if args.depth > 0 {
args.urls
.iter()
.flat_map(|u| crawl(u, args.depth, args.max_pages, &opts))
.collect()
} else {
args.urls.iter().map(|u| process(u, &opts)).collect()
};
let out = if args.json {
render_json(&results)
} else {
render_flq(&results, &opts, args.silent)
};
match args.output {
Some(path) => {
if let Err(e) = fs::write(&path, &out) {
eprintln!("flq: write {path}: {e}");
exit(1);
}
}
None => print!("{out}"),
}
if results.iter().all(|r| r.is_err()) {
exit(1);
}
}
fn run_pipe(opts: &Opts) {
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
for line in stdin.lock().lines() {
let Ok(url) = line else { break };
let url = url.trim();
if url.is_empty() {
continue;
}
let obj = match process(url, opts) {
Ok(page) => serde_json::to_string(&page).unwrap_or_default(),
Err(e) => format!(
r#"{{"url":{},"error":{}}}"#,
serde_json::to_string(url).unwrap_or_default(),
serde_json::to_string(&e).unwrap_or_default()
),
};
if writeln!(stdout, "{obj}").is_err() {
break;
}
}
}
fn run_head(urls: &[String], opts: &Opts, silent: bool) {
for url in urls {
match flq::fetch::get(url, opts) {
Ok(res) => {
println!("status: {}", res.status);
for (k, v) in &res.headers {
println!("{k}: {v}");
}
}
Err(e) => {
if !silent {
eprintln!("flq: {e}");
}
}
}
}
}
fn render_json(results: &[Result<Page, String>]) -> String {
let pages: Vec<&Page> = results.iter().filter_map(|r| r.as_ref().ok()).collect();
if pages.len() == 1 {
serde_json::to_string(&pages[0]).unwrap_or_default()
} else {
serde_json::to_string(&pages).unwrap_or_default()
}
}
fn render_flq(results: &[Result<Page, String>], opts: &Opts, silent: bool) -> String {
let mut out = String::new();
let multi = results.len() > 1;
for (i, r) in results.iter().enumerate() {
match r {
Ok(page) => {
if !opts.no_meta {
head_line(&mut out, "title", &page.title);
head_line(&mut out, "url", &page.url);
head_line(&mut out, "lang", &page.lang);
head_line(&mut out, "desc", &page.desc);
out.push_str("---\n");
}
out.push_str(&page.body);
if page.truncated {
out.push_str("\n...(truncated)\n");
}
if multi && i + 1 < results.len() {
out.push_str("\n===\n");
}
}
Err(e) => {
if !silent {
eprintln!("flq: {e}");
}
}
}
}
out
}
fn head_line(s: &mut String, key: &str, val: &str) {
if !val.is_empty() {
s.push_str(key);
s.push_str(": ");
s.push_str(val);
s.push('\n');
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_integer() {
assert_eq!(human_u64("500").unwrap(), 500);
assert_eq!(human_u64("0").unwrap(), 0);
}
#[test]
fn k_m_g_suffix_case_insensitive() {
assert_eq!(human_u64("2k").unwrap(), 2_000);
assert_eq!(human_u64("1K").unwrap(), 1_000);
assert_eq!(human_u64("1m").unwrap(), 1_000_000);
assert_eq!(human_u64("3M").unwrap(), 3_000_000);
assert_eq!(human_u64("1g").unwrap(), 1_000_000_000);
}
#[test]
fn decimals_allowed() {
assert_eq!(human_u64("1.5k").unwrap(), 1_500);
assert_eq!(human_u64("2.5m").unwrap(), 2_500_000);
}
#[test]
fn surrounding_whitespace_trimmed() {
assert_eq!(human_u64(" 2k ").unwrap(), 2_000);
}
#[test]
fn invalid_inputs_error() {
assert!(human_u64("").is_err());
assert!(human_u64("abc").is_err());
assert!(human_u64("1x").is_err());
assert!(human_u64("k").is_err());
assert!(human_u64("-5").is_err());
}
#[test]
fn usize_wrapper() {
assert_eq!(human_usize("2k").unwrap(), 2_000usize);
}
}