1use std::cmp::Ordering;
2use std::collections::BinaryHeap;
3
4use chrono::{DateTime, Utc};
5use dashmap::DashSet;
6use parking_lot::Mutex;
7use url::Url;
8
9use crate::CrawlConfig;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub struct Priority(u8);
17
18impl Priority {
19 pub const HIGHEST: Self = Self(0);
21 pub const HIGH: Self = Self(32);
23 pub const NORMAL: Self = Self(64);
25 pub const LOW: Self = Self(128);
27 pub const LOWEST: Self = Self(255);
29
30 pub fn new(value: u8) -> Self {
32 Self(value)
33 }
34
35 pub fn value(&self) -> u8 {
37 self.0
38 }
39}
40
41impl Default for Priority {
42 fn default() -> Self {
43 Self::NORMAL
44 }
45}
46
47impl PartialOrd for Priority {
48 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
49 Some(self.cmp(other))
50 }
51}
52
53impl Ord for Priority {
54 fn cmp(&self, other: &Self) -> Ordering {
55 self.0.cmp(&other.0)
56 }
57}
58
59use serde::{Deserialize, Serialize};
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct QueueEntry {
68 pub url: Url,
70 pub canonical_url: Url,
72 pub depth: usize,
74 pub priority: Priority,
76 pub discovered_at: DateTime<Utc>,
78 pub referrer: Option<Url>,
80}
81
82impl PartialEq for QueueEntry {
84 fn eq(&self, other: &Self) -> bool {
85 self.priority == other.priority && self.url == other.url
86 }
87}
88
89impl Eq for QueueEntry {}
90
91impl PartialOrd for QueueEntry {
92 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
93 Some(self.cmp(other))
94 }
95}
96
97impl Ord for QueueEntry {
98 fn cmp(&self, other: &Self) -> Ordering {
99 other
102 .priority
103 .cmp(&self.priority)
104 .then_with(|| other.url.as_str().cmp(self.url.as_str()))
105 }
106}
107
108#[derive(Debug, Clone, Default)]
113pub struct ScopeConfig {
114 pub allowed_domains: Vec<String>,
116 pub blocked_domains: Vec<String>,
118 pub allowed_paths: Vec<String>,
120 pub blocked_paths: Vec<String>,
122 pub max_depth: Option<usize>,
124}
125
126impl From<&CrawlConfig> for ScopeConfig {
127 fn from(config: &CrawlConfig) -> Self {
128 Self {
129 allowed_domains: Vec::new(),
130 blocked_domains: Vec::new(),
131 allowed_paths: config.allowed_patterns.clone(),
132 blocked_paths: config.disallowed_patterns.clone(),
133 max_depth: None,
134 }
135 }
136}
137
138pub struct UrlQueue {
156 heap: Mutex<BinaryHeap<QueueEntry>>,
157 seen: DashSet<String>,
158 scope: ScopeConfig,
159 domain_counts: dashmap::DashMap<String, usize>,
160}
161
162impl UrlQueue {
163 pub fn new(scope: ScopeConfig) -> Self {
165 Self {
166 heap: Mutex::new(BinaryHeap::new()),
167 seen: DashSet::new(),
168 scope,
169 domain_counts: dashmap::DashMap::new(),
170 }
171 }
172
173 pub fn from_crawl_config(config: &CrawlConfig) -> Self {
175 Self::new(ScopeConfig::from(config))
176 }
177
178 pub fn push(&self, url: Url, depth: usize, priority: Priority) -> bool {
183 self.push_with_referrer(url, depth, priority, None)
184 }
185
186 pub fn push_with_referrer(
190 &self,
191 url: Url,
192 depth: usize,
193 priority: Priority,
194 referrer: Option<Url>,
195 ) -> bool {
196 let url_str = url.to_string();
198 if !self.seen.insert(url_str) {
199 return false;
200 }
201
202 if let Some(max_depth) = self.scope.max_depth {
204 if depth > max_depth {
205 return false;
206 }
207 }
208
209 if !self.is_in_scope(&url) {
211 return false;
212 }
213
214 if let Some(domain) = url.domain() {
216 *self.domain_counts.entry(domain.to_string()).or_insert(0) += 1;
217 }
218
219 let entry = QueueEntry {
220 url: url.clone(),
221 canonical_url: url,
222 depth,
223 priority,
224 discovered_at: Utc::now(),
225 referrer,
226 };
227
228 self.heap.lock().push(entry);
229 true
230 }
231
232 pub fn pop(&self) -> Option<QueueEntry> {
234 self.heap.lock().pop()
235 }
236
237 pub fn len(&self) -> usize {
239 self.heap.lock().len()
240 }
241
242 pub fn is_empty(&self) -> bool {
244 self.heap.lock().is_empty()
245 }
246
247 pub fn seen_count(&self) -> usize {
249 self.seen.len()
250 }
251
252 pub fn domain_count(&self, domain: &str) -> usize {
254 self.domain_counts.get(domain).map_or(0, |c| *c)
255 }
256
257 pub fn domains(&self) -> Vec<String> {
259 self.domain_counts
260 .iter()
261 .map(|entry| entry.key().clone())
262 .collect()
263 }
264
265 pub fn peek(&self) -> Option<QueueEntry> {
267 self.heap.lock().peek().cloned()
268 }
269
270 pub fn drain(&self) -> Vec<QueueEntry> {
272 let mut entries = Vec::new();
273 let mut heap = self.heap.lock();
274 while let Some(entry) = heap.pop() {
275 entries.push(entry);
276 }
277 entries
278 }
279
280 fn is_in_scope(&self, url: &Url) -> bool {
282 let domain = match url.domain() {
283 Some(d) => d,
284 None => return false,
285 };
286
287 for pattern in &self.scope.blocked_domains {
289 if domain_matches_pattern(domain, pattern) {
290 return false;
291 }
292 }
293
294 if !self.scope.allowed_domains.is_empty() {
296 let mut allowed = false;
297 for pattern in &self.scope.allowed_domains {
298 if domain_matches_pattern(domain, pattern) {
299 allowed = true;
300 break;
301 }
302 }
303 if !allowed {
304 return false;
305 }
306 }
307
308 let path = url.path();
309
310 for pattern in &self.scope.blocked_paths {
312 if path.starts_with(pattern) {
313 return false;
314 }
315 }
316
317 if !self.scope.allowed_paths.is_empty() {
319 let mut allowed = false;
320 for pattern in &self.scope.allowed_paths {
321 if path.starts_with(pattern) {
322 allowed = true;
323 break;
324 }
325 }
326 if !allowed {
327 return false;
328 }
329 }
330
331 true
332 }
333}
334
335fn domain_matches_pattern(domain: &str, pattern: &str) -> bool {
337 if let Some(suffix) = pattern.strip_prefix("*.") {
338 domain.ends_with(&format!(".{suffix}"))
339 } else {
340 domain == pattern
341 }
342}
343
344#[cfg(test)]
345#[allow(clippy::unwrap_used)]
346mod tests {
347 use super::*;
348
349 #[test]
350 fn test_priority_ordering() {
351 assert!(Priority::HIGHEST < Priority::HIGH);
352 assert!(Priority::HIGH < Priority::NORMAL);
353 assert!(Priority::NORMAL < Priority::LOW);
354 assert!(Priority::LOW < Priority::LOWEST);
355 }
356
357 #[test]
358 fn test_queue_push_and_pop() {
359 let queue = UrlQueue::new(ScopeConfig::default());
360 let url1 = Url::parse("https://example.com/a").unwrap();
361 let url2 = Url::parse("https://example.com/b").unwrap();
362
363 assert!(queue.push(url1.clone(), 0, Priority::LOW));
364 assert!(queue.push(url2.clone(), 0, Priority::HIGH));
365
366 assert_eq!(queue.len(), 2);
367
368 let entry = queue.pop().unwrap();
370 assert_eq!(entry.url, url2);
371 assert_eq!(entry.priority, Priority::HIGH);
372
373 let entry = queue.pop().unwrap();
374 assert_eq!(entry.url, url1);
375 assert_eq!(entry.priority, Priority::LOW);
376 }
377
378 #[test]
379 fn test_queue_deduplication() {
380 let queue = UrlQueue::new(ScopeConfig::default());
381 let url = Url::parse("https://example.com/page").unwrap();
382
383 assert!(queue.push(url.clone(), 0, Priority::NORMAL));
384 assert!(!queue.push(url, 1, Priority::HIGH)); assert_eq!(queue.len(), 1);
387 assert_eq!(queue.seen_count(), 1);
388 }
389
390 #[test]
391 fn test_queue_depth_limit() {
392 let scope = ScopeConfig {
393 max_depth: Some(2),
394 ..Default::default()
395 };
396 let queue = UrlQueue::new(scope);
397
398 assert!(queue.push(
399 Url::parse("https://example.com/p0").unwrap(),
400 0,
401 Priority::NORMAL,
402 ));
403 assert!(queue.push(
404 Url::parse("https://example.com/p1").unwrap(),
405 1,
406 Priority::NORMAL,
407 ));
408 assert!(queue.push(
409 Url::parse("https://example.com/p2").unwrap(),
410 2,
411 Priority::NORMAL,
412 ));
413 assert!(!queue.push(
414 Url::parse("https://example.com/p3").unwrap(),
415 3,
416 Priority::NORMAL,
417 )); assert_eq!(queue.len(), 3);
420 }
421
422 #[test]
423 fn test_queue_domain_scope() {
424 let scope = ScopeConfig {
425 allowed_domains: vec!["example.com".to_string()],
426 ..Default::default()
427 };
428 let queue = UrlQueue::new(scope);
429
430 let url1 = Url::parse("https://example.com/page").unwrap();
431 let url2 = Url::parse("https://other.com/page").unwrap();
432
433 assert!(queue.push(url1, 0, Priority::NORMAL));
434 assert!(!queue.push(url2, 0, Priority::NORMAL)); assert_eq!(queue.len(), 1);
437 }
438
439 #[test]
440 fn test_queue_blocked_domain() {
441 let scope = ScopeConfig {
442 blocked_domains: vec!["spam.com".to_string()],
443 ..Default::default()
444 };
445 let queue = UrlQueue::new(scope);
446
447 let url1 = Url::parse("https://example.com/page").unwrap();
448 let url2 = Url::parse("https://spam.com/page").unwrap();
449
450 assert!(queue.push(url1, 0, Priority::NORMAL));
451 assert!(!queue.push(url2, 0, Priority::NORMAL)); assert_eq!(queue.len(), 1);
454 }
455
456 #[test]
457 fn test_queue_wildcard_domain_pattern() {
458 let scope = ScopeConfig {
459 allowed_domains: vec!["*.example.com".to_string()],
460 ..Default::default()
461 };
462 let queue = UrlQueue::new(scope);
463
464 assert!(queue.push(
465 Url::parse("https://www.example.com/page").unwrap(),
466 0,
467 Priority::NORMAL,
468 ));
469 assert!(queue.push(
470 Url::parse("https://sub.example.com/page").unwrap(),
471 0,
472 Priority::NORMAL,
473 ));
474 assert!(!queue.push(
475 Url::parse("https://example.com/page").unwrap(),
476 0,
477 Priority::NORMAL,
478 )); assert_eq!(queue.len(), 2);
481 }
482
483 #[test]
484 fn test_queue_domain_tracking() {
485 let queue = UrlQueue::new(ScopeConfig::default());
486
487 queue.push(Url::parse("https://a.com/1").unwrap(), 0, Priority::NORMAL);
488 queue.push(Url::parse("https://a.com/2").unwrap(), 0, Priority::NORMAL);
489 queue.push(Url::parse("https://b.com/1").unwrap(), 0, Priority::NORMAL);
490
491 assert_eq!(queue.domain_count("a.com"), 2);
492 assert_eq!(queue.domain_count("b.com"), 1);
493 assert_eq!(queue.domain_count("c.com"), 0);
494
495 let mut domains = queue.domains();
496 domains.sort();
497 assert_eq!(domains, vec!["a.com", "b.com"]);
498 }
499
500 #[test]
501 fn test_queue_is_empty() {
502 let queue = UrlQueue::new(ScopeConfig::default());
503 assert!(queue.is_empty());
504
505 queue.push(
506 Url::parse("https://example.com").unwrap(),
507 0,
508 Priority::NORMAL,
509 );
510 assert!(!queue.is_empty());
511 }
512
513 #[test]
514 fn test_queue_peek() {
515 let queue = UrlQueue::new(ScopeConfig::default());
516 assert!(queue.peek().is_none());
517
518 let url = Url::parse("https://example.com").unwrap();
519 queue.push(url, 0, Priority::NORMAL);
520
521 let peeked = queue.peek().unwrap();
522 assert_eq!(peeked.url.as_str(), "https://example.com/");
523 assert_eq!(queue.len(), 1); }
525
526 #[test]
527 fn test_queue_drain() {
528 let queue = UrlQueue::new(ScopeConfig::default());
529 queue.push(Url::parse("https://a.com").unwrap(), 0, Priority::HIGH);
530 queue.push(Url::parse("https://b.com").unwrap(), 0, Priority::LOW);
531
532 let entries = queue.drain();
533 assert_eq!(entries.len(), 2);
534 assert!(queue.is_empty());
535 }
536
537 #[test]
538 fn test_domain_matches_pattern() {
539 assert!(domain_matches_pattern("example.com", "example.com"));
540 assert!(domain_matches_pattern("www.example.com", "*.example.com"));
541 assert!(domain_matches_pattern("sub.example.com", "*.example.com"));
542 assert!(!domain_matches_pattern("example.com", "*.example.com"));
543 assert!(!domain_matches_pattern("notexample.com", "example.com"));
544 assert!(!domain_matches_pattern("evil-example.com", "*.example.com"));
545 }
546
547 #[test]
548 fn test_queue_push_with_referrer() {
549 let queue = UrlQueue::new(ScopeConfig::default());
550 let url = Url::parse("https://example.com/page").unwrap();
551 let referrer = Url::parse("https://example.com/other").unwrap();
552
553 queue.push_with_referrer(url, 0, Priority::NORMAL, Some(referrer.clone()));
554
555 let entry = queue.pop().unwrap();
556 assert_eq!(entry.referrer.as_ref(), Some(&referrer));
557 }
558
559 #[test]
560 fn test_scope_config_from_crawl_config() {
561 let crawl_config = CrawlConfig {
562 allowed_patterns: vec!["/blog".to_string()],
563 disallowed_patterns: vec!["/admin".to_string()],
564 ..Default::default()
565 };
566
567 let scope = ScopeConfig::from(&crawl_config);
568 assert_eq!(scope.allowed_paths, vec!["/blog"]);
569 assert_eq!(scope.blocked_paths, vec!["/admin"]);
570 }
571
572 #[test]
573 fn test_queue_path_scope() {
574 let scope = ScopeConfig {
575 blocked_paths: vec!["/admin".to_string()],
576 ..Default::default()
577 };
578 let queue = UrlQueue::new(scope);
579
580 assert!(queue.push(
581 Url::parse("https://example.com/page").unwrap(),
582 0,
583 Priority::NORMAL,
584 ));
585 assert!(!queue.push(
586 Url::parse("https://example.com/admin/secret").unwrap(),
587 0,
588 Priority::NORMAL,
589 ));
590
591 assert_eq!(queue.len(), 1);
592 }
593}