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