browser-use e graph
This commit is contained in:
parent
cb9814b39a
commit
bb18bc6e74
|
|
@ -0,0 +1,76 @@
|
|||
#pip install browser-use langchain-openai langchain-ollama playwright
|
||||
#playwright install
|
||||
|
||||
"""import asyncio
|
||||
from browser_use import Agent
|
||||
from langchain_ollama import ChatOllama
|
||||
|
||||
async def run_local_agent():
|
||||
# Inizializziamo il modello locale tramite Ollama
|
||||
# Impostiamo num_ctx a 32000 perché l'albero visivo/DOM consuma molti token
|
||||
llm = ChatOllama(
|
||||
|
||||
model="gemma4:e2b",
|
||||
num_ctx=32000,
|
||||
temperature=0.0
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
task="Naviga su wikipedia, cerca la pagina dell'intelligenza artificiale e dimmi l'anno di nascita del termine.",#"Vai su google.it, cerca 'meteo Roma' e leggi la temperatura attuale.",
|
||||
llm=llm
|
||||
)
|
||||
|
||||
result = await agent.run(max_steps=20)
|
||||
print("\n[Risultato Finale Ollama]:\n", result)
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_local_agent())"""
|
||||
|
||||
import asyncio
|
||||
from browser_use import Agent, ChatOpenAI
|
||||
from langchain_ollama import ChatOllama
|
||||
|
||||
async def run_remote_ollama_agent():
|
||||
# Poiché l'istanza Ollama è protetta da un Bearer Token, usiamo la classe ChatOpenAI
|
||||
# che è nativamente progettata per gestire base_url custom ed header di autenticazione.
|
||||
llm = ChatOpenAI(
|
||||
model="ministral-3:3b",#"gemma3:4b",#"gemma4:e4b",#"ministral-3:3b",#"gemma4:e2b", # Inserisci il nome esatto del modello presente sul tuo server remoto
|
||||
base_url="https://vgpu.hiis.cloud.isti.cnr.it/v1", # L'endpoint v1 del tuo proxy/gateway
|
||||
api_key="", # Il tuo token di autenticazione va inserito qui
|
||||
|
||||
# Parametri critici per far funzionare i modelli Open Source locali con browser-use:
|
||||
temperature=0.0,
|
||||
|
||||
|
||||
|
||||
# Sovrascriviamo il comportamento di default dicendo al client OpenAI
|
||||
# di effettuare la chiamata POST puntando alla rotta esatta richiesta dal tuo server
|
||||
default_headers={
|
||||
"Authorization": "Bearer your_api_key"
|
||||
},
|
||||
|
||||
)
|
||||
|
||||
# FORZATURA DIRETTA: Iniettiamo num_ctx direttamente nei model_kwargs dell'oggetto appena creato.
|
||||
# Questo bypassa i controlli del costruttore __init__() e scrive direttamente nella configurazione di rete.
|
||||
llm.model_kwargs = { #SEMBRA CHE NON SI APPLICHI
|
||||
"extra_body": {
|
||||
"num_ctx": 32000
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Inizializzazione dell'agente
|
||||
agent = Agent(
|
||||
#task="Naviga su wikipedia, cerca la pagina dell'intelligenza artificiale e dimmi l'anno di nascita del termine.",#"Vai su google.it, cerca 'meteo Roma' e leggi la temperatura attuale.",
|
||||
task="Open the URL https://github.com/rasbt and check if the page offers an alternative function to the interactive map of content in the year (contribution heatmap data visualization). 'Equivalent' is to be interpreted according to the WCAG Success Criterion 2.5.8 Target Size (Minimum): 'The function can be achieved through a different control on the same page that meets this criterion' - Answer YES or NO and justify your answer.",
|
||||
|
||||
llm=llm
|
||||
)
|
||||
|
||||
# Esecuzione del workflow
|
||||
result = await agent.run(max_steps=20)
|
||||
print("\n[Risultato Finale]:\n", result)
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_remote_ollama_agent())
|
||||
|
|
@ -0,0 +1,318 @@
|
|||
|
||||
#pip install browser-use langchain-openai langchain-ollama playwright
|
||||
#playwright install
|
||||
|
||||
## comandi da CLI
|
||||
"""
|
||||
browser-use open https://example.com # Navigate to URL
|
||||
browser-use state # See clickable elements
|
||||
browser-use click 5 # Click element by index
|
||||
browser-use type "Hello" # Type text
|
||||
browser-use screenshot page.png # Take screenshot
|
||||
browser-use close # Close browser
|
||||
"""
|
||||
|
||||
|
||||
|
||||
import asyncio
|
||||
import aiofiles
|
||||
import os
|
||||
import logging
|
||||
import sys
|
||||
import re
|
||||
from datetime import datetime
|
||||
from browser_use import Agent, ChatAzureOpenAI, Browser
|
||||
import base64, os, json
|
||||
|
||||
|
||||
from browser_use.browser import BrowserProfile, BrowserSession
|
||||
from browser_use.browser.profile import ViewportSize
|
||||
|
||||
# highlight_elements goes on BrowserProfile, not Browser
|
||||
browser_profile = BrowserProfile(
|
||||
headless=False, #Visible window opens on screen
|
||||
highlight_elements=True, # draws colored bounding boxes on interactive elements
|
||||
# dom_highlight_elements=True, # alternative: DOM-based highlighting (takes priority if both set)
|
||||
|
||||
window_size=ViewportSize(width=900, height=1100), # bigger window to compensate for OS chrome
|
||||
viewport=ViewportSize(width=768, height=1024), # ✅ forces exact page content size
|
||||
)
|
||||
|
||||
browser_session = BrowserSession(browser_profile=browser_profile)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ─── LOGGING SETUP ────────────────────────────────────────────────────────────
|
||||
|
||||
os.environ["BROWSER_USE_LOGGING_LEVEL"] = "debug"
|
||||
|
||||
|
||||
# ── BBox tracker — captures the count from the log message ──────────────────
|
||||
class BBoxTracker:
|
||||
"""Intercepts the BBox filtering log line and stores the latest count."""
|
||||
last_excluded: int = 0
|
||||
history: list[dict] = []
|
||||
|
||||
def record(self, step: int, count: int):
|
||||
self.last_excluded = count
|
||||
self.history.append({"step": step, "excluded_nodes": count})
|
||||
|
||||
bbox_tracker = BBoxTracker()
|
||||
_BBOX_RE = re.compile(r"BBox filtering excluded (\d+) nodes", re.IGNORECASE)
|
||||
|
||||
class BBoxInterceptHandler(logging.Handler):
|
||||
"""Silently watches every log record for the BBox line."""
|
||||
def emit(self, record: logging.LogRecord):
|
||||
msg = record.getMessage()
|
||||
m = _BBOX_RE.search(msg)
|
||||
if m:
|
||||
bbox_tracker.last_excluded = int(m.group(1))
|
||||
|
||||
# Colored formatter
|
||||
class ColoredFormatter(logging.Formatter):
|
||||
COLORS = {
|
||||
logging.DEBUG: "\033[36m",
|
||||
logging.INFO: "\033[32m",
|
||||
logging.WARNING: "\033[33m",
|
||||
logging.ERROR: "\033[31m",
|
||||
logging.CRITICAL: "\033[35m",
|
||||
}
|
||||
RESET = "\033[0m"
|
||||
BOLD = "\033[1m"
|
||||
|
||||
def format(self, record):
|
||||
color = self.COLORS.get(record.levelno, self.RESET)
|
||||
record.levelname = f"{color}{self.BOLD}{record.levelname:<8}{self.RESET}"
|
||||
record.name = f"\033[90m{record.name}{self.RESET}"
|
||||
return super().format(record)
|
||||
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(logging.DEBUG)
|
||||
console_handler.setFormatter(ColoredFormatter(
|
||||
fmt="%(asctime)s │ %(levelname)s │ %(name)s\n └─ %(message)s\n",
|
||||
datefmt="%H:%M:%S",
|
||||
))
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(logging.DEBUG)
|
||||
root_logger.handlers.clear()
|
||||
root_logger.addHandler(console_handler)
|
||||
root_logger.addHandler(BBoxInterceptHandler()) # ← silent tracker
|
||||
|
||||
for noisy in ("httpx", "httpcore", "playwright", "urllib3", "asyncio"):
|
||||
logging.getLogger(noisy).setLevel(logging.WARNING)
|
||||
|
||||
logger = logging.getLogger("agent_runner")
|
||||
|
||||
# ─── STEP CALLBACKS ───────────────────────────────────────────────────────────
|
||||
|
||||
step_count = 0
|
||||
step_start_time: datetime | None = None
|
||||
|
||||
|
||||
os.makedirs("./screenshots_on_step", exist_ok=True)
|
||||
|
||||
import asyncio
|
||||
import aiofiles
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
async def on_step(state, model_output, step_num):
|
||||
if not state.screenshot:
|
||||
return
|
||||
|
||||
img = Image.open(io.BytesIO(base64.b64decode(state.screenshot)))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
selector_map = state.dom_state.selector_map
|
||||
colors = ["#FF0000", "#FF6600", "#0066FF", "#009900", "#9900CC"]
|
||||
|
||||
for idx, node in selector_map.items():
|
||||
# ✅ node IS the EnhancedDOMTreeNode directly — no .original_node
|
||||
rect = node.absolute_position
|
||||
if rect is None:
|
||||
continue
|
||||
|
||||
x, y, w, h = rect.x, rect.y, rect.width, rect.height
|
||||
if w <= 0 or h <= 0:
|
||||
continue
|
||||
|
||||
color = colors[idx % len(colors)]
|
||||
draw.rectangle([x, y, x + w, y + h], outline=color, width=2)
|
||||
|
||||
label = str(idx)
|
||||
draw.rectangle([x, y - 14, x + len(label) * 8 + 4, y], fill=color)
|
||||
draw.text((x + 2, y - 13), label, fill="white")
|
||||
|
||||
path = f"./screenshots_on_step/step_{step_num:03d}.png"
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
async with aiofiles.open(path, "wb") as f:
|
||||
await f.write(buf.getvalue())
|
||||
|
||||
print(f"[step {step_num}] annotated: {path} ({len(selector_map)} elements) | {state.url}")
|
||||
|
||||
async def on_step_start(agent):
|
||||
global step_count, step_start_time
|
||||
step_count += 1
|
||||
step_start_time = datetime.now()
|
||||
bbox_tracker.last_excluded = 0 # reset for this step
|
||||
print(f"\n\033[1;34m{'━'*60}\033[0m")
|
||||
print(f"\033[1;34m 🚀 STEP {step_count} — {step_start_time.strftime('%H:%M:%S')}\033[0m")
|
||||
print(f"\033[1;34m{'━'*60}\033[0m")
|
||||
|
||||
async def on_step_end(agent):
|
||||
global step_start_time
|
||||
elapsed = (datetime.now() - step_start_time).total_seconds() if step_start_time else 0
|
||||
|
||||
# record bbox stats for this step
|
||||
bbox_tracker.record(step_count, bbox_tracker.last_excluded)
|
||||
|
||||
history = agent.history.history
|
||||
last = history[-1] if history else None
|
||||
|
||||
url = "N/A"
|
||||
if last and last.state and hasattr(last.state, "url"):
|
||||
url = last.state.url
|
||||
|
||||
actions = "N/A"
|
||||
if last and last.model_output and hasattr(last.model_output, "action"):
|
||||
acts = last.model_output.action
|
||||
if isinstance(acts, list):
|
||||
actions = ", ".join(
|
||||
type(a).__name__ if not isinstance(a, dict) else list(a.keys())[0]
|
||||
for a in acts
|
||||
)
|
||||
else:
|
||||
actions = str(acts)
|
||||
|
||||
result_text = "N/A"
|
||||
if last and last.result:
|
||||
results = last.result if isinstance(last.result, list) else [last.result]
|
||||
parts = []
|
||||
for r in results:
|
||||
if hasattr(r, "extracted_content") and r.extracted_content:
|
||||
parts.append(f"📄 {str(r.extracted_content)[:120]}")
|
||||
elif hasattr(r, "error") and r.error:
|
||||
parts.append(f"❌ {str(r.error)[:120]}")
|
||||
result_text = " | ".join(parts) if parts else "✔ done"
|
||||
|
||||
# BBox colour: green if low, yellow if medium, red if high
|
||||
bbox_n = bbox_tracker.last_excluded
|
||||
if bbox_n < 20:
|
||||
bbox_color = "\033[32m"
|
||||
elif bbox_n < 60:
|
||||
bbox_color = "\033[33m"
|
||||
else:
|
||||
bbox_color = "\033[31m"
|
||||
|
||||
print(f"\033[32m ✅ STEP {step_count} COMPLETE ({elapsed:.2f}s)\033[0m")
|
||||
print(f"\033[90m URL :\033[0m {url}")
|
||||
print(f"\033[90m Actions:\033[0m {actions}")
|
||||
print(f"\033[90m Result :\033[0m {result_text}")
|
||||
print(f"\033[90m BBox :\033[0m {bbox_color}{bbox_n} nodes pruned (redundant children of links/buttons)\033[0m")
|
||||
print(f"\033[1;34m{'━'*60}\033[0m\n")
|
||||
|
||||
|
||||
# ✅ highlight_elements draws bounding boxes from DOM on the live browser
|
||||
#browser = Browser(
|
||||
# headless=False,
|
||||
# highlight_elements=True,
|
||||
#)
|
||||
|
||||
# ─── MAIN ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def run_azure_agent():
|
||||
logger.info("Initializing Azure OpenAI LLM...")
|
||||
|
||||
llm = ChatAzureOpenAI(
|
||||
model="gpt-4o",
|
||||
azure_endpoint="https://hiis-accessibility-fonderia.cognitiveservices.azure.com/",
|
||||
azure_deployment="gpt-4o",
|
||||
api_version="2025-01-01-preview",
|
||||
api_key=""
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
|
||||
|
||||
#task=(
|
||||
# "Open the URL https://github.com/rasbt and check if the page offers "
|
||||
# "an alternative function to the interactive map of content in the year "
|
||||
# "(contribution heatmap data visualization). 'Equivalent' is to be interpreted "
|
||||
# "according to the WCAG Success Criterion 2.5.8 Target Size (Minimum): "
|
||||
# "'The function can be achieved through a different control on the same page "
|
||||
# "that meets this criterion' - Answer YES or NO and justify your answer."
|
||||
#)
|
||||
|
||||
task=(
|
||||
"Open the URL https://github.com/rasbt and check if the page offers "
|
||||
"an 'Equivalent' function to the ”Contribution graph cells”: The dense grid of small squares in "
|
||||
"the '1,535 contributions in the last year' heatmap calendar."
|
||||
"'Equivalent' is to be interpreted according to the WCAG Success Criterion 2.5.8 Target Size (Minimum): "
|
||||
"'The function can be achieved through a different control on the same page "
|
||||
"that meets this criterion'. - Answer YES or NO and justify your answer."
|
||||
),
|
||||
llm=llm,
|
||||
#on_step_start=on_step_start,
|
||||
#on_step_end=on_step_end,
|
||||
save_conversation_path="./logs/session.json",# all screenshots embedded here
|
||||
save_conversation_path_encoding="utf-8", # default
|
||||
# Optional: keep full history (don't prune steps)
|
||||
max_history_items=None, # None = keep ALL steps in memory
|
||||
use_vision=True,
|
||||
browser_session=browser_session, #browser # pass session, not Browser()
|
||||
vision_detail_level="high", # 'low', 'high', or 'auto'
|
||||
generate_gif="./logs/session.gif",
|
||||
register_new_step_callback=on_step, # real-time callback
|
||||
)
|
||||
|
||||
print(f"\n\033[1;35m{'═'*60}\033[0m")
|
||||
print(f"\033[1;35m 🤖 BROWSER-USE AGENT STARTING\033[0m")
|
||||
print(f"\033[1;35m{'═'*60}\033[0m\n")
|
||||
|
||||
result = await agent.run(max_steps=20)
|
||||
#await browser.close()
|
||||
|
||||
"""
|
||||
#### non va ###
|
||||
import os
|
||||
import base64
|
||||
import json
|
||||
os.makedirs("screenshots_from_results", exist_ok=True)
|
||||
for i, shot in enumerate(result.screenshots()):
|
||||
# screenshots() returns base64 strings
|
||||
img_bytes = base64.b64decode(shot)
|
||||
path = f"screenshots_from_results/step_{i+1:02d}.png"
|
||||
with open(path, "wb") as f:
|
||||
f.write(img_bytes)
|
||||
print(f"Saved {path}")
|
||||
"""
|
||||
|
||||
|
||||
|
||||
# ── BBox summary across all steps ──────────────────────────────────────
|
||||
print(f"\n\033[1;33m{'═'*60}\033[0m")
|
||||
print(f"\033[1;33m 📦 BBOX FILTERING SUMMARY\033[0m")
|
||||
print(f"\033[1;33m{'═'*60}\033[0m")
|
||||
total = sum(s["excluded_nodes"] for s in bbox_tracker.history)
|
||||
for s in bbox_tracker.history:
|
||||
bar = "█" * min(s["excluded_nodes"] // 5, 30)
|
||||
print(f" Step {s['step']:>2} │ {s['excluded_nodes']:>4} nodes {bar}")
|
||||
print(f" {'─'*40}")
|
||||
print(f" Total pruned across all steps: {total} nodes")
|
||||
print(f"\033[1;33m{'═'*60}\033[0m\n")
|
||||
|
||||
print(f"\n\033[1;35m{'═'*60}\033[0m")
|
||||
print(f"\033[1;35m 🏁 FINAL RESULT\033[0m")
|
||||
print(f"\033[1;35m{'═'*60}\033[0m")
|
||||
print(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_azure_agent())
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
|
||||
#pip install browser-use langchain-openai langchain-ollama playwright
|
||||
#playwright install
|
||||
|
||||
## comandi da CLI
|
||||
"""
|
||||
browser-use open https://example.com # Navigate to URL
|
||||
browser-use state # See clickable elements
|
||||
browser-use click 5 # Click element by index
|
||||
browser-use type "Hello" # Type text
|
||||
browser-use screenshot page.png # Take screenshot
|
||||
browser-use close # Close browser
|
||||
"""
|
||||
|
||||
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
from browser_use import Agent
|
||||
from langchain_openai import AzureChatOpenAI
|
||||
|
||||
async def run_azure_agent():
|
||||
# Inizializziamo il modello Azure OpenAI tramite LangChain
|
||||
# Sostituisci "il-tuo-deployment-name" con il nome esatto del deployment su Azure (es. gpt-4o)
|
||||
llm = AzureChatOpenAI(
|
||||
azure_deployment="gpt-4o",
|
||||
openai_api_version=os.getenv("AZURE_OPENAI_API_VERSION", "2025-01-01-preview"),
|
||||
azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT","https://hiis-accessibility-fonderia.cognitiveservices.azure.com"),
|
||||
api_key=os.getenv("AZURE_OPENAI_API_KEY","4lwGUwrx7jsqdxESGBpN9wYYyLNsxzC2s8ZLQlZPCQUayDWuDo3NJQQJ99BKACfhMk5XJ3w3AAAAACOGs2uw")
|
||||
)
|
||||
|
||||
# Definiamo l'agente con il modello Azure
|
||||
agent = Agent(
|
||||
task="Naviga su wikipedia, cerca la pagina dell'intelligenza artificiale e dimmi l'anno di nascita del termine.",
|
||||
llm=llm
|
||||
)
|
||||
|
||||
# Eseguiamo l'agente
|
||||
result = await agent.run()
|
||||
print("\n[Risultato Finale Azure OpenAI]:\n", result)
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_azure_agent())
|
||||
"""
|
||||
|
||||
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
# IMPORTANTE: Importiamo ChatAzureOpenAI direttamente da browser_use!
|
||||
from browser_use import Agent, ChatAzureOpenAI
|
||||
|
||||
async def run_azure_agent():
|
||||
# browser-use gestisce internamente la classe per evitare l'errore di Pydantic
|
||||
llm = ChatAzureOpenAI(
|
||||
model="gpt-4o",
|
||||
azure_endpoint="https://hiis-accessibility-fonderia.cognitiveservices.azure.com/",
|
||||
azure_deployment="gpt-4o",
|
||||
api_version="2025-01-01-preview",
|
||||
api_key="" # Sostituisci con la tua chiave Azure reale
|
||||
)
|
||||
|
||||
# Configurazione dell'agente
|
||||
agent = Agent(
|
||||
#task="apri l'url https://github.com/rasbt e verifica se la pagina presenta una funzione alternativa alla mappa interattiva dei contenuti nell'anno (contribution heatmap data visualization). 'Equivalente' è da interpretare secondo la definizione WCAG Sucess Criterion 2.5.8 Target Size (Minimum): 'The function can be achieved through a different control on the same page that meets this criterion' - Rispondi SI o No e motiva la tua risposta",#"Naviga su wikipedia, cerca la pagina dell'intelligenza artificiale e dimmi l'anno di nascita del termine.",
|
||||
task="Open the URL https://github.com/rasbt and check if the page offers an alternative function to the interactive map of content in the year (contribution heatmap data visualization). 'Equivalent' is to be interpreted according to the WCAG Success Criterion 2.5.8 Target Size (Minimum): 'The function can be achieved through a different control on the same page that meets this criterion' - Answer YES or NO and justify your answer.",
|
||||
llm=llm
|
||||
)
|
||||
|
||||
# Esecuzione del workflow
|
||||
result = await agent.run()
|
||||
print("\n[Risultato Finale Azure OpenAI]:\n", result)
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_azure_agent())
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
from __future__ import annotations
|
||||
"""
|
||||
hag_queries.py
|
||||
==============
|
||||
|
|
@ -32,9 +31,7 @@ Or from the command line (requires a saved JSON graph):
|
|||
python hag_queries.py hag.json --verbose
|
||||
python hag_queries.py hag.json --query Q1.1
|
||||
python hag_queries.py hag.json --report issues.json
|
||||
"""
|
||||
|
||||
"""
|
||||
Architettura del modulo
|
||||
Ogni query è una funzione autonoma che restituisce una lista di Issue — un dataclass con query_id, title, wcag, severity, layer, node_id, detail e un dizionario extra per dati specifici. La severità è calcolata in modo contestuale (non fissa) — ad esempio Q1.2 diventa critical se il nodo visivo senza AX occupa più del 10% della viewport.
|
||||
Le query sono organizzate in quattro gap, coerenti con la MVAR discussa prima:
|
||||
|
|
@ -44,11 +41,13 @@ Le query sono organizzate in quattro gap, coerenti con la MVAR discussa prima:
|
|||
Δ3 — DOM - Visual (Q3.x): elementi visivamente prominenti il cui DOM node non porta semantica (div/span senza ruolo ARIA).
|
||||
"""
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from statistics import harmonic_mean
|
||||
from typing import Any
|
||||
|
||||
import networkx as nx
|
||||
|
|
@ -105,78 +104,189 @@ def _dom_neighbors_via_cross(HAG: nx.MultiDiGraph, dom_node: str) -> list[str]:
|
|||
return _neighbors_by_relation(HAG, dom_node, "dom_to_ax")
|
||||
|
||||
|
||||
def _dom_parent(HAG: nx.MultiDiGraph, dom_node: str) -> Optional[str]:
|
||||
"""Strict parent lookup (child --hasParent--> parent), no reverse edges."""
|
||||
for _, v, d in HAG.out_edges(dom_node, data=True):
|
||||
if d.get("relation") == "hasParent":
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def _dom_children(HAG: nx.MultiDiGraph, dom_node: str) -> list[str]:
|
||||
"""Strict children lookup (parent --hasChild--> child), no reverse edges."""
|
||||
return [v for _, v, d in HAG.out_edges(dom_node, data=True)
|
||||
if d.get("relation") == "hasChild"]
|
||||
|
||||
|
||||
def _is_aria_expanded_false(attrs: dict) -> bool:
|
||||
val = attrs.get("aria_expanded")
|
||||
return val is not None and str(val).strip().lower() == "false"
|
||||
|
||||
|
||||
def _inside_collapsed_disclosure(
|
||||
HAG: nx.MultiDiGraph, dom_node: str, max_depth: int = 6
|
||||
) -> bool:
|
||||
"""
|
||||
Heuristic for the "modal / dropdown menu" case: a DOM element can be
|
||||
legitimately absent from the accessibility tree because it lives inside
|
||||
a disclosure widget (dropdown menu, accordion, modal, …) that is
|
||||
currently collapsed/closed. Browsers commonly drop such subtrees from
|
||||
the AX tree entirely and only expose them once the trigger is activated
|
||||
(aria-expanded="true").
|
||||
|
||||
We walk up the DOM ancestor chain and, at each level, check both the
|
||||
ancestor itself and its direct children (covers the common pattern
|
||||
where the trigger — e.g. a <button aria-expanded="false"> — is a
|
||||
*sibling* of the collapsible content, not its ancestor). If any of
|
||||
those carries aria-expanded="false", the node is treated as being
|
||||
inside a collapsed (not activated) widget rather than a real Q1.3 gap.
|
||||
"""
|
||||
current = dom_node
|
||||
for _ in range(max_depth):
|
||||
parent = _dom_parent(HAG, current)
|
||||
if parent is None:
|
||||
return False
|
||||
candidates = [parent] + _dom_children(HAG, parent)
|
||||
for cand in candidates:
|
||||
if _is_aria_expanded_false(HAG.nodes.get(cand, {})):
|
||||
return True
|
||||
current = parent
|
||||
return False
|
||||
|
||||
|
||||
def _get_dom_tabindex(HAG: nx.MultiDiGraph, ax_nid: str) -> Optional[int]:
|
||||
"""
|
||||
Resolve the raw DOM tabindex value for an AX node by crossing to its
|
||||
DOM peer via the dom_to_ax cross-edge.
|
||||
|
||||
Returns:
|
||||
None – no tabindex attribute present (naturally focusable elements
|
||||
follow DOM order, non-focusable elements are not in tab sequence)
|
||||
-1 – tabindex="-1" (removed from tab sequence, focusable by script)
|
||||
0 – tabindex="0" (in tab sequence, DOM order)
|
||||
N > 0 – tabindex="N" (explicit positive tab order)
|
||||
"""
|
||||
for u, _, d in HAG.in_edges(ax_nid, data=True):
|
||||
if d.get("relation") == "dom_to_ax":
|
||||
raw = HAG.nodes[u].get("tabindex") # set by build_dom_layer
|
||||
if raw is None:
|
||||
raw = HAG.nodes[u].get("attr_tabindex") # fallback key
|
||||
if raw is not None:
|
||||
try:
|
||||
return int(str(raw).strip())
|
||||
except ValueError:
|
||||
pass
|
||||
# also check AX-layer aria_tabindex (CDP exposes it as a property)
|
||||
raw_ax = HAG.nodes[ax_nid].get("aria_tabindex")
|
||||
if raw_ax is not None:
|
||||
try:
|
||||
return int(float(str(raw_ax)))
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _is_natively_focusable(role: str, ax_attrs: dict) -> bool:
|
||||
"""
|
||||
Mirror the WAVE extension's implicit focusability logic.
|
||||
These roles/elements participate in tab order without an explicit tabindex.
|
||||
"""
|
||||
FOCUSABLE_ROLES = {
|
||||
"link", "button", "textbox", "searchbox", "combobox",
|
||||
"listbox", "checkbox", "radio", "menuitem", "menuitemcheckbox",
|
||||
"menuitemradio", "tab", "slider", "spinbutton", "switch",
|
||||
"treeitem", "gridcell", "columnheader", "rowheader",
|
||||
}
|
||||
return role.lower() in FOCUSABLE_ROLES
|
||||
|
||||
|
||||
def _ax_reading_order(HAG: nx.MultiDiGraph) -> list[tuple[str, dict]]:
|
||||
"""
|
||||
Return non-ignored AX nodes in DOM tree order (breadth-first from root).
|
||||
This approximates screen-reader reading order.
|
||||
Return non-ignored AX nodes ordered to match the browser's actual
|
||||
reading / focus sequence, aligned with the WAVE extension's approach.
|
||||
|
||||
The WAVE extension draws tab-order arrows between element centroids
|
||||
sorted by the browser's focus sequence, which follows these rules
|
||||
(matching HTML spec §focus management):
|
||||
|
||||
1. Elements with tabindex > 0 come FIRST, sorted ascending by
|
||||
tabindex value, then by DOM tree order within ties.
|
||||
2. Elements with tabindex = 0 OR natively focusable elements with
|
||||
no tabindex attribute come NEXT, in DOM tree order (depth-first
|
||||
pre-order, which is what a depth-first traversal of the AX tree
|
||||
gives us — NOT breadth-first).
|
||||
3. Elements with tabindex = -1 are NOT in the tab sequence but are
|
||||
still included here for reading-order purposes (screen readers
|
||||
read all non-ignored nodes, not only focusable ones).
|
||||
|
||||
Differences from the old BFS implementation:
|
||||
• DFS pre-order instead of BFS — matches DOM source order more
|
||||
accurately (BFS visits siblings before descending, DFS descends
|
||||
immediately, which is how screen readers traverse).
|
||||
• Positive-tabindex nodes are promoted to the front of the sequence
|
||||
(the WAVE extension shows them first in the arrow chain).
|
||||
• tabindex=-1 nodes stay in DOM order but are flagged so callers
|
||||
can distinguish them from naturally focusable nodes.
|
||||
"""
|
||||
# ── find AX root ──────────────────────────────────────────────────────
|
||||
roots = [
|
||||
n for n, d in HAG.nodes(data=True)
|
||||
if d.get("layer") == "ax"
|
||||
and d.get("role") in ("RootWebArea", "WebArea")
|
||||
and str(d.get("role") or "").lower() in ("rootwebarea", "webarea")
|
||||
]
|
||||
if not roots:
|
||||
# fallback: any AX node with no AX parent
|
||||
ax_nodes = {n for n, d in HAG.nodes(data=True) if d.get("layer") == "ax"}
|
||||
ax_children = set()
|
||||
for u, v, d in HAG.edges(data=True):
|
||||
if d.get("relation") == "hasChild" and d.get("layer") == "ax":
|
||||
ax_children.add(v)
|
||||
ax_nodes = {n for n, d in HAG.nodes(data=True) if d.get("layer") == "ax"}
|
||||
ax_children = {
|
||||
v for _, v, d in HAG.edges(data=True)
|
||||
if d.get("relation") == "hasChild" and d.get("layer") == "ax"
|
||||
}
|
||||
roots = list(ax_nodes - ax_children)
|
||||
|
||||
visited, order = set(), []
|
||||
queue = list(roots)
|
||||
while queue:
|
||||
node = queue.pop(0)
|
||||
# ── DFS pre-order traversal ───────────────────────────────────────────
|
||||
# Produces the same sequence as the browser's DOM-order focus walk.
|
||||
visited: set[str] = set()
|
||||
dom_order: list[tuple[str, dict]] = [] # all non-ignored nodes, DFS order
|
||||
|
||||
def _dfs(node: str) -> None:
|
||||
if node in visited:
|
||||
continue
|
||||
return
|
||||
visited.add(node)
|
||||
attrs = HAG.nodes[node]
|
||||
if not attrs.get("ignored"):
|
||||
order.append((node, attrs))
|
||||
dom_order.append((node, attrs))
|
||||
# children in the order CDP reported them (childIds preserves DOM order)
|
||||
children = [
|
||||
v for _, v, d in HAG.out_edges(node, data=True)
|
||||
if d.get("relation") == "hasChild" and d.get("layer") == "ax"
|
||||
]
|
||||
queue.extend(children)
|
||||
return order
|
||||
for child in children:
|
||||
_dfs(child)
|
||||
|
||||
for root in roots:
|
||||
_dfs(root)
|
||||
|
||||
# ── separate positive-tabindex nodes ──────────────────────────────────
|
||||
# Positive tabindex nodes jump to the front of the tab sequence.
|
||||
# They keep their relative DOM order within the same tabindex value.
|
||||
positive_tab: list[tuple[int, int, str, dict]] = [] # (tabval, dom_pos, nid, attrs)
|
||||
normal_order: list[tuple[str, dict]] = [] # tabindex 0 / native / -1
|
||||
|
||||
def _dom_reading_order(HAG: nx.MultiDiGraph) -> list[tuple[str, dict]]:
|
||||
"""
|
||||
Return non-ignored DOM nodes in DOM tree order (breadth-first from root).
|
||||
This approximates TAB reading order.
|
||||
"""
|
||||
|
||||
#dom_nodes = {n for n, d in HAG.nodes(data=True) if (d.get("layer") == "dom" and d.get("node_type")==1)}
|
||||
dom_nodes = {n for n, d in HAG.nodes(data=True) if d.get("layer") == "dom" }
|
||||
print("dom_nodes:",dom_nodes)
|
||||
dom_children = set()
|
||||
for u, v, d in HAG.edges(data=True):
|
||||
if d.get("relation") == "hasChild" and d.get("layer") == "dom":
|
||||
dom_children.add(v)
|
||||
roots = list(dom_nodes - dom_children)
|
||||
print("roots:",roots)# va controllato filtro solo eleenti realmente interattivi (vedi plugin)
|
||||
for dom_pos, (nid, attrs) in enumerate(dom_order):
|
||||
role = str(attrs.get("role") or "").lower()
|
||||
tabidx = _get_dom_tabindex(HAG, nid)
|
||||
|
||||
|
||||
if tabidx is not None and tabidx > 0:
|
||||
positive_tab.append((tabidx, dom_pos, nid, attrs))
|
||||
else:
|
||||
# tabindex=0, tabindex=-1, or no tabindex (native focus)
|
||||
normal_order.append((nid, attrs))
|
||||
|
||||
visited, order = set(), []
|
||||
queue = list(roots)
|
||||
while queue:
|
||||
node = queue.pop(0)
|
||||
if node in visited:
|
||||
continue
|
||||
visited.add(node)
|
||||
attrs = HAG.nodes[node]
|
||||
if not attrs.get("ignored"):
|
||||
order.append((node, attrs))
|
||||
children = [
|
||||
v for _, v, d in HAG.out_edges(node, data=True)
|
||||
if d.get("relation") == "hasChild" and d.get("layer") == "dom"
|
||||
]
|
||||
queue.extend(children)
|
||||
return order
|
||||
# Sort positive-tabindex group: primary key = tabindex value, secondary = DOM pos
|
||||
positive_tab.sort(key=lambda x: (x[0], x[1]))
|
||||
promoted = [(nid, attrs) for _, _, nid, attrs in positive_tab]
|
||||
|
||||
# ── final sequence: promoted first, then DOM order ────────────────────
|
||||
return promoted + normal_order
|
||||
|
||||
|
||||
def _visual_reading_order(HAG: nx.MultiDiGraph) -> list[tuple[str, dict]]:
|
||||
|
|
@ -231,17 +341,19 @@ def q_ax_nodes_without_visual(HAG: nx.MultiDiGraph) -> list[Issue]:
|
|||
a bug (content accidentally off-screen / clipped).
|
||||
|
||||
WCAG: 1.3.1 Info and Relationships (A)
|
||||
#NL: qua dentro cadono anche i tag particolari come "main" o "list" tolta da grafo visivo (la lista di per se non viene vista ma solo i singoli elementi nel caso). ol e ul tolti anche perchè se le vede solo il testo
|
||||
# ci cascano anche elemneti ax di tipo "generic"
|
||||
"""
|
||||
issues = []
|
||||
for nid, attrs in _nodes_by_layer(HAG, "ax"):
|
||||
if attrs.get("ignored"):
|
||||
continue
|
||||
role = str(attrs.get("role") or "")
|
||||
# skip purely structural roles that are never rendered
|
||||
if role in ("RootWebArea", "WebArea", "InlineTextBox",
|
||||
"none", "presentation", "ListMarker"):
|
||||
# Skip roles that are never independently rendered:
|
||||
# StaticText nodes have no box model — their parent element does.
|
||||
# InlineTextBox, ListMarker are sub-element artefacts.
|
||||
if role in ("RootWebArea", "WebArea", "InlineTextBox", "StaticText",
|
||||
"none", "presentation", "ListMarker", "generic","list","link","listitem","main","heading",
|
||||
"group","landmark","region","article","banner","complementary","contentinfo","linebreak","paragraph","separator","form","search","navigation",
|
||||
"sectionheader","span","code"):
|
||||
continue
|
||||
vis_neighbors = _ax_neighbors_via_cross(HAG, nid)
|
||||
# filter to only visual-layer nodes
|
||||
|
|
@ -275,12 +387,19 @@ def q_visual_nodes_without_ax(HAG: nx.MultiDiGraph) -> list[Issue]:
|
|||
|
||||
WCAG: 1.1.1 Non-text Content (A), 4.1.2 Name Role Value (A)
|
||||
#NL: non capito. sembra elemento visivo collegato a elementi ax rilevanti, con ignore=false
|
||||
# no, è cos'ì: elementi visivi non collegati a nessun elemento ax rivelante
|
||||
"""
|
||||
issues = []
|
||||
# Tags that intentionally have no AX node (browser container elements)
|
||||
SKIP_TAGS = {"html", "body", "head", "script", "style", "meta", "link"}
|
||||
|
||||
for nid, attrs in _nodes_by_layer(HAG, "visual"):
|
||||
tag = attrs.get("tag", "")
|
||||
prominence = attrs.get("prominence", 0)
|
||||
|
||||
if tag in SKIP_TAGS:
|
||||
continue
|
||||
|
||||
# Find all AX neighbors
|
||||
ax_neighbors = _neighbors_by_relation(HAG, nid, "ax_to_visual")
|
||||
# Also check via dom→ax chain
|
||||
|
|
@ -292,7 +411,7 @@ def q_visual_nodes_without_ax(HAG: nx.MultiDiGraph) -> list[Issue]:
|
|||
visible_ax = [
|
||||
v for v in ax_neighbors
|
||||
if HAG.nodes[v].get("layer") == "ax"
|
||||
and not HAG.nodes[v].get("ignored")#take "ignored"=false, take usefull ax nodes
|
||||
and not HAG.nodes[v].get("ignored")
|
||||
]
|
||||
|
||||
if not visible_ax:
|
||||
|
|
@ -309,8 +428,8 @@ def q_visual_nodes_without_ax(HAG: nx.MultiDiGraph) -> list[Issue]:
|
|||
layer = "cross:visual-ax",
|
||||
node_id = nid,
|
||||
detail = (
|
||||
f"<{tag}> element is rendered (prominence={prominence:.5f}) "
|
||||
f"but relevant AX node (ignored=false; has no corresponding non-ignored AX node.) "
|
||||
f"<{tag}> element is rendered (prominence={prominence:.3f}) "
|
||||
f"but has no corresponding non-ignored AX node. (not connected to a relevant node in the accessibility tree)."
|
||||
f"Screen reader users cannot perceive this content."
|
||||
),
|
||||
extra = {"tag": tag, "prominence": prominence,
|
||||
|
|
@ -323,7 +442,13 @@ def q_dom_nodes_without_ax(HAG: nx.MultiDiGraph) -> list[Issue]:
|
|||
"""
|
||||
Δ1 – Interactive DOM nodes (a, button, input, select, textarea)
|
||||
that have no AX node at all (not even an ignored one).
|
||||
# NL da verificare comportamneto su modali e drop-down menu dove elemento DOM esiste ma non su accessibility tree (compare solo se elemento attivato)
|
||||
|
||||
Nodes that live inside a currently collapsed disclosure widget (modal,
|
||||
dropdown menu, accordion, …) are excluded: browsers legitimately drop
|
||||
such subtrees from the AX tree until the trigger is activated
|
||||
(aria-expanded="true"), so their absence is expected, not a defect.
|
||||
aria-expanded="false" on the nearest controlling ancestor/sibling is
|
||||
used as the discriminant — see _inside_collapsed_disclosure().
|
||||
|
||||
WCAG: 4.1.2 Name, Role, Value (A)
|
||||
"""
|
||||
|
|
@ -335,25 +460,28 @@ def q_dom_nodes_without_ax(HAG: nx.MultiDiGraph) -> list[Issue]:
|
|||
if tag not in INTERACTIVE_TAGS:
|
||||
continue
|
||||
ax_neighbors = _dom_neighbors_via_cross(HAG, nid)
|
||||
if not ax_neighbors:
|
||||
issues.append(Issue(
|
||||
query_id = "Q1.3",
|
||||
title = "Interactive DOM node with no AX node",
|
||||
wcag = ["4.1.2"],
|
||||
severity = "serious",
|
||||
layer = "cross:dom-ax",
|
||||
node_id = nid,
|
||||
detail = (
|
||||
f"<{tag}> element (DOM node {nid}) is interactive but "
|
||||
f"does not appear in the accessibility tree at all. "
|
||||
f"It is completely opaque to assistive technologies."
|
||||
),
|
||||
extra = {
|
||||
"tag": tag,
|
||||
"attr_id": attrs.get("attr_id"),
|
||||
"aria_hidden": attrs.get("aria_hidden"),
|
||||
},
|
||||
))
|
||||
if ax_neighbors:
|
||||
continue
|
||||
if _inside_collapsed_disclosure(HAG, nid):
|
||||
continue
|
||||
issues.append(Issue(
|
||||
query_id = "Q1.3",
|
||||
title = "Interactive DOM node with no AX node",
|
||||
wcag = ["4.1.2"],
|
||||
severity = "serious",
|
||||
layer = "cross:dom-ax",
|
||||
node_id = nid,
|
||||
detail = (
|
||||
f"<{tag}> element (DOM node {nid}) is interactive but "
|
||||
f"does not appear in the accessibility tree at all. "
|
||||
f"It is completely opaque to assistive technologies."
|
||||
),
|
||||
extra = {
|
||||
"tag": tag,
|
||||
"attr_id": attrs.get("attr_id"),
|
||||
"aria_hidden": attrs.get("aria_hidden"),
|
||||
},
|
||||
))
|
||||
return issues
|
||||
|
||||
|
||||
|
|
@ -409,82 +537,7 @@ def q_ignored_ax_with_visual(HAG: nx.MultiDiGraph) -> list[Issue]:
|
|||
# "what SR reads" vs "what sighted users perceive first"
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def q_focus_order_vs_visual(HAG: nx.MultiDiGraph) -> list[Issue]:
|
||||
"""
|
||||
Δ2 – Detect focusable AX nodes whose visual reading_rank differs greatly
|
||||
from their position in the AX child traversal order.
|
||||
|
||||
Focusable elements with a wrong visual position violate 2.4.3 Focus Order.
|
||||
|
||||
WCAG: 2.4.3 Focus Order (A)
|
||||
#NL: corrispettivo check disallineamento ordine visivo e screen reader / tab
|
||||
"""
|
||||
FOCUSABLE_ROLES = {
|
||||
"link", "button", "textbox", "combobox", "listbox",
|
||||
"checkbox", "radio", "menuitem", "tab", "slider",
|
||||
"spinbutton", "searchbox", "switch",
|
||||
}
|
||||
ax_order = _ax_reading_order(HAG)
|
||||
ax_rank = {nid: i for i, (nid, _) in enumerate(ax_order)}
|
||||
max_ax = max(ax_rank.values(), default=1) or 1
|
||||
|
||||
issues = []
|
||||
focusable = [
|
||||
(nid, attrs) for nid, attrs in ax_order
|
||||
if str(attrs.get("role") or "").lower() in FOCUSABLE_ROLES
|
||||
or str(attrs.get("aria_focusable") or "") == "True"
|
||||
]
|
||||
|
||||
for ax_nid, ax_attrs in focusable:
|
||||
vis_neighbors = [
|
||||
v for v in _ax_neighbors_via_cross(HAG, ax_nid)
|
||||
if HAG.nodes[v].get("layer") == "visual"
|
||||
]
|
||||
if not vis_neighbors:
|
||||
continue
|
||||
|
||||
best_vis = max(vis_neighbors,
|
||||
key=lambda v: HAG.nodes[v].get("prominence", 0))
|
||||
vis_attrs = HAG.nodes[best_vis]
|
||||
vis_r = vis_attrs.get("reading_rank", 0)
|
||||
max_vis = max(
|
||||
(HAG.nodes[v].get("reading_rank", 0)
|
||||
for v in HAG.nodes()
|
||||
if HAG.nodes[v].get("layer") == "visual"),
|
||||
default=1,
|
||||
) or 1
|
||||
|
||||
ax_r_norm = ax_rank[ax_nid] / max_ax
|
||||
vis_r_norm = vis_r / max_vis
|
||||
delta = int(abs(ax_r_norm - vis_r_norm) * max_ax)
|
||||
|
||||
if delta >= 8:
|
||||
role = str(ax_attrs.get("role") or "")
|
||||
name = ax_attrs.get("name") or ""
|
||||
issues.append(Issue(
|
||||
query_id = "Q2.2",
|
||||
title = "Focus order diverges from visual order",
|
||||
wcag = ["2.4.3"],
|
||||
severity = "serious",
|
||||
layer = "cross:ax-visual",
|
||||
node_id = ax_nid,
|
||||
detail = (
|
||||
f"Focusable element role='{role}' name='{name}' "
|
||||
f"is at tab-order position #{ax_rank[ax_nid]+1} "
|
||||
f"but visual rank #{vis_r}. "
|
||||
f"Keyboard users tabbing through the page will jump "
|
||||
f"unexpectedly (circa {delta} positions off)."
|
||||
),
|
||||
extra = {
|
||||
"role": role, "name": name,
|
||||
"ax_rank": ax_rank[ax_nid], "visual_rank": vis_r,
|
||||
"delta": delta,
|
||||
},
|
||||
))
|
||||
return issues
|
||||
|
||||
def q_reading_order_mismatch(
|
||||
def q_reading_order_mismatch(
|
||||
HAG: nx.MultiDiGraph,
|
||||
prominence_threshold: float = 0.05,
|
||||
rank_distance_threshold: int = 5,
|
||||
|
|
@ -567,7 +620,7 @@ def q_reading_order_mismatch(
|
|||
layer = "cross:ax-visual",
|
||||
node_id = ax_nid,
|
||||
detail = (
|
||||
f"AX node role='{role}' name='{name}' appears at Screen Reader position "
|
||||
f"AX node role='{role}' name='{name}' appears at SR position "
|
||||
f"#{ax_rank[ax_nid]+1} but at visual rank "
|
||||
f"#{vis_r_raw} (circa {delta_positions} positions {direction} "
|
||||
f"expected). Visual prominence={max_prom:.3f}. "
|
||||
|
|
@ -677,7 +730,124 @@ def q_heading_hierarchy(HAG: nx.MultiDiGraph) -> list[Issue]:
|
|||
return issues
|
||||
|
||||
|
||||
def q_focus_order_vs_visual(HAG: nx.MultiDiGraph) -> list[Issue]:
|
||||
"""
|
||||
Δ2 – Detect focusable AX nodes whose tab-order position (as computed by
|
||||
_ax_reading_order, which honours positive tabindex promotion) differs
|
||||
substantially from their visual reading rank.
|
||||
|
||||
Aligned with the WAVE extension's tab-order arrows:
|
||||
• Only natively focusable or explicitly focusable elements are checked
|
||||
(tabindex=-1 nodes are excluded — they are not in the tab sequence).
|
||||
• Positive-tabindex nodes that have been promoted to the front are
|
||||
flagged with a dedicated cause so the report explains *why* they
|
||||
are out of visual order.
|
||||
• The delta threshold is applied on the focusable-only subsequence
|
||||
(not the full AX tree) to avoid noise from structural/non-interactive
|
||||
nodes inflating the rank gap.
|
||||
|
||||
WCAG: 2.4.3 Focus Order (A)
|
||||
#NL: corrispettivo check disallineamento ordine visivo e screen reader / tab
|
||||
"""
|
||||
FOCUSABLE_ROLES = {
|
||||
"link", "button", "textbox", "combobox", "listbox",
|
||||
"checkbox", "radio", "menuitem", "menuitemcheckbox",
|
||||
"menuitemradio", "tab", "slider", "spinbutton",
|
||||
"searchbox", "switch", "treeitem",
|
||||
}
|
||||
|
||||
ax_order = _ax_reading_order(HAG)
|
||||
max_vis = max(
|
||||
(HAG.nodes[v].get("reading_rank", 0)
|
||||
for v in HAG.nodes()
|
||||
if HAG.nodes[v].get("layer") == "visual"),
|
||||
default=1,
|
||||
) or 1
|
||||
|
||||
# Build focusable-only subsequence (mirrors WAVE's tab-stop list)
|
||||
focusable: list[tuple[str, dict, Optional[int]]] = []
|
||||
for nid, attrs in ax_order:
|
||||
role = str(attrs.get("role") or "").lower()
|
||||
tabidx = _get_dom_tabindex(HAG, nid)
|
||||
|
||||
is_native = _is_natively_focusable(role, attrs)
|
||||
is_explicit = tabidx is not None and tabidx >= 0
|
||||
if not (is_native or is_explicit):
|
||||
continue
|
||||
if tabidx is not None and tabidx < 0:
|
||||
continue # tabindex=-1 → script-only, not in tab sequence
|
||||
|
||||
focusable.append((nid, attrs, tabidx))
|
||||
|
||||
max_focus = max(len(focusable) - 1, 1)
|
||||
issues = []
|
||||
|
||||
for focus_pos, (ax_nid, ax_attrs, tabidx) in enumerate(focusable):
|
||||
vis_neighbors = [
|
||||
v for v in _ax_neighbors_via_cross(HAG, ax_nid)
|
||||
if HAG.nodes[v].get("layer") == "visual"
|
||||
]
|
||||
if not vis_neighbors:
|
||||
continue
|
||||
|
||||
best_vis = max(vis_neighbors,
|
||||
key=lambda v: HAG.nodes[v].get("prominence", 0))
|
||||
vis_r = HAG.nodes[best_vis].get("reading_rank", 0)
|
||||
prom = HAG.nodes[best_vis].get("prominence", 0)
|
||||
|
||||
# Normalise both ranks to 0..1 on their own scales
|
||||
tab_norm = focus_pos / max_focus
|
||||
vis_norm = vis_r / max_vis
|
||||
delta = int(abs(tab_norm - vis_norm) * max_focus)
|
||||
|
||||
if delta < 8:
|
||||
continue
|
||||
|
||||
role = str(ax_attrs.get("role") or "")
|
||||
name = ax_attrs.get("name") or ""
|
||||
|
||||
# Diagnose root cause — mirrors what WAVE highlights visually
|
||||
if tabidx is not None and tabidx > 0:
|
||||
cause = (
|
||||
f"Element has tabindex={tabidx} which promotes it to tab "
|
||||
f"position #{focus_pos+1}, ahead of its visual position "
|
||||
f"(rank #{vis_r}). Positive tabindex values are the most "
|
||||
f"common cause of focus-order violations."
|
||||
)
|
||||
severity = "serious"
|
||||
else:
|
||||
direction = "before" if tab_norm < vis_norm else "after"
|
||||
cause = (
|
||||
f"Element reaches tab position #{focus_pos+1} via DOM order "
|
||||
f"but appears visually at rank #{vis_r} "
|
||||
f"(circa {delta} positions {direction} expected). "
|
||||
f"Likely caused by CSS reordering (flex/grid order, "
|
||||
f"absolute positioning, or float layout)."
|
||||
)
|
||||
severity = "moderate"
|
||||
|
||||
issues.append(Issue(
|
||||
query_id = "Q2.2",
|
||||
title = "Focus order diverges from visual order",
|
||||
wcag = ["2.4.3"],
|
||||
severity = severity,
|
||||
layer = "cross:ax-visual",
|
||||
node_id = ax_nid,
|
||||
detail = (
|
||||
f"role='{role}' name='{name}' — {cause} "
|
||||
f"Visual prominence={prom:.3f}."
|
||||
),
|
||||
extra = {
|
||||
"role": role, "name": name,
|
||||
"tab_position": focus_pos + 1,
|
||||
"visual_rank": vis_r,
|
||||
"delta": delta,
|
||||
"tabindex": tabidx,
|
||||
"prominence": prom,
|
||||
},
|
||||
))
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -703,6 +873,15 @@ def q_images_missing_alt(HAG: nx.MultiDiGraph) -> list[Issue]:
|
|||
if role not in ("img", "image"):
|
||||
continue
|
||||
name = (attrs.get("name") or "").strip()
|
||||
|
||||
"""
|
||||
attrs.get("name") is the computed accessible name — not the alt-text attribute directly.
|
||||
It is the result of the browser's accessible name computation algorithm (ARIA spec, accname-1.1),
|
||||
which considers multiple sources in priority order: aria-labelledby, aria-label, the alt attribute, the title attribute,
|
||||
and finally the image's filename as a last resort.
|
||||
So what you get in name is the browser's final answer to "what should a screen reader announce for this element?"
|
||||
"""
|
||||
|
||||
if not name:
|
||||
# Check if it's intentionally decorative (aria-hidden on DOM side)
|
||||
dom_neighbors = _neighbors_by_relation(HAG, nid, "dom_to_ax")
|
||||
|
|
@ -937,13 +1116,13 @@ def q_prominent_visual_without_dom_semantics(HAG: nx.MultiDiGraph) -> list[Issue
|
|||
|
||||
WCAG: 1.3.1 Info and Relationships (A)
|
||||
"""
|
||||
SEMANTIC_TAGS = {
|
||||
"""SEMANTIC_TAGS = {
|
||||
"main", "nav", "header", "footer", "aside", "section",
|
||||
"article", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"p", "ul", "ol", "li", "button", "a", "input",
|
||||
"select", "textarea", "table", "th", "td", "form",
|
||||
"figure", "figcaption", "blockquote", "details", "summary",
|
||||
}
|
||||
}"""
|
||||
issues = []
|
||||
|
||||
for vis_nid, vis_attrs in _nodes_by_layer(HAG, "visual"):
|
||||
|
|
@ -979,6 +1158,212 @@ def q_prominent_visual_without_dom_semantics(HAG: nx.MultiDiGraph) -> list[Issue
|
|||
return issues
|
||||
|
||||
|
||||
def q_positive_tabindex(HAG: nx.MultiDiGraph) -> list[Issue]:
|
||||
"""
|
||||
Δ0 – Elements with a positive tabindex value (tabindex > 0).
|
||||
|
||||
The WAVE extension explicitly flags these as a distinct category because
|
||||
they are the single most common root cause of focus-order violations:
|
||||
a developer adds tabindex=3 to one button and suddenly every other
|
||||
naturally-focusable element on the page is pushed behind it.
|
||||
|
||||
Note: tabindex=0 is fine (puts element in DOM-order tab sequence).
|
||||
tabindex=-1 is fine (removes from tab sequence, script-focusable).
|
||||
tabindex>0 is almost always a mistake on modern pages.
|
||||
|
||||
WCAG: 2.4.3 Focus Order (A)
|
||||
"""
|
||||
issues = []
|
||||
for nid, attrs in _nodes_by_layer(HAG, "ax"):
|
||||
if attrs.get("ignored"):
|
||||
continue
|
||||
tabidx = _get_dom_tabindex(HAG, nid)
|
||||
if tabidx is None or tabidx <= 0:
|
||||
continue
|
||||
|
||||
role = str(attrs.get("role") or "")
|
||||
name = attrs.get("name") or ""
|
||||
|
||||
# Get visual context
|
||||
vis_nodes = [
|
||||
v for v in _ax_neighbors_via_cross(HAG, nid)
|
||||
if HAG.nodes[v].get("layer") == "visual"
|
||||
]
|
||||
prom = max(
|
||||
(HAG.nodes[v].get("prominence", 0) for v in vis_nodes),
|
||||
default=0,
|
||||
)
|
||||
|
||||
issues.append(Issue(
|
||||
query_id = "Q0.11",
|
||||
title = "Positive tabindex value",
|
||||
wcag = ["2.4.3"],
|
||||
severity = "serious",
|
||||
layer = "ax",
|
||||
node_id = nid,
|
||||
detail = (
|
||||
f"role='{role}' name='{name}' has tabindex={tabidx}. "
|
||||
f"Positive tabindex values override DOM-order focus navigation "
|
||||
f"and cause all naturally-focusable elements to be visited "
|
||||
f"after elements with explicit positive values, regardless of "
|
||||
f"their visual position. Replace with tabindex='0' and reorder "
|
||||
f"elements in the DOM to match the intended tab sequence. "
|
||||
f"Visual prominence={prom:.3f}."
|
||||
),
|
||||
extra = {
|
||||
"role": role, "name": name,
|
||||
"tabindex": tabidx, "prominence": prom,
|
||||
},
|
||||
))
|
||||
return issues
|
||||
|
||||
|
||||
def q_ax_community_complexity(HAG: nx.MultiDiGraph) -> list[Issue]:
|
||||
"""
|
||||
Δ0 – Structural/navigational complexity of the accessibility tree,
|
||||
measured via community detection (Louvain) on the AX-only subgraph:
|
||||
nodes with layer=="ax" (excluding ignored ones) connected by the
|
||||
structural edges (hasChild_ax / hasParent_ax) and the ARIA relational
|
||||
edges (describedby, labelledby, controls, owns, activedescendant,
|
||||
flowto, colindex, rowindex) built in build_ax_layer(). Those relational
|
||||
edges create cross-links between distant branches of the tree, so the
|
||||
resulting subgraph is not a bare tree — community detection on it is
|
||||
meaningful rather than trivially returning subtrees.
|
||||
|
||||
Rationale: feedback such as "la pagina è complessa da navigare con lo
|
||||
screen reader", "ci si perde", "non riesco a orientarmi" describes a
|
||||
perceived lack of navigable structure rather than a single markup
|
||||
defect. Two independent bad patterns are detected:
|
||||
• fragmentation = n_communities / n_ax_nodes — many tiny disconnected
|
||||
communities mean no unifying structure to build a mental map from.
|
||||
• max_share = size of the largest community / n_ax_nodes — one
|
||||
dominant community means an undifferentiated block with no
|
||||
internal landmarks to orient by.
|
||||
The two are combined into a single navigability score as the harmonic
|
||||
mean of their reciprocals — i.e. 2/(fragmentation + max_share) — which
|
||||
is high for a well-partitioned tree and falls to its floor (~2) at both
|
||||
pathological extremes, symmetrically. No clipping or per-signal
|
||||
normalization is involved: because fragmentation = k/n and
|
||||
max_share >= 1/k, the two quantities are reciprocally linked and can
|
||||
never both be large, so their sum peaks (~1) exactly at the two
|
||||
pathologies and bottoms out (2/sqrt(n)) for a balanced partition.
|
||||
|
||||
complexity_index then expresses navigability as a shortfall from the
|
||||
best achievable value sqrt(n) (attained at k = sqrt(n) balanced
|
||||
communities, and an upper bound by AM-GM). This keeps the index in
|
||||
[0, 1) without clipping and makes it scale-aware in the right
|
||||
direction: a monolithic 10.000-node tree scores 0.98 while a monolithic
|
||||
100-node one scores 0.80, reflecting the genuinely heavier navigation
|
||||
burden of the larger page rather than saturating both to the same value.
|
||||
|
||||
WCAG: 1.3.1 Info and Relationships (A), 2.4.10 Section Headings (AAA)
|
||||
"""
|
||||
ax_nodes = [
|
||||
nid for nid, attrs in _nodes_by_layer(HAG, "ax")
|
||||
if not attrs.get("ignored")
|
||||
]
|
||||
if len(ax_nodes) < 3:
|
||||
return []
|
||||
ax_node_set = set(ax_nodes)
|
||||
ax_edges = [
|
||||
(u, v) for u, v, d in HAG.edges(data=True)
|
||||
if d.get("layer") == "ax" and u in ax_node_set and v in ax_node_set
|
||||
]
|
||||
# convert to undirected graph for community detection
|
||||
G_ax = nx.Graph()
|
||||
G_ax.add_nodes_from(ax_nodes)
|
||||
G_ax.add_edges_from(ax_edges)
|
||||
|
||||
if G_ax.number_of_edges() == 0:
|
||||
return []
|
||||
|
||||
communities = nx.community.louvain_communities(G_ax, seed=42)
|
||||
|
||||
n_nodes = G_ax.number_of_nodes()
|
||||
n_communities = len(communities)
|
||||
sizes = sorted((len(c) for c in communities), reverse=True)
|
||||
fragmentation = n_communities / n_nodes #n_communities / n_nodes → many tiny communities = fragmented structure, no common thread to follow ("you get lost").
|
||||
max_share = sizes[0] / n_nodes #largest community size / n_nodes → one dominant community = undifferentiated block with no internal reference points ("the site is big... I can't find my way around").
|
||||
|
||||
# The two signals are NOT independent: for a partition into k communities
|
||||
# fragmentation = k/n and max_share >= 1/k, so their product is bounded
|
||||
# below by 1/n. They are reciprocally linked, which means they can never
|
||||
# both be large — one of them is large exactly when the partition is
|
||||
# pathological (k -> 1 monolithic, or k -> n hyper-fragmented) and both
|
||||
# are small only for a balanced mid-range partition. This is why no
|
||||
# per-signal normalization / clipping is needed: combining the raw
|
||||
# reciprocals already behaves correctly at both extremes.
|
||||
#
|
||||
# navigability = harmonic mean of the reciprocals = 2/(fragmentation +
|
||||
# max_share). It is HIGH when the AX tree is well-partitioned and drops
|
||||
# to its floor (~2) at both pathological extremes, symmetrically.
|
||||
navigability = harmonic_mean([1 / fragmentation, 1 / max_share])
|
||||
|
||||
# The best achievable navigability is sqrt(n) (k = sqrt(n) balanced
|
||||
# communities); by AM-GM navigability <= sqrt(n) always, so the ratio is
|
||||
# guaranteed to stay in (0, 1] and no clipping is required here either.
|
||||
# Expressing complexity relative to that ideal makes the index
|
||||
# scale-aware in the right direction: a monolithic 10.000-node tree is a
|
||||
# far heavier navigation burden than a monolithic 100-node one, and the
|
||||
# index reflects that (0.98 vs 0.80) instead of saturating for both.
|
||||
ideal_navigability = math.sqrt(n_nodes)
|
||||
complexity_index = 1 - navigability / ideal_navigability
|
||||
|
||||
if complexity_index >= 0.90:
|
||||
severity = "critical"
|
||||
elif complexity_index >= 0.75:
|
||||
severity = "serious"
|
||||
elif complexity_index >= 0.55:
|
||||
severity = "moderate"
|
||||
else:
|
||||
# Below the lowest threshold: the AX tree's community structure is
|
||||
# unremarkable — not worth reporting as an issue at all.
|
||||
return []
|
||||
|
||||
# Anchor the page-level metric to the AX root node (RootWebArea), as a
|
||||
# concrete node_id, falling back to the landmark placeholder convention
|
||||
# used elsewhere in this module when no root is found.
|
||||
root_nid = next(
|
||||
(nid for nid, attrs in _nodes_by_layer(HAG, "ax")
|
||||
if str(attrs.get("role") or "") == "RootWebArea"),
|
||||
"ax:—",
|
||||
)
|
||||
|
||||
issues = [Issue(
|
||||
query_id = "Q0.12",
|
||||
title = "AX tree community structure indicates navigational complexity",
|
||||
wcag = ["1.3.1", "2.4.10"],
|
||||
severity = severity,
|
||||
layer = "ax",
|
||||
node_id = root_nid,
|
||||
detail = (
|
||||
f"The AX tree ({n_nodes} nodes) splits into {n_communities} "
|
||||
f"communities (sizes={sizes}) under Louvain detection over "
|
||||
f"structural + ARIA relational edges. "
|
||||
f"fragmentation={fragmentation:.3f} (n_communities/n_nodes), "
|
||||
f"max_share={max_share:.3f} (largest community/n_nodes), "
|
||||
f"navigability={navigability:.2f} "
|
||||
f"(harmonic mean of the two reciprocals) against an ideal of "
|
||||
f"{ideal_navigability:.2f} (=sqrt(n_nodes)), giving "
|
||||
f"complexity_index={complexity_index:.3f}. High fragmentation "
|
||||
f"means no unifying structure to build a mental map from; high "
|
||||
f"max_share means an undifferentiated block with no internal "
|
||||
f"landmarks to orient by."
|
||||
),
|
||||
extra = {
|
||||
"n_ax_nodes": n_nodes,
|
||||
"n_communities": n_communities,
|
||||
"community_sizes": sizes,
|
||||
"fragmentation": round(fragmentation, 3),
|
||||
"max_share": round(max_share, 3),
|
||||
"navigability": round(navigability, 3),
|
||||
"ideal_navigability": round(ideal_navigability, 3),
|
||||
"complexity_index": round(complexity_index, 3),
|
||||
},
|
||||
)]
|
||||
return issues
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Registry + runner
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -990,6 +1375,8 @@ QUERY_REGISTRY: list[tuple[str, callable]] = [
|
|||
("Q0.5 Form inputs without label", q_form_inputs_without_label),
|
||||
("Q0.6–Q0.9 Landmark structure", q_landmark_structure),
|
||||
("Q0.10 Interactive without name", q_interactive_without_name),
|
||||
("Q0.11 Positive tabindex", q_positive_tabindex),
|
||||
("Q0.12 AX community complexity", q_ax_community_complexity),
|
||||
# Δ1 – AX - DOM
|
||||
("Q1.1 AX node without visual", q_ax_nodes_without_visual),
|
||||
("Q1.2 Visual node without AX", q_visual_nodes_without_ax),
|
||||
|
|
@ -1120,41 +1507,10 @@ def _cli() -> None:
|
|||
else:
|
||||
issues = run_all_queries(HAG)
|
||||
|
||||
print_report(issues, verbose=args.verbose)
|
||||
#print_report(issues, verbose=args.verbose)
|
||||
|
||||
if args.report:
|
||||
save_report_json(issues, args.report)
|
||||
|
||||
|
||||
|
||||
"""
|
||||
###----- gia in def q_focus_order_vs_visual
|
||||
ax_order=_ax_reading_order(HAG)
|
||||
|
||||
ax_rank: dict[str, int] = {nid: i for i, (nid, _) in enumerate(ax_order)}
|
||||
print(f"ax_rank:{ax_rank}")
|
||||
|
||||
|
||||
FOCUSABLE_TABBLE_ROLES = {
|
||||
"link", "button", "textbox", "combobox", "listbox",
|
||||
"checkbox", "radio", "menuitem", "tab", "slider",
|
||||
"spinbutton", "searchbox", "switch",
|
||||
}
|
||||
focusable = [
|
||||
(nid, attrs) for nid, attrs in ax_order
|
||||
if str(attrs.get("role") or "").lower() in FOCUSABLE_TABBLE_ROLES
|
||||
or str(attrs.get("aria_focusable") or "") == "True"
|
||||
]
|
||||
print(f"focusable:{focusable}")
|
||||
|
||||
ax_rank_focusable: dict[str, int] = {nid: i for i, (nid, _) in enumerate(focusable)}
|
||||
print(f"ax_rank_focusable:{ax_rank_focusable}")
|
||||
|
||||
##------------
|
||||
"""
|
||||
dom_order=_dom_reading_order(HAG)
|
||||
dom_rank: dict[str, int] = {nid: i for i, (nid, _) in enumerate(dom_order)}
|
||||
print(f"dom_rank:{dom_rank}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ Il grafo usa nx.MultiDiGraph perché tra la stessa coppia di nodi possono esiste
|
|||
I prefissi dei nodi (dom:N, ax:N, vis:N) garantiscono che i tre namespace non collidano mai, anche quando i backend node ID si sovrappongono numericamente tra layers.
|
||||
Layer A — DOM (build_dom_layer): estrae l'albero completo via DOM.getDocument con depth: -1 e pierce: True (attraversa shadow DOM). Costruisce archi strutturali parent→child, archi label_for da <label for="X"> verso l'input corrispondente, e archi ARIA (aria-labelledby, aria-describedby) risolti per id HTML.
|
||||
Layer B — AX (build_ax_layer): invariato rispetto allo script precedente, ora lavora in parallelo agli altri due layer. Mantiene i nodi ignored (utili per Δ1 — confronto tra cosa il markup dichiara e cosa lo screen reader annuncia davvero).
|
||||
Layer C — Visual (build_visual_layer): chiama DOM.getBoxModel per ogni elemento DOM di tipo nodeType=1 (solo Element, non TextNode o Comment). Scarta elementi con area zero (display:none, collapsed). Calcola due attributi chiave: prominence (frazione della viewport occupata) e reading_rank (posizione nell'ordine visivo top→bottom, left→right). Aggiunge archi visual_overlap tra elementi sovrapposti e visual_sibling tra elementi allineati sulla stessa riga visiva.
|
||||
Layer C — Visual (build_visual_layer): chiama DOM.getBoxModel per ogni elemento DOM di tipo nodeType=1 (solo Element, non TextNode o Comment). [NB cambiato con nodeType=3 e altri elementi custom]. Scarta elementi con area zero (display:none, collapsed). Calcola due attributi chiave: prominence (frazione della viewport occupata) e reading_rank (posizione nell'ordine visivo top→bottom, left→right). Aggiunge archi visual_overlap tra elementi sovrapposti e visual_sibling tra elementi allineati sulla stessa riga visiva.
|
||||
Cross-edges (add_cross_edges): i tre layer condividono il backendNodeId come chiave universale — CDP lo espone sia nel DOM tree (backendNodeId) che nell'AX tree (backendDOMNodeId), quindi il join è diretto. I 93 cross-edges del test (39 dom→ax + 27 dom→visual + 27 ax→visual) sono esattamente i punti dove puoi misurare Δ1 e Δ2 discussi prima.
|
||||
"""
|
||||
|
||||
|
|
@ -70,9 +70,23 @@ def _free_port() -> int:
|
|||
return s.getsockname()[1]
|
||||
|
||||
|
||||
#def _attrs(**kw) -> dict:
|
||||
# """Return a dict with None / empty-string values removed."""
|
||||
# return {k: v for k, v in kw.items() if v is not None and v != ""}
|
||||
|
||||
def _attrs(**kw) -> dict:
|
||||
"""Return a dict with None / empty-string values removed."""
|
||||
return {k: v for k, v in kw.items() if v is not None and v != ""}
|
||||
"""Recursively strip None / empty string values from dicts and lists."""
|
||||
def clean(val):
|
||||
if isinstance(val, list):
|
||||
items = [clean(item) for item in val]
|
||||
return [item for item in items if item is not None and item != ""]
|
||||
if isinstance(val, dict):
|
||||
entries = {k: clean(v) for k, v in val.items()}
|
||||
return {k: v for k, v in entries.items() if v is not None and v != ""}
|
||||
return val
|
||||
|
||||
result = clean(kw)
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -125,6 +139,7 @@ def _flatten_dom_tree(raw_node: dict, result: Optional[dict] = None) -> dict:
|
|||
"attr_aria_labelledby": attrs.get("aria-labelledby"),
|
||||
"attr_aria_describedby": attrs.get("aria-describedby"),
|
||||
"attr_aria_hidden": attrs.get("aria-hidden"),
|
||||
"attr_aria_expanded": attrs.get("aria-expanded"),
|
||||
"attr_tabindex": attrs.get("tabindex"),
|
||||
"attr_href": attrs.get("href"),
|
||||
"attr_src": attrs.get("src"),
|
||||
|
|
@ -172,6 +187,7 @@ async def build_dom_layer(dom_flat: dict,cdp: CDPClient) -> tuple[nx.MultiDiGrap
|
|||
attr_focused = info.get("attr_focused"),
|
||||
aria_label = info.get("attr_aria_label"),
|
||||
aria_hidden = info.get("attr_aria_hidden"),
|
||||
aria_expanded = info.get("attr_aria_expanded"),
|
||||
aria_labelledby = info.get("attr_aria_labelledby"),
|
||||
aria_describedby = info.get("attr_aria_describedby"),
|
||||
tabindex = info.get("attr_tabindex"),
|
||||
|
|
@ -622,7 +638,7 @@ async def build_visual_layer(
|
|||
) -> tuple[nx.MultiDiGraph, dict[int, str]]:
|
||||
"""
|
||||
Build visual layout sub-graph by calling DOM.getBoxModel for every
|
||||
DOM element node (nodeType == 1).
|
||||
DOM element node (nodeType == 3).
|
||||
|
||||
Returns (graph, backendNodeId → graph_node_id mapping).
|
||||
"""
|
||||
|
|
@ -630,6 +646,7 @@ async def build_visual_layer(
|
|||
backend_to_gid: dict[int, str] = {}
|
||||
viewport_area = max(viewport_w * viewport_h, 1)
|
||||
|
||||
# consider only nodes that are likely to be visually relevant (headings, paragraphs, images, buttons, inputs, etc.)
|
||||
ui_target_nodes = {
|
||||
"h1", "h2", "h3", "h4", "h5", "h6", "p", "span", "a", "img", "svg",
|
||||
"video", "audio", "input", "textarea", "button", "select", "option",
|
||||
|
|
@ -637,7 +654,7 @@ async def build_visual_layer(
|
|||
"table", #"tr", "td", "th",
|
||||
"nav",
|
||||
"header", "footer",
|
||||
"details", "summary", "dialog", "iframe"
|
||||
"details", "summary", "dialog", "iframe","figure"
|
||||
}
|
||||
|
||||
# ARIA roles that transform generic elements (like <div>) into interactive ones
|
||||
|
|
@ -745,6 +762,7 @@ async def build_visual_layer(
|
|||
value = info.get("node_value").replace("#","") or None,
|
||||
outerHtml=node_outerHtml.get("outerHTML")[:100].replace("#","") or None,
|
||||
visibility=visibility,
|
||||
#node_type=info.get("node_type"),#is always 3 for visual layer, so not useful
|
||||
type= "visual_"+info.get("node_name").replace("#","")
|
||||
)
|
||||
G.add_node(gid, **node_attrs)
|
||||
|
|
|
|||
Loading…
Reference in New Issue