browser-use e graph

This commit is contained in:
Nicola Leonardi 2026-07-30 10:47:56 +02:00
parent cb9814b39a
commit bb18bc6e74
5 changed files with 1040 additions and 197 deletions

View File

@ -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())

View File

@ -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())

View File

@ -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())

View File

@ -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.6Q0.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__":

View File

@ -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)