1use 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
20pub const HTTP_USER_AGENT: &str =
22 "browser-automation-cli/0.1.3 (+https://github.com/danilo-aguiar-br/browser-automation-cli; local-scrape)";
23
24pub 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum ScrapeFormat {
67 Text,
69 Markdown,
71 Html,
73 Links,
75 Metadata,
77 Screenshot,
79 Summary,
81 Product,
83 Branding,
85}
86
87impl ScrapeFormat {
88 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#[derive(Debug, Clone)]
111pub struct ScrapeOpts {
112 pub format: ScrapeFormat,
114 pub only_main_content: bool,
116 pub engine: String,
118 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
133pub async fn scrape_http(
135 url: &str,
136 robots: RobotsPolicy,
137 opts: &ScrapeOpts,
138) -> Result<Value, CliError> {
139 reject_non_http_scheme_for_http_engine(url)?;
141
142 crate::robots::enforce_robots(url, robots, HTTP_USER_AGENT).await?;
143
144 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 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
224pub 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 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
366pub 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 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 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
504pub 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 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
547pub 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 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
643pub 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
683pub 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 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
743fn 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 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 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
771pub fn parse_file(path: &Path) -> Result<Value, CliError> {
773 parse_file_opts(path, false)
774}
775
776pub 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 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 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
987pub 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#[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}