use crate::dom::views::{EnhancedDOMTreeNode, PageIntent, SemanticRole};
#[derive(Debug, Clone)]
pub struct PerceptionConfig {
pub enable_role_inference: bool,
pub enable_page_intent: bool,
pub enable_affordances: bool,
pub confidence_threshold: f32,
}
impl Default for PerceptionConfig {
fn default() -> Self {
Self {
enable_role_inference: true,
enable_page_intent: true,
enable_affordances: true,
confidence_threshold: 0.5,
}
}
}
#[derive(Debug, Clone)]
pub struct PerceptionEngine {
config: PerceptionConfig,
}
impl PerceptionEngine {
pub fn new() -> Self {
Self {
config: PerceptionConfig::default(),
}
}
pub fn with_config(config: PerceptionConfig) -> Self {
Self { config }
}
pub fn perceive(&self, root: &mut EnhancedDOMTreeNode) -> (Option<PageIntent>, f32) {
if self.config.enable_role_inference {
self.infer_node_roles(root);
}
if self.config.enable_page_intent {
let (intent, confidence) = self.infer_page_intent(root);
(Some(intent), confidence)
} else {
(None, 0.0)
}
}
fn infer_node_roles(&self, node: &mut EnhancedDOMTreeNode) {
let (role, confidence, affordance) = self.classify_node(node);
node.semantic_role = role;
node.semantic_confidence = confidence;
if let Some(aff) = &affordance {
node.attributes.insert("__affordance".to_string(), aff.clone());
}
if let Some(ref mut children) = node.children_nodes {
for child in children {
self.infer_node_roles(child);
}
}
if let Some(ref mut shadow_roots) = node.shadow_roots {
for shadow in shadow_roots {
self.infer_node_roles(shadow);
}
}
if let Some(ref mut content_doc) = node.content_document {
self.infer_node_roles(content_doc);
}
}
fn classify_node(
&self,
node: &EnhancedDOMTreeNode,
) -> (Option<SemanticRole>, f32, Option<String>) {
if node.node_type != crate::dom::views::NodeType::ElementNode {
return (None, 0.0, None);
}
let tag = node.tag_name();
let attrs = &node.attributes;
let text = node.node_value.trim().to_lowercase();
let role_attr = attrs.get("role").map(|s| s.as_str());
if tag == "form" {
let has_search_input = attrs.get("action").map(|a| a.contains("search")).unwrap_or(false)
|| attrs.get("class").map(|c| c.to_lowercase().contains("search")).unwrap_or(false)
|| attrs.get("id").map(|i| i.to_lowercase().contains("search")).unwrap_or(false);
let has_search_button = node.children_nodes.as_ref().map(|children| {
children.iter().any(|c| {
let ctag = c.tag_name();
let ctext = c.node_value.to_lowercase();
(ctag == "button" || ctag == "input")
&& (ctext.contains("search")
|| c.attributes.get("value").map(|v| v.to_lowercase().contains("search")).unwrap_or(false)
|| c.attributes.get("aria-label").map(|v| v.to_lowercase().contains("search")).unwrap_or(false))
})
}).unwrap_or(false);
if has_search_input || has_search_button {
return (
Some(SemanticRole::SearchForm),
0.85,
Some("Submit a search query".to_string()),
);
}
}
if tag == "form" || tag == "div" {
let has_password = node.children_nodes.as_ref().map(|children| {
children.iter().any(|c| {
c.attributes.get("type").map(|t| t == "password").unwrap_or(false)
})
}).unwrap_or(false);
let has_login_text = text.contains("login")
|| text.contains("sign in")
|| text.contains("log in")
|| attrs.get("class").map(|c| c.to_lowercase().contains("login")).unwrap_or(false)
|| attrs.get("id").map(|i| i.to_lowercase().contains("login")).unwrap_or(false);
if has_password || has_login_text {
return (
Some(SemanticRole::LoginForm),
0.9,
Some("Authenticate with username and password".to_string()),
);
}
}
if tag == "form" || tag == "div" {
let has_register_text = text.contains("register")
|| text.contains("sign up")
|| text.contains("create account")
|| attrs.get("class").map(|c| c.to_lowercase().contains("register")).unwrap_or(false)
|| attrs.get("id").map(|i| i.to_lowercase().contains("register")).unwrap_or(false);
if has_register_text {
return (
Some(SemanticRole::RegistrationForm),
0.8,
Some("Create a new account".to_string()),
);
}
}
if tag == "nav"
|| role_attr == Some("navigation")
|| attrs.get("class").map(|c| c.to_lowercase().contains("nav")).unwrap_or(false)
|| attrs.get("id").map(|i| i.to_lowercase().contains("nav")).unwrap_or(false)
{
return (
Some(SemanticRole::Navigation),
0.85,
Some("Navigate to another page".to_string()),
);
}
if (text.contains("page")
|| text.contains("next")
|| text.contains("previous")
|| text.contains("«")
|| text.contains("»")
|| attrs.get("class").map(|c| {
let c = c.to_lowercase();
c.contains("paginat") || c.contains("pager")
}).unwrap_or(false)
|| attrs.get("aria-label").map(|l| l.to_lowercase().contains("page")).unwrap_or(false))
&& (tag == "a" || tag == "button" || tag == "li" || tag == "span") {
return (
Some(SemanticRole::Pagination),
0.7,
Some("Navigate to another page of results".to_string()),
);
}
if (attrs.get("class").map(|c| {
let c = c.to_lowercase();
c.contains("product") || c.contains("item") || c.contains("card")
}).unwrap_or(false)
|| attrs.get("data-product-id").is_some()
|| attrs.get("data-sku").is_some())
&& (tag == "div" || tag == "article" || tag == "li") {
return (
Some(SemanticRole::ProductCard),
0.65,
Some("View product details".to_string()),
);
}
if tag == "article"
|| role_attr == Some("article")
|| attrs.get("class").map(|c| c.to_lowercase().contains("article")).unwrap_or(false)
|| attrs.get("itemtype").map(|t| t.contains("Article")).unwrap_or(false)
{
return (
Some(SemanticRole::Article),
0.85,
Some("Read article content".to_string()),
);
}
if attrs.get("class").map(|c| {
let c = c.to_lowercase();
c.contains("filter") || c.contains("sort") || c.contains("facet")
}).unwrap_or(false)
|| attrs.get("id").map(|i| {
let i = i.to_lowercase();
i.contains("filter") || i.contains("sort")
}).unwrap_or(false)
{
return (
Some(SemanticRole::FilterPanel),
0.7,
Some("Filter or sort results".to_string()),
);
}
if text.contains("captcha")
|| text.contains("i'm not a robot")
|| text.contains("recaptcha")
|| attrs.get("class").map(|c| c.to_lowercase().contains("captcha")).unwrap_or(false)
|| attrs.get("id").map(|i| i.to_lowercase().contains("captcha")).unwrap_or(false)
{
return (
Some(SemanticRole::Captcha),
0.95,
Some("Complete verification challenge".to_string()),
);
}
if (text.contains("cookie")
|| text.contains("privacy")
|| attrs.get("class").map(|c| {
let c = c.to_lowercase();
c.contains("cookie") || c.contains("consent") || c.contains("gdpr")
}).unwrap_or(false)
|| attrs.get("id").map(|i| {
let i = i.to_lowercase();
i.contains("cookie") || i.contains("consent")
}).unwrap_or(false))
&& (text.contains("accept") || text.contains("agree") || text.contains("consent")) {
return (
Some(SemanticRole::CookieConsent),
0.8,
Some("Accept or manage cookie consent".to_string()),
);
}
if tag == "iframe"
&& (attrs.get("src").map(|s| {
let s = s.to_lowercase();
s.contains("ad") || s.contains("doubleclick") || s.contains("googleads")
}).unwrap_or(false)
|| attrs.get("id").map(|i| i.to_lowercase().contains("ad")).unwrap_or(false)
|| attrs.get("class").map(|c| c.to_lowercase().contains("ad")).unwrap_or(false))
{
return (
Some(SemanticRole::Advertisement),
0.75,
None,
);
}
if tag == "button"
|| (tag == "input" && attrs.get("type").map(|t| t == "submit" || t == "button").unwrap_or(false))
|| role_attr == Some("button")
{
let btn_text = text.clone();
let val = attrs.get("value").map(|v| v.to_lowercase()).unwrap_or_default();
if btn_text.contains("submit")
|| btn_text.contains("save")
|| btn_text.contains("continue")
|| val.contains("submit")
|| val.contains("save")
{
return (
Some(SemanticRole::SubmitButton),
0.8,
Some("Submit the form".to_string()),
);
}
if btn_text.contains("search")
|| btn_text.contains("find")
|| val.contains("search")
{
return (
Some(SemanticRole::PrimaryAction),
0.75,
Some("Execute search".to_string()),
);
}
if btn_text.contains("buy")
|| btn_text.contains("purchase")
|| btn_text.contains("add to cart")
|| btn_text.contains("checkout")
{
return (
Some(SemanticRole::PrimaryAction),
0.8,
Some("Complete purchase".to_string()),
);
}
return (
Some(SemanticRole::SecondaryAction),
0.5,
Some("Perform an action".to_string()),
);
}
if tag == "input" || tag == "textarea" {
let input_type = attrs.get("type").map(|t| t.as_str()).unwrap_or("text");
match input_type {
"password" => {
return (
Some(SemanticRole::TextInput),
0.9,
Some("Enter password".to_string()),
)
}
"email" => {
return (
Some(SemanticRole::TextInput),
0.85,
Some("Enter email address".to_string()),
)
}
"search" => {
return (
Some(SemanticRole::SearchForm),
0.9,
Some("Enter search query".to_string()),
)
}
"file" => {
return (
Some(SemanticRole::FileUpload),
0.9,
Some("Upload a file".to_string()),
)
}
"date" | "datetime-local" | "time" => {
return (
Some(SemanticRole::DatePicker),
0.85,
Some("Select a date/time".to_string()),
)
}
"checkbox" | "radio" => {
return (
Some(SemanticRole::ToggleGroup),
0.8,
Some("Select an option".to_string()),
)
}
_ => {
let placeholder = attrs.get("placeholder").map(|p| p.to_lowercase()).unwrap_or_default();
let name = attrs.get("name").map(|n| n.to_lowercase()).unwrap_or_default();
let aria = attrs.get("aria-label").map(|a| a.to_lowercase()).unwrap_or_default();
let purpose = if placeholder.contains("search") || name.contains("search") || aria.contains("search") {
Some("Enter search query".to_string())
} else if placeholder.contains("email") || name.contains("email") || aria.contains("email") {
Some("Enter email address".to_string())
} else if placeholder.contains("password") || name.contains("password") || aria.contains("password") {
Some("Enter password".to_string())
} else if placeholder.contains("username") || name.contains("username") || name.contains("user") || aria.contains("username") {
Some("Enter username".to_string())
} else {
Some("Enter text".to_string())
};
return (
Some(SemanticRole::TextInput),
0.6,
purpose,
);
}
}
}
if tag == "select" {
return (
Some(SemanticRole::Dropdown),
0.85,
Some("Select from dropdown".to_string()),
);
}
if tag == "header" || role_attr == Some("banner") {
return (Some(SemanticRole::Header), 0.9, None);
}
if tag == "footer" || role_attr == Some("contentinfo") {
return (Some(SemanticRole::Footer), 0.9, None);
}
if tag == "aside" || role_attr == Some("complementary") {
return (Some(SemanticRole::Sidebar), 0.85, None);
}
if tag == "main" || role_attr == Some("main") {
return (Some(SemanticRole::MainContent), 0.9, None);
}
(None, 0.0, None)
}
fn infer_page_intent(&self, root: &EnhancedDOMTreeNode) -> (PageIntent, f32) {
let mut scores: std::collections::HashMap<PageIntent, f32> = std::collections::HashMap::new();
self.collect_intent_signals(root, &mut scores);
let mut best_intent = PageIntent::Unknown;
let mut best_score = 0.0f32;
for (intent, score) in scores {
if score > best_score {
best_score = score;
best_intent = intent;
}
}
let confidence = if best_score > 0.0 {
(best_score / (best_score + 1.0)).min(0.99)
} else {
0.0
};
(best_intent, confidence)
}
fn collect_intent_signals(
&self,
node: &EnhancedDOMTreeNode,
scores: &mut std::collections::HashMap<PageIntent, f32>,
) {
if let Some(ref role) = node.semantic_role {
match role {
SemanticRole::LoginForm => {
*scores.entry(PageIntent::Login).or_insert(0.0) += 2.0;
}
SemanticRole::SearchForm => {
*scores.entry(PageIntent::SearchResults).or_insert(0.0) += 0.5;
*scores.entry(PageIntent::Landing).or_insert(0.0) += 0.3;
}
SemanticRole::ProductCard => {
*scores.entry(PageIntent::ProductListing).or_insert(0.0) += 1.0;
}
SemanticRole::Article => {
*scores.entry(PageIntent::Article).or_insert(0.0) += 1.5;
}
SemanticRole::Captcha => {
*scores.entry(PageIntent::Captcha).or_insert(0.0) += 3.0;
}
SemanticRole::CookieConsent => {
*scores.entry(PageIntent::CookieConsent).or_insert(0.0) += 2.0;
}
SemanticRole::SubmitButton => {
let text = node.node_value.to_lowercase();
if text.contains("checkout") || text.contains("pay") || text.contains("purchase") {
*scores.entry(PageIntent::Checkout).or_insert(0.0) += 1.5;
}
}
_ => {}
}
}
if let Some(title) = node.attributes.get("__page_title") {
let title = title.to_lowercase();
if title.contains("login") || title.contains("sign in") {
*scores.entry(PageIntent::Login).or_insert(0.0) += 1.0;
}
if title.contains("search results") || title.contains("results for") {
*scores.entry(PageIntent::SearchResults).or_insert(0.0) += 1.5;
}
if title.contains("product") && title.contains("detail") {
*scores.entry(PageIntent::ProductDetail).or_insert(0.0) += 1.0;
}
if title.contains("404") || title.contains("not found") || title.contains("error") {
*scores.entry(PageIntent::ErrorPage).or_insert(0.0) += 2.0;
}
}
if let Some(ref children) = node.children_nodes {
for child in children {
self.collect_intent_signals(child, scores);
}
}
if let Some(ref shadow_roots) = node.shadow_roots {
for shadow in shadow_roots {
self.collect_intent_signals(shadow, scores);
}
}
if let Some(ref content_doc) = node.content_document {
self.collect_intent_signals(content_doc, scores);
}
}
}
impl Default for PerceptionEngine {
fn default() -> Self {
Self::new()
}
}