use crate::SelectorKind;
use crate::ops::{
OP_DEFAULT_TIMEOUT_MS, attach_target, connect_browser, declare_action, dur, eval_json,
protocol, truncate_text, validate_property, validate_selector, verify_target_alive,
};
use crate::scripts::{locate_all_script, locate_deep_script, scroll_page_script};
use crate::session::{self, Paths};
use actl_core::CtlError;
use serde_json::{Value, json};
use std::time::Duration;
pub struct CollectSpec<'a> {
pub session_id: &'a str,
pub kind: SelectorKind,
pub value: &'a str,
pub property: Option<&'a str>,
pub deep: bool,
pub interval_ms: Option<u64>,
pub max_rounds: Option<u64>,
pub max_items: Option<u64>,
pub timeout_ms: Option<u64>,
}
pub fn collect(paths: &Paths, spec: CollectSpec<'_>) -> Result<Value, CtlError> {
let CollectSpec {
session_id,
kind,
value,
property,
deep,
interval_ms,
max_rounds,
max_items,
timeout_ms,
} = spec;
validate_selector(kind, value)?;
let property = property.unwrap_or("text");
validate_property(property)?;
let interval = interval_ms.unwrap_or(500);
if !(100..=5000).contains(&interval) {
return Err(protocol(format!(
"browser-collect --interval-ms must be 100..=5000, got {interval}"
)));
}
let max_rounds = max_rounds.unwrap_or(100);
if !(1..=200).contains(&max_rounds) {
return Err(protocol(format!(
"browser-collect --max-rounds must be 1..=200, got {max_rounds}"
)));
}
let max_items = max_items.unwrap_or(2000);
if !(1..=5000).contains(&max_items) {
return Err(protocol(format!(
"browser-collect --max-items must be 1..=5000, got {max_items}"
)));
}
let timeout = dur(timeout_ms, OP_DEFAULT_TIMEOUT_MS);
let record = session::load(paths, session_id)?;
let conn = connect_browser(&record)?;
let cdp_session = attach_target(&conn, &record.target_id, timeout)?;
verify_target_alive(&conn, &record.target_id, timeout)?;
let script = if deep {
locate_deep_script(kind, value, Some(property))
} else {
locate_all_script(kind, value, Some(property))
};
let mut action = declare_action("browser-collect", &format!("cdp:{session_id}"));
action.executing();
let mut items: Vec<Value> = Vec::new();
let mut seen: std::collections::HashSet<u64> = std::collections::HashSet::new();
let mut new_per_round: Vec<u64> = Vec::new();
let mut empty_streak = 0u32;
let mut complete = false;
let mut truncation_reason: Option<&str> = None;
let mut last_scroll: Option<Value> = None;
for round in 1..=max_rounds {
actl_core::stop::check_current()?;
actl_core::watchdog::reset();
let located = eval_json(&conn, &cdp_session, &script, None, timeout)?;
let elements = located
.get("elements")
.and_then(Value::as_array)
.ok_or_else(|| CtlError::internal("enumeration script returned no elements"))?;
let mut fresh = 0u64;
for element in elements {
if items.len() >= max_items as usize {
break;
}
let digest = element_digest(element);
if seen.insert(digest) {
fresh += 1;
items.push(collect_item(element, property));
}
}
new_per_round.push(fresh);
if items.len() >= max_items as usize {
truncation_reason = Some("max_items");
break;
}
if fresh == 0 {
empty_streak += 1;
} else {
empty_streak = 0;
}
let at_bottom = match eval_json(&conn, &cdp_session, &scroll_page_script(), None, timeout) {
Ok(scrolled) => {
last_scroll = Some(scrolled.clone());
scrolled
.get("at_bottom")
.and_then(Value::as_bool)
.unwrap_or(false)
}
Err(_) => {
truncation_reason = Some("scroll_failed");
break;
}
};
if empty_streak >= 2 && round >= 2 && at_bottom {
complete = true;
break;
}
std::thread::sleep(Duration::from_millis(interval));
}
let mut payload = json!({
"action": "browser-collect",
"session_id": session_id,
"selector_kind": kind.as_str(),
"total": items.len(),
"rounds": new_per_round.len(),
"new_items_per_round": new_per_round,
"complete": complete,
});
if property != "text" {
payload["property"] = Value::String(property.to_string());
}
if deep {
payload["deep"] = Value::Bool(true);
}
if complete {
payload["converged_by"] = Value::String("empty-streak".into());
}
if let Some(reason) = truncation_reason {
payload["truncation_reason"] = Value::String(reason.into());
}
if let Some(scroll) = last_scroll {
payload["last_scroll"] = scroll;
}
payload["items"] = Value::Array(items);
Ok(payload)
}
fn element_digest(element: &Value) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
element
.get("tag")
.and_then(Value::as_str)
.unwrap_or("")
.hash(&mut hasher);
element
.get("id")
.and_then(Value::as_str)
.unwrap_or("")
.hash(&mut hasher);
element
.get("text")
.and_then(Value::as_str)
.unwrap_or("")
.hash(&mut hasher);
element.get("property").hash(&mut hasher);
hasher.finish()
}
fn collect_item(element: &Value, property: &str) -> Value {
let (text, truncated) = truncate_text(
element
.get("text")
.and_then(Value::as_str)
.unwrap_or_default(),
);
let mut item = json!({ "text": text, "truncated": truncated });
if let Some(tag) = element.get("tag").and_then(Value::as_str) {
item["tag"] = Value::String(tag.to_string());
}
if let Some(id) = element.get("id").and_then(Value::as_str) {
item["id"] = Value::String(id.to_string());
}
if property != "text" {
item["property"] = Value::String(property.to_string());
item["value"] = element.get("property").cloned().unwrap_or(Value::Null);
}
item
}
#[cfg(test)]
mod collect_tests {
use super::*;
use actl_core::ErrorCode;
#[test]
fn digest_is_content_stable_and_order_free() {
let a = json!({"tag":"LI","id":null,"text":"item-1","property":null});
let b = json!({"tag":"LI","id":null,"text":"item-1","property":null});
let c = json!({"tag":"LI","id":null,"text":"item-2","property":null});
assert_eq!(element_digest(&a), element_digest(&b));
assert_ne!(element_digest(&a), element_digest(&c));
}
#[test]
fn budget_validation_rejects_out_of_range() {
let spec_of = |interval: Option<u64>, rounds: Option<u64>| CollectSpec {
session_id: "s",
kind: SelectorKind::Css,
value: ".x",
property: None,
deep: false,
interval_ms: interval,
max_rounds: rounds,
max_items: None,
timeout_ms: None,
};
let err = collect(&Paths::default(), spec_of(Some(50), None)).unwrap_err();
assert_eq!(err.code, ErrorCode::Protocol);
assert!(err.message.contains("interval-ms"));
let err = collect(&Paths::default(), spec_of(Some(500), Some(999))).unwrap_err();
assert_eq!(err.code, ErrorCode::Protocol);
assert!(err.message.contains("max-rounds"));
}
}