跳到主要内容

动作体系与执行:从一个 ID 到 Playwright 落地

30 秒导读: 上一章讲了主循环怎么「决定下一步做什么」。本章只讲那之后的一小段路——LLM 最终吐出的一个动作(比如 {type: "click", id: "B1"}),是怎么被 Notte 类型化成一个强类型对象、把那个短短的 B1 解析回页面上真实的 DOM 元素、再翻译成一句 Playwright 调用page.locator(...).click())可靠落地的。

本章承接 02-agent-loop.md 结尾的「单步执行」——主循环把动作交给 session.execute 之后,控制权就进入本章描述的这条流水线。凭据替换(把占位符换成真密码)和结构化抓取属于 04-enterprise.md,本章只在流程图上标出它们的位置,不展开。


1. 这是什么(零基础也能懂)

先建立一个画面

想象 LLM 看着页面,最后只说了一句话:

"点 id 为 B1 的那个元素。"

不知道、也不关心 B1 在真实 HTML 里对应哪个 <button>、CSS 选择器长什么样、是不是藏在 shadow DOM 里。它只知道一个短 ID——因为感知层在把页面喂给它时,就是用这种「带 ID 的动作空间」把复杂 DOM 压缩成一张清单的。

于是就有了一个翻译问题

LLM 说的 浏览器能听懂的
───────────── ──────────────────────────
{type:"click", id:"B1"} → page.locator("xpath=//button[...]").click()

本章讲的就是这台翻译机。 它要干三件事:

步骤白话负责的模块
① 类型化把一坨 JSON 变成一个强类型、可校验的动作对象notte-core/actions/actions.py
② 解析把短 ID B1 查回真实元素的选择器notte-browser/resolution.py
③ 执行按动作种类分派到对应的 Playwright 调用notte-browser/controller.py

一句话直觉

把它想成餐厅点单:LLM 是顾客,只说「我要 3 号套餐」(id);resolution.py 是收银台,把「3 号」查回后厨真正要做的菜谱(selectors);controller.py 是厨房,按菜谱(动作类型)拿对应的锅具(Playwright API)真正做出来。顾客永远不碰锅。


2. 顶层全景(它大概怎么转)

一张图看懂整条流水线

下面这张图从上到下就是一次 session.execute(...) 的完整生命周期。怎么读:从上往下是时间顺序,每个方框标了负责它的文件。

LLM 输出 / SDK 调用
{type:"click", id:"B1", ...}


┌─────────────────────────────┐
│ parse_action │ 把 JSON / kwargs 变成
│ (session.py) │ 强类型 BaseAction 子类
└─────────────────────────────┘
│ ClickAction(id="B1", selector=None)

┌─────────────────────────────┐
│ ① 解析 NodeResolutionPipe │ 用 snapshot 的 selector_map
│ .forward (resolution.py) │ 把 id="B1" 查回 selectors
└─────────────────────────────┘
│ id 不在页面 → InvalidActionError
│ 命中 → action.selector = NodeSelectors(...)

┌─────────────────────────────┐
│ (凭据替换 · 见第4章) │ fill 类动作才走这步
└─────────────────────────────┘


┌─────────────────────────────┐
│ ③ 执行 BrowserController │ match/case 按动作种类分派
│ .execute (controller.py) │
└──────────────┬──────────────┘
┌─────────┴──────────┐
▼ ▼
interaction 动作 browser 动作
locate_element → goto / scroll /
locator.click() wait / press_key ...
(dom/locate.py) (直接调 page.*)


┌─────────────────────────────┐
│ 包成 ExecutionResult │ success / message / data
│ (session.py) │ → 回到主循环给裁判验证
└─────────────────────────────┘

部件一句话职责

