Skip to main content

crawlkit_engine/
js_render_decision.rs

1use serde::{Deserialize, Serialize};
2
3/// SPA detection indicators.
4///
5/// Contains patterns for detecting single-page application frameworks
6/// from HTML content and API endpoint URLs.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct SpaIndicators {
9    /// Common SPA framework root elements.
10    pub root_elements: Vec<String>,
11    /// JavaScript framework signatures.
12    pub framework_signatures: Vec<String>,
13    /// API endpoint patterns.
14    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
45/// Decision engine for whether to use JavaScript rendering.
46///
47/// Analyzes URL patterns and HTML hints to decide if a page needs
48/// Playwright rendering. Checks skip/force patterns first, then
49/// SPA framework indicators.
50///
51/// # Examples
52///
53/// ```rust
54/// use crawlkit_engine::JsRenderDecisionEngine;
55/// use crawlkit_engine::JsRenderDecision;
56///
57/// let engine = JsRenderDecisionEngine::new();
58/// let decision = engine.should_render_js("https://example.com/page", None);
59/// assert!(matches!(decision, JsRenderDecision::Skip { .. }));
60/// ```
61pub struct JsRenderDecisionEngine {
62    indicators: SpaIndicators,
63    /// URL patterns that should always use JS rendering.
64    force_js_patterns: Vec<String>,
65    /// URL patterns that should never use JS rendering.
66    skip_js_patterns: Vec<String>,
67}
68
69impl JsRenderDecisionEngine {
70    /// Create new decision engine.
71    #[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    /// Add URL pattern that forces JS rendering.
81    pub fn add_force_js_pattern(&mut self, pattern: String) {
82        self.force_js_patterns.push(pattern);
83    }
84
85    /// Add URL pattern that skips JS rendering.
86    pub fn add_skip_js_pattern(&mut self, pattern: String) {
87        self.skip_js_patterns.push(pattern);
88    }
89
90    /// Decide if a URL needs JavaScript rendering.
91    #[must_use]
92    pub fn should_render_js(&self, url: &str, html_hint: Option<&str>) -> JsRenderDecision {
93        // Check skip patterns first
94        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        // Check force patterns
103        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        // Check HTML hints for SPA indicators
112        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/// Decision on whether to render with JavaScript.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub enum JsRenderDecision {
145    /// Page should be rendered with JavaScript.
146    Render {
147        /// Reason for the decision.
148        reason: String,
149    },
150    /// Page should be fetched without JavaScript rendering.
151    Skip {
152        /// Reason for the decision.
153        reason: String,
154    },
155}
156
157// ---------------------------------------------------------------------------
158// Tests
159// ---------------------------------------------------------------------------
160
161#[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}