Skip to main content

browser_automation_cli/
scrape_local.rs

1//! Local scrape/crawl/map/search/parse (one-shot HTTP and file extract; 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::cache::{self, HttpCache};
17use crate::error::{CliError, ErrorKind};
18use crate::robots::RobotsPolicy;
19
20/// Identifiable product User-Agent for HTTP scrapes (PRD politeness).
21pub const HTTP_USER_AGENT: &str =
22    "browser-automation-cli/0.1.3 (+https://github.com/danilo-aguiar-br/browser-automation-cli; local-scrape)";
23
24/// Reject `file://`, bare paths, and other non-HTTP(S) targets for the HTTP engine (GAP-A004).
25pub fn reject_non_http_scheme_for_http_engine(url: &str) -> Result<(), CliError> {
26    let trimmed = url.trim();
27    if trimmed.is_empty() {
28        return Err(CliError::with_suggestion(
29            ErrorKind::Usage,
30            "empty URL for scrape --engine http",
31            "Pass an absolute http(s) URL",
32        ));
33    }
34    // Bare local path (not a URL).
35    if !trimmed.contains("://") {
36        return Err(CliError::with_suggestion(
37            ErrorKind::Usage,
38            format!("HTTP engine cannot fetch local path: {trimmed}"),
39            "Use: browser-automation-cli parse <path>   or   scrape file:///… --engine browser",
40        ));
41    }
42    match Url::parse(trimmed) {
43        Ok(u) => match u.scheme() {
44            "http" | "https" => Ok(()),
45            "file" => Err(CliError::with_suggestion(
46                ErrorKind::Usage,
47                format!("HTTP engine cannot fetch file:// URL: {trimmed}"),
48                "Use: browser-automation-cli scrape <url> --engine browser   or   parse <path>",
49            )),
50            other => Err(CliError::with_suggestion(
51                ErrorKind::Usage,
52                format!("HTTP engine does not support scheme `{other}`"),
53                "Pass an absolute http(s) URL, or use --engine browser / parse for local files",
54            )),
55        },
56        Err(e) => Err(CliError::with_suggestion(
57            ErrorKind::Usage,
58            format!("invalid URL for HTTP scrape: {e}"),
59            "Pass an absolute http(s) URL",
60        )),
61    }
62}
63
64/// Output formats for local scrape.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum ScrapeFormat {
67    /// Visible/plain text.
68    Text,
69    /// Simplified markdown.
70    Markdown,
71    /// Raw HTML body.
72    Html,
73    /// Extracted anchor links.
74    Links,
75    /// Title / description / status metadata.
76    Metadata,
77    /// Screenshot path placeholder (browser engine fills via CDP grab).
78    Screenshot,
79    /// LLM-oriented short summary (requires --llm path or offline stub from title/text).
80    Summary,
81    /// Product fields from JSON-LD Product schema when present.
82    Product,
83    /// Branding colors/fonts heuristics from HTML.
84    Branding,
85}
86
87impl ScrapeFormat {
88    /// Parse from CLI flag (comma-separated first token).
89    pub fn parse(s: &str) -> Result<Self, CliError> {
90        match s.trim().to_ascii_lowercase().as_str() {
91            "text" | "body" => Ok(Self::Text),
92            "markdown" | "md" => Ok(Self::Markdown),
93            "html" | "raw-html" | "rawhtml" | "raw_html" | "rawHtml" => Ok(Self::Html),
94            "links" => Ok(Self::Links),
95            "metadata" | "meta" => Ok(Self::Metadata),
96            "screenshot" | "shot" | "image" => Ok(Self::Screenshot),
97            "summary" => Ok(Self::Summary),
98            "product" => Ok(Self::Product),
99            "branding" => Ok(Self::Branding),
100            other => Err(CliError::with_suggestion(
101                ErrorKind::Usage,
102                format!("unknown scrape format: {other}"),
103                "Use text|markdown|html|raw-html|links|metadata|screenshot|summary|product|branding",
104            )),
105        }
106    }
107}
108
109/// Shared scrape options.
110#[derive(Debug, Clone)]
111pub struct ScrapeOpts {
112    /// Output format.
113    pub format: ScrapeFormat,
114    /// Prefer only main content heuristics.
115    pub only_main_content: bool,
116    /// Engine: "http" or "browser".
117    pub engine: String,
118    /// Max body bytes for HTTP.
119    pub max_body_bytes: usize,
120}
121
122impl Default for ScrapeOpts {
123    fn default() -> Self {
124        Self {
125            format: ScrapeFormat::Text,
126            only_main_content: false,
127            engine: "browser".into(),
128            max_body_bytes: 5_000_000,
129        }
130    }
131}
132
133/// HTTP static scrape (no Chrome).
134pub async fn scrape_http(
135    url: &str,
136    robots: RobotsPolicy,
137    opts: &ScrapeOpts,
138) -> Result<Value, CliError> {
139    // GAP-A004: reject non-HTTP(S) schemes early with an agent-usable suggestion.
140    reject_non_http_scheme_for_http_engine(url)?;
141
142    crate::robots::enforce_robots(url, robots, HTTP_USER_AGENT).await?;
143
144    // GAP-011: layered XDG cache for GET scrape (hit skips network).
145    let cache_key = cache::CacheKey::http_get(url);
146    if let Ok(cache) = cache::default_cache() {
147        if let Ok(Some(entry)) = HttpCache::get(cache.as_ref(), &cache_key) {
148            if let Ok(html) = String::from_utf8(entry.body) {
149                let mut payload = build_scrape_payload(url, 200, &html, opts, robots);
150                if let Some(obj) = payload.as_object_mut() {
151                    obj.insert("cache_hit".into(), json!(true));
152                }
153                return Ok(payload);
154            }
155        }
156    }
157
158    let client = reqwest::Client::builder()
159        .user_agent(HTTP_USER_AGENT)
160        .timeout(Duration::from_secs(30))
161        .redirect(reqwest::redirect::Policy::limited(10))
162        .build()
163        .map_err(|e| CliError::new(ErrorKind::Software, format!("http client: {e}")))?;
164
165    // GAP-013: retry transient HTTP failures with named policy.
166    let cfg = crate::retry::RetryConfig::http();
167    let mut attempt = 0u32;
168    let (status, final_url, bytes) = loop {
169        attempt += 1;
170        match client.get(url).send().await {
171            Ok(resp) => {
172                let status = resp.status().as_u16();
173                let final_url = resp.url().to_string();
174                match resp.bytes().await {
175                    Ok(bytes) => break (status, final_url, bytes),
176                    Err(e) => {
177                        let err = format!("read body: {e}");
178                        if attempt >= cfg.max_attempts || !crate::retry::is_retryable_message(&err)
179                        {
180                            return Err(CliError::new(ErrorKind::Io, err));
181                        }
182                    }
183                }
184            }
185            Err(e) => {
186                let err = format!("GET {url}: {e}");
187                if attempt >= cfg.max_attempts || !crate::retry::is_retryable_message(&err) {
188                    return Err(CliError::new(ErrorKind::Unavailable, err));
189                }
190            }
191        }
192        tokio::time::sleep(cfg.delay_for_attempt(attempt.saturating_sub(1))).await;
193    };
194    if bytes.len() > opts.max_body_bytes {
195        return Err(CliError::new(
196            ErrorKind::Data,
197            format!(
198                "body {} exceeds max_body_bytes {}",
199                bytes.len(),
200                opts.max_body_bytes
201            ),
202        ));
203    }
204    let html = String::from_utf8_lossy(&bytes).into_owned();
205    if let Ok(cache) = cache::default_cache() {
206        let _ = HttpCache::put(
207            cache.as_ref(),
208            &cache_key,
209            cache::CacheEntry {
210                body: html.as_bytes().to_vec(),
211                content_type: Some("text/html".into()),
212                expires_unix: cache::expires_after(Duration::from_secs(3600)),
213            },
214        );
215    }
216    let mut payload = build_scrape_payload(&final_url, status, &html, opts, robots);
217    if let Some(obj) = payload.as_object_mut() {
218        obj.insert("cache_hit".into(), json!(false));
219        obj.insert("http_attempts".into(), json!(attempt));
220    }
221    Ok(payload)
222}
223
224/// Build agent envelope data from HTML.
225pub fn build_scrape_payload(
226    source_url: &str,
227    status: u16,
228    html: &str,
229    opts: &ScrapeOpts,
230    robots: RobotsPolicy,
231) -> Value {
232    let document = Html::parse_document(html);
233    let title = text_of_first(&document, "title");
234    let description = meta_content(&document, "description")
235        .or_else(|| meta_content(&document, "og:description"))
236        .unwrap_or_default();
237    let body_html = if opts.only_main_content {
238        extract_main_html(&document).unwrap_or_else(|| html.to_string())
239    } else {
240        html.to_string()
241    };
242    let body_doc = Html::parse_document(&body_html);
243    let text = visible_text(&body_doc);
244    let markdown = html_to_markdown_simple(&body_html, &title);
245    let links = extract_links(source_url, &document);
246
247    let mut data = json!({
248        "source_url": source_url,
249        "status_code": status,
250        "title": title,
251        "robots_policy": robots.as_str(),
252        "engine": opts.engine,
253        "format": format!("{:?}", opts.format).to_ascii_lowercase(),
254    });
255
256    match opts.format {
257        ScrapeFormat::Text => {
258            data["text"] = json!(text);
259        }
260        ScrapeFormat::Markdown => {
261            data["markdown"] = json!(markdown);
262            data["text"] = json!(text);
263        }
264        ScrapeFormat::Html => {
265            data["html"] = json!(body_html);
266        }
267        ScrapeFormat::Links => {
268            data["links"] = json!(links);
269        }
270        ScrapeFormat::Metadata => {
271            data["metadata"] = json!({
272                "title": title,
273                "description": description,
274                "status_code": status,
275                "source_url": source_url,
276                "link_count": links.len(),
277            });
278        }
279        ScrapeFormat::Screenshot => {
280            // Browser path attaches path after grab; HTTP engine notes unsupported.
281            data["text"] = json!(text);
282            data["screenshot"] = json!({
283                "note": "screenshot format requires --engine browser; use grab for explicit capture",
284                "path": null,
285            });
286        }
287        ScrapeFormat::Summary => {
288            let summary = if text.len() > 400 {
289                format!("{}…", text.chars().take(400).collect::<String>())
290            } else {
291                text.clone()
292            };
293            data["summary"] = json!(summary);
294            data["text"] = json!(text);
295            data["llm_required_for_full"] = json!(true);
296        }
297        ScrapeFormat::Product => {
298            data["product"] = extract_json_ld_product(html);
299            data["text"] = json!(text);
300        }
301        ScrapeFormat::Branding => {
302            data["branding"] = extract_branding_hints(html, &title);
303            data["text"] = json!(text);
304        }
305    }
306    data
307}
308
309fn extract_json_ld_product(html: &str) -> Value {
310    let doc = Html::parse_document(html);
311    let Ok(sel) = Selector::parse("script[type=\"application/ld+json\"]") else {
312        return json!({ "found": false });
313    };
314    for el in doc.select(&sel) {
315        let raw = el.text().collect::<String>();
316        if let Ok(v) = serde_json::from_str::<Value>(&raw) {
317            if is_product_ld(&v) {
318                return json!({ "found": true, "json_ld": v });
319            }
320            if let Some(arr) = v.as_array() {
321                for item in arr {
322                    if is_product_ld(item) {
323                        return json!({ "found": true, "json_ld": item });
324                    }
325                }
326            }
327            if let Some(graph) = v.get("@graph").and_then(|g| g.as_array()) {
328                for item in graph {
329                    if is_product_ld(item) {
330                        return json!({ "found": true, "json_ld": item });
331                    }
332                }
333            }
334        }
335    }
336    json!({ "found": false, "json_ld": null })
337}
338
339fn is_product_ld(v: &Value) -> bool {
340    match v.get("@type") {
341        Some(Value::String(s)) => s.eq_ignore_ascii_case("Product"),
342        Some(Value::Array(a)) => a.iter().any(|x| {
343            x.as_str()
344                .map(|s| s.eq_ignore_ascii_case("Product"))
345                .unwrap_or(false)
346        }),
347        _ => false,
348    }
349}
350
351fn extract_branding_hints(html: &str, title: &str) -> Value {
352    let mut colors = BTreeSet::new();
353    let re = regex::Regex::new(r"#[0-9A-Fa-f]{3,8}\b").ok();
354    if let Some(re) = re {
355        for m in re.find_iter(html).take(32) {
356            colors.insert(m.as_str().to_string());
357        }
358    }
359    json!({
360        "title": title,
361        "color_samples": colors.into_iter().collect::<Vec<_>>(),
362        "note": "heuristic branding; not a full brand kit",
363    })
364}
365
366/// Redact common PII patterns in text (email, phone, card-like digits).
367pub fn redact_pii(text: &str) -> String {
368    let email = regex::Regex::new(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}").ok();
369    let phone = regex::Regex::new(
370        r"\b(?:\+?\d{1,3}[-.\s]?)?(?:\(?\d{2,4}\)?[-.\s]?)?\d{3,4}[-.\s]?\d{4}\b",
371    )
372    .ok();
373    let card = regex::Regex::new(r"\b(?:\d[ -]*?){13,19}\b").ok();
374    let mut out = text.to_string();
375    if let Some(re) = email {
376        out = re.replace_all(&out, "[REDACTED_EMAIL]").into_owned();
377    }
378    if let Some(re) = phone {
379        out = re.replace_all(&out, "[REDACTED_PHONE]").into_owned();
380    }
381    if let Some(re) = card {
382        out = re.replace_all(&out, "[REDACTED_CARD]").into_owned();
383    }
384    out
385}
386
387fn text_of_first(doc: &Html, sel: &str) -> String {
388    let Ok(selector) = Selector::parse(sel) else {
389        return String::new();
390    };
391    doc.select(&selector)
392        .next()
393        .map(|e| e.text().collect::<String>().trim().to_string())
394        .unwrap_or_default()
395}
396
397fn meta_content(doc: &Html, name: &str) -> Option<String> {
398    let sel =
399        format!("meta[name=\"{name}\"], meta[property=\"{name}\"], meta[property=\"og:{name}\"]");
400    let Ok(selector) = Selector::parse(&sel) else {
401        return None;
402    };
403    doc.select(&selector)
404        .find_map(|e| e.value().attr("content").map(|s| s.trim().to_string()))
405        .filter(|s| !s.is_empty())
406}
407
408fn extract_main_html(doc: &Html) -> Option<String> {
409    for sel in ["main", "article", "[role=main]", "#content", ".content"] {
410        if let Ok(selector) = Selector::parse(sel) {
411            if let Some(el) = doc.select(&selector).next() {
412                return Some(el.html());
413            }
414        }
415    }
416    None
417}
418
419fn visible_text(doc: &Html) -> String {
420    let Ok(selector) = Selector::parse("body") else {
421        return String::new();
422    };
423    let text = doc
424        .select(&selector)
425        .next()
426        .map(|e| e.text().collect::<Vec<_>>().join(" "))
427        .unwrap_or_default();
428    text.split_whitespace().collect::<Vec<_>>().join(" ")
429}
430
431fn html_to_markdown_simple(html: &str, title: &str) -> String {
432    let doc = Html::parse_document(html);
433    let mut out = String::new();
434    if !title.is_empty() {
435        out.push_str("# ");
436        out.push_str(title);
437        out.push_str("\n\n");
438    }
439    // Headings (static selectors avoid SelectorErrorKind lifetime on dynamic strings).
440    const HEADINGS: &[&str] = &["h1", "h2", "h3", "h4", "h5", "h6"];
441    for (idx, sel) in HEADINGS.iter().enumerate() {
442        let level = idx + 1;
443        let Ok(selector) = Selector::parse(sel) else {
444            continue;
445        };
446        for el in doc.select(&selector) {
447            let t = el.text().collect::<String>().trim().to_string();
448            if !t.is_empty() {
449                out.push_str(&"#".repeat(level));
450                out.push(' ');
451                out.push_str(&t);
452                out.push_str("\n\n");
453            }
454        }
455    }
456    // Paragraphs
457    if let Ok(selector) = Selector::parse("p") {
458        for el in doc.select(&selector) {
459            let t = el.text().collect::<String>();
460            let t = t.split_whitespace().collect::<Vec<_>>().join(" ");
461            if !t.is_empty() {
462                out.push_str(&t);
463                out.push_str("\n\n");
464            }
465        }
466    }
467    if out.trim().is_empty() {
468        out = visible_text(&doc);
469    }
470    out
471}
472
473fn extract_links(base: &str, doc: &Html) -> Vec<Value> {
474    let Ok(selector) = Selector::parse("a[href]") else {
475        return Vec::new();
476    };
477    let base_url = Url::parse(base).ok();
478    let mut out = Vec::new();
479    let mut seen = BTreeSet::new();
480    for el in doc.select(&selector) {
481        let href = el.value().attr("href").unwrap_or("").trim();
482        if href.is_empty() || href.starts_with('#') || href.starts_with("javascript:") {
483            continue;
484        }
485        let abs = match (&base_url, Url::parse(href)) {
486            (_, Ok(u)) if u.scheme() == "http" || u.scheme() == "https" || u.scheme() == "file" => {
487                u.to_string()
488            }
489            (Some(b), _) => b
490                .join(href)
491                .map(|u| u.to_string())
492                .unwrap_or_else(|_| href.to_string()),
493            _ => href.to_string(),
494        };
495        if seen.insert(abs.clone()) {
496            let text = el.text().collect::<String>();
497            let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
498            out.push(json!({ "url": abs, "text": text }));
499        }
500    }
501    out
502}
503
504/// Batch scrape N URLs (HTTP engine, sequential by default).
505pub async fn batch_scrape_http(
506    urls: &[String],
507    robots: RobotsPolicy,
508    opts: &ScrapeOpts,
509    concurrency: usize,
510) -> Result<Value, CliError> {
511    let concurrency = concurrency.clamp(1, 16);
512    let mut results = Vec::new();
513    let mut errors = Vec::new();
514    // Bounded concurrency via JoinSet (shutdown-friendly).
515    use tokio::task::JoinSet;
516    let mut set: JoinSet<Result<Value, CliError>> = JoinSet::new();
517    let mut in_flight = 0usize;
518    let mut idx = 0usize;
519    while idx < urls.len() || in_flight > 0 {
520        while in_flight < concurrency && idx < urls.len() {
521            let u = urls[idx].clone();
522            idx += 1;
523            let opts = opts.clone();
524            set.spawn(async move { scrape_http(&u, robots, &opts).await });
525            in_flight += 1;
526        }
527        if let Some(joined) = set.join_next().await {
528            in_flight = in_flight.saturating_sub(1);
529            match joined {
530                Ok(Ok(v)) => results.push(v),
531                Ok(Err(e)) => errors.push(json!({ "error": e.to_string() })),
532                Err(e) => errors.push(json!({ "error": format!("join: {e}") })),
533            }
534        }
535    }
536    Ok(json!({
537        "ok": errors.is_empty(),
538        "count": results.len(),
539        "error_count": errors.len(),
540        "results": results,
541        "errors": errors,
542        "engine": "http",
543        "robots_policy": robots.as_str(),
544    }))
545}
546
547/// BFS crawl from seed URL (HTTP), limited by `limit` and `max_depth`.
548pub async fn crawl_http(
549    seed: &str,
550    robots: RobotsPolicy,
551    opts: &ScrapeOpts,
552    limit: usize,
553    max_depth: usize,
554    same_host: bool,
555) -> Result<Value, CliError> {
556    let limit = limit.clamp(1, 500);
557    let max_depth = max_depth.min(10);
558    let seed_url = Url::parse(seed)
559        .map_err(|e| CliError::new(ErrorKind::Usage, format!("invalid seed URL: {e}")))?;
560    let seed_host = seed_url.host_str().map(|s| s.to_string());
561
562    let mut queue: VecDeque<(String, usize)> = VecDeque::new();
563    let mut seen: BTreeSet<String> = BTreeSet::new();
564    queue.push_back((seed.to_string(), 0));
565    seen.insert(seed.to_string());
566
567    let mut pages = Vec::new();
568    while let Some((url, depth)) = queue.pop_front() {
569        if pages.len() >= limit {
570            break;
571        }
572        match scrape_http(&url, robots, opts).await {
573            Ok(mut page) => {
574                page["depth"] = json!(depth);
575                if let Some(links) = page.get("links").and_then(|v| v.as_array()).cloned() {
576                    if depth < max_depth {
577                        for link in links {
578                            let Some(href) = link.get("url").and_then(|v| v.as_str()) else {
579                                continue;
580                            };
581                            if !seen.insert(href.to_string()) {
582                                continue;
583                            }
584                            if same_host {
585                                if let (Some(ref sh), Ok(u)) = (&seed_host, Url::parse(href)) {
586                                    if u.host_str() != Some(sh.as_str()) {
587                                        continue;
588                                    }
589                                }
590                            }
591                            queue.push_back((href.to_string(), depth + 1));
592                        }
593                    }
594                } else if depth < max_depth {
595                    // Re-scrape as links format for discovery when current format has no links.
596                    let mut link_opts = opts.clone();
597                    link_opts.format = ScrapeFormat::Links;
598                    if let Ok(lp) = scrape_http(&url, robots, &link_opts).await {
599                        if let Some(links) = lp.get("links").and_then(|v| v.as_array()) {
600                            for link in links {
601                                let Some(href) = link.get("url").and_then(|v| v.as_str()) else {
602                                    continue;
603                                };
604                                if !seen.insert(href.to_string()) {
605                                    continue;
606                                }
607                                if same_host {
608                                    if let (Some(ref sh), Ok(u)) = (&seed_host, Url::parse(href)) {
609                                        if u.host_str() != Some(sh.as_str()) {
610                                            continue;
611                                        }
612                                    }
613                                }
614                                queue.push_back((href.to_string(), depth + 1));
615                            }
616                        }
617                    }
618                }
619                pages.push(page);
620            }
621            Err(e) => {
622                pages.push(json!({
623                    "source_url": url,
624                    "depth": depth,
625                    "error": e.to_string(),
626                }));
627            }
628        }
629    }
630
631    Ok(json!({
632        "seed": seed,
633        "count": pages.len(),
634        "limit": limit,
635        "max_depth": max_depth,
636        "same_host": same_host,
637        "pages": pages,
638        "robots_policy": robots.as_str(),
639        "engine": "http",
640    }))
641}
642
643/// Map site: collect unique URLs via BFS without full content (links format).
644pub async fn map_http(
645    seed: &str,
646    robots: RobotsPolicy,
647    limit: usize,
648    max_depth: usize,
649) -> Result<Value, CliError> {
650    let mut opts = ScrapeOpts {
651        format: ScrapeFormat::Links,
652        engine: "http".into(),
653        ..ScrapeOpts::default()
654    };
655    opts.only_main_content = false;
656    let crawl = crawl_http(seed, robots, &opts, limit, max_depth, true).await?;
657    let mut urls: BTreeSet<String> = BTreeSet::new();
658    urls.insert(seed.to_string());
659    if let Some(pages) = crawl.get("pages").and_then(|p| p.as_array()) {
660        for p in pages {
661            if let Some(u) = p.get("source_url").and_then(|v| v.as_str()) {
662                urls.insert(u.to_string());
663            }
664            if let Some(links) = p.get("links").and_then(|v| v.as_array()) {
665                for l in links {
666                    if let Some(u) = l.get("url").and_then(|v| v.as_str()) {
667                        urls.insert(u.to_string());
668                    }
669                }
670            }
671        }
672    }
673    let list: Vec<String> = urls.into_iter().take(limit.max(1)).collect();
674    Ok(json!({
675        "seed": seed,
676        "count": list.len(),
677        "urls": list,
678        "robots_policy": robots.as_str(),
679        "engine": "http",
680    }))
681}
682
683/// Local search: fetch a public HTML search page or treat query as URL list seed.
684/// MVP: if query looks like URL, map it; else use DuckDuckGo HTML (optional network).
685pub async fn search_http(
686    query: &str,
687    robots: RobotsPolicy,
688    limit: usize,
689) -> Result<Value, CliError> {
690    let limit = limit.clamp(1, 50);
691    let q = query.trim();
692    if q.starts_with("http://") || q.starts_with("https://") {
693        return map_http(q, robots, limit, 1).await;
694    }
695    let search_url = format!(
696        "https://html.duckduckgo.com/html/?q={}",
697        urlencoding::encode(q)
698    );
699    let opts = ScrapeOpts {
700        format: ScrapeFormat::Links,
701        engine: "http".into(),
702        ..ScrapeOpts::default()
703    };
704    let page = scrape_http(&search_url, robots, &opts).await?;
705    let mut results = Vec::new();
706    if let Some(links) = page.get("links").and_then(|v| v.as_array()) {
707        for l in links {
708            let raw = l
709                .get("url")
710                .and_then(|v| v.as_str())
711                .unwrap_or("")
712                .to_string();
713            let text = l
714                .get("text")
715                .and_then(|v| v.as_str())
716                .unwrap_or("")
717                .to_string();
718            let clean = clean_serp_url(&raw);
719            // Drop same-host SERP chrome and empty destinations.
720            if clean.is_empty() {
721                continue;
722            }
723            if clean.contains("duckduckgo.com/html") || clean.ends_with("duckduckgo.com/") {
724                continue;
725            }
726            results.push(json!({ "text": text, "url": clean }));
727            if results.len() >= limit {
728                break;
729            }
730        }
731    }
732    Ok(json!({
733        "query": q,
734        "count": results.len(),
735        "results": results,
736        "source_url": search_url,
737        "robots_policy": robots.as_str(),
738        "engine": "http",
739        "note": "local HTTP search via public HTML SERP; no SaaS API key",
740    }))
741}
742
743/// Unwrap SERP redirect wrappers (e.g. uddg=) into destination URLs.
744fn clean_serp_url(raw: &str) -> String {
745    let raw = raw.trim();
746    if raw.is_empty() {
747        return String::new();
748    }
749    if let Ok(u) = Url::parse(raw) {
750        // duckduckgo /l/?uddg=https%3A%2F%2F...
751        for (k, v) in u.query_pairs() {
752            if k == "uddg" || k == "u" || k == "url" {
753                let decoded = urlencoding::decode(&v).unwrap_or_else(|_| v.clone());
754                let s = decoded.into_owned();
755                if s.starts_with("http://") || s.starts_with("https://") {
756                    return s;
757                }
758            }
759        }
760        // already clean
761        if u.host_str()
762            .map(|h| !h.contains("duckduckgo.com"))
763            .unwrap_or(true)
764        {
765            return raw.to_string();
766        }
767    }
768    raw.to_string()
769}
770
771/// Parse local file (html/md/txt/csv/json/xml/pdf/docx/xlsx) one-shot, no Chrome.
772pub fn parse_file(path: &Path) -> Result<Value, CliError> {
773    parse_file_opts(path, false)
774}
775
776/// Parse local file with optional PII redaction.
777pub fn parse_file_opts(path: &Path, redact: bool) -> Result<Value, CliError> {
778    const MAX_PARSE_BYTES: usize = 50_000_000;
779    let meta = fs::metadata(path)
780        .map_err(|e| CliError::new(ErrorKind::Io, format!("parse open {}: {e}", path.display())))?;
781    if !meta.is_file() {
782        return Err(CliError::new(
783            ErrorKind::Usage,
784            format!("not a file: {}", path.display()),
785        ));
786    }
787    if meta.len() as usize > MAX_PARSE_BYTES {
788        return Err(CliError::new(
789            ErrorKind::Data,
790            format!(
791                "file {} exceeds max parse size {}",
792                path.display(),
793                MAX_PARSE_BYTES
794            ),
795        ));
796    }
797    let bytes = fs::read(path)
798        .map_err(|e| CliError::new(ErrorKind::Io, format!("read {}: {e}", path.display())))?;
799    let ext = path
800        .extension()
801        .and_then(|e| e.to_str())
802        .unwrap_or("")
803        .to_ascii_lowercase();
804    let mut extra = json!({});
805    let (kind, mut text, engine) = match ext.as_str() {
806        "html" | "htm" => {
807            let s = String::from_utf8_lossy(&bytes);
808            let doc = Html::parse_document(&s);
809            ("html", visible_text(&doc), "local")
810        }
811        "md" | "markdown" | "txt" | "json" | "xml" => (
812            "text",
813            String::from_utf8_lossy(&bytes).into_owned(),
814            "local",
815        ),
816        "csv" => {
817            let s = String::from_utf8_lossy(&bytes).into_owned();
818            extra["rows"] = json!(s.lines().count());
819            ("csv", s, "local")
820        }
821        "pdf" => {
822            let (kind, text, engine, pages, ocr_needed) = parse_pdf_bytes(&bytes)?;
823            extra["pages"] = json!(pages);
824            extra["ocr_needed"] = json!(ocr_needed);
825            (kind, text, engine)
826        }
827        "docx" => parse_docx_bytes(&bytes)?,
828        "xlsx" | "xlsm" | "xls" | "ods" => parse_spreadsheet(path)?,
829        other => {
830            return Err(CliError::with_suggestion(
831                ErrorKind::Usage,
832                format!("unsupported parse extension: {other}"),
833                "Supported: html htm md markdown txt csv json xml pdf docx xlsx xls ods",
834            ));
835        }
836    };
837    let mut redacted = false;
838    if redact {
839        text = redact_pii(&text);
840        redacted = true;
841    }
842    // GAP-023/011: store parse result under XDG HTTP/parse cache when available.
843    let mut cache_hit = false;
844    let mtime = std::fs::metadata(path)
845        .and_then(|m| m.modified())
846        .ok()
847        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
848        .map(|d| d.as_secs())
849        .unwrap_or(0);
850    let cache_key = cache::CacheKey::file_parse(path, bytes.len() as u64, mtime);
851    if let Ok(c) = cache::default_cache() {
852        if let Ok(Some(entry)) = HttpCache::get(c.as_ref(), &cache_key) {
853            if let Ok(cached_text) = String::from_utf8(entry.body) {
854                text = cached_text;
855                cache_hit = true;
856            }
857        } else {
858            let _ = HttpCache::put(
859                c.as_ref(),
860                &cache_key,
861                cache::CacheEntry {
862                    body: text.as_bytes().to_vec(),
863                    content_type: Some(format!("text/{kind}")),
864                    expires_unix: cache::expires_after(std::time::Duration::from_secs(86_400)),
865                },
866            );
867        }
868    }
869    let mut out = json!({
870        "path": path.display().to_string(),
871        "kind": kind,
872        "bytes": bytes.len(),
873        "text": text,
874        "chars": text.chars().count(),
875        "engine": engine,
876        "redacted": redacted,
877        "cache_hit": cache_hit,
878    });
879    if let Some(obj) = out.as_object_mut() {
880        if let Some(extra_obj) = extra.as_object() {
881            for (k, v) in extra_obj {
882                obj.insert(k.clone(), v.clone());
883            }
884        }
885    }
886    Ok(out)
887}
888
889fn parse_pdf_bytes(
890    bytes: &[u8],
891) -> Result<(&'static str, String, &'static str, usize, bool), CliError> {
892    if bytes.len() < 5 || &bytes[0..5] != b"%PDF-" {
893        return Err(CliError::with_suggestion(
894            ErrorKind::Data,
895            "invalid PDF magic: expected %PDF- header",
896            "Provide a real PDF file; generate with print-pdf if needed",
897        ));
898    }
899    let doc = lopdf::Document::load_mem(bytes)
900        .map_err(|e| CliError::new(ErrorKind::Data, format!("pdf load failed: {e}")))?;
901    let pages = doc.get_pages();
902    let page_numbers: Vec<u32> = pages.keys().copied().collect();
903    let page_count = page_numbers.len();
904    let text = doc
905        .extract_text(&page_numbers)
906        .map_err(|e| CliError::new(ErrorKind::Data, format!("pdf extract_text: {e}")))?;
907    let ocr_needed = text.trim().is_empty();
908    Ok(("pdf", text, "lopdf", page_count, ocr_needed))
909}
910
911fn parse_docx_bytes(bytes: &[u8]) -> Result<(&'static str, String, &'static str), CliError> {
912    use std::io::Read;
913    let cursor = std::io::Cursor::new(bytes);
914    let mut archive = zip::ZipArchive::new(cursor)
915        .map_err(|e| CliError::new(ErrorKind::Data, format!("docx zip open: {e}")))?;
916    let mut file = archive.by_name("word/document.xml").map_err(|e| {
917        CliError::new(
918            ErrorKind::Data,
919            format!("docx missing word/document.xml: {e}"),
920        )
921    })?;
922    let mut xml = String::new();
923    file.read_to_string(&mut xml)
924        .map_err(|e| CliError::new(ErrorKind::Io, format!("docx read xml: {e}")))?;
925    // Strip tags; insert space between tags for word boundaries.
926    let mut text = String::with_capacity(xml.len() / 4);
927    let mut in_tag = false;
928    let mut last_space = true;
929    for ch in xml.chars() {
930        match ch {
931            '<' => in_tag = true,
932            '>' => {
933                in_tag = false;
934                if !last_space {
935                    text.push(' ');
936                    last_space = true;
937                }
938            }
939            _ if !in_tag => {
940                if ch.is_whitespace() {
941                    if !last_space {
942                        text.push(' ');
943                        last_space = true;
944                    }
945                } else {
946                    text.push(ch);
947                    last_space = false;
948                }
949            }
950            _ => {}
951        }
952    }
953    Ok(("docx", text.trim().to_string(), "local-docx"))
954}
955
956fn parse_spreadsheet(path: &Path) -> Result<(&'static str, String, &'static str), CliError> {
957    use calamine::{open_workbook_auto, Data, Reader};
958    let mut workbook = open_workbook_auto(path)
959        .map_err(|e| CliError::new(ErrorKind::Data, format!("spreadsheet open: {e}")))?;
960    let mut lines = Vec::new();
961    let sheets = workbook.sheet_names().to_vec();
962    for name in sheets {
963        if let Ok(range) = workbook.worksheet_range(&name) {
964            lines.push(format!("# sheet: {name}"));
965            for row in range.rows() {
966                let cells: Vec<String> = row
967                    .iter()
968                    .map(|c| match c {
969                        Data::Empty => String::new(),
970                        Data::String(s) => s.clone(),
971                        Data::Float(f) => f.to_string(),
972                        Data::Int(i) => i.to_string(),
973                        Data::Bool(b) => b.to_string(),
974                        Data::DateTime(dt) => format!("{dt:?}"),
975                        Data::DateTimeIso(s) => s.clone(),
976                        Data::DurationIso(s) => s.clone(),
977                        Data::Error(e) => format!("{e:?}"),
978                    })
979                    .collect();
980                lines.push(cells.join("\t"));
981            }
982        }
983    }
984    Ok(("spreadsheet", lines.join("\n"), "calamine"))
985}
986
987/// Read URLs file (one URL per line, # comments).
988pub fn read_urls_file(path: &Path) -> Result<Vec<String>, CliError> {
989    let raw = fs::read_to_string(path).map_err(|e| {
990        CliError::new(
991            ErrorKind::Io,
992            format!("read urls file {}: {e}", path.display()),
993        )
994    })?;
995    let mut urls = Vec::new();
996    for line in raw.lines() {
997        let line = line.trim();
998        if line.is_empty() || line.starts_with('#') {
999            continue;
1000        }
1001        urls.push(line.to_string());
1002    }
1003    if urls.is_empty() {
1004        return Err(CliError::new(ErrorKind::Usage, "urls file has no URLs"));
1005    }
1006    Ok(urls)
1007}
1008
1009/// Stable sorted map helper for tests.
1010#[allow(dead_code)]
1011pub fn sorted_keys(v: &Value) -> Vec<String> {
1012    v.as_object()
1013        .map(|o| o.keys().cloned().collect())
1014        .unwrap_or_default()
1015}
1016
1017#[cfg(test)]
1018mod tests {
1019    use super::*;
1020
1021    #[test]
1022    fn format_parse() {
1023        assert!(matches!(
1024            ScrapeFormat::parse("md").unwrap(),
1025            ScrapeFormat::Markdown
1026        ));
1027    }
1028
1029    #[test]
1030    fn build_payload_links() {
1031        let html = r#"<html><head><title>T</title></head><body><a href="/a">A</a></body></html>"#;
1032        let opts = ScrapeOpts {
1033            format: ScrapeFormat::Links,
1034            engine: "http".into(),
1035            ..Default::default()
1036        };
1037        let v = build_scrape_payload(
1038            "https://example.com/",
1039            200,
1040            html,
1041            &opts,
1042            RobotsPolicy::Ignore,
1043        );
1044        assert_eq!(v["title"], "T");
1045        assert!(!v["links"].as_array().unwrap().is_empty());
1046    }
1047}