Skip to main content

browser_automation_cli/
scrape_local.rs

1//! Local Firecrawl-parity scrape/crawl/map/search/parse (one-shot, no SaaS).
2//!
3//! Engines:
4//! - `http` — reqwest + scraper HTML (no Chrome)
5//! - `browser` — chromiumoxide via [`crate::browser::OneShotSession`]
6
7use std::collections::{BTreeSet, VecDeque};
8use std::fs;
9use std::path::Path;
10use std::time::Duration;
11
12use scraper::{Html, Selector};
13use serde_json::{json, Value};
14use url::Url;
15
16use crate::error::{CliError, ErrorKind};
17use crate::robots::RobotsPolicy;
18use crate::xdg;
19
20/// Identifiable product User-Agent for HTTP scrapes (PRD politeness).
21pub const HTTP_USER_AGENT: &str =
22    "browser-automation-cli/0.1 (+https://github.com/danilo-aguiar-br/browser-automation-cli; local-scrape)";
23
24/// Output formats for local scrape.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum ScrapeFormat {
27    /// Visible/plain text.
28    Text,
29    /// Simplified markdown.
30    Markdown,
31    /// Raw HTML body.
32    Html,
33    /// Extracted anchor links.
34    Links,
35    /// Title / description / status metadata.
36    Metadata,
37}
38
39impl ScrapeFormat {
40    /// Parse from CLI flag (comma-separated first token).
41    pub fn parse(s: &str) -> Result<Self, CliError> {
42        match s.trim().to_ascii_lowercase().as_str() {
43            "text" | "body" => Ok(Self::Text),
44            "markdown" | "md" => Ok(Self::Markdown),
45            "html" => Ok(Self::Html),
46            "links" => Ok(Self::Links),
47            "metadata" | "meta" => Ok(Self::Metadata),
48            other => Err(CliError::with_suggestion(
49                ErrorKind::Usage,
50                format!("unknown scrape format: {other}"),
51                "Use text|markdown|html|links|metadata",
52            )),
53        }
54    }
55}
56
57/// Shared scrape options.
58#[derive(Debug, Clone)]
59pub struct ScrapeOpts {
60    /// Output format.
61    pub format: ScrapeFormat,
62    /// Prefer only main content heuristics.
63    pub only_main_content: bool,
64    /// Engine: "http" or "browser".
65    pub engine: String,
66    /// Max body bytes for HTTP.
67    pub max_body_bytes: usize,
68}
69
70impl Default for ScrapeOpts {
71    fn default() -> Self {
72        Self {
73            format: ScrapeFormat::Text,
74            only_main_content: false,
75            engine: "browser".into(),
76            max_body_bytes: 5_000_000,
77        }
78    }
79}
80
81/// HTTP static scrape (no Chrome).
82pub async fn scrape_http(url: &str, robots: RobotsPolicy, opts: &ScrapeOpts) -> Result<Value, CliError> {
83    crate::robots::enforce_robots(url, robots, HTTP_USER_AGENT).await?;
84    let client = reqwest::Client::builder()
85        .user_agent(HTTP_USER_AGENT)
86        .timeout(Duration::from_secs(30))
87        .redirect(reqwest::redirect::Policy::limited(10))
88        .build()
89        .map_err(|e| CliError::new(ErrorKind::Software, format!("http client: {e}")))?;
90
91    let resp = client
92        .get(url)
93        .send()
94        .await
95        .map_err(|e| CliError::new(ErrorKind::Unavailable, format!("GET {url}: {e}")))?;
96    let status = resp.status().as_u16();
97    let final_url = resp.url().to_string();
98    let bytes = resp
99        .bytes()
100        .await
101        .map_err(|e| CliError::new(ErrorKind::Io, format!("read body: {e}")))?;
102    if bytes.len() > opts.max_body_bytes {
103        return Err(CliError::new(
104            ErrorKind::Data,
105            format!(
106                "body {} exceeds max_body_bytes {}",
107                bytes.len(),
108                opts.max_body_bytes
109            ),
110        ));
111    }
112    let html = String::from_utf8_lossy(&bytes).into_owned();
113    Ok(build_scrape_payload(&final_url, status, &html, opts, robots))
114}
115
116/// Build agent envelope data from HTML.
117pub fn build_scrape_payload(
118    source_url: &str,
119    status: u16,
120    html: &str,
121    opts: &ScrapeOpts,
122    robots: RobotsPolicy,
123) -> Value {
124    let document = Html::parse_document(html);
125    let title = text_of_first(&document, "title");
126    let description = meta_content(&document, "description")
127        .or_else(|| meta_content(&document, "og:description"))
128        .unwrap_or_default();
129    let body_html = if opts.only_main_content {
130        extract_main_html(&document).unwrap_or_else(|| html.to_string())
131    } else {
132        html.to_string()
133    };
134    let body_doc = Html::parse_document(&body_html);
135    let text = visible_text(&body_doc);
136    let markdown = html_to_markdown_simple(&body_html, &title);
137    let links = extract_links(source_url, &document);
138
139    let mut data = json!({
140        "source_url": source_url,
141        "status_code": status,
142        "title": title,
143        "robots_policy": robots.as_str(),
144        "engine": opts.engine,
145        "format": format!("{:?}", opts.format).to_ascii_lowercase(),
146    });
147
148    match opts.format {
149        ScrapeFormat::Text => {
150            data["text"] = json!(text);
151        }
152        ScrapeFormat::Markdown => {
153            data["markdown"] = json!(markdown);
154            data["text"] = json!(text);
155        }
156        ScrapeFormat::Html => {
157            data["html"] = json!(body_html);
158        }
159        ScrapeFormat::Links => {
160            data["links"] = json!(links);
161        }
162        ScrapeFormat::Metadata => {
163            data["metadata"] = json!({
164                "title": title,
165                "description": description,
166                "status_code": status,
167                "source_url": source_url,
168                "link_count": links.len(),
169            });
170        }
171    }
172    data
173}
174
175fn text_of_first(doc: &Html, sel: &str) -> String {
176    let Ok(selector) = Selector::parse(sel) else {
177        return String::new();
178    };
179    doc.select(&selector)
180        .next()
181        .map(|e| e.text().collect::<String>().trim().to_string())
182        .unwrap_or_default()
183}
184
185fn meta_content(doc: &Html, name: &str) -> Option<String> {
186    let sel = format!(
187        "meta[name=\"{name}\"], meta[property=\"{name}\"], meta[property=\"og:{name}\"]"
188    );
189    let Ok(selector) = Selector::parse(&sel) else {
190        return None;
191    };
192    doc.select(&selector)
193        .find_map(|e| e.value().attr("content").map(|s| s.trim().to_string()))
194        .filter(|s| !s.is_empty())
195}
196
197fn extract_main_html(doc: &Html) -> Option<String> {
198    for sel in ["main", "article", "[role=main]", "#content", ".content"] {
199        if let Ok(selector) = Selector::parse(sel) {
200            if let Some(el) = doc.select(&selector).next() {
201                return Some(el.html());
202            }
203        }
204    }
205    None
206}
207
208fn visible_text(doc: &Html) -> String {
209    let Ok(selector) = Selector::parse("body") else {
210        return String::new();
211    };
212    let text = doc
213        .select(&selector)
214        .next()
215        .map(|e| e.text().collect::<Vec<_>>().join(" "))
216        .unwrap_or_default();
217    text.split_whitespace().collect::<Vec<_>>().join(" ")
218}
219
220fn html_to_markdown_simple(html: &str, title: &str) -> String {
221    let doc = Html::parse_document(html);
222    let mut out = String::new();
223    if !title.is_empty() {
224        out.push_str("# ");
225        out.push_str(title);
226        out.push_str("\n\n");
227    }
228    // Headings (static selectors avoid SelectorErrorKind lifetime on dynamic strings).
229    const HEADINGS: &[&str] = &["h1", "h2", "h3", "h4", "h5", "h6"];
230    for (idx, sel) in HEADINGS.iter().enumerate() {
231        let level = idx + 1;
232        let Ok(selector) = Selector::parse(sel) else {
233            continue;
234        };
235        for el in doc.select(&selector) {
236            let t = el.text().collect::<String>().trim().to_string();
237            if !t.is_empty() {
238                out.push_str(&"#".repeat(level));
239                out.push(' ');
240                out.push_str(&t);
241                out.push_str("\n\n");
242            }
243        }
244    }
245    // Paragraphs
246    if let Ok(selector) = Selector::parse("p") {
247        for el in doc.select(&selector) {
248            let t = el.text().collect::<String>();
249            let t = t.split_whitespace().collect::<Vec<_>>().join(" ");
250            if !t.is_empty() {
251                out.push_str(&t);
252                out.push_str("\n\n");
253            }
254        }
255    }
256    if out.trim().is_empty() {
257        out = visible_text(&doc);
258    }
259    out
260}
261
262fn extract_links(base: &str, doc: &Html) -> Vec<Value> {
263    let Ok(selector) = Selector::parse("a[href]") else {
264        return Vec::new();
265    };
266    let base_url = Url::parse(base).ok();
267    let mut out = Vec::new();
268    let mut seen = BTreeSet::new();
269    for el in doc.select(&selector) {
270        let href = el.value().attr("href").unwrap_or("").trim();
271        if href.is_empty() || href.starts_with('#') || href.starts_with("javascript:") {
272            continue;
273        }
274        let abs = match (&base_url, Url::parse(href)) {
275            (_, Ok(u)) if u.scheme() == "http" || u.scheme() == "https" || u.scheme() == "file" => {
276                u.to_string()
277            }
278            (Some(b), _) => b.join(href).map(|u| u.to_string()).unwrap_or_else(|_| href.to_string()),
279            _ => href.to_string(),
280        };
281        if seen.insert(abs.clone()) {
282            let text = el.text().collect::<String>();
283            let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
284            out.push(json!({ "url": abs, "text": text }));
285        }
286    }
287    out
288}
289
290/// Batch scrape N URLs (HTTP engine, sequential by default).
291pub async fn batch_scrape_http(
292    urls: &[String],
293    robots: RobotsPolicy,
294    opts: &ScrapeOpts,
295    concurrency: usize,
296) -> Result<Value, CliError> {
297    let concurrency = concurrency.max(1).min(16);
298    let mut results = Vec::new();
299    let mut errors = Vec::new();
300    // Bounded concurrency via JoinSet (shutdown-friendly).
301    use tokio::task::JoinSet;
302    let mut set: JoinSet<Result<Value, CliError>> = JoinSet::new();
303    let mut in_flight = 0usize;
304    let mut idx = 0usize;
305    while idx < urls.len() || in_flight > 0 {
306        while in_flight < concurrency && idx < urls.len() {
307            let u = urls[idx].clone();
308            idx += 1;
309            let robots = robots;
310            let opts = opts.clone();
311            set.spawn(async move { scrape_http(&u, robots, &opts).await });
312            in_flight += 1;
313        }
314        if let Some(joined) = set.join_next().await {
315            in_flight = in_flight.saturating_sub(1);
316            match joined {
317                Ok(Ok(v)) => results.push(v),
318                Ok(Err(e)) => errors.push(json!({ "error": e.to_string() })),
319                Err(e) => errors.push(json!({ "error": format!("join: {e}") })),
320            }
321        }
322    }
323    Ok(json!({
324        "ok": errors.is_empty(),
325        "count": results.len(),
326        "error_count": errors.len(),
327        "results": results,
328        "errors": errors,
329        "engine": "http",
330        "robots_policy": robots.as_str(),
331    }))
332}
333
334/// BFS crawl from seed URL (HTTP), limited by `limit` and `max_depth`.
335pub async fn crawl_http(
336    seed: &str,
337    robots: RobotsPolicy,
338    opts: &ScrapeOpts,
339    limit: usize,
340    max_depth: usize,
341    same_host: bool,
342) -> Result<Value, CliError> {
343    let limit = limit.max(1).min(500);
344    let max_depth = max_depth.min(10);
345    let seed_url = Url::parse(seed)
346        .map_err(|e| CliError::new(ErrorKind::Usage, format!("invalid seed URL: {e}")))?;
347    let seed_host = seed_url.host_str().map(|s| s.to_string());
348
349    let mut queue: VecDeque<(String, usize)> = VecDeque::new();
350    let mut seen: BTreeSet<String> = BTreeSet::new();
351    queue.push_back((seed.to_string(), 0));
352    seen.insert(seed.to_string());
353
354    let mut pages = Vec::new();
355    while let Some((url, depth)) = queue.pop_front() {
356        if pages.len() >= limit {
357            break;
358        }
359        match scrape_http(&url, robots, opts).await {
360            Ok(mut page) => {
361                page["depth"] = json!(depth);
362                if let Some(links) = page.get("links").and_then(|v| v.as_array()).cloned() {
363                    if depth < max_depth {
364                        for link in links {
365                            let Some(href) = link.get("url").and_then(|v| v.as_str()) else {
366                                continue;
367                            };
368                            if !seen.insert(href.to_string()) {
369                                continue;
370                            }
371                            if same_host {
372                                if let (Some(ref sh), Ok(u)) = (&seed_host, Url::parse(href)) {
373                                    if u.host_str() != Some(sh.as_str()) {
374                                        continue;
375                                    }
376                                }
377                            }
378                            queue.push_back((href.to_string(), depth + 1));
379                        }
380                    }
381                } else if depth < max_depth {
382                    // Re-scrape as links format for discovery when current format has no links.
383                    let mut link_opts = opts.clone();
384                    link_opts.format = ScrapeFormat::Links;
385                    if let Ok(lp) = scrape_http(&url, robots, &link_opts).await {
386                        if let Some(links) = lp.get("links").and_then(|v| v.as_array()) {
387                            for link in links {
388                                let Some(href) = link.get("url").and_then(|v| v.as_str()) else {
389                                    continue;
390                                };
391                                if !seen.insert(href.to_string()) {
392                                    continue;
393                                }
394                                if same_host {
395                                    if let (Some(ref sh), Ok(u)) = (&seed_host, Url::parse(href)) {
396                                        if u.host_str() != Some(sh.as_str()) {
397                                            continue;
398                                        }
399                                    }
400                                }
401                                queue.push_back((href.to_string(), depth + 1));
402                            }
403                        }
404                    }
405                }
406                pages.push(page);
407            }
408            Err(e) => {
409                pages.push(json!({
410                    "source_url": url,
411                    "depth": depth,
412                    "error": e.to_string(),
413                }));
414            }
415        }
416    }
417
418    Ok(json!({
419        "seed": seed,
420        "count": pages.len(),
421        "limit": limit,
422        "max_depth": max_depth,
423        "same_host": same_host,
424        "pages": pages,
425        "robots_policy": robots.as_str(),
426        "engine": "http",
427    }))
428}
429
430/// Map site: collect unique URLs via BFS without full content (links format).
431pub async fn map_http(
432    seed: &str,
433    robots: RobotsPolicy,
434    limit: usize,
435    max_depth: usize,
436) -> Result<Value, CliError> {
437    let mut opts = ScrapeOpts {
438        format: ScrapeFormat::Links,
439        engine: "http".into(),
440        ..ScrapeOpts::default()
441    };
442    opts.only_main_content = false;
443    let crawl = crawl_http(seed, robots, &opts, limit, max_depth, true).await?;
444    let mut urls: BTreeSet<String> = BTreeSet::new();
445    urls.insert(seed.to_string());
446    if let Some(pages) = crawl.get("pages").and_then(|p| p.as_array()) {
447        for p in pages {
448            if let Some(u) = p.get("source_url").and_then(|v| v.as_str()) {
449                urls.insert(u.to_string());
450            }
451            if let Some(links) = p.get("links").and_then(|v| v.as_array()) {
452                for l in links {
453                    if let Some(u) = l.get("url").and_then(|v| v.as_str()) {
454                        urls.insert(u.to_string());
455                    }
456                }
457            }
458        }
459    }
460    let list: Vec<String> = urls.into_iter().take(limit.max(1)).collect();
461    Ok(json!({
462        "seed": seed,
463        "count": list.len(),
464        "urls": list,
465        "robots_policy": robots.as_str(),
466        "engine": "http",
467    }))
468}
469
470/// Local search: fetch a public HTML search page or treat query as URL list seed.
471/// MVP: if query looks like URL, map it; else use DuckDuckGo HTML (optional network).
472pub async fn search_http(query: &str, robots: RobotsPolicy, limit: usize) -> Result<Value, CliError> {
473    let limit = limit.max(1).min(50);
474    let q = query.trim();
475    if q.starts_with("http://") || q.starts_with("https://") {
476        return map_http(q, robots, limit, 1).await;
477    }
478    let search_url = format!(
479        "https://html.duckduckgo.com/html/?q={}",
480        urlencoding::encode(q)
481    );
482    let opts = ScrapeOpts {
483        format: ScrapeFormat::Links,
484        engine: "http".into(),
485        ..ScrapeOpts::default()
486    };
487    let page = scrape_http(&search_url, robots, &opts).await?;
488    let mut results = Vec::new();
489    if let Some(links) = page.get("links").and_then(|v| v.as_array()) {
490        for l in links.iter().take(limit) {
491            results.push(l.clone());
492        }
493    }
494    Ok(json!({
495        "query": q,
496        "count": results.len(),
497        "results": results,
498        "source_url": search_url,
499        "robots_policy": robots.as_str(),
500        "engine": "http",
501        "note": "local HTTP search via public HTML SERP; no SaaS API key",
502    }))
503}
504
505/// Parse local file (text/html/markdown/pdf bytes as text extraction best-effort).
506pub fn parse_file(path: &Path) -> Result<Value, CliError> {
507    let meta = fs::metadata(path).map_err(|e| {
508        CliError::new(
509            ErrorKind::Io,
510            format!("parse open {}: {e}", path.display()),
511        )
512    })?;
513    if !meta.is_file() {
514        return Err(CliError::new(
515            ErrorKind::Usage,
516            format!("not a file: {}", path.display()),
517        ));
518    }
519    let bytes = fs::read(path)
520        .map_err(|e| CliError::new(ErrorKind::Io, format!("read {}: {e}", path.display())))?;
521    let ext = path
522        .extension()
523        .and_then(|e| e.to_str())
524        .unwrap_or("")
525        .to_ascii_lowercase();
526    let (kind, text) = match ext.as_str() {
527        "html" | "htm" => {
528            let s = String::from_utf8_lossy(&bytes);
529            let doc = Html::parse_document(&s);
530            ("html", visible_text(&doc))
531        }
532        "md" | "markdown" | "txt" | "csv" | "json" | "xml" => {
533            ("text", String::from_utf8_lossy(&bytes).into_owned())
534        }
535        "pdf" => {
536            // Best-effort: extract printable ASCII runs (no full PDF engine in MVP path).
537            let mut out = String::new();
538            let mut run = String::new();
539            for &b in &bytes {
540                if (32..127).contains(&b) || b == b'\n' || b == b'\t' {
541                    run.push(b as char);
542                } else if run.len() >= 4 {
543                    out.push_str(&run);
544                    out.push('\n');
545                    run.clear();
546                } else {
547                    run.clear();
548                }
549            }
550            if run.len() >= 4 {
551                out.push_str(&run);
552            }
553            ("pdf-text-extract", out)
554        }
555        other => {
556            return Err(CliError::with_suggestion(
557                ErrorKind::Usage,
558                format!("unsupported parse extension: {other}"),
559                "Supported: html htm md markdown txt csv json xml pdf",
560            ));
561        }
562    };
563    // Optional cache note under XDG.
564    let _ = xdg::cache_dir().map(|d| d.join("parse"));
565    Ok(json!({
566        "path": path.display().to_string(),
567        "kind": kind,
568        "bytes": bytes.len(),
569        "text": text,
570        "engine": "local",
571    }))
572}
573
574/// Read URLs file (one URL per line, # comments).
575pub fn read_urls_file(path: &Path) -> Result<Vec<String>, CliError> {
576    let raw = fs::read_to_string(path).map_err(|e| {
577        CliError::new(
578            ErrorKind::Io,
579            format!("read urls file {}: {e}", path.display()),
580        )
581    })?;
582    let mut urls = Vec::new();
583    for line in raw.lines() {
584        let line = line.trim();
585        if line.is_empty() || line.starts_with('#') {
586            continue;
587        }
588        urls.push(line.to_string());
589    }
590    if urls.is_empty() {
591        return Err(CliError::new(
592            ErrorKind::Usage,
593            "urls file has no URLs",
594        ));
595    }
596    Ok(urls)
597}
598
599/// Stable sorted map helper for tests.
600#[allow(dead_code)]
601pub fn sorted_keys(v: &Value) -> Vec<String> {
602    v.as_object()
603        .map(|o| o.keys().cloned().collect())
604        .unwrap_or_default()
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610
611    #[test]
612    fn format_parse() {
613        assert!(matches!(ScrapeFormat::parse("md").unwrap(), ScrapeFormat::Markdown));
614    }
615
616    #[test]
617    fn build_payload_links() {
618        let html = r#"<html><head><title>T</title></head><body><a href="/a">A</a></body></html>"#;
619        let opts = ScrapeOpts {
620            format: ScrapeFormat::Links,
621            engine: "http".into(),
622            ..Default::default()
623        };
624        let v = build_scrape_payload(
625            "https://example.com/",
626            200,
627            html,
628            &opts,
629            RobotsPolicy::Ignore,
630        );
631        assert_eq!(v["title"], "T");
632        assert!(v["links"].as_array().unwrap().len() >= 1);
633    }
634}