版本1
python
"""
可视化工作流录制器 - GUI 界面
功能:启动 codegen,实时解析代码为可视化步骤
"""
import tkinter as tk
from tkinter import ttk, scrolledtext, messagebox
import threading
import subprocess
import time
import os
import re
import json
from pathlib import Path
class WorkflowRecorder:
def __init__(self, root):
self.root = root
self.root.title("FingerAgent - 可视化工作流录制器")
self.root.geometry("600x500")
# 状态变量
self.is_recording = False
self.is_paused = False
self.codegen_process = None
self.output_file = "temp_recorded.py"
self.steps = [] # 存储步骤列表
self.last_code = "" # 上一次解析的代码
self.setup_ui()
def setup_ui(self):
"""创建 UI 组件"""
# 顶部按钮区域
btn_frame = ttk.Frame(self.root, padding="10")
btn_frame.pack(fill=tk.X)
self.btn_record = ttk.Button(btn_frame, text="● 开始录制", command=self.toggle_recording)
self.btn_record.pack(side=tk.LEFT, padx=5)
self.btn_pause = ttk.Button(btn_frame, text="⏸ 暂停", command=self.toggle_pause, state=tk.DISABLED)
self.btn_pause.pack(side=tk.LEFT, padx=5)
self.btn_save = ttk.Button(btn_frame, text="💾 保存脚本", command=self.save_script, state=tk.DISABLED)
self.btn_save.pack(side=tk.LEFT, padx=5)
self.btn_clear = ttk.Button(btn_frame, text="🗑️ 清空", command=self.clear_steps)
self.btn_clear.pack(side=tk.LEFT, padx=5)
# 状态显示
self.status_label = ttk.Label(btn_frame, text="状态: 未开始", foreground="gray")
self.status_label.pack(side=tk.RIGHT, padx=10)
# 分隔线
ttk.Separator(self.root, orient=tk.HORIZONTAL).pack(fill=tk.X, padx=10, pady=5)
# 步骤列表区域
steps_label = ttk.Label(self.root, text="录制的步骤:", font=("Microsoft YaHei", 10, "bold"))
steps_label.pack(anchor=tk.W, padx=15, pady=(10, 5))
# 步骤列表(Listbox)
list_frame = ttk.Frame(self.root, padding="10")
list_frame.pack(fill=tk.BOTH, expand=True)
self.steps_listbox = tk.Listbox(
list_frame,
font=("Microsoft YaHei", 10),
selectmode=tk.EXTENDED,
height=15
)
scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=self.steps_listbox.yview)
self.steps_listbox.configure(yscrollcommand=scrollbar.set)
self.steps_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
# 底部提示
tip_label = ttk.Label(
self.root,
text="💡 提示:录制时操作浏览器,所有步骤会自动显示在这里",
font=("Microsoft YaHei", 9),
foreground="gray"
)
tip_label.pack(pady=10)
def toggle_recording(self):
"""切换录制状态"""
if not self.is_recording:
self.start_recording()
else:
self.stop_recording()
def start_recording(self):
"""开始录制"""
self.is_recording = True
self.is_paused = False
self.btn_record.config(text="■ 停止录制")
self.btn_pause.config(text="⏸ 暂停", state=tk.NORMAL)
self.btn_save.config(state=tk.NORMAL)
self.status_label.config(text="状态: 录制中...", foreground="green")
# 清空之前的数据
self.steps = []
self.steps_listbox.delete(0, tk.END)
self.last_code = ""
# 启动 codegen 线程
self.codegen_thread = threading.Thread(target=self.run_codegen, daemon=True)
self.codegen_thread.start()
# 启动监听线程
self.monitor_thread = threading.Thread(target=self.monitor_output, daemon=True)
self.monitor_thread.start()
def stop_recording(self):
"""停止录制"""
self.is_recording = False
# 终止 codegen 进程
if self.codegen_process:
self.codegen_process.terminate()
try:
self.codegen_process.wait(timeout=3)
except:
self.codegen_process.kill()
self.btn_record.config(text="● 开始录制")
self.btn_pause.config(state=tk.DISABLED)
self.status_label.config(text="状态: 已停止", foreground="gray")
def toggle_pause(self):
"""切换暂停状态"""
self.is_paused = not self.is_paused
if self.is_paused:
self.btn_pause.config(text="▶ 继续")
self.status_label.config(text="状态: 已暂停", foreground="orange")
else:
self.btn_pause.config(text="⏸ 暂停")
self.status_label.config(text="状态: 录制中...", foreground="green")
def run_codegen(self):
"""运行 codegen 进程"""
try:
# 删除旧文件
if os.path.exists(self.output_file):
os.remove(self.output_file)
# 启动 codegen
cmd = [
"playwright", "codegen",
"-o", self.output_file,
"--target=python",
"-b", "chromium"
]
self.codegen_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
# 等待进程结束
self.codegen_process.wait()
except Exception as e:
self.root.after(0, lambda: messagebox.showerror("错误", f"启动 codegen 失败: {e}"))
def monitor_output(self):
"""监控输出文件变化"""
last_mtime = 0
while self.is_recording:
try:
if os.path.exists(self.output_file):
# 获取文件修改时间
mtime = os.path.getmtime(self.output_file)
# 文件有变化
if mtime > last_mtime:
last_mtime = mtime
# 读取文件内容
with open(self.output_file, "r", encoding="utf-8") as f:
current_code = f.read()
# 每次都从头解析整个文件
all_steps = self.parse_code_to_steps(current_code)
# 如果步骤数量增加了,更新 UI
if len(all_steps) > len(self.steps):
# 获取新增的步骤
new_steps = all_steps[len(self.steps):]
self.root.after(0, lambda s=new_steps: self.update_steps(s))
time.sleep(0.3) # 每 0.3 秒检查一次
except Exception as e:
print(f"监控错误: {e}")
time.sleep(1)
def parse_code_to_steps(self, code: str) -> list:
"""解析 Python 代码为可视化步骤"""
steps = []
if not code:
return steps
# 预处理:合并多行语句
lines = code.split('\n')
processed_lines = []
current_line = ""
indent_count = 0
for line in lines:
stripped = line.strip()
# 跳过空行、注释、import
if not stripped or stripped.startswith('#') or stripped.startswith('import ') or stripped.startswith('from '):
continue
# 如果是 with 语句的 continuation(缩进行)
if stripped and (stripped.startswith('page.') or stripped.startswith('page1.') or stripped.startswith('page2.')):
current_line += " " + stripped
else:
if current_line:
processed_lines.append(current_line)
current_line = stripped
# 最后一行
if current_line:
processed_lines.append(current_line)
for line in processed_lines:
step = self.parse_action_line(line)
if step:
steps.append(step)
return steps
def parse_action_line(self, line: str) -> str:
"""解析单行代码为中文描述"""
line = line.strip()
import re # 移到这里避免作用域问题
# page.goto(...) - 导航
if '.goto(' in line:
# 提取 URL
import re
match = re.search(r'["\'](https?://[^"\']+)["\']', line)
if match:
return f"🌐 打开网页: {match.group(1)}"
# get_by_role(...).click() - 按角色点击
if 'get_by_role' in line and '.click()' in line:
# 提取角色和名称
import re
role_match = re.search(r'["\'](link|button|checkbox|radio|input|menuitem|heading)["\']', line)
name_match = re.search(r'name=["\']([^"\']+)["\']', line)
if role_match and name_match:
return f"🖱️ 点击 [{role_match.group(1)}]: {name_match.group(1)}"
elif name_match:
return f"🖱️ 点击: {name_match.group(1)}"
return "🖱️ 点击元素"
# get_by_text(...).click()
if 'get_by_text' in line and '.click()' in line:
import re
match = re.search(r'["\']([^"\']+)["\']', line)
if match:
return f"🖱️ 点击文本: {match.group(1)}"
return "🖱️ 点击文本"
# get_by_label
if 'get_by_label' in line:
import re
match = re.search(r'["\']([^"\']+)["\']', line)
if match:
return f"🖱️ 点击标签: {match.group(1)}"
# locator(...).click()
if re.match(r'\w+\.locator\(', line) and '.click()' in line:
match = re.search(r'locator\(["\']([^"\']+)["\']', line)
if match:
return f"🖱️ 点击: {match.group(1)}"
# page.click(...)
if re.match(r'\w+\.click\(', line) and 'get_by' not in line:
import re
match = re.search(r'["\']([^"\']+)["\']', line)
if match:
return f"🖱️ 点击: {match.group(1)}"
# page.fill - 输入
if '.fill(' in line:
import re
match = re.search(r'["\']([^"\']+)["\']', line)
if match:
selector = match.group(1)
# 尝试找第二个参数(值)
value_match = re.search(r',\s*["\']([^"\']+)["\']', line)
if value_match:
return f"⌨️ 输入: {selector} → '{value_match.group(1)}'"
return f"⌨️ 输入: {selector}"
# wait_for_timeout
if 'wait_for_timeout' in line:
import re
match = re.search(r'(\d+)', line)
if match:
ms = int(match.group(1))
return f"⏱️ 等待 {ms/1000:.1f} 秒"
# wait_for_load_state
if 'wait_for_load_state' in line:
return "⏳ 等待加载"
# screenshot
if 'screenshot' in line:
return "📷 截图"
# evaluate
if 'evaluate' in line:
return "⚡ 执行脚本"
# expect
if 'expect(' in line:
return "✅ 断言"
# expect_popup
if 'expect_popup' in line:
return "🔔 等待弹窗"
return None
def update_steps(self, new_steps: list):
"""更新步骤列表"""
# 找到新增的步骤(从上次的位置之后)
start_idx = len(self.steps)
self.steps.extend(new_steps)
# 添加到 Listbox
for step in new_steps:
self.steps_listbox.insert(tk.END, step)
# 自动滚动到底部
if new_steps:
self.steps_listbox.see(tk.END)
def clear_steps(self):
"""清空步骤列表"""
self.steps = []
self.steps_listbox.delete(0, tk.END)
self.last_code = ""
# 删除临时文件
if os.path.exists(self.output_file):
os.remove(self.output_file)
def save_script(self):
"""保存脚本"""
if not self.steps:
messagebox.showwarning("警告", "没有可保存的步骤!")
return
# 读取生成的代码
if os.path.exists(self.output_file):
with open(self.output_file, "r", encoding="utf-8") as f:
code = f.read()
# 让用户选择保存位置
from tkinter import filedialog
save_path = filedialog.asksaveasfilename(
defaultextension=".py",
filetypes=[("Python 文件", "*.py")],
initialfile="recorded_workflow.py"
)
if save_path:
with open(save_path, "w", encoding="utf-8") as f:
f.write(code)
messagebox.showinfo("成功", f"脚本已保存到:\n{save_path}")
else:
messagebox.showwarning("警告", "未找到生成的代码文件!")
def main():
root = tk.Tk()
app = WorkflowRecorder(root)
root.mainloop()
if __name__ == "__main__":
main()
版本2
python
"""
可视化工作流录制器 - PySide6 现代界面版
功能:启动 codegen,实时解析代码为可视化步骤
"""
import sys
from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QListWidget, QLabel, QFrame, QListWidgetItem
)
from PySide6.QtCore import Qt, QThread, Signal, QTimer
from PySide6.QtGui import QIcon, QColor, QPalette, QLinearGradient, QPainter, QFont
import subprocess
import time
import os
import re
from workflow_parser import parse_workflow
class WorkflowRecorder(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("FingerAgent - 可视化工作流录制器")
self.setGeometry(100, 100, 700, 550)
# 状态变量
self.is_recording = False
self.codegen_process = None
self.output_file = "temp_recorded.py"
self.steps = []
self.setup_ui()
self.apply_styles()
def setup_ui(self):
"""创建 UI 组件"""
# 中心部件
central_widget = QWidget()
self.setCentralWidget(central_widget)
# 主布局
main_layout = QVBoxLayout(central_widget)
main_layout.setContentsMargins(20, 20, 20, 20)
main_layout.setSpacing(15)
# ===== 顶部标题栏 =====
title_layout = QHBoxLayout()
# 标题
title_label = QLabel("🎬 工作流录制器")
title_label.setFont(QFont("Microsoft YaHei", 16, QFont.Bold))
title_layout.addWidget(title_label)
title_layout.addStretch()
# 状态指示灯 + 状态文字
status_container = QFrame()
status_container.setFixedSize(120, 30)
status_layout = QHBoxLayout(status_container)
status_layout.setContentsMargins(10, 0, 10, 0)
self.status_indicator = QLabel("●")
self.status_indicator.setFont(QFont("Arial", 14))
self.status_indicator.setStyleSheet("color: #666666;")
status_layout.addWidget(self.status_indicator)
self.status_label = QLabel("未开始")
self.status_label.setFont(QFont("Microsoft YaHei", 10))
self.status_label.setStyleSheet("color: #1a1a1a;")
status_layout.addWidget(self.status_label)
title_layout.addWidget(status_container)
main_layout.addLayout(title_layout)
# ===== 分隔线 =====
separator = QFrame()
separator.setFrameShape(QFrame.HLine)
separator.setStyleSheet("background-color: #333; border: none; height: 1px;")
main_layout.addWidget(separator)
# ===== 步骤列表区域 =====
steps_title = QLabel("📋 录制的步骤")
steps_title.setFont(QFont("Microsoft YaHei", 12, QFont.Bold))
main_layout.addWidget(steps_title)
# 步骤列表
self.steps_list = QListWidget()
self.steps_list.setFont(QFont("Microsoft YaHei", 10))
self.steps_list.setSpacing(5)
main_layout.addWidget(self.steps_list, 1)
# ===== 底部按钮区域 =====
btn_layout = QHBoxLayout()
btn_layout.setSpacing(15)
# 录制按钮
self.btn_record = QPushButton("▶ 开始录制")
self.btn_record.setFixedHeight(40)
self.btn_record.setFont(QFont("Microsoft YaHei", 11))
self.btn_record.clicked.connect(self.toggle_recording)
btn_layout.addWidget(self.btn_record)
# 清空按钮
self.btn_clear = QPushButton("🗑️ 清空")
self.btn_clear.setFixedHeight(40)
self.btn_clear.setFont(QFont("Microsoft YaHei", 11))
self.btn_clear.clicked.connect(self.clear_steps)
btn_layout.addWidget(self.btn_clear)
btn_layout.addStretch()
main_layout.addLayout(btn_layout)
# ===== 底部提示 =====
tip_label = QLabel("💡 提示:录制时操作浏览器,所有步骤会自动显示在这里")
tip_label.setFont(QFont("Microsoft YaHei", 9))
tip_label.setStyleSheet("color: #666666;")
tip_label.setAlignment(Qt.AlignCenter)
main_layout.addWidget(tip_label)
def apply_styles(self):
"""应用亮色主题样式"""
self.setStyleSheet("""
QMainWindow {
background-color: #ffffff;
}
QLabel {
color: #1a1a1a;
}
QListWidget {
background-color: #f5f5f5;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 5px;
color: #1a1a1a;
}
QListWidget::item {
padding: 8px;
border-radius: 5px;
margin: 2px;
}
QListWidget::item:selected {
background-color: #0078d4;
color: white;
}
QListWidget::item:hover {
background-color: #e8e8e8;
}
QPushButton {
background-color: #0078d4;
border: none;
border-radius: 6px;
color: white;
padding: 8px 20px;
font-weight: bold;
}
QPushButton:hover {
background-color: #106ebe;
}
QPushButton:pressed {
background-color: #005a9e;
}
QPushButton:disabled {
background-color: #cccccc;
color: #666666;
}
#btn_stop {
background-color: #d32f2f;
}
#btn_stop:hover {
background-color: #e53935;
}
""")
# 设置按钮对象名称
self.btn_record.setObjectName("btn_start")
def toggle_recording(self):
"""切换录制状态"""
if not self.is_recording:
self.start_recording()
else:
self.stop_recording()
def start_recording(self):
"""开始录制"""
self.is_recording = True
# 更新按钮
self.btn_record.setText("■ 停止录制")
self.btn_record.setObjectName("btn_stop")
self.btn_record.style().unpolish(self.btn_record)
self.btn_record.style().polish(self.btn_record)
# 更新状态
self.status_indicator.setText("●")
self.status_indicator.setStyleSheet("color: #0078d4;")
self.status_label.setText("录制中...")
self.status_label.setStyleSheet("color: #0078d4;")
# 更新按钮
self.btn_record.setText("■ 停止录制")
self.btn_record.setObjectName("btn_stop")
self.btn_record.style().unpolish(self.btn_record)
self.btn_record.style().polish(self.btn_record)
# 清空之前的数据
self.steps = []
self.steps_list.clear()
# 启动 codegen 线程
self.codegen_thread = Thread(target=self.run_codegen)
self.codegen_thread.start()
# 启动监听线程
self.monitor_thread = Thread(target=self.monitor_output)
self.monitor_thread.start()
def stop_recording(self):
"""停止录制并保存"""
self.is_recording = False
# 终止 codegen 进程
if self.codegen_process:
self.codegen_process.terminate()
try:
self.codegen_process.wait(timeout=3)
except:
self.codegen_process.kill()
# 自动保存脚本
self.save_script()
# 更新按钮
self.btn_record.setText("▶ 开始录制")
self.btn_record.setObjectName("btn_start")
self.btn_record.style().unpolish(self.btn_record)
self.btn_record.style().polish(self.btn_record)
# 更新状态
self.status_indicator.setText("●")
self.status_indicator.setStyleSheet("color: #666666;")
self.status_label.setText("已保存")
self.status_label.setStyleSheet("color: #1a1a1a;")
def run_codegen(self):
"""运行 codegen 进程"""
try:
# 删除旧文件
if os.path.exists(self.output_file):
os.remove(self.output_file)
# 启动 codegen
cmd = [
"playwright", "codegen",
"-o", self.output_file,
"--target=python",
"-b", "chromium"
]
self.codegen_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
# 等待进程结束
self.codegen_process.wait()
except Exception as e:
self.status_label.setText(f"错误: {str(e)}")
def monitor_output(self):
"""监控输出文件变化"""
last_mtime = 0
while self.is_recording:
try:
if os.path.exists(self.output_file):
mtime = os.path.getmtime(self.output_file)
if mtime > last_mtime:
last_mtime = mtime
# 直接调用 workflow_parser 解析文件
all_steps = parse_workflow(self.output_file)
if len(all_steps) > len(self.steps):
new_steps = all_steps[len(self.steps):]
self.update_steps(new_steps)
time.sleep(0.3)
except Exception as e:
print(f"监控错误: {e}")
time.sleep(1)
def update_steps(self, new_steps: list):
"""更新步骤列表"""
self.steps.extend(new_steps)
for step in new_steps:
item = QListWidgetItem(step)
self.steps_list.addItem(item)
if new_steps:
self.steps_list.scrollToBottom()
def clear_steps(self):
"""清空步骤列表"""
self.steps = []
self.steps_list.clear()
if os.path.exists(self.output_file):
os.remove(self.output_file)
def save_script(self):
"""保存脚本"""
if not self.steps:
from PySide6.QtWidgets import QMessageBox
QMessageBox.warning(self, "警告", "没有可保存的步骤!")
return
if os.path.exists(self.output_file):
with open(self.output_file, "r", encoding="utf-8") as f:
code = f.read()
save_path = "recorded_workflow.py"
with open(save_path, "w", encoding="utf-8") as f:
f.write(code)
from PySide6.QtWidgets import QMessageBox
QMessageBox.information(self, "成功", f"脚本已保存到:\n{save_path}")
class Thread(QThread):
"""简单的线程包装"""
def __init__(self, target):
super().__init__()
self._target = target
self.start()
def run(self):
self._target()
def main():
app = QApplication(sys.argv)
app.setStyle("Fusion")
window = WorkflowRecorder()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
python
import re
import sys
def translate_action(action: str) -> str:
"""翻译动作名称为中文"""
action_map = {
'click': '点击',
'fill': '输入',
'check': '勾选',
'uncheck': '取消勾选',
'select_option': '选择',
'press': '按键',
'dblclick': '双击',
'hover': '悬停',
}
return action_map.get(action, action)
def extract_locator(line: str) -> dict:
"""提取定位方式"""
result = {}
# get_by_role
role_match = re.search(r'\.get_by_role\("([^"]+)",\s*name="([^"]+)"\)', line)
if role_match:
result['定位'] = 'get_by_role'
result['角色'] = role_match.group(1)
result['名字'] = role_match.group(2)
# 修饰符
modifier_match = re.search(r'\.(first|last|nth)\b', line)
if modifier_match:
result['修饰符'] = f".{modifier_match.group(1)}"
return result
# get_by_text
text_match = re.search(r'\.get_by_text\("([^"]+)"(?:,\s*exact=True)?\)', line)
if text_match:
result['定位'] = 'get_by_text'
result['文本'] = text_match.group(1)
# 检查 exact=True
if 'exact=True' in line:
result['精确'] = '是'
return result
# locator (CSS选择器)
locator_match = re.search(r'\.locator\("([^"]+)"\)', line)
if locator_match:
result['定位'] = 'locator'
result['选择器'] = locator_match.group(1)
# 可能还有 get_by_text 链式调用
chained_text = re.search(r'\.get_by_text\("([^"]+)"\)\.click\(\)', line)
if chained_text:
result['文本'] = chained_text.group(1)
return result
return result
def extract_action(line: str) -> dict:
"""提取动作"""
result = {}
# .click()
if '.click()' in line:
result['动作'] = '点击'
# .fill("xxx")
fill_match = re.search(r'\.fill\("([^"]*)"\)', line)
if fill_match:
result['动作'] = '输入'
result['值'] = fill_match.group(1)
# .press("xxx")
press_match = re.search(r'\.press\("([^"]+)"\)', line)
if press_match:
result['动作'] = f"按键({press_match.group(1)})"
# .check()
if '.check()' in line:
result['动作'] = '勾选'
# .uncheck()
if '.uncheck()' in line:
result['动作'] = '取消勾选'
return result
def parse_single_line(line: str) -> str:
"""解析单行代码,返回中文描述"""
line = line.strip()
if not line or line.startswith('#'):
return None
# 解析 goto
goto_match = re.search(r'(page\d*)\.goto\("([^"]+)"\)', line)
if goto_match:
return f"URL: {goto_match.group(2)}"
# 提取定位信息
locator_info = extract_locator(line)
# 提取动作信息
action_info = extract_action(line)
# 只有当有定位或动作时才输出
if locator_info or action_info:
parts = []
for key, value in locator_info.items():
parts.append(f"{key}: {value}")
for key, value in action_info.items():
parts.append(f"{key}: {value}")
return ', '.join(parts)
return None
def parse_workflow(file_path: str) -> list:
"""解析 Playwright 录制的 workflow 文件,返回步骤列表"""
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
lines = content.split('\n')
steps = []
step_num = 0
for line in lines:
result = parse_single_line(line)
if result:
step_num += 1
steps.append(f"步骤{step_num}: {result}")
return steps
if __name__ == "__main__":
# 默认解析当前目录的 recorded_workflow.py
file_path = "recorded_workflow.py"
# 可以通过命令行参数指定文件
if len(sys.argv) > 1:
file_path = sys.argv[1]
try:
steps = parse_workflow(file_path)
for step in steps:
print(step)
except FileNotFoundError:
print(f"错误: 找不到文件 {file_path}")
except Exception as e:
print(f"错误: {e}")
123