use chromiumoxide::cdp::browser_protocol::page::AddScriptToEvaluateOnNewDocumentParams;
use chromiumoxide::Page;
use crate::error::{BrowserError, BrowserResult};
pub const CAPTURE_GLOBAL: &str = "__dravrCaptures";
#[derive(Debug, Clone, Default)]
pub struct StealthOptions {
pub capture_url_pattern: Option<String>,
pub streaming: bool,
}
impl StealthOptions {
#[must_use]
pub const fn stealth_only() -> Self {
Self {
capture_url_pattern: None,
streaming: false,
}
}
#[must_use]
pub fn capture(pattern: impl Into<String>) -> Self {
Self {
capture_url_pattern: Some(pattern.into()),
streaming: false,
}
}
#[must_use]
pub fn capture_stream(pattern: impl Into<String>) -> Self {
Self {
capture_url_pattern: Some(pattern.into()),
streaming: true,
}
}
}
fn build_script(opts: &StealthOptions) -> String {
let Some(pattern) = opts.capture_url_pattern.as_ref() else {
return "(function(){})();".to_owned();
};
let pattern_lit = serde_json::to_string(pattern).unwrap_or_else(|_| "\"\"".to_owned());
let capture_body = if opts.streaming {
"var rec = { status: r.status, chunks: [], done: false, streaming: true };
store.byUrl[url] = rec; store.last = url;
try {
var reader = r.clone().body.getReader();
var dec = new TextDecoder();
(function pump() {
reader.read().then(function(res) {
if (res.done) { rec.done = true; return; }
rec.chunks.push(dec.decode(res.value, { stream: true }));
pump();
}).catch(function() { rec.done = true; });
})();
} catch (e) { rec.done = true; }"
} else {
"var rec = { status: r.status, chunks: [], done: false, streaming: false };
store.byUrl[url] = rec; store.last = url;
try {
r.clone().text().then(function(t) {
rec.chunks.push(t); rec.done = true;
}).catch(function() { rec.done = true; });
} catch (e) { rec.done = true; }"
};
format!(
r"(function() {{
if (window.{CAPTURE_GLOBAL}) return;
var store = window.{CAPTURE_GLOBAL} = {{ byUrl: {{}}, last: null }};
var pattern = new RegExp({pattern_lit});
var origFetch = window.fetch;
window.fetch = function(input, init) {{
var url = typeof input === 'string' ? input : (input && input.url) || '';
var p = origFetch.apply(this, arguments);
if (pattern.test(url)) {{
p.then(function(r) {{
{capture_body}
return r;
}}).catch(function() {{}});
}}
return p;
}};
var origOpen = XMLHttpRequest.prototype.open;
var origSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, url) {{
this.__dravrUrl = url;
return origOpen.apply(this, arguments);
}};
XMLHttpRequest.prototype.send = function() {{
var self = this;
var url = this.__dravrUrl || '';
if (pattern.test(url)) {{
this.addEventListener('load', function() {{
try {{
store.byUrl[url] = {{
status: self.status,
chunks: [self.responseText],
done: true,
streaming: false
}};
store.last = url;
}} catch (e) {{}}
}});
}}
return origSend.apply(this, arguments);
}};
}})();"
)
}
pub async fn apply_stealth(page: &Page, opts: &StealthOptions) -> BrowserResult<()> {
page.execute(AddScriptToEvaluateOnNewDocumentParams::new(build_script(
opts,
)))
.await
.map_err(|e| BrowserError::Browser {
reason: format!("Failed to inject stealth script: {e}"),
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stealth_only_has_no_capture_hook() {
let js = build_script(&StealthOptions::stealth_only());
assert!(!js.contains(CAPTURE_GLOBAL));
}
#[test]
fn capture_script_embeds_pattern_and_global() {
let js = build_script(&StealthOptions::capture("/completion"));
assert!(js.contains(CAPTURE_GLOBAL));
assert!(js.contains("/completion"));
assert!(js.contains("r.clone().text()"));
assert!(!js.contains("getReader"));
}
#[test]
fn stream_capture_script_tees_reader() {
let js = build_script(&StealthOptions::capture_stream("/completion"));
assert!(js.contains("getReader"));
assert!(js.contains("streaming: true"));
}
#[test]
fn pattern_with_quotes_is_escaped() {
let js = build_script(&StealthOptions::capture(r#"a"b"#));
assert!(js.contains(r#"a\"b"#));
}
}