部件干什么在哪个文件
BaseAction 及其子类全部动作的强类型定义 + 自注册表notte-core/src/notte_core/actions/actions.py
NodeResolutionPipeid 解析成 selectors(或报错)notte-browser/src/notte_browser/resolution.py
BrowserController按动作种类 match/case 分派到 Playwrightnotte-browser/src/notte_browser/controller.py
locate_elementNodeSelectors 落成一个 Playwright Locatornotte-browser/src/notte_browser/dom/locate.py
NotteSession.aexecute串起解析→执行→打包,产出 ExecutionResultnotte-browser/src/notte_browser/session.py

3. Action 类型体系:一坨 JSON 如何变成强类型对象

本节讲:Notte 把「所有可能的动作」建成一棵 pydantic 类树,并让每个动作类在定义时就自己注册进一张表

3.1 三大族:按「和页面元素的关系」分类

所有动作的根是 BaseActionactions.py:60),它下面按「这个动作需不需要落到某个具体元素上」分成三族:

基类需要页面元素吗代表动作关键字段
浏览器动作BrowserAction (:151)不需要(针对整页/浏览器)GotoActionScrollUpActionWaitActionPressKeyActionCompletionActionCaptchaSolveAction各自的参数(url / time_ms / key…)
工具动作ToolAction (:747)不需要(读数据/跑 JS)ScrapeActionEvaluateJsActionEmailReadActionSmsReadActioninstructions / code / timedelta
交互动作InteractionAction (:983)需要(必须落到某个元素)ClickActionFillActionSelectDropdownOptionActionCheckActionUploadFileActionMultiFactorFillActionFallbackFillActionDownloadFileActionid + selector + value

注意一个微妙之处:ToolAction 在类型上其实继承自 BrowserActionactions.py:747 写着 class ToolAction(BrowserAction, ...)),但它的语义是「取数据/跑脚本」而非「操作浏览器」,执行时会被单独分流(见 §6)。教学上把它当第三族理解更清楚。

3.2 交互动作为什么特殊:它带 idselector

只有 InteractionAction 这一族带这两个字段(actions.py:984-985):

class InteractionAction(BaseAction, metaclass=ABCMeta):
id: str = Field(default="") # LLM 说的那个短 ID
selector: str | NodeSelectors | None = Field(default=None) # 解析后填进来
  • id:LLM 从动作空间里挑的那个短标识(B1I3…)。
  • selector:一开始是 None;等 §5 的解析跑完,才会被填成一个真正的 NodeSelectors

它还有个前置校验器 validate_id_and_selectoractions.py:1009):idselector 不能同时为空,否则根本无从定位元素——这是「二选一必须有一个」的守门规则。

3.3 自注册:动作类一被定义就进表

这是整个类型体系最巧妙的一处。每个基类都用 pydantic/Python 的 __init_subclass__ 钩子,让子类一被 import、定义完成,就自动把自己塞进一张注册表

# actions.py:79 —— 示意,抓核心逻辑
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
if not inspect.isabstract(cls):
name = cls.name() # ClickAction → "click"(CamelCase 转 snake)
cls.ACTION_REGISTRY[name] = cls # 登记进表:{"click": ClickAction, ...}

于是产生了三张平行的注册表:

注册表定义处装了谁
ACTION_REGISTRYactions.py:77所有非抽象动作
BROWSER_ACTION_REGISTRYactions.py:156所有 BrowserAction 子类
INTERACTION_ACTION_REGISTRYactions.py:992所有 InteractionAction 子类

这三张表就是「类型字符串 → Python 类」的字典。 当 JSON 里写着 type: "click",系统就能靠 "click" → ClickAction 反查出该实例化哪个类。类名到字符串的转换由 name()actions.py:122)用一条正则把 ClickAction 变成 click(去掉 _action 后缀)。

最后,三张表被 reduce(operator.or_, ...) 折叠成一个discriminator="type" 的可辨识联合类型actions.py:1326-1332)。pydantic 拿到一坨 JSON,就靠 type 字段自动选中对应的类去校验——这就是「一坨 JSON → 强类型对象」的魔法所在。


4. 交互动作全家福(一张表看清)

