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::error::{CliError, ErrorKind};
17use crate::robots::RobotsPolicy;
18use crate::xdg;
19
20pub const HTTP_USER_AGENT: &str =
22 "browser-automation-cli/0.1 (+https://github.com/danilo-aguiar-br/browser-automation-cli; local-scrape)";
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum ScrapeFormat {
27 Text,
29 Markdown,
31 Html,
33 Links,
35 Metadata,
37 Screenshot,
39 Summary,
41 Product,
43 Branding,
45}
46
47impl ScrapeFormat {
48 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#[derive(Debug, Clone)]
71pub struct ScrapeOpts {
72 pub format: ScrapeFormat,
74 pub only_main_content: bool,
76 pub engine: String,
78 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
93pub 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
134pub 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 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
276pub 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 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 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
414pub 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 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
457pub 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 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
553pub 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
593pub 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 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
653fn 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 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 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
681pub fn parse_file(path: &Path) -> Result<Value, CliError> {
683 parse_file_opts(path, false)
684}
685
686pub 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 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
870pub 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#[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}