1use std::collections::{HashMap, HashSet};
2use std::time::Duration;
3use url::Url;
4
5use crate::parser::ParsedPage;
6use crate::storage::{IssueCategory, Severity};
7use crate::{CrawlConfig, RedirectHop};
8
9#[derive(Debug, Clone)]
15pub struct SslCertificateInfo {
16 pub subject: Option<String>,
18 pub issuer: Option<String>,
20 pub san_entries: Vec<String>,
22 pub not_before: Option<String>,
24 pub not_after: Option<String>,
26 pub is_valid_chain: bool,
28 pub is_self_signed: bool,
30 pub signature_algorithm: Option<String>,
32}
33
34pub struct AnalysisContext<'a> {
44 pub page: &'a ParsedPage,
46 pub status_code: Option<u16>,
48 pub headers: &'a [(String, String)],
50 pub response_time: Option<Duration>,
52 pub redirect_chain: &'a [RedirectHop],
54 pub robots_txt: Option<&'a str>,
56}
57
58#[derive(Debug, Clone)]
64pub struct Finding {
65 pub severity: Severity,
67 pub category: IssueCategory,
69 pub code: String,
71 pub title: String,
73 pub description: String,
75 pub url: String,
77 pub recommendation: String,
79}
80
81pub trait Analyzer: Send + Sync {
102 fn name(&self) -> &str;
104
105 fn analyze(&self, ctx: &AnalysisContext, config: &CrawlConfig) -> Vec<Finding>;
107}
108
109pub struct HttpStatusAnalyzer;
114
115impl HttpStatusAnalyzer {
116 pub fn new() -> Self {
117 Self
118 }
119
120 #[allow(dead_code)]
122 pub(crate) fn is_soft_404(body: &str) -> bool {
123 let lower = body.to_lowercase();
124 let indicators = [
125 "page not found",
126 "404 not found",
127 "the page you requested",
128 "does not exist",
129 "has been removed",
130 "no longer available",
131 "error 404",
132 "not found",
133 "sorry, we couldn't find",
134 "this page is no longer available",
135 "the requested url was not found",
136 ];
137 indicators.iter().any(|ind| lower.contains(ind))
138 }
139
140 fn status_category(code: u16) -> &'static str {
141 match code {
142 200..=299 => "success",
143 300..=399 => "redirection",
144 400..=499 => "client_error",
145 500..=599 => "server_error",
146 _ => "unknown",
147 }
148 }
149}
150
151impl Default for HttpStatusAnalyzer {
152 fn default() -> Self {
153 Self::new()
154 }
155}
156
157impl Analyzer for HttpStatusAnalyzer {
158 fn name(&self) -> &str {
159 "http-status"
160 }
161
162 fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
163 let mut findings = Vec::new();
164 let url = &ctx.page.url;
165
166 let status = match ctx.status_code {
167 Some(s) => s,
168 None => {
169 findings.push(Finding {
170 severity: Severity::Error,
171 category: IssueCategory::Http,
172 code: "HTTP001".to_string(),
173 title: "Missing status code".to_string(),
174 description: "No HTTP status code was recorded for this page.".to_string(),
175 url: url.clone(),
176 recommendation: "Ensure the page is fetched and the status code is recorded."
177 .to_string(),
178 });
179 return findings;
180 }
181 };
182
183 if let Some(rt) = ctx.response_time {
185 if rt > Duration::from_secs(5) {
186 findings.push(Finding {
187 severity: Severity::Warning,
188 category: IssueCategory::Performance,
189 code: "HTTP002".to_string(),
190 title: "Slow response time".to_string(),
191 description: format!("Response took {rt:?}, which exceeds 5 seconds."),
192 url: url.clone(),
193 recommendation: "Optimize server response time. Consider caching, CDN, or \
194 reducing server-side processing."
195 .to_string(),
196 });
197 }
198 }
199
200 match status {
201 200 => {
202 let body_lower = ctx.page.word_count;
204 if body_lower == 0 {
206 findings.push(Finding {
207 severity: Severity::Warning,
208 category: IssueCategory::Http,
209 code: "HTTP003".to_string(),
210 title: "Possible soft 404 — empty body".to_string(),
211 description: "Page returned 200 but has no content. This may indicate \
212 a soft 404."
213 .to_string(),
214 url: url.clone(),
215 recommendation: "Verify the page renders correctly. Fix server-side \
216 rendering issues."
217 .to_string(),
218 });
219 }
220 }
221 301 | 302 | 307 | 308 => {
222 }
224 404 => {
225 findings.push(Finding {
226 severity: Severity::Error,
227 category: IssueCategory::Http,
228 code: "HTTP004".to_string(),
229 title: "Page not found (404)".to_string(),
230 description: "The page returned a 404 status code.".to_string(),
231 url: url.clone(),
232 recommendation: "Remove links to this page or redirect to a valid URL."
233 .to_string(),
234 });
235 }
236 500..=599 => {
237 findings.push(Finding {
238 severity: Severity::Critical,
239 category: IssueCategory::Http,
240 code: "HTTP005".to_string(),
241 title: format!("Server error ({status})"),
242 description: format!(
243 "The page returned a {status} status code, indicating a server error."
244 ),
245 url: url.clone(),
246 recommendation: "Fix server-side errors. Check application logs for details."
247 .to_string(),
248 });
249 }
250 _ => {
251 }
253 }
254
255 findings.push(Finding {
257 severity: Severity::Info,
258 category: IssueCategory::Http,
259 code: "HTTP006".to_string(),
260 title: format!("Status category: {}", Self::status_category(status)),
261 description: format!(
262 "HTTP {status} — categorized as {}.",
263 Self::status_category(status)
264 ),
265 url: url.clone(),
266 recommendation: String::new(),
267 });
268
269 findings
270 }
271}
272
273pub struct RedirectChainAnalyzer;
278
279impl RedirectChainAnalyzer {
280 pub fn new() -> Self {
281 Self
282 }
283
284 fn is_mixed_protocol(from: &Url, to: &Url) -> bool {
285 from.scheme() != to.scheme()
286 }
287}
288
289impl Default for RedirectChainAnalyzer {
290 fn default() -> Self {
291 Self::new()
292 }
293}
294
295impl Analyzer for RedirectChainAnalyzer {
296 fn name(&self) -> &str {
297 "redirect-chain"
298 }
299
300 fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
301 let mut findings = Vec::new();
302 let url = &ctx.page.url;
303 let hops = ctx.redirect_chain;
304
305 if hops.is_empty() {
306 return findings;
307 }
308
309 if hops.len() > 5 {
311 findings.push(Finding {
312 severity: Severity::Warning,
313 category: IssueCategory::Http,
314 code: "REDIR001".to_string(),
315 title: "Long redirect chain".to_string(),
316 description: format!(
317 "Redirect chain has {} hops, exceeding the recommended maximum of 5.",
318 hops.len()
319 ),
320 url: url.clone(),
321 recommendation: "Reduce redirect hops. Update links to point directly to the \
322 final URL."
323 .to_string(),
324 });
325 }
326
327 let mut seen_urls: HashSet<String> = HashSet::new();
329 seen_urls.insert(hops[0].from.to_string());
330 let mut has_loop = false;
331 for hop in hops {
332 if !seen_urls.insert(hop.to.to_string()) {
333 has_loop = true;
334 break;
335 }
336 }
337 if has_loop {
338 findings.push(Finding {
339 severity: Severity::Critical,
340 category: IssueCategory::Http,
341 code: "REDIR002".to_string(),
342 title: "Redirect loop detected".to_string(),
343 description: "The redirect chain contains a loop, which will prevent the page \
344 from loading."
345 .to_string(),
346 url: url.clone(),
347 recommendation: "Fix the redirect chain to eliminate the loop. Ensure each \
348 redirect points to a unique destination."
349 .to_string(),
350 });
351 }
352
353 for hop in hops {
355 if Self::is_mixed_protocol(&hop.from, &hop.to) {
356 findings.push(Finding {
357 severity: Severity::Warning,
358 category: IssueCategory::Security,
359 code: "REDIR003".to_string(),
360 title: "Mixed-protocol redirect".to_string(),
361 description: format!(
362 "Redirect from {} ({}) to {} ({}) changes protocol.",
363 hop.from,
364 hop.from.scheme(),
365 hop.to,
366 hop.to.scheme()
367 ),
368 url: url.clone(),
369 recommendation: "Use consistent protocol (prefer HTTPS) for all redirects."
370 .to_string(),
371 });
372 break;
373 }
374 }
375
376 if hops.len() == 1 {
378 findings.push(Finding {
379 severity: Severity::Info,
380 category: IssueCategory::Http,
381 code: "REDIR004".to_string(),
382 title: "Single redirect detected".to_string(),
383 description: format!("URL redirects from {} to {}.", hops[0].from, hops[0].to),
384 url: url.clone(),
385 recommendation: "Consider updating inbound links to point directly to the \
386 final URL to eliminate the redirect."
387 .to_string(),
388 });
389 }
390
391 findings
392 }
393}
394
395pub struct CanonicalUrlValidator;
400
401impl CanonicalUrlValidator {
402 pub fn new() -> Self {
403 Self
404 }
405
406 fn normalize_url(url: &Url) -> String {
408 let mut s = url.to_string();
409 if let Some(pos) = s.find('#') {
411 s.truncate(pos);
412 }
413 if s.ends_with('/') && url.path() != "/" {
415 s.pop();
416 }
417 s
418 }
419}
420
421impl Default for CanonicalUrlValidator {
422 fn default() -> Self {
423 Self::new()
424 }
425}
426
427impl Analyzer for CanonicalUrlValidator {
428 fn name(&self) -> &str {
429 "canonical-url"
430 }
431
432 fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
433 let mut findings = Vec::new();
434 let url = &ctx.page.url;
435 let page_url = match Url::parse(url) {
436 Ok(u) => u,
437 Err(_) => return findings,
438 };
439
440 match &ctx.page.meta.canonical {
441 None => {
442 findings.push(Finding {
443 severity: Severity::Warning,
444 category: IssueCategory::Seo,
445 code: "CANON001".to_string(),
446 title: "Missing canonical URL".to_string(),
447 description: "No <link rel=\"canonical\"> tag was found on this page."
448 .to_string(),
449 url: url.clone(),
450 recommendation: "Add a canonical URL tag pointing to the preferred version \
451 of this page."
452 .to_string(),
453 });
454 }
455 Some(canonical) => {
456 let canonical_str = Self::normalize_url(canonical);
457 let page_str = Self::normalize_url(&page_url);
458
459 if canonical_str == page_str {
460 } else {
462 let same_host = canonical.host_str() == page_url.host_str();
464 let same_path = canonical.path() == page_url.path();
465
466 if same_host && same_path {
467 findings.push(Finding {
469 severity: Severity::Info,
470 category: IssueCategory::Seo,
471 code: "CANON002".to_string(),
472 title: "Canonical URL differs".to_string(),
473 description: format!(
474 "Canonical points to {canonical}, which differs from the \
475 current URL."
476 ),
477 url: url.clone(),
478 recommendation: "Verify this is intentional. The canonical should \
479 point to the preferred URL."
480 .to_string(),
481 });
482 } else {
483 findings.push(Finding {
484 severity: Severity::Warning,
485 category: IssueCategory::Seo,
486 code: "CANON003".to_string(),
487 title: "Canonical URL mismatch".to_string(),
488 description: format!(
489 "Canonical URL ({canonical}) does not match the current page \
490 URL ({url})."
491 ),
492 url: url.clone(),
493 recommendation: "Ensure the canonical URL points to the correct \
494 preferred version of this page."
495 .to_string(),
496 });
497 }
498 }
499 }
500 }
501
502 findings
503 }
504}
505
506pub struct HreflangValidator;
511
512impl HreflangValidator {
513 pub fn new() -> Self {
514 Self
515 }
516
517 fn is_valid_locale(code: &str) -> bool {
519 if code == "x-default" {
520 return true;
521 }
522 let parts: Vec<&str> = code.split('-').collect();
523 match parts.len() {
524 1 => {
525 let lang = parts[0];
527 lang.len() >= 2 && lang.len() <= 3 && lang.chars().all(|c| c.is_ascii_alphabetic())
528 }
529 2 => {
530 let lang = parts[0];
532 let region = parts[1];
533 lang.len() >= 2
534 && lang.len() <= 3
535 && lang.chars().all(|c| c.is_ascii_alphabetic())
536 && (region.len() == 2 && region.chars().all(|c| c.is_ascii_alphabetic())
537 || region.len() == 4 && region.chars().all(|c| c.is_ascii_digit()))
538 }
539 _ => false,
540 }
541 }
542}
543
544impl Default for HreflangValidator {
545 fn default() -> Self {
546 Self::new()
547 }
548}
549
550impl Analyzer for HreflangValidator {
551 fn name(&self) -> &str {
552 "hreflang"
553 }
554
555 fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
556 let mut findings = Vec::new();
557 let url = &ctx.page.url;
558 let hreflang_tags = &ctx.page.meta.hreflang;
559
560 if hreflang_tags.is_empty() {
561 return findings; }
563
564 let has_x_default = hreflang_tags.iter().any(|t| t.lang == "x-default");
566 if !has_x_default {
567 findings.push(Finding {
568 severity: Severity::Warning,
569 category: IssueCategory::Seo,
570 code: "HREF001".to_string(),
571 title: "Missing x-default hreflang".to_string(),
572 description: "No hreflang tag with lang=\"x-default\" was found.".to_string(),
573 url: url.clone(),
574 recommendation: "Add an x-default hreflang tag to specify the fallback URL for \
575 users whose language doesn't match any other hreflang."
576 .to_string(),
577 });
578 }
579
580 for tag in hreflang_tags {
582 if !Self::is_valid_locale(&tag.lang) {
583 findings.push(Finding {
584 severity: Severity::Error,
585 category: IssueCategory::Seo,
586 code: "HREF002".to_string(),
587 title: "Invalid hreflang locale code".to_string(),
588 description: format!(
589 "The hreflang code \"{}\" does not follow the ISO 639-1/BCP 47 format.",
590 tag.lang
591 ),
592 url: url.clone(),
593 recommendation: "Use valid BCP 47 language tags (e.g., \"en\", \"en-US\", \
594 \"fr-CA\")."
595 .to_string(),
596 });
597 }
598 }
599
600 let mut lang_to_urls: HashMap<String, Vec<String>> = HashMap::new();
602 for tag in hreflang_tags {
603 lang_to_urls
604 .entry(tag.lang.clone())
605 .or_default()
606 .push(tag.url.to_string());
607 }
608
609 for (lang, urls) in &lang_to_urls {
611 if urls.len() > 1 && lang != "x-default" {
612 findings.push(Finding {
613 severity: Severity::Error,
614 category: IssueCategory::Seo,
615 code: "HREF003".to_string(),
616 title: "Duplicate hreflang language".to_string(),
617 description: format!(
618 "Language \"{}\" appears {} times in hreflang tags.",
619 lang,
620 urls.len()
621 ),
622 url: url.clone(),
623 recommendation: "Each language code should appear only once in hreflang tags."
624 .to_string(),
625 });
626 }
627 }
628
629 findings
630 }
631}
632
633#[derive(Debug, Clone)]
639pub struct SitemapEntry {
640 pub url: String,
641 pub lastmod: Option<String>,
642 pub changefreq: Option<String>,
643 pub priority: Option<f64>,
644}
645
646pub struct SitemapAnalyzer {
647 known_urls: HashSet<String>,
649 entries: Vec<SitemapEntry>,
651}
652
653impl SitemapAnalyzer {
654 pub fn new(known_urls: HashSet<String>, entries: Vec<SitemapEntry>) -> Self {
655 Self {
656 known_urls,
657 entries,
658 }
659 }
660
661 pub fn empty() -> Self {
662 Self {
663 known_urls: HashSet::new(),
664 entries: Vec::new(),
665 }
666 }
667
668 fn is_valid_lastmod(lastmod: &str) -> bool {
670 let bytes = lastmod.as_bytes();
672 if bytes.len() < 7 {
673 return false;
674 }
675 for i in 0..=bytes.len().saturating_sub(7) {
677 if bytes[i].is_ascii_digit()
678 && bytes[i + 1].is_ascii_digit()
679 && bytes[i + 2].is_ascii_digit()
680 && bytes[i + 3].is_ascii_digit()
681 && bytes[i + 4] == b'-'
682 && bytes[i + 5].is_ascii_digit()
683 && bytes[i + 6].is_ascii_digit()
684 {
685 return true;
686 }
687 }
688 false
689 }
690
691 fn is_valid_changefreq(freq: &str) -> bool {
693 matches!(
694 freq,
695 "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never"
696 )
697 }
698
699 fn is_valid_priority(p: f64) -> bool {
701 (0.0..=1.0).contains(&p)
702 }
703}
704
705impl Default for SitemapAnalyzer {
706 fn default() -> Self {
707 Self::empty()
708 }
709}
710
711impl Analyzer for SitemapAnalyzer {
712 fn name(&self) -> &str {
713 "sitemap"
714 }
715
716 fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
717 let mut findings = Vec::new();
718 let url = &ctx.page.url;
719
720 if self.known_urls.is_empty() {
721 findings.push(Finding {
722 severity: Severity::Info,
723 category: IssueCategory::Seo,
724 code: "SITEMAP001".to_string(),
725 title: "No sitemap data available".to_string(),
726 description: "No sitemap URLs were loaded for validation.".to_string(),
727 url: url.clone(),
728 recommendation: "Provide sitemap data to enable sitemap validation.".to_string(),
729 });
730 return findings;
731 }
732
733 if !self.known_urls.contains(url) {
735 findings.push(Finding {
736 severity: Severity::Warning,
737 category: IssueCategory::Seo,
738 code: "SITEMAP002".to_string(),
739 title: "Page not found in sitemap".to_string(),
740 description: "This page URL was not found in any loaded sitemap.".to_string(),
741 url: url.clone(),
742 recommendation: "Add this page to your sitemap.xml to ensure it is crawled and \
743 indexed."
744 .to_string(),
745 });
746 }
747
748 if let Some(entry) = self.entries.iter().find(|e| e.url == *url) {
750 if let Some(ref lastmod) = entry.lastmod {
751 if !Self::is_valid_lastmod(lastmod) {
752 findings.push(Finding {
753 severity: Severity::Warning,
754 category: IssueCategory::Seo,
755 code: "SITEMAP003".to_string(),
756 title: "Invalid lastmod format".to_string(),
757 description: format!(
758 "The lastmod value \"{lastmod}\" does not appear to be a valid \
759 ISO 8601 date."
760 ),
761 url: url.clone(),
762 recommendation: "Use ISO 8601 format for lastmod (e.g., \
763 2024-01-15T10:30:00Z)."
764 .to_string(),
765 });
766 }
767 }
768
769 if let Some(ref freq) = entry.changefreq {
770 if !Self::is_valid_changefreq(freq) {
771 findings.push(Finding {
772 severity: Severity::Warning,
773 category: IssueCategory::Seo,
774 code: "SITEMAP004".to_string(),
775 title: "Invalid changefreq value".to_string(),
776 description: format!(
777 "The changefreq value \"{freq}\" is not a recognized value."
778 ),
779 url: url.clone(),
780 recommendation: "Use one of: always, hourly, daily, weekly, monthly, \
781 yearly, never."
782 .to_string(),
783 });
784 }
785 }
786
787 if let Some(priority) = entry.priority {
788 if !Self::is_valid_priority(priority) {
789 findings.push(Finding {
790 severity: Severity::Warning,
791 category: IssueCategory::Seo,
792 code: "SITEMAP005".to_string(),
793 title: "Invalid priority value".to_string(),
794 description: format!(
795 "The priority value {priority} is outside the valid range (0.0-1.0)."
796 ),
797 url: url.clone(),
798 recommendation: "Set priority to a value between 0.0 and 1.0.".to_string(),
799 });
800 }
801 }
802 }
803
804 findings
805 }
806}
807
808#[derive(Debug, Clone)]
814pub struct RobotsRule {
815 pub user_agent: String,
816 pub disallowed_paths: Vec<String>,
817 pub allowed_paths: Vec<String>,
818 pub crawl_delay: Option<f64>,
819 pub sitemaps: Vec<String>,
820}
821
822pub struct RobotsTxtAnalyzer {
823 rules: Vec<RobotsRule>,
824 sitemap_urls: Vec<String>,
825}
826
827impl RobotsTxtAnalyzer {
828 pub fn new(rules: Vec<RobotsRule>, sitemap_urls: Vec<String>) -> Self {
829 Self {
830 rules,
831 sitemap_urls,
832 }
833 }
834
835 pub fn empty() -> Self {
836 Self {
837 rules: Vec::new(),
838 sitemap_urls: Vec::new(),
839 }
840 }
841
842 fn is_disallowed(path: &str, disallowed: &[String]) -> bool {
844 for pattern in disallowed {
845 if path.starts_with(pattern.as_str()) {
846 return true;
847 }
848 }
849 false
850 }
851
852 fn is_allowed(path: &str, allowed: &[String]) -> bool {
854 for pattern in allowed {
855 if path.starts_with(pattern.as_str()) {
856 return true;
857 }
858 }
859 false
860 }
861}
862
863impl Default for RobotsTxtAnalyzer {
864 fn default() -> Self {
865 Self::empty()
866 }
867}
868
869impl Analyzer for RobotsTxtAnalyzer {
870 fn name(&self) -> &str {
871 "robots-txt"
872 }
873
874 fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
875 let mut findings = Vec::new();
876 let url = &ctx.page.url;
877
878 if self.rules.is_empty() {
879 return findings;
880 }
881
882 let page_url = match Url::parse(url) {
883 Ok(u) => u,
884 Err(_) => return findings,
885 };
886
887 let path = page_url.path();
888
889 for rule in &self.rules {
891 let user_agent = &rule.user_agent;
892 let is_match = user_agent == "*" || user_agent.contains("crawlkit");
893
894 if !is_match {
895 continue;
896 }
897
898 if Self::is_disallowed(path, &rule.disallowed_paths) {
900 if Self::is_allowed(path, &rule.allowed_paths) {
902 findings.push(Finding {
903 severity: Severity::Info,
904 category: IssueCategory::Seo,
905 code: "ROBOT001".to_string(),
906 title: "Path allowed by robots.txt override".to_string(),
907 description: format!(
908 "Path \"{path}\" was disallowed but is explicitly allowed by a \
909 more specific rule for user-agent \"{user_agent}\"."
910 ),
911 url: url.clone(),
912 recommendation: "This is informational. The path is crawlable due to an \
913 allow rule override."
914 .to_string(),
915 });
916 } else {
917 findings.push(Finding {
918 severity: Severity::Error,
919 category: IssueCategory::Seo,
920 code: "ROBOT002".to_string(),
921 title: "Path disallowed by robots.txt".to_string(),
922 description: format!(
923 "Path \"{path}\" is disallowed for user-agent \"{user_agent}\"."
924 ),
925 url: url.clone(),
926 recommendation: "Remove the disallow rule if this page should be \
927 crawled, or update internal links to avoid disallowed \
928 paths."
929 .to_string(),
930 });
931 }
932 }
933
934 if let Some(delay) = rule.crawl_delay {
936 if delay > 10.0 {
937 findings.push(Finding {
938 severity: Severity::Warning,
939 category: IssueCategory::Performance,
940 code: "ROBOT003".to_string(),
941 title: "High crawl-delay value".to_string(),
942 description: format!(
943 "Crawl-delay of {delay}s for user-agent \"{user_agent}\" may \
944 significantly slow crawling."
945 ),
946 url: url.clone(),
947 recommendation: "Consider reducing crawl-delay if faster crawling is \
948 needed."
949 .to_string(),
950 });
951 }
952 }
953 }
954
955 for sitemap_url in &self.sitemap_urls {
957 if Url::parse(sitemap_url).is_err() {
958 findings.push(Finding {
959 severity: Severity::Warning,
960 category: IssueCategory::Seo,
961 code: "ROBOT004".to_string(),
962 title: "Invalid sitemap URL in robots.txt".to_string(),
963 description: format!(
964 "The sitemap URL \"{sitemap_url}\" in robots.txt is not a valid URL."
965 ),
966 url: url.clone(),
967 recommendation: "Fix the sitemap URL in robots.txt to be a valid absolute URL."
968 .to_string(),
969 });
970 }
971 }
972
973 findings
974 }
975}
976
977pub struct MetaTagAnalyzer;
982
983impl MetaTagAnalyzer {
984 pub fn new() -> Self {
985 Self
986 }
987}
988
989impl Default for MetaTagAnalyzer {
990 fn default() -> Self {
991 Self::new()
992 }
993}
994
995impl Analyzer for MetaTagAnalyzer {
996 fn name(&self) -> &str {
997 "meta-tags"
998 }
999
1000 fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
1001 let mut findings = Vec::new();
1002 let url = &ctx.page.url;
1003 let meta = &ctx.page.meta;
1004
1005 match &meta.title {
1007 None => {
1008 findings.push(Finding {
1009 severity: Severity::Error,
1010 category: IssueCategory::Seo,
1011 code: "META001".to_string(),
1012 title: "Missing page title".to_string(),
1013 description: "No <title> tag was found on this page.".to_string(),
1014 url: url.clone(),
1015 recommendation: "Add a descriptive title tag (30-60 characters).".to_string(),
1016 });
1017 }
1018 Some(title) => {
1019 let len = title.len();
1020 if len < 30 {
1021 findings.push(Finding {
1022 severity: Severity::Warning,
1023 category: IssueCategory::Seo,
1024 code: "META002".to_string(),
1025 title: "Title too short".to_string(),
1026 description: format!(
1027 "Title is {len} characters, below the recommended minimum of 30."
1028 ),
1029 url: url.clone(),
1030 recommendation: "Expand the title to 30-60 characters to improve \
1031 search snippet quality."
1032 .to_string(),
1033 });
1034 } else if len > 60 {
1035 findings.push(Finding {
1036 severity: Severity::Warning,
1037 category: IssueCategory::Seo,
1038 code: "META003".to_string(),
1039 title: "Title too long".to_string(),
1040 description: format!(
1041 "Title is {len} characters, exceeding the recommended maximum of 60."
1042 ),
1043 url: url.clone(),
1044 recommendation: "Shorten the title to 60 characters or less to prevent \
1045 truncation in search results."
1046 .to_string(),
1047 });
1048 }
1049 }
1050 }
1051
1052 match &meta.description {
1054 None => {
1055 findings.push(Finding {
1056 severity: Severity::Warning,
1057 category: IssueCategory::Seo,
1058 code: "META004".to_string(),
1059 title: "Missing meta description".to_string(),
1060 description: "No meta description was found on this page.".to_string(),
1061 url: url.clone(),
1062 recommendation: "Add a meta description (120-160 characters) to control \
1063 how your page appears in search results."
1064 .to_string(),
1065 });
1066 }
1067 Some(desc) => {
1068 let len = desc.len();
1069 if len < 120 {
1070 let is_utility_page = url.contains("/account")
1072 || url.contains("/compare")
1073 || url.contains("/wishlist")
1074 || url.contains("/cart")
1075 || url.contains("/checkout")
1076 || url.contains("/login")
1077 || url.contains("/register")
1078 || url.contains("/forgot")
1079 || url.contains("/contact")
1080 || url.contains("/about")
1081 || url.contains("/certifications")
1082 || url.contains("/research-use");
1083 if !is_utility_page {
1084 findings.push(Finding {
1085 severity: Severity::Warning,
1086 category: IssueCategory::Seo,
1087 code: "META005".to_string(),
1088 title: "Meta description too short".to_string(),
1089 description: format!(
1090 "Description is {len} characters, below the recommended minimum \
1091 of 120."
1092 ),
1093 url: url.clone(),
1094 recommendation: "Expand the description to 120-160 characters."
1095 .to_string(),
1096 });
1097 }
1098 } else if len > 165 {
1099 findings.push(Finding {
1101 severity: Severity::Warning,
1102 category: IssueCategory::Seo,
1103 code: "META006".to_string(),
1104 title: "Meta description too long".to_string(),
1105 description: format!(
1106 "Description is {len} characters, exceeding the recommended \
1107 maximum of 160."
1108 ),
1109 url: url.clone(),
1110 recommendation: "Shorten the description to 160 characters or less."
1111 .to_string(),
1112 });
1113 }
1114 }
1115 }
1116
1117 let og_required = [
1119 ("og:title", &meta.og.title),
1120 ("og:image", &meta.og.image),
1121 ("og:url", &meta.og.url),
1122 ("og:type", &meta.og.r#type),
1123 ];
1124
1125 for (tag_name, value) in &og_required {
1126 if value.is_none() {
1127 findings.push(Finding {
1128 severity: Severity::Warning,
1129 category: IssueCategory::Social,
1130 code: "META007".to_string(),
1131 title: format!("Missing {tag_name} tag"),
1132 description: format!(
1133 "The Open Graph tag {tag_name} is missing. Social media previews \
1134 may be incomplete."
1135 ),
1136 url: url.clone(),
1137 recommendation: format!(
1138 "Add <meta property=\"{tag_name}\" content=\"...\"> to improve social \
1139 sharing."
1140 ),
1141 });
1142 }
1143 }
1144
1145 let twitter_required = [
1147 ("twitter:card", &meta.twitter.card),
1148 ("twitter:title", &meta.twitter.title),
1149 ("twitter:image", &meta.twitter.image),
1150 ];
1151
1152 for (tag_name, value) in &twitter_required {
1153 if value.is_none() {
1154 findings.push(Finding {
1155 severity: Severity::Info,
1156 category: IssueCategory::Social,
1157 code: "META008".to_string(),
1158 title: format!("Missing {tag_name} tag"),
1159 description: format!(
1160 "The Twitter Card tag {tag_name} is missing. Twitter/X previews \
1161 may be incomplete."
1162 ),
1163 url: url.clone(),
1164 recommendation: format!(
1165 "Add <meta name=\"{tag_name}\" content=\"...\"> to improve Twitter/X \
1166 sharing."
1167 ),
1168 });
1169 }
1170 }
1171
1172 if meta.viewport.is_none() {
1174 findings.push(Finding {
1175 severity: Severity::Warning,
1176 category: IssueCategory::Mobile,
1177 code: "META009".to_string(),
1178 title: "Missing viewport meta tag".to_string(),
1179 description: "No viewport meta tag was found. This may affect mobile rendering."
1180 .to_string(),
1181 url: url.clone(),
1182 recommendation: "Add <meta name=\"viewport\" content=\"width=device-width, \
1183 initial-scale=1\"> for proper mobile rendering."
1184 .to_string(),
1185 });
1186 }
1187
1188 findings
1189 }
1190}
1191
1192pub struct HeadingHierarchyAnalyzer;
1197
1198impl HeadingHierarchyAnalyzer {
1199 pub fn new() -> Self {
1200 Self
1201 }
1202}
1203
1204impl Default for HeadingHierarchyAnalyzer {
1205 fn default() -> Self {
1206 Self::new()
1207 }
1208}
1209
1210impl Analyzer for HeadingHierarchyAnalyzer {
1211 fn name(&self) -> &str {
1212 "heading-hierarchy"
1213 }
1214
1215 fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
1216 let mut findings = Vec::new();
1217 let url = &ctx.page.url;
1218 let headings = &ctx.page.headings;
1219
1220 if headings.is_empty() {
1221 findings.push(Finding {
1222 severity: Severity::Warning,
1223 category: IssueCategory::Content,
1224 code: "HEAD001".to_string(),
1225 title: "No headings found".to_string(),
1226 description: "The page contains no heading elements (H1-H6).".to_string(),
1227 url: url.clone(),
1228 recommendation: "Add at least one H1 heading to define the page topic.".to_string(),
1229 });
1230 return findings;
1231 }
1232
1233 let h1_count = headings.iter().filter(|h| h.level == 1).count();
1235 if h1_count == 0 {
1236 findings.push(Finding {
1237 severity: Severity::Error,
1238 category: IssueCategory::Seo,
1239 code: "HEAD002".to_string(),
1240 title: "Missing H1 heading".to_string(),
1241 description: "The page has headings but no H1 tag.".to_string(),
1242 url: url.clone(),
1243 recommendation: "Add exactly one H1 heading that describes the main topic of \
1244 the page."
1245 .to_string(),
1246 });
1247 } else if h1_count > 1 {
1248 findings.push(Finding {
1249 severity: Severity::Warning,
1250 category: IssueCategory::Seo,
1251 code: "HEAD003".to_string(),
1252 title: "Multiple H1 headings".to_string(),
1253 description: format!(
1254 "The page has {h1_count} H1 headings. Best practice is to have exactly one."
1255 ),
1256 url: url.clone(),
1257 recommendation: "Use a single H1 for the main topic. Use H2-H6 for subsections."
1258 .to_string(),
1259 });
1260 }
1261
1262 let mut prev_level: Option<u8> = None;
1264 for heading in headings {
1265 if let Some(prev) = prev_level {
1266 if heading.level > prev + 1 {
1267 findings.push(Finding {
1268 severity: Severity::Warning,
1269 category: IssueCategory::Content,
1270 code: "HEAD004".to_string(),
1271 title: "Skipped heading level".to_string(),
1272 description: format!(
1273 "Heading level jumps from H{prev} to H{}, skipping intermediate \
1274 levels.",
1275 heading.level
1276 ),
1277 url: url.clone(),
1278 recommendation: format!(
1279 "Use H{} after H{prev} to maintain proper document hierarchy.",
1280 prev + 1
1281 ),
1282 });
1283 }
1284 }
1285 prev_level = Some(heading.level);
1286 }
1287
1288 let max_depth = headings.iter().map(|h| h.level).max().unwrap_or(0);
1290 if max_depth >= 5 {
1291 findings.push(Finding {
1292 severity: Severity::Info,
1293 category: IssueCategory::Content,
1294 code: "HEAD005".to_string(),
1295 title: "Deep heading hierarchy".to_string(),
1296 description: format!(
1297 "Heading hierarchy reaches H{max_depth}. Deep nesting may indicate \
1298 overly complex content structure."
1299 ),
1300 url: url.clone(),
1301 recommendation: "Consider flattening the heading hierarchy for better \
1302 readability."
1303 .to_string(),
1304 });
1305 }
1306
1307 findings
1308 }
1309}
1310
1311#[derive(Debug, Clone, Default)]
1317pub struct LinkInfo {
1318 pub total: usize,
1319 pub internal: usize,
1320 pub external: usize,
1321 pub nofollow: usize,
1322 pub anchor_text_empty: usize,
1323}
1324
1325pub struct LinkAnalyzer {
1326 inbound_links: HashMap<String, usize>,
1329}
1330
1331impl LinkAnalyzer {
1332 pub fn new() -> Self {
1333 Self {
1334 inbound_links: HashMap::new(),
1335 }
1336 }
1337
1338 pub fn with_inbound_links(inbound_links: HashMap<String, usize>) -> Self {
1341 Self { inbound_links }
1342 }
1343}
1344
1345impl Default for LinkAnalyzer {
1346 fn default() -> Self {
1347 Self::new()
1348 }
1349}
1350
1351impl Analyzer for LinkAnalyzer {
1352 fn name(&self) -> &str {
1353 "link-analyzer"
1354 }
1355
1356 fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
1357 let mut findings = Vec::new();
1358 let url = &ctx.page.url;
1359
1360 let internal_count = ctx.page.links.iter().filter(|l| !l.is_external).count();
1361 let external_count = ctx.page.links.iter().filter(|l| l.is_external).count();
1362 let nofollow_count = ctx
1363 .page
1364 .links
1365 .iter()
1366 .filter(|l| l.rel.contains(&"nofollow".to_string()))
1367 .count();
1368
1369 findings.push(Finding {
1371 severity: Severity::Info,
1372 category: IssueCategory::Links,
1373 code: "LINK001".to_string(),
1374 title: "Link counts".to_string(),
1375 description: format!(
1376 "Internal: {}, External: {}, Nofollow: {}",
1377 internal_count, external_count, nofollow_count
1378 ),
1379 url: url.clone(),
1380 recommendation: String::new(),
1381 });
1382
1383 for link in &ctx.page.links {
1385 let resolved = Url::parse(url)
1386 .ok()
1387 .and_then(|base| base.join(&link.href).ok());
1388 let target = resolved.as_ref().map(|u| u.as_str()).unwrap_or(&link.href);
1389 if let Some(status) = ctx.status_code {
1390 if (400..=599).contains(&status) {
1391 findings.push(Finding {
1393 severity: Severity::Error,
1394 category: IssueCategory::Links,
1395 code: "LINK002".to_string(),
1396 title: "Link on broken page".to_string(),
1397 description: format!(
1398 "Link \"{}\" points to \"{}\" but the current page itself returned \
1399 HTTP {status}.",
1400 link.text, target,
1401 ),
1402 url: url.clone(),
1403 recommendation: "Fix the broken page or remove links from it.".to_string(),
1404 });
1405 }
1406 }
1407 }
1408
1409 if nofollow_count > 0 {
1411 findings.push(Finding {
1412 severity: Severity::Info,
1413 category: IssueCategory::Links,
1414 code: "LINK003".to_string(),
1415 title: "Nofollow links present".to_string(),
1416 description: format!(
1417 "{} link(s) have rel=\"nofollow\". This tells search engines not to pass \
1418 PageRank.",
1419 nofollow_count
1420 ),
1421 url: url.clone(),
1422 recommendation: "Ensure nofollow is used intentionally (e.g., paid links, \
1423 untrusted user content)."
1424 .to_string(),
1425 });
1426 }
1427
1428 for link in &ctx.page.links {
1430 let has_accessible_name = !link.text.trim().is_empty()
1432 || link
1433 .aria_label
1434 .as_ref()
1435 .is_some_and(|l| !l.trim().is_empty())
1436 || link.img_alt.as_ref().is_some_and(|a| !a.trim().is_empty());
1437 if !has_accessible_name {
1438 findings.push(Finding {
1439 severity: Severity::Warning,
1440 category: IssueCategory::Links,
1441 code: "LINK004".to_string(),
1442 title: "Empty anchor text".to_string(),
1443 description: format!("Link to \"{}\" has no visible anchor text.", link.href),
1444 url: url.clone(),
1445 recommendation: "Add descriptive anchor text to help users and search engines \
1446 understand the link destination."
1447 .to_string(),
1448 });
1449 } else if link.text.trim().len() < 3 && !link.text.trim().is_empty() {
1450 findings.push(Finding {
1451 severity: Severity::Info,
1452 category: IssueCategory::Links,
1453 code: "LINK005".to_string(),
1454 title: "Very short anchor text".to_string(),
1455 description: format!(
1456 "Link \"{}\" has very short anchor text ({} chars).",
1457 link.text,
1458 link.text.len()
1459 ),
1460 url: url.clone(),
1461 recommendation: "Use more descriptive anchor text for better usability."
1462 .to_string(),
1463 });
1464 }
1465 }
1466
1467 if let Some(&count) = self.inbound_links.get(url) {
1469 if count == 0 {
1470 findings.push(Finding {
1471 severity: Severity::Warning,
1472 category: IssueCategory::Links,
1473 code: "LINK006".to_string(),
1474 title: "Orphan page".to_string(),
1475 description: "This page has no inbound links from other pages on the site."
1476 .to_string(),
1477 url: url.clone(),
1478 recommendation:
1479 "Add internal links from other pages to improve discoverability."
1480 .to_string(),
1481 });
1482 }
1483 }
1484
1485 findings
1486 }
1487}
1488
1489#[derive(Debug, Clone)]
1495pub struct ImageInfo {
1496 pub src: String,
1497 pub alt: String,
1498 pub width: Option<u32>,
1499 pub height: Option<u32>,
1500 pub format: Option<String>,
1501 pub file_size: Option<u64>,
1502 pub has_alt: bool,
1503 pub is_lazy_loaded: bool,
1504}
1505
1506pub struct ImageAnalyzer;
1507
1508impl ImageAnalyzer {
1509 pub fn new() -> Self {
1510 Self
1511 }
1512
1513 #[allow(dead_code)]
1514 pub(crate) fn detect_format(src: &str) -> Option<String> {
1515 let path = src.split('?').next()?;
1516 let ext = path.rsplit('.').next()?;
1517 match ext.to_lowercase().as_str() {
1518 "jpg" | "jpeg" => Some("jpeg".to_string()),
1519 "png" => Some("png".to_string()),
1520 "gif" => Some("gif".to_string()),
1521 "webp" => Some("webp".to_string()),
1522 "avif" => Some("avif".to_string()),
1523 "svg" => Some("svg".to_string()),
1524 "bmp" => Some("bmp".to_string()),
1525 "ico" => Some("ico".to_string()),
1526 "tiff" | "tif" => Some("tiff".to_string()),
1527 _ => None,
1528 }
1529 }
1530}
1531
1532impl Default for ImageAnalyzer {
1533 fn default() -> Self {
1534 Self::new()
1535 }
1536}
1537
1538impl Analyzer for ImageAnalyzer {
1539 fn name(&self) -> &str {
1540 "image-analyzer"
1541 }
1542
1543 fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
1544 let mut findings = Vec::new();
1545 let url = &ctx.page.url;
1546
1547 if ctx.page.images.is_empty() {
1548 return findings;
1549 }
1550
1551 let mut total_lazy = 0u32;
1552 let mut total_with_dimensions = 0u32;
1553
1554 for img in &ctx.page.images {
1555 if !img.has_alt {
1557 findings.push(Finding {
1558 severity: Severity::Warning,
1559 category: IssueCategory::Accessibility,
1560 code: "IMG001".to_string(),
1561 title: "Image missing alt text".to_string(),
1562 description: format!("Image \"{}\" has no alt attribute.", img.src),
1563 url: url.clone(),
1564 recommendation: "Add descriptive alt text to improve accessibility and SEO."
1565 .to_string(),
1566 });
1567 }
1568
1569 if img.is_lazy_loaded {
1574 total_lazy += 1;
1575 }
1576
1577 if img.width.is_some() && img.height.is_some() {
1578 total_with_dimensions += 1;
1579 }
1580 }
1581
1582 if total_lazy > 0 {
1584 findings.push(Finding {
1585 severity: Severity::Info,
1586 category: IssueCategory::Performance,
1587 code: "IMG003".to_string(),
1588 title: "Lazy-loaded images".to_string(),
1589 description: format!(
1590 "{} of {} images use lazy loading.",
1591 total_lazy,
1592 ctx.page.images.len()
1593 ),
1594 url: url.clone(),
1595 recommendation: "Verify lazy loading is only applied to below-the-fold images."
1596 .to_string(),
1597 });
1598 }
1599
1600 let missing_dimensions = ctx.page.images.len() as u32 - total_with_dimensions;
1602 if missing_dimensions > 0 {
1603 findings.push(Finding {
1604 severity: Severity::Info,
1605 category: IssueCategory::Performance,
1606 code: "IMG004".to_string(),
1607 title: "Images missing dimensions".to_string(),
1608 description: format!(
1609 "{} of {} images are missing width/height attributes.",
1610 missing_dimensions,
1611 ctx.page.images.len()
1612 ),
1613 url: url.clone(),
1614 recommendation: "Specify width and height to prevent layout shifts (CLS)."
1615 .to_string(),
1616 });
1617 }
1618
1619 findings
1620 }
1621}
1622
1623const REQUIRED_PROPERTIES: &[(&str, &[&str])] = &[
1629 ("Article", &["headline", "author"]),
1630 ("NewsArticle", &["headline", "author"]),
1631 ("BlogPosting", &["headline", "author"]),
1632 ("ScholarlyArticle", &["headline", "author"]),
1633 ("Product", &["name"]),
1634 ("Organization", &["name"]),
1635 ("LocalBusiness", &["name", "address"]),
1636 ("Store", &["name", "address"]),
1637 ("WebPage", &["name"]),
1638 ("BreadcrumbList", &["itemListElement"]),
1639 ("FAQPage", &["mainEntity"]),
1640 ("HowTo", &["name"]),
1641 ("HowToStep", &["text"]),
1642 ("Event", &["name", "startDate"]),
1643 ("Recipe", &["name"]),
1644 ("VideoObject", &["name", "embedUrl"]),
1645 ("SoftwareApplication", &["name"]),
1646 ("Book", &["name"]),
1647 ("MusicAlbum", &["name"]),
1648 ("Movie", &["name"]),
1649 ("Quiz", &["name"]),
1650 ("Question", &["text"]),
1651 ("Answer", &["text"]),
1652 ("Drug", &["name"]),
1653 ("DefinedTerm", &["name"]),
1654 ("DefinedTermSet", &["name"]),
1655 ("Dataset", &["name"]),
1656];
1657
1658const RECOGNIZED_TYPES: &[&str] = &[
1660 "Article",
1662 "NewsArticle",
1663 "BlogPosting",
1664 "ScholarlyArticle",
1665 "Product",
1667 "Offer",
1668 "Brand",
1669 "AggregateRating",
1670 "Review",
1671 "Organization",
1673 "LocalBusiness",
1674 "Store",
1675 "Person",
1676 "Place",
1677 "WebSite",
1679 "WebPage",
1680 "WebPageElement",
1681 "BreadcrumbList",
1683 "ItemList",
1684 "ListItem",
1685 "FAQPage",
1687 "HowTo",
1688 "HowToStep",
1689 "HowToDirection",
1690 "HowToSupply",
1691 "HowToTool",
1692 "Quiz",
1693 "Question",
1694 "Answer",
1695 "Event",
1697 "Recipe",
1698 "VideoObject",
1699 "ImageObject",
1700 "AudioObject",
1701 "SoftwareApplication",
1703 "SoftwareSourceCode",
1704 "Book",
1706 "MusicAlbum",
1707 "MusicRecording",
1708 "Movie",
1709 "TVSeries",
1710 "Dataset",
1712 "DataDownload",
1713 "DefinedTerm",
1714 "DefinedTermSet",
1715 "Drug",
1717 "MedicalWebPage",
1718 "MedicalSubstance",
1719 "MedicalAudience",
1720 "MedicalCondition",
1721 "MedicalProcedure",
1722 "CreativeWork",
1724 "Course",
1725 "LearningResource",
1726 "ResearchProject",
1728 "CollectionPage",
1729];
1730
1731pub struct StructuredDataValidator;
1732
1733impl StructuredDataValidator {
1734 pub fn new() -> Self {
1735 Self
1736 }
1737
1738 fn required_properties(schema_type: &str) -> &'static [&'static str] {
1739 REQUIRED_PROPERTIES
1740 .iter()
1741 .find(|(t, _)| *t == schema_type)
1742 .map(|(_, props)| *props)
1743 .unwrap_or(&[])
1744 }
1745}
1746
1747impl Default for StructuredDataValidator {
1748 fn default() -> Self {
1749 Self::new()
1750 }
1751}
1752
1753impl Analyzer for StructuredDataValidator {
1754 fn name(&self) -> &str {
1755 "structured-data"
1756 }
1757
1758 fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
1759 let mut findings = Vec::new();
1760 let url = &ctx.page.url;
1761
1762 if ctx.page.structured_data.is_empty() {
1763 findings.push(Finding {
1764 severity: Severity::Info,
1765 category: IssueCategory::Schema,
1766 code: "SD001".to_string(),
1767 title: "No structured data found".to_string(),
1768 description: "No JSON-LD structured data was found on this page.".to_string(),
1769 url: url.clone(),
1770 recommendation: "Add relevant Schema.org JSON-LD markup to enhance search \
1771 results."
1772 .to_string(),
1773 });
1774 return findings;
1775 }
1776
1777 for sd in &ctx.page.structured_data {
1778 match &sd.context {
1780 None => {
1781 findings.push(Finding {
1782 severity: Severity::Error,
1783 category: IssueCategory::Schema,
1784 code: "SD002".to_string(),
1785 title: "Missing @context".to_string(),
1786 description: "JSON-LD block is missing the @context property.".to_string(),
1787 url: url.clone(),
1788 recommendation: "Add \"@context\": \"https://schema.org\" to all JSON-LD \
1789 blocks."
1790 .to_string(),
1791 });
1792 }
1793 Some(ctx_val) => {
1794 if ctx_val != "https://schema.org" && ctx_val != "schema.org" {
1795 findings.push(Finding {
1796 severity: Severity::Warning,
1797 category: IssueCategory::Schema,
1798 code: "SD003".to_string(),
1799 title: "Non-standard @context".to_string(),
1800 description: format!(
1801 "JSON-LD @context is \"{ctx_val}\" instead of \
1802 \"https://schema.org\"."
1803 ),
1804 url: url.clone(),
1805 recommendation: "Use \"https://schema.org\" as the @context."
1806 .to_string(),
1807 });
1808 }
1809 }
1810 }
1811
1812 match &sd.r#type {
1814 None => {
1815 findings.push(Finding {
1816 severity: Severity::Error,
1817 category: IssueCategory::Schema,
1818 code: "SD004".to_string(),
1819 title: "Missing @type".to_string(),
1820 description: "JSON-LD block is missing the @type property.".to_string(),
1821 url: url.clone(),
1822 recommendation: "Add an appropriate @type to describe the content."
1823 .to_string(),
1824 });
1825 }
1826 Some(type_val) => {
1827 if !RECOGNIZED_TYPES.contains(&type_val.as_str()) {
1828 findings.push(Finding {
1829 severity: Severity::Warning,
1830 category: IssueCategory::Schema,
1831 code: "SD005".to_string(),
1832 title: "Unknown @type".to_string(),
1833 description: format!(
1834 "JSON-LD @type \"{type_val}\" is not a recognized Schema.org \
1835 type."
1836 ),
1837 url: url.clone(),
1838 recommendation: "Verify this is a valid Schema.org type or use a \
1839 recognized type."
1840 .to_string(),
1841 });
1842 }
1843
1844 let required = Self::required_properties(type_val);
1846 if !required.is_empty() {
1847 let mut missing = Vec::new();
1848 for prop in required {
1849 if sd.data.get(*prop).is_none() {
1850 missing.push(*prop);
1851 }
1852 }
1853 if !missing.is_empty() {
1854 findings.push(Finding {
1855 severity: Severity::Error,
1856 category: IssueCategory::Schema,
1857 code: "SD006".to_string(),
1858 title: "Missing required properties".to_string(),
1859 description: format!(
1860 "Schema type \"{type_val}\" is missing required properties: \
1861 {}.",
1862 missing.join(", ")
1863 ),
1864 url: url.clone(),
1865 recommendation: format!(
1866 "Add the missing properties to the \"{type_val}\" schema."
1867 ),
1868 });
1869 }
1870 }
1871 }
1872 }
1873 }
1874
1875 findings
1876 }
1877}
1878
1879const STOP_WORDS: &[&str] = &[
1885 "a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "by", "is",
1886 "are", "was", "were", "be", "been", "being", "have", "has", "had", "do", "does", "did", "will",
1887 "would", "could", "should", "may", "might", "shall", "can", "it", "its", "this", "that",
1888 "these", "those", "i", "you", "he", "she", "we", "they", "me", "him", "her", "us", "them",
1889 "my", "your", "his", "our", "their", "what", "which", "who", "whom", "where", "when", "why",
1890 "how", "all", "each", "every", "both", "few", "more", "most", "other", "some", "such", "no",
1891 "nor", "not", "only", "own", "same", "so", "than", "too", "very", "just", "about", "above",
1892 "after", "again", "also", "as", "before", "between", "down", "from", "here", "if", "into",
1893 "then", "there", "through", "under", "until", "up",
1894];
1895
1896pub struct ContentQualityAnalyzer;
1897
1898impl ContentQualityAnalyzer {
1899 pub fn new() -> Self {
1900 Self
1901 }
1902
1903 fn count_syllables(word: &str) -> usize {
1905 let word = word.to_lowercase();
1906 let chars: Vec<char> = word.chars().collect();
1907 if chars.is_empty() {
1908 return 0;
1909 }
1910 if chars.len() <= 2 {
1911 return 1;
1912 }
1913
1914 let vowels = ['a', 'e', 'i', 'o', 'u', 'y'];
1915 let mut count = 0;
1916 let mut prev_vowel = false;
1917
1918 for &c in &chars {
1919 let is_vowel = vowels.contains(&c);
1920 if is_vowel && !prev_vowel {
1921 count += 1;
1922 }
1923 prev_vowel = is_vowel;
1924 }
1925
1926 if chars.last() == Some(&'e') && count > 1 {
1928 count -= 1;
1929 }
1930
1931 count.max(1)
1932 }
1933
1934 fn count_sentences(text: &str) -> usize {
1936 if text.trim().is_empty() {
1937 return 0;
1938 }
1939 let count = text
1940 .chars()
1941 .filter(|&c| c == '.' || c == '!' || c == '?')
1942 .count();
1943 count.max(1)
1944 }
1945
1946 fn flesch_kincaid(words: usize, sentences: usize, syllables: usize) -> f64 {
1948 if words == 0 || sentences == 0 {
1949 return 0.0;
1950 }
1951 let score = 206.835
1952 - 1.015 * (words as f64 / sentences as f64)
1953 - 84.6 * (syllables as f64 / words as f64);
1954 score.clamp(0.0, 100.0)
1955 }
1956}
1957
1958impl Default for ContentQualityAnalyzer {
1959 fn default() -> Self {
1960 Self::new()
1961 }
1962}
1963
1964impl Analyzer for ContentQualityAnalyzer {
1965 fn name(&self) -> &str {
1966 "content-quality"
1967 }
1968
1969 fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
1970 let mut findings = Vec::new();
1971 let url = &ctx.page.url;
1972
1973 let word_count = ctx.page.word_count;
1975 if word_count > 0 {
1976 let text = &ctx
1977 .page
1978 .headings
1979 .iter()
1980 .map(|h| h.text.as_str())
1981 .collect::<Vec<_>>()
1982 .join(" ");
1983 let sentences = Self::count_sentences(text);
1984 let syllables: usize = text.split_whitespace().map(Self::count_syllables).sum();
1985 let score = Self::flesch_kincaid(word_count, sentences.max(1), syllables);
1986
1987 findings.push(Finding {
1988 severity: Severity::Info,
1989 category: IssueCategory::Content,
1990 code: "CQ001".to_string(),
1991 title: "Flesch-Kincaid readability score".to_string(),
1992 description: format!(
1993 "Readability score: {score:.1}/100 (words: {word_count}, sentences: \
1994 {sentences}, syllables: {syllables})."
1995 ),
1996 url: url.clone(),
1997 recommendation: if score < 30.0 {
1998 "Content is very difficult to read. Consider simplifying language and \
1999 shortening sentences."
2000 .to_string()
2001 } else if score < 50.0 {
2002 "Content is fairly difficult to read. Aim for a score of 60+ for general \
2003 audiences."
2004 .to_string()
2005 } else if score < 70.0 {
2006 "Content has moderate readability. Suitable for most audiences.".to_string()
2007 } else {
2008 "Content is easy to read. Good for general audiences.".to_string()
2009 },
2010 });
2011 }
2012
2013 let headings_text: String = ctx
2015 .page
2016 .headings
2017 .iter()
2018 .map(|h| h.text.as_str())
2019 .collect::<Vec<_>>()
2020 .join(" ");
2021
2022 if !headings_text.trim().is_empty() {
2023 let mut freq: HashMap<String, usize> = HashMap::new();
2024 for word in headings_text.split_whitespace() {
2025 let lower = word
2026 .to_lowercase()
2027 .chars()
2028 .filter(|c| c.is_alphanumeric())
2029 .collect::<String>();
2030 if lower.len() > 2 && !STOP_WORDS.contains(&lower.as_str()) {
2031 *freq.entry(lower).or_default() += 1;
2032 }
2033 }
2034
2035 let mut terms: Vec<(String, usize)> = freq.into_iter().collect();
2036 terms.sort_by_key(|b| std::cmp::Reverse(b.1));
2037 terms.truncate(10);
2038
2039 if !terms.is_empty() {
2040 let display: String = terms
2041 .iter()
2042 .map(|(word, count)| format!("\"{}\" ({})", word, count))
2043 .collect::<Vec<_>>()
2044 .join(", ");
2045 findings.push(Finding {
2046 severity: Severity::Info,
2047 category: IssueCategory::Content,
2048 code: "CQ002".to_string(),
2049 title: "Top keywords".to_string(),
2050 description: format!("Top 10 keyword occurrences in headings: {display}."),
2051 url: url.clone(),
2052 recommendation: "Ensure target keywords appear in headings and body content \
2053 naturally."
2054 .to_string(),
2055 });
2056 }
2057 }
2058
2059 if word_count == 0 {
2061 findings.push(Finding {
2062 severity: Severity::Warning,
2063 category: IssueCategory::Content,
2064 code: "CQ003".to_string(),
2065 title: "No content detected".to_string(),
2066 description: "The page has zero word count, which may indicate missing or \
2067 hidden content."
2068 .to_string(),
2069 url: url.clone(),
2070 recommendation: "Ensure the page has meaningful visible text content.".to_string(),
2071 });
2072 } else if word_count < 300 {
2073 let is_utility_page = url.contains("/account")
2075 || url.contains("/compare")
2076 || url.contains("/wishlist")
2077 || url.contains("/cart")
2078 || url.contains("/checkout")
2079 || url.contains("/login")
2080 || url.contains("/register")
2081 || url.contains("/forgot");
2082 if !is_utility_page {
2083 findings.push(Finding {
2084 severity: Severity::Warning,
2085 category: IssueCategory::Content,
2086 code: "CQ004".to_string(),
2087 title: "Thin content".to_string(),
2088 description: format!(
2089 "Page has only {word_count} words. Pages with fewer than 300 words may be \
2090 considered thin content."
2091 ),
2092 url: url.clone(),
2093 recommendation: "Expand the content to at least 300 words for better search \
2094 visibility."
2095 .to_string(),
2096 });
2097 }
2098 } else if word_count > 3000 {
2099 findings.push(Finding {
2100 severity: Severity::Info,
2101 category: IssueCategory::Content,
2102 code: "CQ005".to_string(),
2103 title: "Long-form content".to_string(),
2104 description: format!(
2105 "Page has {word_count} words. Consider whether all content is necessary or \
2106 if it could be split into multiple pages."
2107 ),
2108 url: url.clone(),
2109 recommendation: "Long-form content is good for SEO but ensure it remains \
2110 scannable with proper headings."
2111 .to_string(),
2112 });
2113 }
2114
2115 findings
2116 }
2117}
2118
2119pub struct WordCountAnalyzer;
2124
2125impl WordCountAnalyzer {
2126 pub fn new() -> Self {
2127 Self
2128 }
2129
2130 fn visible_text(ctx: &AnalysisContext) -> String {
2132 ctx.page
2133 .headings
2134 .iter()
2135 .map(|h| h.text.as_str())
2136 .collect::<Vec<_>>()
2137 .join(" ")
2138 }
2139}
2140
2141impl Default for WordCountAnalyzer {
2142 fn default() -> Self {
2143 Self::new()
2144 }
2145}
2146
2147impl Analyzer for WordCountAnalyzer {
2148 fn name(&self) -> &str {
2149 "word-count"
2150 }
2151
2152 fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
2153 let mut findings = Vec::new();
2154 let url = &ctx.page.url;
2155
2156 let text = Self::visible_text(ctx);
2157 let word_count = ctx.page.word_count;
2158 let char_count = text.chars().count();
2159
2160 let sentence_count = if text.trim().is_empty() {
2162 0
2163 } else {
2164 text.chars()
2165 .filter(|&c| c == '.' || c == '!' || c == '?')
2166 .count()
2167 .max(1)
2168 };
2169
2170 let avg_words_per_sentence = if sentence_count > 0 {
2171 word_count as f64 / sentence_count as f64
2172 } else {
2173 0.0
2174 };
2175
2176 findings.push(Finding {
2177 severity: Severity::Info,
2178 category: IssueCategory::Content,
2179 code: "WC001".to_string(),
2180 title: "Word count statistics".to_string(),
2181 description: format!(
2182 "Words: {word_count}, Characters: {char_count}, Sentences: {sentence_count}, \
2183 Avg words/sentence: {avg_words_per_sentence:.1}."
2184 ),
2185 url: url.clone(),
2186 recommendation: String::new(),
2187 });
2188
2189 if word_count == 0 {
2190 findings.push(Finding {
2191 severity: Severity::Warning,
2192 category: IssueCategory::Content,
2193 code: "WC002".to_string(),
2194 title: "Zero word count".to_string(),
2195 description: "The page has no detectable words. This may indicate a rendering \
2196 issue or an empty page."
2197 .to_string(),
2198 url: url.clone(),
2199 recommendation: "Verify the page content is visible and not hidden behind \
2200 JavaScript or CSS."
2201 .to_string(),
2202 });
2203 } else if word_count < 100 {
2204 let is_utility_page = url.contains("/account")
2206 || url.contains("/compare")
2207 || url.contains("/wishlist")
2208 || url.contains("/cart")
2209 || url.contains("/checkout")
2210 || url.contains("/login")
2211 || url.contains("/register")
2212 || url.contains("/forgot");
2213 if !is_utility_page {
2214 findings.push(Finding {
2215 severity: Severity::Warning,
2216 category: IssueCategory::Content,
2217 code: "WC003".to_string(),
2218 title: "Very low word count".to_string(),
2219 description: format!(
2220 "Page has only {word_count} words. This is very thin content."
2221 ),
2222 url: url.clone(),
2223 recommendation: "Add more substantive content to the page.".to_string(),
2224 });
2225 }
2226 }
2227
2228 if avg_words_per_sentence > 25.0 {
2229 findings.push(Finding {
2230 severity: Severity::Info,
2231 category: IssueCategory::Content,
2232 code: "WC004".to_string(),
2233 title: "Long average sentence length".to_string(),
2234 description: format!(
2235 "Average sentence length is {avg_words_per_sentence:.1} words. Sentences \
2236 longer than 25 words may be difficult to read."
2237 ),
2238 url: url.clone(),
2239 recommendation: "Break long sentences into shorter ones for better readability."
2240 .to_string(),
2241 });
2242 }
2243
2244 findings
2245 }
2246}
2247
2248pub struct SecurityHeaderAnalyzer;
2253
2254impl SecurityHeaderAnalyzer {
2255 pub fn new() -> Self {
2256 Self
2257 }
2258
2259 fn get_header<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
2261 headers
2262 .iter()
2263 .find(|(k, _)| k.eq_ignore_ascii_case(name))
2264 .map(|(_, v)| v.as_str())
2265 }
2266
2267 fn is_valid_csp(value: &str) -> bool {
2269 if value.trim().is_empty() {
2270 return false;
2271 }
2272 let directives = [
2274 "default-src",
2275 "script-src",
2276 "style-src",
2277 "img-src",
2278 "font-src",
2279 "connect-src",
2280 "frame-src",
2281 "object-src",
2282 "media-src",
2283 "child-src",
2284 "worker-src",
2285 "manifest-src",
2286 "form-action",
2287 "frame-ancestors",
2288 "base-uri",
2289 "upgrade-insecure-requests",
2290 "block-all-mixed-content",
2291 ];
2292 value.split(';').any(|part| {
2293 let trimmed = part.trim();
2294 directives.iter().any(|d| trimmed.starts_with(d))
2295 })
2296 }
2297
2298 fn validate_hsts(value: &str) -> Vec<String> {
2300 let mut issues = Vec::new();
2301 let lower = value.to_lowercase();
2302
2303 if !lower.contains("max-age=") {
2305 issues.push("missing max-age directive".to_string());
2306 } else if let Some(ma_pos) = lower.find("max-age=") {
2307 let after = &lower[ma_pos + 8..];
2312 let num_str: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
2313 match num_str.parse::<u64>() {
2314 Ok(age) if age < 31536000 => {
2315 issues.push(format!(
2316 "max-age ({age}) is below recommended minimum of 31536000 (1 year)"
2317 ));
2318 }
2319 Ok(_) => {} Err(_) => {
2321 issues.push("max-age is not a valid integer".to_string());
2322 }
2323 }
2324 }
2325
2326 issues
2327 }
2328
2329 fn compute_score(findings: &[Finding]) -> u32 {
2331 let mut score: i32 = 100;
2332 for f in findings {
2333 if f.code == "SEC012" {
2334 continue; }
2336 match f.severity {
2337 Severity::Critical => score -= 20,
2338 Severity::Error => score -= 10,
2339 Severity::Warning => score -= 5,
2340 Severity::Info => {}
2341 }
2342 }
2343 score.max(0) as u32
2344 }
2345}
2346
2347impl Default for SecurityHeaderAnalyzer {
2348 fn default() -> Self {
2349 Self::new()
2350 }
2351}
2352
2353impl Analyzer for SecurityHeaderAnalyzer {
2354 fn name(&self) -> &str {
2355 "security-headers"
2356 }
2357
2358 fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
2359 let mut findings = Vec::new();
2360 let url = &ctx.page.url;
2361 let headers = ctx.headers;
2362
2363 match Self::get_header(headers, "Content-Security-Policy") {
2365 None => {
2366 findings.push(Finding {
2367 severity: Severity::Warning,
2368 category: IssueCategory::Security,
2369 code: "SEC001".to_string(),
2370 title: "Missing Content-Security-Policy header".to_string(),
2371 description: "No Content-Security-Policy header was found. CSP helps prevent \
2372 XSS, clickjacking, and other code injection attacks."
2373 .to_string(),
2374 url: url.clone(),
2375 recommendation: "Implement a Content-Security-Policy header. Start with \
2376 \"default-src 'self'\" and refine as needed."
2377 .to_string(),
2378 });
2379 }
2380 Some(csp) => {
2381 if !Self::is_valid_csp(csp) {
2382 findings.push(Finding {
2383 severity: Severity::Warning,
2384 category: IssueCategory::Security,
2385 code: "SEC013".to_string(),
2386 title: "Invalid Content-Security-Policy syntax".to_string(),
2387 description: "The CSP header value does not appear to contain valid \
2388 directive syntax."
2389 .to_string(),
2390 url: url.clone(),
2391 recommendation: "Ensure CSP contains at least one valid directive \
2392 (e.g. default-src, script-src)."
2393 .to_string(),
2394 });
2395 }
2396 }
2397 }
2398
2399 match Self::get_header(headers, "Strict-Transport-Security") {
2401 None => {
2402 findings.push(Finding {
2403 severity: Severity::Warning,
2404 category: IssueCategory::Security,
2405 code: "SEC002".to_string(),
2406 title: "Missing Strict-Transport-Security header".to_string(),
2407 description: "No Strict-Transport-Security (HSTS) header was found. HSTS \
2408 forces browsers to use HTTPS."
2409 .to_string(),
2410 url: url.clone(),
2411 recommendation: "Add \"Strict-Transport-Security: max-age=31536000; \
2412 includeSubDomains; preload\"."
2413 .to_string(),
2414 });
2415 }
2416 Some(hsts) => {
2417 let hsts_issues = Self::validate_hsts(hsts);
2418 for issue in &hsts_issues {
2419 findings.push(Finding {
2420 severity: Severity::Warning,
2421 category: IssueCategory::Security,
2422 code: "SEC014".to_string(),
2423 title: "HSTS configuration issue".to_string(),
2424 description: format!("HSTS header: {issue}."),
2425 url: url.clone(),
2426 recommendation: "Set max-age to at least 31536000 (1 year). Add \
2427 includeSubDomains and preload."
2428 .to_string(),
2429 });
2430 }
2431 if !hsts.to_lowercase().contains("includesubdomains") {
2432 findings.push(Finding {
2433 severity: Severity::Info,
2434 category: IssueCategory::Security,
2435 code: "SEC015".to_string(),
2436 title: "HSTS missing includeSubDomains".to_string(),
2437 description: "The HSTS header does not include the includeSubDomains \
2438 directive."
2439 .to_string(),
2440 url: url.clone(),
2441 recommendation: "Add includeSubDomains to protect all subdomains."
2442 .to_string(),
2443 });
2444 }
2445 if !hsts.to_lowercase().contains("preload") {
2446 findings.push(Finding {
2447 severity: Severity::Info,
2448 category: IssueCategory::Security,
2449 code: "SEC016".to_string(),
2450 title: "HSTS missing preload".to_string(),
2451 description: "The HSTS header does not include the preload directive."
2452 .to_string(),
2453 url: url.clone(),
2454 recommendation: "Consider adding preload for browser HSTS preload \
2455 list inclusion."
2456 .to_string(),
2457 });
2458 }
2459 }
2460 }
2461
2462 match Self::get_header(headers, "X-Frame-Options") {
2464 None => {
2465 findings.push(Finding {
2466 severity: Severity::Warning,
2467 category: IssueCategory::Security,
2468 code: "SEC003".to_string(),
2469 title: "Missing X-Frame-Options header".to_string(),
2470 description: "No X-Frame-Options header was found. This header prevents \
2471 clickjacking by controlling frame embedding."
2472 .to_string(),
2473 url: url.clone(),
2474 recommendation: "Set X-Frame-Options to DENY or SAMEORIGIN.".to_string(),
2475 });
2476 }
2477 Some(value) => {
2478 let upper = value.to_uppercase().trim().to_string();
2479 if upper != "DENY" && upper != "SAMEORIGIN" {
2480 findings.push(Finding {
2481 severity: Severity::Warning,
2482 category: IssueCategory::Security,
2483 code: "SEC004".to_string(),
2484 title: "Invalid X-Frame-Options value".to_string(),
2485 description: format!(
2486 "X-Frame-Options is \"{value}\" but must be DENY or SAMEORIGIN."
2487 ),
2488 url: url.clone(),
2489 recommendation: "Set X-Frame-Options to DENY (preferred) or SAMEORIGIN."
2490 .to_string(),
2491 });
2492 }
2493 }
2494 }
2495
2496 match Self::get_header(headers, "X-Content-Type-Options") {
2498 None => {
2499 findings.push(Finding {
2500 severity: Severity::Warning,
2501 category: IssueCategory::Security,
2502 code: "SEC005".to_string(),
2503 title: "Missing X-Content-Type-Options header".to_string(),
2504 description: "No X-Content-Type-Options header was found. This header \
2505 prevents MIME-type sniffing."
2506 .to_string(),
2507 url: url.clone(),
2508 recommendation: "Set X-Content-Type-Options to nosniff.".to_string(),
2509 });
2510 }
2511 Some(value) => {
2512 if value.trim().to_lowercase() != "nosniff" {
2513 findings.push(Finding {
2514 severity: Severity::Warning,
2515 category: IssueCategory::Security,
2516 code: "SEC006".to_string(),
2517 title: "Invalid X-Content-Type-Options value".to_string(),
2518 description: format!(
2519 "X-Content-Type-Options is \"{value}\" but must be nosniff."
2520 ),
2521 url: url.clone(),
2522 recommendation: "Set X-Content-Type-Options to nosniff.".to_string(),
2523 });
2524 }
2525 }
2526 }
2527
2528 const RECOMMENDED_REFERRER: &[&str] = &[
2530 "no-referrer",
2531 "no-referrer-when-downgrade",
2532 "origin",
2533 "origin-when-cross-origin",
2534 "same-origin",
2535 "strict-origin",
2536 "strict-origin-when-cross-origin",
2537 "unsafe-url",
2538 ];
2539 match Self::get_header(headers, "Referrer-Policy") {
2540 None => {
2541 findings.push(Finding {
2542 severity: Severity::Info,
2543 category: IssueCategory::Security,
2544 code: "SEC007".to_string(),
2545 title: "Missing Referrer-Policy header".to_string(),
2546 description: "No Referrer-Policy header was found. This header controls how \
2547 much referrer information is sent with requests."
2548 .to_string(),
2549 url: url.clone(),
2550 recommendation: "Set Referrer-Policy to strict-origin-when-cross-origin \
2551 or no-referrer for maximum privacy."
2552 .to_string(),
2553 });
2554 }
2555 Some(value) => {
2556 if !RECOMMENDED_REFERRER.contains(&value.trim()) {
2557 findings.push(Finding {
2558 severity: Severity::Info,
2559 category: IssueCategory::Security,
2560 code: "SEC017".to_string(),
2561 title: "Uncommon Referrer-Policy value".to_string(),
2562 description: format!(
2563 "Referrer-Policy \"{value}\" is not in the list of commonly used \
2564 policies."
2565 ),
2566 url: url.clone(),
2567 recommendation: "Consider using strict-origin-when-cross-origin or \
2568 no-referrer."
2569 .to_string(),
2570 });
2571 }
2572 }
2573 }
2574
2575 if Self::get_header(headers, "Permissions-Policy").is_none() {
2577 findings.push(Finding {
2578 severity: Severity::Info,
2579 category: IssueCategory::Security,
2580 code: "SEC008".to_string(),
2581 title: "Missing Permissions-Policy header".to_string(),
2582 description: "No Permissions-Policy header was found. This header controls \
2583 which browser features APIs can be used."
2584 .to_string(),
2585 url: url.clone(),
2586 recommendation: "Consider setting Permissions-Policy to disable unused features \
2587 like camera, microphone, geolocation."
2588 .to_string(),
2589 });
2590 } else if let Some(pp) = Self::get_header(headers, "Permissions-Policy") {
2591 let dangerous = ["camera", "microphone", "geolocation"];
2593 for feature in &dangerous {
2594 if pp.to_lowercase().contains(feature)
2595 && pp.to_lowercase().contains(&format!("{feature}=()"))
2596 {
2597 } else if pp.to_lowercase().contains(feature) {
2599 findings.push(Finding {
2600 severity: Severity::Info,
2601 category: IssueCategory::Security,
2602 code: "SEC018".to_string(),
2603 title: format!("Permissions-Policy: {feature} not restricted"),
2604 description: format!(
2605 "The {feature} feature in Permissions-Policy is not explicitly \
2606 restricted."
2607 ),
2608 url: url.clone(),
2609 recommendation: format!(
2610 "Add {feature}=() to Permissions-Policy to disable it if not \
2611 needed."
2612 ),
2613 });
2614 }
2615 }
2616 }
2617
2618 if Self::get_header(headers, "Cross-Origin-Embedder-Policy").is_none() {
2620 findings.push(Finding {
2621 severity: Severity::Info,
2622 category: IssueCategory::Security,
2623 code: "SEC009".to_string(),
2624 title: "Missing Cross-Origin-Embedder-Policy header".to_string(),
2625 description: "No COEP header was found. COEP prevents resources from loading \
2626 cross-origin without explicit permission."
2627 .to_string(),
2628 url: url.clone(),
2629 recommendation: "Set COEP to require-corp for stricter cross-origin isolation."
2630 .to_string(),
2631 });
2632 }
2633
2634 if Self::get_header(headers, "Cross-Origin-Opener-Policy").is_none() {
2636 findings.push(Finding {
2637 severity: Severity::Info,
2638 category: IssueCategory::Security,
2639 code: "SEC010".to_string(),
2640 title: "Missing Cross-Origin-Opener-Policy header".to_string(),
2641 description: "No COOP header was found. COOP isolates your browsing context \
2642 from cross-origin popups."
2643 .to_string(),
2644 url: url.clone(),
2645 recommendation: "Set COOP to same-origin for stricter isolation.".to_string(),
2646 });
2647 }
2648
2649 if Self::get_header(headers, "Cross-Origin-Resource-Policy").is_none() {
2651 findings.push(Finding {
2652 severity: Severity::Info,
2653 category: IssueCategory::Security,
2654 code: "SEC011".to_string(),
2655 title: "Missing Cross-Origin-Resource-Policy header".to_string(),
2656 description: "No CORP header was found. CORP prevents cross-origin reads of \
2657 embedded resources."
2658 .to_string(),
2659 url: url.clone(),
2660 recommendation: "Set CORP to same-origin if the resource should only be used \
2661 by the same origin."
2662 .to_string(),
2663 });
2664 }
2665
2666 let score = Self::compute_score(&findings);
2668 findings.push(Finding {
2669 severity: Severity::Info,
2670 category: IssueCategory::Security,
2671 code: "SEC012".to_string(),
2672 title: "Security posture score".to_string(),
2673 description: format!("Security header score: {score}/100."),
2674 url: url.clone(),
2675 recommendation: if score < 50 {
2676 "Security posture is weak. Prioritize adding CSP, HSTS, and frame-protecting \
2677 headers."
2678 .to_string()
2679 } else if score < 80 {
2680 "Security posture is moderate. Address remaining missing headers.".to_string()
2681 } else {
2682 "Security posture is strong. Minor improvements possible.".to_string()
2683 },
2684 });
2685
2686 findings
2687 }
2688}
2689
2690pub struct SslCertificateValidator {
2695 cert_info: Option<SslCertificateInfo>,
2696}
2697
2698impl SslCertificateValidator {
2699 pub fn new(cert_info: Option<SslCertificateInfo>) -> Self {
2701 Self { cert_info }
2702 }
2703
2704 pub fn empty() -> Self {
2705 Self { cert_info: None }
2706 }
2707
2708 fn parse_epoch(s: &str) -> Option<i64> {
2710 chrono::DateTime::parse_from_rfc3339(s)
2711 .or_else(|_| chrono::DateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%SZ"))
2712 .ok()
2713 .map(|dt| dt.timestamp())
2714 }
2715
2716 fn is_weak_algorithm(algo: &str) -> bool {
2722 let lower = algo.to_lowercase();
2723 (lower.contains("md5") || lower.contains("sha1"))
2724 || (lower.contains("with rsa encryption")
2725 && !lower.contains("sha256")
2726 && !lower.contains("sha384")
2727 && !lower.contains("sha512"))
2728 }
2729}
2730
2731impl Default for SslCertificateValidator {
2732 fn default() -> Self {
2733 Self::empty()
2734 }
2735}
2736
2737impl Analyzer for SslCertificateValidator {
2738 fn name(&self) -> &str {
2739 "ssl-certificate"
2740 }
2741
2742 fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
2743 let mut findings = Vec::new();
2744 let url = &ctx.page.url;
2745
2746 let info = match &self.cert_info {
2747 Some(i) => i,
2748 None => {
2749 findings.push(Finding {
2750 severity: Severity::Info,
2751 category: IssueCategory::Security,
2752 code: "SSL007".to_string(),
2753 title: "No SSL certificate data available".to_string(),
2754 description: "No TLS certificate information was provided for validation."
2755 .to_string(),
2756 url: url.clone(),
2757 recommendation: "Provide certificate data from the TLS connection to enable \
2758 SSL validation."
2759 .to_string(),
2760 });
2761 return findings;
2762 }
2763 };
2764
2765 if let Some(ref not_after) = info.not_after {
2767 if let Some(expiry_epoch) = Self::parse_epoch(not_after) {
2768 let now = chrono::Utc::now().timestamp();
2769 if now > expiry_epoch {
2770 findings.push(Finding {
2771 severity: Severity::Critical,
2772 category: IssueCategory::Security,
2773 code: "SSL001".to_string(),
2774 title: "SSL certificate has expired".to_string(),
2775 description: format!(
2776 "Certificate expired on {not_after} ({} days ago).",
2777 (now - expiry_epoch) / 86400
2778 ),
2779 url: url.clone(),
2780 recommendation: "Renew the SSL certificate immediately. Expired \
2781 certificates cause browser security warnings."
2782 .to_string(),
2783 });
2784 } else {
2785 let days_left = (expiry_epoch - now) / 86400;
2786 if days_left < 30 {
2787 findings.push(Finding {
2788 severity: Severity::Warning,
2789 category: IssueCategory::Security,
2790 code: "SSL002".to_string(),
2791 title: "SSL certificate expiring soon".to_string(),
2792 description: format!(
2793 "Certificate expires on {not_after} ({days_left} days remaining)."
2794 ),
2795 url: url.clone(),
2796 recommendation: "Renew the certificate before it expires. Set up \
2797 auto-renewal (e.g. Let's Encrypt certbot)."
2798 .to_string(),
2799 });
2800 }
2801 }
2802 }
2803 }
2804
2805 if !info.is_valid_chain {
2807 findings.push(Finding {
2808 severity: Severity::Critical,
2809 category: IssueCategory::Security,
2810 code: "SSL003".to_string(),
2811 title: "Invalid certificate chain".to_string(),
2812 description: "The TLS certificate chain did not validate successfully. Browsers \
2813 will show a security warning."
2814 .to_string(),
2815 url: url.clone(),
2816 recommendation: "Ensure the full certificate chain (including intermediate \
2817 certificates) is properly installed."
2818 .to_string(),
2819 });
2820 }
2821
2822 if info.is_self_signed {
2824 findings.push(Finding {
2825 severity: Severity::Error,
2826 category: IssueCategory::Security,
2827 code: "SSL006".to_string(),
2828 title: "Self-signed certificate detected".to_string(),
2829 description: "The certificate is self-signed and will not be trusted by browsers."
2830 .to_string(),
2831 url: url.clone(),
2832 recommendation: "Use a certificate signed by a trusted Certificate Authority. \
2833 Consider Let's Encrypt for free trusted certificates."
2834 .to_string(),
2835 });
2836 }
2837
2838 let page_host = Url::parse(url)
2840 .ok()
2841 .and_then(|u| u.host_str().map(String::from));
2842 if let Some(ref host) = page_host {
2843 let mut matched = false;
2844 if let Some(ref subject) = info.subject {
2845 if subject.eq_ignore_ascii_case(host) {
2846 matched = true;
2847 }
2848 if let Some(wildcard_domain) = subject.strip_prefix("*.") {
2850 if let Some(stripped_host) = host.strip_prefix('*') {
2851 if stripped_host == wildcard_domain {
2852 matched = true;
2853 }
2854 }
2855 let parts: Vec<&str> = host.split('.').collect();
2857 if parts.len() > 1 {
2858 let root = parts[1..].join(".");
2859 if wildcard_domain == root {
2860 matched = true;
2861 }
2862 }
2863 }
2864 }
2865 for san in &info.san_entries {
2866 if san.eq_ignore_ascii_case(host) {
2867 matched = true;
2868 break;
2869 }
2870 if let Some(wildcard_domain) = san.strip_prefix("*.") {
2872 let parts: Vec<&str> = host.split('.').collect();
2873 if parts.len() > 1 {
2874 let root = parts[1..].join(".");
2875 if wildcard_domain == root {
2876 matched = true;
2877 break;
2878 }
2879 }
2880 }
2881 }
2882 if !matched && !info.san_entries.is_empty() {
2883 findings.push(Finding {
2884 severity: Severity::Error,
2885 category: IssueCategory::Security,
2886 code: "SSL004".to_string(),
2887 title: "Subject/SAN does not match hostname".to_string(),
2888 description: format!(
2889 "Certificate subject {:?} and SANs {:?} do not match hostname \"{host}\".",
2890 info.subject, info.san_entries,
2891 ),
2892 url: url.clone(),
2893 recommendation: "Issue a certificate that includes the correct hostname in \
2894 the Subject CN or Subject Alternative Names."
2895 .to_string(),
2896 });
2897 }
2898 }
2899
2900 if let Some(ref algo) = info.signature_algorithm {
2902 if Self::is_weak_algorithm(algo) {
2903 findings.push(Finding {
2904 severity: Severity::Warning,
2905 category: IssueCategory::Security,
2906 code: "SSL005".to_string(),
2907 title: "Weak signature algorithm".to_string(),
2908 description: format!(
2909 "Certificate uses signature algorithm \"{algo}\", which is considered \
2910 weak."
2911 ),
2912 url: url.clone(),
2913 recommendation: "Reissue the certificate with SHA-256 or stronger signature \
2914 algorithm."
2915 .to_string(),
2916 });
2917 }
2918 }
2919
2920 findings.push(Finding {
2922 severity: Severity::Info,
2923 category: IssueCategory::Security,
2924 code: "SSL008".to_string(),
2925 title: "SSL certificate details".to_string(),
2926 description: format!(
2927 "Subject: {}, Issuer: {}, SANs: {}, Chain valid: {}, Self-signed: {}",
2928 info.subject.as_deref().unwrap_or("N/A"),
2929 info.issuer.as_deref().unwrap_or("N/A"),
2930 info.san_entries.len(),
2931 info.is_valid_chain,
2932 info.is_self_signed,
2933 ),
2934 url: url.clone(),
2935 recommendation: String::new(),
2936 });
2937
2938 findings
2939 }
2940}
2941
2942pub struct MobileFriendlinessChecker;
2947
2948impl MobileFriendlinessChecker {
2949 pub fn new() -> Self {
2950 Self
2951 }
2952
2953 fn parse_viewport(viewport: &str) -> HashMap<String, String> {
2955 let mut map = HashMap::new();
2956 for part in viewport.split(',') {
2957 let part = part.trim();
2958 if let Some((key, value)) = part.split_once('=') {
2959 map.insert(key.trim().to_lowercase(), value.trim().to_lowercase());
2960 }
2961 }
2962 map
2963 }
2964}
2965
2966impl Default for MobileFriendlinessChecker {
2967 fn default() -> Self {
2968 Self::new()
2969 }
2970}
2971
2972impl Analyzer for MobileFriendlinessChecker {
2973 fn name(&self) -> &str {
2974 "mobile-friendliness"
2975 }
2976
2977 fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
2978 let mut findings = Vec::new();
2979 let url = &ctx.page.url;
2980
2981 let viewport = match &ctx.page.meta.viewport {
2983 Some(v) => v,
2984 None => {
2985 findings.push(Finding {
2986 severity: Severity::Error,
2987 category: IssueCategory::Mobile,
2988 code: "MOB001".to_string(),
2989 title: "Missing viewport meta tag".to_string(),
2990 description: "No viewport meta tag was found. Without it, the page will not \
2991 scale properly on mobile devices."
2992 .to_string(),
2993 url: url.clone(),
2994 recommendation: "Add <meta name=\"viewport\" content=\"width=device-width, \
2995 initial-scale=1\"> to the <head>."
2996 .to_string(),
2997 });
2998 return findings;
2999 }
3000 };
3001
3002 let directives = Self::parse_viewport(viewport);
3003
3004 match directives.get("width") {
3006 None => {
3007 findings.push(Finding {
3008 severity: Severity::Warning,
3009 category: IssueCategory::Mobile,
3010 code: "MOB002".to_string(),
3011 title: "Viewport missing width directive".to_string(),
3012 description: "The viewport meta tag does not specify width=device-width."
3013 .to_string(),
3014 url: url.clone(),
3015 recommendation: "Set width=device-width in the viewport meta tag.".to_string(),
3016 });
3017 }
3018 Some(w) if w != "device-width" => {
3019 findings.push(Finding {
3020 severity: Severity::Error,
3021 category: IssueCategory::Mobile,
3022 code: "MOB003".to_string(),
3023 title: "Viewport width is not device-width".to_string(),
3024 description: format!(
3025 "Viewport width is set to \"{w}\" instead of device-width. This forces \
3026 a fixed layout."
3027 ),
3028 url: url.clone(),
3029 recommendation: "Change viewport width to device-width for responsive layout."
3030 .to_string(),
3031 });
3032 }
3033 _ => {} }
3035
3036 if directives.get("user-scalable") == Some(&"no".to_string()) {
3038 findings.push(Finding {
3039 severity: Severity::Error,
3040 category: IssueCategory::Mobile,
3041 code: "MOB004".to_string(),
3042 title: "Zooming is disabled (user-scalable=no)".to_string(),
3043 description: "The viewport meta tag disables user zooming with \
3044 user-scalable=no. This is a critical accessibility issue."
3045 .to_string(),
3046 url: url.clone(),
3047 recommendation: "Remove user-scalable=no to allow pinch-to-zoom. This is \
3048 required for WCAG 2.1 compliance."
3049 .to_string(),
3050 });
3051 }
3052
3053 if let Some(max_scale) = directives.get("maximum-scale") {
3054 if max_scale == "1" || max_scale == "1.0" {
3055 findings.push(Finding {
3056 severity: Severity::Warning,
3057 category: IssueCategory::Mobile,
3058 code: "MOB005".to_string(),
3059 title: "Maximum scale restricted".to_string(),
3060 description: format!(
3061 "maximum-scale={max_scale} limits zooming. While not as severe as \
3062 user-scalable=no, it can still hinder accessibility."
3063 ),
3064 url: url.clone(),
3065 recommendation: "Remove the maximum-scale constraint or set it to at least \
3066 5.0."
3067 .to_string(),
3068 });
3069 }
3070 }
3071
3072 if let Some(scale) = directives.get("initial-scale") {
3074 if scale != "1" && scale != "1.0" {
3075 findings.push(Finding {
3076 severity: Severity::Info,
3077 category: IssueCategory::Mobile,
3078 code: "MOB009".to_string(),
3079 title: "Non-standard initial scale".to_string(),
3080 description: format!(
3081 "initial-scale is set to \"{scale}\" instead of the standard 1.0. This \
3082 may cause unexpected zoom behavior on page load."
3083 ),
3084 url: url.clone(),
3085 recommendation: "Set initial-scale=1 for a consistent mobile experience."
3086 .to_string(),
3087 });
3088 }
3089 }
3090
3091 findings
3097 }
3098}
3099
3100pub struct AccessibilityAnalyzer;
3105
3106impl AccessibilityAnalyzer {
3107 pub fn new() -> Self {
3108 Self
3109 }
3110
3111 const VAGUE_LINK_TEXTS: &[&str] = &[
3113 "click here",
3114 "here",
3115 "read more",
3116 "more",
3117 "learn more",
3118 "click",
3119 "link",
3120 "this",
3121 "go",
3122 "continue",
3123 ];
3124}
3125
3126impl Default for AccessibilityAnalyzer {
3127 fn default() -> Self {
3128 Self::new()
3129 }
3130}
3131
3132impl Analyzer for AccessibilityAnalyzer {
3133 fn name(&self) -> &str {
3134 "accessibility"
3135 }
3136
3137 fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
3138 let mut findings = Vec::new();
3139 let url = &ctx.page.url;
3140
3141 let images_without_alt: Vec<&str> = ctx
3143 .page
3144 .images
3145 .iter()
3146 .filter(|img| !img.has_alt || img.alt.trim().is_empty())
3147 .map(|img| img.src.as_str())
3148 .collect();
3149 if !images_without_alt.is_empty() {
3150 findings.push(Finding {
3151 severity: Severity::Error,
3152 category: IssueCategory::Accessibility,
3153 code: "A11Y001".to_string(),
3154 title: "Images missing alt text".to_string(),
3155 description: format!(
3156 "{} image(s) missing alt attribute or have empty alt text: {}.",
3157 images_without_alt.len(),
3158 images_without_alt.join(", ")
3159 ),
3160 url: url.clone(),
3161 recommendation: "Add descriptive alt text to all images. Use alt=\"\" for \
3162 decorative images."
3163 .to_string(),
3164 });
3165 }
3166
3167 if ctx.page.headings.is_empty() {
3169 findings.push(Finding {
3170 severity: Severity::Warning,
3171 category: IssueCategory::Accessibility,
3172 code: "A11Y002".to_string(),
3173 title: "No headings found".to_string(),
3174 description: "The page has no heading elements. Headings provide structure \
3175 for screen reader users."
3176 .to_string(),
3177 url: url.clone(),
3178 recommendation: "Add heading elements (H1-H6) to provide page structure."
3179 .to_string(),
3180 });
3181 } else {
3182 let h1_count = ctx.page.headings.iter().filter(|h| h.level == 1).count();
3183 if h1_count == 0 {
3184 findings.push(Finding {
3185 severity: Severity::Error,
3186 category: IssueCategory::Accessibility,
3187 code: "A11Y003".to_string(),
3188 title: "Missing H1 heading".to_string(),
3189 description: "No H1 heading found. Screen readers use H1 to identify \
3190 the main page topic."
3191 .to_string(),
3192 url: url.clone(),
3193 recommendation: "Add exactly one H1 heading per page.".to_string(),
3194 });
3195 } else if h1_count > 1 {
3196 findings.push(Finding {
3197 severity: Severity::Warning,
3198 category: IssueCategory::Accessibility,
3199 code: "A11Y004".to_string(),
3200 title: "Multiple H1 headings".to_string(),
3201 description: format!(
3202 "Page has {h1_count} H1 headings. Use a single H1 for the main topic."
3203 ),
3204 url: url.clone(),
3205 recommendation: "Use one H1 for the page title and H2+ for sections."
3206 .to_string(),
3207 });
3208 }
3209
3210 let mut prev_level: Option<u8> = None;
3212 for heading in &ctx.page.headings {
3213 if let Some(prev) = prev_level {
3214 if heading.level > prev + 1 {
3215 findings.push(Finding {
3216 severity: Severity::Warning,
3217 category: IssueCategory::Accessibility,
3218 code: "A11Y005".to_string(),
3219 title: "Skipped heading level".to_string(),
3220 description: format!(
3221 "Heading jumps from H{prev} to H{}, skipping intermediate \
3222 levels.",
3223 heading.level
3224 ),
3225 url: url.clone(),
3226 recommendation: format!(
3227 "Use H{} after H{prev} to maintain document outline.",
3228 prev + 1
3229 ),
3230 });
3231 break; }
3233 }
3234 prev_level = Some(heading.level);
3235 }
3236 }
3237
3238 if !ctx.page.has_main_landmark {
3240 findings.push(Finding {
3241 severity: Severity::Warning,
3242 category: IssueCategory::Accessibility,
3243 code: "A11Y006".to_string(),
3244 title: "Missing main landmark".to_string(),
3245 description: "No <main> element or role=\"main\" found. Screen readers use \
3246 landmarks for page navigation."
3247 .to_string(),
3248 url: url.clone(),
3249 recommendation: "Wrap primary content in a <main> element.".to_string(),
3250 });
3251 }
3252
3253 if !ctx.page.has_nav_landmark {
3254 findings.push(Finding {
3255 severity: Severity::Info,
3256 category: IssueCategory::Accessibility,
3257 code: "A11Y007".to_string(),
3258 title: "No navigation landmark".to_string(),
3259 description: "No <nav> element or role=\"navigation\" found.".to_string(),
3260 url: url.clone(),
3261 recommendation: "Wrap navigation links in a <nav> element.".to_string(),
3262 });
3263 }
3264
3265 if !ctx.page.has_skip_link && ctx.page.has_nav_landmark {
3267 findings.push(Finding {
3268 severity: Severity::Warning,
3269 category: IssueCategory::Accessibility,
3270 code: "A11Y008".to_string(),
3271 title: "Missing skip navigation link".to_string(),
3272 description: "No skip-to-content link found. Keyboard users must tab through \
3273 all navigation links to reach main content."
3274 .to_string(),
3275 url: url.clone(),
3276 recommendation: "Add a skip link as the first focusable element: \
3277 <a href=\"#main\" class=\"skip-link\">Skip to content</a>."
3278 .to_string(),
3279 });
3280 }
3281
3282 for link in &ctx.page.links {
3284 let text_lower = link.text.trim().to_lowercase();
3285 let has_accessible_name = !text_lower.is_empty()
3287 || link
3288 .aria_label
3289 .as_ref()
3290 .is_some_and(|l| !l.trim().is_empty())
3291 || link.img_alt.as_ref().is_some_and(|a| !a.trim().is_empty());
3292 if !has_accessible_name {
3293 findings.push(Finding {
3294 severity: Severity::Error,
3295 category: IssueCategory::Accessibility,
3296 code: "A11Y009".to_string(),
3297 title: "Empty link text".to_string(),
3298 description: format!(
3299 "Link to \"{}\" has no text. Screen readers announce the URL, \
3300 which is not descriptive.",
3301 link.href
3302 ),
3303 url: url.clone(),
3304 recommendation: "Add descriptive text or an aria-label to the link."
3305 .to_string(),
3306 });
3307 } else if Self::VAGUE_LINK_TEXTS.contains(&text_lower.as_str()) {
3308 findings.push(Finding {
3309 severity: Severity::Warning,
3310 category: IssueCategory::Accessibility,
3311 code: "A11Y010".to_string(),
3312 title: "Non-descriptive link text".to_string(),
3313 description: format!(
3314 "Link text \"{}\" is vague and does not describe the destination.",
3315 link.text
3316 ),
3317 url: url.clone(),
3318 recommendation: "Use descriptive text that explains the link purpose \
3319 (e.g., \"View pricing details\" instead of \"click here\")."
3320 .to_string(),
3321 });
3322 }
3323 }
3324
3325 for form in &ctx.page.forms {
3327 for input in &form.inputs {
3328 if !input.has_label {
3329 let desc = match (&input.name, &input.input_type) {
3330 (Some(n), Some(t)) => format!("input (name=\"{n}\", type=\"{t}\")"),
3331 (Some(n), None) => format!("input (name=\"{n}\")"),
3332 (None, Some(t)) => format!("input (type=\"{t}\")"),
3333 (None, None) => "input".to_string(),
3334 };
3335 findings.push(Finding {
3336 severity: Severity::Error,
3337 category: IssueCategory::Accessibility,
3338 code: "A11Y011".to_string(),
3339 title: "Form input missing label".to_string(),
3340 description: format!(
3341 "{desc} has no associated <label>, aria-label, or aria-labelledby."
3342 ),
3343 url: url.clone(),
3344 recommendation: "Add a <label for=\"id\"> element or an aria-label \
3345 attribute to the input."
3346 .to_string(),
3347 });
3348 }
3349 }
3350 }
3351
3352 if ctx.page.has_positive_tabindex {
3354 findings.push(Finding {
3355 severity: Severity::Error,
3356 category: IssueCategory::Accessibility,
3357 code: "A11Y012".to_string(),
3358 title: "Positive tabindex values detected".to_string(),
3359 description: "Elements with tabindex > 0 alter the natural tab order, \
3360 making keyboard navigation unpredictable."
3361 .to_string(),
3362 url: url.clone(),
3363 recommendation: "Use tabindex=\"0\" to add elements to the natural tab order \
3364 or tabindex=\"-1\" for programmatic focus only."
3365 .to_string(),
3366 });
3367 }
3368
3369 if ctx.page.aria_role_count == 0 && !ctx.page.landmarks.is_empty() {
3371 } else if ctx.page.aria_role_count > 0 && ctx.page.aria_label_count == 0 {
3373 findings.push(Finding {
3374 severity: Severity::Warning,
3375 category: IssueCategory::Accessibility,
3376 code: "A11Y013".to_string(),
3377 title: "ARIA roles without labels".to_string(),
3378 description: format!(
3379 "{} ARIA role(s) found but no aria-label or aria-labelledby attributes. \
3380 Custom roles require accessible names.",
3381 ctx.page.aria_role_count
3382 ),
3383 url: url.clone(),
3384 recommendation: "Add aria-label or aria-labelledby to elements with custom \
3385 ARIA roles."
3386 .to_string(),
3387 });
3388 }
3389
3390 if ctx.page.tables_total > 0 {
3392 let tables_without_headers = ctx.page.tables_total - ctx.page.tables_with_headers;
3393 if tables_without_headers > 0 {
3394 findings.push(Finding {
3395 severity: Severity::Warning,
3396 category: IssueCategory::Accessibility,
3397 code: "A11Y014".to_string(),
3398 title: "Table missing header cells".to_string(),
3399 description: format!(
3400 "{} of {} table(s) have no <th> header cells.",
3401 tables_without_headers, ctx.page.tables_total
3402 ),
3403 url: url.clone(),
3404 recommendation: "Use <th> elements for header cells and add scope \
3405 attributes for complex tables."
3406 .to_string(),
3407 });
3408 }
3409
3410 let tables_without_captions = ctx.page.tables_total - ctx.page.tables_with_captions;
3411 if tables_without_captions > 0 {
3412 findings.push(Finding {
3413 severity: Severity::Info,
3414 category: IssueCategory::Accessibility,
3415 code: "A11Y015".to_string(),
3416 title: "Table missing caption".to_string(),
3417 description: format!(
3418 "{} of {} table(s) have no <caption> element.",
3419 tables_without_captions, ctx.page.tables_total
3420 ),
3421 url: url.clone(),
3422 recommendation: "Add a <caption> to describe the table purpose.".to_string(),
3423 });
3424 }
3425 }
3426
3427 if !ctx.page.has_lang_attribute {
3429 findings.push(Finding {
3430 severity: Severity::Error,
3431 category: IssueCategory::Accessibility,
3432 code: "A11Y016".to_string(),
3433 title: "Missing html lang attribute".to_string(),
3434 description: "The <html> element has no lang attribute. Screen readers use \
3435 this to select the correct pronunciation engine."
3436 .to_string(),
3437 url: url.clone(),
3438 recommendation: "Add lang=\"en\" (or the appropriate language code) to the \
3439 <html> element."
3440 .to_string(),
3441 });
3442 }
3443
3444 findings
3445 }
3446}
3447
3448pub struct SocialMediaAnalyzer;
3453
3454impl SocialMediaAnalyzer {
3455 pub fn new() -> Self {
3456 Self
3457 }
3458
3459 const OG_MIN_WIDTH: u32 = 1200;
3461 const OG_MIN_HEIGHT: u32 = 630;
3463}
3464
3465impl Default for SocialMediaAnalyzer {
3466 fn default() -> Self {
3467 Self::new()
3468 }
3469}
3470
3471impl Analyzer for SocialMediaAnalyzer {
3472 fn name(&self) -> &str {
3473 "social-media"
3474 }
3475
3476 fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
3477 let mut findings = Vec::new();
3478 let url = &ctx.page.url;
3479
3480 if ctx.page.og_image_width.is_none() && ctx.page.og_image_height.is_none() {
3482 if ctx.page.meta.og.image.is_some() {
3484 findings.push(Finding {
3485 severity: Severity::Warning,
3486 category: IssueCategory::Social,
3487 code: "SOCIAL001".to_string(),
3488 title: "OG image missing dimensions".to_string(),
3489 description: "og:image is set but og:image:width and og:image:height \
3490 are missing. Social platforms may crop or scale the image \
3491 incorrectly."
3492 .to_string(),
3493 url: url.clone(),
3494 recommendation: "Add <meta property=\"og:image:width\" content=\"1200\"> \
3495 and <meta property=\"og:image:height\" content=\"630\">."
3496 .to_string(),
3497 });
3498 }
3499 } else {
3500 if let Some(width) = ctx.page.og_image_width {
3502 if width < Self::OG_MIN_WIDTH {
3503 findings.push(Finding {
3504 severity: Severity::Warning,
3505 category: IssueCategory::Social,
3506 code: "SOCIAL002".to_string(),
3507 title: "OG image too narrow".to_string(),
3508 description: format!(
3509 "og:image:width is {width}px, below the recommended minimum of \
3510 {}px.",
3511 Self::OG_MIN_WIDTH
3512 ),
3513 url: url.clone(),
3514 recommendation: format!(
3515 "Use an image at least {}x{} pixels for optimal social previews.",
3516 Self::OG_MIN_WIDTH,
3517 Self::OG_MIN_HEIGHT
3518 ),
3519 });
3520 }
3521 }
3522 if let Some(height) = ctx.page.og_image_height {
3523 if height < Self::OG_MIN_HEIGHT {
3524 findings.push(Finding {
3525 severity: Severity::Warning,
3526 category: IssueCategory::Social,
3527 code: "SOCIAL003".to_string(),
3528 title: "OG image too short".to_string(),
3529 description: format!(
3530 "og:image:height is {height}px, below the recommended minimum of \
3531 {}px.",
3532 Self::OG_MIN_HEIGHT
3533 ),
3534 url: url.clone(),
3535 recommendation: format!(
3536 "Use an image at least {}x{} pixels for optimal social previews.",
3537 Self::OG_MIN_WIDTH,
3538 Self::OG_MIN_HEIGHT
3539 ),
3540 });
3541 }
3542 }
3543 }
3544
3545 match &ctx.page.meta.twitter.card {
3547 None => {
3548 findings.push(Finding {
3549 severity: Severity::Warning,
3550 category: IssueCategory::Social,
3551 code: "SOCIAL004".to_string(),
3552 title: "Missing Twitter Card type".to_string(),
3553 description: "No twitter:card meta tag found. Twitter/X will not render \
3554 a rich preview without it."
3555 .to_string(),
3556 url: url.clone(),
3557 recommendation:
3558 "Add <meta name=\"twitter:card\" content=\"summary_large_image\">."
3559 .to_string(),
3560 });
3561 }
3562 Some(card_type) => {
3563 let valid_types = ["summary", "summary_large_image", "app", "player"];
3564 if !valid_types.contains(&card_type.as_str()) {
3565 findings.push(Finding {
3566 severity: Severity::Warning,
3567 category: IssueCategory::Social,
3568 code: "SOCIAL005".to_string(),
3569 title: "Invalid Twitter Card type".to_string(),
3570 description: format!(
3571 "twitter:card value \"{card_type}\" is not a recognized card type."
3572 ),
3573 url: url.clone(),
3574 recommendation: "Use one of: summary, summary_large_image, app, player."
3575 .to_string(),
3576 });
3577 }
3578 }
3579 }
3580
3581 let og_required = [
3583 ("og:title", ctx.page.meta.og.title.is_some()),
3584 ("og:description", ctx.page.meta.og.description.is_some()),
3585 ("og:image", ctx.page.meta.og.image.is_some()),
3586 ("og:url", ctx.page.meta.og.url.is_some()),
3587 ("og:type", ctx.page.meta.og.r#type.is_some()),
3588 ];
3589
3590 let missing_og: Vec<&str> = og_required
3591 .iter()
3592 .filter(|(_, present)| !present)
3593 .map(|(name, _)| *name)
3594 .collect();
3595
3596 if !missing_og.is_empty() {
3597 findings.push(Finding {
3598 severity: Severity::Warning,
3599 category: IssueCategory::Social,
3600 code: "SOCIAL006".to_string(),
3601 title: "Incomplete Open Graph tags".to_string(),
3602 description: format!(
3603 "Missing OG tags: {}. Social previews may be incomplete.",
3604 missing_og.join(", ")
3605 ),
3606 url: url.clone(),
3607 recommendation: "Add all required OG tags for complete social media previews."
3608 .to_string(),
3609 });
3610 }
3611
3612 let twitter_required = [
3613 ("twitter:title", ctx.page.meta.twitter.title.is_some()),
3614 (
3615 "twitter:description",
3616 ctx.page.meta.twitter.description.is_some(),
3617 ),
3618 ("twitter:image", ctx.page.meta.twitter.image.is_some()),
3619 ];
3620
3621 let missing_twitter: Vec<&str> = twitter_required
3622 .iter()
3623 .filter(|(_, present)| !present)
3624 .map(|(name, _)| *name)
3625 .collect();
3626
3627 if !missing_twitter.is_empty() {
3628 findings.push(Finding {
3629 severity: Severity::Info,
3630 category: IssueCategory::Social,
3631 code: "SOCIAL007".to_string(),
3632 title: "Incomplete Twitter Card tags".to_string(),
3633 description: format!(
3634 "Missing Twitter tags: {}. Twitter/X previews may fall back to OG tags.",
3635 missing_twitter.join(", ")
3636 ),
3637 url: url.clone(),
3638 recommendation: "Add Twitter-specific tags for optimal X/Twitter previews."
3639 .to_string(),
3640 });
3641 }
3642
3643 let og_score = og_required.iter().filter(|(_, p)| *p).count();
3645 let twitter_score = twitter_required.iter().filter(|(_, p)| *p).count();
3646 let total = og_required.len() + twitter_required.len();
3647 let score = og_score + twitter_score;
3648
3649 findings.push(Finding {
3650 severity: Severity::Info,
3651 category: IssueCategory::Social,
3652 code: "SOCIAL008".to_string(),
3653 title: "Social preview completeness score".to_string(),
3654 description: format!(
3655 "Social metadata score: {score}/{total} (OG: {og_score}/{og_len}, Twitter: \
3656 {twitter_score}/{tw_len}).",
3657 og_len = og_required.len(),
3658 tw_len = twitter_required.len(),
3659 ),
3660 url: url.clone(),
3661 recommendation: if score < total {
3662 "Add missing social meta tags to improve how your page appears when shared."
3663 .to_string()
3664 } else {
3665 "Social metadata is complete.".to_string()
3666 },
3667 });
3668
3669 findings
3670 }
3671}
3672
3673const PERSON_INDICATORS: &[&str] = &[
3678 "mr.",
3679 "mrs.",
3680 "ms.",
3681 "dr.",
3682 "prof.",
3683 "sir",
3684 "lord",
3685 "president",
3686 "ceo",
3687 "cto",
3688 "founder",
3689 "author",
3690 "by",
3691 "written by",
3692 "edited by",
3693 "interview with",
3694];
3695
3696const ORG_INDICATORS: &[&str] = &[
3697 "inc.",
3698 "llc",
3699 "ltd.",
3700 "corp.",
3701 "corporation",
3702 "company",
3703 "organization",
3704 "university",
3705 "institute",
3706 "foundation",
3707 "association",
3708 "group",
3709 "partners",
3710];
3711
3712const LOCATION_INDICATORS: &[&str] = &[
3713 "city",
3714 "state",
3715 "country",
3716 "province",
3717 "district",
3718 "region",
3719 "street",
3720 "avenue",
3721 "boulevard",
3722 "road",
3723 "lane",
3724 "square",
3725 "park",
3726 "mountain",
3727 "river",
3728 "lake",
3729 "island",
3730 "bay",
3731 "coast",
3732 "valley",
3733];
3734
3735pub struct EntityAnalyzer;
3736
3737impl EntityAnalyzer {
3738 pub fn new() -> Self {
3739 Self
3740 }
3741
3742 fn detect_people(text: &str) -> Vec<String> {
3743 let mut found = Vec::new();
3744 let lower = text.to_lowercase();
3745 for indicator in PERSON_INDICATORS {
3746 if lower.contains(indicator) {
3747 let words: Vec<&str> = text.split_whitespace().collect();
3748 let indicator_word = indicator.trim_end_matches('.');
3749 for (i, word) in words.iter().enumerate() {
3750 let clean: String = word.chars().filter(|c| c.is_alphanumeric()).collect();
3751 if clean.to_lowercase() == indicator_word && i + 1 < words.len() {
3752 let mut name_parts = Vec::new();
3753 for w in words.iter().skip(i + 1) {
3754 let w_clean: String = w
3755 .chars()
3756 .filter(|c| c.is_alphanumeric() || *c == '-')
3757 .collect();
3758 let w_lower = w_clean.to_lowercase();
3759 if w_clean
3760 .chars()
3761 .next()
3762 .map(|c| c.is_uppercase())
3763 .unwrap_or(false)
3764 || w_lower == "de"
3765 || w_lower == "van"
3766 || w_lower == "von"
3767 || w_lower == "la"
3768 || w_lower == "le"
3769 {
3770 name_parts.push(w_clean);
3771 } else {
3772 break;
3773 }
3774 }
3775 let name = name_parts.join(" ");
3776 if !name.is_empty() {
3777 found.push(name);
3778 }
3779 }
3780 }
3781 }
3782 }
3783 found.sort();
3784 found.dedup();
3785 found
3786 }
3787
3788 fn detect_organizations(text: &str) -> Vec<String> {
3789 let mut found = Vec::new();
3790 let lower = text.to_lowercase();
3791 for indicator in ORG_INDICATORS {
3792 if lower.contains(indicator) {
3793 for sentence in text.split(['.', '!', '?']) {
3794 let words: Vec<&str> = sentence.split_whitespace().collect();
3795 for (i, word) in words.iter().enumerate() {
3796 if word.to_lowercase().contains(indicator) {
3797 let start = i.saturating_sub(2);
3798 let org: String = words[start..=i.min(words.len() - 1)]
3799 .iter()
3800 .map(|w| w.to_string())
3801 .collect::<Vec<_>>()
3802 .join(" ");
3803 if org.len() > 3 {
3804 found.push(org);
3805 }
3806 }
3807 }
3808 }
3809 }
3810 }
3811 found.sort();
3812 found.dedup();
3813 found
3814 }
3815
3816 fn detect_locations(text: &str) -> Vec<String> {
3817 let mut found = Vec::new();
3818 let lower = text.to_lowercase();
3819 for indicator in LOCATION_INDICATORS {
3820 if lower.contains(indicator) {
3821 for sentence in text.split(['.', '!', '?']) {
3822 let words: Vec<&str> = sentence.split_whitespace().collect();
3823 for (i, word) in words.iter().enumerate() {
3824 if word.to_lowercase().contains(indicator) {
3825 let start = i.saturating_sub(2);
3826 let loc: String = words[start..=i.min(words.len() - 1)]
3827 .iter()
3828 .map(|w| w.to_string())
3829 .collect::<Vec<_>>()
3830 .join(" ");
3831 if loc.len() > 3 {
3832 found.push(loc);
3833 }
3834 }
3835 }
3836 }
3837 }
3838 }
3839 found.sort();
3840 found.dedup();
3841 found
3842 }
3843
3844 fn detect_topics(headings: &[crate::parser::Heading], word_count: usize) -> Vec<String> {
3845 if word_count == 0 {
3846 return Vec::new();
3847 }
3848 let mut freq: HashMap<String, usize> = HashMap::new();
3849 for heading in headings {
3850 for word in heading.text.split_whitespace() {
3851 let lower = word
3852 .to_lowercase()
3853 .chars()
3854 .filter(|c| c.is_alphanumeric())
3855 .collect::<String>();
3856 if lower.len() > 3 && !STOP_WORDS.contains(&lower.as_str()) {
3857 *freq.entry(lower).or_default() += 1;
3858 }
3859 }
3860 }
3861 let mut terms: Vec<(String, usize)> = freq.into_iter().collect();
3862 terms.sort_by_key(|b| std::cmp::Reverse(b.1));
3863 terms.into_iter().take(5).map(|(w, _)| w).collect()
3864 }
3865
3866 fn analyze_sentiment(text: &str) -> (f64, &'static str) {
3867 let positive_words = [
3868 "good",
3869 "great",
3870 "excellent",
3871 "amazing",
3872 "wonderful",
3873 "best",
3874 "love",
3875 "happy",
3876 "fantastic",
3877 "superb",
3878 "outstanding",
3879 "perfect",
3880 "beautiful",
3881 "brilliant",
3882 "awesome",
3883 "nice",
3884 "pleasant",
3885 "delightful",
3886 "impressive",
3887 "remarkable",
3888 "magnificent",
3889 "splendid",
3890 "fabulous",
3891 "terrific",
3892 ];
3893 let negative_words = [
3894 "bad",
3895 "terrible",
3896 "horrible",
3897 "awful",
3898 "worst",
3899 "hate",
3900 "sad",
3901 "ugly",
3902 "poor",
3903 "disappointing",
3904 "boring",
3905 "annoying",
3906 "frustrating",
3907 "difficult",
3908 "broken",
3909 "failed",
3910 "error",
3911 "wrong",
3912 "problem",
3913 "issue",
3914 "bug",
3915 "fail",
3916 "crash",
3917 "dead",
3918 ];
3919 let words: Vec<String> = text
3920 .split_whitespace()
3921 .map(|w| {
3922 w.to_lowercase()
3923 .chars()
3924 .filter(|c| c.is_alphanumeric())
3925 .collect()
3926 })
3927 .collect();
3928 if words.is_empty() {
3929 return (0.0, "neutral");
3930 }
3931 let pos_count = words
3932 .iter()
3933 .filter(|w| positive_words.contains(&w.as_str()))
3934 .count();
3935 let neg_count = words
3936 .iter()
3937 .filter(|w| negative_words.contains(&w.as_str()))
3938 .count();
3939 let total = words.len() as f64;
3940 let score = ((pos_count as f64 - neg_count as f64) / total * 100.0).round() / 100.0;
3941 let label = if score > 0.05 {
3942 "positive"
3943 } else if score < -0.05 {
3944 "negative"
3945 } else {
3946 "neutral"
3947 };
3948 (score, label)
3949 }
3950}
3951
3952impl Default for EntityAnalyzer {
3953 fn default() -> Self {
3954 Self::new()
3955 }
3956}
3957
3958impl Analyzer for EntityAnalyzer {
3959 fn name(&self) -> &str {
3960 "entity-analyzer"
3961 }
3962
3963 fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
3964 let mut findings = Vec::new();
3965 let url = &ctx.page.url;
3966
3967 let headings_text: String = ctx
3968 .page
3969 .headings
3970 .iter()
3971 .map(|h| h.text.as_str())
3972 .collect::<Vec<_>>()
3973 .join(" ");
3974
3975 let text = &headings_text;
3976
3977 let people = Self::detect_people(text);
3978 let organizations = Self::detect_organizations(text);
3979 let locations = Self::detect_locations(text);
3980 let topics = Self::detect_topics(&ctx.page.headings, ctx.page.word_count);
3981 let (sentiment_score, sentiment_label) = Self::analyze_sentiment(text);
3982
3983 if !people.is_empty() {
3984 findings.push(Finding {
3985 severity: Severity::Info,
3986 category: IssueCategory::Content,
3987 code: "ENTITY001".to_string(),
3988 title: "People entities detected".to_string(),
3989 description: format!(
3990 "Found {} people entity(ies): {}.",
3991 people.len(),
3992 people.join(", ")
3993 ),
3994 url: url.clone(),
3995 recommendation: "People entities can boost E-E-A-T signals. Link to author \
3996 profiles when applicable."
3997 .to_string(),
3998 });
3999 }
4000
4001 if !organizations.is_empty() {
4002 findings.push(Finding {
4003 severity: Severity::Info,
4004 category: IssueCategory::Content,
4005 code: "ENTITY002".to_string(),
4006 title: "Organization entities detected".to_string(),
4007 description: format!(
4008 "Found {} organization entity(ies): {}.",
4009 organizations.len(),
4010 organizations.join(", ")
4011 ),
4012 url: url.clone(),
4013 recommendation: "Organization entities help establish topical authority. \
4014 Consider adding Organization schema markup."
4015 .to_string(),
4016 });
4017 }
4018
4019 if !locations.is_empty() {
4020 findings.push(Finding {
4021 severity: Severity::Info,
4022 category: IssueCategory::Content,
4023 code: "ENTITY003".to_string(),
4024 title: "Location entities detected".to_string(),
4025 description: format!(
4026 "Found {} location entity(ies): {}.",
4027 locations.len(),
4028 locations.join(", ")
4029 ),
4030 url: url.clone(),
4031 recommendation: "Location entities are important for local SEO. Ensure \
4032 NAP consistency across the site."
4033 .to_string(),
4034 });
4035 }
4036
4037 if !topics.is_empty() {
4038 let topic_display = topics.join(", ");
4039 findings.push(Finding {
4040 severity: Severity::Info,
4041 category: IssueCategory::Content,
4042 code: "ENTITY004".to_string(),
4043 title: "Detected topics and themes".to_string(),
4044 description: format!("Primary topics: {topic_display}."),
4045 url: url.clone(),
4046 recommendation: "Ensure these topics align with the target keywords and \
4047 page intent."
4048 .to_string(),
4049 });
4050 }
4051
4052 findings.push(Finding {
4053 severity: Severity::Info,
4054 category: IssueCategory::Content,
4055 code: "ENTITY005".to_string(),
4056 title: "Content sentiment analysis".to_string(),
4057 description: format!("Sentiment score: {sentiment_score} ({sentiment_label})."),
4058 url: url.clone(),
4059 recommendation: if sentiment_label == "negative" {
4060 "Content has a negative sentiment tone. Consider revising for a more \
4061 neutral or positive tone."
4062 .to_string()
4063 } else if sentiment_label == "positive" {
4064 "Positive sentiment detected. This can improve user engagement.".to_string()
4065 } else {
4066 "Neutral sentiment detected.".to_string()
4067 },
4068 });
4069
4070 findings.push(Finding {
4071 severity: Severity::Info,
4072 category: IssueCategory::Content,
4073 code: "ENTITY006".to_string(),
4074 title: "Entity counts per page".to_string(),
4075 description: format!(
4076 "People: {}, Organizations: {}, Locations: {}, Topics: {}.",
4077 people.len(),
4078 organizations.len(),
4079 locations.len(),
4080 topics.len()
4081 ),
4082 url: url.clone(),
4083 recommendation: String::new(),
4084 });
4085
4086 findings
4087 }
4088}
4089
4090pub struct EnhancedReadabilityAnalyzer;
4095
4096impl EnhancedReadabilityAnalyzer {
4097 pub fn new() -> Self {
4098 Self
4099 }
4100
4101 fn count_letters(text: &str) -> usize {
4102 text.chars().filter(|c| c.is_alphabetic()).count()
4103 }
4104
4105 fn count_sentences(text: &str) -> usize {
4106 if text.trim().is_empty() {
4107 return 1;
4108 }
4109 text.chars()
4110 .filter(|&c| c == '.' || c == '!' || c == '?')
4111 .count()
4112 .max(1)
4113 }
4114
4115 fn count_syllables(word: &str) -> usize {
4116 let word = word.to_lowercase();
4117 let chars: Vec<char> = word.chars().collect();
4118 if chars.is_empty() {
4119 return 0;
4120 }
4121 if chars.len() <= 2 {
4122 return 1;
4123 }
4124
4125 let vowels = ['a', 'e', 'i', 'o', 'u', 'y'];
4126 let mut count = 0;
4127 let mut prev_vowel = false;
4128
4129 for &c in &chars {
4130 let is_vowel = vowels.contains(&c);
4131 if is_vowel && !prev_vowel {
4132 count += 1;
4133 }
4134 prev_vowel = is_vowel;
4135 }
4136
4137 if chars.last() == Some(&'e') && count > 1 {
4138 count -= 1;
4139 }
4140
4141 count.max(1)
4142 }
4143
4144 fn flesch_kincaid_grade(words: usize, sentences: usize, syllables: usize) -> f64 {
4145 if words == 0 || sentences == 0 {
4146 return 0.0;
4147 }
4148 0.39 * (words as f64 / sentences as f64) + 11.8 * (syllables as f64 / words as f64) - 15.59
4149 }
4150
4151 fn coleman_liau_index(letters: usize, words: usize, sentences: usize) -> f64 {
4152 if words == 0 {
4153 return 0.0;
4154 }
4155 let l = letters as f64 / words as f64 * 100.0;
4156 let s = sentences as f64 / words as f64 * 100.0;
4157 0.0588 * l - 0.296 * s - 15.8
4158 }
4159
4160 fn automated_readability_index(characters: usize, words: usize, sentences: usize) -> f64 {
4161 if words == 0 || sentences == 0 {
4162 return 0.0;
4163 }
4164 4.71 * (characters as f64 / words as f64) + 0.5 * (words as f64 / sentences as f64) - 21.43
4165 }
4166
4167 fn gunning_fog_index(words: usize, sentences: usize, complex_words: usize) -> f64 {
4168 if words == 0 || sentences == 0 {
4169 return 0.0;
4170 }
4171 0.4 * (words as f64 / sentences as f64 + 100.0 * complex_words as f64 / words as f64)
4172 }
4173
4174 fn flesch_reading_ease(words: usize, sentences: usize, syllables: usize) -> f64 {
4175 if words == 0 || sentences == 0 {
4176 return 0.0;
4177 }
4178 let score = 206.835
4179 - 1.015 * (words as f64 / sentences as f64)
4180 - 84.6 * (syllables as f64 / words as f64);
4181 score.clamp(0.0, 100.0)
4182 }
4183
4184 fn reading_ease_label(score: f64) -> &'static str {
4185 if score >= 90.0 {
4186 "very easy"
4187 } else if score >= 80.0 {
4188 "easy"
4189 } else if score >= 70.0 {
4190 "fairly easy"
4191 } else if score >= 60.0 {
4192 "standard"
4193 } else if score >= 50.0 {
4194 "fairly difficult"
4195 } else if score >= 30.0 {
4196 "difficult"
4197 } else {
4198 "very difficult"
4199 }
4200 }
4201
4202 fn grade_label(grade: f64) -> &'static str {
4203 if grade < 1.0 {
4204 "kindergarten"
4205 } else if grade < 6.0 {
4206 "elementary school"
4207 } else if grade < 9.0 {
4208 "middle school"
4209 } else if grade < 13.0 {
4210 "high school"
4211 } else if grade < 16.0 {
4212 "college"
4213 } else {
4214 "postgraduate"
4215 }
4216 }
4217}
4218
4219impl Default for EnhancedReadabilityAnalyzer {
4220 fn default() -> Self {
4221 Self::new()
4222 }
4223}
4224
4225impl Analyzer for EnhancedReadabilityAnalyzer {
4226 fn name(&self) -> &str {
4227 "enhanced-readability"
4228 }
4229
4230 fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
4231 let mut findings = Vec::new();
4232 let url = &ctx.page.url;
4233
4234 if ctx.page.word_count == 0 {
4235 return findings;
4236 }
4237
4238 let text = ctx
4239 .page
4240 .headings
4241 .iter()
4242 .map(|h| h.text.as_str())
4243 .collect::<Vec<_>>()
4244 .join(" ");
4245
4246 if text.trim().is_empty() {
4247 return findings;
4248 }
4249
4250 let words: Vec<&str> = text.split_whitespace().collect();
4251 let word_count = words.len();
4252 let sentence_count = Self::count_sentences(&text);
4253 let letter_count = Self::count_letters(&text);
4254 let syllable_count: usize = words.iter().map(|w| Self::count_syllables(w)).sum();
4255 let complex_words = Self::complex_words_count(&words);
4256
4257 let fk_grade = Self::flesch_kincaid_grade(word_count, sentence_count, syllable_count);
4258 let cl_index = Self::coleman_liau_index(letter_count, word_count, sentence_count);
4259 let ari = Self::automated_readability_index(letter_count, word_count, sentence_count);
4260 let fog = Self::gunning_fog_index(word_count, sentence_count, complex_words);
4261 let fre = Self::flesch_reading_ease(word_count, sentence_count, syllable_count);
4262
4263 findings.push(Finding {
4264 severity: Severity::Info,
4265 category: IssueCategory::Content,
4266 code: "READ001".to_string(),
4267 title: "Flesch-Kincaid Grade Level".to_string(),
4268 description: format!(
4269 "Grade level: {fk_grade:.1} ({})",
4270 Self::grade_label(fk_grade)
4271 ),
4272 url: url.clone(),
4273 recommendation: if fk_grade > 12.0 {
4274 "Content is at a college reading level. Consider simplifying for broader \
4275 audiences."
4276 .to_string()
4277 } else if fk_grade > 8.0 {
4278 "Content is at a high school reading level. Suitable for most web audiences."
4279 .to_string()
4280 } else {
4281 "Content is easy to read for most audiences.".to_string()
4282 },
4283 });
4284
4285 findings.push(Finding {
4286 severity: Severity::Info,
4287 category: IssueCategory::Content,
4288 code: "READ002".to_string(),
4289 title: "Coleman-Liau Index".to_string(),
4290 description: format!("Index: {cl_index:.1}"),
4291 url: url.clone(),
4292 recommendation: if cl_index > 12.0 {
4293 "High Coleman-Liau index. Consider reducing sentence complexity.".to_string()
4294 } else {
4295 "Readability is within acceptable range.".to_string()
4296 },
4297 });
4298
4299 findings.push(Finding {
4300 severity: Severity::Info,
4301 category: IssueCategory::Content,
4302 code: "READ003".to_string(),
4303 title: "Automated Readability Index".to_string(),
4304 description: format!("ARI: {ari:.1}"),
4305 url: url.clone(),
4306 recommendation: if ari > 12.0 {
4307 "High ARI score. Content may be difficult for general audiences.".to_string()
4308 } else {
4309 "Readability is within acceptable range.".to_string()
4310 },
4311 });
4312
4313 findings.push(Finding {
4314 severity: Severity::Info,
4315 category: IssueCategory::Content,
4316 code: "READ004".to_string(),
4317 title: "Gunning Fog Index".to_string(),
4318 description: format!("Fog index: {fog:.1}"),
4319 url: url.clone(),
4320 recommendation: if fog > 17.0 {
4321 "Very high Fog index. Content is extremely complex. Simplify vocabulary \
4322 and shorten sentences."
4323 .to_string()
4324 } else if fog > 12.0 {
4325 "High Fog index. Consider simplifying for a broader audience.".to_string()
4326 } else {
4327 "Fog index is within acceptable range.".to_string()
4328 },
4329 });
4330
4331 findings.push(Finding {
4332 severity: Severity::Info,
4333 category: IssueCategory::Content,
4334 code: "READ005".to_string(),
4335 title: "Flesch Reading Ease score".to_string(),
4336 description: format!("Score: {fre:.1}/100 ({})", Self::reading_ease_label(fre)),
4337 url: url.clone(),
4338 recommendation: if fre < 30.0 {
4339 "Very difficult to read. Aim for a score of 60+ for general audiences.".to_string()
4340 } else if fre < 50.0 {
4341 "Fairly difficult. Consider simplifying language.".to_string()
4342 } else if fre < 70.0 {
4343 "Standard readability. Suitable for most web content.".to_string()
4344 } else {
4345 "Easy to read. Good for broad audiences.".to_string()
4346 },
4347 });
4348
4349 findings
4350 }
4351}
4352
4353impl EnhancedReadabilityAnalyzer {
4354 fn complex_words_count(words: &[&str]) -> usize {
4355 words
4356 .iter()
4357 .filter(|w| Self::count_syllables(w) >= 3)
4358 .count()
4359 }
4360}
4361
4362pub struct KeywordAnalyzer {
4367 corpus_tf: HashMap<String, f64>,
4368}
4369
4370impl KeywordAnalyzer {
4371 pub fn new() -> Self {
4372 Self {
4373 corpus_tf: HashMap::new(),
4374 }
4375 }
4376
4377 pub fn with_corpus_tf(corpus_tf: HashMap<String, f64>) -> Self {
4378 Self { corpus_tf }
4379 }
4380
4381 fn tokenize(text: &str) -> Vec<String> {
4382 text.split_whitespace()
4383 .map(|w| {
4384 w.to_lowercase()
4385 .chars()
4386 .filter(|c| c.is_alphanumeric())
4387 .collect()
4388 })
4389 .filter(|w: &String| w.len() > 2 && !STOP_WORDS.contains(&w.as_str()))
4390 .collect()
4391 }
4392
4393 fn compute_tf(tokens: &[String]) -> HashMap<String, f64> {
4394 let mut freq: HashMap<String, usize> = HashMap::new();
4395 for token in tokens {
4396 *freq.entry(token.clone()).or_default() += 1;
4397 }
4398 let total = tokens.len() as f64;
4399 freq.into_iter()
4400 .map(|(term, count)| (term, count as f64 / total))
4401 .collect()
4402 }
4403
4404 fn compute_tfidf(
4405 tf: &HashMap<String, f64>,
4406 corpus_tf: &HashMap<String, f64>,
4407 ) -> HashMap<String, f64> {
4408 let total_docs = corpus_tf.len().max(1) as f64;
4409 tf.iter()
4410 .map(|(term, tf_val)| {
4411 let df = corpus_tf.get(term).copied().unwrap_or(0.0);
4412 let idf = if df > 0.0 {
4413 (total_docs / df).ln() + 1.0
4414 } else {
4415 1.0
4416 };
4417 (term.clone(), tf_val * idf)
4418 })
4419 .collect()
4420 }
4421
4422 fn keyword_density(tokens: &[String], total_words: usize) -> HashMap<String, f64> {
4423 if total_words == 0 {
4424 return HashMap::new();
4425 }
4426 let mut freq: HashMap<String, usize> = HashMap::new();
4427 for token in tokens {
4428 *freq.entry(token.clone()).or_default() += 1;
4429 }
4430 freq.into_iter()
4431 .map(|(term, count)| (term, count as f64 / total_words as f64 * 100.0))
4432 .collect()
4433 }
4434
4435 fn detect_prominent_keywords(density: &HashMap<String, f64>) -> Vec<(String, f64)> {
4436 let mut prominent: Vec<(String, f64)> = density
4437 .iter()
4438 .filter(|(_, &d)| d >= 1.5)
4439 .map(|(k, &v)| (k.clone(), v))
4440 .collect();
4441 prominent.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
4442 prominent
4443 }
4444
4445 fn cooccurrence(tokens: &[String], window: usize) -> Vec<((String, String), usize)> {
4446 let mut pairs: HashMap<(String, String), usize> = HashMap::new();
4447 for i in 0..tokens.len() {
4448 let end = (i + window + 1).min(tokens.len());
4449 for j in (i + 1)..end {
4450 let mut pair = [tokens[i].clone(), tokens[j].clone()];
4451 pair.sort();
4452 *pairs.entry((pair[0].clone(), pair[1].clone())).or_default() += 1;
4453 }
4454 }
4455 let mut result: Vec<((String, String), usize)> = pairs.into_iter().collect();
4456 result.sort_by_key(|b| std::cmp::Reverse(b.1));
4457 result
4458 }
4459}
4460
4461impl Default for KeywordAnalyzer {
4462 fn default() -> Self {
4463 Self::new()
4464 }
4465}
4466
4467impl Analyzer for KeywordAnalyzer {
4468 fn name(&self) -> &str {
4469 "keyword-analyzer"
4470 }
4471
4472 fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
4473 let mut findings = Vec::new();
4474 let url = &ctx.page.url;
4475
4476 if ctx.page.word_count == 0 {
4477 return findings;
4478 }
4479
4480 let text = ctx
4481 .page
4482 .headings
4483 .iter()
4484 .map(|h| h.text.as_str())
4485 .collect::<Vec<_>>()
4486 .join(" ");
4487
4488 if text.trim().is_empty() {
4489 return findings;
4490 }
4491
4492 let tokens = Self::tokenize(&text);
4493 if tokens.is_empty() {
4494 return findings;
4495 }
4496
4497 let tf = Self::compute_tf(&tokens);
4498 let tfidf = Self::compute_tfidf(&tf, &self.corpus_tf);
4499 let density = Self::keyword_density(&tokens, ctx.page.word_count);
4500 let prominent = Self::detect_prominent_keywords(&density);
4501 let cooccur = Self::cooccurrence(&tokens, 3);
4502
4503 let mut tfidf_sorted: Vec<(&String, &f64)> = tfidf.iter().collect();
4504 tfidf_sorted.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal));
4505 let top_tfidf: Vec<String> = tfidf_sorted
4506 .iter()
4507 .take(10)
4508 .map(|(k, v)| format!("{k} ({v:.2})"))
4509 .collect();
4510
4511 if !top_tfidf.is_empty() {
4512 findings.push(Finding {
4513 severity: Severity::Info,
4514 category: IssueCategory::Content,
4515 code: "KW001".to_string(),
4516 title: "Top TF-IDF keywords".to_string(),
4517 description: format!("Top keywords by TF-IDF score: {}.", top_tfidf.join(", ")),
4518 url: url.clone(),
4519 recommendation: "TF-IDF highlights the most distinctive terms on this page. \
4520 Ensure these align with your target keywords."
4521 .to_string(),
4522 });
4523 }
4524
4525 let mut density_sorted: Vec<(&String, &f64)> = density.iter().collect();
4526 density_sorted.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal));
4527 let top_density: Vec<String> = density_sorted
4528 .iter()
4529 .take(10)
4530 .map(|(k, v)| format!("{k} ({v:.1}%)"))
4531 .collect();
4532
4533 if !top_density.is_empty() {
4534 findings.push(Finding {
4535 severity: Severity::Info,
4536 category: IssueCategory::Content,
4537 code: "KW002".to_string(),
4538 title: "Keyword density".to_string(),
4539 description: format!(
4540 "Top keyword densities (of {} words): {}.",
4541 ctx.page.word_count,
4542 top_density.join(", ")
4543 ),
4544 url: url.clone(),
4545 recommendation: "Ideal keyword density is 1-2%. Higher may indicate keyword \
4546 stuffing."
4547 .to_string(),
4548 });
4549 }
4550
4551 if !prominent.is_empty() {
4552 let display: Vec<String> = prominent
4553 .iter()
4554 .map(|(k, v)| format!("\"{k}\" ({v:.1}%)"))
4555 .collect();
4556 findings.push(Finding {
4557 severity: if prominent.iter().any(|(_, d)| *d > 3.0) {
4558 Severity::Warning
4559 } else {
4560 Severity::Info
4561 },
4562 category: IssueCategory::Content,
4563 code: "KW003".to_string(),
4564 title: "Prominent keywords detected".to_string(),
4565 description: format!("Keywords with density >= 1.5%: {}.", display.join(", ")),
4566 url: url.clone(),
4567 recommendation: if prominent.iter().any(|(_, d)| *d > 3.0) {
4568 "Some keywords exceed 3% density. This may be flagged as keyword \
4569 stuffing by search engines."
4570 .to_string()
4571 } else {
4572 "Keyword densities are within acceptable range.".to_string()
4573 },
4574 });
4575 }
4576
4577 if !cooccur.is_empty() {
4578 let display: Vec<String> = cooccur
4579 .iter()
4580 .take(5)
4581 .map(|((a, b), c)| format!("\"{a}\" + \"{b}\" ({c})"))
4582 .collect();
4583 findings.push(Finding {
4584 severity: Severity::Info,
4585 category: IssueCategory::Content,
4586 code: "KW004".to_string(),
4587 title: "Keyword co-occurrence".to_string(),
4588 description: format!("Top keyword pairs: {}.", display.join(", ")),
4589 url: url.clone(),
4590 recommendation: "Co-occurring keywords help search engines understand topic \
4591 relationships."
4592 .to_string(),
4593 });
4594 }
4595
4596 findings
4597 }
4598}
4599
4600pub struct EcommerceSignalsAnalyzer;
4605
4606impl EcommerceSignalsAnalyzer {
4607 pub fn new() -> Self {
4608 Self
4609 }
4610
4611 fn detect_product_schema(sd: &crate::parser::StructuredData) -> bool {
4612 sd.r#type
4613 .as_deref()
4614 .map(|t| t == "Product" || t == "IndividualProduct" || t == "AggregateOffer")
4615 .unwrap_or(false)
4616 }
4617
4618 fn extract_price(data: &serde_json::Value) -> Option<String> {
4619 let direct = data
4620 .get("price")
4621 .or_else(|| data.get("lowPrice"))
4622 .or_else(|| data.get("highPrice"));
4623 if let Some(v) = direct {
4624 return Self::value_to_price(v);
4625 }
4626 let offers = data.get("offers")?;
4627 let offer = if offers.is_array() {
4628 offers.get(0)?
4629 } else {
4630 offers
4631 };
4632 let price_val = offer
4633 .get("price")
4634 .or_else(|| offer.get("lowPrice"))
4635 .or_else(|| offer.get("highPrice"))?;
4636 Self::value_to_price(price_val)
4637 }
4638
4639 fn value_to_price(v: &serde_json::Value) -> Option<String> {
4640 if let Some(s) = v.as_str() {
4641 if !s.is_empty() {
4642 return Some(s.to_string());
4643 }
4644 }
4645 v.as_f64().map(|p| format!("{p}"))
4646 }
4647
4648 fn extract_availability(data: &serde_json::Value) -> Option<String> {
4649 data.get("availability")
4650 .and_then(|v| v.as_str().map(String::from))
4651 .or_else(|| {
4652 data.get("offers")
4653 .and_then(|o| o.get("availability"))
4654 .and_then(|v| v.as_str().map(String::from))
4655 })
4656 }
4657
4658 fn detect_reviews(sd: &crate::parser::StructuredData) -> Vec<String> {
4659 let mut reviews = Vec::new();
4660 if let Some(obj) = sd.data.as_object() {
4661 if let Some(rating) = obj.get("aggregateRating") {
4662 if let Some(score) = rating.get("ratingValue").and_then(|v| {
4663 v.as_f64()
4664 .or_else(|| v.as_str().and_then(|s| s.parse::<f64>().ok()))
4665 }) {
4666 reviews.push(format!("rating: {score}"));
4667 }
4668 }
4669 if let Some(r) = obj.get("reviewCount").or_else(|| obj.get("ratingCount")) {
4670 if let Some(count) = r
4671 .as_f64()
4672 .or_else(|| r.as_str().and_then(|s| s.parse::<f64>().ok()))
4673 {
4674 reviews.push(format!("reviews: {count}"));
4675 }
4676 }
4677 }
4678 reviews
4679 }
4680
4681 fn detect_offers(sd: &crate::parser::StructuredData) -> bool {
4682 sd.data
4683 .get("offers")
4684 .or_else(|| sd.data.get("hasOffersCatalog"))
4685 .map(|v| !v.is_null())
4686 .unwrap_or(false)
4687 }
4688}
4689
4690impl Default for EcommerceSignalsAnalyzer {
4691 fn default() -> Self {
4692 Self::new()
4693 }
4694}
4695
4696impl Analyzer for EcommerceSignalsAnalyzer {
4697 fn name(&self) -> &str {
4698 "ecommerce-signals"
4699 }
4700
4701 fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
4702 let mut findings = Vec::new();
4703 let url = &ctx.page.url;
4704
4705 if ctx.page.structured_data.is_empty() {
4706 return findings;
4707 }
4708
4709 let mut has_product = false;
4710 let mut prices_found = Vec::new();
4711 let mut availability_found = Vec::new();
4712 let mut reviews_found = Vec::new();
4713 let mut offers_found = false;
4714
4715 for sd in &ctx.page.structured_data {
4716 if Self::detect_product_schema(sd) {
4717 has_product = true;
4718
4719 if let Some(price) = Self::extract_price(&sd.data) {
4720 prices_found.push(price);
4721 }
4722 if let Some(avail) = Self::extract_availability(&sd.data) {
4723 availability_found.push(avail);
4724 }
4725 reviews_found.extend(Self::detect_reviews(sd));
4726 if Self::detect_offers(sd) {
4727 offers_found = true;
4728 }
4729 }
4730
4731 if sd.r#type.as_deref() == Some("Offer")
4732 || sd.r#type.as_deref() == Some("AggregateOffer")
4733 {
4734 if let Some(price) = Self::extract_price(&sd.data) {
4735 prices_found.push(price);
4736 }
4737 }
4738
4739 if sd.r#type.as_deref() == Some("Review")
4740 || sd.r#type.as_deref() == Some("AggregateRating")
4741 {
4742 reviews_found.extend(Self::detect_reviews(sd));
4743 }
4744 }
4745
4746 if has_product {
4747 findings.push(Finding {
4748 severity: Severity::Info,
4749 category: IssueCategory::Schema,
4750 code: "ECOM001".to_string(),
4751 title: "Product schema detected".to_string(),
4752 description: "Product structured data found. This enables rich product \
4753 results in search."
4754 .to_string(),
4755 url: url.clone(),
4756 recommendation: "Ensure all required Product properties are present (name, \
4757 image, description, offers)."
4758 .to_string(),
4759 });
4760 }
4761
4762 if !prices_found.is_empty() {
4763 findings.push(Finding {
4764 severity: Severity::Info,
4765 category: IssueCategory::Schema,
4766 code: "ECOM002".to_string(),
4767 title: "Price information detected".to_string(),
4768 description: format!(
4769 "Prices found in structured data: {}.",
4770 prices_found.join(", ")
4771 ),
4772 url: url.clone(),
4773 recommendation: "Verify prices match the visible page content.".to_string(),
4774 });
4775 }
4776
4777 if !availability_found.is_empty() {
4778 findings.push(Finding {
4779 severity: Severity::Info,
4780 category: IssueCategory::Schema,
4781 code: "ECOM003".to_string(),
4782 title: "Availability information detected".to_string(),
4783 description: format!("Availability: {}.", availability_found.join(", ")),
4784 url: url.clone(),
4785 recommendation: "Availability status should match the actual product state."
4786 .to_string(),
4787 });
4788 }
4789
4790 if !reviews_found.is_empty() {
4791 findings.push(Finding {
4792 severity: Severity::Info,
4793 category: IssueCategory::Schema,
4794 code: "ECOM004".to_string(),
4795 title: "Review/rating information detected".to_string(),
4796 description: format!("Review data: {}.", reviews_found.join(", ")),
4797 url: url.clone(),
4798 recommendation: "Ratings and reviews enhance search result CTR. Keep them \
4799 updated."
4800 .to_string(),
4801 });
4802 }
4803
4804 if offers_found {
4805 findings.push(Finding {
4806 severity: Severity::Info,
4807 category: IssueCategory::Schema,
4808 code: "ECOM005".to_string(),
4809 title: "Offer schema detected".to_string(),
4810 description: "Offer structured data found in product schema.".to_string(),
4811 url: url.clone(),
4812 recommendation: "Ensure offer includes price, priceCurrency, and availability."
4813 .to_string(),
4814 });
4815 }
4816
4817 if !has_product && !prices_found.is_empty() {
4818 findings.push(Finding {
4819 severity: Severity::Warning,
4820 category: IssueCategory::Schema,
4821 code: "ECOM006".to_string(),
4822 title: "Price data without Product schema".to_string(),
4823 description: "Price information found but no Product schema type detected. \
4824 Search engines may not interpret this as product data."
4825 .to_string(),
4826 url: url.clone(),
4827 recommendation: "Add Product schema to wrap price and availability data."
4828 .to_string(),
4829 });
4830 }
4831
4832 findings
4833 }
4834}
4835
4836pub struct InternationalSeoAnalyzer {
4841 known_hrefs: HashMap<String, Vec<String>>,
4842}
4843
4844impl InternationalSeoAnalyzer {
4845 pub fn new() -> Self {
4846 Self {
4847 known_hrefs: HashMap::new(),
4848 }
4849 }
4850
4851 pub fn with_known_hrefs(known_hrefs: HashMap<String, Vec<String>>) -> Self {
4852 Self { known_hrefs }
4853 }
4854
4855 fn detect_locale_from_url(url: &str) -> Option<String> {
4856 if let Ok(parsed) = Url::parse(url) {
4857 let segments: Vec<&str> = parsed
4858 .path_segments()
4859 .map(|s| s.filter(|s| !s.is_empty()).collect())
4860 .unwrap_or_default();
4861 if let Some(first) = segments.first() {
4862 if Self::is_locale_segment(first) {
4863 return Some(first.to_string());
4864 }
4865 }
4866 }
4867 None
4868 }
4869
4870 fn is_locale_segment(s: &str) -> bool {
4871 let parts: Vec<&str> = s.split('-').collect();
4872 match parts.len() {
4873 1 => {
4874 let lang = parts[0];
4875 lang.len() >= 2 && lang.len() <= 3 && lang.chars().all(|c| c.is_ascii_alphabetic())
4876 }
4877 2 => {
4878 let lang = parts[0];
4879 let region = parts[1];
4880 lang.len() >= 2
4881 && lang.len() <= 3
4882 && lang.chars().all(|c| c.is_ascii_alphabetic())
4883 && ((region.len() == 2 && region.chars().all(|c| c.is_ascii_alphabetic()))
4884 || (region.len() == 4 && region.chars().all(|c| c.is_ascii_digit())))
4885 }
4886 _ => false,
4887 }
4888 }
4889
4890 fn detect_multilang_content(
4891 hreflang: &[crate::meta::HreflangTag],
4892 html_lang: &Option<String>,
4893 ) -> bool {
4894 if !hreflang.is_empty() {
4895 return true;
4896 }
4897 if let Some(lang) = html_lang {
4898 let parts: Vec<&str> = lang.split('-').collect();
4899 if parts.len() >= 2 {
4900 return true;
4901 }
4902 }
4903 false
4904 }
4905}
4906
4907impl Default for InternationalSeoAnalyzer {
4908 fn default() -> Self {
4909 Self::new()
4910 }
4911}
4912
4913impl Analyzer for InternationalSeoAnalyzer {
4914 fn name(&self) -> &str {
4915 "international-seo"
4916 }
4917
4918 fn analyze(&self, ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
4919 let mut findings = Vec::new();
4920 let url = &ctx.page.url;
4921
4922 let hreflang_tags = &ctx.page.meta.hreflang;
4923
4924 if !hreflang_tags.is_empty() {
4925 for tag in hreflang_tags {
4926 if tag.lang.to_lowercase() == "x-default" {
4929 continue;
4930 }
4931 if let Some(locale) = Self::detect_locale_from_url(tag.url.as_str()) {
4932 let locale_lower = locale.to_lowercase();
4933 let tag_lang_lower = tag.lang.to_lowercase();
4934 if locale_lower != tag_lang_lower
4935 && !tag_lang_lower.starts_with(&locale_lower)
4936 && !locale_lower.starts_with(&tag_lang_lower)
4937 {
4938 findings.push(Finding {
4939 severity: Severity::Warning,
4940 category: IssueCategory::Seo,
4941 code: "ISEO001".to_string(),
4942 title: "Hreflang URL locale mismatch".to_string(),
4943 description: format!(
4944 "Hreflang tag lang=\"{}\" points to URL \"{}\" which has \
4945 locale segment \"{}\".",
4946 tag.lang, tag.url, locale
4947 ),
4948 url: url.clone(),
4949 recommendation: "Ensure the hreflang URL path segment matches \
4950 the language code in the hreflang tag."
4951 .to_string(),
4952 });
4953 }
4954 }
4955 }
4956
4957 let has_x_default = hreflang_tags.iter().any(|t| t.lang == "x-default");
4958 if !has_x_default {
4959 findings.push(Finding {
4960 severity: Severity::Warning,
4961 category: IssueCategory::Seo,
4962 code: "ISEO002".to_string(),
4963 title: "Missing x-default in enhanced hreflang".to_string(),
4964 description: "No x-default hreflang found. This tag specifies the fallback \
4965 page for unmatched locales."
4966 .to_string(),
4967 url: url.clone(),
4968 recommendation: "Add <link rel=\"alternate\" hreflang=\"x-default\" \
4969 href=\"...\"> pointing to the default language version."
4970 .to_string(),
4971 });
4972 }
4973
4974 let lang_count: HashMap<String, usize> =
4975 hreflang_tags.iter().fold(HashMap::new(), |mut acc, t| {
4976 *acc.entry(t.lang.clone()).or_insert(0) += 1;
4977 acc
4978 });
4979 for (lang, count) in &lang_count {
4980 if *count > 1 && lang != "x-default" {
4981 findings.push(Finding {
4982 severity: Severity::Error,
4983 category: IssueCategory::Seo,
4984 code: "ISEO003".to_string(),
4985 title: "Duplicate hreflang language".to_string(),
4986 description: format!(
4987 "Language \"{lang}\" appears {count} times. Each language code \
4988 must be unique per page."
4989 ),
4990 url: url.clone(),
4991 recommendation: "Remove duplicate hreflang tags for the same language."
4992 .to_string(),
4993 });
4994 }
4995 }
4996 }
4997
4998 let locale_in_url = Self::detect_locale_from_url(url);
4999 if locale_in_url.is_none()
5000 && ctx.page.meta.hreflang.is_empty()
5001 && ctx.page.html_lang.is_some()
5002 {
5003 findings.push(Finding {
5004 severity: Severity::Info,
5005 category: IssueCategory::Seo,
5006 code: "ISEO004".to_string(),
5007 title: "Single-language page without hreflang".to_string(),
5008 description: "Page has a lang attribute but no hreflang tags. If this \
5009 site serves multiple languages, add hreflang annotations."
5010 .to_string(),
5011 url: url.clone(),
5012 recommendation: "For multilingual sites, add hreflang tags to all language \
5013 variants of each page."
5014 .to_string(),
5015 });
5016 }
5017
5018 if let Some(canonical) = &ctx.page.meta.canonical {
5019 if let Some(hop_from) = self.known_hrefs.get(url) {
5020 for hop_url in hop_from {
5021 if let Some(target_canonical) = self.known_hrefs.get(hop_url.as_str()) {
5022 if !target_canonical.is_empty() {
5023 let canonical_str = canonical.to_string();
5024 if target_canonical.iter().any(|c| c == &canonical_str) {
5025 findings.push(Finding {
5026 severity: Severity::Info,
5027 category: IssueCategory::Seo,
5028 code: "ISEO005".to_string(),
5029 title: "Canonical chain detected".to_string(),
5030 description: format!(
5031 "URL \"{url}\" canonical points to \"{}\", which is \
5032 itself canonicalized. This forms a chain.",
5033 canonical
5034 ),
5035 url: url.clone(),
5036 recommendation: "Ensure all pages point to the same \
5037 final canonical URL to avoid crawl \
5038 confusion."
5039 .to_string(),
5040 });
5041 }
5042 }
5043 }
5044 }
5045 }
5046 }
5047
5048 let is_multilang = Self::detect_multilang_content(hreflang_tags, &ctx.page.html_lang);
5049 if is_multilang {
5050 findings.push(Finding {
5051 severity: Severity::Info,
5052 category: IssueCategory::Seo,
5053 code: "ISEO006".to_string(),
5054 title: "Multi-language content detected".to_string(),
5055 description: "Page appears to be part of a multilingual setup. Ensure all \
5056 language variants cross-reference each other via hreflang."
5057 .to_string(),
5058 url: url.clone(),
5059 recommendation: "Each language version should have reciprocal hreflang tags \
5060 pointing to all other versions."
5061 .to_string(),
5062 });
5063 }
5064
5065 findings
5066 }
5067}
5068
5069pub struct AnalyzerRegistry {
5089 analyzers: Vec<Box<dyn Analyzer>>,
5090}
5091
5092impl AnalyzerRegistry {
5093 pub fn new(_config: &CrawlConfig) -> Self {
5099 Self {
5100 analyzers: vec![
5101 Box::new(HttpStatusAnalyzer::new()),
5102 Box::new(RedirectChainAnalyzer::new()),
5103 Box::new(CanonicalUrlValidator::new()),
5104 Box::new(HreflangValidator::new()),
5105 Box::new(SitemapAnalyzer::empty()),
5106 Box::new(RobotsTxtAnalyzer::empty()),
5107 Box::new(MetaTagAnalyzer::new()),
5108 Box::new(HeadingHierarchyAnalyzer::new()),
5109 Box::new(LinkAnalyzer::new()),
5110 Box::new(ImageAnalyzer::new()),
5111 Box::new(StructuredDataValidator::new()),
5112 Box::new(ContentQualityAnalyzer::new()),
5113 Box::new(WordCountAnalyzer::new()),
5114 Box::new(SecurityHeaderAnalyzer::new()),
5115 Box::new(SslCertificateValidator::empty()),
5116 Box::new(MobileFriendlinessChecker::new()),
5117 Box::new(AccessibilityAnalyzer::new()),
5118 Box::new(SocialMediaAnalyzer::new()),
5119 Box::new(EntityAnalyzer::new()),
5120 Box::new(EnhancedReadabilityAnalyzer::new()),
5121 Box::new(KeywordAnalyzer::new()),
5122 Box::new(EcommerceSignalsAnalyzer::new()),
5123 Box::new(InternationalSeoAnalyzer::new()),
5124 Box::new(crate::ai_analyzers::AiCrawlerAccessibilityAnalyzer::new()),
5126 Box::new(crate::ai_analyzers::AiContentStructureAnalyzer::new()),
5127 Box::new(crate::ai_analyzers::AiCitationEligibilityAnalyzer::new()),
5128 Box::new(crate::ai_analyzers::AiAnswerBoxAnalyzer::new()),
5129 Box::new(crate::wasm_analyzers::WasmPatternAnalyzer::new()),
5131 ],
5132 }
5133 }
5134
5135 pub fn with_analyzers(analyzers: Vec<Box<dyn Analyzer>>) -> Self {
5140 Self { analyzers }
5141 }
5142
5143 pub fn register(&mut self, analyzer: Box<dyn Analyzer>) {
5148 self.analyzers.push(analyzer);
5149 }
5150
5151 pub fn analyze(&self, ctx: &AnalysisContext, config: &CrawlConfig) -> Vec<Finding> {
5156 use rayon::prelude::*;
5157
5158 self.analyzers
5159 .par_iter()
5160 .flat_map(|a| a.analyze(ctx, config))
5161 .collect()
5162 }
5163
5164 pub fn len(&self) -> usize {
5166 self.analyzers.len()
5167 }
5168
5169 pub fn is_empty(&self) -> bool {
5171 self.analyzers.is_empty()
5172 }
5173}
5174
5175#[cfg(test)]
5180mod tests {
5181 use super::*;
5182 use crate::meta::MetaTags;
5183 use crate::parser::{ExtractedImage, ExtractedLink, Heading, StructuredData};
5184 use crate::storage::{IssueCategory, Severity};
5185
5186 fn default_config() -> CrawlConfig {
5187 CrawlConfig::default()
5188 }
5189
5190 fn make_page(url: &str) -> ParsedPage {
5191 ParsedPage {
5192 url: url.to_string(),
5193 meta: MetaTags::default(),
5194 headings: Vec::new(),
5195 links: Vec::new(),
5196 images: Vec::new(),
5197 forms: Vec::new(),
5198 scripts: Vec::new(),
5199 styles: Vec::new(),
5200 structured_data: Vec::new(),
5201 word_count: 0,
5202 landmarks: Vec::new(),
5203 has_skip_link: false,
5204 has_main_landmark: false,
5205 has_nav_landmark: false,
5206 has_positive_tabindex: false,
5207 tabindex_negative_count: 0,
5208 aria_role_count: 0,
5209 aria_label_count: 0,
5210 has_lang_attribute: false,
5211 html_lang: None,
5212 has_aria_hidden: false,
5213 tables_with_headers: 0,
5214 tables_total: 0,
5215 tables_with_captions: 0,
5216 og_image_width: None,
5217 og_image_height: None,
5218 }
5219 }
5220
5221 fn make_ctx<'a>(page: &'a ParsedPage, status: Option<u16>) -> AnalysisContext<'a> {
5222 AnalysisContext {
5223 page,
5224 status_code: status,
5225 headers: &[],
5226 response_time: None,
5227 redirect_chain: &[],
5228 robots_txt: None,
5229 }
5230 }
5231
5232 #[test]
5235 fn test_http_status_200() {
5236 let page = make_page("https://example.com");
5237 let ctx = make_ctx(&page, Some(200));
5238 let findings = HttpStatusAnalyzer::new().analyze(&ctx, &default_config());
5239 assert!(findings.iter().any(|f| f.code == "HTTP006"));
5241 }
5242
5243 #[test]
5244 fn test_http_status_404() {
5245 let page = make_page("https://example.com/missing");
5246 let ctx = make_ctx(&page, Some(404));
5247 let findings = HttpStatusAnalyzer::new().analyze(&ctx, &default_config());
5248 assert!(findings.iter().any(|f| f.code == "HTTP004"));
5249 }
5250
5251 #[test]
5252 fn test_http_status_500() {
5253 let page = make_page("https://example.com/error");
5254 let ctx = make_ctx(&page, Some(500));
5255 let findings = HttpStatusAnalyzer::new().analyze(&ctx, &default_config());
5256 assert!(findings.iter().any(|f| f.code == "HTTP005"));
5257 assert!(findings.iter().any(|f| f.severity == Severity::Critical));
5258 }
5259
5260 #[test]
5261 fn test_http_status_missing() {
5262 let page = make_page("https://example.com");
5263 let ctx = make_ctx(&page, None);
5264 let findings = HttpStatusAnalyzer::new().analyze(&ctx, &default_config());
5265 assert!(findings.iter().any(|f| f.code == "HTTP001"));
5266 }
5267
5268 #[test]
5269 fn test_http_status_soft_404_empty_body() {
5270 let mut page = make_page("https://example.com/soft404");
5271 page.word_count = 0;
5272 let ctx = make_ctx(&page, Some(200));
5273 let findings = HttpStatusAnalyzer::new().analyze(&ctx, &default_config());
5274 assert!(findings.iter().any(|f| f.code == "HTTP003"));
5275 }
5276
5277 #[test]
5278 fn test_http_status_slow_response() {
5279 let page = make_page("https://example.com/slow");
5280 let ctx = AnalysisContext {
5281 page: &page,
5282 status_code: Some(200),
5283 headers: &[],
5284 response_time: Some(Duration::from_secs(10)),
5285 redirect_chain: &[],
5286 robots_txt: None,
5287 };
5288 let findings = HttpStatusAnalyzer::new().analyze(&ctx, &default_config());
5289 assert!(findings.iter().any(|f| f.code == "HTTP002"));
5290 }
5291
5292 #[test]
5295 fn test_redirect_no_hops() {
5296 let page = make_page("https://example.com");
5297 let ctx = make_ctx(&page, Some(200));
5298 let findings = RedirectChainAnalyzer::new().analyze(&ctx, &default_config());
5299 assert!(findings.is_empty());
5300 }
5301
5302 #[test]
5303 fn test_redirect_long_chain() {
5304 let hops: Vec<RedirectHop> = (0..7)
5305 .map(|i| RedirectHop {
5306 from: Url::parse(&format!("https://example.com/page{i}")).unwrap(),
5307 to: Url::parse(&format!("https://example.com/page{}", i + 1)).unwrap(),
5308 status_code: 301,
5309 })
5310 .collect();
5311 let page = make_page("https://example.com/page0");
5312 let ctx = AnalysisContext {
5313 page: &page,
5314 status_code: Some(200),
5315 headers: &[],
5316 response_time: None,
5317 redirect_chain: &hops,
5318 robots_txt: None,
5319 };
5320 let findings = RedirectChainAnalyzer::new().analyze(&ctx, &default_config());
5321 assert!(findings.iter().any(|f| f.code == "REDIR001"));
5322 }
5323
5324 #[test]
5325 fn test_redirect_loop() {
5326 let hops = vec![
5327 RedirectHop {
5328 from: Url::parse("https://example.com/a").unwrap(),
5329 to: Url::parse("https://example.com/b").unwrap(),
5330 status_code: 301,
5331 },
5332 RedirectHop {
5333 from: Url::parse("https://example.com/b").unwrap(),
5334 to: Url::parse("https://example.com/a").unwrap(),
5335 status_code: 301,
5336 },
5337 ];
5338 let page = make_page("https://example.com/a");
5339 let ctx = AnalysisContext {
5340 page: &page,
5341 status_code: Some(200),
5342 headers: &[],
5343 response_time: None,
5344 redirect_chain: &hops,
5345 robots_txt: None,
5346 };
5347 let findings = RedirectChainAnalyzer::new().analyze(&ctx, &default_config());
5348 assert!(findings.iter().any(|f| f.code == "REDIR002"));
5349 }
5350
5351 #[test]
5352 fn test_redirect_mixed_protocol() {
5353 let hops = vec![RedirectHop {
5354 from: Url::parse("http://example.com/page").unwrap(),
5355 to: Url::parse("https://example.com/page").unwrap(),
5356 status_code: 301,
5357 }];
5358 let page = make_page("http://example.com/page");
5359 let ctx = AnalysisContext {
5360 page: &page,
5361 status_code: Some(200),
5362 headers: &[],
5363 response_time: None,
5364 redirect_chain: &hops,
5365 robots_txt: None,
5366 };
5367 let findings = RedirectChainAnalyzer::new().analyze(&ctx, &default_config());
5368 assert!(findings.iter().any(|f| f.code == "REDIR003"));
5369 }
5370
5371 #[test]
5372 fn test_redirect_single_hop() {
5373 let hops = vec![RedirectHop {
5374 from: Url::parse("https://example.com/old").unwrap(),
5375 to: Url::parse("https://example.com/new").unwrap(),
5376 status_code: 301,
5377 }];
5378 let page = make_page("https://example.com/old");
5379 let ctx = AnalysisContext {
5380 page: &page,
5381 status_code: Some(200),
5382 headers: &[],
5383 response_time: None,
5384 redirect_chain: &hops,
5385 robots_txt: None,
5386 };
5387 let findings = RedirectChainAnalyzer::new().analyze(&ctx, &default_config());
5388 assert!(findings.iter().any(|f| f.code == "REDIR004"));
5389 }
5390
5391 #[test]
5394 fn test_canonical_missing() {
5395 let page = make_page("https://example.com/page");
5396 let ctx = make_ctx(&page, Some(200));
5397 let findings = CanonicalUrlValidator::new().analyze(&ctx, &default_config());
5398 assert!(findings.iter().any(|f| f.code == "CANON001"));
5399 }
5400
5401 #[test]
5402 fn test_canonical_self_referencing() {
5403 let mut page = make_page("https://example.com/page");
5404 page.meta.canonical = Some(Url::parse("https://example.com/page").unwrap());
5405 let ctx = make_ctx(&page, Some(200));
5406 let findings = CanonicalUrlValidator::new().analyze(&ctx, &default_config());
5407 assert!(!findings.iter().any(|f| f.code == "CANON003"));
5409 }
5410
5411 #[test]
5412 fn test_canonical_mismatch() {
5413 let mut page = make_page("https://example.com/page");
5414 page.meta.canonical = Some(Url::parse("https://example.com/other").unwrap());
5415 let ctx = make_ctx(&page, Some(200));
5416 let findings = CanonicalUrlValidator::new().analyze(&ctx, &default_config());
5417 assert!(findings.iter().any(|f| f.code == "CANON003"));
5418 }
5419
5420 #[test]
5423 fn test_hreflang_no_tags() {
5424 let page = make_page("https://example.com/en");
5425 let ctx = make_ctx(&page, Some(200));
5426 let findings = HreflangValidator::new().analyze(&ctx, &default_config());
5427 assert!(findings.is_empty());
5428 }
5429
5430 #[test]
5431 fn test_hreflang_missing_x_default() {
5432 let mut page = make_page("https://example.com/en");
5433 page.meta.hreflang = vec![
5434 crate::meta::HreflangTag {
5435 lang: "en".to_string(),
5436 url: Url::parse("https://example.com/en").unwrap(),
5437 },
5438 crate::meta::HreflangTag {
5439 lang: "fr".to_string(),
5440 url: Url::parse("https://example.com/fr").unwrap(),
5441 },
5442 ];
5443 let ctx = make_ctx(&page, Some(200));
5444 let findings = HreflangValidator::new().analyze(&ctx, &default_config());
5445 assert!(findings.iter().any(|f| f.code == "HREF001"));
5446 }
5447
5448 #[test]
5449 fn test_hreflang_invalid_locale() {
5450 let mut page = make_page("https://example.com/en");
5451 page.meta.hreflang = vec![
5452 crate::meta::HreflangTag {
5453 lang: "invalid-locale-code-too-long".to_string(),
5454 url: Url::parse("https://example.com/invalid").unwrap(),
5455 },
5456 crate::meta::HreflangTag {
5457 lang: "x-default".to_string(),
5458 url: Url::parse("https://example.com").unwrap(),
5459 },
5460 ];
5461 let ctx = make_ctx(&page, Some(200));
5462 let findings = HreflangValidator::new().analyze(&ctx, &default_config());
5463 assert!(findings.iter().any(|f| f.code == "HREF002"));
5464 }
5465
5466 #[test]
5467 fn test_hreflang_duplicate_language() {
5468 let mut page = make_page("https://example.com/en");
5469 page.meta.hreflang = vec![
5470 crate::meta::HreflangTag {
5471 lang: "en".to_string(),
5472 url: Url::parse("https://example.com/en").unwrap(),
5473 },
5474 crate::meta::HreflangTag {
5475 lang: "en".to_string(),
5476 url: Url::parse("https://example.com/en-uk").unwrap(),
5477 },
5478 crate::meta::HreflangTag {
5479 lang: "x-default".to_string(),
5480 url: Url::parse("https://example.com").unwrap(),
5481 },
5482 ];
5483 let ctx = make_ctx(&page, Some(200));
5484 let findings = HreflangValidator::new().analyze(&ctx, &default_config());
5485 assert!(findings.iter().any(|f| f.code == "HREF003"));
5486 }
5487
5488 #[test]
5489 fn test_hreflang_valid_with_x_default() {
5490 let mut page = make_page("https://example.com/en");
5491 page.meta.hreflang = vec![
5492 crate::meta::HreflangTag {
5493 lang: "en".to_string(),
5494 url: Url::parse("https://example.com/en").unwrap(),
5495 },
5496 crate::meta::HreflangTag {
5497 lang: "fr".to_string(),
5498 url: Url::parse("https://example.com/fr").unwrap(),
5499 },
5500 crate::meta::HreflangTag {
5501 lang: "x-default".to_string(),
5502 url: Url::parse("https://example.com").unwrap(),
5503 },
5504 ];
5505 let ctx = make_ctx(&page, Some(200));
5506 let findings = HreflangValidator::new().analyze(&ctx, &default_config());
5507 assert!(!findings.iter().any(|f| f.severity == Severity::Error));
5509 }
5510
5511 #[test]
5514 fn test_sitemap_no_data() {
5515 let page = make_page("https://example.com/page");
5516 let ctx = make_ctx(&page, Some(200));
5517 let findings = SitemapAnalyzer::empty().analyze(&ctx, &default_config());
5518 assert!(findings.iter().any(|f| f.code == "SITEMAP001"));
5519 }
5520
5521 #[test]
5522 fn test_sitemap_url_not_found() {
5523 let mut known = HashSet::new();
5524 known.insert("https://example.com/other".to_string());
5525 let analyzer = SitemapAnalyzer::new(known, Vec::new());
5526 let page = make_page("https://example.com/page");
5527 let ctx = make_ctx(&page, Some(200));
5528 let findings = analyzer.analyze(&ctx, &default_config());
5529 assert!(findings.iter().any(|f| f.code == "SITEMAP002"));
5530 }
5531
5532 #[test]
5533 fn test_sitemap_url_found() {
5534 let mut known = HashSet::new();
5535 known.insert("https://example.com/page".to_string());
5536 let analyzer = SitemapAnalyzer::new(known, Vec::new());
5537 let page = make_page("https://example.com/page");
5538 let ctx = make_ctx(&page, Some(200));
5539 let findings = analyzer.analyze(&ctx, &default_config());
5540 assert!(!findings.iter().any(|f| f.code == "SITEMAP002"));
5541 }
5542
5543 #[test]
5544 fn test_sitemap_invalid_lastmod() {
5545 let mut known = HashSet::new();
5546 known.insert("https://example.com/page".to_string());
5547 let entries = vec![SitemapEntry {
5548 url: "https://example.com/page".to_string(),
5549 lastmod: Some("not-a-date".to_string()),
5550 changefreq: None,
5551 priority: None,
5552 }];
5553 let analyzer = SitemapAnalyzer::new(known, entries);
5554 let page = make_page("https://example.com/page");
5555 let ctx = make_ctx(&page, Some(200));
5556 let findings = analyzer.analyze(&ctx, &default_config());
5557 assert!(findings.iter().any(|f| f.code == "SITEMAP003"));
5558 }
5559
5560 #[test]
5561 fn test_sitemap_invalid_changefreq() {
5562 let mut known = HashSet::new();
5563 known.insert("https://example.com/page".to_string());
5564 let entries = vec![SitemapEntry {
5565 url: "https://example.com/page".to_string(),
5566 lastmod: None,
5567 changefreq: Some("sometimes".to_string()),
5568 priority: None,
5569 }];
5570 let analyzer = SitemapAnalyzer::new(known, entries);
5571 let page = make_page("https://example.com/page");
5572 let ctx = make_ctx(&page, Some(200));
5573 let findings = analyzer.analyze(&ctx, &default_config());
5574 assert!(findings.iter().any(|f| f.code == "SITEMAP004"));
5575 }
5576
5577 #[test]
5578 fn test_sitemap_invalid_priority() {
5579 let mut known = HashSet::new();
5580 known.insert("https://example.com/page".to_string());
5581 let entries = vec![SitemapEntry {
5582 url: "https://example.com/page".to_string(),
5583 lastmod: None,
5584 changefreq: None,
5585 priority: Some(2.5),
5586 }];
5587 let analyzer = SitemapAnalyzer::new(known, entries);
5588 let page = make_page("https://example.com/page");
5589 let ctx = make_ctx(&page, Some(200));
5590 let findings = analyzer.analyze(&ctx, &default_config());
5591 assert!(findings.iter().any(|f| f.code == "SITEMAP005"));
5592 }
5593
5594 #[test]
5595 fn test_sitemap_valid_metadata() {
5596 let mut known = HashSet::new();
5597 known.insert("https://example.com/page".to_string());
5598 let entries = vec![SitemapEntry {
5599 url: "https://example.com/page".to_string(),
5600 lastmod: Some("2024-01-15T10:30:00Z".to_string()),
5601 changefreq: Some("weekly".to_string()),
5602 priority: Some(0.8),
5603 }];
5604 let analyzer = SitemapAnalyzer::new(known, entries);
5605 let page = make_page("https://example.com/page");
5606 let ctx = make_ctx(&page, Some(200));
5607 let findings = analyzer.analyze(&ctx, &default_config());
5608 assert!(!findings.iter().any(|f| f.severity == Severity::Error));
5610 }
5611
5612 #[test]
5615 fn test_robots_empty() {
5616 let page = make_page("https://example.com/page");
5617 let ctx = make_ctx(&page, Some(200));
5618 let findings = RobotsTxtAnalyzer::empty().analyze(&ctx, &default_config());
5619 assert!(findings.is_empty());
5620 }
5621
5622 #[test]
5623 fn test_robots_disallowed() {
5624 let rules = vec![RobotsRule {
5625 user_agent: "*".to_string(),
5626 disallowed_paths: vec!["/admin".to_string()],
5627 allowed_paths: Vec::new(),
5628 crawl_delay: None,
5629 sitemaps: Vec::new(),
5630 }];
5631 let analyzer = RobotsTxtAnalyzer::new(rules, Vec::new());
5632 let page = make_page("https://example.com/admin/secret");
5633 let ctx = make_ctx(&page, Some(200));
5634 let findings = analyzer.analyze(&ctx, &default_config());
5635 assert!(findings.iter().any(|f| f.code == "ROBOT002"));
5636 }
5637
5638 #[test]
5639 fn test_robots_allowed() {
5640 let rules = vec![RobotsRule {
5641 user_agent: "*".to_string(),
5642 disallowed_paths: vec!["/admin".to_string()],
5643 allowed_paths: Vec::new(),
5644 crawl_delay: None,
5645 sitemaps: Vec::new(),
5646 }];
5647 let analyzer = RobotsTxtAnalyzer::new(rules, Vec::new());
5648 let page = make_page("https://example.com/page");
5649 let ctx = make_ctx(&page, Some(200));
5650 let findings = analyzer.analyze(&ctx, &default_config());
5651 assert!(!findings.iter().any(|f| f.code == "ROBOT002"));
5652 }
5653
5654 #[test]
5655 fn test_robots_allow_override() {
5656 let rules = vec![RobotsRule {
5657 user_agent: "*".to_string(),
5658 disallowed_paths: vec!["/admin".to_string()],
5659 allowed_paths: vec!["/admin/public".to_string()],
5660 crawl_delay: None,
5661 sitemaps: Vec::new(),
5662 }];
5663 let analyzer = RobotsTxtAnalyzer::new(rules, Vec::new());
5664 let page = make_page("https://example.com/admin/public/page");
5665 let ctx = make_ctx(&page, Some(200));
5666 let findings = analyzer.analyze(&ctx, &default_config());
5667 assert!(findings.iter().any(|f| f.code == "ROBOT001"));
5668 }
5669
5670 #[test]
5671 fn test_robots_high_crawl_delay() {
5672 let rules = vec![RobotsRule {
5673 user_agent: "*".to_string(),
5674 disallowed_paths: Vec::new(),
5675 allowed_paths: Vec::new(),
5676 crawl_delay: Some(20.0),
5677 sitemaps: Vec::new(),
5678 }];
5679 let analyzer = RobotsTxtAnalyzer::new(rules, Vec::new());
5680 let page = make_page("https://example.com/page");
5681 let ctx = make_ctx(&page, Some(200));
5682 let findings = analyzer.analyze(&ctx, &default_config());
5683 assert!(findings.iter().any(|f| f.code == "ROBOT003"));
5684 }
5685
5686 #[test]
5687 fn test_robots_invalid_sitemap_url() {
5688 let rules = vec![RobotsRule {
5689 user_agent: "*".to_string(),
5690 disallowed_paths: Vec::new(),
5691 allowed_paths: Vec::new(),
5692 crawl_delay: None,
5693 sitemaps: Vec::new(),
5694 }];
5695 let analyzer = RobotsTxtAnalyzer::new(rules, vec!["not-a-url".to_string()]);
5696 let page = make_page("https://example.com/page");
5697 let ctx = make_ctx(&page, Some(200));
5698 let findings = analyzer.analyze(&ctx, &default_config());
5699 assert!(findings.iter().any(|f| f.code == "ROBOT004"));
5700 }
5701
5702 #[test]
5705 fn test_meta_missing_title() {
5706 let page = make_page("https://example.com");
5707 let ctx = make_ctx(&page, Some(200));
5708 let findings = MetaTagAnalyzer::new().analyze(&ctx, &default_config());
5709 assert!(findings.iter().any(|f| f.code == "META001"));
5710 }
5711
5712 #[test]
5713 fn test_meta_title_too_short() {
5714 let mut page = make_page("https://example.com");
5715 page.meta.title = Some("Hi".to_string());
5716 let ctx = make_ctx(&page, Some(200));
5717 let findings = MetaTagAnalyzer::new().analyze(&ctx, &default_config());
5718 assert!(findings.iter().any(|f| f.code == "META002"));
5719 }
5720
5721 #[test]
5722 fn test_meta_title_too_long() {
5723 let mut page = make_page("https://example.com");
5724 page.meta.title = Some("A".repeat(80));
5725 let ctx = make_ctx(&page, Some(200));
5726 let findings = MetaTagAnalyzer::new().analyze(&ctx, &default_config());
5727 assert!(findings.iter().any(|f| f.code == "META003"));
5728 }
5729
5730 #[test]
5731 fn test_meta_title_just_right() {
5732 let mut page = make_page("https://example.com");
5733 page.meta.title = Some("A".repeat(45));
5734 let ctx = make_ctx(&page, Some(200));
5735 let findings = MetaTagAnalyzer::new().analyze(&ctx, &default_config());
5736 assert!(!findings.iter().any(|f| f.code == "META002"));
5737 assert!(!findings.iter().any(|f| f.code == "META003"));
5738 }
5739
5740 #[test]
5741 fn test_meta_missing_description() {
5742 let page = make_page("https://example.com");
5743 let ctx = make_ctx(&page, Some(200));
5744 let findings = MetaTagAnalyzer::new().analyze(&ctx, &default_config());
5745 assert!(findings.iter().any(|f| f.code == "META004"));
5746 }
5747
5748 #[test]
5749 fn test_meta_description_too_short() {
5750 let mut page = make_page("https://example.com");
5751 page.meta.description = Some("Short".to_string());
5752 let ctx = make_ctx(&page, Some(200));
5753 let findings = MetaTagAnalyzer::new().analyze(&ctx, &default_config());
5754 assert!(findings.iter().any(|f| f.code == "META005"));
5755 }
5756
5757 #[test]
5758 fn test_meta_description_too_long() {
5759 let mut page = make_page("https://example.com");
5760 page.meta.description = Some("A".repeat(200));
5761 let ctx = make_ctx(&page, Some(200));
5762 let findings = MetaTagAnalyzer::new().analyze(&ctx, &default_config());
5763 assert!(findings.iter().any(|f| f.code == "META006"));
5764 }
5765
5766 #[test]
5767 fn test_meta_missing_og_tags() {
5768 let page = make_page("https://example.com");
5769 let ctx = make_ctx(&page, Some(200));
5770 let findings = MetaTagAnalyzer::new().analyze(&ctx, &default_config());
5771 let og_codes: Vec<&str> = findings
5773 .iter()
5774 .filter(|f| f.code == "META007")
5775 .map(|f| f.title.as_str())
5776 .collect();
5777 assert!(og_codes.len() >= 4);
5778 }
5779
5780 #[test]
5781 fn test_meta_missing_twitter_tags() {
5782 let page = make_page("https://example.com");
5783 let ctx = make_ctx(&page, Some(200));
5784 let findings = MetaTagAnalyzer::new().analyze(&ctx, &default_config());
5785 let tw_count = findings.iter().filter(|f| f.code == "META008").count();
5787 assert!(tw_count >= 3);
5788 }
5789
5790 #[test]
5791 fn test_meta_missing_viewport() {
5792 let page = make_page("https://example.com");
5793 let ctx = make_ctx(&page, Some(200));
5794 let findings = MetaTagAnalyzer::new().analyze(&ctx, &default_config());
5795 assert!(findings.iter().any(|f| f.code == "META009"));
5796 }
5797
5798 #[test]
5799 fn test_meta_complete_tags() {
5800 let mut page = make_page("https://example.com");
5801 page.meta.title = Some("Perfect Title for SEO".to_string());
5802 page.meta.description = Some("A".repeat(140));
5803 page.meta.viewport = Some("width=device-width".to_string());
5804 page.meta.og.title = Some("OG Title".to_string());
5805 page.meta.og.image = Some("https://example.com/img.png".to_string());
5806 page.meta.og.url = Some("https://example.com".to_string());
5807 page.meta.og.r#type = Some("website".to_string());
5808 page.meta.twitter.card = Some("summary_large_image".to_string());
5809 page.meta.twitter.title = Some("Twitter Title".to_string());
5810 page.meta.twitter.image = Some("https://example.com/tw.png".to_string());
5811 let ctx = make_ctx(&page, Some(200));
5812 let findings = MetaTagAnalyzer::new().analyze(&ctx, &default_config());
5813 assert!(!findings
5815 .iter()
5816 .any(|f| f.code == "META001" || f.code == "META004"));
5817 }
5818
5819 #[test]
5822 fn test_heading_no_headings() {
5823 let page = make_page("https://example.com");
5824 let ctx = make_ctx(&page, Some(200));
5825 let findings = HeadingHierarchyAnalyzer::new().analyze(&ctx, &default_config());
5826 assert!(findings.iter().any(|f| f.code == "HEAD001"));
5827 }
5828
5829 #[test]
5830 fn test_heading_missing_h1() {
5831 let mut page = make_page("https://example.com");
5832 page.headings = vec![
5833 Heading {
5834 level: 2,
5835 text: "Section".to_string(),
5836 length: 7,
5837 },
5838 Heading {
5839 level: 3,
5840 text: "Sub".to_string(),
5841 length: 3,
5842 },
5843 ];
5844 let ctx = make_ctx(&page, Some(200));
5845 let findings = HeadingHierarchyAnalyzer::new().analyze(&ctx, &default_config());
5846 assert!(findings.iter().any(|f| f.code == "HEAD002"));
5847 }
5848
5849 #[test]
5850 fn test_heading_multiple_h1() {
5851 let mut page = make_page("https://example.com");
5852 page.headings = vec![
5853 Heading {
5854 level: 1,
5855 text: "First".to_string(),
5856 length: 5,
5857 },
5858 Heading {
5859 level: 1,
5860 text: "Second".to_string(),
5861 length: 6,
5862 },
5863 ];
5864 let ctx = make_ctx(&page, Some(200));
5865 let findings = HeadingHierarchyAnalyzer::new().analyze(&ctx, &default_config());
5866 assert!(findings.iter().any(|f| f.code == "HEAD003"));
5867 }
5868
5869 #[test]
5870 fn test_heading_skipped_level() {
5871 let mut page = make_page("https://example.com");
5872 page.headings = vec![
5873 Heading {
5874 level: 1,
5875 text: "Title".to_string(),
5876 length: 5,
5877 },
5878 Heading {
5879 level: 3,
5880 text: "Skipped H2".to_string(),
5881 length: 10,
5882 },
5883 ];
5884 let ctx = make_ctx(&page, Some(200));
5885 let findings = HeadingHierarchyAnalyzer::new().analyze(&ctx, &default_config());
5886 assert!(findings.iter().any(|f| f.code == "HEAD004"));
5887 }
5888
5889 #[test]
5890 fn test_heading_valid_hierarchy() {
5891 let mut page = make_page("https://example.com");
5892 page.headings = vec![
5893 Heading {
5894 level: 1,
5895 text: "Main".to_string(),
5896 length: 4,
5897 },
5898 Heading {
5899 level: 2,
5900 text: "Section".to_string(),
5901 length: 7,
5902 },
5903 Heading {
5904 level: 2,
5905 text: "Section 2".to_string(),
5906 length: 9,
5907 },
5908 Heading {
5909 level: 3,
5910 text: "Sub".to_string(),
5911 length: 3,
5912 },
5913 ];
5914 let ctx = make_ctx(&page, Some(200));
5915 let findings = HeadingHierarchyAnalyzer::new().analyze(&ctx, &default_config());
5916 assert!(!findings.iter().any(|f| f.code == "HEAD004"));
5917 }
5918
5919 #[test]
5920 fn test_heading_deep_hierarchy() {
5921 let mut page = make_page("https://example.com");
5922 page.headings = vec![
5923 Heading {
5924 level: 1,
5925 text: "H1".to_string(),
5926 length: 2,
5927 },
5928 Heading {
5929 level: 2,
5930 text: "H2".to_string(),
5931 length: 2,
5932 },
5933 Heading {
5934 level: 3,
5935 text: "H3".to_string(),
5936 length: 2,
5937 },
5938 Heading {
5939 level: 4,
5940 text: "H4".to_string(),
5941 length: 2,
5942 },
5943 Heading {
5944 level: 5,
5945 text: "H5".to_string(),
5946 length: 2,
5947 },
5948 ];
5949 let ctx = make_ctx(&page, Some(200));
5950 let findings = HeadingHierarchyAnalyzer::new().analyze(&ctx, &default_config());
5951 assert!(findings.iter().any(|f| f.code == "HEAD005"));
5952 }
5953
5954 #[test]
5957 fn test_registry_default() {
5958 let config = default_config();
5959 let registry = AnalyzerRegistry::new(&config);
5960 assert_eq!(registry.len(), 28);
5961 assert!(!registry.is_empty());
5962 }
5963
5964 #[test]
5965 fn test_registry_analyze() {
5966 let config = default_config();
5967 let registry = AnalyzerRegistry::new(&config);
5968 let mut page = make_page("https://example.com");
5969 page.meta.title = Some("Good Title Here for SEO".to_string());
5970 let ctx = make_ctx(&page, Some(200));
5971 let findings = registry.analyze(&ctx, &config);
5972 assert!(!findings.is_empty());
5974 }
5975
5976 #[test]
5977 fn test_registry_custom() {
5978 struct DummyAnalyzer;
5979 impl Analyzer for DummyAnalyzer {
5980 fn name(&self) -> &str {
5981 "dummy"
5982 }
5983 fn analyze(&self, _ctx: &AnalysisContext, _config: &CrawlConfig) -> Vec<Finding> {
5984 vec![Finding {
5985 severity: Severity::Info,
5986 category: IssueCategory::Custom("test".to_string()),
5987 code: "DUMMY001".to_string(),
5988 title: "Dummy finding".to_string(),
5989 description: "Test".to_string(),
5990 url: String::new(),
5991 recommendation: "None".to_string(),
5992 }]
5993 }
5994 }
5995 let mut registry = AnalyzerRegistry::with_analyzers(Vec::new());
5996 registry.register(Box::new(DummyAnalyzer));
5997 assert_eq!(registry.len(), 1);
5998
5999 let page = make_page("https://example.com");
6000 let ctx = make_ctx(&page, Some(200));
6001 let findings = registry.analyze(&ctx, &default_config());
6002 assert_eq!(findings.len(), 1);
6003 assert_eq!(findings[0].code, "DUMMY001");
6004 }
6005
6006 #[test]
6009 fn test_valid_locales() {
6010 assert!(HreflangValidator::is_valid_locale("en"));
6011 assert!(HreflangValidator::is_valid_locale("fr"));
6012 assert!(HreflangValidator::is_valid_locale("de"));
6013 assert!(HreflangValidator::is_valid_locale("en-US"));
6014 assert!(HreflangValidator::is_valid_locale("fr-CA"));
6015 assert!(HreflangValidator::is_valid_locale("zh-CN"));
6016 assert!(HreflangValidator::is_valid_locale("x-default"));
6017 }
6018
6019 #[test]
6020 fn test_invalid_locales() {
6021 assert!(!HreflangValidator::is_valid_locale("e"));
6022 assert!(!HreflangValidator::is_valid_locale("english"));
6023 assert!(!HreflangValidator::is_valid_locale("en-us-extra"));
6024 assert!(!HreflangValidator::is_valid_locale("123"));
6025 }
6026
6027 #[test]
6030 fn test_soft_404_indicators() {
6031 assert!(HttpStatusAnalyzer::is_soft_404(
6032 "<html><body>Page Not Found</body></html>"
6033 ));
6034 assert!(HttpStatusAnalyzer::is_soft_404(
6035 "Error 404 — The page you requested does not exist."
6036 ));
6037 assert!(HttpStatusAnalyzer::is_soft_404(
6038 "Sorry, we couldn't find the page you're looking for."
6039 ));
6040 assert!(!HttpStatusAnalyzer::is_soft_404(
6041 "<html><body>Welcome to our site</body></html>"
6042 ));
6043 }
6044
6045 #[test]
6048 fn test_robots_path_matching() {
6049 assert!(RobotsTxtAnalyzer::is_disallowed(
6050 "/admin/secret",
6051 &["/admin".to_string()]
6052 ));
6053 assert!(!RobotsTxtAnalyzer::is_disallowed(
6054 "/page",
6055 &["/admin".to_string()]
6056 ));
6057 assert!(RobotsTxtAnalyzer::is_allowed(
6058 "/admin/public",
6059 &["/admin/public".to_string()]
6060 ));
6061 assert!(!RobotsTxtAnalyzer::is_allowed(
6062 "/admin/secret",
6063 &["/admin/public".to_string()]
6064 ));
6065 }
6066
6067 #[test]
6070 fn test_finding_creation() {
6071 let finding = Finding {
6072 severity: Severity::Warning,
6073 category: IssueCategory::Seo,
6074 code: "TEST001".to_string(),
6075 title: "Test finding".to_string(),
6076 description: "A test finding for unit tests".to_string(),
6077 url: "https://example.com".to_string(),
6078 recommendation: "Fix it".to_string(),
6079 };
6080 assert_eq!(finding.severity, Severity::Warning);
6081 assert_eq!(finding.category, IssueCategory::Seo);
6082 }
6083
6084 #[test]
6087 fn test_sitemap_valid_lastmod_formats() {
6088 assert!(SitemapAnalyzer::is_valid_lastmod("2024-01-15T10:30:00Z"));
6089 assert!(SitemapAnalyzer::is_valid_lastmod("2024-01-15"));
6090 assert!(!SitemapAnalyzer::is_valid_lastmod("lastweek"));
6091 }
6092
6093 #[test]
6094 fn test_sitemap_valid_changefreq() {
6095 assert!(SitemapAnalyzer::is_valid_changefreq("daily"));
6096 assert!(SitemapAnalyzer::is_valid_changefreq("weekly"));
6097 assert!(SitemapAnalyzer::is_valid_changefreq("never"));
6098 assert!(!SitemapAnalyzer::is_valid_changefreq("sometimes"));
6099 assert!(!SitemapAnalyzer::is_valid_changefreq("often"));
6100 }
6101
6102 #[test]
6103 fn test_sitemap_valid_priority() {
6104 assert!(SitemapAnalyzer::is_valid_priority(0.0));
6105 assert!(SitemapAnalyzer::is_valid_priority(0.5));
6106 assert!(SitemapAnalyzer::is_valid_priority(1.0));
6107 assert!(!SitemapAnalyzer::is_valid_priority(-0.1));
6108 assert!(!SitemapAnalyzer::is_valid_priority(1.1));
6109 }
6110
6111 #[test]
6114 fn test_full_analysis_minimal_page() {
6115 let config = default_config();
6116 let registry = AnalyzerRegistry::new(&config);
6117 let page = make_page("https://example.com");
6118 let ctx = make_ctx(&page, Some(200));
6119 let findings = registry.analyze(&ctx, &config);
6120
6121 let codes: Vec<&str> = findings.iter().map(|f| f.code.as_str()).collect();
6123 assert!(codes.contains(&"META001")); assert!(codes.contains(&"META004")); assert!(codes.contains(&"CANON001")); assert!(codes.contains(&"HEAD001")); }
6128
6129 #[test]
6130 fn test_full_analysis_well_optimized_page() {
6131 let config = default_config();
6132 let registry = AnalyzerRegistry::new(&config);
6133
6134 let mut page = make_page("https://example.com/page");
6135 page.meta.title = Some("Optimized Page Title for Search".to_string());
6136 page.meta.description = Some("A".repeat(145));
6137 page.meta.canonical = Some(Url::parse("https://example.com/page").unwrap());
6138 page.meta.viewport = Some("width=device-width".to_string());
6139 page.meta.og.title = Some("OG Title".to_string());
6140 page.meta.og.image = Some("https://example.com/img.png".to_string());
6141 page.meta.og.url = Some("https://example.com/page".to_string());
6142 page.meta.og.r#type = Some("article".to_string());
6143 page.meta.twitter.card = Some("summary_large_image".to_string());
6144 page.meta.twitter.title = Some("Twitter Title".to_string());
6145 page.meta.twitter.image = Some("https://example.com/tw.png".to_string());
6146 page.headings = vec![
6147 Heading {
6148 level: 1,
6149 text: "Main Topic".to_string(),
6150 length: 10,
6151 },
6152 Heading {
6153 level: 2,
6154 text: "Section".to_string(),
6155 length: 7,
6156 },
6157 ];
6158 page.has_lang_attribute = true;
6159 page.html_lang = Some("en".to_string());
6160 page.has_main_landmark = true;
6161 page.has_nav_landmark = true;
6162 page.has_skip_link = true;
6163
6164 let ctx = make_ctx(&page, Some(200));
6165 let findings = registry.analyze(&ctx, &config);
6166
6167 let errors: Vec<&Finding> = findings
6169 .iter()
6170 .filter(|f| f.severity == Severity::Error || f.severity == Severity::Critical)
6171 .collect();
6172 assert!(
6173 errors.is_empty(),
6174 "Well-optimized page should have no errors: {:?}",
6175 errors.iter().map(|f| &f.code).collect::<Vec<_>>()
6176 );
6177 }
6178
6179 #[test]
6184 fn test_link_analyzer_no_links() {
6185 let page = make_page("https://example.com");
6186 let ctx = make_ctx(&page, Some(200));
6187 let findings = LinkAnalyzer::new().analyze(&ctx, &default_config());
6188 assert!(findings.iter().any(|f| f.code == "LINK001"));
6189 let link001 = findings.iter().find(|f| f.code == "LINK001").unwrap();
6190 assert!(link001.description.contains("Internal: 0"));
6191 assert!(link001.description.contains("External: 0"));
6192 }
6193
6194 #[test]
6195 fn test_link_analyzer_internal_external_counts() {
6196 let mut page = make_page("https://example.com");
6197 page.links = vec![
6198 ExtractedLink {
6199 href: "/about".to_string(),
6200 text: "About".to_string(),
6201 rel: vec![],
6202 is_external: false,
6203 aria_label: None,
6204 img_alt: None,
6205 },
6206 ExtractedLink {
6207 href: "/contact".to_string(),
6208 text: "Contact".to_string(),
6209 rel: vec![],
6210 is_external: false,
6211 aria_label: None,
6212 img_alt: None,
6213 },
6214 ExtractedLink {
6215 href: "https://external.com".to_string(),
6216 text: "External".to_string(),
6217 rel: vec![],
6218 is_external: true,
6219 aria_label: None,
6220 img_alt: None,
6221 },
6222 ];
6223 let ctx = make_ctx(&page, Some(200));
6224 let findings = LinkAnalyzer::new().analyze(&ctx, &default_config());
6225 let link001 = findings.iter().find(|f| f.code == "LINK001").unwrap();
6226 assert!(link001.description.contains("Internal: 2"));
6227 assert!(link001.description.contains("External: 1"));
6228 }
6229
6230 #[test]
6231 fn test_link_analyzer_nofollow_detection() {
6232 let mut page = make_page("https://example.com");
6233 page.links = vec![ExtractedLink {
6234 href: "https://external.com".to_string(),
6235 text: "Nofollow link".to_string(),
6236 rel: vec!["nofollow".to_string()],
6237 is_external: true,
6238 aria_label: None,
6239 img_alt: None,
6240 }];
6241 let ctx = make_ctx(&page, Some(200));
6242 let findings = LinkAnalyzer::new().analyze(&ctx, &default_config());
6243 assert!(findings.iter().any(|f| f.code == "LINK003"));
6244 }
6245
6246 #[test]
6247 fn test_link_analyzer_empty_anchor_text() {
6248 let mut page = make_page("https://example.com");
6249 page.links = vec![ExtractedLink {
6250 href: "/page".to_string(),
6251 text: String::new(),
6252 rel: vec![],
6253 is_external: false,
6254 aria_label: None,
6255 img_alt: None,
6256 }];
6257 let ctx = make_ctx(&page, Some(200));
6258 let findings = LinkAnalyzer::new().analyze(&ctx, &default_config());
6259 assert!(findings.iter().any(|f| f.code == "LINK004"));
6260 }
6261
6262 #[test]
6263 fn test_link_analyzer_short_anchor_text() {
6264 let mut page = make_page("https://example.com");
6265 page.links = vec![ExtractedLink {
6266 href: "/page".to_string(),
6267 text: "Go".to_string(),
6268 rel: vec![],
6269 is_external: false,
6270 aria_label: None,
6271 img_alt: None,
6272 }];
6273 let ctx = make_ctx(&page, Some(200));
6274 let findings = LinkAnalyzer::new().analyze(&ctx, &default_config());
6275 assert!(findings.iter().any(|f| f.code == "LINK005"));
6276 }
6277
6278 #[test]
6279 fn test_link_analyzer_broken_page_links() {
6280 let mut page = make_page("https://example.com");
6281 page.links = vec![ExtractedLink {
6282 href: "/broken".to_string(),
6283 text: "Broken link".to_string(),
6284 rel: vec![],
6285 is_external: false,
6286 aria_label: None,
6287 img_alt: None,
6288 }];
6289 let ctx = make_ctx(&page, Some(404));
6290 let findings = LinkAnalyzer::new().analyze(&ctx, &default_config());
6291 assert!(findings.iter().any(|f| f.code == "LINK002"));
6292 }
6293
6294 #[test]
6295 fn test_link_analyzer_orphan_page() {
6296 let mut inbound = HashMap::new();
6297 inbound.insert("https://example.com/orphan".to_string(), 0);
6298 let analyzer = LinkAnalyzer::with_inbound_links(inbound);
6299 let page = make_page("https://example.com/orphan");
6300 let ctx = make_ctx(&page, Some(200));
6301 let findings = analyzer.analyze(&ctx, &default_config());
6302 assert!(findings.iter().any(|f| f.code == "LINK006"));
6303 }
6304
6305 #[test]
6306 fn test_link_analyzer_not_orphan() {
6307 let mut inbound = HashMap::new();
6308 inbound.insert("https://example.com/page".to_string(), 3);
6309 let analyzer = LinkAnalyzer::with_inbound_links(inbound);
6310 let page = make_page("https://example.com/page");
6311 let ctx = make_ctx(&page, Some(200));
6312 let findings = analyzer.analyze(&ctx, &default_config());
6313 assert!(!findings.iter().any(|f| f.code == "LINK006"));
6314 }
6315
6316 #[test]
6317 fn test_link_analyzer_no_nofollow_when_absent() {
6318 let mut page = make_page("https://example.com");
6319 page.links = vec![ExtractedLink {
6320 href: "/page".to_string(),
6321 text: "Go".to_string(),
6322 rel: vec![],
6323 is_external: false,
6324 aria_label: None,
6325 img_alt: None,
6326 }];
6327 let ctx = make_ctx(&page, Some(200));
6328 let findings = LinkAnalyzer::new().analyze(&ctx, &default_config());
6329 assert!(!findings.iter().any(|f| f.code == "LINK003"));
6330 }
6331
6332 #[test]
6337 fn test_image_analyzer_no_images() {
6338 let page = make_page("https://example.com");
6339 let ctx = make_ctx(&page, Some(200));
6340 let findings = ImageAnalyzer::new().analyze(&ctx, &default_config());
6341 assert!(findings.is_empty());
6342 }
6343
6344 #[test]
6345 fn test_image_analyzer_missing_alt_text() {
6346 let mut page = make_page("https://example.com");
6347 page.images = vec![ExtractedImage {
6348 src: "/img.png".to_string(),
6349 alt: String::new(),
6350 width: None,
6351 height: None,
6352 has_alt: false,
6353 is_lazy_loaded: false,
6354 }];
6355 let ctx = make_ctx(&page, Some(200));
6356 let findings = ImageAnalyzer::new().analyze(&ctx, &default_config());
6357 assert!(findings.iter().any(|f| f.code == "IMG001"));
6358 }
6359
6360 #[test]
6361 fn test_image_analyzer_with_alt_text() {
6362 let mut page = make_page("https://example.com");
6363 page.images = vec![ExtractedImage {
6364 src: "/img.png".to_string(),
6365 alt: "A photo".to_string(),
6366 width: Some(100),
6367 height: Some(200),
6368 has_alt: true,
6369 is_lazy_loaded: false,
6370 }];
6371 let ctx = make_ctx(&page, Some(200));
6372 let findings = ImageAnalyzer::new().analyze(&ctx, &default_config());
6373 assert!(!findings.iter().any(|f| f.code == "IMG001"));
6374 }
6375
6376 #[test]
6377 fn test_image_analyzer_lazy_loading() {
6378 let mut page = make_page("https://example.com");
6379 page.images = vec![
6380 ExtractedImage {
6381 src: "/a.png".to_string(),
6382 alt: "A".to_string(),
6383 width: None,
6384 height: None,
6385 has_alt: true,
6386 is_lazy_loaded: true,
6387 },
6388 ExtractedImage {
6389 src: "/b.png".to_string(),
6390 alt: "B".to_string(),
6391 width: None,
6392 height: None,
6393 has_alt: true,
6394 is_lazy_loaded: true,
6395 },
6396 ExtractedImage {
6397 src: "/c.png".to_string(),
6398 alt: "C".to_string(),
6399 width: None,
6400 height: None,
6401 has_alt: true,
6402 is_lazy_loaded: false,
6403 },
6404 ];
6405 let ctx = make_ctx(&page, Some(200));
6406 let findings = ImageAnalyzer::new().analyze(&ctx, &default_config());
6407 assert!(findings.iter().any(|f| f.code == "IMG003"));
6408 let img003 = findings.iter().find(|f| f.code == "IMG003").unwrap();
6409 assert!(img003.description.contains("2 of 3"));
6410 }
6411
6412 #[test]
6413 fn test_image_analyzer_missing_dimensions() {
6414 let mut page = make_page("https://example.com");
6415 page.images = vec![ExtractedImage {
6416 src: "/no-dims.png".to_string(),
6417 alt: "No dims".to_string(),
6418 width: None,
6419 height: None,
6420 has_alt: true,
6421 is_lazy_loaded: false,
6422 }];
6423 let ctx = make_ctx(&page, Some(200));
6424 let findings = ImageAnalyzer::new().analyze(&ctx, &default_config());
6425 assert!(findings.iter().any(|f| f.code == "IMG004"));
6426 }
6427
6428 #[test]
6429 fn test_image_analyzer_detect_format() {
6430 assert_eq!(
6431 ImageAnalyzer::detect_format("photo.jpg"),
6432 Some("jpeg".into())
6433 );
6434 assert_eq!(
6435 ImageAnalyzer::detect_format("photo.jpeg"),
6436 Some("jpeg".into())
6437 );
6438 assert_eq!(ImageAnalyzer::detect_format("pic.png"), Some("png".into()));
6439 assert_eq!(ImageAnalyzer::detect_format("anim.gif"), Some("gif".into()));
6440 assert_eq!(
6441 ImageAnalyzer::detect_format("modern.webp"),
6442 Some("webp".into())
6443 );
6444 assert_eq!(
6445 ImageAnalyzer::detect_format("new.avif"),
6446 Some("avif".into())
6447 );
6448 assert_eq!(ImageAnalyzer::detect_format("icon.svg"), Some("svg".into()));
6449 assert_eq!(
6450 ImageAnalyzer::detect_format("photo.jpg?v=1"),
6451 Some("jpeg".into())
6452 );
6453 assert_eq!(ImageAnalyzer::detect_format("no-ext"), None);
6454 }
6455
6456 #[test]
6461 fn test_structured_data_no_data() {
6462 let page = make_page("https://example.com");
6463 let ctx = make_ctx(&page, Some(200));
6464 let findings = StructuredDataValidator::new().analyze(&ctx, &default_config());
6465 assert!(findings.iter().any(|f| f.code == "SD001"));
6466 }
6467
6468 #[test]
6469 fn test_structured_data_missing_context() {
6470 let mut page = make_page("https://example.com");
6471 page.structured_data = vec![StructuredData {
6472 context: None,
6473 r#type: Some("Article".to_string()),
6474 data: serde_json::json!({"@type": "Article", "headline": "Test"}),
6475 }];
6476 let ctx = make_ctx(&page, Some(200));
6477 let findings = StructuredDataValidator::new().analyze(&ctx, &default_config());
6478 assert!(findings.iter().any(|f| f.code == "SD002"));
6479 }
6480
6481 #[test]
6482 fn test_structured_data_wrong_context() {
6483 let mut page = make_page("https://example.com");
6484 page.structured_data = vec![StructuredData {
6485 context: Some("https://example.com/schema".to_string()),
6486 r#type: Some("Article".to_string()),
6487 data: serde_json::json!({"@type": "Article"}),
6488 }];
6489 let ctx = make_ctx(&page, Some(200));
6490 let findings = StructuredDataValidator::new().analyze(&ctx, &default_config());
6491 assert!(findings.iter().any(|f| f.code == "SD003"));
6492 }
6493
6494 #[test]
6495 fn test_structured_data_missing_type() {
6496 let mut page = make_page("https://example.com");
6497 page.structured_data = vec![StructuredData {
6498 context: Some("https://schema.org".to_string()),
6499 r#type: None,
6500 data: serde_json::json!({"@context": "https://schema.org"}),
6501 }];
6502 let ctx = make_ctx(&page, Some(200));
6503 let findings = StructuredDataValidator::new().analyze(&ctx, &default_config());
6504 assert!(findings.iter().any(|f| f.code == "SD004"));
6505 }
6506
6507 #[test]
6508 fn test_structured_data_unknown_type() {
6509 let mut page = make_page("https://example.com");
6510 page.structured_data = vec![StructuredData {
6511 context: Some("https://schema.org".to_string()),
6512 r#type: Some("CustomWidget".to_string()),
6513 data: serde_json::json!({"@context": "https://schema.org", "@type": "CustomWidget"}),
6514 }];
6515 let ctx = make_ctx(&page, Some(200));
6516 let findings = StructuredDataValidator::new().analyze(&ctx, &default_config());
6517 assert!(findings.iter().any(|f| f.code == "SD005"));
6518 }
6519
6520 #[test]
6521 fn test_structured_data_missing_required_properties() {
6522 let mut page = make_page("https://example.com");
6523 page.structured_data = vec![StructuredData {
6524 context: Some("https://schema.org".to_string()),
6525 r#type: Some("Article".to_string()),
6526 data: serde_json::json!({
6527 "@context": "https://schema.org",
6528 "@type": "Article"
6529 }),
6530 }];
6531 let ctx = make_ctx(&page, Some(200));
6532 let findings = StructuredDataValidator::new().analyze(&ctx, &default_config());
6533 assert!(findings.iter().any(|f| f.code == "SD006"));
6534 let sd006 = findings.iter().find(|f| f.code == "SD006").unwrap();
6535 assert!(sd006.description.contains("headline"));
6536 assert!(sd006.description.contains("author"));
6537 }
6538
6539 #[test]
6540 fn test_structured_data_valid_article() {
6541 let mut page = make_page("https://example.com");
6542 page.structured_data = vec![StructuredData {
6543 context: Some("https://schema.org".to_string()),
6544 r#type: Some("Article".to_string()),
6545 data: serde_json::json!({
6546 "@context": "https://schema.org",
6547 "@type": "Article",
6548 "headline": "Test Article",
6549 "author": "John Doe"
6550 }),
6551 }];
6552 let ctx = make_ctx(&page, Some(200));
6553 let findings = StructuredDataValidator::new().analyze(&ctx, &default_config());
6554 assert!(!findings.iter().any(|f| f.code == "SD006"));
6555 }
6556
6557 #[test]
6558 fn test_structured_data_valid_product() {
6559 let mut page = make_page("https://example.com");
6560 page.structured_data = vec![StructuredData {
6561 context: Some("https://schema.org".to_string()),
6562 r#type: Some("Product".to_string()),
6563 data: serde_json::json!({
6564 "@context": "https://schema.org",
6565 "@type": "Product",
6566 "name": "Widget"
6567 }),
6568 }];
6569 let ctx = make_ctx(&page, Some(200));
6570 let findings = StructuredDataValidator::new().analyze(&ctx, &default_config());
6571 assert!(!findings.iter().any(|f| f.code == "SD006"));
6572 }
6573
6574 #[test]
6575 fn test_structured_data_schema_org_context() {
6576 let mut page = make_page("https://example.com");
6577 page.structured_data = vec![StructuredData {
6578 context: Some("schema.org".to_string()),
6579 r#type: Some("WebSite".to_string()),
6580 data: serde_json::json!({
6581 "@context": "schema.org",
6582 "@type": "WebSite",
6583 "name": "My Site"
6584 }),
6585 }];
6586 let ctx = make_ctx(&page, Some(200));
6587 let findings = StructuredDataValidator::new().analyze(&ctx, &default_config());
6588 assert!(!findings.iter().any(|f| f.code == "SD003"));
6590 }
6591
6592 #[test]
6597 fn test_content_quality_empty_page() {
6598 let page = make_page("https://example.com");
6599 let ctx = make_ctx(&page, Some(200));
6600 let findings = ContentQualityAnalyzer::new().analyze(&ctx, &default_config());
6601 assert!(findings.iter().any(|f| f.code == "CQ003"));
6603 }
6604
6605 #[test]
6606 fn test_content_quality_thin_content() {
6607 let mut page = make_page("https://example.com");
6608 page.word_count = 150;
6609 let ctx = make_ctx(&page, Some(200));
6610 let findings = ContentQualityAnalyzer::new().analyze(&ctx, &default_config());
6611 assert!(findings.iter().any(|f| f.code == "CQ004"));
6612 }
6613
6614 #[test]
6615 fn test_content_quality_long_form() {
6616 let mut page = make_page("https://example.com");
6617 page.word_count = 5000;
6618 let ctx = make_ctx(&page, Some(200));
6619 let findings = ContentQualityAnalyzer::new().analyze(&ctx, &default_config());
6620 assert!(findings.iter().any(|f| f.code == "CQ005"));
6621 }
6622
6623 #[test]
6624 fn test_content_quality_readability_score() {
6625 let mut page = make_page("https://example.com");
6626 page.word_count = 500;
6627 page.headings = vec![Heading {
6628 level: 1,
6629 text: "This is a simple heading for testing readability scores in content analysis"
6630 .to_string(),
6631 length: 66,
6632 }];
6633 let ctx = make_ctx(&page, Some(200));
6634 let findings = ContentQualityAnalyzer::new().analyze(&ctx, &default_config());
6635 assert!(findings.iter().any(|f| f.code == "CQ001"));
6636 }
6637
6638 #[test]
6639 fn test_content_quality_keyword_density() {
6640 let mut page = make_page("https://example.com");
6641 page.word_count = 500;
6642 page.headings = vec![
6643 Heading {
6644 level: 1,
6645 text: "Rust Programming Language Tutorial".to_string(),
6646 length: 33,
6647 },
6648 Heading {
6649 level: 2,
6650 text: "Rust Basics for Beginners".to_string(),
6651 length: 24,
6652 },
6653 Heading {
6654 level: 2,
6655 text: "Advanced Rust Programming".to_string(),
6656 length: 24,
6657 },
6658 ];
6659 let ctx = make_ctx(&page, Some(200));
6660 let findings = ContentQualityAnalyzer::new().analyze(&ctx, &default_config());
6661 assert!(findings.iter().any(|f| f.code == "CQ002"));
6662 let cq002 = findings.iter().find(|f| f.code == "CQ002").unwrap();
6663 assert!(cq002.description.contains("rust"));
6664 }
6665
6666 #[test]
6667 fn test_content_quality_syllable_counting() {
6668 assert_eq!(ContentQualityAnalyzer::count_syllables("cat"), 1);
6669 assert_eq!(ContentQualityAnalyzer::count_syllables("hello"), 2);
6670 assert_eq!(ContentQualityAnalyzer::count_syllables("beautiful"), 3);
6671 assert_eq!(ContentQualityAnalyzer::count_syllables("a"), 1);
6672 assert_eq!(ContentQualityAnalyzer::count_syllables(""), 0);
6673 }
6674
6675 #[test]
6676 fn test_content_quality_sentence_counting() {
6677 assert_eq!(ContentQualityAnalyzer::count_sentences("Hello world."), 1);
6678 assert_eq!(
6679 ContentQualityAnalyzer::count_sentences("First sentence. Second sentence! Third?"),
6680 3
6681 );
6682 assert_eq!(ContentQualityAnalyzer::count_sentences(""), 0);
6683 assert_eq!(ContentQualityAnalyzer::count_sentences(" "), 0);
6684 }
6685
6686 #[test]
6687 fn test_content_quality_flesch_kincaid() {
6688 let score = ContentQualityAnalyzer::flesch_kincaid(100, 5, 150);
6690 assert!(score > 50.0);
6691
6692 let score = ContentQualityAnalyzer::flesch_kincaid(100, 20, 300);
6694 assert!(score < 50.0);
6695
6696 assert_eq!(ContentQualityAnalyzer::flesch_kincaid(0, 0, 0), 0.0);
6698 }
6699
6700 #[test]
6705 fn test_word_count_analyzer_empty() {
6706 let page = make_page("https://example.com");
6707 let ctx = make_ctx(&page, Some(200));
6708 let findings = WordCountAnalyzer::new().analyze(&ctx, &default_config());
6709 assert!(findings.iter().any(|f| f.code == "WC001"));
6710 assert!(findings.iter().any(|f| f.code == "WC002"));
6711 }
6712
6713 #[test]
6714 fn test_word_count_analyzer_with_content() {
6715 let mut page = make_page("https://example.com");
6716 page.word_count = 150;
6717 page.headings = vec![Heading {
6718 level: 1,
6719 text: "A page with some words".to_string(),
6720 length: 22,
6721 }];
6722 let ctx = make_ctx(&page, Some(200));
6723 let findings = WordCountAnalyzer::new().analyze(&ctx, &default_config());
6724 let wc001 = findings.iter().find(|f| f.code == "WC001").unwrap();
6725 assert!(wc001.description.contains("Words: 150"));
6726 }
6727
6728 #[test]
6729 fn test_word_count_analyzer_very_low() {
6730 let mut page = make_page("https://example.com");
6731 page.word_count = 50;
6732 page.headings = vec![Heading {
6733 level: 1,
6734 text: "Short".to_string(),
6735 length: 5,
6736 }];
6737 let ctx = make_ctx(&page, Some(200));
6738 let findings = WordCountAnalyzer::new().analyze(&ctx, &default_config());
6739 assert!(findings.iter().any(|f| f.code == "WC003"));
6740 }
6741
6742 #[test]
6743 fn test_word_count_analyzer_long_sentences() {
6744 let mut page = make_page("https://example.com");
6745 page.word_count = 100;
6746 page.headings = vec![Heading {
6747 level: 1,
6748 text: "This is a very long sentence that contains many words and should trigger \
6749 the long sentence warning because it has more than twenty five words on average"
6750 .to_string(),
6751 length: 150,
6752 }];
6753 let ctx = make_ctx(&page, Some(200));
6754 let findings = WordCountAnalyzer::new().analyze(&ctx, &default_config());
6755 assert!(findings.iter().any(|f| f.code == "WC004"));
6756 }
6757
6758 #[test]
6759 fn test_word_count_analyzer_normal_content() {
6760 let mut page = make_page("https://example.com");
6761 page.word_count = 500;
6762 page.headings = vec![
6763 Heading {
6764 level: 1,
6765 text: "Main Title".to_string(),
6766 length: 10,
6767 },
6768 Heading {
6769 level: 2,
6770 text: "Section One".to_string(),
6771 length: 11,
6772 },
6773 ];
6774 let ctx = make_ctx(&page, Some(200));
6775 let findings = WordCountAnalyzer::new().analyze(&ctx, &default_config());
6776 assert!(findings.iter().any(|f| f.code == "WC001"));
6778 assert!(!findings.iter().any(|f| f.code == "WC002"));
6779 assert!(!findings.iter().any(|f| f.code == "WC003"));
6780 }
6781
6782 #[test]
6787 fn test_security_headers_none_present() {
6788 let page = make_page("https://example.com");
6789 let ctx = make_ctx(&page, Some(200));
6790 let findings = SecurityHeaderAnalyzer::new().analyze(&ctx, &default_config());
6791 assert!(findings.iter().any(|f| f.code == "SEC001"));
6793 assert!(findings.iter().any(|f| f.code == "SEC002"));
6794 assert!(findings.iter().any(|f| f.code == "SEC003"));
6795 assert!(findings.iter().any(|f| f.code == "SEC005"));
6796 assert!(findings.iter().any(|f| f.code == "SEC012"));
6798 }
6799
6800 #[test]
6801 fn test_security_headers_all_present() {
6802 let headers = vec![
6803 (
6804 "Content-Security-Policy".to_string(),
6805 "default-src 'self'".to_string(),
6806 ),
6807 (
6808 "Strict-Transport-Security".to_string(),
6809 "max-age=31536000; includeSubDomains; preload".to_string(),
6810 ),
6811 ("X-Frame-Options".to_string(), "DENY".to_string()),
6812 ("X-Content-Type-Options".to_string(), "nosniff".to_string()),
6813 (
6814 "Referrer-Policy".to_string(),
6815 "strict-origin-when-cross-origin".to_string(),
6816 ),
6817 (
6818 "Permissions-Policy".to_string(),
6819 "camera=(), microphone=(), geolocation=()".to_string(),
6820 ),
6821 (
6822 "Cross-Origin-Embedder-Policy".to_string(),
6823 "require-corp".to_string(),
6824 ),
6825 (
6826 "Cross-Origin-Opener-Policy".to_string(),
6827 "same-origin".to_string(),
6828 ),
6829 (
6830 "Cross-Origin-Resource-Policy".to_string(),
6831 "same-origin".to_string(),
6832 ),
6833 ];
6834 let page = make_page("https://example.com");
6835 let ctx = AnalysisContext {
6836 page: &page,
6837 status_code: Some(200),
6838 headers: &headers,
6839 response_time: None,
6840 redirect_chain: &[],
6841 robots_txt: None,
6842 };
6843 let findings = SecurityHeaderAnalyzer::new().analyze(&ctx, &default_config());
6844 assert!(!findings.iter().any(|f| f.code == "SEC001"));
6846 assert!(!findings.iter().any(|f| f.code == "SEC002"));
6847 assert!(!findings.iter().any(|f| f.code == "SEC003"));
6848 assert!(!findings.iter().any(|f| f.code == "SEC005"));
6849 let score_finding = findings.iter().find(|f| f.code == "SEC012").unwrap();
6851 assert!(score_finding.description.contains("100/100"));
6852 }
6853
6854 #[test]
6855 fn test_security_headers_invalid_xfo() {
6856 let headers = vec![(
6857 "X-Frame-Options".to_string(),
6858 "ALLOW-FROM https://example.com".to_string(),
6859 )];
6860 let page = make_page("https://example.com");
6861 let ctx = AnalysisContext {
6862 page: &page,
6863 status_code: Some(200),
6864 headers: &headers,
6865 response_time: None,
6866 redirect_chain: &[],
6867 robots_txt: None,
6868 };
6869 let findings = SecurityHeaderAnalyzer::new().analyze(&ctx, &default_config());
6870 assert!(findings.iter().any(|f| f.code == "SEC004"));
6871 }
6872
6873 #[test]
6874 fn test_security_headers_invalid_xcto() {
6875 let headers = vec![("X-Content-Type-Options".to_string(), "sniff".to_string())];
6876 let page = make_page("https://example.com");
6877 let ctx = AnalysisContext {
6878 page: &page,
6879 status_code: Some(200),
6880 headers: &headers,
6881 response_time: None,
6882 redirect_chain: &[],
6883 robots_txt: None,
6884 };
6885 let findings = SecurityHeaderAnalyzer::new().analyze(&ctx, &default_config());
6886 assert!(findings.iter().any(|f| f.code == "SEC006"));
6887 }
6888
6889 #[test]
6890 fn test_security_headers_xfo_sameorigin() {
6891 let headers = vec![("X-Frame-Options".to_string(), "SAMEORIGIN".to_string())];
6892 let page = make_page("https://example.com");
6893 let ctx = AnalysisContext {
6894 page: &page,
6895 status_code: Some(200),
6896 headers: &headers,
6897 response_time: None,
6898 redirect_chain: &[],
6899 robots_txt: None,
6900 };
6901 let findings = SecurityHeaderAnalyzer::new().analyze(&ctx, &default_config());
6902 assert!(!findings.iter().any(|f| f.code == "SEC003"));
6904 assert!(!findings.iter().any(|f| f.code == "SEC004"));
6905 }
6906
6907 #[test]
6908 fn test_security_headers_hsts_weak_max_age() {
6909 let headers = vec![(
6910 "Strict-Transport-Security".to_string(),
6911 "max-age=300".to_string(),
6912 )];
6913 let page = make_page("https://example.com");
6914 let ctx = AnalysisContext {
6915 page: &page,
6916 status_code: Some(200),
6917 headers: &headers,
6918 response_time: None,
6919 redirect_chain: &[],
6920 robots_txt: None,
6921 };
6922 let findings = SecurityHeaderAnalyzer::new().analyze(&ctx, &default_config());
6923 assert!(findings.iter().any(|f| f.code == "SEC014"));
6924 }
6925
6926 #[test]
6927 fn test_security_headers_hsts_missing_max_age() {
6928 let headers = vec![(
6929 "Strict-Transport-Security".to_string(),
6930 "includeSubDomains".to_string(),
6931 )];
6932 let page = make_page("https://example.com");
6933 let ctx = AnalysisContext {
6934 page: &page,
6935 status_code: Some(200),
6936 headers: &headers,
6937 response_time: None,
6938 redirect_chain: &[],
6939 robots_txt: None,
6940 };
6941 let findings = SecurityHeaderAnalyzer::new().analyze(&ctx, &default_config());
6942 assert!(findings.iter().any(|f| f.code == "SEC014"));
6943 }
6944
6945 #[test]
6946 fn test_security_headers_invalid_csp() {
6947 let headers = vec![(
6948 "Content-Security-Policy".to_string(),
6949 "invalid-value".to_string(),
6950 )];
6951 let page = make_page("https://example.com");
6952 let ctx = AnalysisContext {
6953 page: &page,
6954 status_code: Some(200),
6955 headers: &headers,
6956 response_time: None,
6957 redirect_chain: &[],
6958 robots_txt: None,
6959 };
6960 let findings = SecurityHeaderAnalyzer::new().analyze(&ctx, &default_config());
6961 assert!(findings.iter().any(|f| f.code == "SEC013"));
6962 }
6963
6964 #[test]
6965 fn test_security_headers_valid_csp() {
6966 let headers = vec![(
6967 "Content-Security-Policy".to_string(),
6968 "default-src 'self'; script-src 'self'".to_string(),
6969 )];
6970 let page = make_page("https://example.com");
6971 let ctx = AnalysisContext {
6972 page: &page,
6973 status_code: Some(200),
6974 headers: &headers,
6975 response_time: None,
6976 redirect_chain: &[],
6977 robots_txt: None,
6978 };
6979 let findings = SecurityHeaderAnalyzer::new().analyze(&ctx, &default_config());
6980 assert!(!findings.iter().any(|f| f.code == "SEC013"));
6981 }
6982
6983 #[test]
6984 fn test_security_headers_case_insensitive_lookup() {
6985 let headers = vec![
6986 (
6987 "content-security-policy".to_string(),
6988 "default-src 'self'".to_string(),
6989 ),
6990 (
6991 "strict-transport-security".to_string(),
6992 "max-age=63072000".to_string(),
6993 ),
6994 ("x-frame-options".to_string(), "DENY".to_string()),
6995 ("x-content-type-options".to_string(), "nosniff".to_string()),
6996 ];
6997 let page = make_page("https://example.com");
6998 let ctx = AnalysisContext {
6999 page: &page,
7000 status_code: Some(200),
7001 headers: &headers,
7002 response_time: None,
7003 redirect_chain: &[],
7004 robots_txt: None,
7005 };
7006 let findings = SecurityHeaderAnalyzer::new().analyze(&ctx, &default_config());
7007 assert!(!findings.iter().any(|f| f.code == "SEC001"));
7009 assert!(!findings.iter().any(|f| f.code == "SEC002"));
7010 assert!(!findings.iter().any(|f| f.code == "SEC003"));
7011 assert!(!findings.iter().any(|f| f.code == "SEC005"));
7012 }
7013
7014 #[test]
7015 fn test_security_headers_score_decreases_with_missing() {
7016 let page = make_page("https://example.com");
7017 let ctx = make_ctx(&page, Some(200));
7018 let findings = SecurityHeaderAnalyzer::new().analyze(&ctx, &default_config());
7019 let score_finding = findings.iter().find(|f| f.code == "SEC012").unwrap();
7020 assert!(score_finding.description.contains("/100"));
7022 let score_str = score_finding
7024 .description
7025 .split_whitespace()
7026 .find(|s| s.contains("/100"))
7027 .unwrap();
7028 let score_num: u32 = score_str.split('/').next().unwrap().parse().unwrap();
7029 assert!(score_num < 90);
7030 }
7031
7032 #[test]
7037 fn test_ssl_no_data() {
7038 let page = make_page("https://example.com");
7039 let ctx = make_ctx(&page, Some(200));
7040 let findings = SslCertificateValidator::empty().analyze(&ctx, &default_config());
7041 assert!(findings.iter().any(|f| f.code == "SSL007"));
7042 }
7043
7044 #[test]
7045 fn test_ssl_expired_certificate() {
7046 let cert = SslCertificateInfo {
7047 subject: Some("example.com".to_string()),
7048 issuer: Some("Let's Encrypt".to_string()),
7049 san_entries: vec!["example.com".to_string()],
7050 not_before: Some("2024-01-01T00:00:00Z".to_string()),
7051 not_after: Some("2025-01-01T00:00:00Z".to_string()),
7052 is_valid_chain: true,
7053 is_self_signed: false,
7054 signature_algorithm: Some("SHA256withRSA".to_string()),
7055 };
7056 let validator = SslCertificateValidator::new(Some(cert));
7057 let page = make_page("https://example.com");
7058 let ctx = make_ctx(&page, Some(200));
7059 let findings = validator.analyze(&ctx, &default_config());
7060 assert!(findings.iter().any(|f| f.code == "SSL001"));
7061 }
7062
7063 #[test]
7064 fn test_ssl_expiring_soon() {
7065 let cert = SslCertificateInfo {
7066 subject: Some("example.com".to_string()),
7067 issuer: Some("Let's Encrypt".to_string()),
7068 san_entries: vec!["example.com".to_string()],
7069 not_before: Some("2025-06-01T00:00:00Z".to_string()),
7070 not_after: Some("2025-08-01T00:00:00Z".to_string()),
7071 is_valid_chain: true,
7072 is_self_signed: false,
7073 signature_algorithm: Some("SHA256withRSA".to_string()),
7074 };
7075 let validator = SslCertificateValidator::new(Some(cert));
7076 let page = make_page("https://example.com");
7077 let ctx = make_ctx(&page, Some(200));
7078 let findings = validator.analyze(&ctx, &default_config());
7079 let has_expiry_finding = findings
7082 .iter()
7083 .any(|f| f.code == "SSL001" || f.code == "SSL002");
7084 assert!(has_expiry_finding);
7086 }
7087
7088 #[test]
7089 fn test_ssl_valid_certificate() {
7090 let cert = SslCertificateInfo {
7091 subject: Some("example.com".to_string()),
7092 issuer: Some("Let's Encrypt".to_string()),
7093 san_entries: vec!["example.com".to_string(), "www.example.com".to_string()],
7094 not_before: Some("2025-01-01T00:00:00Z".to_string()),
7095 not_after: Some("2027-01-01T00:00:00Z".to_string()),
7096 is_valid_chain: true,
7097 is_self_signed: false,
7098 signature_algorithm: Some("SHA256withRSA".to_string()),
7099 };
7100 let validator = SslCertificateValidator::new(Some(cert));
7101 let page = make_page("https://example.com");
7102 let ctx = make_ctx(&page, Some(200));
7103 let findings = validator.analyze(&ctx, &default_config());
7104 assert!(!findings.iter().any(|f| f.severity == Severity::Critical));
7106 assert!(!findings.iter().any(|f| f.severity == Severity::Error));
7107 assert!(findings.iter().any(|f| f.code == "SSL008"));
7109 }
7110
7111 #[test]
7112 fn test_ssl_invalid_chain() {
7113 let cert = SslCertificateInfo {
7114 subject: Some("example.com".to_string()),
7115 issuer: Some("Unknown CA".to_string()),
7116 san_entries: vec!["example.com".to_string()],
7117 not_before: Some("2025-01-01T00:00:00Z".to_string()),
7118 not_after: Some("2027-01-01T00:00:00Z".to_string()),
7119 is_valid_chain: false,
7120 is_self_signed: false,
7121 signature_algorithm: Some("SHA256withRSA".to_string()),
7122 };
7123 let validator = SslCertificateValidator::new(Some(cert));
7124 let page = make_page("https://example.com");
7125 let ctx = make_ctx(&page, Some(200));
7126 let findings = validator.analyze(&ctx, &default_config());
7127 assert!(findings.iter().any(|f| f.code == "SSL003"));
7128 }
7129
7130 #[test]
7131 fn test_ssl_self_signed() {
7132 let cert = SslCertificateInfo {
7133 subject: Some("localhost".to_string()),
7134 issuer: Some("localhost".to_string()),
7135 san_entries: vec!["localhost".to_string()],
7136 not_before: Some("2025-01-01T00:00:00Z".to_string()),
7137 not_after: Some("2027-01-01T00:00:00Z".to_string()),
7138 is_valid_chain: true,
7139 is_self_signed: true,
7140 signature_algorithm: Some("SHA256withRSA".to_string()),
7141 };
7142 let validator = SslCertificateValidator::new(Some(cert));
7143 let page = make_page("https://localhost");
7144 let ctx = make_ctx(&page, Some(200));
7145 let findings = validator.analyze(&ctx, &default_config());
7146 assert!(findings.iter().any(|f| f.code == "SSL006"));
7147 }
7148
7149 #[test]
7150 fn test_ssl_subject_mismatch() {
7151 let cert = SslCertificateInfo {
7152 subject: Some("wrong.example.com".to_string()),
7153 issuer: Some("Let's Encrypt".to_string()),
7154 san_entries: vec!["wrong.example.com".to_string()],
7155 not_before: Some("2025-01-01T00:00:00Z".to_string()),
7156 not_after: Some("2027-01-01T00:00:00Z".to_string()),
7157 is_valid_chain: true,
7158 is_self_signed: false,
7159 signature_algorithm: Some("SHA256withRSA".to_string()),
7160 };
7161 let validator = SslCertificateValidator::new(Some(cert));
7162 let page = make_page("https://example.com");
7163 let ctx = make_ctx(&page, Some(200));
7164 let findings = validator.analyze(&ctx, &default_config());
7165 assert!(findings.iter().any(|f| f.code == "SSL004"));
7166 }
7167
7168 #[test]
7169 fn test_ssl_wildcard_match() {
7170 let cert = SslCertificateInfo {
7171 subject: Some("*.example.com".to_string()),
7172 issuer: Some("Let's Encrypt".to_string()),
7173 san_entries: vec!["*.example.com".to_string()],
7174 not_before: Some("2025-01-01T00:00:00Z".to_string()),
7175 not_after: Some("2027-01-01T00:00:00Z".to_string()),
7176 is_valid_chain: true,
7177 is_self_signed: false,
7178 signature_algorithm: Some("SHA256withRSA".to_string()),
7179 };
7180 let validator = SslCertificateValidator::new(Some(cert));
7181 let page = make_page("https://sub.example.com");
7182 let ctx = make_ctx(&page, Some(200));
7183 let findings = validator.analyze(&ctx, &default_config());
7184 assert!(!findings.iter().any(|f| f.code == "SSL004"));
7185 }
7186
7187 #[test]
7188 fn test_ssl_weak_algorithm() {
7189 let cert = SslCertificateInfo {
7190 subject: Some("example.com".to_string()),
7191 issuer: Some("Let's Encrypt".to_string()),
7192 san_entries: vec!["example.com".to_string()],
7193 not_before: Some("2025-01-01T00:00:00Z".to_string()),
7194 not_after: Some("2027-01-01T00:00:00Z".to_string()),
7195 is_valid_chain: true,
7196 is_self_signed: false,
7197 signature_algorithm: Some("SHA1withRSA".to_string()),
7198 };
7199 let validator = SslCertificateValidator::new(Some(cert));
7200 let page = make_page("https://example.com");
7201 let ctx = make_ctx(&page, Some(200));
7202 let findings = validator.analyze(&ctx, &default_config());
7203 assert!(findings.iter().any(|f| f.code == "SSL005"));
7204 }
7205
7206 #[test]
7207 fn test_ssl_strong_algorithm() {
7208 let cert = SslCertificateInfo {
7209 subject: Some("example.com".to_string()),
7210 issuer: Some("Let's Encrypt".to_string()),
7211 san_entries: vec!["example.com".to_string()],
7212 not_before: Some("2025-01-01T00:00:00Z".to_string()),
7213 not_after: Some("2027-01-01T00:00:00Z".to_string()),
7214 is_valid_chain: true,
7215 is_self_signed: false,
7216 signature_algorithm: Some("SHA256withECDSA".to_string()),
7217 };
7218 let validator = SslCertificateValidator::new(Some(cert));
7219 let page = make_page("https://example.com");
7220 let ctx = make_ctx(&page, Some(200));
7221 let findings = validator.analyze(&ctx, &default_config());
7222 assert!(!findings.iter().any(|f| f.code == "SSL005"));
7223 }
7224
7225 #[test]
7226 fn test_ssl_san_match() {
7227 let cert = SslCertificateInfo {
7228 subject: Some("other.com".to_string()),
7229 issuer: Some("Let's Encrypt".to_string()),
7230 san_entries: vec!["other.com".to_string(), "example.com".to_string()],
7231 not_before: Some("2025-01-01T00:00:00Z".to_string()),
7232 not_after: Some("2027-01-01T00:00:00Z".to_string()),
7233 is_valid_chain: true,
7234 is_self_signed: false,
7235 signature_algorithm: Some("SHA256withRSA".to_string()),
7236 };
7237 let validator = SslCertificateValidator::new(Some(cert));
7238 let page = make_page("https://example.com");
7239 let ctx = make_ctx(&page, Some(200));
7240 let findings = validator.analyze(&ctx, &default_config());
7241 assert!(!findings.iter().any(|f| f.code == "SSL004"));
7243 }
7244
7245 #[test]
7250 fn test_mobile_missing_viewport() {
7251 let page = make_page("https://example.com");
7252 let ctx = make_ctx(&page, Some(200));
7253 let findings = MobileFriendlinessChecker::new().analyze(&ctx, &default_config());
7254 assert!(findings.iter().any(|f| f.code == "MOB001"));
7255 }
7256
7257 #[test]
7258 fn test_mobile_optimal_viewport() {
7259 let mut page = make_page("https://example.com");
7260 page.meta.viewport = Some("width=device-width, initial-scale=1".to_string());
7261 let ctx = make_ctx(&page, Some(200));
7262 let findings = MobileFriendlinessChecker::new().analyze(&ctx, &default_config());
7263 assert!(!findings.iter().any(|f| f.code == "MOB001"));
7264 assert!(!findings.iter().any(|f| f.code == "MOB002"));
7265 assert!(!findings.iter().any(|f| f.code == "MOB003"));
7266 assert!(!findings.iter().any(|f| f.code == "MOB004"));
7267 }
7268
7269 #[test]
7270 fn test_mobile_user_scalable_no() {
7271 let mut page = make_page("https://example.com");
7272 page.meta.viewport =
7273 Some("width=device-width, initial-scale=1, user-scalable=no".to_string());
7274 let ctx = make_ctx(&page, Some(200));
7275 let findings = MobileFriendlinessChecker::new().analyze(&ctx, &default_config());
7276 assert!(findings.iter().any(|f| f.code == "MOB004"));
7277 }
7278
7279 #[test]
7280 fn test_mobile_maximum_scale_restricted() {
7281 let mut page = make_page("https://example.com");
7282 page.meta.viewport =
7283 Some("width=device-width, initial-scale=1, maximum-scale=1.0".to_string());
7284 let ctx = make_ctx(&page, Some(200));
7285 let findings = MobileFriendlinessChecker::new().analyze(&ctx, &default_config());
7286 assert!(findings.iter().any(|f| f.code == "MOB005"));
7287 }
7288
7289 #[test]
7290 fn test_mobile_fixed_width() {
7291 let mut page = make_page("https://example.com");
7292 page.meta.viewport = Some("width=980".to_string());
7293 let ctx = make_ctx(&page, Some(200));
7294 let findings = MobileFriendlinessChecker::new().analyze(&ctx, &default_config());
7295 assert!(findings.iter().any(|f| f.code == "MOB003"));
7296 }
7297
7298 #[test]
7299 fn test_mobile_missing_width_directive() {
7300 let mut page = make_page("https://example.com");
7301 page.meta.viewport = Some("initial-scale=1".to_string());
7302 let ctx = make_ctx(&page, Some(200));
7303 let findings = MobileFriendlinessChecker::new().analyze(&ctx, &default_config());
7304 assert!(findings.iter().any(|f| f.code == "MOB002"));
7305 }
7306
7307 #[test]
7308 fn test_mobile_non_standard_initial_scale() {
7309 let mut page = make_page("https://example.com");
7310 page.meta.viewport = Some("width=device-width, initial-scale=2.0".to_string());
7311 let ctx = make_ctx(&page, Some(200));
7312 let findings = MobileFriendlinessChecker::new().analyze(&ctx, &default_config());
7313 assert!(findings.iter().any(|f| f.code == "MOB009"));
7314 }
7315
7316 #[test]
7317 fn test_mobile_parse_viewport() {
7318 let vp = MobileFriendlinessChecker::parse_viewport(
7319 "width=device-width, initial-scale=1, user-scalable=no",
7320 );
7321 assert_eq!(vp.get("width").unwrap(), "device-width");
7322 assert_eq!(vp.get("initial-scale").unwrap(), "1");
7323 assert_eq!(vp.get("user-scalable").unwrap(), "no");
7324 }
7325
7326 #[test]
7327 fn test_mobile_parse_viewport_empty() {
7328 let vp = MobileFriendlinessChecker::parse_viewport("");
7329 assert!(vp.is_empty());
7330 }
7331
7332 #[test]
7333 fn test_mobile_both_user_scalable_and_max_scale() {
7334 let mut page = make_page("https://example.com");
7335 page.meta.viewport = Some(
7336 "width=device-width, initial-scale=1, user-scalable=no, maximum-scale=1.0".to_string(),
7337 );
7338 let ctx = make_ctx(&page, Some(200));
7339 let findings = MobileFriendlinessChecker::new().analyze(&ctx, &default_config());
7340 assert!(findings.iter().any(|f| f.code == "MOB004"));
7341 assert!(findings.iter().any(|f| f.code == "MOB005"));
7342 }
7343
7344 #[test]
7349 fn test_a11y_images_missing_alt() {
7350 let mut page = make_page("https://example.com");
7351 page.images = vec![
7352 ExtractedImage {
7353 src: "/a.png".to_string(),
7354 alt: String::new(),
7355 width: None,
7356 height: None,
7357 has_alt: false,
7358 is_lazy_loaded: false,
7359 },
7360 ExtractedImage {
7361 src: "/b.jpg".to_string(),
7362 alt: "Good alt".to_string(),
7363 width: None,
7364 height: None,
7365 has_alt: true,
7366 is_lazy_loaded: false,
7367 },
7368 ];
7369 let ctx = make_ctx(&page, Some(200));
7370 let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7371 assert!(findings.iter().any(|f| f.code == "A11Y001"));
7372 let f = findings.iter().find(|f| f.code == "A11Y001").unwrap();
7373 assert!(f.description.contains("/a.png"));
7374 }
7375
7376 #[test]
7377 fn test_a11y_images_all_have_alt() {
7378 let mut page = make_page("https://example.com");
7379 page.images = vec![ExtractedImage {
7380 src: "/a.png".to_string(),
7381 alt: "Description".to_string(),
7382 width: None,
7383 height: None,
7384 has_alt: true,
7385 is_lazy_loaded: false,
7386 }];
7387 let ctx = make_ctx(&page, Some(200));
7388 let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7389 assert!(!findings.iter().any(|f| f.code == "A11Y001"));
7390 }
7391
7392 #[test]
7393 fn test_a11y_no_headings() {
7394 let page = make_page("https://example.com");
7395 let ctx = make_ctx(&page, Some(200));
7396 let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7397 assert!(findings.iter().any(|f| f.code == "A11Y002"));
7398 }
7399
7400 #[test]
7401 fn test_a11y_missing_h1() {
7402 let mut page = make_page("https://example.com");
7403 page.headings = vec![Heading {
7404 level: 2,
7405 text: "Section".to_string(),
7406 length: 7,
7407 }];
7408 let ctx = make_ctx(&page, Some(200));
7409 let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7410 assert!(findings.iter().any(|f| f.code == "A11Y003"));
7411 }
7412
7413 #[test]
7414 fn test_a11y_multiple_h1() {
7415 let mut page = make_page("https://example.com");
7416 page.headings = vec![
7417 Heading {
7418 level: 1,
7419 text: "First".to_string(),
7420 length: 5,
7421 },
7422 Heading {
7423 level: 1,
7424 text: "Second".to_string(),
7425 length: 6,
7426 },
7427 ];
7428 let ctx = make_ctx(&page, Some(200));
7429 let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7430 assert!(findings.iter().any(|f| f.code == "A11Y004"));
7431 }
7432
7433 #[test]
7434 fn test_a11y_skipped_heading_level() {
7435 let mut page = make_page("https://example.com");
7436 page.headings = vec![
7437 Heading {
7438 level: 1,
7439 text: "H1".to_string(),
7440 length: 2,
7441 },
7442 Heading {
7443 level: 3,
7444 text: "H3 skipped H2".to_string(),
7445 length: 13,
7446 },
7447 ];
7448 let ctx = make_ctx(&page, Some(200));
7449 let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7450 assert!(findings.iter().any(|f| f.code == "A11Y005"));
7451 }
7452
7453 #[test]
7454 fn test_a11y_valid_heading_hierarchy() {
7455 let mut page = make_page("https://example.com");
7456 page.headings = vec![
7457 Heading {
7458 level: 1,
7459 text: "H1".to_string(),
7460 length: 2,
7461 },
7462 Heading {
7463 level: 2,
7464 text: "H2".to_string(),
7465 length: 2,
7466 },
7467 Heading {
7468 level: 2,
7469 text: "H2b".to_string(),
7470 length: 3,
7471 },
7472 Heading {
7473 level: 3,
7474 text: "H3".to_string(),
7475 length: 2,
7476 },
7477 ];
7478 let ctx = make_ctx(&page, Some(200));
7479 let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7480 assert!(!findings.iter().any(|f| f.code == "A11Y005"));
7481 }
7482
7483 #[test]
7484 fn test_a11y_missing_main_landmark() {
7485 let mut page = make_page("https://example.com");
7486 page.has_main_landmark = false;
7487 let ctx = make_ctx(&page, Some(200));
7488 let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7489 assert!(findings.iter().any(|f| f.code == "A11Y006"));
7490 }
7491
7492 #[test]
7493 fn test_a11y_has_main_landmark() {
7494 let mut page = make_page("https://example.com");
7495 page.has_main_landmark = true;
7496 let ctx = make_ctx(&page, Some(200));
7497 let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7498 assert!(!findings.iter().any(|f| f.code == "A11Y006"));
7499 }
7500
7501 #[test]
7502 fn test_a11y_empty_link_text() {
7503 let mut page = make_page("https://example.com");
7504 page.links = vec![ExtractedLink {
7505 href: "/page".to_string(),
7506 text: String::new(),
7507 rel: vec![],
7508 is_external: false,
7509 aria_label: None,
7510 img_alt: None,
7511 }];
7512 let ctx = make_ctx(&page, Some(200));
7513 let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7514 assert!(findings.iter().any(|f| f.code == "A11Y009"));
7515 }
7516
7517 #[test]
7518 fn test_a11y_vague_link_text() {
7519 let mut page = make_page("https://example.com");
7520 page.links = vec![ExtractedLink {
7521 href: "/page".to_string(),
7522 text: "click here".to_string(),
7523 rel: vec![],
7524 is_external: false,
7525 aria_label: None,
7526 img_alt: None,
7527 }];
7528 let ctx = make_ctx(&page, Some(200));
7529 let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7530 assert!(findings.iter().any(|f| f.code == "A11Y010"));
7531 }
7532
7533 #[test]
7534 fn test_a11y_good_link_text() {
7535 let mut page = make_page("https://example.com");
7536 page.links = vec![ExtractedLink {
7537 href: "/pricing".to_string(),
7538 text: "View pricing details".to_string(),
7539 rel: vec![],
7540 is_external: false,
7541 aria_label: None,
7542 img_alt: None,
7543 }];
7544 let ctx = make_ctx(&page, Some(200));
7545 let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7546 assert!(!findings.iter().any(|f| f.code == "A11Y009"));
7547 assert!(!findings.iter().any(|f| f.code == "A11Y010"));
7548 }
7549
7550 #[test]
7551 fn test_a11y_form_input_missing_label() {
7552 use crate::parser::ExtractedInput;
7553 let mut page = make_page("https://example.com");
7554 page.forms = vec![crate::parser::ExtractedForm {
7555 action: None,
7556 method: "post".to_string(),
7557 input_count: 1,
7558 has_file_input: false,
7559 has_search_input: false,
7560 inputs: vec![ExtractedInput {
7561 input_type: Some("text".to_string()),
7562 name: Some("email".to_string()),
7563 id: None,
7564 has_label: false,
7565 aria_label: None,
7566 aria_labelledby: None,
7567 aria_describedby: None,
7568 placeholder: Some("Enter email".to_string()),
7569 required: true,
7570 }],
7571 has_fieldset: false,
7572 has_legend: false,
7573 }];
7574 let ctx = make_ctx(&page, Some(200));
7575 let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7576 assert!(findings.iter().any(|f| f.code == "A11Y011"));
7577 }
7578
7579 #[test]
7580 fn test_a11y_form_input_with_label() {
7581 use crate::parser::ExtractedInput;
7582 let mut page = make_page("https://example.com");
7583 page.forms = vec![crate::parser::ExtractedForm {
7584 action: None,
7585 method: "post".to_string(),
7586 input_count: 1,
7587 has_file_input: false,
7588 has_search_input: false,
7589 inputs: vec![ExtractedInput {
7590 input_type: Some("text".to_string()),
7591 name: Some("email".to_string()),
7592 id: Some("email-input".to_string()),
7593 has_label: true,
7594 aria_label: None,
7595 aria_labelledby: None,
7596 aria_describedby: None,
7597 placeholder: None,
7598 required: true,
7599 }],
7600 has_fieldset: false,
7601 has_legend: false,
7602 }];
7603 let ctx = make_ctx(&page, Some(200));
7604 let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7605 assert!(!findings.iter().any(|f| f.code == "A11Y011"));
7606 }
7607
7608 #[test]
7609 fn test_a11y_positive_tabindex() {
7610 let mut page = make_page("https://example.com");
7611 page.has_positive_tabindex = true;
7612 let ctx = make_ctx(&page, Some(200));
7613 let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7614 assert!(findings.iter().any(|f| f.code == "A11Y012"));
7615 }
7616
7617 #[test]
7618 fn test_a11y_no_positive_tabindex() {
7619 let mut page = make_page("https://example.com");
7620 page.has_positive_tabindex = false;
7621 let ctx = make_ctx(&page, Some(200));
7622 let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7623 assert!(!findings.iter().any(|f| f.code == "A11Y012"));
7624 }
7625
7626 #[test]
7627 fn test_a11y_table_missing_headers() {
7628 let mut page = make_page("https://example.com");
7629 page.tables_total = 2;
7630 page.tables_with_headers = 1;
7631 let ctx = make_ctx(&page, Some(200));
7632 let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7633 assert!(findings.iter().any(|f| f.code == "A11Y014"));
7634 let f = findings.iter().find(|f| f.code == "A11Y014").unwrap();
7635 assert!(f.description.contains("1 of 2"));
7636 }
7637
7638 #[test]
7639 fn test_a11y_table_with_headers() {
7640 let mut page = make_page("https://example.com");
7641 page.tables_total = 2;
7642 page.tables_with_headers = 2;
7643 let ctx = make_ctx(&page, Some(200));
7644 let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7645 assert!(!findings.iter().any(|f| f.code == "A11Y014"));
7646 }
7647
7648 #[test]
7649 fn test_a11y_missing_lang_attribute() {
7650 let mut page = make_page("https://example.com");
7651 page.has_lang_attribute = false;
7652 let ctx = make_ctx(&page, Some(200));
7653 let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7654 assert!(findings.iter().any(|f| f.code == "A11Y016"));
7655 }
7656
7657 #[test]
7658 fn test_a11y_has_lang_attribute() {
7659 let mut page = make_page("https://example.com");
7660 page.has_lang_attribute = true;
7661 page.html_lang = Some("en".to_string());
7662 let ctx = make_ctx(&page, Some(200));
7663 let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7664 assert!(!findings.iter().any(|f| f.code == "A11Y016"));
7665 }
7666
7667 #[test]
7668 fn test_a11y_no_images_no_findings() {
7669 let page = make_page("https://example.com");
7670 let ctx = make_ctx(&page, Some(200));
7671 let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7672 assert!(!findings.iter().any(|f| f.code == "A11Y001"));
7674 }
7675
7676 #[test]
7677 fn test_a11y_well_accessible_page() {
7678 let mut page = make_page("https://example.com");
7679 page.headings = vec![
7680 Heading {
7681 level: 1,
7682 text: "Page Title".to_string(),
7683 length: 10,
7684 },
7685 Heading {
7686 level: 2,
7687 text: "Section".to_string(),
7688 length: 7,
7689 },
7690 ];
7691 page.has_main_landmark = true;
7692 page.has_nav_landmark = true;
7693 page.has_skip_link = true;
7694 page.has_lang_attribute = true;
7695 page.html_lang = Some("en".to_string());
7696 page.links = vec![ExtractedLink {
7697 href: "/page".to_string(),
7698 text: "click here".to_string(),
7699 rel: vec![],
7700 is_external: false,
7701 aria_label: None,
7702 img_alt: None,
7703 }];
7704 let ctx = make_ctx(&page, Some(200));
7705 let findings = AccessibilityAnalyzer::new().analyze(&ctx, &default_config());
7706 let errors: Vec<&Finding> = findings
7708 .iter()
7709 .filter(|f| f.severity == Severity::Error)
7710 .collect();
7711 assert!(
7712 errors.is_empty(),
7713 "Unexpected errors: {:?}",
7714 errors.iter().map(|f| &f.code).collect::<Vec<_>>()
7715 );
7716 }
7717
7718 #[test]
7723 fn test_social_og_image_no_dimensions() {
7724 let mut page = make_page("https://example.com");
7725 page.meta.og.image = Some("https://example.com/og.png".to_string());
7726 let ctx = make_ctx(&page, Some(200));
7727 let findings = SocialMediaAnalyzer::new().analyze(&ctx, &default_config());
7728 assert!(findings.iter().any(|f| f.code == "SOCIAL001"));
7729 }
7730
7731 #[test]
7732 fn test_social_og_image_adequate_dimensions() {
7733 let mut page = make_page("https://example.com");
7734 page.meta.og.image = Some("https://example.com/og.png".to_string());
7735 page.og_image_width = Some(1200);
7736 page.og_image_height = Some(630);
7737 let ctx = make_ctx(&page, Some(200));
7738 let findings = SocialMediaAnalyzer::new().analyze(&ctx, &default_config());
7739 assert!(!findings.iter().any(|f| f.code == "SOCIAL001"));
7740 assert!(!findings.iter().any(|f| f.code == "SOCIAL002"));
7741 assert!(!findings.iter().any(|f| f.code == "SOCIAL003"));
7742 }
7743
7744 #[test]
7745 fn test_social_og_image_too_small() {
7746 let mut page = make_page("https://example.com");
7747 page.meta.og.image = Some("https://example.com/og.png".to_string());
7748 page.og_image_width = Some(600);
7749 page.og_image_height = Some(315);
7750 let ctx = make_ctx(&page, Some(200));
7751 let findings = SocialMediaAnalyzer::new().analyze(&ctx, &default_config());
7752 assert!(findings.iter().any(|f| f.code == "SOCIAL002"));
7753 assert!(findings.iter().any(|f| f.code == "SOCIAL003"));
7754 }
7755
7756 #[test]
7757 fn test_social_og_image_width_only_too_narrow() {
7758 let mut page = make_page("https://example.com");
7759 page.meta.og.image = Some("https://example.com/og.png".to_string());
7760 page.og_image_width = Some(800);
7761 page.og_image_height = Some(630);
7762 let ctx = make_ctx(&page, Some(200));
7763 let findings = SocialMediaAnalyzer::new().analyze(&ctx, &default_config());
7764 assert!(findings.iter().any(|f| f.code == "SOCIAL002"));
7765 assert!(!findings.iter().any(|f| f.code == "SOCIAL003"));
7766 }
7767
7768 #[test]
7769 fn test_social_missing_twitter_card() {
7770 let page = make_page("https://example.com");
7771 let ctx = make_ctx(&page, Some(200));
7772 let findings = SocialMediaAnalyzer::new().analyze(&ctx, &default_config());
7773 assert!(findings.iter().any(|f| f.code == "SOCIAL004"));
7774 }
7775
7776 #[test]
7777 fn test_social_valid_twitter_card() {
7778 let mut page = make_page("https://example.com");
7779 page.meta.twitter.card = Some("summary_large_image".to_string());
7780 let ctx = make_ctx(&page, Some(200));
7781 let findings = SocialMediaAnalyzer::new().analyze(&ctx, &default_config());
7782 assert!(!findings.iter().any(|f| f.code == "SOCIAL004"));
7783 assert!(!findings.iter().any(|f| f.code == "SOCIAL005"));
7784 }
7785
7786 #[test]
7787 fn test_social_invalid_twitter_card() {
7788 let mut page = make_page("https://example.com");
7789 page.meta.twitter.card = Some("invalid_type".to_string());
7790 let ctx = make_ctx(&page, Some(200));
7791 let findings = SocialMediaAnalyzer::new().analyze(&ctx, &default_config());
7792 assert!(findings.iter().any(|f| f.code == "SOCIAL005"));
7793 }
7794
7795 #[test]
7796 fn test_social_incomplete_og_tags() {
7797 let page = make_page("https://example.com");
7798 let ctx = make_ctx(&page, Some(200));
7799 let findings = SocialMediaAnalyzer::new().analyze(&ctx, &default_config());
7800 assert!(findings.iter().any(|f| f.code == "SOCIAL006"));
7802 let f = findings.iter().find(|f| f.code == "SOCIAL006").unwrap();
7803 assert!(f.description.contains("og:title"));
7804 }
7805
7806 #[test]
7807 fn test_social_complete_og_tags() {
7808 let mut page = make_page("https://example.com");
7809 page.meta.og.title = Some("Title".to_string());
7810 page.meta.og.description = Some("Description".to_string());
7811 page.meta.og.image = Some("https://example.com/og.png".to_string());
7812 page.meta.og.url = Some("https://example.com".to_string());
7813 page.meta.og.r#type = Some("website".to_string());
7814 let ctx = make_ctx(&page, Some(200));
7815 let findings = SocialMediaAnalyzer::new().analyze(&ctx, &default_config());
7816 assert!(!findings.iter().any(|f| f.code == "SOCIAL006"));
7817 }
7818
7819 #[test]
7820 fn test_social_completeness_score() {
7821 let mut page = make_page("https://example.com");
7822 page.meta.og.title = Some("Title".to_string());
7823 page.meta.og.description = Some("Description".to_string());
7824 page.meta.og.image = Some("https://example.com/og.png".to_string());
7825 page.meta.og.url = Some("https://example.com".to_string());
7826 page.meta.og.r#type = Some("website".to_string());
7827 page.meta.twitter.card = Some("summary_large_image".to_string());
7828 page.meta.twitter.title = Some("Title".to_string());
7829 page.meta.twitter.description = Some("Desc".to_string());
7830 page.meta.twitter.image = Some("https://example.com/tw.png".to_string());
7831 let ctx = make_ctx(&page, Some(200));
7832 let findings = SocialMediaAnalyzer::new().analyze(&ctx, &default_config());
7833 let score = findings.iter().find(|f| f.code == "SOCIAL008").unwrap();
7834 assert!(score.description.contains("8/8"));
7835 }
7836
7837 #[test]
7838 fn test_social_no_og_image_no_dimension_warning() {
7839 let mut page = make_page("https://example.com");
7840 page.meta.og.image = None;
7841 let ctx = make_ctx(&page, Some(200));
7842 let findings = SocialMediaAnalyzer::new().analyze(&ctx, &default_config());
7843 assert!(!findings.iter().any(|f| f.code == "SOCIAL001"));
7845 }
7846
7847 #[test]
7848 fn test_social_all_twitter_cards_valid() {
7849 for card_type in &["summary", "summary_large_image", "app", "player"] {
7850 let mut page = make_page("https://example.com");
7851 page.meta.twitter.card = Some(card_type.to_string());
7852 let ctx = make_ctx(&page, Some(200));
7853 let findings = SocialMediaAnalyzer::new().analyze(&ctx, &default_config());
7854 assert!(
7855 !findings.iter().any(|f| f.code == "SOCIAL005"),
7856 "Card type {card_type} should be valid"
7857 );
7858 }
7859 }
7860
7861 #[test]
7866 fn test_entity_analyzer_empty_page() {
7867 let page = make_page("https://example.com");
7868 let ctx = make_ctx(&page, Some(200));
7869 let findings = EntityAnalyzer::new().analyze(&ctx, &default_config());
7870 assert!(findings.iter().any(|f| f.code == "ENTITY005"));
7871 assert!(findings.iter().any(|f| f.code == "ENTITY006"));
7872 }
7873
7874 #[test]
7875 fn test_entity_detect_people() {
7876 let text = "Written by Dr. John Smith and edited by Prof. Jane Doe.";
7877 let people = EntityAnalyzer::detect_people(text);
7878 assert!(!people.is_empty());
7879 }
7880
7881 #[test]
7882 fn test_entity_detect_organizations() {
7883 let text = "This product is made by Acme Corporation and Widget Inc.";
7884 let orgs = EntityAnalyzer::detect_organizations(text);
7885 assert!(!orgs.is_empty());
7886 }
7887
7888 #[test]
7889 fn test_entity_detect_locations() {
7890 let text = "The city of London and the mountain region are beautiful.";
7891 let locs = EntityAnalyzer::detect_locations(text);
7892 assert!(!locs.is_empty());
7893 }
7894
7895 #[test]
7896 fn test_entity_topics() {
7897 let headings = vec![
7898 Heading {
7899 level: 1,
7900 text: "Rust Programming Language Tutorial".to_string(),
7901 length: 33,
7902 },
7903 Heading {
7904 level: 2,
7905 text: "Advanced Rust Programming Concepts".to_string(),
7906 length: 35,
7907 },
7908 ];
7909 let topics = EntityAnalyzer::detect_topics(&headings, 500);
7910 assert!(topics
7911 .iter()
7912 .any(|t| t.contains("rust") || t.contains("programming")));
7913 }
7914
7915 #[test]
7916 fn test_entity_sentiment_positive() {
7917 let (score, label) = EntityAnalyzer::analyze_sentiment(
7918 "This is a great and amazing product, truly wonderful and excellent!",
7919 );
7920 assert!(score > 0.0);
7921 assert_eq!(label, "positive");
7922 }
7923
7924 #[test]
7925 fn test_entity_sentiment_negative() {
7926 let (score, label) = EntityAnalyzer::analyze_sentiment(
7927 "This is a terrible and horrible product, truly awful and bad!",
7928 );
7929 assert!(score < 0.0);
7930 assert_eq!(label, "negative");
7931 }
7932
7933 #[test]
7934 fn test_entity_sentiment_neutral() {
7935 let (score, label) =
7936 EntityAnalyzer::analyze_sentiment("The page contains information about the topic.");
7937 assert_eq!(label, "neutral");
7938 assert!((-0.05..=0.05).contains(&score));
7939 }
7940
7941 #[test]
7942 fn test_entity_analyzer_with_people() {
7943 let mut page = make_page("https://example.com");
7944 page.headings = vec![Heading {
7945 level: 1,
7946 text: "Interview with Dr. Alice Smith".to_string(),
7947 length: 29,
7948 }];
7949 page.word_count = 500;
7950 let ctx = make_ctx(&page, Some(200));
7951 let findings = EntityAnalyzer::new().analyze(&ctx, &default_config());
7952 assert!(findings.iter().any(|f| f.code == "ENTITY001"));
7953 }
7954
7955 #[test]
7960 fn test_readability_empty_page() {
7961 let page = make_page("https://example.com");
7962 let ctx = make_ctx(&page, Some(200));
7963 let findings = EnhancedReadabilityAnalyzer::new().analyze(&ctx, &default_config());
7964 assert!(findings.is_empty());
7965 }
7966
7967 #[test]
7968 fn test_readability_simple_text() {
7969 let mut page = make_page("https://example.com");
7970 page.word_count = 100;
7971 page.headings = vec![Heading {
7972 level: 1,
7973 text: "The cat sat on the mat. The dog ran in the park.".to_string(),
7974 length: 48,
7975 }];
7976 let ctx = make_ctx(&page, Some(200));
7977 let findings = EnhancedReadabilityAnalyzer::new().analyze(&ctx, &default_config());
7978 assert!(findings.iter().any(|f| f.code == "READ001"));
7979 assert!(findings.iter().any(|f| f.code == "READ005"));
7980 let fre = findings.iter().find(|f| f.code == "READ005").unwrap();
7981 assert!(fre.description.contains("Score:"));
7982 }
7983
7984 #[test]
7985 fn test_readability_complex_text() {
7986 let mut page = make_page("https://example.com");
7987 page.word_count = 200;
7988 page.headings = vec![Heading {
7989 level: 1,
7990 text: "The implementation of sophisticated algorithmic methodologies \
7991 necessitates comprehensive understanding of computational complexity \
7992 and theoretical frameworks"
7993 .to_string(),
7994 length: 150,
7995 }];
7996 let ctx = make_ctx(&page, Some(200));
7997 let findings = EnhancedReadabilityAnalyzer::new().analyze(&ctx, &default_config());
7998 assert!(findings.iter().any(|f| f.code == "READ001"));
7999 let fk = findings.iter().find(|f| f.code == "READ001").unwrap();
8000 assert!(fk.description.contains("Grade level:"));
8001 }
8002
8003 #[test]
8004 fn test_readability_syllable_counting() {
8005 assert_eq!(EnhancedReadabilityAnalyzer::count_syllables("cat"), 1);
8006 assert_eq!(EnhancedReadabilityAnalyzer::count_syllables("hello"), 2);
8007 assert_eq!(EnhancedReadabilityAnalyzer::count_syllables("beautiful"), 3);
8008 assert_eq!(EnhancedReadabilityAnalyzer::count_syllables("a"), 1);
8009 assert_eq!(EnhancedReadabilityAnalyzer::count_syllables(""), 0);
8010 }
8011
8012 #[test]
8013 fn test_readability_indices() {
8014 let mut page = make_page("https://example.com");
8015 page.word_count = 500;
8016 page.headings = vec![Heading {
8017 level: 1,
8018 text: "A comprehensive guide to understanding modern web development \
8019 practices and techniques for beginners and experienced developers"
8020 .to_string(),
8021 length: 140,
8022 }];
8023 let ctx = make_ctx(&page, Some(200));
8024 let findings = EnhancedReadabilityAnalyzer::new().analyze(&ctx, &default_config());
8025 assert!(findings.iter().any(|f| f.code == "READ001"));
8026 assert!(findings.iter().any(|f| f.code == "READ002"));
8027 assert!(findings.iter().any(|f| f.code == "READ003"));
8028 assert!(findings.iter().any(|f| f.code == "READ004"));
8029 assert!(findings.iter().any(|f| f.code == "READ005"));
8030 }
8031
8032 #[test]
8033 fn test_readability_grade_label() {
8034 assert_eq!(
8035 EnhancedReadabilityAnalyzer::grade_label(0.5),
8036 "kindergarten"
8037 );
8038 assert_eq!(
8039 EnhancedReadabilityAnalyzer::grade_label(4.0),
8040 "elementary school"
8041 );
8042 assert_eq!(
8043 EnhancedReadabilityAnalyzer::grade_label(7.0),
8044 "middle school"
8045 );
8046 assert_eq!(
8047 EnhancedReadabilityAnalyzer::grade_label(10.0),
8048 "high school"
8049 );
8050 assert_eq!(EnhancedReadabilityAnalyzer::grade_label(14.0), "college");
8051 assert_eq!(
8052 EnhancedReadabilityAnalyzer::grade_label(18.0),
8053 "postgraduate"
8054 );
8055 }
8056
8057 #[test]
8058 fn test_readability_ease_label() {
8059 assert_eq!(
8060 EnhancedReadabilityAnalyzer::reading_ease_label(95.0),
8061 "very easy"
8062 );
8063 assert_eq!(
8064 EnhancedReadabilityAnalyzer::reading_ease_label(85.0),
8065 "easy"
8066 );
8067 assert_eq!(
8068 EnhancedReadabilityAnalyzer::reading_ease_label(75.0),
8069 "fairly easy"
8070 );
8071 assert_eq!(
8072 EnhancedReadabilityAnalyzer::reading_ease_label(65.0),
8073 "standard"
8074 );
8075 assert_eq!(
8076 EnhancedReadabilityAnalyzer::reading_ease_label(55.0),
8077 "fairly difficult"
8078 );
8079 assert_eq!(
8080 EnhancedReadabilityAnalyzer::reading_ease_label(35.0),
8081 "difficult"
8082 );
8083 assert_eq!(
8084 EnhancedReadabilityAnalyzer::reading_ease_label(20.0),
8085 "very difficult"
8086 );
8087 }
8088
8089 #[test]
8094 fn test_keyword_analyzer_empty_page() {
8095 let page = make_page("https://example.com");
8096 let ctx = make_ctx(&page, Some(200));
8097 let findings = KeywordAnalyzer::new().analyze(&ctx, &default_config());
8098 assert!(findings.is_empty());
8099 }
8100
8101 #[test]
8102 fn test_keyword_analyzer_with_content() {
8103 let mut page = make_page("https://example.com");
8104 page.word_count = 500;
8105 page.headings = vec![
8106 Heading {
8107 level: 1,
8108 text: "Rust Programming Language Tutorial for Beginners".to_string(),
8109 length: 48,
8110 },
8111 Heading {
8112 level: 2,
8113 text: "Learn Rust Programming Today".to_string(),
8114 length: 28,
8115 },
8116 ];
8117 let ctx = make_ctx(&page, Some(200));
8118 let findings = KeywordAnalyzer::new().analyze(&ctx, &default_config());
8119 assert!(findings.iter().any(|f| f.code == "KW001"));
8120 assert!(findings.iter().any(|f| f.code == "KW002"));
8121 }
8122
8123 #[test]
8124 fn test_keyword_tokenize() {
8125 let tokens = KeywordAnalyzer::tokenize("Rust Programming Language Tutorial");
8126 assert!(tokens.contains(&"rust".to_string()));
8127 assert!(tokens.contains(&"programming".to_string()));
8128 assert!(tokens.contains(&"language".to_string()));
8129 assert!(tokens.contains(&"tutorial".to_string()));
8130 }
8131
8132 #[test]
8133 fn test_keyword_tf() {
8134 let tokens = vec![
8135 "rust".to_string(),
8136 "programming".to_string(),
8137 "rust".to_string(),
8138 "language".to_string(),
8139 ];
8140 let tf = KeywordAnalyzer::compute_tf(&tokens);
8141 assert!(*tf.get("rust").unwrap() > 0.4);
8142 }
8143
8144 #[test]
8145 fn test_keyword_density() {
8146 let tokens = vec![
8147 "rust".to_string(),
8148 "programming".to_string(),
8149 "rust".to_string(),
8150 ];
8151 let density = KeywordAnalyzer::keyword_density(&tokens, 100);
8152 assert!(*density.get("rust").unwrap() > 1.5);
8153 }
8154
8155 #[test]
8156 fn test_keyword_prominent_detection() {
8157 let mut density = HashMap::new();
8158 density.insert("rust".to_string(), 3.5);
8159 density.insert("programming".to_string(), 1.0);
8160 let prominent = KeywordAnalyzer::detect_prominent_keywords(&density);
8161 assert_eq!(prominent.len(), 1);
8162 assert_eq!(prominent[0].0, "rust");
8163 }
8164
8165 #[test]
8166 fn test_keyword_cooccurrence() {
8167 let tokens = vec![
8168 "rust".to_string(),
8169 "programming".to_string(),
8170 "language".to_string(),
8171 "rust".to_string(),
8172 "programming".to_string(),
8173 ];
8174 let cooccur = KeywordAnalyzer::cooccurrence(&tokens, 2);
8175 assert!(!cooccur.is_empty());
8176 }
8177
8178 #[test]
8179 fn test_keyword_analyzer_prominent_warning() {
8180 let mut page = make_page("https://example.com");
8181 page.word_count = 100;
8182 page.headings = vec![Heading {
8183 level: 1,
8184 text: "rust rust rust rust rust rust rust rust rust rust rust".to_string(),
8185 length: 55,
8186 }];
8187 let ctx = make_ctx(&page, Some(200));
8188 let findings = KeywordAnalyzer::new().analyze(&ctx, &default_config());
8189 let kw003 = findings.iter().find(|f| f.code == "KW003");
8190 assert!(kw003.is_some());
8191 assert_eq!(kw003.unwrap().severity, Severity::Warning);
8192 }
8193
8194 #[test]
8199 fn test_ecom_empty_page() {
8200 let page = make_page("https://example.com");
8201 let ctx = make_ctx(&page, Some(200));
8202 let findings = EcommerceSignalsAnalyzer::new().analyze(&ctx, &default_config());
8203 assert!(findings.is_empty());
8204 }
8205
8206 #[test]
8207 fn test_ecom_product_schema() {
8208 let mut page = make_page("https://example.com/product");
8209 page.structured_data = vec![StructuredData {
8210 context: Some("https://schema.org".to_string()),
8211 r#type: Some("Product".to_string()),
8212 data: serde_json::json!({
8213 "@context": "https://schema.org",
8214 "@type": "Product",
8215 "name": "Widget",
8216 "offers": {
8217 "@type": "Offer",
8218 "price": "29.99",
8219 "priceCurrency": "USD",
8220 "availability": "https://schema.org/InStock"
8221 }
8222 }),
8223 }];
8224 let ctx = make_ctx(&page, Some(200));
8225 let findings = EcommerceSignalsAnalyzer::new().analyze(&ctx, &default_config());
8226 assert!(findings.iter().any(|f| f.code == "ECOM001"));
8227 assert!(findings.iter().any(|f| f.code == "ECOM002"));
8228 assert!(findings.iter().any(|f| f.code == "ECOM003"));
8229 assert!(findings.iter().any(|f| f.code == "ECOM005"));
8230 }
8231
8232 #[test]
8233 fn test_ecom_product_with_reviews() {
8234 let mut page = make_page("https://example.com/product");
8235 page.structured_data = vec![StructuredData {
8236 context: Some("https://schema.org".to_string()),
8237 r#type: Some("Product".to_string()),
8238 data: serde_json::json!({
8239 "@context": "https://schema.org",
8240 "@type": "Product",
8241 "name": "Widget",
8242 "aggregateRating": {
8243 "@type": "AggregateRating",
8244 "ratingValue": "4.5",
8245 "reviewCount": "120"
8246 }
8247 }),
8248 }];
8249 let ctx = make_ctx(&page, Some(200));
8250 let findings = EcommerceSignalsAnalyzer::new().analyze(&ctx, &default_config());
8251 assert!(findings.iter().any(|f| f.code == "ECOM004"));
8252 }
8253
8254 #[test]
8255 fn test_ecom_price_without_product() {
8256 let mut page = make_page("https://example.com/product");
8257 page.structured_data = vec![StructuredData {
8258 context: Some("https://schema.org".to_string()),
8259 r#type: Some("Offer".to_string()),
8260 data: serde_json::json!({
8261 "@context": "https://schema.org",
8262 "@type": "Offer",
8263 "price": "19.99",
8264 "priceCurrency": "USD"
8265 }),
8266 }];
8267 let ctx = make_ctx(&page, Some(200));
8268 let findings = EcommerceSignalsAnalyzer::new().analyze(&ctx, &default_config());
8269 assert!(findings.iter().any(|f| f.code == "ECOM006"));
8270 }
8271
8272 #[test]
8273 fn test_ecom_no_price() {
8274 let mut page = make_page("https://example.com/product");
8275 page.structured_data = vec![StructuredData {
8276 context: Some("https://schema.org".to_string()),
8277 r#type: Some("Product".to_string()),
8278 data: serde_json::json!({
8279 "@context": "https://schema.org",
8280 "@type": "Product",
8281 "name": "Widget"
8282 }),
8283 }];
8284 let ctx = make_ctx(&page, Some(200));
8285 let findings = EcommerceSignalsAnalyzer::new().analyze(&ctx, &default_config());
8286 assert!(findings.iter().any(|f| f.code == "ECOM001"));
8287 assert!(!findings.iter().any(|f| f.code == "ECOM002"));
8288 }
8289
8290 #[test]
8291 fn test_ecom_non_product_schema() {
8292 let mut page = make_page("https://example.com/article");
8293 page.structured_data = vec![StructuredData {
8294 context: Some("https://schema.org".to_string()),
8295 r#type: Some("Article".to_string()),
8296 data: serde_json::json!({
8297 "@context": "https://schema.org",
8298 "@type": "Article",
8299 "headline": "Test"
8300 }),
8301 }];
8302 let ctx = make_ctx(&page, Some(200));
8303 let findings = EcommerceSignalsAnalyzer::new().analyze(&ctx, &default_config());
8304 assert!(findings.is_empty());
8305 }
8306
8307 #[test]
8312 fn test_iseo_empty_page() {
8313 let mut page = make_page("https://example.com");
8314 page.html_lang = Some("en".to_string());
8315 let ctx = make_ctx(&page, Some(200));
8316 let findings = InternationalSeoAnalyzer::new().analyze(&ctx, &default_config());
8317 assert!(findings.iter().any(|f| f.code == "ISEO004"));
8318 }
8319
8320 #[test]
8321 fn test_iseo_detect_locale_from_url() {
8322 assert_eq!(
8323 InternationalSeoAnalyzer::detect_locale_from_url("https://example.com/en/page"),
8324 Some("en".to_string())
8325 );
8326 assert_eq!(
8327 InternationalSeoAnalyzer::detect_locale_from_url("https://example.com/fr/page"),
8328 Some("fr".to_string())
8329 );
8330 assert_eq!(
8331 InternationalSeoAnalyzer::detect_locale_from_url("https://example.com/en-US/page"),
8332 Some("en-US".to_string())
8333 );
8334 assert_eq!(
8335 InternationalSeoAnalyzer::detect_locale_from_url("https://example.com/page"),
8336 None
8337 );
8338 }
8339
8340 #[test]
8341 fn test_iseo_is_locale_segment() {
8342 assert!(InternationalSeoAnalyzer::is_locale_segment("en"));
8343 assert!(InternationalSeoAnalyzer::is_locale_segment("fr"));
8344 assert!(InternationalSeoAnalyzer::is_locale_segment("en-US"));
8345 assert!(InternationalSeoAnalyzer::is_locale_segment("zh-CN"));
8346 assert!(!InternationalSeoAnalyzer::is_locale_segment("page"));
8347 assert!(!InternationalSeoAnalyzer::is_locale_segment("e"));
8348 }
8349
8350 #[test]
8351 fn test_iseo_hreflang_url_locale_mismatch() {
8352 let mut page = make_page("https://example.com/en");
8353 page.meta.hreflang = vec![crate::meta::HreflangTag {
8354 lang: "fr".to_string(),
8355 url: Url::parse("https://example.com/en/about").unwrap(),
8356 }];
8357 let ctx = make_ctx(&page, Some(200));
8358 let findings = InternationalSeoAnalyzer::new().analyze(&ctx, &default_config());
8359 assert!(findings.iter().any(|f| f.code == "ISEO001"));
8360 }
8361
8362 #[test]
8363 fn test_iseo_missing_x_default() {
8364 let mut page = make_page("https://example.com/en");
8365 page.meta.hreflang = vec![
8366 crate::meta::HreflangTag {
8367 lang: "en".to_string(),
8368 url: Url::parse("https://example.com/en").unwrap(),
8369 },
8370 crate::meta::HreflangTag {
8371 lang: "fr".to_string(),
8372 url: Url::parse("https://example.com/fr").unwrap(),
8373 },
8374 ];
8375 let ctx = make_ctx(&page, Some(200));
8376 let findings = InternationalSeoAnalyzer::new().analyze(&ctx, &default_config());
8377 assert!(findings.iter().any(|f| f.code == "ISEO002"));
8378 }
8379
8380 #[test]
8381 fn test_iseo_duplicate_language() {
8382 let mut page = make_page("https://example.com/en");
8383 page.meta.hreflang = vec![
8384 crate::meta::HreflangTag {
8385 lang: "en".to_string(),
8386 url: Url::parse("https://example.com/en").unwrap(),
8387 },
8388 crate::meta::HreflangTag {
8389 lang: "en".to_string(),
8390 url: Url::parse("https://example.com/en-uk").unwrap(),
8391 },
8392 ];
8393 let ctx = make_ctx(&page, Some(200));
8394 let findings = InternationalSeoAnalyzer::new().analyze(&ctx, &default_config());
8395 assert!(findings.iter().any(|f| f.code == "ISEO003"));
8396 }
8397
8398 #[test]
8399 fn test_iseo_multilang_content() {
8400 let mut page = make_page("https://example.com/en");
8401 page.meta.hreflang = vec![
8402 crate::meta::HreflangTag {
8403 lang: "en".to_string(),
8404 url: Url::parse("https://example.com/en").unwrap(),
8405 },
8406 crate::meta::HreflangTag {
8407 lang: "x-default".to_string(),
8408 url: Url::parse("https://example.com").unwrap(),
8409 },
8410 ];
8411 page.html_lang = Some("en".to_string());
8412 let ctx = make_ctx(&page, Some(200));
8413 let findings = InternationalSeoAnalyzer::new().analyze(&ctx, &default_config());
8414 assert!(findings.iter().any(|f| f.code == "ISEO006"));
8415 }
8416
8417 #[test]
8418 fn test_iseo_valid_hreflang_with_xdefault() {
8419 let mut page = make_page("https://example.com/en");
8420 page.meta.hreflang = vec![
8421 crate::meta::HreflangTag {
8422 lang: "en".to_string(),
8423 url: Url::parse("https://example.com/en").unwrap(),
8424 },
8425 crate::meta::HreflangTag {
8426 lang: "fr".to_string(),
8427 url: Url::parse("https://example.com/fr").unwrap(),
8428 },
8429 crate::meta::HreflangTag {
8430 lang: "x-default".to_string(),
8431 url: Url::parse("https://example.com").unwrap(),
8432 },
8433 ];
8434 let ctx = make_ctx(&page, Some(200));
8435 let findings = InternationalSeoAnalyzer::new().analyze(&ctx, &default_config());
8436 assert!(!findings.iter().any(|f| f.severity == Severity::Error));
8437 }
8438
8439 #[test]
8440 fn test_iseo_locale_detection_from_url() {
8441 let locale = InternationalSeoAnalyzer::detect_locale_from_url(
8442 "https://example.com/de/products/widget",
8443 );
8444 assert_eq!(locale, Some("de".to_string()));
8445 }
8446
8447 #[test]
8448 fn test_iseo_no_locale_no_hreflang() {
8449 let mut page = make_page("https://example.com/products");
8450 page.html_lang = None;
8451 let ctx = make_ctx(&page, Some(200));
8452 let findings = InternationalSeoAnalyzer::new().analyze(&ctx, &default_config());
8453 assert!(!findings.iter().any(|f| f.code == "ISEO004"));
8454 }
8455
8456 #[test]
8457 fn test_iseo_multilang_detect() {
8458 assert!(InternationalSeoAnalyzer::detect_multilang_content(
8459 &[crate::meta::HreflangTag {
8460 lang: "en".to_string(),
8461 url: Url::parse("https://example.com/en").unwrap(),
8462 }],
8463 &None,
8464 ));
8465 assert!(InternationalSeoAnalyzer::detect_multilang_content(
8466 &[],
8467 &Some("en-US".to_string()),
8468 ));
8469 assert!(!InternationalSeoAnalyzer::detect_multilang_content(
8470 &[],
8471 &Some("en".to_string()),
8472 ));
8473 }
8474}