crawlkit_engine/
js_render_decision.rs1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct SpaIndicators {
9 pub root_elements: Vec<String>,
11 pub framework_signatures: Vec<String>,
13 pub api_patterns: Vec<String>,
15}
16
17impl Default for SpaIndicators {
18 fn default() -> Self {
19 Self {
20 root_elements: vec![
21 "id=\"app\"".to_string(),
22 "id=\"root\"".to_string(),
23 "id=\"__next\"".to_string(),
24 "id=\"__nuxt\"".to_string(),
25 "id=\"svelte\"".to_string(),
26 ],
27 framework_signatures: vec![
28 "__NEXT_DATA__".to_string(),
29 "__NUXT__".to_string(),
30 "window.__SVELTEKIT".to_string(),
31 "React.createElement".to_string(),
32 "Vue.createApp".to_string(),
33 "angular".to_string(),
34 ],
35 api_patterns: vec![
36 "/api/".to_string(),
37 "/graphql".to_string(),
38 "/_next/".to_string(),
39 "/_nuxt/".to_string(),
40 ],
41 }
42 }
43}
44
45pub struct JsRenderDecisionEngine {
62 indicators: SpaIndicators,
63 force_js_patterns: Vec<String>,
65 skip_js_patterns: Vec<String>,
67}
68
69impl JsRenderDecisionEngine {
70 #[must_use]
72 pub fn new() -> Self {
73 Self {
74 indicators: SpaIndicators::default(),
75 force_js_patterns: Vec::new(),
76 skip_js_patterns: Vec::new(),
77 }
78 }
79
80 pub fn add_force_js_pattern(&mut self, pattern: String) {
82 self.force_js_patterns.push(pattern);
83 }
84
85 pub fn add_skip_js_pattern(&mut self, pattern: String) {
87 self.skip_js_patterns.push(pattern);
88 }
89
90 #[must_use]
92 pub fn should_render_js(&self, url: &str, html_hint: Option<&str>) -> JsRenderDecision {
93 for pattern in &self.skip_js_patterns {
95 if url.contains(pattern.as_str()) {
96 return JsRenderDecision::Skip {
97 reason: format!("URL matches skip pattern: {}", pattern),
98 };
99 }
100 }
101
102 for pattern in &self.force_js_patterns {
104 if url.contains(pattern.as_str()) {
105 return JsRenderDecision::Render {
106 reason: format!("URL matches force pattern: {}", pattern),
107 };
108 }
109 }
110
111 if let Some(html) = html_hint {
113 for element in &self.indicators.root_elements {
114 if html.contains(element.as_str()) {
115 return JsRenderDecision::Render {
116 reason: format!("SPA root element detected: {}", element),
117 };
118 }
119 }
120
121 for signature in &self.indicators.framework_signatures {
122 if html.contains(signature.as_str()) {
123 return JsRenderDecision::Render {
124 reason: format!("Framework signature detected: {}", signature),
125 };
126 }
127 }
128 }
129
130 JsRenderDecision::Skip {
131 reason: "No SPA indicators detected".to_string(),
132 }
133 }
134}
135
136impl Default for JsRenderDecisionEngine {
137 fn default() -> Self {
138 Self::new()
139 }
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
144pub enum JsRenderDecision {
145 Render {
147 reason: String,
149 },
150 Skip {
152 reason: String,
154 },
155}
156
157#[cfg(test)]
162mod tests {
163 use super::*;
164
165 #[test]
166 fn test_spa_indicators_default() {
167 let indicators = SpaIndicators::default();
168 assert!(!indicators.root_elements.is_empty());
169 assert!(!indicators.framework_signatures.is_empty());
170 }
171
172 #[test]
173 fn test_js_render_decision_skip() {
174 let engine = JsRenderDecisionEngine::new();
175 let decision = engine.should_render_js("https://example.com/page", None);
176 match decision {
177 JsRenderDecision::Skip { .. } => {}
178 _ => panic!("Expected Skip"),
179 }
180 }
181
182 #[test]
183 fn test_js_render_decision_nextjs() {
184 let engine = JsRenderDecisionEngine::new();
185 let html = r#"<div id="__next">Hello</div>"#;
186 let decision = engine.should_render_js("https://example.com/page", Some(html));
187 match decision {
188 JsRenderDecision::Render { reason } => {
189 assert!(reason.contains("SPA root element"));
190 }
191 _ => panic!("Expected Render"),
192 }
193 }
194
195 #[test]
196 fn test_js_render_decision_force_pattern() {
197 let mut engine = JsRenderDecisionEngine::new();
198 engine.add_force_js_pattern("/dashboard".to_string());
199 let decision = engine.should_render_js("https://example.com/dashboard", None);
200 match decision {
201 JsRenderDecision::Render { reason } => {
202 assert!(reason.contains("force pattern"));
203 }
204 _ => panic!("Expected Render"),
205 }
206 }
207}