本节讲:InteractionAction 这一族里到底有哪些动作、各带什么字段、执行时对应什么 Playwright 行为。这是本章后面所有内容的「花名册」。

动作类type 字符串独有字段执行时大致做什么定义处
ClickActionclick——先判元素是否 disabled,再 locator.click()actions.py:1099
FillActionfillvalueclear_before_filllocator.fill(value);含缩进则走剪贴板粘贴actions.py:1119
MultiFactorFillActionmulti_factor_fillvalue点一下,然后逐字符 keyboard.press(专填 OTP)actions.py:1148
FallbackFillActionfallback_fillvalue点一下,press_sequentially 逐字慢打(兜底)actions.py:1175
CheckActioncheckvalue: boollocator.check() / .uncheck()actions.py:1202
SelectDropdownOptionActionselect_dropdown_optionvalue原生 <select>select_option,否则点开actions.py:1236
UploadFileActionupload_filefile_path探测 file chooser 或 set_input_filesactions.py:1270
DownloadFileActiondownload_file——拦截下载 / 抓 blob 字节actions.py:1295

这几个 fill 类动作看起来重复,但对应的是三档递进的容错策略:正常 fill → 慢速逐字 fallback_fill → 专治 OTP 的 multi_factor_fill。填不进去时逐级降级,这也是 browser-agent 常见的「一个语义、多种落地」模式。


5. 从 ID 到选择器:NodeResolutionPipe.forward

本节讲:执行之前那关键的一步——把 action.id(一个短字符串)查回页面上真实元素的 NodeSelectors。这是「LLM 的话」和「浏览器的现实」之间的接缝。

5.1 要解决的小问题

LLM 给的是 id="B1",但 Playwright 只认选择器(CSS / XPath / Playwright 内部选择器)。中间缺的这一环,就靠感知层拍快照时建立的映射来补——每个交互元素在快照里都带着算好的选择器。

5.2 思路:拿 snapshot 建一张「id → 节点」的表,再查

NodeResolutionPipe.forwardresolution.py:19)的核心逻辑:

# resolution.py:38 —— 示意,抓主干
# 1. 用当前快照的所有交互节点建一张查找表:id → InteractionDomNode
selector_map = {inode.id: inode for inode in snapshot.interaction_nodes()}

# 2. id 不在表里 → 说明 LLM 引用了页面上不存在的元素,直接报错
if action.id not in selector_map:
raise InvalidActionError(action_id=action.id,
reason=f"action '{action.id}' not found in page context.")

# 3. 命中 → 取该节点算好的选择器,填回 action
node = selector_map[action.id]
action.selector = await NodeResolutionPipe.resolve_selectors(node)
action.text_label = node.text # 顺手把可读文本也带上,用于日志/裁判

重点看三处: 表是临时用当前 snapshot 现建的(页面变了,表就变);id 查不到就是 InvalidActionError(防止 LLM 幻觉一个不存在的元素);命中后 selectors 来自节点的 computed_attributes.selectors——这是感知阶段就算好、存好的。

5.3 三条快捷路径(在建表之前就短路返回)

forward 开头有几个 early-return,不是每次都走建表(resolution.py:24-35):

action 是 BrowserAction? → 无需元素,原样返回(goto/scroll 等)
action 不是 InteractionAction? → 报错(未知动作类型)
action.selector 已经有了? → SDK 直接传了 selector,跳过解析
snapshot 是 None? → NoSnapshotObservedError(还没 observe 过)

第三条很实用:SDK 用户可以绕过 ID 机制、直接传一个 Playwright selector(见 05-sdk-deployment.md),这时就没有「查表」这一步。

5.4 坑:shadow DOM 元素要特殊拼选择器

如果目标元素藏在 shadow root 里,普通 XPath 穿不进去。resolve_selectorsresolution.py:47)会检测 selectors.in_shadow_root,命中时改调 selectors_through_shadow_domdom/locate.py:58)。

