1use crate::http_client::HttpClient;
2use crate::robots::RobotsTxt;
3use crossbeam::queue::SegQueue;
4use dashmap::mapref::entry::Entry;
5use dashmap::{DashMap, DashSet};
6use miette::miette;
7use mq_markdown::ConversionOptions;
8use scraper::{Html, Selector};
9use serde::{Deserialize, Serialize};
10use std::io::Write;
11use std::path::Path;
12use std::sync::Arc;
13use std::sync::atomic::{AtomicUsize, Ordering};
14use std::time::{Duration, Instant};
15use std::{fs, io};
16use tokio::sync::{Notify, RwLock, Semaphore};
17use tokio::time::sleep;
18use url::Url;
19
20#[derive(Clone, Debug, Default)]
21pub enum OutputFormat {
22 #[default]
23 Text,
24 Json,
25}
26
27#[derive(Debug, Default)]
28pub struct CrawlResult {
29 pub start_time: Option<Instant>,
30 pub end_time: Option<Instant>,
31 pub pages_crawled: usize,
32 pub pages_skipped_robots: usize,
33 pub pages_failed: usize,
34 pub links_discovered: usize,
35 pub total_pages_visited: usize,
36}
37
38#[derive(Debug, Default, Serialize, Deserialize)]
39pub struct CrawlResultStats {
40 pub pages_crawled: usize,
41 pub pages_skipped_robots: usize,
42 pub pages_failed: usize,
43 pub links_discovered: usize,
44 pub total_pages_visited: usize,
45 pub duration_secs: Option<f64>,
46}
47
48impl CrawlResult {
49 pub fn duration(&self) -> Option<Duration> {
50 if let (Some(start), Some(end)) = (self.start_time, self.end_time) {
51 Some(end.duration_since(start))
52 } else {
53 None
54 }
55 }
56
57 pub fn to_stats(&self) -> CrawlResultStats {
58 CrawlResultStats {
59 pages_crawled: self.pages_crawled,
60 pages_skipped_robots: self.pages_skipped_robots,
61 pages_failed: self.pages_failed,
62 links_discovered: self.links_discovered,
63 total_pages_visited: self.total_pages_visited,
64 duration_secs: self.duration().map(|d| d.as_secs_f64()),
65 }
66 }
67
68 pub fn write_stats_to_stderr(&self, format: &OutputFormat) {
69 let stderr = io::stderr();
70 let mut handle = stderr.lock();
71
72 match format {
73 OutputFormat::Text => {
74 let _ = writeln!(handle, "\n=== Crawl Statistics ===");
75 let _ = writeln!(handle, "Pages crawled successfully: {}", self.pages_crawled);
76 let _ = writeln!(handle, "Pages skipped (robots.txt): {}", self.pages_skipped_robots);
77 let _ = writeln!(handle, "Pages failed: {}", self.pages_failed);
78 let _ = writeln!(handle, "Total pages visited: {}", self.total_pages_visited);
79 let _ = writeln!(handle, "Links discovered: {}", self.links_discovered);
80
81 if let Some(duration) = self.duration() {
82 let _ = writeln!(handle, "Total duration: {:.2}s", duration.as_secs_f64());
83 }
84 let _ = writeln!(handle, "========================\n");
85 }
86 OutputFormat::Json => {
87 let stats = self.to_stats();
88 if let Ok(json) = serde_json::to_string_pretty(&stats) {
89 let _ = writeln!(handle, "{}", json);
90 }
91 }
92 }
93 }
94}
95
96#[derive(Debug, Default, Serialize, Deserialize)]
99pub struct CrawlCheckpoint {
100 pub visited: Vec<Url>,
102 pub frontier: Vec<(Url, usize)>,
104 pub stats: CrawlResultStats,
106}
107
108impl CrawlCheckpoint {
109 pub fn load(path: &str) -> Result<Self, String> {
111 let data = fs::read_to_string(path).map_err(|e| format!("Failed to read checkpoint file '{}': {}", path, e))?;
112 serde_json::from_str(&data).map_err(|e| format!("Failed to parse checkpoint file '{}': {}", path, e))
113 }
114}
115
116fn sanitize_filename_component(component: &str, max_len: usize) -> String {
118 let mut sanitized: String = component
119 .chars()
120 .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
121 .collect();
122
123 if sanitized.is_empty() {
124 sanitized.push_str("empty");
125 }
126
127 if sanitized.chars().count() > max_len {
128 sanitized = sanitized.chars().take(max_len).collect();
129 }
130 sanitized
131}
132
133fn extract_links(html_content: &str, base_url: &Url) -> Vec<Url> {
135 if html_content.is_empty() {
136 return Vec::new();
137 }
138
139 let mut found_urls = Vec::new();
140 let document = Html::parse_document(html_content);
141 let link_selector = Selector::parse("a[href]").expect("Failed to parse 'a[href]' selector"); for element in document.select(&link_selector) {
144 if let Some(href_attr) = element.value().attr("href") {
145 match base_url.join(href_attr) {
146 Ok(mut new_url) => {
147 new_url.set_fragment(None);
149 found_urls.push(new_url);
150 }
151 Err(e) => {
152 tracing::debug!(
153 "Failed to parse or join URL '{}' with base '{}': {}",
154 href_attr,
155 base_url,
156 e
157 );
158 }
159 }
160 }
161 }
162 tracing::debug!("Extracted {} links from {}", found_urls.len(), base_url);
163 found_urls
164}
165
166#[derive(Debug, Clone)]
167pub struct Crawler {
168 allowed_domains: Option<Vec<String>>,
169 checkpoint_interval_pages: usize,
170 checkpoint_path: Option<String>,
171 conversion_options: mq_markdown::ConversionOptions,
172 crawl_delay: Duration,
173 custom_robots_path: Option<String>,
174 concurrency: usize,
175 depth_limit: Option<usize>,
176 domain_last_request: Arc<DashMap<String, Instant>>,
177 format: OutputFormat,
178 http_client: HttpClient,
179 initial_domain: String,
180 max_pages: Option<usize>,
181 mq_query: String,
182 output_path: Option<String>,
183 result: Arc<RwLock<CrawlResult>>,
184 robots_cache: Arc<DashMap<String, Arc<RobotsTxt>>>,
185 to_visit: Arc<SegQueue<(Url, usize)>>,
186 notify: Arc<Notify>,
187 user_agent: String,
188 visited: Arc<DashSet<Url>>,
189}
190
191impl Crawler {
192 #[allow(clippy::too_many_arguments)]
193 pub async fn new(
194 http_client: HttpClient,
195 start_url: Url,
196 crawl_delay_secs: f64,
197 custom_robots_path: Option<String>,
198 mq_query: Option<String>,
199 output_path: Option<String>,
200 concurrency: usize,
201 format: OutputFormat,
202 conversion_options: mq_markdown::ConversionOptions,
203 depth_limit: Option<usize>,
204 allowed_domains: Option<Vec<String>>,
205 additional_seed_urls: Vec<Url>,
206 max_pages: Option<usize>,
207 checkpoint_path: Option<String>,
208 checkpoint_interval_pages: usize,
209 resume_checkpoint: Option<CrawlCheckpoint>,
210 ) -> Result<Self, String> {
211 let initial_domain = start_url
212 .domain()
213 .ok_or_else(|| "Start URL has no domain".to_string())?
214 .to_string();
215 let user_agent = format!("mq crawler/0.1 ({})", env!("CARGO_PKG_HOMEPAGE"));
216
217 let to_visit = SegQueue::new();
218 let visited = DashSet::new();
219 let mut initial_result = CrawlResult::default();
220
221 if let Some(checkpoint) = resume_checkpoint {
222 tracing::info!(
223 "Resuming crawl from checkpoint: {} visited, {} queued.",
224 checkpoint.visited.len(),
225 checkpoint.frontier.len()
226 );
227 for url in checkpoint.visited {
228 visited.insert(url);
229 }
230 for (url, depth) in checkpoint.frontier {
231 to_visit.push((url, depth));
232 }
233 initial_result.pages_crawled = checkpoint.stats.pages_crawled;
234 initial_result.pages_skipped_robots = checkpoint.stats.pages_skipped_robots;
235 initial_result.pages_failed = checkpoint.stats.pages_failed;
236 initial_result.links_discovered = checkpoint.stats.links_discovered;
237 } else {
238 to_visit.push((start_url.clone(), 0));
239 for seed_url in additional_seed_urls {
240 to_visit.push((seed_url, 0));
241 }
242 }
243
244 Ok(Self {
245 allowed_domains,
246 checkpoint_interval_pages: checkpoint_interval_pages.max(1),
247 checkpoint_path,
248 http_client,
249 to_visit: Arc::new(to_visit),
250 visited: Arc::new(visited),
251 robots_cache: Arc::new(DashMap::new()),
252 crawl_delay: Duration::from_secs_f64(crawl_delay_secs),
253 domain_last_request: Arc::new(DashMap::new()),
254 max_pages,
255 mq_query: mq_query.unwrap_or("identity()".to_string()),
256 user_agent,
257 output_path,
258 initial_domain,
259 custom_robots_path,
260 result: Arc::new(RwLock::new(initial_result)),
261 notify: Arc::new(Notify::new()),
262 concurrency: concurrency.max(1),
263 format,
264 conversion_options,
265 depth_limit,
266 })
267 }
268
269 async fn get_or_fetch_robots(&self, url_to_check: &Url) -> Result<Arc<RobotsTxt>, String> {
270 let domain = url_to_check
271 .domain()
272 .ok_or_else(|| "URL has no domain".to_string())?
273 .to_string();
274
275 {
277 if let Some(robots) = self.robots_cache.get(&domain) {
278 return Ok(robots.clone());
279 }
280 }
281
282 let robots = RobotsTxt::fetch(&self.http_client, url_to_check, self.custom_robots_path.as_deref()).await?;
284 let arc_robots = Arc::new(robots);
285
286 self.robots_cache.insert(domain.clone(), arc_robots.clone());
288 Ok(arc_robots)
289 }
290
291 pub async fn run(&mut self) -> Result<(), String> {
292 {
294 let mut result = self.result.write().await;
295 result.start_time = Some(Instant::now());
296 }
297
298 let mut startup_info = format!(
299 "Crawler run initiated. User-Agent: '{}'. Initial domain: '{}'. Crawl delay: {:?}. Concurrency: {}.",
300 self.user_agent, self.initial_domain, self.crawl_delay, self.concurrency
301 );
302 if let Some(ref path) = self.custom_robots_path {
303 startup_info.push_str(&format!(" Custom robots.txt path: '{}'.", path));
304 }
305 if let Some(ref path) = self.output_path {
306 startup_info.push_str(&format!(" Outputting to directory: '{}'.", path));
307 } else {
308 startup_info.push_str(" Outputting to stdout.");
309 }
310 tracing::info!("{}", startup_info);
311
312 if let Some(ref output_dir_str) = self.output_path {
313 let output_dir = Path::new(output_dir_str);
314 if !output_dir.exists() {
315 fs::create_dir_all(output_dir)
316 .map_err(|e| format!("Failed to create output directory '{}': {}", output_dir_str, e))?;
317 tracing::info!("Created output directory: {:?}", output_dir);
318 } else if !output_dir.is_dir() {
319 return Err(format!(
320 "Specified output path '{}' exists but is not a directory.",
321 output_dir_str
322 ));
323 }
324 }
325
326 self.run_parallel().await
327 }
328
329 async fn run_parallel(&mut self) -> Result<(), String> {
330 let semaphore = Arc::new(Semaphore::new(self.concurrency));
331 let active_tasks = Arc::new(AtomicUsize::new(0));
332 let mut pages_at_last_checkpoint = self.result.read().await.pages_crawled;
333
334 loop {
335 let max_pages_reached = self.max_pages.is_some_and(|max| self.visited.len() >= max);
336
337 if (self.to_visit.is_empty() || max_pages_reached) && active_tasks.load(Ordering::SeqCst) == 0 {
340 break;
341 }
342
343 let notified = self.notify.notified();
346
347 if !max_pages_reached {
349 while let Some((url, depth)) = self.to_visit.pop() {
350 if self.should_skip_url_without_visited_check(&url) || !self.visited.insert(url.clone()) {
351 continue;
352 }
353
354 let permit = semaphore.clone().acquire_owned().await.expect("Semaphore closed");
355 active_tasks.fetch_add(1, Ordering::SeqCst);
356
357 let crawler = self.clone();
358 let active_tasks_clone = active_tasks.clone();
359 let notify_clone = self.notify.clone();
360 tokio::spawn(async move {
361 let _permit = permit;
362 crawler.process_url_with_rate_limit(url, depth).await;
363 active_tasks_clone.fetch_sub(1, Ordering::SeqCst);
364 notify_clone.notify_one();
367 });
368
369 if self.max_pages.is_some_and(|max| self.visited.len() >= max) {
370 tracing::info!(
371 "Reached --max-pages limit ({}); no further URLs will be queued.",
372 self.max_pages.expect("checked by is_some_and above")
373 );
374 break;
375 }
376 }
377 }
378
379 self.maybe_save_checkpoint(&mut pages_at_last_checkpoint).await;
380
381 notified.await;
385 }
386
387 if self.checkpoint_path.is_some()
388 && let Err(e) = self.save_checkpoint().await
389 {
390 tracing::warn!("Failed to save final checkpoint: {}", e);
391 }
392
393 self.finalize_crawl().await;
394 Ok(())
395 }
396
397 async fn maybe_save_checkpoint(&self, pages_at_last_checkpoint: &mut usize) {
400 if self.checkpoint_path.is_none() {
401 return;
402 }
403
404 let pages_crawled = self.result.read().await.pages_crawled;
405 if pages_crawled < pages_at_last_checkpoint.saturating_add(self.checkpoint_interval_pages) {
406 return;
407 }
408
409 *pages_at_last_checkpoint = pages_crawled;
410 if let Err(e) = self.save_checkpoint().await {
411 tracing::warn!("Failed to save checkpoint: {}", e);
412 }
413 }
414
415 async fn save_checkpoint(&self) -> Result<(), String> {
420 let Some(path) = self.checkpoint_path.clone() else {
421 return Ok(());
422 };
423
424 let visited: Vec<Url> = self.visited.iter().map(|entry| entry.clone()).collect();
425 let frontier = self.snapshot_frontier();
426 let stats = self.result.read().await.to_stats();
427 let visited_len = visited.len();
428 let frontier_len = frontier.len();
429
430 let checkpoint = CrawlCheckpoint {
431 visited,
432 frontier,
433 stats,
434 };
435 let json =
436 serde_json::to_string_pretty(&checkpoint).map_err(|e| format!("Failed to serialize checkpoint: {}", e))?;
437
438 let tmp_path = format!("{}.tmp", path);
439 fs::write(&tmp_path, json).map_err(|e| format!("Failed to write checkpoint to '{}': {}", tmp_path, e))?;
440 fs::rename(&tmp_path, &path).map_err(|e| format!("Failed to finalize checkpoint file '{}': {}", path, e))?;
441
442 tracing::info!(
443 "Checkpoint saved to '{}' ({} visited, {} queued).",
444 path,
445 visited_len,
446 frontier_len
447 );
448 Ok(())
449 }
450
451 fn snapshot_frontier(&self) -> Vec<(Url, usize)> {
456 let mut items = Vec::new();
457 while let Some(item) = self.to_visit.pop() {
458 items.push(item);
459 }
460 for item in &items {
461 self.to_visit.push(item.clone());
462 }
463 items
464 }
465
466 fn should_skip_url_without_visited_check(&self, url: &Url) -> bool {
467 let Some(domain) = url.domain() else {
468 tracing::info!("Skipping URL with no domain: {}", url);
469 return true;
470 };
471
472 let allowed = match &self.allowed_domains {
473 None => domain == self.initial_domain,
474 Some(list) if list.is_empty() => true,
475 Some(list) => list.iter().any(|d| d == domain),
476 };
477
478 if !allowed {
479 tracing::info!("Skipping URL from disallowed domain: {}", url);
480 return true;
481 }
482
483 false
484 }
485
486 async fn wait_for_domain_rate_limit(&self, domain: &str) {
487 if self.crawl_delay.is_zero() {
488 return;
489 }
490 loop {
491 let now = Instant::now();
492 let wait = match self.domain_last_request.entry(domain.to_string()) {
496 Entry::Occupied(mut e) => {
497 let elapsed = now.duration_since(*e.get());
498 if elapsed >= self.crawl_delay {
499 e.insert(now);
500 None
501 } else {
502 Some(self.crawl_delay - elapsed)
503 }
504 }
505 Entry::Vacant(e) => {
506 e.insert(now);
507 None
508 }
509 };
510 match wait {
512 None => return,
513 Some(dur) => sleep(dur).await,
514 }
515 }
516 }
517
518 async fn process_url_with_rate_limit(&self, url: Url, depth: usize) {
519 if let Some(domain) = url.domain() {
520 self.wait_for_domain_rate_limit(domain).await;
521 }
522 self.process_url(url, depth).await;
523 }
524
525 async fn process_url(&self, current_url: Url, current_depth: usize) {
526 tracing::info!("Processing URL: {} (depth: {})", current_url, current_depth);
527
528 let robots_rules = match self.get_or_fetch_robots(¤t_url).await {
530 Ok(rules) => rules,
531 Err(e) => {
532 tracing::error!("Failed to fetch robots.txt for {}: {}", current_url, e);
533 return;
534 }
535 };
536
537 if !robots_rules.is_allowed(¤t_url, &self.user_agent) {
538 tracing::warn!("Skipping URL disallowed by robots.txt: {}", current_url);
539 {
540 let mut result = self.result.write().await;
541 result.pages_skipped_robots += 1;
542 }
543 return;
544 }
545
546 match self.http_client.fetch(current_url.clone()).await {
547 Ok(html_content) => {
548 tracing::info!("Applying mq query to content from {}", current_url);
549
550 let query = self.mq_query.clone();
551 let html_content_clone = html_content.clone();
552 let mut conversion_options = self.conversion_options.clone();
554 conversion_options.base_url = Some(current_url.to_string());
555 let current_url_clone = current_url.clone();
556 let self_clone = self.clone();
557 let new_links = tokio::task::spawn_blocking(move || {
558 if let Err(e) = Self::execute_query(&query, &html_content_clone, conversion_options).map(|md| {
559 if let Err(e) = self_clone.output_markdown(¤t_url_clone, md.as_str()) {
560 tracing::error!("Failed to output markdown for {}: {}", current_url_clone, e);
561 }
562 }) {
563 tracing::error!(
564 "Failed to execute mq query on content from {}: {}",
565 current_url_clone,
566 e
567 );
568 }
569
570 extract_links(&html_content, ¤t_url_clone)
571 })
572 .await;
573
574 match new_links {
575 Ok(new_links) => {
576 {
577 let mut result = self.result.write().await;
578 result.links_discovered += new_links.len();
579 result.pages_crawled += 1;
580 }
581
582 {
583 let next_depth = current_depth + 1;
584 for link in new_links {
585 if !self.visited.contains(&link)
586 && !self.should_skip_url_without_visited_check(&link)
587 && self.depth_limit.is_none_or(|limit| next_depth <= limit)
588 {
589 self.to_visit.push((link, next_depth));
590 self.notify.notify_one();
592 }
593 }
594 }
595 }
596 Err(e) => {
597 tracing::error!("Failed to execute mq query on content from {}: {}", current_url, e);
598 }
599 }
600 }
601 Err(e) => {
602 tracing::error!("Failed to fetch URL {}: {}", current_url, e);
603 {
604 let mut result = self.result.write().await;
605 result.pages_failed += 1;
606 }
607 }
608 }
609 }
610
611 fn execute_query(query: &str, input: &str, conversion_options: ConversionOptions) -> miette::Result<String> {
612 let input = mq_lang::parse_html_input_with_options(input, conversion_options)?;
613 let mut mq_engine = mq_lang::DefaultEngine::default();
614 mq_engine.load_builtin_module();
615
616 match mq_engine
617 .eval(query, input.into_iter())
618 .map_err(|e| miette!(format!("Error evaluating mq query: {}", e)))
619 {
620 Ok(values) => Ok(mq_markdown::Markdown::new(
621 values
622 .into_iter()
623 .map(|value| match value {
624 mq_lang::RuntimeValue::Markdown(node, _) => mq_lang::Shared::unwrap_or_clone(node),
625 _ => value.to_string().into(),
626 })
627 .collect(),
628 )
629 .to_string()),
630 Err(e) => Err(miette!(e)),
631 }
632 }
633
634 async fn finalize_crawl(&self) {
635 {
637 let mut result = self.result.write().await;
638 result.end_time = Some(Instant::now());
639 let visited_len = self.visited.len();
640 result.total_pages_visited = visited_len;
641 }
642
643 {
645 let result = self.result.read().await;
646 result.write_stats_to_stderr(&self.format);
647 }
648 }
649
650 fn output_markdown(&self, url: &Url, markdown: &str) -> Result<(), String> {
651 tracing::debug!("Preparing to output markdown for {}", url);
652 if let Some(ref output_dir_str) = self.output_path {
653 let domain_str = url.domain().unwrap_or("unknown_domain");
655 let path_str = url.path();
656
657 let sane_domain = sanitize_filename_component(domain_str, 50);
659
660 let sane_path = if path_str == "/" || path_str.is_empty() {
661 "index".to_string()
662 } else {
663 sanitize_filename_component(path_str.trim_matches('/'), 100)
664 };
665
666 let filename = format!("{}_{}.md", sane_domain, sane_path);
667 let output_dir = Path::new(output_dir_str);
668 let output_file_path = output_dir.join(&filename);
669 tracing::info!("Saving markdown for {} to: {:?}", url, output_file_path);
670
671 let mut file = fs::File::create(&output_file_path)
672 .map_err(|e| format!("Failed to create output file '{:?}': {}", output_file_path, e))?;
673 file.write_all(markdown.as_bytes())
674 .map_err(|e| format!("Failed to write markdown to file '{:?}': {}", output_file_path, e))?;
675 tracing::debug!("Successfully wrote {} bytes to {:?}", markdown.len(), output_file_path);
676 } else {
677 let filename = format!(
678 "{}_{}.md",
679 sanitize_filename_component(url.domain().unwrap_or("unknown_domain"), 50),
680 if url.path() == "/" || url.path().is_empty() {
681 "index".to_string()
682 } else {
683 sanitize_filename_component(url.path().trim_matches('/'), 100)
684 }
685 );
686 writeln!(io::stderr().lock(), "Filename: {} --", filename)
687 .map_err(|e| format!("Failed to write to stderr: {}", e))?;
688 let stdout = io::stdout();
689 let mut handle = stdout.lock();
690 handle
691 .write_all(markdown.as_bytes())
692 .map_err(|e| format!("Failed to write markdown to stdout: {}", e))?;
693 writeln!(handle).map_err(|e| format!("Failed to write newline to stdout: {}", e))?;
694 handle
695 .flush()
696 .map_err(|e| format!("Failed to flush stdout after writing markdown: {}", e))?;
697 }
698 Ok(())
699 }
700}
701
702#[cfg(test)]
703mod tests {
704 use super::*;
705 use httpmock::{Method::GET, MockServer};
706 use rstest::rstest;
707 use url::Url;
708
709 #[rstest]
710 #[case(
711 "<html><body><p>No links here.</p></body></html>",
712 "http://example.com",
713 vec![]
714 )]
715 #[case(
716 r#"<html><body><a href="http://example.com/page1">Page 1</a></body></html>"#,
717 "http://example.com",
718 vec!["http://example.com/page1"]
719 )]
720 #[case(
721 r#"<html><body><a href="/page2">Page 2</a></body></html>"#,
722 "http://example.com/path/",
723 vec!["http://example.com/page2"]
724 )]
725 #[case(
726 r#"<html><body><a href="page3#section">Page 3</a></body></html>"#,
727 "http://example.com/",
728 vec!["http://example.com/page3"]
729 )]
730 #[case(
731 r##"
732 <html><body>
733 <a href="https://othersite.com/abs">Absolute</a>
734 <a href="relative/link">Relative</a>
735 <a href="../another">Another Relative</a>
736 <a href="#fragmentonly">Fragment Only</a>
737 <a href="page?query=val">With Query</a>
738 </body></html>
739 "##,
740 "http://example.com/folder1/folder2/current.html",
741 vec![
742 "https://othersite.com/abs",
743 "http://example.com/folder1/folder2/relative/link",
744 "http://example.com/folder1/another",
745 "http://example.com/folder1/folder2/current.html",
746 "http://example.com/folder1/folder2/page?query=val"
747 ]
748 )]
749 #[case(
750 r#"<html><body><a href="">Empty Href</a></body></html>"#,
751 "http://example.com/page.html",
752 vec!["http://example.com/page.html"]
753 )]
754 #[case(
755 r#"<html><body><a href="http://[::1]:namedport">Malformed</a></body></html>"#,
756 "http://example.com",
757 vec![]
758 )]
759 #[case(
760 "",
761 "http://example.com",
762 vec![]
763 )]
764 fn test_extract_links(#[case] html: &str, #[case] base_url: &str, #[case] expected_urls: Vec<&str>) {
765 let base = Url::parse(base_url).unwrap();
766 let links = extract_links(html, &base);
767
768 assert_eq!(
769 links.len(),
770 expected_urls.len(),
771 "Link count mismatch for base_url: {}",
772 base_url
773 );
774
775 for expected_url_str in expected_urls {
776 let expected_url = Url::parse(expected_url_str).unwrap();
777 assert!(
778 links.contains(&expected_url),
779 "Expected link {} not found in {:?}",
780 expected_url_str,
781 links
782 );
783 }
784 }
785
786 #[rstest]
787 #[case("abcDEF-123_foo", 20, "abcDEF-123_foo")]
788 #[case("!@#abc/\\:*?\"<>|", 20, "abc")]
789 #[case("", 10, "empty")]
790 #[case("valid_name", 5, "valid")]
791 #[case("___", 10, "___")]
792 #[case("foo bar-baz_123", 20, "foobar-baz_123")]
793 #[case("日本語テスト", 10, "日本語テスト")]
794 fn test_sanitize_filename_component(#[case] input: &str, #[case] max_len: usize, #[case] expected: &str) {
795 let result = sanitize_filename_component(input, max_len);
796 assert_eq!(result, expected, "input: {}", input);
797 }
798
799 #[test]
800 fn test_crawl_result_duration_some() {
801 let start = Instant::now();
802 let end = start + Duration::from_secs(5);
803 let result = CrawlResult {
804 start_time: Some(start),
805 end_time: Some(end),
806 ..Default::default()
807 };
808 assert_eq!(result.duration(), Some(Duration::from_secs(5)));
809 }
810
811 #[test]
812 fn test_crawl_result_duration_none() {
813 let result = CrawlResult {
814 start_time: None,
815 end_time: None,
816 ..Default::default()
817 };
818 assert_eq!(result.duration(), None);
819
820 let result = CrawlResult {
821 start_time: Some(Instant::now()),
822 end_time: None,
823 ..Default::default()
824 };
825 assert_eq!(result.duration(), None);
826
827 let result = CrawlResult {
828 start_time: None,
829 end_time: Some(Instant::now()),
830 ..Default::default()
831 };
832 assert_eq!(result.duration(), None);
833 }
834
835 async fn make_test_crawler(
838 start_domain: &str,
839 allowed_domains: Option<Vec<String>>,
840 depth_limit: Option<usize>,
841 ) -> Crawler {
842 let start_url = Url::parse(&format!("http://{}/", start_domain)).unwrap();
843 let http_client = HttpClient::new_reqwest(30.0).unwrap();
844 Crawler::new(
845 http_client,
846 start_url,
847 0.0,
848 None,
849 None,
850 None,
851 1,
852 OutputFormat::Text,
853 mq_markdown::ConversionOptions::default(),
854 depth_limit,
855 allowed_domains,
856 Vec::new(),
857 None,
858 None,
859 1,
860 None,
861 )
862 .await
863 .unwrap()
864 }
865
866 #[tokio::test]
867 async fn test_should_skip_url_no_domain() {
868 let crawler = make_test_crawler("start.invalid", None, None).await;
869
870 let no_domain = Url::parse("data:text/plain,hello").unwrap();
872 assert!(crawler.should_skip_url_without_visited_check(&no_domain));
873 }
874
875 #[tokio::test]
876 async fn test_should_skip_url_same_domain_allowed() {
877 let crawler = make_test_crawler("start.invalid", None, None).await;
878 let same = Url::parse("http://start.invalid/page").unwrap();
879 assert!(!crawler.should_skip_url_without_visited_check(&same));
880 }
881
882 #[tokio::test]
883 async fn test_should_skip_url_different_domain_denied() {
884 let crawler = make_test_crawler("start.invalid", None, None).await;
885 let other = Url::parse("http://other.invalid/page").unwrap();
886 assert!(crawler.should_skip_url_without_visited_check(&other));
887 }
888
889 #[tokio::test]
890 async fn test_should_skip_url_empty_allowed_list_permits_all() {
891 let crawler = make_test_crawler("start.invalid", Some(vec![]), None).await;
892 let other = Url::parse("http://other.invalid/page").unwrap();
893 assert!(!crawler.should_skip_url_without_visited_check(&other));
894 }
895
896 #[tokio::test]
897 async fn test_should_skip_url_explicit_allowed_list() {
898 let allowed = vec!["start.invalid".to_string(), "extra.invalid".to_string()];
899 let crawler = make_test_crawler("start.invalid", Some(allowed), None).await;
900
901 let allowed_url = Url::parse("http://extra.invalid/page").unwrap();
902 let denied_url = Url::parse("http://denied.invalid/page").unwrap();
903
904 assert!(!crawler.should_skip_url_without_visited_check(&allowed_url));
905 assert!(crawler.should_skip_url_without_visited_check(&denied_url));
906 }
907
908 #[tokio::test]
909 async fn test_crawler_new_with_depth_limit() {
910 let start_url = Url::parse("http://start.invalid/").unwrap();
911 let http_client = HttpClient::new_reqwest(30.0).unwrap();
912 let crawler = Crawler::new(
913 http_client,
914 start_url,
915 1.0,
916 None,
917 None,
918 None,
919 1,
920 OutputFormat::Text,
921 mq_markdown::ConversionOptions::default(),
922 Some(2),
923 None,
924 Vec::new(),
925 None,
926 None,
927 1,
928 None,
929 )
930 .await
931 .unwrap();
932
933 assert_eq!(crawler.depth_limit, Some(2));
934 }
935
936 #[tokio::test]
937 async fn test_crawler_new_with_unlimited_depth() {
938 let start_url = Url::parse("http://start.invalid/").unwrap();
939 let http_client = HttpClient::new_reqwest(30.0).unwrap();
940 let crawler = Crawler::new(
941 http_client,
942 start_url,
943 1.0,
944 None,
945 None,
946 None,
947 1,
948 OutputFormat::Text,
949 mq_markdown::ConversionOptions::default(),
950 None,
951 None,
952 Vec::new(),
953 None,
954 None,
955 1,
956 None,
957 )
958 .await
959 .unwrap();
960
961 assert_eq!(crawler.depth_limit, None);
962 }
963
964 #[tokio::test]
965 async fn test_crawler_new_with_additional_seed_urls() {
966 let start_url = Url::parse("http://start.invalid/").unwrap();
967 let http_client = HttpClient::new_reqwest(30.0).unwrap();
968 let seed_urls = vec![
969 Url::parse("http://start.invalid/from-sitemap-1").unwrap(),
970 Url::parse("http://start.invalid/from-sitemap-2").unwrap(),
971 ];
972 let crawler = Crawler::new(
973 http_client,
974 start_url.clone(),
975 0.0,
976 None,
977 None,
978 None,
979 1,
980 OutputFormat::Text,
981 mq_markdown::ConversionOptions::default(),
982 None,
983 None,
984 seed_urls.clone(),
985 None,
986 None,
987 1,
988 None,
989 )
990 .await
991 .unwrap();
992
993 let mut queued = Vec::new();
994 while let Some((url, depth)) = crawler.to_visit.pop() {
995 queued.push((url, depth));
996 }
997
998 assert_eq!(queued.len(), 3);
999 assert!(queued.contains(&(start_url, 0)));
1000 for seed_url in seed_urls {
1001 assert!(queued.contains(&(seed_url, 0)));
1002 }
1003 }
1004
1005 #[tokio::test]
1006 async fn test_max_pages_limits_total_visited() {
1007 let server = MockServer::start_async().await;
1008 let port = server.address().port();
1009
1010 let index_mock = server
1011 .mock_async(|when, then| {
1012 when.method(GET).path("/");
1013 then.status(200).body(
1014 r#"<html><body>
1015 <a href="/page1">1</a>
1016 <a href="/page2">2</a>
1017 <a href="/page3">3</a>
1018 <a href="/page4">4</a>
1019 <a href="/page5">5</a>
1020 </body></html>"#,
1021 );
1022 })
1023 .await;
1024 let page1_mock = server
1025 .mock_async(|when, then| {
1026 when.method(GET).path("/page1");
1027 then.status(200).body("<html><body>Page 1</body></html>");
1028 })
1029 .await;
1030
1031 let start_url = Url::parse(&format!("http://localhost:{}/", port)).unwrap();
1032 let http_client = HttpClient::new_reqwest(30.0).unwrap();
1033
1034 let mut crawler = Crawler::new(
1035 http_client,
1036 start_url,
1037 0.0,
1038 None,
1039 None,
1040 None,
1041 1,
1042 OutputFormat::Text,
1043 mq_markdown::ConversionOptions::default(),
1044 None,
1045 None,
1046 Vec::new(),
1047 Some(2),
1048 None,
1049 1,
1050 None,
1051 )
1052 .await
1053 .unwrap();
1054
1055 crawler.run().await.unwrap();
1056
1057 assert_eq!(crawler.visited.len(), 2);
1058 assert!(!crawler.to_visit.is_empty(), "unvisited URLs should remain queued");
1059 index_mock.assert_async().await;
1060 page1_mock.assert_async().await;
1061 }
1062
1063 #[tokio::test]
1064 async fn test_save_checkpoint_captures_visited_and_frontier() {
1065 let start_url = Url::parse("http://start.invalid/").unwrap();
1066 let http_client = HttpClient::new_reqwest(30.0).unwrap();
1067 let checkpoint_file = tempfile::NamedTempFile::new().unwrap();
1068 let checkpoint_path = checkpoint_file.path().to_string_lossy().to_string();
1069
1070 let crawler = Crawler::new(
1071 http_client,
1072 start_url.clone(),
1073 0.0,
1074 None,
1075 None,
1076 None,
1077 1,
1078 OutputFormat::Text,
1079 mq_markdown::ConversionOptions::default(),
1080 None,
1081 None,
1082 Vec::new(),
1083 None,
1084 Some(checkpoint_path.clone()),
1085 1,
1086 None,
1087 )
1088 .await
1089 .unwrap();
1090
1091 while crawler.to_visit.pop().is_some() {}
1094
1095 crawler.visited.insert(start_url.clone());
1097 let queued_url = Url::parse("http://start.invalid/page1").unwrap();
1098 crawler.to_visit.push((queued_url.clone(), 1));
1099
1100 crawler.save_checkpoint().await.unwrap();
1101
1102 let loaded = CrawlCheckpoint::load(&checkpoint_path).unwrap();
1103 assert_eq!(loaded.visited, vec![start_url]);
1104 assert_eq!(loaded.frontier, vec![(queued_url, 1)]);
1105
1106 assert!(!crawler.to_visit.is_empty());
1108 }
1109
1110 #[tokio::test]
1111 async fn test_resume_from_checkpoint_restores_state() {
1112 let start_url = Url::parse("http://start.invalid/").unwrap();
1113 let http_client = HttpClient::new_reqwest(30.0).unwrap();
1114
1115 let visited_url = Url::parse("http://start.invalid/already-visited").unwrap();
1116 let queued_url = Url::parse("http://start.invalid/still-queued").unwrap();
1117 let checkpoint = CrawlCheckpoint {
1118 visited: vec![visited_url.clone()],
1119 frontier: vec![(queued_url.clone(), 3)],
1120 stats: CrawlResultStats {
1121 pages_crawled: 5,
1122 pages_skipped_robots: 1,
1123 pages_failed: 2,
1124 links_discovered: 7,
1125 total_pages_visited: 1,
1126 duration_secs: None,
1127 },
1128 };
1129
1130 let crawler = Crawler::new(
1131 http_client,
1132 start_url.clone(),
1133 0.0,
1134 None,
1135 None,
1136 None,
1137 1,
1138 OutputFormat::Text,
1139 mq_markdown::ConversionOptions::default(),
1140 None,
1141 None,
1142 Vec::new(),
1143 None,
1144 None,
1145 1,
1146 Some(checkpoint),
1147 )
1148 .await
1149 .unwrap();
1150
1151 assert!(crawler.visited.contains(&visited_url));
1154 assert_eq!(crawler.visited.len(), 1);
1155
1156 let mut queued = Vec::new();
1157 while let Some(item) = crawler.to_visit.pop() {
1158 queued.push(item);
1159 }
1160 assert_eq!(queued, vec![(queued_url, 3)]);
1161
1162 let result = crawler.result.read().await;
1163 assert_eq!(result.pages_crawled, 5);
1164 assert_eq!(result.pages_skipped_robots, 1);
1165 assert_eq!(result.pages_failed, 2);
1166 assert_eq!(result.links_discovered, 7);
1167 }
1168}