mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-10 12:10:53 +00:00
359 lines
8.2 KiB
Go
359 lines
8.2 KiB
Go
package browser
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/go-rod/rod/lib/proto"
|
|
)
|
|
|
|
// axValue extracts a string value from an AXValue.
|
|
// Ported from TS cdp.ts:170-190 (axValue function).
|
|
func axValue(v *proto.AccessibilityAXValue) string {
|
|
if v == nil {
|
|
return ""
|
|
}
|
|
// gson.JSON — call Str() for string, or String() for raw JSON
|
|
s := v.Value.Str()
|
|
if s != "" {
|
|
return s
|
|
}
|
|
// Try raw string representation for numbers/booleans
|
|
raw := v.Value.String()
|
|
if raw == "" || raw == "null" || raw == "\"\"" {
|
|
return ""
|
|
}
|
|
return raw
|
|
}
|
|
|
|
// axNodeTree is an internal tree node built from flat AX nodes.
|
|
type axNodeTree struct {
|
|
node *proto.AccessibilityAXNode
|
|
depth int
|
|
}
|
|
|
|
// buildAXTree converts flat CDP AX nodes into a tree structure.
|
|
// Ported from TS cdp.ts:192-249 (formatAriaSnapshot).
|
|
func buildAXTree(nodes []*proto.AccessibilityAXNode, limit int) []*axNodeTree {
|
|
if len(nodes) == 0 {
|
|
return nil
|
|
}
|
|
|
|
byID := make(map[proto.AccessibilityAXNodeID]*proto.AccessibilityAXNode, len(nodes))
|
|
for _, n := range nodes {
|
|
if n.NodeID != "" {
|
|
byID[n.NodeID] = n
|
|
}
|
|
}
|
|
|
|
// Find root: a node not referenced as a child by any other node
|
|
referenced := make(map[proto.AccessibilityAXNodeID]bool)
|
|
for _, n := range nodes {
|
|
for _, cid := range n.ChildIDs {
|
|
referenced[cid] = true
|
|
}
|
|
}
|
|
|
|
var root *proto.AccessibilityAXNode
|
|
for _, n := range nodes {
|
|
if n.NodeID != "" && !referenced[n.NodeID] {
|
|
root = n
|
|
break
|
|
}
|
|
}
|
|
if root == nil && len(nodes) > 0 {
|
|
root = nodes[0]
|
|
}
|
|
if root == nil || root.NodeID == "" {
|
|
return nil
|
|
}
|
|
|
|
// DFS traversal using explicit stack (matching TS behavior)
|
|
var result []*axNodeTree
|
|
type stackItem struct {
|
|
id proto.AccessibilityAXNodeID
|
|
depth int
|
|
}
|
|
stack := []stackItem{{id: root.NodeID, depth: 0}}
|
|
|
|
for len(stack) > 0 && len(result) < limit {
|
|
item := stack[len(stack)-1]
|
|
stack = stack[:len(stack)-1]
|
|
|
|
n, ok := byID[item.id]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
treeNode := &axNodeTree{
|
|
node: n,
|
|
depth: item.depth,
|
|
}
|
|
result = append(result, treeNode)
|
|
|
|
// Push children in reverse order so first child is processed first
|
|
children := n.ChildIDs
|
|
for i := len(children) - 1; i >= 0; i-- {
|
|
cid := children[i]
|
|
if _, exists := byID[cid]; exists {
|
|
stack = append(stack, stackItem{id: cid, depth: item.depth + 1})
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// roleNameTracker tracks role+name combinations for nth deduplication.
|
|
// Ported from TS pw-role-snapshot.ts:129-170.
|
|
type roleNameTracker struct {
|
|
counts map[string]int
|
|
refsByKey map[string][]string
|
|
}
|
|
|
|
func newRoleNameTracker() *roleNameTracker {
|
|
return &roleNameTracker{
|
|
counts: make(map[string]int),
|
|
refsByKey: make(map[string][]string),
|
|
}
|
|
}
|
|
|
|
func (t *roleNameTracker) key(role, name string) string {
|
|
return role + ":" + name
|
|
}
|
|
|
|
func (t *roleNameTracker) getNextIndex(role, name string) int {
|
|
k := t.key(role, name)
|
|
idx := t.counts[k]
|
|
t.counts[k] = idx + 1
|
|
return idx
|
|
}
|
|
|
|
func (t *roleNameTracker) trackRef(role, name, ref string) {
|
|
k := t.key(role, name)
|
|
t.refsByKey[k] = append(t.refsByKey[k], ref)
|
|
}
|
|
|
|
func (t *roleNameTracker) getDuplicateKeys() map[string]bool {
|
|
dups := make(map[string]bool)
|
|
for k, refs := range t.refsByKey {
|
|
if len(refs) > 1 {
|
|
dups[k] = true
|
|
}
|
|
}
|
|
return dups
|
|
}
|
|
|
|
// removeNthFromNonDuplicates cleans up nth=0 from refs that have no duplicates.
|
|
func removeNthFromNonDuplicates(refs map[string]RoleRef, tracker *roleNameTracker) {
|
|
dups := tracker.getDuplicateKeys()
|
|
for ref, data := range refs {
|
|
k := tracker.key(data.Role, data.Name)
|
|
if !dups[k] {
|
|
data.Nth = 0
|
|
refs[ref] = data
|
|
}
|
|
}
|
|
}
|
|
|
|
// FormatSnapshot converts raw CDP AX nodes into a text tree with refs.
|
|
// This is the core algorithm combining:
|
|
// - TS cdp.ts:192-249 (formatAriaSnapshot) — tree building
|
|
// - TS pw-role-snapshot.ts:207-267 (processLine) — role filtering + ref assignment
|
|
func FormatSnapshot(nodes []*proto.AccessibilityAXNode, opts SnapshotOptions) *SnapshotResult {
|
|
if opts.MaxChars == 0 {
|
|
opts.MaxChars = 8000
|
|
}
|
|
if opts.Limit == 0 {
|
|
opts.Limit = 500
|
|
}
|
|
|
|
treeNodes := buildAXTree(nodes, opts.Limit)
|
|
if len(treeNodes) == 0 {
|
|
return &SnapshotResult{
|
|
Snapshot: "(empty page)",
|
|
Refs: map[string]RoleRef{},
|
|
Stats: SnapshotStats{Lines: 1, Chars: 12},
|
|
}
|
|
}
|
|
|
|
refs := make(map[string]RoleRef)
|
|
tracker := newRoleNameTracker()
|
|
refCounter := 0
|
|
nextRef := func() string {
|
|
refCounter++
|
|
return fmt.Sprintf("e%d", refCounter)
|
|
}
|
|
|
|
var lines []string
|
|
interactiveCount := 0
|
|
|
|
for _, tn := range treeNodes {
|
|
role := strings.ToLower(axValue(tn.node.Role))
|
|
name := axValue(tn.node.Name)
|
|
value := axValue(tn.node.Value)
|
|
description := axValue(tn.node.Description)
|
|
|
|
// Skip empty/invisible roles
|
|
if role == "" || role == "none" || role == "unknown" {
|
|
if name == "" {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Skip low-value leaf roles that clutter the tree.
|
|
// statictext and inlinetextbox are internal text representation nodes.
|
|
if role == "statictext" || role == "inlinetextbox" {
|
|
continue
|
|
}
|
|
|
|
// Apply depth filter
|
|
if opts.MaxDepth > 0 && tn.depth > opts.MaxDepth {
|
|
continue
|
|
}
|
|
|
|
isInteractive := IsInteractive(role)
|
|
isContent := IsContent(role)
|
|
isStruct := IsStructural(role)
|
|
|
|
// Interactive-only mode: skip non-interactive
|
|
if opts.Interactive && !isInteractive {
|
|
continue
|
|
}
|
|
|
|
// Compact mode: skip unnamed structural elements
|
|
if opts.Compact && isStruct && name == "" {
|
|
continue
|
|
}
|
|
|
|
// Build the line
|
|
indent := strings.Repeat(" ", tn.depth)
|
|
line := indent + "- " + role
|
|
|
|
if name != "" {
|
|
line += fmt.Sprintf(" %q", name)
|
|
}
|
|
|
|
// Determine if this element should get a ref
|
|
shouldHaveRef := isInteractive || (isContent && name != "")
|
|
if shouldHaveRef {
|
|
ref := nextRef()
|
|
nth := tracker.getNextIndex(role, name)
|
|
tracker.trackRef(role, name, ref)
|
|
|
|
backendNodeID := int(tn.node.BackendDOMNodeID)
|
|
|
|
refs[ref] = RoleRef{
|
|
Role: role,
|
|
Name: name,
|
|
Nth: nth,
|
|
BackendNodeID: backendNodeID,
|
|
}
|
|
|
|
line += fmt.Sprintf(" [ref=%s]", ref)
|
|
if nth > 0 {
|
|
line += fmt.Sprintf(" [nth=%d]", nth)
|
|
}
|
|
|
|
if isInteractive {
|
|
interactiveCount++
|
|
}
|
|
}
|
|
|
|
// Append value/description if present
|
|
if value != "" {
|
|
line += fmt.Sprintf(": %q", value)
|
|
}
|
|
if description != "" {
|
|
line += fmt.Sprintf(" (%s)", description)
|
|
}
|
|
|
|
lines = append(lines, line)
|
|
}
|
|
|
|
// Remove nth from non-duplicate refs
|
|
removeNthFromNonDuplicates(refs, tracker)
|
|
|
|
snapshot := strings.Join(lines, "\n")
|
|
if len(lines) == 0 {
|
|
snapshot = "(empty page)"
|
|
}
|
|
|
|
// Compact mode: remove structural lines that have no ref descendants
|
|
if opts.Compact && len(lines) > 0 {
|
|
snapshot = compactTree(snapshot)
|
|
}
|
|
|
|
// Truncate if needed
|
|
truncated := false
|
|
if opts.MaxChars > 0 && len(snapshot) > opts.MaxChars {
|
|
snapshot = snapshot[:opts.MaxChars] + "\n[...TRUNCATED]"
|
|
truncated = true
|
|
}
|
|
|
|
return &SnapshotResult{
|
|
Snapshot: snapshot,
|
|
Refs: refs,
|
|
Truncated: truncated,
|
|
Stats: SnapshotStats{
|
|
Lines: len(lines),
|
|
Chars: len(snapshot),
|
|
Refs: len(refs),
|
|
Interactive: interactiveCount,
|
|
},
|
|
}
|
|
}
|
|
|
|
// compactTree removes structural lines that have no ref descendants.
|
|
// Ported from TS pw-role-snapshot.ts:172-205.
|
|
func compactTree(tree string) string {
|
|
lines := strings.Split(tree, "\n")
|
|
var result []string
|
|
|
|
for i, line := range lines {
|
|
// Keep lines with refs
|
|
if strings.Contains(line, "[ref=") {
|
|
result = append(result, line)
|
|
continue
|
|
}
|
|
// Keep lines with values (colon not at end)
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.Contains(trimmed, ":") && !strings.HasSuffix(trimmed, ":") {
|
|
result = append(result, line)
|
|
continue
|
|
}
|
|
|
|
// Check if any descendant has a ref
|
|
currentIndent := getIndentLevel(line)
|
|
hasRefDescendant := false
|
|
for j := i + 1; j < len(lines); j++ {
|
|
childIndent := getIndentLevel(lines[j])
|
|
if childIndent <= currentIndent {
|
|
break
|
|
}
|
|
if strings.Contains(lines[j], "[ref=") {
|
|
hasRefDescendant = true
|
|
break
|
|
}
|
|
}
|
|
if hasRefDescendant {
|
|
result = append(result, line)
|
|
}
|
|
}
|
|
|
|
return strings.Join(result, "\n")
|
|
}
|
|
|
|
// getIndentLevel returns the indentation level (number of 2-space indents).
|
|
func getIndentLevel(line string) int {
|
|
spaces := 0
|
|
for _, c := range line {
|
|
if c == ' ' {
|
|
spaces++
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
return spaces / 2
|
|
}
|