它的做法是从目标节点一路往上走到根,在每个 shadow 边界把 XPath 用 >> 拼接(Playwright 的 shadow-piercing 语法):

# dom/locate.py:87 —— 示意
shadow_locator = " >> ".join(xpaths[::-1]) # 形如 "xpath=//x >> xpath=//y >> ..."

这样得到的组合选择器能一层层穿过 shadow root 定位到真正的元素。普通元素则不走这段,直接用现成的 xpath_selector


6. 执行分派:BrowserController 的 match/case

本节讲:解析完拿到 selectors 后,controller.py 怎么按动作种类,把它翻译成一句具体的 Playwright 调用。

6.1 两级分派

执行入口是 BrowserController.executecontroller.py:482),它先做粗分controller.py:491):

execute(action)
├─ InteractionAction → execute_interaction_action(...) # 落到元素
├─ CompletionAction → 只记日志(终止信号,不碰浏览器)
├─ ScrapeAction → 跳过(抓取在 session 层做,见第4章)
├─ EvaluateJsAction → 跳过(在 session 层做)
├─ HelpAction → 返回 False(请求澄清,无副作用)
└─ BrowserAction → execute_browser_action(...) # 操作整页

CompletionActionScrapeActionEvaluateJsActionHelpAction 虽然类型上是 BrowserAction,但不该在 controller 里真正执行——它们要么是控制信号,要么在更上层的 session 里处理。controller 对它们只是记个日志放行。

6.2 浏览器动作:直接调 page.*

execute_browser_actioncontroller.py:139)是一个大 match/case,每个分支就是一两句 Playwright:

动作落地调用
GotoAction(url)window.goto(url):148
GotoNewTabAction(url)context.new_page()goto:150
SwitchTabAction(idx)switch_tab(...)bring_to_front():154
WaitAction(time_ms)page.wait_for_timeout(time_ms):158
PressKeyAction(key)page.keyboard.press(key):167
ScrollUp/DownActionpage.mouse.wheel(...):169

滚动那段有个值得学的细节(controller.py:169-196):它先记下 window.scrollY,滚完再对比,如果位置没变就抛 ScrollActionFailedError。这是为了不让 agent 误以为「我滚动了」而其实页面到底了——把「静默失败」变成「显式失败」,好让裁判和主循环知道。

6.3 交互动作:先定位,再按种类操作

execute_interaction_actioncontroller.py:201)的骨架是:

# controller.py:214 —— 示意,抓主干
locator = await locate_element(window.page, action.selectors) # ① 拿到 Playwright Locator
original_url = window.page.url # ② 记下当前 URL(后面判导航)

match action:
case ClickAction(): # 先查 disabled,再 click,失败回退 JS click
...
case FillAction(value=value): # 普通 fill;含 Tab 缩进则走剪贴板粘贴
...
case CheckAction(value=value): # value 决定 check / uncheck
...
case SelectDropdownOptionAction(): # 原生 select 走 select_option,否则点开
...
case UploadFileAction(): # file chooser / set_input_files
...

几个各动作的落地要点:

  • Clickcontroller.py:223):先用一段 JS 沿 DOM 向上遍历判断元素(含祖先/shadow host)是否 disabledaria-disabled,是就直接报错;正常 locator.click() 若超时,回退到 locator.evaluate("el => el.click()") 的 JS 点击。这是「原生点击失败就退而求其次用 JS 触发」的经典兜底。
  • Fillcontroller.py:256):一般走 locator.fill(value);但若文本含 Tab 缩进(比如往代码框里贴代码),fill 会丢格式,于是改用一段 JS 构造 ClipboardEvent('paste') 模拟粘贴,绕开 fill 的规范化。
  • MultiFactorFillcontroller.py:290):点一下输入框,然后逐个字符 keyboard.press,每字符间隔 100ms——因为 OTP 输入框常是「一格一个数字」的分离式,整体 fill 塞不进去。
  • SelectDropdowncontroller.py:306):先 evaluate("el => el.tagName") 看是不是原生 <select>;是就 select_option,不是(自绘下拉)就只能 click() 把它点开,让后续步骤再选。

