Skip to main content

crawlkit_engine/
backlinks.rs

1use std::collections::{HashMap, HashSet};
2
3use serde::{Deserialize, Serialize};
4use url::Url;
5
6// ---------------------------------------------------------------------------
7// Types
8// ---------------------------------------------------------------------------
9
10/// A single backlink pointing to a page.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Backlink {
13    /// The source page URL (the page containing the link).
14    pub source_url: String,
15    /// The target page URL (the page being linked to).
16    pub target_url: String,
17    /// Anchor text of the link.
18    pub anchor_text: String,
19    /// Whether the link is followed (not nofollow).
20    pub is_followed: bool,
21    /// Whether the link is internal (same domain as target).
22    pub is_internal: bool,
23}
24
25/// PageRank-like score for a URL.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct PageScore {
28    /// The URL.
29    pub url: String,
30    /// PageRank score (0.0 – 1.0).
31    pub pagerank: f64,
32    /// Number of inbound links (backlinks).
33    pub inbound_links: usize,
34    /// Number of outbound links.
35    pub outbound_links: usize,
36    /// Number of unique referring domains.
37    pub referring_domains: usize,
38}
39
40/// Result of a backlink analysis for a single page.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct BacklinkReport {
43    /// The analyzed page URL.
44    pub url: String,
45    /// All backlinks to this page.
46    pub backlinks: Vec<Backlink>,
47    /// Total number of backlinks.
48    pub total_backlinks: usize,
49    /// Number of followed (non-nofollow) backlinks.
50    pub followed_backlinks: usize,
51    /// Number of unique referring domains.
52    pub referring_domains: usize,
53    /// Number of internal backlinks.
54    pub internal_backlinks: usize,
55    /// Number of external backlinks.
56    pub external_backlinks: usize,
57    /// PageRank-like score.
58    pub pagerank_score: f64,
59}
60
61/// Summary of the entire site's backlink profile.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct BacklinkSummary {
64    /// PageRank scores for all pages, sorted by score descending.
65    pub pages: Vec<PageScore>,
66    /// Total number of internal links.
67    pub total_internal_links: usize,
68    /// Total number of external links.
69    pub total_external_links: usize,
70    /// Total number of unique referring domains.
71    pub total_referring_domains: usize,
72    /// Pages with zero inbound links (orphans).
73    pub orphan_pages: Vec<String>,
74}
75
76// ---------------------------------------------------------------------------
77// BacklinkAnalyzer
78// ---------------------------------------------------------------------------
79
80/// Analyzes internal link graphs and computes PageRank-like scores.
81pub struct BacklinkAnalyzer {
82    /// Adjacency list: source -> set of targets.
83    outgoing: HashMap<String, HashSet<String>>,
84    /// Reverse adjacency: target -> set of sources.
85    incoming: HashMap<String, HashSet<String>>,
86    /// All known URLs.
87    all_urls: HashSet<String>,
88    /// External link data.
89    external_backlinks: Vec<Backlink>,
90}
91
92impl BacklinkAnalyzer {
93    /// Creates a new empty `BacklinkAnalyzer`.
94    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    /// Adds an internal link from `source` to `target`.
104    ///
105    /// Both URLs should be normalized (e.g. trailing slash stripped).
106    /// Fragments are automatically stripped.
107    pub fn add_link(&mut self, source: &str, target: &str) {
108        // Strip fragments from URLs for consistent comparison
109        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    /// Adds a backlink record (used for external backlink tracking).
127    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    /// Bulk-load internal links from crawl data.
137    ///
138    /// `pages` should be a slice of `(page_url, [link_urls])` tuples.
139    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    /// Computes PageRank scores for all known URLs.
148    ///
149    /// Uses the iterative PageRank algorithm with damping factor `d`.
150    /// Typically `d = 0.85`.
151    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            // Collect dangling contribution
170            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    /// Generates a backlink report for a specific URL.
210    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    /// Generates a full backlink summary for the entire site.
265    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    /// Returns the total number of known URLs.
342    pub fn url_count(&self) -> usize {
343        self.all_urls.len()
344    }
345
346    /// Returns the total number of internal links.
347    pub fn link_count(&self) -> usize {
348        self.outgoing.values().map(|v| v.len()).sum()
349    }
350
351    /// Returns the set of all known URLs.
352    pub fn known_urls(&self) -> &HashSet<String> {
353        &self.all_urls
354    }
355
356    /// Export the link graph as DOT format for Graphviz.
357    #[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    /// Export the link graph as CSV adjacency list.
370    #[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// ---------------------------------------------------------------------------
394// Tests
395// ---------------------------------------------------------------------------
396
397#[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        // A -> B -> C
443        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        // C should have highest score (receives link from B, which receives from A)
450        // B receives from A, C receives from B
451        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        // Scores should sum to ~1.0
456        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        // C should have the highest score in this chain
463        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        // A <-> B (bidirectional)
472        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        // Should be roughly equal
482        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        // Hub page links to many pages, all pages link back to hub
491        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        // Hub should have a much higher score
507        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); // all from example.com
527        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); // example.com + external.com
548    }
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        // / is linked from /about, /about is linked from /
564        // /blog is only linked from / → orphan? No, it has an inbound link
565        // All pages have at least one inbound link
566        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        // /orphan has no inbound links
574        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        // Page with outgoing links but no incoming → PageRank should still work
587        let mut analyzer = BacklinkAnalyzer::new();
588        analyzer.add_link("https://a.com", "https://b.com");
589        // b.com has no outgoing links (dangling node)
590
591        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}