Skip to main content

web_analyzer/
seo_analysis.rs

1use regex::Regex;
2use reqwest::Client;
3use scraper::{Html, Selector};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::time::{Duration, Instant};
7
8// ── Tracking tool detection patterns ────────────────────────────────────────
9
10const TRACKING_TOOLS: &[(&str, &[&str])] = &[
11    (
12        "Google Tag Manager",
13        &["googletagmanager.com/gtm.js", "dataLayer"],
14    ),
15    (
16        "Google Ads",
17        &["googleads.g.doubleclick.net", "googlesyndication.com"],
18    ),
19    ("Facebook Pixel", &["connect.facebook.net", "fbq("]),
20    (
21        "LinkedIn Insight",
22        &["snap.licdn.com", "_linkedin_partner_id"],
23    ),
24    ("TikTok Pixel", &["analytics.tiktok.com", "ttq."]),
25    ("Hotjar", &["static.hotjar.com", "hjid"]),
26    ("Mixpanel", &["cdn.mxpnl.com", "mixpanel.init"]),
27    ("Segment", &["cdn.segment.com", "analytics.load"]),
28    ("Intercom", &["widget.intercom.io"]),
29    ("Zendesk", &["static.zdassets.com"]),
30    ("Crisp", &["client.crisp.chat"]),
31];
32
33/// SEO resources to check
34const SEO_RESOURCES: &[&str] = &["robots.txt", "sitemap.xml", "humans.txt", "ads.txt"];
35
36// ── Data Structures ─────────────────────────────────────────────────────────
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct SeoAnalysisResult {
40    pub domain: String,
41    pub basic_seo: BasicSeoResult,
42    pub content_analysis: ContentAnalysisResult,
43    pub technical_seo: TechnicalSeoResult,
44    pub social_media: SocialMediaResult,
45    pub analytics: HashMap<String, String>,
46    pub performance: PerformanceResult,
47    pub mobile_accessibility: MobileAccessibilityResult,
48    pub seo_resources: HashMap<String, String>,
49    pub schema_markup: SchemaMarkupResult,
50    pub link_analysis: LinkAnalysisResult,
51    pub image_seo: ImageSeoResult,
52    pub page_speed_factors: PageSpeedResult,
53    pub seo_score: SeoScoreResult,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct TitleAnalysis {
58    pub text: String,
59    pub length: usize,
60    pub status: String,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct MetaDescAnalysis {
65    pub text: String,
66    pub length: usize,
67    pub status: String,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct BasicSeoResult {
72    pub title: TitleAnalysis,
73    pub meta_description: MetaDescAnalysis,
74    pub meta_keywords: String,
75    pub canonical_url: String,
76    pub meta_robots: String,
77    pub viewport: String,
78    pub language: String,
79    pub charset: String,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct HeadingInfo {
84    pub count: usize,
85    pub texts: Vec<String>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct KeywordInfo {
90    pub word: String,
91    pub count: usize,
92    pub density: String,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct ContentAnalysisResult {
97    pub headings: HashMap<String, HeadingInfo>,
98    pub heading_issues: Vec<String>,
99    pub word_count: usize,
100    pub word_count_status: String,
101    pub paragraphs: usize,
102    pub text_to_html_ratio: String,
103    pub top_keywords: Vec<KeywordInfo>,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct TechnicalSeoResult {
108    pub page_size_bytes: usize,
109    pub http_status: u16,
110    pub redirects: usize,
111    pub internal_links: usize,
112    pub external_links: usize,
113    pub structured_data_count: usize,
114    pub has_breadcrumbs: bool,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct SocialMediaResult {
119    pub open_graph: HashMap<String, String>,
120    pub twitter_cards: HashMap<String, String>,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct PerformanceResult {
125    pub load_time_secs: f64,
126    pub load_time_status: String,
127    pub content_size_kb: f64,
128    pub compression: String,
129    pub server: String,
130    pub cache_control: String,
131    pub etag: bool,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct AltAttributeResult {
136    pub total_images: usize,
137    pub images_with_alt: usize,
138    pub missing_alt: usize,
139    pub alt_coverage: String,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct MobileAccessibilityResult {
144    pub viewport_present: bool,
145    pub mobile_friendly: bool,
146    pub alt_attributes: AltAttributeResult,
147    pub aria_labels: usize,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct SchemaMarkupResult {
152    pub json_ld_count: usize,
153    pub json_ld_types: Vec<String>,
154    pub microdata_items: usize,
155    pub total_structured_data: usize,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct LinkAnalysisResult {
160    pub total_links: usize,
161    pub internal_links: usize,
162    pub external_links: usize,
163    pub nofollow_links: usize,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct ImageSeoResult {
168    pub total_images: usize,
169    pub lazy_loaded: usize,
170    pub with_alt_text: usize,
171    pub with_title: usize,
172    pub optimization_score: String,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct PageSpeedResult {
177    pub css_files: usize,
178    pub js_files: usize,
179    pub inline_styles: usize,
180    pub inline_scripts: usize,
181    pub compression: String,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct SeoScoreResult {
186    pub score: u32,
187    pub max_score: u32,
188    pub percentage: String,
189    pub grade: String,
190}
191
192// ── Main function ───────────────────────────────────────────────────────────
193
194pub async fn analyze_advanced_seo(
195    domain: &str,
196    progress_tx: Option<tokio::sync::mpsc::Sender<crate::ScanProgress>>,
197) -> Result<SeoAnalysisResult, Box<dyn std::error::Error + Send + Sync>> {
198    let url = if domain.starts_with("http") {
199        domain.to_string()
200    } else {
201        format!("https://{}", domain)
202    };
203
204    if let Some(t) = &progress_tx {
205        let _ = t.try_send(crate::ScanProgress {
206            module: "SEO Analysis".into(),
207            percentage: 5.0,
208            message: "Fetching homepage HTML...".into(),
209            status: "Info".into(),
210        });
211    }
212
213    let client = crate::http_client_builder()
214        .timeout(Duration::from_secs(20))
215        .danger_accept_invalid_certs(true)
216        .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
217        .build()?;
218
219    let start = Instant::now();
220    let resp = client.get(&url).send().await?;
221    let load_time = start.elapsed().as_secs_f64();
222
223    let status_code = resp.status().as_u16();
224    let redirects = resp.url().to_string() != url; // simplified
225    let headers = resp.headers().clone();
226    let content_bytes = resp.bytes().await?;
227    let content_size = content_bytes.len();
228    let html_text = String::from_utf8_lossy(&content_bytes).to_string();
229    let base_domain = domain
230        .replace("https://", "")
231        .replace("http://", "")
232        .replace("www.", "");
233
234    if let Some(t) = &progress_tx {
235        let _ = t.try_send(crate::ScanProgress {
236            module: "SEO Analysis".into(),
237            percentage: 20.0,
238            message: "HTML fetched. Searching for SEO resources (sitemap, robots)...".into(),
239            status: "Success".into(),
240        });
241    }
242
243    // ── 8. SEO Resources (await before parsing HTML to avoid Send bounds) ──
244    let seo_resources = check_seo_resources(&client, domain).await;
245
246    if let Some(t) = &progress_tx {
247        let _ = t.try_send(crate::ScanProgress {
248            module: "SEO Analysis".into(),
249            percentage: 40.0,
250            message: "Parsing HTML document...".into(),
251            status: "Info".into(),
252        });
253    }
254
255    let document = Html::parse_document(&html_text);
256
257    // ── 1. Basic SEO ────────────────────────────────────────────────────
258    let basic_seo = analyze_basic_seo(&document);
259
260    // ── 2. Content Analysis ─────────────────────────────────────────────
261    let content_analysis = analyze_content(&document);
262
263    // ── 3. Technical SEO ────────────────────────────────────────────────
264    let technical_seo = analyze_technical(
265        &document,
266        status_code,
267        content_size,
268        redirects as usize,
269        &base_domain,
270    );
271
272    if let Some(t) = &progress_tx {
273        let _ = t.try_send(crate::ScanProgress {
274            module: "SEO Analysis".into(),
275            percentage: 60.0,
276            message: "Analyzing Social Media & Analytics...".into(),
277            status: "Info".into(),
278        });
279    }
280
281    // ── 4. Social Media Tags ────────────────────────────────────────────
282    let social_media = analyze_social_tags(&document);
283
284    // ── 5. Analytics & Tracking ─────────────────────────────────────────
285    let analytics = analyze_analytics(&html_text);
286
287    if let Some(t) = &progress_tx {
288        let _ = t.try_send(crate::ScanProgress {
289            module: "SEO Analysis".into(),
290            percentage: 80.0,
291            message: "Calculating SEO Core Web Factors...".into(),
292            status: "Info".into(),
293        });
294    }
295
296    // ── 6. Performance ──────────────────────────────────────────────────
297    let performance = analyze_performance(&headers, load_time, content_size);
298
299    // ── 7. Mobile & Accessibility ───────────────────────────────────────
300    let mobile_accessibility = analyze_mobile(&document);
301
302    // ── 9. Schema Markup ────────────────────────────────────────────────
303    let schema_markup = analyze_schema(&document, &html_text);
304
305    // ── 10. Link Analysis ───────────────────────────────────────────────
306    let link_analysis = analyze_links(&document, &base_domain);
307
308    // ── 11. Image SEO ───────────────────────────────────────────────────
309    let image_seo = analyze_images(&document);
310
311    // ── 12. Page Speed Factors ──────────────────────────────────────────
312    let page_speed_factors = analyze_speed_factors(&document, &headers);
313
314    // ── 13. SEO Score ───────────────────────────────────────────────────
315    let seo_score = calculate_seo_score(
316        &basic_seo,
317        &content_analysis,
318        &seo_resources,
319        &schema_markup,
320        &performance,
321        &mobile_accessibility,
322    );
323
324    if let Some(t) = &progress_tx {
325        let _ = t.try_send(crate::ScanProgress {
326            module: "SEO Analysis".into(),
327            percentage: 100.0,
328            message: "SEO Analysis successfully completed.".into(),
329            status: "Success".into(),
330        });
331    }
332
333    Ok(SeoAnalysisResult {
334        domain: domain.to_string(),
335        basic_seo,
336        content_analysis,
337        technical_seo,
338        social_media,
339        analytics,
340        performance,
341        mobile_accessibility,
342        seo_resources,
343        schema_markup,
344        link_analysis,
345        image_seo,
346        page_speed_factors,
347        seo_score,
348    })
349}
350
351// ── 1. Basic SEO ────────────────────────────────────────────────────────────
352
353fn analyze_basic_seo(doc: &Html) -> BasicSeoResult {
354    let title_sel = Selector::parse("title").unwrap();
355    let title_text = doc
356        .select(&title_sel)
357        .next()
358        .map(|el| el.text().collect::<String>().trim().to_string())
359        .unwrap_or_default();
360
361    let title_len = title_text.len();
362    let title_status = if title_text.is_empty() {
363        "Missing"
364    } else if title_len < 30 {
365        "Too short"
366    } else if title_len > 60 {
367        "Too long"
368    } else {
369        "Good"
370    };
371
372    let desc = get_meta_content(doc, "name", "description");
373    let desc_len = if desc == "Not Found" { 0 } else { desc.len() };
374    let desc_status = if desc == "Not Found" {
375        "Missing"
376    } else if desc_len < 120 {
377        "Too short"
378    } else if desc_len > 160 {
379        "Too long"
380    } else {
381        "Good"
382    };
383
384    BasicSeoResult {
385        title: TitleAnalysis {
386            text: if title_text.is_empty() {
387                "Missing".into()
388            } else {
389                title_text
390            },
391            length: title_len,
392            status: title_status.into(),
393        },
394        meta_description: MetaDescAnalysis {
395            text: desc.clone(),
396            length: desc_len,
397            status: desc_status.into(),
398        },
399        meta_keywords: get_meta_content(doc, "name", "keywords"),
400        canonical_url: get_link_href(doc, "canonical"),
401        meta_robots: get_meta_content(doc, "name", "robots"),
402        viewport: get_meta_content(doc, "name", "viewport"),
403        language: doc
404            .root_element()
405            .value()
406            .attr("lang")
407            .unwrap_or("Not specified")
408            .to_string(),
409        charset: get_charset(doc),
410    }
411}
412
413fn get_meta_content(doc: &Html, attr: &str, value: &str) -> String {
414    let selector_str = format!("meta[{}=\"{}\"]", attr, value);
415    if let Ok(sel) = Selector::parse(&selector_str) {
416        if let Some(el) = doc.select(&sel).next() {
417            if let Some(content) = el.value().attr("content") {
418                return content.trim().to_string();
419            }
420        }
421    }
422    "Not Found".into()
423}
424
425fn get_link_href(doc: &Html, rel: &str) -> String {
426    let selector_str = format!("link[rel=\"{}\"]", rel);
427    if let Ok(sel) = Selector::parse(&selector_str) {
428        if let Some(el) = doc.select(&sel).next() {
429            if let Some(href) = el.value().attr("href") {
430                return href.trim().to_string();
431            }
432        }
433    }
434    "Not Found".into()
435}
436
437fn get_charset(doc: &Html) -> String {
438    if let Ok(sel) = Selector::parse("meta[charset]") {
439        if let Some(el) = doc.select(&sel).next() {
440            if let Some(cs) = el.value().attr("charset") {
441                return cs.to_string();
442            }
443        }
444    }
445    if let Ok(sel) = Selector::parse("meta[http-equiv=\"Content-Type\"]") {
446        if let Some(el) = doc.select(&sel).next() {
447            if let Some(content) = el.value().attr("content") {
448                if let Some(cs) = Regex::new(r"charset=([^;]+)")
449                    .ok()
450                    .and_then(|r| r.captures(content))
451                {
452                    return cs.get(1).unwrap().as_str().to_string();
453                }
454            }
455        }
456    }
457    "Unknown".into()
458}
459
460// ── 2. Content Analysis ─────────────────────────────────────────────────────
461
462fn analyze_content(doc: &Html) -> ContentAnalysisResult {
463    let mut headings = HashMap::new();
464    let mut hierarchy: Vec<(u8, String)> = Vec::new();
465
466    let h_selectors = [
467        (1u8, Selector::parse("h1").unwrap()),
468        (2, Selector::parse("h2").unwrap()),
469        (3, Selector::parse("h3").unwrap()),
470        (4, Selector::parse("h4").unwrap()),
471        (5, Selector::parse("h5").unwrap()),
472        (6, Selector::parse("h6").unwrap()),
473    ];
474
475    for (i, sel) in &h_selectors {
476        let elements: Vec<_> = doc.select(sel).collect();
477        if !elements.is_empty() {
478            let texts: Vec<String> = elements
479                .iter()
480                .take(3)
481                .map(|e| {
482                    let t = e.text().collect::<String>();
483                    t.trim().chars().take(100).collect()
484                })
485                .collect();
486            headings.insert(
487                format!("H{}", i),
488                HeadingInfo {
489                    count: elements.len(),
490                    texts,
491                },
492            );
493            for e in &elements {
494                let t = e.text().collect::<String>().trim().to_string();
495                hierarchy.push((*i, t));
496            }
497        }
498    }
499
500    let heading_issues = check_heading_issues(&hierarchy);
501
502    let text = doc.root_element().text().collect::<String>();
503    let words: Vec<&str> = text.split_whitespace().collect();
504    let word_count = words.len();
505
506    let p_sel = Selector::parse("p").unwrap();
507    let paragraphs = doc.select(&p_sel).count();
508
509    let html_len = doc.html().len();
510    let text_len = text.len();
511    let ratio = if html_len > 0 {
512        (text_len as f64 / html_len as f64) * 100.0
513    } else {
514        0.0
515    };
516
517    let top_keywords = analyze_keyword_density(&words);
518
519    ContentAnalysisResult {
520        headings,
521        heading_issues,
522        word_count,
523        word_count_status: if word_count >= 300 {
524            "Good"
525        } else {
526            "Too short"
527        }
528        .into(),
529        paragraphs,
530        text_to_html_ratio: format!("{:.1}%", ratio),
531        top_keywords,
532    }
533}
534
535fn check_heading_issues(hierarchy: &[(u8, String)]) -> Vec<String> {
536    let mut issues = Vec::new();
537    if hierarchy.is_empty() {
538        issues.push("No headings found".into());
539        return issues;
540    }
541
542    let h1_count = hierarchy.iter().filter(|(l, _)| *l == 1).count();
543    if h1_count == 0 {
544        issues.push("Missing H1 tag".into());
545    } else if h1_count > 1 {
546        issues.push(format!("Multiple H1 tags ({})", h1_count));
547    }
548
549    let mut prev = 0u8;
550    for &(level, _) in hierarchy {
551        if prev > 0 && level > prev + 1 {
552            issues.push(format!(
553                "Skipped heading level (from H{} to H{})",
554                prev, level
555            ));
556        }
557        prev = level;
558    }
559    issues
560}
561
562fn analyze_keyword_density(words: &[&str]) -> Vec<KeywordInfo> {
563    let total = words.len();
564    if total == 0 {
565        return vec![];
566    }
567
568    let mut freq: HashMap<String, usize> = HashMap::new();
569    for &w in words {
570        let lower = w.to_lowercase();
571        if lower.len() > 3 {
572            *freq.entry(lower).or_insert(0) += 1;
573        }
574    }
575
576    let mut sorted: Vec<_> = freq.into_iter().collect();
577    sorted.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
578
579    sorted
580        .into_iter()
581        .take(5)
582        .map(|(word, count)| KeywordInfo {
583            word,
584            count,
585            density: format!("{:.2}%", (count as f64 / total as f64) * 100.0),
586        })
587        .collect()
588}
589
590// ── 3. Technical SEO ────────────────────────────────────────────────────────
591
592fn analyze_technical(
593    doc: &Html,
594    status: u16,
595    size: usize,
596    redirects: usize,
597    base_domain: &str,
598) -> TechnicalSeoResult {
599    let link_sel = Selector::parse("a[href]").unwrap();
600    let mut internal = 0;
601    let mut external = 0;
602
603    for el in doc.select(&link_sel) {
604        if let Some(href) = el.value().attr("href") {
605            if href.starts_with("http") && !href.contains(base_domain) {
606                external += 1;
607            } else if !href.starts_with("mailto:")
608                && !href.starts_with("tel:")
609                && !href.starts_with('#')
610            {
611                internal += 1;
612            }
613        }
614    }
615
616    let json_ld = Selector::parse("script[type=\"application/ld+json\"]")
617        .ok()
618        .map(|s| doc.select(&s).count())
619        .unwrap_or(0);
620    let microdata = Selector::parse("[itemtype]")
621        .ok()
622        .map(|s| doc.select(&s).count())
623        .unwrap_or(0);
624
625    let breadcrumb = Selector::parse("[typeof=\"BreadcrumbList\"]")
626        .ok()
627        .map(|s| doc.select(&s).next().is_some())
628        .unwrap_or(false)
629        || doc.html().to_lowercase().contains("breadcrumb");
630
631    TechnicalSeoResult {
632        page_size_bytes: size,
633        http_status: status,
634        redirects,
635        internal_links: internal,
636        external_links: external,
637        structured_data_count: json_ld + microdata,
638        has_breadcrumbs: breadcrumb,
639    }
640}
641
642// ── 4. Social Media Tags ────────────────────────────────────────────────────
643
644fn analyze_social_tags(doc: &Html) -> SocialMediaResult {
645    let og_keys = [
646        "og:title",
647        "og:description",
648        "og:image",
649        "og:url",
650        "og:type",
651        "og:site_name",
652    ];
653    let tw_keys = [
654        "twitter:card",
655        "twitter:title",
656        "twitter:description",
657        "twitter:image",
658        "twitter:site",
659    ];
660
661    let mut og = HashMap::new();
662    for key in &og_keys {
663        og.insert(key.to_string(), get_meta_content(doc, "property", key));
664    }
665
666    let mut tw = HashMap::new();
667    for key in &tw_keys {
668        tw.insert(key.to_string(), get_meta_content(doc, "name", key));
669    }
670
671    SocialMediaResult {
672        open_graph: og,
673        twitter_cards: tw,
674    }
675}
676
677// ── 5. Analytics & Tracking ─────────────────────────────────────────────────
678
679fn analyze_analytics(html: &str) -> HashMap<String, String> {
680    let mut results = HashMap::new();
681
682    // Google Analytics
683    let has_ga4 = Regex::new(r#"gtag\(['"]config['"],\s*['"]G-[A-Z0-9]+['"]\)"#)
684        .ok()
685        .map(|r| r.is_match(html))
686        .unwrap_or(false);
687    let has_ua = Regex::new(r#"gtag\(['"]config['"],\s*['"]UA-[0-9-]+['"]\)"#)
688        .ok()
689        .map(|r| r.is_match(html))
690        .unwrap_or(false);
691    results.insert(
692        "Google Analytics GA4".into(),
693        if has_ga4 { "Found" } else { "Not Found" }.into(),
694    );
695    results.insert(
696        "Google Analytics UA".into(),
697        if has_ua { "Found" } else { "Not Found" }.into(),
698    );
699
700    // Other tracking tools
701    let lower = html.to_lowercase();
702    for &(name, patterns) in TRACKING_TOOLS {
703        let found = patterns.iter().any(|p| lower.contains(&p.to_lowercase()));
704        results.insert(
705            name.to_string(),
706            if found { "Found" } else { "Not Found" }.into(),
707        );
708    }
709
710    results
711}
712
713// ── 6. Performance ──────────────────────────────────────────────────────────
714
715fn analyze_performance(
716    headers: &reqwest::header::HeaderMap,
717    load_time: f64,
718    size: usize,
719) -> PerformanceResult {
720    let status = if load_time < 1.0 {
721        "Excellent"
722    } else if load_time < 3.0 {
723        "Good"
724    } else {
725        "Poor"
726    };
727
728    PerformanceResult {
729        load_time_secs: (load_time * 100.0).round() / 100.0,
730        load_time_status: status.into(),
731        content_size_kb: (size as f64 / 1024.0 * 100.0).round() / 100.0,
732        compression: headers
733            .get("content-encoding")
734            .and_then(|v| v.to_str().ok())
735            .unwrap_or("None")
736            .into(),
737        server: headers
738            .get("server")
739            .and_then(|v| v.to_str().ok())
740            .unwrap_or("Unknown")
741            .into(),
742        cache_control: headers
743            .get("cache-control")
744            .and_then(|v| v.to_str().ok())
745            .unwrap_or("Not Set")
746            .into(),
747        etag: headers.contains_key("etag"),
748    }
749}
750
751// ── 7. Mobile & Accessibility ───────────────────────────────────────────────
752
753fn analyze_mobile(doc: &Html) -> MobileAccessibilityResult {
754    let viewport_content = get_meta_content(doc, "name", "viewport");
755    let has_viewport = viewport_content != "Not Found";
756    let mobile_friendly = viewport_content.contains("width=device-width");
757
758    let img_sel = Selector::parse("img").unwrap();
759    let images: Vec<_> = doc.select(&img_sel).collect();
760    let total = images.len();
761    let with_alt = images
762        .iter()
763        .filter(|i| i.value().attr("alt").is_some())
764        .count();
765
766    let aria_sel = Selector::parse("[aria-label]").unwrap();
767    let aria_count = doc.select(&aria_sel).count();
768
769    MobileAccessibilityResult {
770        viewport_present: has_viewport,
771        mobile_friendly,
772        alt_attributes: AltAttributeResult {
773            total_images: total,
774            images_with_alt: with_alt,
775            missing_alt: total - with_alt,
776            alt_coverage: if total > 0 {
777                format!("{:.1}%", (with_alt as f64 / total as f64) * 100.0)
778            } else {
779                "0%".into()
780            },
781        },
782        aria_labels: aria_count,
783    }
784}
785
786// ── 8. SEO Resources ────────────────────────────────────────────────────────
787
788async fn check_seo_resources(client: &Client, domain: &str) -> HashMap<String, String> {
789    let mut results = HashMap::new();
790    for &file in SEO_RESOURCES {
791        let url = format!("https://{}/{}", domain, file);
792        let found = match client.get(&url).send().await {
793            Ok(r) if r.status().is_success() => "Found",
794            _ => "Not Found",
795        };
796        results.insert(file.to_string(), found.into());
797    }
798    results
799}
800
801// ── 9. Schema Markup ────────────────────────────────────────────────────────
802
803fn analyze_schema(doc: &Html, html: &str) -> SchemaMarkupResult {
804    let json_ld_sel = Selector::parse("script[type=\"application/ld+json\"]").unwrap();
805    let json_lds: Vec<_> = doc.select(&json_ld_sel).collect();
806    let json_ld_count = json_lds.len();
807
808    let mut types = Vec::new();
809    for script in &json_lds {
810        let text = script.text().collect::<String>();
811        if let Ok(val) = serde_json::from_str::<serde_json::Value>(&text) {
812            extract_types(&val, &mut types);
813        }
814    }
815
816    let microdata = Selector::parse("[itemtype]")
817        .ok()
818        .map(|s| doc.select(&s).count())
819        .unwrap_or(0);
820
821    // Also check for inline JSON-LD in raw HTML (in case scraper misses it)
822    let additional = Regex::new(r#""@type"\s*:\s*"([^"]+)""#)
823        .ok()
824        .map(|r| {
825            r.captures_iter(html)
826                .filter_map(|c| c.get(1).map(|m| m.as_str().to_string()))
827                .collect::<Vec<_>>()
828        })
829        .unwrap_or_default();
830
831    for t in additional {
832        if !types.contains(&t) {
833            types.push(t);
834        }
835    }
836
837    SchemaMarkupResult {
838        json_ld_count,
839        json_ld_types: types,
840        microdata_items: microdata,
841        total_structured_data: json_ld_count + microdata,
842    }
843}
844
845fn extract_types(val: &serde_json::Value, types: &mut Vec<String>) {
846    match val {
847        serde_json::Value::Object(map) => {
848            if let Some(t) = map.get("@type").and_then(|v| v.as_str()) {
849                types.push(t.to_string());
850            }
851            for (_, v) in map {
852                extract_types(v, types);
853            }
854        }
855        serde_json::Value::Array(arr) => {
856            for v in arr {
857                extract_types(v, types);
858            }
859        }
860        _ => {}
861    }
862}
863
864// ── 10. Link Analysis ───────────────────────────────────────────────────────
865
866fn analyze_links(doc: &Html, base_domain: &str) -> LinkAnalysisResult {
867    let link_sel = Selector::parse("a[href]").unwrap();
868    let mut internal = 0;
869    let mut external = 0;
870    let mut nofollow = 0;
871    let mut total = 0;
872
873    for el in doc.select(&link_sel) {
874        total += 1;
875        if let Some(href) = el.value().attr("href") {
876            if href.starts_with("http") && !href.contains(base_domain) {
877                external += 1;
878            } else if !href.starts_with("mailto:")
879                && !href.starts_with("tel:")
880                && !href.starts_with('#')
881            {
882                internal += 1;
883            }
884        }
885        if let Some(rel) = el.value().attr("rel") {
886            if rel.contains("nofollow") {
887                nofollow += 1;
888            }
889        }
890    }
891
892    LinkAnalysisResult {
893        total_links: total,
894        internal_links: internal,
895        external_links: external,
896        nofollow_links: nofollow,
897    }
898}
899
900// ── 11. Image SEO ───────────────────────────────────────────────────────────
901
902fn analyze_images(doc: &Html) -> ImageSeoResult {
903    let img_sel = Selector::parse("img").unwrap();
904    let images: Vec<_> = doc.select(&img_sel).collect();
905    let total = images.len();
906    let lazy = images
907        .iter()
908        .filter(|i| i.value().attr("loading") == Some("lazy"))
909        .count();
910    let alt = images
911        .iter()
912        .filter(|i| i.value().attr("alt").is_some())
913        .count();
914    let title = images
915        .iter()
916        .filter(|i| i.value().attr("title").is_some())
917        .count();
918
919    let opt_score = if total > 0 {
920        format!("{:.1}%", ((lazy + alt) as f64 / (total * 2) as f64) * 100.0)
921    } else {
922        "0%".into()
923    };
924
925    ImageSeoResult {
926        total_images: total,
927        lazy_loaded: lazy,
928        with_alt_text: alt,
929        with_title: title,
930        optimization_score: opt_score,
931    }
932}
933
934// ── 12. Page Speed Factors ──────────────────────────────────────────────────
935
936fn analyze_speed_factors(doc: &Html, headers: &reqwest::header::HeaderMap) -> PageSpeedResult {
937    let css_sel = Selector::parse("link[rel=\"stylesheet\"]").unwrap();
938    let js_sel = Selector::parse("script[src]").unwrap();
939    let style_sel = Selector::parse("style").unwrap();
940    let inline_js_sel = Selector::parse("script:not([src])").unwrap();
941
942    PageSpeedResult {
943        css_files: doc.select(&css_sel).count(),
944        js_files: doc.select(&js_sel).count(),
945        inline_styles: doc.select(&style_sel).count(),
946        inline_scripts: doc.select(&inline_js_sel).count(),
947        compression: headers
948            .get("content-encoding")
949            .and_then(|v| v.to_str().ok())
950            .unwrap_or("None")
951            .into(),
952    }
953}
954
955// ── 13. SEO Score ───────────────────────────────────────────────────────────
956
957fn calculate_seo_score(
958    basic: &BasicSeoResult,
959    content: &ContentAnalysisResult,
960    resources: &HashMap<String, String>,
961    schema: &SchemaMarkupResult,
962    perf: &PerformanceResult,
963    mobile: &MobileAccessibilityResult,
964) -> SeoScoreResult {
965    let mut score: u32 = 0;
966
967    // Basic SEO (30 pts)
968    if basic.title.status == "Good" {
969        score += 10;
970    }
971    if basic.meta_description.status == "Good" {
972        score += 10;
973    }
974    if basic.canonical_url != "Not Found" {
975        score += 5;
976    }
977    if basic.viewport != "Not Found" {
978        score += 5;
979    }
980
981    // Content (20 pts)
982    if content.word_count_status == "Good" {
983        score += 10;
984    }
985    if content.headings.contains_key("H1") {
986        score += 10;
987    }
988
989    // Technical (20 pts)
990    if resources.get("robots.txt").map(|s| s.as_str()) == Some("Found") {
991        score += 5;
992    }
993    if resources.get("sitemap.xml").map(|s| s.as_str()) == Some("Found") {
994        score += 5;
995    }
996    if schema.total_structured_data > 0 {
997        score += 10;
998    }
999
1000    // Performance (15 pts)
1001    match perf.load_time_status.as_str() {
1002        "Excellent" | "Good" => score += 15,
1003        _ => {}
1004    }
1005
1006    // Security (10 pts) — counted from headers presence (simplified)
1007    score += 5; // base
1008
1009    // Mobile (5 pts)
1010    if mobile.mobile_friendly {
1011        score += 5;
1012    }
1013
1014    let max_score = 100u32;
1015    let pct = (score as f64 / max_score as f64) * 100.0;
1016    let grade = if pct >= 90.0 {
1017        "A+"
1018    } else if pct >= 80.0 {
1019        "A"
1020    } else if pct >= 70.0 {
1021        "B"
1022    } else if pct >= 60.0 {
1023        "C"
1024    } else if pct >= 50.0 {
1025        "D"
1026    } else {
1027        "F"
1028    };
1029
1030    SeoScoreResult {
1031        score,
1032        max_score,
1033        percentage: format!("{:.1}%", pct),
1034        grade: grade.into(),
1035    }
1036}