Skip to main content

indexflow_seo/
lib.rs

1//! # indexflow-seo
2//!
3//! A high-performance, lightweight Technical SEO quality gate & GEO (Generative Engine Optimization) auditor.
4//!
5//! ## Features
6//! - Zero-dependency HTML evaluation (HTTP Status, Canonical, noindex, Title, H1, Meta Description)
7//! - JSON-LD (Schema.org) structured data extraction and entity mapping (`@graph`, array `@type`)
8//! - OpenGraph & Twitter Card social meta parsing
9//! - AI Search Engine Bot directive auditing (GPTBot, Perplexity, Claude, Google-Extended)
10//! - Non-redirecting fast HTTP prober client (Redirects treated as gate issues)
11
12pub mod canonical;
13pub mod evaluator;
14pub mod extractor;
15pub mod models;
16
17#[cfg(feature = "probe")]
18pub mod probe;
19
20pub use canonical::{canonical_matches_page, normalize_url};
21pub use evaluator::evaluate_html;
22pub use extractor::{decode_basic_entities, inspect_html};
23pub use models::*;
24
25#[cfg(feature = "probe")]
26pub use probe::SeoProbeClient;
27
28#[cfg(test)]
29mod tests {
30    use super::*;
31
32    const SAMPLE_HTML_FULL: &str = r#"<!DOCTYPE html>
33    <html lang="en">
34    <head>
35      <meta charset="UTF-8">
36      <title>Rust Monolith Guide &amp; SEO Best Practices</title>
37      <meta name="description" content="A complete technical SEO guide for modern Rust developers." />
38      <meta name="robots" content="index, follow" />
39      <link rel="canonical" href="https://example.com/guide" />
40      <link rel="alternate" hreflang="zh" href="https://example.com/zh/guide" />
41      <link rel="alternate" hreflang="en" href="https://example.com/guide" />
42      
43      <!-- Social Metadata -->
44      <meta property="og:title" content="Rust Monolith Guide" />
45      <meta property="og:description" content="Social preview description." />
46      <meta property="og:type" content="article" />
47      <meta property="og:image" content="https://example.com/cover.jpg" />
48      <meta name="twitter:card" content="summary_large_image" />
49      
50      <!-- AI Bot Directives -->
51      <meta name="gptbot" content="noindex" />
52      <meta name="perplexitybot" content="index" />
53      
54      <!-- JSON-LD Schema.org -->
55      <script type="application/ld+json">
56      {
57        "@context": "https://schema.org",
58        "@type": "Article",
59        "headline": "Rust Monolith Guide",
60        "author": {
61          "@type": "Person",
62          "name": "IndexFlow Team"
63        }
64      }
65      </script>
66      <script type="application/ld+json">
67      {
68        "@context": "https://schema.org",
69        "@type": "FAQPage",
70        "mainEntity": []
71      }
72      </script>
73    </head>
74    <body>
75      <h1>Complete Rust Guide</h1>
76      <p>Body paragraph</p>
77    </body>
78    </html>"#;
79
80    #[test]
81    fn test_full_inspection_pass() {
82        let res = evaluate_html("https://example.com/guide", 200, 32, None, SAMPLE_HTML_FULL);
83
84        assert!(res.passed);
85        assert_eq!(res.block_reason, None);
86        assert_eq!(res.http_status, Some(200));
87        assert_eq!(
88            res.page_title.as_deref(),
89            Some("Rust Monolith Guide & SEO Best Practices")
90        );
91        assert_eq!(
92            res.meta_description.as_deref(),
93            Some("A complete technical SEO guide for modern Rust developers.")
94        );
95        assert_eq!(res.h1_content.as_deref(), Some("Complete Rust Guide"));
96        assert_eq!(res.h1_count, 1);
97        assert!(res.has_canonical);
98        assert_eq!(res.canonical_url.as_deref(), Some("https://example.com/guide"));
99        assert!(!res.has_noindex);
100        assert!(!res.has_nofollow);
101        assert_eq!(res.hreflang.len(), 2);
102
103        assert_eq!(res.opengraph.title.as_deref(), Some("Rust Monolith Guide"));
104        assert_eq!(res.opengraph.og_type.as_deref(), Some("article"));
105        assert_eq!(res.opengraph.image.as_deref(), Some("https://example.com/cover.jpg"));
106        assert_eq!(res.twitter_card.card.as_deref(), Some("summary_large_image"));
107
108        assert!(res.ai_directives.gptbot_blocked);
109        assert!(!res.ai_directives.perplexity_blocked);
110
111        assert_eq!(res.json_ld.len(), 2);
112        assert_eq!(
113            res.schema_types(),
114            vec!["Article".to_string(), "FAQPage".to_string()]
115        );
116    }
117
118    #[test]
119    fn test_gate_block_http_non_200() {
120        let res = evaluate_html(
121            "https://example.com/404",
122            404,
123            15,
124            None,
125            "<html><head><title>Not Found</title></head></html>",
126        );
127        assert!(!res.passed);
128        assert_eq!(res.block_reason.as_deref(), Some("HTTP 404"));
129    }
130
131    #[test]
132    fn test_gate_block_meta_noindex() {
133        let html = r#"<html><head><title>Draft Page</title><meta name="robots" content="noindex, nofollow" /></head></html>"#;
134        let res = evaluate_html("https://example.com/draft", 200, 20, None, html);
135        assert!(!res.passed);
136        assert!(res.has_noindex);
137        assert!(res.has_nofollow);
138        assert_eq!(
139            res.block_reason.as_deref(),
140            Some("noindex directive present")
141        );
142    }
143
144    #[test]
145    fn test_gate_block_x_robots_tag_header() {
146        let html = r#"<html><head><title>Valid Title</title></head></html>"#;
147        let res = evaluate_html(
148            "https://example.com/page",
149            200,
150            20,
151            Some("noindex, noarchive"),
152            html,
153        );
154        assert!(!res.passed);
155        assert!(res.has_noindex);
156        assert_eq!(
157            res.block_reason.as_deref(),
158            Some("noindex directive present")
159        );
160    }
161
162    #[test]
163    fn test_gate_block_missing_title() {
164        let html = r#"<html><head><meta name="description" content="No title here" /></head><body><h1>Hello</h1></body></html>"#;
165        let res = evaluate_html("https://example.com/no-title", 200, 20, None, html);
166        assert!(!res.passed);
167        assert_eq!(res.block_reason.as_deref(), Some("Missing <title> tag"));
168    }
169
170    #[test]
171    fn test_gate_block_canonical_mismatch() {
172        let html = r#"<html><head><title>Duplicate Post</title><link rel="canonical" href="https://example.com/original-post" /></head></html>"#;
173        let res = evaluate_html("https://example.com/duplicate-post", 200, 20, None, html);
174        assert!(!res.passed);
175        assert_eq!(
176            res.block_reason.as_deref(),
177            Some("Canonical URL mismatch: https://example.com/original-post")
178        );
179    }
180
181    #[test]
182    fn test_canonical_fuzzy_match() {
183        assert!(canonical_matches_page(
184            "https://example.com/blog/post-1/",
185            "https://example.com/blog/post-1"
186        ));
187        assert!(canonical_matches_page(
188            "https://example.com:443/blog/post-1",
189            "https://example.com/blog/post-1"
190        ));
191        assert!(canonical_matches_page(
192            "https://example.com/blog/post-1",
193            "/blog/post-1"
194        ));
195        assert!(!canonical_matches_page(
196            "https://example.com/blog/post-1",
197            "https://example.com/blog/other"
198        ));
199    }
200
201    #[test]
202    fn test_entity_decoding() {
203        assert_eq!(
204            decode_basic_entities("Tom &amp; Jerry &#39;Special&#39;"),
205            "Tom & Jerry 'Special'"
206        );
207        assert_eq!(
208            decode_basic_entities("&lt;div&gt;&quot;Hello&quot;&nbsp;World&lt;/div&gt;"),
209            "<div>\"Hello\"\u{a0}World</div>"
210        );
211    }
212
213    #[test]
214    fn nested_person_type_is_not_promoted_as_page_schema() {
215        // The Article block also contains an author Person. schema_types walks
216        // only @type / @graph, not arbitrary nested objects — Person stays out.
217        let res = evaluate_html("https://example.com/guide", 200, 1, None, SAMPLE_HTML_FULL);
218        assert_eq!(
219            res.schema_types(),
220            vec!["Article".to_string(), "FAQPage".to_string()]
221        );
222    }
223}