viewpoint_core/page/content/
mod.rs

1//! Content manipulation functionality.
2//!
3//! This module provides methods for manipulating page HTML content,
4//! injecting scripts and styles.
5
6use std::time::Duration;
7
8use tracing::{debug, info, instrument};
9use viewpoint_cdp::protocol::page::SetDocumentContentParams;
10use viewpoint_cdp::protocol::runtime::EvaluateParams;
11
12use crate::error::PageError;
13use crate::wait::DocumentLoadState;
14
15use super::Page;
16
17/// Script type for injection.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19pub enum ScriptType {
20    /// Regular script (default).
21    #[default]
22    Script,
23    /// ES6 module.
24    Module,
25}
26
27/// Builder for injecting script tags.
28#[derive(Debug)]
29pub struct ScriptTagBuilder<'a> {
30    page: &'a Page,
31    url: Option<String>,
32    content: Option<String>,
33    script_type: ScriptType,
34}
35
36impl<'a> ScriptTagBuilder<'a> {
37    /// Create a new script tag builder.
38    pub(crate) fn new(page: &'a Page) -> Self {
39        Self {
40            page,
41            url: None,
42            content: None,
43            script_type: ScriptType::default(),
44        }
45    }
46
47    /// Set the script URL.
48    #[must_use]
49    pub fn url(mut self, url: impl Into<String>) -> Self {
50        self.url = Some(url.into());
51        self
52    }
53
54    /// Set the script content.
55    #[must_use]
56    pub fn content(mut self, content: impl Into<String>) -> Self {
57        self.content = Some(content.into());
58        self
59    }
60
61    /// Set the script type.
62    #[must_use]
63    pub fn script_type(mut self, script_type: ScriptType) -> Self {
64        self.script_type = script_type;
65        self
66    }
67
68    /// Inject the script tag into the page.
69    ///
70    /// # Errors
71    ///
72    /// Returns an error if the injection fails.
73    #[instrument(level = "debug", skip(self), fields(has_url = self.url.is_some(), has_content = self.content.is_some()))]
74    pub async fn inject(self) -> Result<(), PageError> {
75        if self.page.is_closed() {
76            return Err(PageError::Closed);
77        }
78
79        let script_js = if let Some(url) = self.url {
80            format!(
81                r"
82                new Promise((resolve, reject) => {{
83                    const script = document.createElement('script');
84                    script.src = '{}';
85                    script.setAttribute('type', '{}');
86                    script.onload = resolve;
87                    script.onerror = reject;
88                    document.head.appendChild(script);
89                }})
90                ",
91                url.replace('\'', "\\'"),
92                if self.script_type == ScriptType::Module { "module" } else { "text/javascript" }
93            )
94        } else if let Some(content) = self.content {
95            format!(
96                r"
97                (() => {{
98                    const script = document.createElement('script');
99                    script.textContent = {};
100                    {}
101                    document.head.appendChild(script);
102                }})()
103                ",
104                serde_json::to_string(&content).unwrap_or_default(),
105                if self.script_type == ScriptType::Module { "script.type = 'module';" } else { "" }
106            )
107        } else {
108            return Err(PageError::EvaluationFailed(
109                "Either url or content must be provided".to_string(),
110            ));
111        };
112
113        debug!("Injecting script tag");
114
115        self.page
116            .connection()
117            .send_command::<_, serde_json::Value>(
118                "Runtime.evaluate",
119                Some(EvaluateParams {
120                    expression: script_js,
121                    object_group: None,
122                    include_command_line_api: None,
123                    silent: Some(false),
124                    context_id: None,
125                    return_by_value: Some(true),
126                    await_promise: Some(true),
127                }),
128                Some(self.page.session_id()),
129            )
130            .await?;
131
132        Ok(())
133    }
134}
135
136/// Builder for injecting style tags.
137#[derive(Debug)]
138pub struct StyleTagBuilder<'a> {
139    page: &'a Page,
140    url: Option<String>,
141    content: Option<String>,
142}
143
144impl<'a> StyleTagBuilder<'a> {
145    /// Create a new style tag builder.
146    pub(crate) fn new(page: &'a Page) -> Self {
147        Self {
148            page,
149            url: None,
150            content: None,
151        }
152    }
153
154    /// Set the stylesheet URL.
155    #[must_use]
156    pub fn url(mut self, url: impl Into<String>) -> Self {
157        self.url = Some(url.into());
158        self
159    }
160
161    /// Set the CSS content.
162    #[must_use]
163    pub fn content(mut self, content: impl Into<String>) -> Self {
164        self.content = Some(content.into());
165        self
166    }
167
168    /// Inject the style into the page.
169    ///
170    /// # Errors
171    ///
172    /// Returns an error if the injection fails.
173    #[instrument(level = "debug", skip(self), fields(has_url = self.url.is_some(), has_content = self.content.is_some()))]
174    pub async fn inject(self) -> Result<(), PageError> {
175        if self.page.is_closed() {
176            return Err(PageError::Closed);
177        }
178
179        let style_js = if let Some(url) = self.url {
180            format!(
181                r"
182                new Promise((resolve, reject) => {{
183                    const link = document.createElement('link');
184                    link.rel = 'stylesheet';
185                    link.href = '{}';
186                    link.onload = resolve;
187                    link.onerror = reject;
188                    document.head.appendChild(link);
189                }})
190                ",
191                url.replace('\'', "\\'")
192            )
193        } else if let Some(content) = self.content {
194            format!(
195                r"
196                (() => {{
197                    const style = document.createElement('style');
198                    style.textContent = {};
199                    document.head.appendChild(style);
200                }})()
201                ",
202                serde_json::to_string(&content).unwrap_or_default()
203            )
204        } else {
205            return Err(PageError::EvaluationFailed(
206                "Either url or content must be provided".to_string(),
207            ));
208        };
209
210        debug!("Injecting style tag");
211
212        self.page
213            .connection()
214            .send_command::<_, serde_json::Value>(
215                "Runtime.evaluate",
216                Some(EvaluateParams {
217                    expression: style_js,
218                    object_group: None,
219                    include_command_line_api: None,
220                    silent: Some(false),
221                    context_id: None,
222                    return_by_value: Some(true),
223                    await_promise: Some(true),
224                }),
225                Some(self.page.session_id()),
226            )
227            .await?;
228
229        Ok(())
230    }
231}
232
233/// Builder for setting page content.
234#[derive(Debug)]
235pub struct SetContentBuilder<'a> {
236    page: &'a Page,
237    html: String,
238    wait_until: DocumentLoadState,
239    timeout: Duration,
240}
241
242impl<'a> SetContentBuilder<'a> {
243    /// Create a new set content builder.
244    pub(crate) fn new(page: &'a Page, html: String) -> Self {
245        Self {
246            page,
247            html,
248            wait_until: DocumentLoadState::Load,
249            timeout: Duration::from_secs(30),
250        }
251    }
252
253    /// Set the load state to wait for.
254    #[must_use]
255    pub fn wait_until(mut self, state: DocumentLoadState) -> Self {
256        self.wait_until = state;
257        self
258    }
259
260    /// Set the timeout.
261    #[must_use]
262    pub fn timeout(mut self, timeout: Duration) -> Self {
263        self.timeout = timeout;
264        self
265    }
266
267    /// Set the page content.
268    ///
269    /// # Errors
270    ///
271    /// Returns an error if setting content fails.
272    #[instrument(level = "info", skip(self), fields(html_len = self.html.len(), wait_until = ?self.wait_until))]
273    pub async fn set(self) -> Result<(), PageError> {
274        if self.page.is_closed() {
275            return Err(PageError::Closed);
276        }
277
278        info!("Setting page content");
279
280        // Use Page.setDocumentContent
281        self.page
282            .connection()
283            .send_command::<_, serde_json::Value>(
284                "Page.setDocumentContent",
285                Some(SetDocumentContentParams {
286                    frame_id: self.page.frame_id().to_string(),
287                    html: self.html,
288                }),
289                Some(self.page.session_id()),
290            )
291            .await?;
292
293        // Wait for the specified load state
294        // For setDocumentContent, the content is set synchronously,
295        // but we may need to wait for any scripts/resources to load
296        if self.wait_until != DocumentLoadState::Commit {
297            // Small delay to allow the document to settle
298            tokio::time::sleep(Duration::from_millis(50)).await;
299        }
300
301        info!("Page content set");
302        Ok(())
303    }
304}
305
306impl Page {
307    /// Get the full HTML content of the page including the doctype.
308    ///
309    /// # Example
310    ///
311    /// ```no_run
312    /// # async fn example(page: viewpoint_core::Page) -> Result<(), viewpoint_core::CoreError> {
313    /// let html = page.content().await?;
314    /// println!("Page HTML: {}", html);
315    /// # Ok(())
316    /// # }
317    /// ```
318    ///
319    /// # Errors
320    ///
321    /// Returns an error if the page is closed or evaluation fails.
322    #[instrument(level = "debug", skip(self))]
323    pub async fn content(&self) -> Result<String, PageError> {
324        if self.closed {
325            return Err(PageError::Closed);
326        }
327
328        let result: viewpoint_cdp::protocol::runtime::EvaluateResult = self
329            .connection
330            .send_command(
331                "Runtime.evaluate",
332                Some(EvaluateParams {
333                    expression: r#"
334                        (() => {
335                            const doctype = document.doctype;
336                            const doctypeString = doctype 
337                                ? `<!DOCTYPE ${doctype.name}${doctype.publicId ? ` PUBLIC "${doctype.publicId}"` : ''}${doctype.systemId ? ` "${doctype.systemId}"` : ''}>`
338                                : '';
339                            return doctypeString + document.documentElement.outerHTML;
340                        })()
341                    "#.to_string(),
342                    object_group: None,
343                    include_command_line_api: None,
344                    silent: Some(true),
345                    context_id: None,
346                    return_by_value: Some(true),
347                    await_promise: Some(false),
348                }),
349                Some(&self.session_id),
350            )
351            .await?;
352
353        result
354            .result
355            .value
356            .and_then(|v| v.as_str().map(ToString::to_string))
357            .ok_or_else(|| PageError::EvaluationFailed("Failed to get content".to_string()))
358    }
359
360    /// Set the page HTML content.
361    ///
362    /// Returns a builder for additional options.
363    ///
364    /// # Example
365    ///
366    /// ```no_run
367    /// use viewpoint_core::DocumentLoadState;
368    ///
369    /// # async fn example(page: viewpoint_core::Page) -> Result<(), viewpoint_core::CoreError> {
370    /// // Set simple content
371    /// page.set_content("<html><body>Hello</body></html>").set().await?;
372    ///
373    /// // Set content and wait for network idle
374    /// page.set_content("<html><body><script src='app.js'></script></body></html>")
375    ///     .wait_until(DocumentLoadState::NetworkIdle)
376    ///     .set()
377    ///     .await?;
378    /// # Ok(())
379    /// # }
380    /// ```
381    pub fn set_content(&self, html: impl Into<String>) -> SetContentBuilder<'_> {
382        SetContentBuilder::new(self, html.into())
383    }
384
385    /// Create a builder for injecting script tags.
386    ///
387    /// # Example
388    ///
389    /// ```no_run
390    /// # async fn example(page: viewpoint_core::Page) -> Result<(), viewpoint_core::CoreError> {
391    /// // Add script by URL
392    /// page.add_script_tag().url("https://example.com/script.js").inject().await?;
393    ///
394    /// // Add inline script
395    /// page.add_script_tag().content("console.log('Hello')").inject().await?;
396    ///
397    /// // Add ES6 module
398    /// use viewpoint_core::page::ScriptType;
399    /// page.add_script_tag()
400    ///     .content("export const x = 1;")
401    ///     .script_type(ScriptType::Module)
402    ///     .inject()
403    ///     .await?;
404    /// # Ok(())
405    /// # }
406    /// ```
407    pub fn add_script_tag(&self) -> ScriptTagBuilder<'_> {
408        ScriptTagBuilder::new(self)
409    }
410
411    /// Create a builder for injecting style tags.
412    ///
413    /// # Example
414    ///
415    /// ```no_run
416    /// # async fn example(page: viewpoint_core::Page) -> Result<(), viewpoint_core::CoreError> {
417    /// // Add stylesheet by URL
418    /// page.add_style_tag().url("https://example.com/style.css").inject().await?;
419    ///
420    /// // Add inline CSS
421    /// page.add_style_tag().content("body { background: red; }").inject().await?;
422    /// # Ok(())
423    /// # }
424    /// ```
425    pub fn add_style_tag(&self) -> StyleTagBuilder<'_> {
426        StyleTagBuilder::new(self)
427    }
428}