1use serde::{Deserialize, Serialize};
2
3#[async_trait::async_trait]
7pub trait BacklinkAdapter: Send + Sync {
8 fn name(&self) -> &str;
10
11 async fn fetch_backlinks(
16 &self,
17 domain: &str,
18 limit: usize,
19 ) -> Result<Vec<ExternalBacklink>, AdapterError>;
20
21 async fn get_domain_rating(&self, domain: &str) -> Result<f64, AdapterError>;
26
27 fn is_available(&self) -> bool;
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct ExternalBacklink {
34 pub source_url: String,
36 pub target_url: String,
38 pub anchor_text: String,
40 pub domain_rating: f64,
42 pub is_followed: bool,
44 pub first_seen: Option<String>,
46 pub last_seen: Option<String>,
48}
49
50#[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
69pub struct AhrefsAdapter {
75 api_key: Option<String>,
76 #[allow(dead_code)]
77 base_url: String,
78}
79
80impl AhrefsAdapter {
81 #[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 #[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
193pub struct MajesticAdapter {
199 api_key: Option<String>,
200}
201
202impl MajesticAdapter {
203 #[must_use]
205 pub fn new(api_key: Option<String>) -> Self {
206 Self { api_key }
207 }
208
209 #[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(¶ms)
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(¶ms)
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
327pub struct GscAdapter {
333 access_token: Option<String>,
334}
335
336impl GscAdapter {
337 #[must_use]
339 pub fn new(access_token: Option<String>) -> Self {
340 Self { access_token }
341 }
342
343 #[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 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(), domain_rating: 0.0, is_followed: true, 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
432pub struct GscSearchResult {
433 pub key: String,
435 pub clicks: u64,
437 pub impressions: u64,
439 pub ctr: f64,
441 pub position: f64,
443}
444
445#[derive(Debug, Clone, Serialize, Deserialize)]
447pub struct GscCoverageEntry {
448 pub url: String,
450 pub status: String,
452 pub coverage_count: u64,
454}
455
456impl GscAdapter {
457 pub async fn search_analytics(
461 &self,
462 domain: &str,
463 row_limit: usize,
464 dimension: &str, ) -> 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 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 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 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 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 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
646pub struct BacklinkAdapterRegistry {
652 adapters: Vec<Box<dyn BacklinkAdapter>>,
653}
654
655impl BacklinkAdapterRegistry {
656 #[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 #[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 #[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#[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}