Skip to main content

crawlkit_engine/
backlink_adapters.rs

1use serde::{Deserialize, Serialize};
2
3/// External backlink data source adapter trait.
4///
5/// All backlink providers (Ahrefs, Majestic, GSC) implement this trait.
6#[async_trait::async_trait]
7pub trait BacklinkAdapter: Send + Sync {
8    /// Get adapter name.
9    fn name(&self) -> &str;
10
11    /// Fetch backlinks for a domain.
12    ///
13    /// # Errors
14    /// Returns error if API call fails.
15    async fn fetch_backlinks(
16        &self,
17        domain: &str,
18        limit: usize,
19    ) -> Result<Vec<ExternalBacklink>, AdapterError>;
20
21    /// Get domain rating/authority score.
22    ///
23    /// # Errors
24    /// Returns error if API call fails.
25    async fn get_domain_rating(&self, domain: &str) -> Result<f64, AdapterError>;
26
27    /// Check if adapter is configured and available.
28    fn is_available(&self) -> bool;
29}
30
31/// A backlink from an external source.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct ExternalBacklink {
34    /// Source URL of the backlink.
35    pub source_url: String,
36    /// Target URL being linked to.
37    pub target_url: String,
38    /// Anchor text.
39    pub anchor_text: String,
40    /// Domain rating of source (0-100).
41    pub domain_rating: f64,
42    /// Whether the link is followed.
43    pub is_followed: bool,
44    /// First seen date.
45    pub first_seen: Option<String>,
46    /// Last seen date.
47    pub last_seen: Option<String>,
48}
49
50/// Adapter errors.
51#[derive(Debug, thiserror::Error)]
52pub enum AdapterError {
53    #[error("API key not configured")]
54    ApiKeyMissing,
55
56    #[error("API request failed: {0}")]
57    RequestFailed(String),
58
59    #[error("Rate limit exceeded, retry after {0}s")]
60    RateLimited(u64),
61
62    #[error("Domain not found: {0}")]
63    DomainNotFound(String),
64
65    #[error("Adapter not available: {0}")]
66    NotAvailable(String),
67}
68
69// ---------------------------------------------------------------------------
70// Ahrefs Adapter
71// ---------------------------------------------------------------------------
72
73/// Ahrefs backlink adapter.
74pub struct AhrefsAdapter {
75    api_key: Option<String>,
76    #[allow(dead_code)]
77    base_url: String,
78}
79
80impl AhrefsAdapter {
81    /// Create new Ahrefs adapter.
82    #[must_use]
83    pub fn new(api_key: Option<String>) -> Self {
84        Self {
85            api_key,
86            base_url: "https://api.ahrefs.com".to_string(),
87        }
88    }
89
90    /// Create from environment variable.
91    #[must_use]
92    pub fn from_env() -> Self {
93        let api_key = std::env::var("AHREFS_API_KEY").ok();
94        Self::new(api_key)
95    }
96}
97
98#[async_trait::async_trait]
99impl BacklinkAdapter for AhrefsAdapter {
100    fn name(&self) -> &str {
101        "ahrefs"
102    }
103
104    async fn fetch_backlinks(
105        &self,
106        domain: &str,
107        limit: usize,
108    ) -> Result<Vec<ExternalBacklink>, AdapterError> {
109        if !self.is_available() {
110            return Err(AdapterError::ApiKeyMissing);
111        }
112
113        let api_key = self.api_key.as_deref().ok_or(AdapterError::ApiKeyMissing)?;
114        let url = format!(
115            "https://api.ahrefs.com/v3/backlinks?target={}&mode=live&output=json&token={}&limit={}",
116            urlencoding::encode(domain),
117            api_key,
118            limit
119        );
120
121        let response = reqwest::get(&url)
122            .await
123            .map_err(|e| AdapterError::RequestFailed(e.to_string()))?;
124
125        if !response.status().is_success() {
126            return Err(AdapterError::RequestFailed(format!(
127                "HTTP {}",
128                response.status()
129            )));
130        }
131
132        let data: serde_json::Value = response
133            .json()
134            .await
135            .map_err(|e| AdapterError::RequestFailed(e.to_string()))?;
136
137        let backlinks = data["refdomains"]
138            .as_array()
139            .map(|arr| {
140                arr.iter()
141                    .map(|item| ExternalBacklink {
142                        source_url: item["source_url"].as_str().unwrap_or("").to_string(),
143                        target_url: item["target_url"].as_str().unwrap_or("").to_string(),
144                        anchor_text: item["anchor"].as_str().unwrap_or("").to_string(),
145                        domain_rating: item["domain_rating"].as_f64().unwrap_or(0.0),
146                        is_followed: item["dofollow"].as_bool().unwrap_or(false),
147                        first_seen: item["first_seen"].as_str().map(|s| s.to_string()),
148                        last_seen: item["last_seen"].as_str().map(|s| s.to_string()),
149                    })
150                    .collect()
151            })
152            .unwrap_or_default();
153
154        Ok(backlinks)
155    }
156
157    async fn get_domain_rating(&self, domain: &str) -> Result<f64, AdapterError> {
158        if !self.is_available() {
159            return Err(AdapterError::ApiKeyMissing);
160        }
161
162        let api_key = self.api_key.as_deref().ok_or(AdapterError::ApiKeyMissing)?;
163        let url = format!(
164            "https://api.ahrefs.com/v3/domain-rating?target={}&output=json&token={}",
165            urlencoding::encode(domain),
166            api_key
167        );
168
169        let response = reqwest::get(&url)
170            .await
171            .map_err(|e| AdapterError::RequestFailed(e.to_string()))?;
172
173        if !response.status().is_success() {
174            return Err(AdapterError::RequestFailed(format!(
175                "HTTP {}",
176                response.status()
177            )));
178        }
179
180        let data: serde_json::Value = response
181            .json()
182            .await
183            .map_err(|e| AdapterError::RequestFailed(e.to_string()))?;
184
185        Ok(data["domain_rating"].as_f64().unwrap_or(0.0))
186    }
187
188    fn is_available(&self) -> bool {
189        self.api_key.is_some()
190    }
191}
192
193// ---------------------------------------------------------------------------
194// Majestic Adapter
195// ---------------------------------------------------------------------------
196
197/// Majestic backlink adapter.
198pub struct MajesticAdapter {
199    api_key: Option<String>,
200}
201
202impl MajesticAdapter {
203    /// Create new Majestic adapter.
204    #[must_use]
205    pub fn new(api_key: Option<String>) -> Self {
206        Self { api_key }
207    }
208
209    /// Create from environment variable.
210    #[must_use]
211    pub fn from_env() -> Self {
212        let api_key = std::env::var("MAJESTIC_API_KEY").ok();
213        Self::new(api_key)
214    }
215}
216
217#[async_trait::async_trait]
218impl BacklinkAdapter for MajesticAdapter {
219    fn name(&self) -> &str {
220        "majestic"
221    }
222
223    async fn fetch_backlinks(
224        &self,
225        domain: &str,
226        limit: usize,
227    ) -> Result<Vec<ExternalBacklink>, AdapterError> {
228        if !self.is_available() {
229            return Err(AdapterError::ApiKeyMissing);
230        }
231
232        let api_key = self.api_key.as_deref().ok_or(AdapterError::ApiKeyMissing)?;
233        let client = reqwest::Client::new();
234
235        let params = [
236            ("cmd", "BacklinkData"),
237            ("item", domain),
238            ("count", &limit.to_string()),
239            ("output", "json"),
240            ("key", api_key),
241        ];
242
243        let response = client
244            .post("https://api.majestic.com/api/command")
245            .form(&params)
246            .send()
247            .await
248            .map_err(|e| AdapterError::RequestFailed(e.to_string()))?;
249
250        if !response.status().is_success() {
251            return Err(AdapterError::RequestFailed(format!(
252                "HTTP {}",
253                response.status()
254            )));
255        }
256
257        let data: serde_json::Value = response
258            .json()
259            .await
260            .map_err(|e| AdapterError::RequestFailed(e.to_string()))?;
261
262        let backlinks = data["Data"]["Backlinks"]
263            .as_array()
264            .map(|arr| {
265                arr.iter()
266                    .map(|item| ExternalBacklink {
267                        source_url: item["SourceURL"].as_str().unwrap_or("").to_string(),
268                        target_url: item["TargetURL"].as_str().unwrap_or("").to_string(),
269                        anchor_text: item["AnchorText"].as_str().unwrap_or("").to_string(),
270                        domain_rating: item["TrustFlow"].as_f64().unwrap_or(0.0),
271                        is_followed: item["Flag"].as_str().unwrap_or("") != "nofollow",
272                        first_seen: item["FirstSeen"].as_str().map(|s| s.to_string()),
273                        last_seen: item["LastSeen"].as_str().map(|s| s.to_string()),
274                    })
275                    .collect()
276            })
277            .unwrap_or_default();
278
279        Ok(backlinks)
280    }
281
282    async fn get_domain_rating(&self, domain: &str) -> Result<f64, AdapterError> {
283        if !self.is_available() {
284            return Err(AdapterError::ApiKeyMissing);
285        }
286
287        let api_key = self.api_key.as_deref().ok_or(AdapterError::ApiKeyMissing)?;
288        let client = reqwest::Client::new();
289
290        let params = [
291            ("cmd", "GetIndexItemInfo"),
292            ("item", domain),
293            ("items", "0"),
294            ("output", "json"),
295            ("key", api_key),
296        ];
297
298        let response = client
299            .post("https://api.majestic.com/api/command")
300            .form(&params)
301            .send()
302            .await
303            .map_err(|e| AdapterError::RequestFailed(e.to_string()))?;
304
305        if !response.status().is_success() {
306            return Err(AdapterError::RequestFailed(format!(
307                "HTTP {}",
308                response.status()
309            )));
310        }
311
312        let data: serde_json::Value = response
313            .json()
314            .await
315            .map_err(|e| AdapterError::RequestFailed(e.to_string()))?;
316
317        Ok(data["Data"]["Results"][0]["TrustFlow"]
318            .as_f64()
319            .unwrap_or(0.0))
320    }
321
322    fn is_available(&self) -> bool {
323        self.api_key.is_some()
324    }
325}
326
327// ---------------------------------------------------------------------------
328// Google Search Console Adapter
329// ---------------------------------------------------------------------------
330
331/// Google Search Console adapter.
332pub struct GscAdapter {
333    access_token: Option<String>,
334}
335
336impl GscAdapter {
337    /// Create new GSC adapter.
338    #[must_use]
339    pub fn new(access_token: Option<String>) -> Self {
340        Self { access_token }
341    }
342
343    /// Create from environment variable.
344    #[must_use]
345    pub fn from_env() -> Self {
346        let access_token = std::env::var("GSC_ACCESS_TOKEN").ok();
347        Self::new(access_token)
348    }
349}
350
351#[async_trait::async_trait]
352impl BacklinkAdapter for GscAdapter {
353    fn name(&self) -> &str {
354        "google_search_console"
355    }
356
357    async fn fetch_backlinks(
358        &self,
359        domain: &str,
360        limit: usize,
361    ) -> Result<Vec<ExternalBacklink>, AdapterError> {
362        if !self.is_available() {
363            return Err(AdapterError::ApiKeyMissing);
364        }
365
366        let access_token = self
367            .access_token
368            .as_deref()
369            .ok_or(AdapterError::ApiKeyMissing)?;
370        let client = reqwest::Client::new();
371
372        // GSC API for external links
373        let url = format!(
374            "https://searchconsole.googleapis.com/webmasters/v3/sites/{}%2FexternalLinks?rowLimit={}",
375            urlencoding::encode(&format!("https://{}/", domain)),
376            limit
377        );
378
379        let response = client
380            .get(&url)
381            .bearer_auth(access_token)
382            .send()
383            .await
384            .map_err(|e| AdapterError::RequestFailed(e.to_string()))?;
385
386        if !response.status().is_success() {
387            return Err(AdapterError::RequestFailed(format!(
388                "HTTP {}",
389                response.status()
390            )));
391        }
392
393        let data: serde_json::Value = response
394            .json()
395            .await
396            .map_err(|e| AdapterError::RequestFailed(e.to_string()))?;
397
398        let backlinks = data["linkEntries"]
399            .as_array()
400            .map(|arr| {
401                arr.iter()
402                    .map(|item| ExternalBacklink {
403                        source_url: item["targetPage"].as_str().unwrap_or("").to_string(),
404                        target_url: item["pageUrl"].as_str().unwrap_or("").to_string(),
405                        anchor_text: String::new(), // GSC doesn't provide anchor text
406                        domain_rating: 0.0,         // GSC doesn't provide DR
407                        is_followed: true,          // GSC links are typically followed
408                        first_seen: None,
409                        last_seen: None,
410                    })
411                    .collect()
412            })
413            .unwrap_or_default();
414
415        Ok(backlinks)
416    }
417
418    async fn get_domain_rating(&self, _domain: &str) -> Result<f64, AdapterError> {
419        // GSC doesn't provide domain rating
420        Err(AdapterError::NotAvailable(
421            "GSC does not provide domain rating".to_string(),
422        ))
423    }
424
425    fn is_available(&self) -> bool {
426        self.access_token.is_some()
427    }
428}
429
430/// GSC search analytics result.
431#[derive(Debug, Clone, Serialize, Deserialize)]
432pub struct GscSearchResult {
433    /// Query or page URL.
434    pub key: String,
435    /// Number of clicks.
436    pub clicks: u64,
437    /// Number of impressions.
438    pub impressions: u64,
439    /// Click-through rate (0.0-1.0).
440    pub ctr: f64,
441    /// Average position in search results.
442    pub position: f64,
443}
444
445/// GSC index coverage entry.
446#[derive(Debug, Clone, Serialize, Deserialize)]
447pub struct GscCoverageEntry {
448    /// The URL or page.
449    pub url: String,
450    /// Index status: "indexed", "excluded", or "error".
451    pub status: String,
452    /// Number of pages in this coverage category.
453    pub coverage_count: u64,
454}
455
456impl GscAdapter {
457    /// Fetch search analytics from Google Search Console.
458    ///
459    /// Returns top queries or pages with clicks, impressions, CTR, and position.
460    pub async fn search_analytics(
461        &self,
462        domain: &str,
463        row_limit: usize,
464        dimension: &str, // "query" or "page"
465    ) -> Result<Vec<GscSearchResult>, AdapterError> {
466        if !self.is_available() {
467            return Err(AdapterError::ApiKeyMissing);
468        }
469
470        let access_token = self
471            .access_token
472            .as_deref()
473            .ok_or(AdapterError::ApiKeyMissing)?;
474        let client = reqwest::Client::new();
475
476        let site_url = format!("https://{domain}/");
477        let url = format!(
478            "https://searchconsole.googleapis.com/webmasters/v3/sites/{}/searchAnalytics/query",
479            urlencoding::encode(&site_url)
480        );
481
482        let body = serde_json::json!({
483            "startDate": "2026-06-01",
484            "endDate": "2026-06-30",
485            "dimensions": [dimension],
486            "rowLimit": row_limit,
487        });
488
489        let response = client
490            .post(&url)
491            .bearer_auth(access_token)
492            .json(&body)
493            .send()
494            .await
495            .map_err(|e| AdapterError::RequestFailed(e.to_string()))?;
496
497        if !response.status().is_success() {
498            return Err(AdapterError::RequestFailed(format!(
499                "HTTP {}",
500                response.status()
501            )));
502        }
503
504        let data: serde_json::Value = response
505            .json()
506            .await
507            .map_err(|e| AdapterError::RequestFailed(e.to_string()))?;
508
509        let results = data["rows"]
510            .as_array()
511            .map(|arr| {
512                arr.iter()
513                    .map(|item| {
514                        let key = item["keys"]
515                            .as_array()
516                            .and_then(|k| k.first())
517                            .and_then(|v| v.as_str())
518                            .unwrap_or("")
519                            .to_string();
520                        GscSearchResult {
521                            key,
522                            clicks: item["clicks"].as_u64().unwrap_or(0),
523                            impressions: item["impressions"].as_u64().unwrap_or(0),
524                            ctr: item["ctr"].as_f64().unwrap_or(0.0),
525                            position: item["position"].as_f64().unwrap_or(0.0),
526                        }
527                    })
528                    .collect()
529            })
530            .unwrap_or_default();
531
532        Ok(results)
533    }
534
535    /// Fetch top queries from Google Search Console.
536    pub async fn top_queries(
537        &self,
538        domain: &str,
539        limit: usize,
540    ) -> Result<Vec<GscSearchResult>, AdapterError> {
541        self.search_analytics(domain, limit, "query").await
542    }
543
544    /// Fetch top pages from Google Search Console.
545    pub async fn top_pages(
546        &self,
547        domain: &str,
548        limit: usize,
549    ) -> Result<Vec<GscSearchResult>, AdapterError> {
550        self.search_analytics(domain, limit, "page").await
551    }
552
553    /// Fetch index coverage data from Google Search Console.
554    ///
555    /// Uses the Search Analytics API with `dimension: ["page"]` to retrieve
556    /// page-level index status information. Pages with clicks/impressions
557    /// are classified as "indexed"; the endpoint also surfacescrawl errors
558    /// and excluded URLs when available.
559    pub async fn index_coverage(
560        &self,
561        domain: &str,
562    ) -> Result<Vec<GscCoverageEntry>, AdapterError> {
563        if !self.is_available() {
564            return Err(AdapterError::ApiKeyMissing);
565        }
566
567        let access_token = self
568            .access_token
569            .as_deref()
570            .ok_or(AdapterError::ApiKeyMissing)?;
571        let client = reqwest::Client::new();
572
573        let site_url = format!("https://{domain}/");
574        let url = format!(
575            "https://searchconsole.googleapis.com/webmasters/v3/sites/{}/searchAnalytics/query",
576            urlencoding::encode(&site_url)
577        );
578
579        // Use page dimension with a generous row limit to capture coverage data
580        let body = serde_json::json!({
581            "startDate": "2026-06-01",
582            "endDate": "2026-06-30",
583            "dimensions": ["page"],
584            "rowLimit": 25000,
585        });
586
587        let response = client
588            .post(&url)
589            .bearer_auth(access_token)
590            .json(&body)
591            .send()
592            .await
593            .map_err(|e| AdapterError::RequestFailed(e.to_string()))?;
594
595        if !response.status().is_success() {
596            return Err(AdapterError::RequestFailed(format!(
597                "HTTP {}",
598                response.status()
599            )));
600        }
601
602        let data: serde_json::Value = response
603            .json()
604            .await
605            .map_err(|e| AdapterError::RequestFailed(e.to_string()))?;
606
607        let entries = data["rows"]
608            .as_array()
609            .map(|arr| {
610                arr.iter()
611                    .map(|item| {
612                        let url = item["keys"]
613                            .as_array()
614                            .and_then(|k| k.first())
615                            .and_then(|v| v.as_str())
616                            .unwrap_or("")
617                            .to_string();
618
619                        let clicks = item["clicks"].as_u64().unwrap_or(0);
620                        let impressions = item["impressions"].as_u64().unwrap_or(0);
621
622                        // Classify index status based on Search Analytics data:
623                        // - pages with impressions are indexed
624                        // - pages with zero impressions but present in results are likely indexed but not visible
625                        // - pages not returned at all are excluded or errored
626                        let status = if clicks > 0 || impressions > 0 {
627                            "indexed".to_string()
628                        } else {
629                            "error".to_string()
630                        };
631
632                        GscCoverageEntry {
633                            url,
634                            status,
635                            coverage_count: clicks + impressions,
636                        }
637                    })
638                    .collect()
639            })
640            .unwrap_or_default();
641
642        Ok(entries)
643    }
644}
645
646// ---------------------------------------------------------------------------
647// Adapter Registry
648// ---------------------------------------------------------------------------
649
650/// Registry of available backlink adapters.
651pub struct BacklinkAdapterRegistry {
652    adapters: Vec<Box<dyn BacklinkAdapter>>,
653}
654
655impl BacklinkAdapterRegistry {
656    /// Create registry with default adapters.
657    #[must_use]
658    pub fn with_defaults() -> Self {
659        Self {
660            adapters: vec![
661                Box::new(AhrefsAdapter::from_env()),
662                Box::new(MajesticAdapter::from_env()),
663                Box::new(GscAdapter::from_env()),
664            ],
665        }
666    }
667
668    /// Get all available adapters.
669    #[must_use]
670    pub fn available(&self) -> Vec<&dyn BacklinkAdapter> {
671        self.adapters
672            .iter()
673            .filter(|a| a.is_available())
674            .map(|a| a.as_ref())
675            .collect()
676    }
677
678    /// Get adapter by name.
679    #[must_use]
680    pub fn get(&self, name: &str) -> Option<&dyn BacklinkAdapter> {
681        self.adapters
682            .iter()
683            .find(|a| a.name() == name)
684            .map(|a| a.as_ref())
685    }
686}
687
688// ---------------------------------------------------------------------------
689// Tests
690// ---------------------------------------------------------------------------
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695
696    #[test]
697    fn test_ahrefs_adapter_not_available() {
698        let adapter = AhrefsAdapter::new(None);
699        assert!(!adapter.is_available());
700    }
701
702    #[test]
703    fn test_ahrefs_adapter_available() {
704        let adapter = AhrefsAdapter::new(Some("test_key".to_string()));
705        assert!(adapter.is_available());
706    }
707
708    #[test]
709    fn test_backlink_registry() {
710        let registry = BacklinkAdapterRegistry::with_defaults();
711        assert!(registry.get("ahrefs").is_some());
712        assert!(registry.get("majestic").is_some());
713        assert!(registry.get("google_search_console").is_some());
714        assert!(registry.get("nonexistent").is_none());
715    }
716}