OpenAdapt 源码拆解:录制一次,如何实现确定性回放

OpenAdapt 源码拆解:录制一次,如何实现确定性回放

OpenAdapt 是一款开源 RPA(Robotic Process Automation)工具,核心理念是"录制一次,确定性回放"。

本文基于 OpenAdapt v1.25.1 代码库进行源码级分析,涉及约 28,000 行 Python 代码,重点拆解它的使用流程、后端体系、录制---编译---回放机制,以及当前版本在动态页面、数据提取和调试方面的局限。

一、核心工作流:录制、编译、回放

OpenAdapt 的主流程可以概括为:

text 复制代码
record → compile → replay

此外,项目还提供批量循环、故障修复、认证检查、性能测试和 Bundle 导出等命令。

1. 录制:record

录制阶段启动一个带浏览器或桌面环境的会话,自动捕获点击、输入、按键、滚动和拖拽等操作。

bash 复制代码
# 最简方式:打开指定 URL 的浏览器并开始录制
python -m openadapt_flow record --url https://example.com

# 指定输出目录和名称
python -m openadapt_flow record --url https://example.com \
    --out my_recordings/ --name "登录流程"

# 桌面端录制(Windows 原生应用)
python -m openadapt_flow record --backend windows --agent-url http://localhost:8080 \
    --identifier 0,0,1920,1080

# RDP 远程桌面录制
python -m openadapt_flow record --backend rdp --rdp-host 192.168.1.100 \
    --rdp-user admin --rdp-password pass123

# 参数化录制:将输入值声明为参数(回放时可替换)
# 在录制脚本中使用 recorder.type_text("张三", param="patient_name")

录制产物是一个包含原始证据的目录:

文件 内容
meta.json viewport、URL、参数列表、时间戳
events.jsonl 每行一个事件:kind / x / y / text / url_before / url_after / structural_state ...
frames/0000_before.png 第 0 步执行前截图
frames/0000_after.png 第 0 步执行后截图
frames/0001_before.png 第 1 步执行前截图,即上一步的 after

录制阶段不做 OCR、不做计算机视觉,也不调用模型。它只负责截图和 JSON 序列化,把"理解画面"的工作推迟到编译阶段。

这样做的直接好处是录制过程足够轻量,不会因为分析画面而明显干扰用户操作节奏。

2. 编译:compile

编译器读取录制目录,生成一个可独立回放的 Bundle。整个过程是纯本地计算,不联网。

bash 复制代码
# 基本编译
python -m openadapt_flow compile recording_xxx/ --out bundle_xxx/

# 编译时确认参数(交互式)
python -m openadapt_flow compile recording_xxx/ --out bundle_xxx/ --accept-params patient_name

# 从文件读取参数确认
python -m openadapt_flow compile recording_xxx/ --out bundle_xxx/ --params-from decisions.json

编译产物包括:

文件 用途
workflow.json 完整的 Workflow IR:步骤定义、锚点、后置条件、参数
manifest.json 内容完整性校验清单(content_digest)
templates/*.png 每步的模板裁剪(160×64 px),回放时做模板匹配
workflow.py 人类可读的 Python 伪代码,仅用于审查,不参与执行

3. 回放:replay

bash 复制代码
# 无头回放(默认,不弹出浏览器窗口)
python -m openadapt_flow replay bundle_xxx/

# 有窗口回放(观察执行过程)
python -m openadapt_flow replay bundle_xxx/ --headed

# 指定部署配置(连接真实系统、启用效果验证)
python -m openadapt_flow replay bundle_xxx/ --config deployment.yaml

回放时,系统根据编译阶段生成的锚点定位目标,并在执行操作前验证当前画面是否仍然符合录制时的状态。

4. 批量循环:for-each

for-each 对预先定义的工作列表逐行执行同一个 Bundle 的循环体。输入格式可以是 CSV 或 JSON。

bash 复制代码
# 准备 worklist.csv:
#   patient_name
#   张三
#   李四
#   王五

python -m openadapt_flow compile recording_xxx/ --accept-params patient_name
python -m openadapt_flow for-each bundle_xxx/ --worklist worklist.csv

循环次数由 worklist 的行数预先决定,不能在运行时从页面动态提取目标。这是 OpenAdapt 与 Playwright 脚本之间的一个重要区别。

5. 其他命令

命令 功能
demo-record 启动 MockMed 模拟医疗系统并录制标准演示流程
tutorial 运行交互式教学
induce 从多个录制中归纳参数化程序(多轨迹归纳)
run 完整录制 + 编译 + 回放,一站式执行
resume 从上次中断处继续回放
repair 对回放失败的 Bundle 进行故障修复
lint 静态检查 Bundle 的覆盖率和风险等级
certify 按策略(demo/standard/regulated)认证 Bundle 是否可安全无人值守运行
bench 对 Bundle 重复回放 N 次并汇总成功率
visualize 生成 Bundle 的可视化程序图
seal 将 Bundle 密封打包,禁止修改
sanitize 脱敏:擦除录制中的敏感数据(姓名、身份证号等)
emit-skill 将 Bundle 导出为 AI Agent 可调用的 Skill
emit-mcp 将 Bundle 导出为 MCP 工具

二、后端体系:同一套编译逻辑适配不同环境

OpenAdapt 通过 backend.py 中的 Protocol 体系支持多种驱动方式,具体后端由 --backend 参数选择。

后端只负责两件事:截图和执行操作,不参与编译逻辑。

后端 标记 底层驱动 画面来源 操作方式 适用场景
Web 默认 Playwright / Chromium 浏览器视口截图 Playwright 鼠标/键盘 API Web 应用自动化(最常见)
Windows --backend windows WAA HTTP Agent 桌面截图 API 通过 agent HTTP 接口发送坐标操作 Windows 原生桌面应用
macOS --backend macos macOS Accessibility API 单应用窗口截图 Accessibility 动作 + 坐标点击 macOS 原生应用
Linux --backend linux AT-SPI 单应用窗口截图 AT-SPI 动作,可选全局指针/键盘兜底 Linux 桌面应用
RDP --backend rdp FreeRDP / aardwolf 远程桌面画面截图 坐标点击/输入(像素级) 远程桌面、VDI、堡垒机
Citrix --backend citrix Citrix Workspace 窗口驱动 Citrix 客户端窗口截图 本地窗口内坐标操作 Citrix 虚拟桌面

Protocol 分层

OpenAdapt 的后端不是一个必须全部实现的单一接口,而是一组可选 Protocol。编译器根据后端具备的能力,决定最终能生成哪些锚点和验证信息。

Protocol 提供的能力 缺失的后果
StructuralBackend URL、page_title、page_count 无法生成 structural postcondition
IdentityBackend DOM/a11y 结构化文本 structured_text_at(x,y) 只能靠 OCR 做身份验证,存在 O/0、l/1 字形混淆风险
StructuralActionBackend DOM 选择器 / UIA 标识符 只能用视觉模板匹配定位,无法使用 CSS selector
FieldLabelBackend 当前聚焦字段的标签文本 编译时只能用 OCR 推测字段标签
EffectBackend 系统记录的写入痕迹,如数据库变更 无法验证操作是否真的写入系统
TextIntegrityBackend 输入框的实际字符值,非 OCR 猜测 输入文本只能通过 OCR 读回验证

Web 后端的能力最完整。它基于 Playwright 实现了全部 Protocol,因此浏览器录制的 Bundle 能生成更丰富的锚点和验证信息,包括 DOM 选择器、结构化身份信息和视觉模板。

RDP、Citrix 等后端则更接近纯像素模式,主要依赖视觉模板匹配和 OCR。

三、源码级实现原理

OpenAdapt 的核心架构分成 Recorder、Compiler 和 Replayer 三个阶段。以下分析基于 v1.25.1 源码,相关代码位于 openadapt_flow 包内。

1. Recorder:只记录证据

Recorder 约有 484 行,核心逻辑位于 recorder.py:260Recorder._record() 方法。

每次用户操作大致经历以下步骤:

python 复制代码
# recorder.py:260 _record() 方法的执行流程(简化)

1. 截取 before_png = backend.screenshot()
2. 采集 structural_state(URL / title / page_count)
3. 执行操作:backend.click(x, y) / backend.type_text(text) / ...
4. 等待画面静止:_wait_settled()
   └─ 轮询截图,直到连续 N 帧 phash 一致(默认超时 2s)
5. 截取 after_png = backend.screenshot()
6. 写入 events.jsonl 一行,保存 before.png / after.png
画面静止检测

_wait_settled() 位于 recorder.py:460,使用感知哈希(phash)判断页面是否完成渲染:

python 复制代码
# recorder.py:460 _wait_settled() 核心逻辑

deadline = time.monotonic() + self._settle_timeout_s  # 默认 2 秒
png = self._backend.screenshot()
prev = _phash(png)
consecutive = 1

while consecutive < SETTLE_CONSECUTIVE and time.monotonic() < deadline:
    time.sleep(SETTLE_INTERVAL_S)   # 默认 0.3 秒
    png = self._backend.screenshot()
    curr = _phash(png)
    if phash_distance(prev, curr) == 0:
        consecutive += 1
    else:
        consecutive = 1
    prev = curr

return png  # 最后一张稳定帧
结构化信息采集

录制器还会被动记录 structural_state,代码位于 recorder.py:433

python 复制代码
# recorder.py:433 _structural_state()
state = {}
for attr, key in (("url", "url"), ("page_title", "title"), ("page_count", "pages")):
    value = getattr(self._backend, attr, None)
    if value is not None:
        state[key] = value
return state

这一步不负责理解页面,只是尽可能保留后续编译和回放可以利用的结构化信息。

2. Compiler:从截图和事件生成 Bundle

Compiler 约有 2,203 行,是整个系统最复杂的模块。它将录制目录转换为包含 Workflow IR、模板图片、后置条件和完整性清单的 Bundle。

锚点生成

锚点解决的是回放阶段的核心问题:如何在当前画面中找到录制时操作过的目标。

相关逻辑位于 compile.py:1581

python 复制代码
# compile.py:1581 锚点构建逻辑(简化)

# 1. 模板裁剪
crop_region = _discriminative_crop_region(frame, click_point)
#    默认 160×64,如像素方差不足则阶梯扩至 640×256
#    CROP_GROWTH_LADDER = ((160,64), (240,96), (320,128), (480,192), (640,256))

# 2. OCR 文字
ocr_text = _best_crop_text(ocr(before_png, region=crop_region))

# 3. 上下文文字(用于身份验证)
context_text = identifier_text_from_lines(frame_lines, ...)

# 4. 地标(用于几何校准)
landmarks = _landmarks_for(frame_lines, crop_region, click_point)
#    取帧内 10~12 个最显著的 OCR 行作为锚点参照系

# 5. 结构化定位器(DOM / UIA)
structural = StructuralLocator.model_validate(event.get("structural"))

# 最终 Anchor 对象
anchor = Anchor(
    template=template_rel,      # 模板 PNG 路径
    region=crop_region,         # 裁剪区域坐标
    click_point=click,          # 点击坐标
    ocr_text=ocr_text,          # 锚点文字
    context_text=context_text,  # 身份上下文
    structural=structural,      # DOM 选择器
    landmarks=landmarks,        # 地标列表
)

一个 Anchor 可以同时包含模板、OCR 文本、上下文文字、结构化定位器和地标。回放时,系统可以根据当前后端能力选择其中的一部分。

后置条件挖掘

编译器会从每一步的 before/after 帧差异中自动推断操作完成后的验证条件。相关入口是 compile.py:656_postconditions()

REGION_STABLE

REGION_STABLE 用于验证某个区域在操作后达到预期稳定状态。

python 复制代码
# compile.py:722 REGION_STABLE 生成逻辑

# 1. 像素差分找最大变化区域
changed = _largest_changed_region(before_png, after_png)
#    流程:cv2.absdiff → 阈值化(25) → cv2.findContours → 最大连通区域

# 2. 添加 24px 内边距(包裹结构边框)
padded = _pad_region(changed, frame_w, frame_h)

# 3. 自变异检测(过滤动画/时钟/toast)
if next_before_png is not None:
    phash_now = phash_png(after_png, region=padded)
    phash_next = phash_png(next_before_png, region=padded)
    if phash_distance(phash_now, phash_next) > REGION_STABLE_TOLERANCE:
        changed = None  # 丢弃!该区域是自变异的

# 4. 参数隔离(如果区域含有参数文字则切掉参数行)
if exclude_texts and _param_text_in_region(padded, after_lines, exclude_texts):
    band = _param_free_band(padded, carriers, after_lines)
    if band is not None:
        padded = band

# 5. 生成 postcondition
expect.append(Postcondition(
    kind=PostconditionKind.REGION_STABLE,
    region=padded,
    phash=phash_png(after_png, region=padded),
    phash_tolerance=REGION_STABLE_TOLERANCE,  # 16
))

处理过程包括:

  1. 使用 cv2.absdiff、阈值化和轮廓分析寻找最大变化区域;
  2. 在区域外扩 24px,尽量包含相关结构边框;
  3. 使用 next_before_png 检测动画、时钟和 Toast 等自变异内容;
  4. 如果区域包含参数文字,则尝试排除参数行;
  5. 用区域 phash 和容差 16 生成后置条件。
TEXT_PRESENT

TEXT_PRESENT 用于检测操作后出现的新文本,核心逻辑位于 compile.py:536_new_text_postcondition()

编译器会比较 before 和 after 帧中的 OCR 文本,只保留 after 帧新增且满足条件的文本:

python 复制代码
# compile.py:536 _new_text_postcondition() 核心逻辑

# 1. OCR after 帧,找出 before 帧中不存在的新文本行
new_lines = [line for line in after_lines if not seen_before(line)]

# 2. 过四道过滤器
for line in new_lines:
    text = normalize_text(line.text)

    # 过滤器 A:长度 ≥ MIN_TEXT_PRESENT_LEN (3)
    if len(text) < 3: continue

    # 过滤器 B:挥发性分类器
    if volatility.is_volatile(text, reference_date): continue
    #    拦截:时钟(18:38)、相对时间(3 min ago)、计数器(1-5 of 12)

    # 过滤器 C:点击区域去重
    if _matches_click_text(text, click_text): continue

    # 过滤器 D:已在前一帧可见(模糊匹配)
    if _was_visible_in_before(text, before_lines): continue

    candidates.append((score, text))

# 3. 取得分最高的一个
return Postcondition(kind=PostconditionKind.TEXT_PRESENT, text=chosen)

它会过滤以下内容:

  • 长度小于 MIN_TEXT_PRESENT_LEN (3) 的文本;
  • 时钟、相对时间和分页计数器;
  • 点击区域原本已经存在的文本;
  • before 帧中已经可见的文本。

最后只选择得分最高的候选文本。

结构性兜底

当连续两步没有挖掘出视觉后置条件时,编译器会尝试使用结构信息生成兜底条件,代码位于 compile.py:880

python 复制代码
# compile.py:880 _structural_postconditions()
# 仅当前两步挖出零视觉后置条件时触发

pcs = []
if pages_after > pages_before:
    pcs.append(Postcondition(kind=PostconditionKind.NEW_TAB_OPENED))
if url_before != url_after:
    pcs.append(Postcondition(kind=PostconditionKind.URL_CHANGED))
elif title_before != title_after:
    pcs.append(Postcondition(kind=PostconditionKind.TITLE_CHANGED))
return pcs

3. Volatility Classifier:过滤不可靠证据

volatility.py 共有 366 行,是编译器中用于降低误判的重要防御模块。它通过正则表达式识别不适合作为稳定证据的文本。

分类器 拦截模式 示例 原因
CLOCK_RE \d{1,2}:\d{2} 及 OCR 片段 :01 18:38、6:05、12:45:59 时钟分钟会走
DOT_CLOCK_RE \d+\.\d{2} 欧式时钟 18.38、updated 8.30 同上
RELATIVE_TIME_RE `\d+ (min hour)s? ago` 3 min ago、just now、moments ago
COUNT_RE \d+ to \d+ of \d+ 1 to 5 of 12 entries 分页计数是瞬时状态
相对日期词 独立的 today / yesterday / now Today、Yesterday 作为消息列表分组头时是瞬时的
近日期 距离录制日期 < 7 天的日期 2026-08-06(录制日期附近) 内容时间线而非身份数据

日期处理是这个分类器中比较细的一点:距离录制日期 ≤7 天的日期被视为内容时序,属于易变化信息;较远的日期,例如出生日期 DOB,则更可能被视为身份数据。

4. 内容完整性校验

编译完成后,manifest.json 会写入 content_digest

该值由 compute_content_digest() 计算,先使用 model_dump(exclude_none=True) 规范化序列化,再计算 SHA256,而不是直接使用简单的 json.dumps

因此,任何对 workflow.json 的修改都需要重新计算 digest,否则回放时可能触发完整性校验错误。

5. Replayer:从结构化定位逐级降级

回放引擎使用多级分辨率阶梯定位目标,相关 Resolution 枚举定义在 ir.py 中:

text 复制代码
1. structural        # DOM 选择器 / UIA 标识符 → 直接定位(毫秒级)
2. template_match    # 模板 PNG 在当前帧上做 cv2.matchTemplate
3. ocr               # OCR 当前帧,用锚点文本匹配
4. landmark          # 用地标的 OCR 位置做几何变换推算目标位置
5. geometry          # 纯坐标偏移(最后兜底)

某一级定位失败后,引擎会自动降级到下一级。历史 replay 日志中的 heal_events 字段会记录每次降级的详细信息。

这套机制的关键不在于某一种定位算法足够稳定,而在于把多种定位证据组合起来:Web 页面优先使用 DOM 结构,纯像素环境则更多依赖模板、OCR、地标和几何关系。

6. 身份验证:先确认目标,再执行操作

在点击或输入之前,回放引擎会先确认当前画面中的目标是否仍然是录制时的目标。

流程如下:

  1. 以点击点为中心,根据 crop_height 和当前帧尺寸生成水平的身份带;
  2. 对身份带执行 OCR;
  3. 将当前文本与录制时的 context_text 比对;
  4. 如果是参数化字段,则使用当前运行参数重新匹配;
  5. 匹配成功后执行操作,匹配失败则降级到下一级分辨率策略。

四、当前版本的局限

1. 后置条件无法在录制时控制

postcondition 完全由编译器自动生成,用户没有 CLI 参数或 API 可以直接干预。

对于搜索引擎、实时数据面板等动态页面,编译器生成的 region_stable 可能监控到持续变化的区域,导致 replay 必然 HALT。

当前的处理方式是编译后手动编辑 workflow.json,清空有问题的 expect 数组,再重算 content_digest。这个过程没有官方工具支持。

2. 运行时不能动态决策

OpenAdapt 的 loop authoring 要求预先声明 CSV/JSON worklist,运行时不能从页面动态提取目标。

因此,类似"搜索关键词 → 爬取前 N 个结果"的场景不适合直接用 OpenAdapt 实现。

根因在于 Workflow 是静态 IR:所有步骤在编译时确定,运行时只负责按图执行和自愈,不负责发现新目标。

3. 帧差分区域可能过大

_largest_changed_region() 在 URL 跳转或整页刷新时,可能返回接近全屏的区域。此时生成的 region_stable 会监控整个视口。

如果页面中存在时钟、广告轮播等动态元素,全屏 phash 几乎不可能保持稳定。

源码位置:compile.py 中的 _largest_changed_region() 只取最大连通区域,不做语义分割。

4. 自变异检测依赖录制时的帧间隔

编译器使用 next_before_pngafter_png 比较,判断区域是否自变异。

如果录制时操作节奏太快,时钟还没有跳到下一分钟,或者动画还没有播放完,自变异检测就可能漏判。

源码位置:compile.py:722。当前逻辑只比较两帧 phash 距离,不做时序建模。

5. 不支持跨页爬取和数据提取

OpenAdapt 的目标是自动化操作,而不是数据抓取。它没有内置 DOM 提取、列表遍历和分页处理能力。

例如,Playwright 可以直接使用:

python 复制代码
page.locator('.result-item')

来提取元素列表;OpenAdapt 只能识别录制时见过的那个特定坐标。

6. 纯像素后端的身份验证存在边界

在 RDP/Citrix 等纯像素后端中,没有 StructuralActionBackendIdentityBackend,身份验证只能依赖 OCR。

OCR 无法可靠区分 O/0、l/1、I/l 等字形。在 backend.py:126 的文档中,源码明确承认:

"An adversarial review proved the OCR-only identity path cannot close the same-name / same-DOB glyph-collapse case: two DIFFERENT patients whose MRN differs only by an O/0 or l/1 glyph render to a byte-identical OCR band --- so no function downstream of OCR can distinguish them"

这意味着,在关键身份字段只存在字形差异时,后续代码无法从 OCR 结果中恢复丢失的信息。

7. 编译产物不便维护

编译后的 workflow.json 内部嵌入了 manifest,同时又有独立的 manifest.json。修改 workflow 后,需要同步更新两处的 content_digest

常见错误是只修改了 manifest.json,没有同步更新 workflow.json 内嵌的 content_digest,最终触发 BundleIntegrityError

更根本的问题是,项目没有提供官方的 post-hoc 编辑工具或 CLI。修改 Bundle 只能手工编辑 JSON,再调用库函数重算 digest。

8. 默认执行过程不可见

replay 默认使用 headless 模式。--headedstore_true,默认值为 False,因此:

text 复制代码
headless = not headed = True

用户看不到浏览器窗口,只能等待 REPORT 输出,调试体验较差。

源码位置:__main__.py:3338 定义了 --headed action="store_true",与 record 的默认可见行为相反。

五、结论:OpenAdapt 适合什么场景

从 v1.25.1 的实现来看,OpenAdapt 更适合以下类型的自动化任务:

  • 操作步骤相对固定;
  • 页面或桌面环境变化有限;
  • 任务目标可以在录制前明确;
  • 需要在 Web、Windows、macOS、Linux、RDP 或 Citrix 等环境中复现操作;
  • 希望通过截图、OCR、结构化定位器和后置条件提高回放可靠性。

它不适合直接替代 Playwright 等数据抓取工具,也不适合需要运行时探索、动态生成任务分支或复杂列表遍历的流程。

OpenAdapt 的核心价值不是"让自动化拥有无限适应能力",而是把一次人工操作转换成包含视觉证据、结构化锚点和执行后验证的静态 Bundle。它的可靠性来自录制证据和多级回放策略,同时也受静态 Workflow、自动生成后置条件和 OCR 能力边界的限制。

免责声明:本文档基于 OpenAdapt v1.25.1 源码逆向分析生成,所有代码片段均来自实际源码文件。分析日期:2026-08,源码总行数约 28,000 行 Python。

相关推荐
SLD_Allen1 小时前
字节跳动飞连(Feilian)AI智能体零信任安全治理深度技术研究报告
网络·人工智能·安全·智能体安全
IT_陈寒1 小时前
Redis的DEL命令居然没删干净数据?这个坑我爬了半天
前端·人工智能·后端
熊猫钓鱼>_>2 小时前
鸿蒙ArkUI全手势操作实战指南:6大基础手势从原理到落地避坑
人工智能·深度学习·华为·架构·harmonyos·arkui·tapgesture
职场的momo2 小时前
11个后端与AI岗位同时开放:Java、网关、推理优化怎么匹配
java·开发语言·人工智能
微石科技2 小时前
社区卫生中心慢病管理怎么做?宁波微石科技智慧医康系统:一个平台管住趋势、随访、患者
大数据·人工智能·科技
AI模型调用笔记2 小时前
GPT-5.4 8月31日退出 Codex?先分清 ChatGPT 登录与 API Key,再迁移 Terra/Luna
人工智能·gpt·chatgpt·ai编程
Henry-SAP2 小时前
AI与机器人信息新闻
人工智能·云原生·sap·erp
天远数科2 小时前
零信任架构实战:基于天远二手车VIN估值构建自动化汽车数据网关
人工智能·架构·自动化·汽车
AI码农小姐姐3 小时前
小说导入AI漫剧赚钱教程:知漫剧批量生成与选型
人工智能