6.4 点击可能开新标签页:_can_create_tab

有些动作会意外开新标签页<a target="_blank"> 的点击、goto、按 Enter 提交表单等)。_can_create_tabcontroller.py:109)先判定动作有没有可能开新标签:

# controller.py:114 —— 示意
match action:
case ClickAction() | GotoAction() | GotoNewTabAction() | PressKeyAction():
return True # 这几类可能开新 tab
case _:
return False

execute 结尾会用它做优化(controller.py:516):只有当动作可能开 tab 时,才在执行前后对比 len(context.pages);发现真的多了一个页面,就 switch_tab(window, -1) 自动切到最新那个标签页。这样 agent 点了个「在新窗口打开」的链接后,视角会自动跟过去,而不是傻留在原页面。


7. 定位细节:NodeSelectorslocate_element

本节讲:selectors 到底是什么结构,以及 locate_element 怎么把它变成一个真正能点的 Locator

7.1 NodeSelectors:一个元素的多种「地址」

一个元素不止一种选择器写法。NodeSelectorsdom_tree.py:54)同时存了好几种:

字段是什么
playwright_selectorPlaywright 原生/内部选择器(最优先)
css_selectorCSS 路径
xpath_selectorXPath
in_iframe / iframe_parent_css_selectors是否在 iframe 里、父 iframe 的路径
in_shadow_root是否在 shadow DOM 里

selectors() 方法(dom_tree.py:64)把它们按优先级排成一个候选列表:playwright_selectorcss=...xpath=...

7.2 locate_element:多候选逐个试,命中唯一才用

locate_elementdom/locate.py:25)把 NodeSelectors 落成一个 Locator

# dom/locate.py —— 示意,抓策略
if selectors.playwright_selector: # 有原生选择器就直接用(:28)
return page.locator(selectors.playwright_selector)

if selectors.in_iframe: # 在 iframe 里先逐层钻进 frame(:31)
frame = await locale_element_in_iframes(page, selectors)

for selector in selectors.selectors(): # 否则候选逐个试(:48)
locator = frame.locator(selector)
count = await locator.count()
if count == 1: # 只有「唯一命中」才敢用
return locator

重点看那个 count == 1 只有当一个选择器精确命中唯一元素时才返回它。命中 0 个就换下一个候选,命中多个就记 debug 日志跳过——避免误点到同名的另一个元素。这是「宁可换选择器,也不点错」的稳健策略。

7.3 XPath → CSS 的降级换算

dom/csspaths.py 里的 xpath_to_css_path:5)和 build_csspath:53)负责把 XPath 转成 CSS 选择器,并只保留一组稳定的安全属性idnametypearia-labeldata-testid 等,csspaths.py:88),跳过易变的动态属性。这让生成的选择器更抗页面刷新——注释里也标明这些函数改编自 browser-use。

7.4 原生下拉 vs 自绘下拉

dom/dropdown_menu.py 专门处理 <select>

  • dropdown_menu_options:23):逐个 framedocument.evaluate(xpath) 找到 <select>,把它的 options 列出来(文本用 json.dumps 编码,保证后续按文本精确匹配)。
  • select_dropdown_option:75):先校验 tagName 确实是 select,再 frame.locator(...).select_option(label=text) 按可见文本选中。

原生 <select> 走这条精确路径;而自绘下拉(一堆 <div> 拼的假下拉)没有 options,controller 里就只能 click() 把它点开,让 agent 下一步再从展开的选项里点(见 §6.3)。


8. 收口:session.aexecute 把结果包成 ExecutionResult

本节讲:前面所有步骤是被谁串起来的,以及执行结果最终以什么形态交回主循环。

8.1 一个方法,几十个重载

