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