1use std::collections::{HashMap, HashSet};
2
3use serde::{Deserialize, Serialize};
4use url::Url;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Backlink {
13 pub source_url: String,
15 pub target_url: String,
17 pub anchor_text: String,
19 pub is_followed: bool,
21 pub is_internal: bool,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct PageScore {
28 pub url: String,
30 pub pagerank: f64,
32 pub inbound_links: usize,
34 pub outbound_links: usize,
36 pub referring_domains: usize,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct BacklinkReport {
43 pub url: String,
45 pub backlinks: Vec<Backlink>,
47 pub total_backlinks: usize,
49 pub followed_backlinks: usize,
51 pub referring_domains: usize,
53 pub internal_backlinks: usize,
55 pub external_backlinks: usize,
57 pub pagerank_score: f64,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct BacklinkSummary {
64 pub pages: Vec<PageScore>,
66 pub total_internal_links: usize,
68 pub total_external_links: usize,
70 pub total_referring_domains: usize,
72 pub orphan_pages: Vec<String>,
74}
75
76pub struct BacklinkAnalyzer {
82 outgoing: HashMap<String, HashSet<String>>,
84 incoming: HashMap<String, HashSet<String>>,
86 all_urls: HashSet<String>,
88 external_backlinks: Vec<Backlink>,
90}
91
92impl BacklinkAnalyzer {
93 pub fn new() -> Self {
95 Self {
96 outgoing: HashMap::new(),
97 incoming: HashMap::new(),
98 all_urls: HashSet::new(),
99 external_backlinks: Vec::new(),
100 }
101 }
102
103 pub fn add_link(&mut self, source: &str, target: &str) {
108 let source_normalized = source.split('#').next().unwrap_or(source);
110 let target_normalized = target.split('#').next().unwrap_or(target);
111
112 self.all_urls.insert(source_normalized.to_string());
113 self.all_urls.insert(target_normalized.to_string());
114
115 self.outgoing
116 .entry(source_normalized.to_string())
117 .or_default()
118 .insert(target_normalized.to_string());
119
120 self.incoming
121 .entry(target_normalized.to_string())
122 .or_default()
123 .insert(source_normalized.to_string());
124 }
125
126 pub fn add_backlink(&mut self, backlink: Backlink) {
128 if backlink.is_internal {
129 self.add_link(&backlink.source_url, &backlink.target_url);
130 } else {
131 self.all_urls.insert(backlink.target_url.clone());
132 self.external_backlinks.push(backlink);
133 }
134 }
135
136 pub fn load_from_crawl_data(&mut self, pages: &[(String, Vec<String>)]) {
140 for (page_url, links) in pages {
141 for link_url in links {
142 self.add_link(page_url, link_url);
143 }
144 }
145 }
146
147 pub fn compute_pagerank(&self, damping: f64, iterations: usize) -> HashMap<String, f64> {
152 let n = self.all_urls.len();
153 if n == 0 {
154 return HashMap::new();
155 }
156
157 let initial_score = 1.0 / n as f64;
158 let mut scores: HashMap<String, f64> = self
159 .all_urls
160 .iter()
161 .map(|url| (url.clone(), initial_score))
162 .collect();
163
164 let dangling_contrib = damping / n as f64;
165
166 for _ in 0..iterations {
167 let mut new_scores: HashMap<String, f64> = HashMap::with_capacity(n);
168
169 let dangling_sum: f64 = self
171 .all_urls
172 .iter()
173 .filter(|url| !self.outgoing.contains_key(*url))
174 .map(|url| scores.get(url).copied().unwrap_or(initial_score))
175 .sum::<f64>()
176 * dangling_contrib;
177
178 for url in &self.all_urls {
179 let base = (1.0 - damping) / n as f64 + dangling_sum;
180
181 let link_contribution: f64 = self
182 .incoming
183 .get(url)
184 .map(|sources| {
185 sources
186 .iter()
187 .filter_map(|src| {
188 let out_degree = self
189 .outgoing
190 .get(src)
191 .map(|t| t.len() as f64)
192 .unwrap_or(1.0);
193 scores.get(src).map(|s| s / out_degree)
194 })
195 .sum()
196 })
197 .unwrap_or(0.0)
198 * damping;
199
200 new_scores.insert(url.clone(), base + link_contribution);
201 }
202
203 scores = new_scores;
204 }
205
206 scores
207 }
208
209 pub fn report_for_url(
211 &self,
212 url: &str,
213 pagerank_scores: &HashMap<String, f64>,
214 ) -> BacklinkReport {
215 let internal_backlinks: Vec<Backlink> = self
216 .incoming
217 .get(url)
218 .map(|sources| {
219 sources
220 .iter()
221 .map(|src| Backlink {
222 source_url: src.clone(),
223 target_url: url.to_string(),
224 anchor_text: String::new(),
225 is_followed: true,
226 is_internal: true,
227 })
228 .collect()
229 })
230 .unwrap_or_default();
231
232 let external_backlinks: Vec<Backlink> = self
233 .external_backlinks
234 .iter()
235 .filter(|bl| bl.target_url == url)
236 .cloned()
237 .collect();
238
239 let mut all_backlinks = internal_backlinks;
240 all_backlinks.extend(external_backlinks);
241
242 let internal_count = all_backlinks.iter().filter(|bl| bl.is_internal).count();
243 let external_count = all_backlinks.iter().filter(|bl| !bl.is_internal).count();
244 let followed_count = all_backlinks.iter().filter(|bl| bl.is_followed).count();
245
246 let referring_domains: HashSet<String> = all_backlinks
247 .iter()
248 .filter_map(|bl| Url::parse(&bl.source_url).ok())
249 .filter_map(|u| u.domain().map(String::from))
250 .collect();
251
252 BacklinkReport {
253 url: url.to_string(),
254 total_backlinks: all_backlinks.len(),
255 followed_backlinks: followed_count,
256 referring_domains: referring_domains.len(),
257 internal_backlinks: internal_count,
258 external_backlinks: external_count,
259 pagerank_score: pagerank_scores.get(url).copied().unwrap_or(0.0),
260 backlinks: all_backlinks,
261 }
262 }
263
264 pub fn summarize(&self) -> BacklinkSummary {
266 let scores = self.compute_pagerank(0.85, 20);
267
268 let mut pages: Vec<PageScore> = self
269 .all_urls
270 .iter()
271 .map(|url| {
272 let inbound = self.incoming.get(url).map_or(0, |s| s.len());
273 let outbound = self.outgoing.get(url).map_or(0, |s| s.len());
274
275 let referring_domains: HashSet<String> = self
276 .incoming
277 .get(url)
278 .map(|sources| {
279 sources
280 .iter()
281 .filter_map(|src| Url::parse(src).ok())
282 .filter_map(|u| u.domain().map(String::from))
283 .collect()
284 })
285 .unwrap_or_default();
286
287 let external_inbound = self
288 .external_backlinks
289 .iter()
290 .filter(|bl| bl.target_url == *url)
291 .count();
292
293 PageScore {
294 url: url.clone(),
295 pagerank: scores.get(url).copied().unwrap_or(0.0),
296 inbound_links: inbound + external_inbound,
297 outbound_links: outbound,
298 referring_domains: referring_domains.len(),
299 }
300 })
301 .collect();
302
303 pages.sort_by(|a, b| {
304 b.pagerank
305 .partial_cmp(&a.pagerank)
306 .unwrap_or(std::cmp::Ordering::Equal)
307 });
308
309 let orphan_pages: Vec<String> = self
310 .all_urls
311 .iter()
312 .filter(|url| {
313 !self.incoming.contains_key(*url)
314 && self
315 .external_backlinks
316 .iter()
317 .all(|bl| bl.target_url != **url)
318 })
319 .cloned()
320 .collect();
321
322 let total_internal: usize = self.outgoing.values().map(|v| v.len()).sum();
323 let total_external = self.external_backlinks.len();
324
325 let all_referring_domains: HashSet<String> = self
326 .external_backlinks
327 .iter()
328 .filter_map(|bl| Url::parse(&bl.source_url).ok())
329 .filter_map(|u| u.domain().map(String::from))
330 .collect();
331
332 BacklinkSummary {
333 pages,
334 total_internal_links: total_internal,
335 total_external_links: total_external,
336 total_referring_domains: all_referring_domains.len(),
337 orphan_pages,
338 }
339 }
340
341 pub fn url_count(&self) -> usize {
343 self.all_urls.len()
344 }
345
346 pub fn link_count(&self) -> usize {
348 self.outgoing.values().map(|v| v.len()).sum()
349 }
350
351 pub fn known_urls(&self) -> &HashSet<String> {
353 &self.all_urls
354 }
355
356 #[must_use]
358 pub fn to_dot(&self) -> String {
359 let mut dot = String::from("digraph linkgraph {\n");
360 for (source, targets) in &self.outgoing {
361 for target in targets {
362 dot.push_str(&format!(" \"{}\" -> \"{}\";\n", source, target));
363 }
364 }
365 dot.push('}');
366 dot
367 }
368
369 #[must_use]
371 pub fn to_csv(&self, pagerank_scores: &HashMap<String, f64>) -> String {
372 let mut csv = String::from("source,target,pagerank_source,pagerank_target\n");
373 for (source, targets) in &self.outgoing {
374 let pr_source = pagerank_scores.get(source).unwrap_or(&0.0);
375 for target in targets {
376 let pr_target = pagerank_scores.get(target).unwrap_or(&0.0);
377 csv.push_str(&format!(
378 "{},{},{:.6},{:.6}\n",
379 source, target, pr_source, pr_target
380 ));
381 }
382 }
383 csv
384 }
385}
386
387impl Default for BacklinkAnalyzer {
388 fn default() -> Self {
389 Self::new()
390 }
391}
392
393#[cfg(test)]
398mod tests {
399 use super::*;
400
401 #[test]
402 fn test_empty_analyzer() {
403 let analyzer = BacklinkAnalyzer::new();
404 assert_eq!(analyzer.url_count(), 0);
405 assert_eq!(analyzer.link_count(), 0);
406 }
407
408 #[test]
409 fn test_add_link() {
410 let mut analyzer = BacklinkAnalyzer::new();
411 analyzer.add_link("https://example.com/", "https://example.com/about");
412 analyzer.add_link("https://example.com/", "https://example.com/contact");
413
414 assert_eq!(analyzer.url_count(), 3);
415 assert_eq!(analyzer.link_count(), 2);
416 }
417
418 #[test]
419 fn test_load_from_crawl_data() {
420 let mut analyzer = BacklinkAnalyzer::new();
421 let pages = vec![
422 (
423 "https://example.com/".to_string(),
424 vec![
425 "https://example.com/about".to_string(),
426 "https://example.com/blog".to_string(),
427 ],
428 ),
429 (
430 "https://example.com/about".to_string(),
431 vec!["https://example.com/".to_string()],
432 ),
433 ];
434
435 analyzer.load_from_crawl_data(&pages);
436 assert_eq!(analyzer.url_count(), 3);
437 assert_eq!(analyzer.link_count(), 3);
438 }
439
440 #[test]
441 fn test_pagerank_simple_linear() {
442 let mut analyzer = BacklinkAnalyzer::new();
444 analyzer.add_link("https://a.com", "https://b.com");
445 analyzer.add_link("https://b.com", "https://c.com");
446
447 let scores = analyzer.compute_pagerank(0.85, 50);
448
449 let score_a = scores.get("https://a.com").unwrap();
452 let score_b = scores.get("https://b.com").unwrap();
453 let score_c = scores.get("https://c.com").unwrap();
454
455 let total = score_a + score_b + score_c;
457 assert!(
458 (total - 1.0).abs() < 0.01,
459 "scores should sum to ~1.0, got {total}"
460 );
461
462 assert!(
464 score_c > score_a,
465 "C ({score_c}) should rank higher than A ({score_a})"
466 );
467 }
468
469 #[test]
470 fn test_pagerank_cycle() {
471 let mut analyzer = BacklinkAnalyzer::new();
473 analyzer.add_link("https://a.com", "https://b.com");
474 analyzer.add_link("https://b.com", "https://a.com");
475
476 let scores = analyzer.compute_pagerank(0.85, 50);
477
478 let score_a = scores.get("https://a.com").unwrap();
479 let score_b = scores.get("https://b.com").unwrap();
480
481 assert!(
483 (score_a - score_b).abs() < 0.05,
484 "A ({score_a}) and B ({score_b}) should have similar scores"
485 );
486 }
487
488 #[test]
489 fn test_pagerank_hub_page() {
490 let mut analyzer = BacklinkAnalyzer::new();
492 let pages: Vec<String> = (0..10)
493 .map(|i| format!("https://example.com/page{i}"))
494 .collect();
495
496 for page in &pages {
497 analyzer.add_link("https://example.com/hub", page);
498 analyzer.add_link(page, "https://example.com/hub");
499 }
500
501 let scores = analyzer.compute_pagerank(0.85, 50);
502
503 let hub_score = scores.get("https://example.com/hub").unwrap();
504 let page0_score = scores.get("https://example.com/page0").unwrap();
505
506 assert!(
508 hub_score > page0_score,
509 "Hub ({hub_score}) should rank higher than page0 ({page0_score})"
510 );
511 }
512
513 #[test]
514 fn test_report_for_url() {
515 let mut analyzer = BacklinkAnalyzer::new();
516 analyzer.add_link("https://example.com/", "https://example.com/about");
517 analyzer.add_link("https://example.com/blog", "https://example.com/about");
518 analyzer.add_link("https://example.com/contact", "https://example.com/about");
519
520 let scores = analyzer.compute_pagerank(0.85, 20);
521 let report = analyzer.report_for_url("https://example.com/about", &scores);
522
523 assert_eq!(report.url, "https://example.com/about");
524 assert_eq!(report.total_backlinks, 3);
525 assert_eq!(report.internal_backlinks, 3);
526 assert_eq!(report.referring_domains, 1); assert!(report.pagerank_score > 0.0);
528 }
529
530 #[test]
531 fn test_report_external_backlinks() {
532 let mut analyzer = BacklinkAnalyzer::new();
533 analyzer.add_backlink(Backlink {
534 source_url: "https://external.com/mention".to_string(),
535 target_url: "https://example.com/about".to_string(),
536 anchor_text: "Great site".to_string(),
537 is_followed: true,
538 is_internal: false,
539 });
540 analyzer.add_link("https://example.com/", "https://example.com/about");
541
542 let scores = analyzer.compute_pagerank(0.85, 20);
543 let report = analyzer.report_for_url("https://example.com/about", &scores);
544
545 assert_eq!(report.total_backlinks, 2);
546 assert_eq!(report.external_backlinks, 1);
547 assert_eq!(report.referring_domains, 2); }
549
550 #[test]
551 fn test_summarize() {
552 let mut analyzer = BacklinkAnalyzer::new();
553 analyzer.add_link("https://example.com/", "https://example.com/about");
554 analyzer.add_link("https://example.com/", "https://example.com/blog");
555 analyzer.add_link("https://example.com/about", "https://example.com/");
556
557 let summary = analyzer.summarize();
558
559 assert_eq!(summary.pages.len(), 3);
560 assert_eq!(summary.total_internal_links, 3);
561 assert_eq!(summary.total_external_links, 0);
562
563 assert!(summary.orphan_pages.is_empty());
567 }
568
569 #[test]
570 fn test_orphan_detection() {
571 let mut analyzer = BacklinkAnalyzer::new();
572 analyzer.add_link("https://example.com/", "https://example.com/about");
573 analyzer
575 .all_urls
576 .insert("https://example.com/orphan".to_string());
577
578 let summary = analyzer.summarize();
579 assert!(summary
580 .orphan_pages
581 .contains(&"https://example.com/orphan".to_string()));
582 }
583
584 #[test]
585 fn test_dangling_nodes() {
586 let mut analyzer = BacklinkAnalyzer::new();
588 analyzer.add_link("https://a.com", "https://b.com");
589 let scores = analyzer.compute_pagerank(0.85, 50);
592 assert!(scores.contains_key("https://a.com"));
593 assert!(scores.contains_key("https://b.com"));
594
595 let total: f64 = scores.values().sum();
596 assert!((total - 1.0).abs() < 0.01);
597 }
598
599 #[test]
600 fn test_pagerank_single_node() {
601 let mut analyzer = BacklinkAnalyzer::new();
602 analyzer.all_urls.insert("https://solo.com".to_string());
603
604 let scores = analyzer.compute_pagerank(0.85, 20);
605 let score = scores.get("https://solo.com").unwrap();
606 assert!(
607 (*score - 1.0).abs() < 0.01,
608 "single node should have score ~1.0"
609 );
610 }
611
612 #[test]
613 fn test_known_urls() {
614 let mut analyzer = BacklinkAnalyzer::new();
615 analyzer.add_link("https://a.com", "https://b.com");
616
617 let urls = analyzer.known_urls();
618 assert!(urls.contains("https://a.com"));
619 assert!(urls.contains("https://b.com"));
620 }
621
622 #[test]
623 fn test_backlink_serialization() {
624 let bl = Backlink {
625 source_url: "https://src.com".to_string(),
626 target_url: "https://tgt.com".to_string(),
627 anchor_text: "click here".to_string(),
628 is_followed: true,
629 is_internal: false,
630 };
631
632 let json = serde_json::to_string(&bl).unwrap();
633 let deser: Backlink = serde_json::from_str(&json).unwrap();
634 assert_eq!(bl.source_url, deser.source_url);
635 assert_eq!(bl.target_url, deser.target_url);
636 }
637}