NotteSession.aexecutesession.py 里有几十个 @overload 签名session.py:555 起),每个对应一种动作的字段字典(GotoActionDictClickActionDict…)。这纯粹是给类型检查器和 IDE 用的打字提示——让你写 session.execute(type="goto", url=...) 时能自动补全、静态校验。真正的实现只有一个:_aexecute_implsession.py:684)。

8.2 实现主干:解析 → 执行 → 打包

_aexecute_implsession.py:684)就是把本章前几节按顺序串起来:

# session.py:684 —— 示意,抓三步骨架
step_action = parse_action(action, **kwargs) # 0. JSON/kwargs → 强类型动作

with TimedSpan.capture() as span:
try:
# Step 1:解析 id → selectors(本章 §5)
resolved = await NodeResolutionPipe.forward(step_action, self._snapshot)

# Step 2:按种类执行
match resolved:
case ScrapeAction(): scraped_data = await self._ascrape(...) # 抓取,第4章
case EvaluateJsAction(): ... # 跑 JS
case ToolAction(): ... # 走已注册的工具
case _: # 其余交给 controller
resolved = await self._action_with_vault(resolved) # 凭据替换,第4章
success = await self.controller.execute(self.window, resolved, self._snapshot)
except InvalidActionError as e: # id 找不到等 → 记成失败,不崩
success, message, exception = False, e.dev_message, e

几处关键: 抓取(ScrapeAction)、跑 JS(EvaluateJsAction)、工具(ToolAction)在 session 层就地处理,不进 controller;真正的浏览器动作才落到 controller.execute(§6)。填值类动作在执行前先过 _action_with_vault 做凭据替换(第4章)。

8.3 结果打包与 raise_on_failure

无论成功失败,最后都统一包成一个 ExecutionResult(定义在 observation.py:318,产出在 session.py:849):

字段含义
action解析后的动作对象
success成没成
message人可读的执行说明(来自 action.execution_message())或错误信息
data抓取/JS 的返回数据(DataSpace),交互动作为 None
exception失败时的异常对象

ExecutionResult 会被 appendtrajectory(轨迹),并顺手补拍一张执行后的截图——这些正是第2章里裁判验证要用的素材。

最后有个 raise_on_failure 开关(session.py:867)决定失败时的行为:

  • True(SDK/脚本默认):动作一失败就抛异常,让调用方立刻知道——适合确定性脚本,「错了就停」。
  • False(Agent 循环用):失败不抛,把 ExecutionResult(success=False, message=...) 原样返回——让 LLM 读到错误信息,自己想办法重试或换招

这个开关是「脚本模式」和「agent 模式」的分水岭:脚本要快速失败,agent 要把失败当反馈继续推理。


9. 一个完整例子:Click(id="B1") 走一遍

把本章串起来。假设 LLM 输出 {type: "click", id: "B1"}

1. parse_action → ClickAction(id="B1", selector=None)
(靠 discriminator="type" 选中 ClickAction 类)

2. NodeResolutionPipe.forward
├─ 是 InteractionAction,selector 还是 None → 需要解析
├─ selector_map = {n.id: n for n in snapshot.interaction_nodes()}
├─ "B1" 在表里吗? 不在 → raise InvalidActionError(结束)
└─ 在 → node = selector_map["B1"]
action.selector = node.computed_attributes.selectors
action.text_label = node.text # 比如 "Submit"

3. controller.execute → 是 InteractionAction → execute_interaction_action
├─ locator = locate_element(page, action.selectors)
│ 逐候选试 selector,count==1 的那个胜出
├─ match ClickAction:
│ ├─ 用 JS 检查 disabled? 是 → 报错
│ └─ locator.click() (超时则回退 JS click)
└─ 点击可能开新 tab(_can_create_tab==True)→ 对比页面数,多了就切过去

4. 打包 ExecutionResult(action=ClickAction, success=True,
message="Clicked on the element with text label: Submit")
→ append 进 trajectory → 回到主循环给裁判验证

一句话总结: LLM 只需说出 id,其余全由这条流水线兜底——查表解析、多候选定位、按类型分派、失败回退、结果打包,把一个短字符串稳稳变成一次真实的浏览器点击。


