okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
4ad84fd
main
1"""Plain-data observation schemas, selection, and rendering. No browser I/O here.""" 2 3from __future__import annotations 4 5import json 6from typing import Any ,NotRequired ,TypedDict 7 8from .errors import SelectionError 9 10 11class Node (TypedDict ): 12id :str 13ref :str | None 14role :str 15name :str 16frame_id :str 17parent :str | None 18depth :int 19value :NotRequired [Any ] 20href :NotRequired [str ] 21disabled :NotRequired [bool ] 22checked :NotRequired [Any ] 23selected :NotRequired [bool ] 24expanded :NotRequired [bool ] 25readonly :NotRequired [bool ] 26focused :NotRequired [bool ] 27required :NotRequired [bool ] 28level :NotRequired [int ] 29attributes :NotRequired [dict [str ,str ]] 30name_truncated :NotRequired [bool ] 31 32 33class Observation (TypedDict ): 34snapshot_id :str 35target_id :str 36url :str 37title :str 38captured_at :str 39capture_ms :float 40nodes :list [Node ] 41frames :list [dict [str ,Any ]] 42coverage :dict [str ,Any ] 43 44 45# Deliberately exclude value, style, inline handlers and arbitrary data attributes. 46IDENTITY_ATTRIBUTES = ( 47"id" , 48"name" , 49"type" , 50"href" , 51"role" , 52"aria-label" , 53"data-testid" , 54) 55 56 57def attributes_from_snapshot (data :dict [str ,Any ])-> dict [int ,dict [str ,str ]]: 58strings = data .get ("strings" , []) 59result = {} 60for document in data .get ("documents" , []): 61nodes = document .get ("nodes" , {}) 62for backend_id ,attrs in zip ( 63nodes .get ("backendNodeId" , []),nodes .get ("attributes" , []) 64 ): 65pairs = { 66strings [attrs [i ]]:strings [attrs [i + 1 ]] 67for i in range (0 ,len (attrs ),2 ) 68 } 69result [backend_id ]= { 70k :pairs [k ]for k in IDENTITY_ATTRIBUTES if k in pairs 71 } 72return result 73 74 75def attributes_from_node (node :dict [str ,Any ])-> dict [str ,str ]: 76flat = node .get ("attributes" , []) 77attrs = dict (zip (flat [::2 ],flat [1 ::2 ])) 78return {k :attrs [k ]for k in IDENTITY_ATTRIBUTES if k in attrs } 79 80 81def ax_properties (node :dict [str ,Any ])-> dict [str ,Any ]: 82return { 83p ["name" ]:p .get ("value" , {}).get ("value" )for p in node .get ("properties" , []) 84 } 85 86 87def walk_ax ( 88nodes :list [dict [str ,Any ]], 89)-> list [tuple [dict [str ,Any ],str | None ,int ]]: 90"""Reorder Chrome's breadth-first response into reading order, keeping context.""" 91by_id = {n ["nodeId" ]:n for n in nodes } 92roots = [n for n in nodes if n .get ("parentId" )not in by_id ] 93stack = [(n ,None ,0 )for n in reversed (roots )] 94seen = set () 95result = [] 96while stack : 97node ,parent ,depth = stack .pop () 98key = node ["nodeId" ] 99if key in seen : 100continue 101seen .add (key ) 102role = node .get ("role" , {}).get ("value" ,"" ) 103name = node .get ("name" , {}).get ("value" ,"" ) 104keep = not node .get ("ignored" )and role != "InlineTextBox" 105if role in ("none" ,"generic" ,"LabelText" )and not name : 106keep = False 107if keep : 108result .append ((node ,parent ,depth )) 109for child in reversed (node .get ("childIds" , [])): 110if child in by_id : 111stack .append ((by_id [child ],key if keep else parent ,depth + int (keep ))) 112return result 113 114 115def find ( 116observation :Observation , 117* , 118role :str | None = None , 119name :str | None = None , 120text :str | None = None , 121frame_id :str | None = None , 122exact :bool = True , 123** fields :Any , 124)-> list [Node ]: 125"""Pure selection. Exact names are case-sensitive; text is a substring filter.""" 126result = [] 127for node in observation ["nodes" ]: 128if role is not None and node ["role" ]!= role : 129continue 130if frame_id is not None and node ["frame_id" ]!= frame_id : 131continue 132if name is not None and not ( 133node ["name" ]== name if exact else name in node ["name" ] 134 ): 135continue 136if ( 137text is not None 138and text not in node ["name" ] 139and text not in str (node .get ("value" ,"" )) 140 ): 141continue 142if any (node .get (k )!= v for k ,v in fields .items ()): 143continue 144result .append (node ) 145return result 146 147 148def one (observation :Observation ,** query :Any )-> Node : 149"""Require exactly one match; never choose the first ambiguous element.""" 150matches = find (observation ,** query ) 151if len (matches )!= 1 : 152raise SelectionError ( 153f"Expected one node, found { len ( matches ) } ." , 154query = query , 155matches = [ 156 { 157"ref" :n ["ref" ], 158"role" :n ["role" ], 159"name" :n ["name" ], 160"frame_id" :n ["frame_id" ], 161 } 162for n in matches [:10 ] 163 ], 164observation_complete = observation ["coverage" ].get ("complete" ,False ), 165 ) 166return matches [0 ] 167 168 169def render (observation :Observation ,* ,max_chars :int = 8000 )-> str : 170"""Bounded, escaped text view. The input observation is never changed.""" 171if max_chars < 128 : 172raise ValueError ("max_chars must be at least 128" ) 173 174def quote (value :Any )-> str : 175return json .dumps (value ,ensure_ascii = False ) 176 177lines = [f"Snapshot { observation [ 'snapshot_id' ] } | { quote ( observation [ 'url' ]) } " ] 178if not observation ["coverage" ].get ("complete" ): 179lines .append ( 180"COVERAGE INCOMPLETE: " + quote (observation ["coverage" ].get ("warnings" , [])) 181 ) 182by_id = {n ["id" ]:n for n in observation ["nodes" ]} 183for node in observation ["nodes" ]: 184parent = by_id .get (node ["parent" ]) 185if node ["role" ]== "text" and parent and parent ["name" ]== node ["name" ]: 186continue 187label = f"[ { node [ 'ref' ] } ]" if node ["ref" ]else "[-]" 188line = f" { ' ' * min ( node [ 'depth' ], 5 ) } { label } { node [ 'role' ] } { quote ( node [ 'name' ]) } " 189for key in ( 190"value" , 191"href" , 192"disabled" , 193"checked" , 194"selected" , 195"expanded" , 196"required" , 197 ): 198if key in node : 199line += f" { key } = { quote ( node [ key ]) } " 200if node .get ("name_truncated" ): 201line += " [name truncated]" 202if len (observation ["frames" ])> 1 : 203line += f" frame= { node [ 'frame_id' ] } " 204lines .append (line ) 205text = "\n" .join (lines ) 206if len (text )<= max_chars : 207return text 208suffix = "\n[RENDER TRUNCATED; structured observation is unchanged]" 209return text [:max_chars - len (suffix )]+ suffix