1pub 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 & 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 & Jerry 'Special'"),
205 "Tom & Jerry 'Special'"
206 );
207 assert_eq!(
208 decode_basic_entities("<div>"Hello" World</div>"),
209 "<div>\"Hello\"\u{a0}World</div>"
210 );
211 }
212
213 #[test]
214 fn nested_person_type_is_not_promoted_as_page_schema() {
215 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}