1use regex::Regex;
2use reqwest::Client;
3use scraper::{Html, Selector};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::time::{Duration, Instant};
7
8const 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
33const SEO_RESOURCES: &[&str] = &["robots.txt", "sitemap.xml", "humans.txt", "ads.txt"];
35
36#[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
192pub 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; 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 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 let basic_seo = analyze_basic_seo(&document);
259
260 let content_analysis = analyze_content(&document);
262
263 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 let social_media = analyze_social_tags(&document);
283
284 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 let performance = analyze_performance(&headers, load_time, content_size);
298
299 let mobile_accessibility = analyze_mobile(&document);
301
302 let schema_markup = analyze_schema(&document, &html_text);
304
305 let link_analysis = analyze_links(&document, &base_domain);
307
308 let image_seo = analyze_images(&document);
310
311 let page_speed_factors = analyze_speed_factors(&document, &headers);
313
314 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
351fn 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
460fn 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
590fn 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
642fn 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
677fn analyze_analytics(html: &str) -> HashMap<String, String> {
680 let mut results = HashMap::new();
681
682 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 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
713fn 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
751fn 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
786async 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
801fn 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 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
864fn 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
900fn 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
934fn 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
955fn 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 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 if content.word_count_status == "Good" {
983 score += 10;
984 }
985 if content.headings.contains_key("H1") {
986 score += 10;
987 }
988
989 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 match perf.load_time_status.as_str() {
1002 "Excellent" | "Good" => score += 15,
1003 _ => {}
1004 }
1005
1006 score += 5; 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}