use serde::Serialize;
use serde_json::{json, Value};
use crate::cdp::client::CdpClient;
const EXTRACT_JS: &str = include_str!("../../vendor/extract.js");
const SCROLL_JS: &str = r"(async () => {
const MAX_SCROLLS = 10;
const SETTLE_MS = 1000;
const DEADLINE_MS = 8000;
// Some sites (YouTube) scroll on documentElement, not body
const getHeight = () => Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
const root = document.body.scrollHeight > 0 ? document.body : document.documentElement;
const scrollLoop = (async () => {
let prevHeight = 0;
for (let i = 0; i < MAX_SCROLLS; i++) {
const height = getHeight();
if (height === prevHeight && i > 0) break;
prevHeight = height;
window.scrollTo(0, height);
// Wait for DOM to settle using MutationObserver
await new Promise(resolve => {
let timer = setTimeout(resolve, SETTLE_MS);
const observer = new MutationObserver(() => {
clearTimeout(timer);
timer = setTimeout(() => {
observer.disconnect();
resolve();
}, 300);
});
observer.observe(root, { childList: true, subtree: true });
});
}
})();
// Hard deadline: even if the page never stops mutating, resolve with
// whatever loaded so far rather than hanging the CDP call forever.
const deadline = new Promise(resolve => setTimeout(resolve, DEADLINE_MS));
await Promise.race([scrollLoop, deadline]);
window.scrollTo(0, 0);
return getHeight();
})()";
#[derive(Debug, Serialize)]
pub struct ExtractResult {
pub items: Vec<Value>,
pub count: usize,
pub pattern: String,
}
pub async fn scroll_to_load(client: &CdpClient) -> Result<(), crate::BoxError> {
let _: Value = client
.call(
"Runtime.evaluate",
json!({
"expression": SCROLL_JS,
"returnByValue": true,
"awaitPromise": true,
}),
)
.await?;
Ok(())
}
pub async fn run_a11y(
client: &CdpClient,
limit: usize,
scroll: bool,
) -> Result<ExtractResult, crate::BoxError> {
let roles = ["article", "listitem", "row", "treeitem"];
for role in &roles {
let filter = vec![*role];
let snapshot = if scroll {
super::inspect::scroll_collect(client, false, None, Some(&filter), limit).await?
} else {
super::inspect::run(client, false, None, None, Some(&filter)).await?
};
let lines: Vec<&str> = snapshot.text.lines()
.filter(|l| l.trim().starts_with("uid="))
.collect();
if lines.is_empty() { continue; }
if lines.len() < 3 && !scroll { continue; }
let items: Vec<Value> = lines.iter()
.take(limit)
.map(|line| {
let text = line.trim();
let text = if let Some(rest) = text.strip_prefix("uid=") {
if let Some((_uid_role, content)) = rest.split_once('"') {
content.trim_end_matches('"')
} else {
rest.splitn(3, ' ').last().unwrap_or(rest)
}
} else {
text
};
json!({"text": text})
})
.collect();
let count = lines.len();
return Ok(ExtractResult {
items,
count,
pattern: format!("a11y:{role}"),
});
}
Err("No repeating a11y pattern found. Try: extract (DOM mode) or inspect --filter \"article\"".into())
}
fn build_extract_js(selector: Option<&str>, limit: usize) -> String {
let scope_js = if let Some(sel) = selector {
let escaped = serde_json::to_string(sel).unwrap_or_default();
format!(
"const _scope = document.querySelector({escaped}); if (!_scope) return JSON.stringify({{ items: [], hint: 'Selector ' + {escaped} + ' not found' }});"
)
} else {
"const _scope = document;".to_string()
};
format!(
"(() => {{\n{scope_js}\nconst _limit = {limit};\n{EXTRACT_JS}\nreturn extract(_scope, _limit);\n}})()"
)
}
pub async fn run(
client: &CdpClient,
selector: Option<&str>,
limit: usize,
) -> Result<ExtractResult, crate::BoxError> {
let js = build_extract_js(selector, limit);
let raw = crate::commands::eval::run_raw(client, &js).await?;
let parsed: Value = match raw {
Value::String(s) => serde_json::from_str(&s)?,
other => other,
};
let items = parsed
.get("items")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let count = parsed
.get("count")
.and_then(Value::as_u64)
.unwrap_or(items.len() as u64) as usize;
let pattern = parsed
.get("pattern")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if let Some(hint) = parsed.get("hint").and_then(Value::as_str)
&& items.is_empty() {
return Err(hint.into());
}
Ok(ExtractResult {
items,
count,
pattern,
})
}
pub fn format_text(result: &ExtractResult) -> String {
let mut out = format!(
"Found {} items (pattern: {})\n",
result.count, result.pattern
);
for (i, item) in result.items.iter().enumerate() {
let mut parts: Vec<String> = Vec::new();
if let Some(title) = item.get("title").and_then(Value::as_str) {
parts.push(format!("Title: \"{title}\""));
}
if let Some(price) = item.get("price").and_then(Value::as_str) {
parts.push(format!("Price: \"{price}\""));
}
if let Some(date) = item.get("date").and_then(Value::as_str) {
parts.push(format!("Date: \"{date}\""));
}
if let Some(url) = item.get("url").and_then(Value::as_str) {
parts.push(format!("URL: {url}"));
}
if let Some(image) = item.get("image").and_then(Value::as_str) {
parts.push(format!("Image: {image}"));
}
if let Some(text) = item.get("text").and_then(Value::as_str) {
parts.push(format!("Text: \"{text}\""));
}
if let Some(fields) = item.get("fields").and_then(Value::as_array) {
let texts: Vec<&str> = fields.iter().filter_map(Value::as_str).collect();
if !texts.is_empty() {
parts.push(format!("Fields: [{}]", texts.join(", ")));
}
}
out.push_str(&format!("{}. {}\n", i + 1, parts.join(" | ")));
}
out
}
pub fn to_json(result: &ExtractResult) -> Value {
json!({
"ok": true,
"items": result.items,
"count": result.count,
"pattern": result.pattern,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scroll_js_has_hard_deadline_race() {
assert!(
SCROLL_JS.contains("Promise.race"),
"scroll routine must bound itself with Promise.race"
);
assert!(
SCROLL_JS.contains("DEADLINE_MS"),
"scroll routine must define a hard deadline"
);
assert!(
SCROLL_JS.contains("const deadline = new Promise(resolve => setTimeout(resolve, DEADLINE_MS))"),
"deadline must be a self-resolving timeout"
);
assert!(SCROLL_JS.contains("MAX_SCROLLS = 10"));
}
#[test]
fn extract_js_is_the_vendored_source() {
assert_eq!(EXTRACT_JS, include_str!("../../vendor/extract.js"));
assert!(
EXTRACT_JS.contains("function extract(_scope, _limit)"),
"vendored source must expose extract(_scope, _limit)"
);
}
#[test]
fn build_extract_js_embeds_vendor_and_calls_entrypoint() {
let js = build_extract_js(None, 20);
assert!(js.contains("const _scope = document;"));
assert!(js.contains("const _limit = 20;"));
assert!(js.contains("function extract(_scope, _limit)"));
assert!(js.contains("return extract(_scope, _limit);"));
assert!(js.trim_start().starts_with("(() => {"));
assert!(js.trim_end().ends_with("})()"));
}
#[test]
fn build_extract_js_scopes_and_escapes_selector() {
let js = build_extract_js(Some("div.card"), 5);
assert!(js.contains("document.querySelector(\"div.card\")"));
assert!(js.contains("const _limit = 5;"));
assert!(js.contains("if (!_scope) return JSON.stringify"));
}
#[test]
fn build_extract_js_selector_escaping_is_injection_safe() {
let js = build_extract_js(Some("a[href=\"x\"]"), 1);
assert!(js.contains(r#"document.querySelector("a[href=\"x\"]")"#));
assert!(!js.contains("querySelector(a[href=\"x\"])"));
}
}