10. 边界与局限(诚实)

  • 解析强依赖当前 snapshot。 selector_map 是用当前快照现建的(resolution.py:38)。如果页面在 observe 之后、execute 之前变了(异步渲染、跳转),id 可能查不到或指向旧元素——这也是为什么主循环是「感知→单步执行」而非「感知→批量执行」。
  • 自绘下拉只能点开,不能一步选中。 非原生 <select> 的下拉,controller 只 click() 展开(controller.py:313),真正选哪一项要靠 agent 下一轮再感知、再点。
  • Upload/Download 依赖 storage。 文件动作需要预先配置 storagecontroller.py:318),否则直接 NoStorageObjectProvidedError
  • count > 1 的候选被跳过。 locate_element 只接受唯一命中(dom/locate.py:50);如果所有候选都命中多个,会一路走到最后抛 InvalidLocatorRuntimeError——宁可失败也不乱点。
  • 凭据与抓取不在本章。 填值的占位符替换、结构化数据抽取见 04-enterprise.md

11. 横向对比(同 shelf 兄弟)

关切Notte 的做法对比
LLM 怎么指认元素感知期给每个元素发短 id,执行期用 selector_map 查回browser-use 类似地给 DOM 元素编号;视觉系(如 Skyvern)则可能直接吐坐标
定位稳健性多候选选择器逐个试、只认唯一命中单一选择器方案更快但更脆
失败反馈raise_on_failure 开关:脚本抛异常、agent 收 ExecutionResult体现「同一执行核,两种消费模式」

同 shelf 的其它 browser-agent 参考总库 doc(跨库原理对比)以及兄弟子库文档。


12. 代码地图(导航索引)

主题文件路径符号名
动作根类 + 三张注册表packages/notte-core/src/notte_core/actions/actions.pyBaseActionACTION_REGISTRY__init_subclass__name
浏览器动作族packages/notte-core/src/notte_core/actions/actions.pyBrowserActionGotoActionScrollUpActionWaitActionCompletionActionCaptchaSolveAction
工具动作族packages/notte-core/src/notte_core/actions/actions.pyToolActionScrapeActionEvaluateJsActionEmailReadAction
交互动作族 + id/selectorpackages/notte-core/src/notte_core/actions/actions.pyInteractionActionClickActionFillActionSelectDropdownOptionActionUploadFileActionvalidate_id_and_selector
可辨识联合packages/notte-core/src/notte_core/actions/actions.pyActionUnionInteractionActionUnionActionList
id → 选择器解析packages/notte-browser/src/notte_browser/resolution.pyNodeResolutionPipe.forwardresolve_selectors
执行分派packages/notte-browser/src/notte_browser/controller.pyBrowserController.executeexecute_browser_actionexecute_interaction_action_can_create_tabswitch_tab
元素定位packages/notte-browser/src/notte_browser/dom/locate.pylocate_elementselectors_through_shadow_domlocate_file_upload_element
XPath→CSS 换算packages/notte-browser/src/notte_browser/dom/csspaths.pyxpath_to_css_pathbuild_csspath
下拉处理packages/notte-browser/src/notte_browser/dom/dropdown_menu.pydropdown_menu_optionsselect_dropdown_option
选择器数据结构packages/notte-core/src/notte_core/browser/dom_tree.pyNodeSelectorsNodeSelectors.selectorsfrom_unique_selector
执行收口packages/notte-browser/src/notte_browser/session.pyNotteSession.aexecute_aexecute_impl
执行结果packages/notte-core/src/notte_core/browser/observation.pyExecutionResult

相关章节:index.md(总览)· 01-perception.md(带 ID 的动作空间从哪来)· 02-agent-loop.md(谁在调用本章的执行)· 04-enterprise.md(凭据替换 · 结构化抓取)· 05-sdk-deployment.md(直接传 selector 绕过 ID)