构建可组合的浏览器自动化技能库:从Playwright封装到工作流编排
1. 项目概述一个浏览器自动化技能库的诞生最近在折腾一些需要批量处理网页数据或者模拟用户操作的项目时我总在重复造轮子。比如登录某个网站获取数据需要处理验证码、管理Cookie、应对反爬策略又比如需要定时监控某个页面的价格变化或者自动填写表单。每次都得从头开始写Selenium或者Playwright的脚本调试各种选择器处理异步加载既繁琐又低效。直到我遇到了一个需求需要一个能像搭积木一样快速组合出复杂浏览器自动化流程的工具。这就是我启动bb-browser-skill这个项目的初衷。简单来说bb-browser-skill是一个基于现代浏览器自动化框架如Playwright或Selenium构建的技能Skill库。它的核心思想是“技能化”和“可组合”。我们把常见的浏览器操作比如“打开网页”、“输入文本”、“点击元素”、“提取数据”、“处理验证码”、“滚动页面”等等封装成一个个独立的、可配置的“技能”模块。当你需要实现一个自动化流程时不再需要从头编写冗长的脚本而是像调用函数一样将这些预制的技能按需组合、串联起来形成一个完整的工作流。这个项目非常适合那些经常与网页打交道的开发者、测试工程师、数据分析师以及任何需要自动化重复性网页操作的人。无论你是想批量采集公开数据请注意合法合规、进行自动化测试、监控网站状态还是实现一些个性化的自动操作如自动签到、定时抢购等bb-browser-skill都旨在提供一个更高效、更易维护的起点。它不是一个试图解决所有问题的庞然大物而是一个工具箱让你能更快地打造出属于自己的自动化解决方案。2. 核心设计思路从脚本到技能集的演进传统的浏览器自动化脚本通常是线性的、过程式的。一个脚本文件里混杂了浏览器驱动初始化、元素定位、业务逻辑、错误处理和数据保存代码复用性差一旦目标网站结构稍有变化就需要在整个脚本中寻找并修改多处代码维护成本很高。bb-browser-skill的设计思路就是要打破这种模式。其核心架构可以分解为以下几个层面2.1 技能Skill的抽象与定义这是项目的基石。一个“技能”是最小的可复用操作单元。它需要满足几个条件职责单一只完成一件明确的事情例如“在指定输入框填入文本”而不关心这个输入框在哪个页面、上下文是什么。接口统一每个技能都有标准的输入和输出。通常输入包括浏览器上下文如Page对象、配置参数如选择器、文本内容输出则是操作结果如成功/失败、提取到的数据。可配置化技能的行为应该通过参数来控制而不是硬编码在内部。这使得同一个“点击”技能可以通过传入不同的选择器参数点击页面上任意元素。基于这个定义我们可以将常见的操作抽象成一系列技能导航技能open_url,go_back,refresh交互技能click,fill,select_option,upload_file等待技能wait_for_selector,wait_for_navigation提取技能get_text,get_attribute,screenshot高级技能handle_captcha处理验证码scroll_to_bottom滚动到底部加载更多switch_to_iframe处理iframe。2.2 技能的执行引擎与上下文管理技能本身是静态的需要一个“引擎”来驱动它们执行并管理执行过程中的共享状态。这个引擎主要负责浏览器生命周期管理启动、关闭浏览器创建浏览器上下文和页面Page。技能调度与执行按照定义的流程依次实例化并执行各个技能将上一个技能的输出如获取到的Page对象传递给下一个技能作为输入。上下文Context传递维护一个共享的上下文对象用于在不同技能间传递数据。例如登录技能执行后可以将获取到的登录状态Cookie存入上下文后续所有技能都能自动携带这个状态。错误处理与重试提供统一的错误捕获和重试机制。例如当“点击”技能因元素未加载而失败时引擎可以自动触发“等待”技能然后重试点击。在实现上这个引擎可以是一个简单的循环也可以是一个基于状态机或工作流引擎的复杂调度器取决于项目复杂度。2.3 工作流的组合与描述有了技能和引擎如何描述一个完整的自动化流程这就是工作流Workflow或管道Pipeline的概念。我们需要一种方式来定义技能的执行顺序、依赖关系和参数传递。一种简单直观的方式是使用YAML或JSON等结构化配置文件。例如name: “商品价格监控流程” skills: - name: open_url params: url: “https://example.com/login” - name: fill params: selector: “#username” text: “${env.USERNAME}” - name: fill params: selector: “#password” text: “${env.PASSWORD}” - name: click params: selector: “button[type‘submit’]” - name: wait_for_navigation - name: open_url params: url: “https://example.com/product/123” - name: get_text id: “price_提取” # 给这个技能一个ID以便后续引用其输出 params: selector: “.price” - name: screenshot params: path: “snapshot_{{current_timestamp}}.png”这种方式将流程逻辑与代码完全分离非开发者也能理解和修改简单的流程极大地提高了可维护性和可读性。引擎解析这个配置文件按顺序调用对应的技能类执行。2.4 扩展性设计一个库能否长久生存扩展性至关重要。bb-browser-skill必须允许用户轻松地自定义技能。定义基类提供一个BaseSkill抽象类规定所有技能必须实现execute(context, params)方法。自动发现可以利用Python的入口点entry_points或简单的文件扫描机制自动发现用户自定义的、继承自BaseSkill的类并将其注册到技能库中。依赖注入技能的参数可以支持从环境变量、配置文件、上游技能输出中动态注入增加灵活性。通过这样的设计bb-browser-skill的目标就清晰了降低浏览器自动化的开发门槛和维护成本通过组件化提升效率。开发者可以专注于编写和积累高质量的“技能”而最终用户则可以像拼装乐高一样快速构建出强大的自动化流程。3. 关键技术点与实现细节拆解理解了宏观设计我们深入到具体的技术实现层面。要让bb-browser-skill真正好用、稳定以下几个关键点的处理至关重要。3.1 浏览器驱动选型Playwright vs Selenium这是项目的基础依赖。目前主流的选择是Selenium和Playwright。Selenium老牌王者生态极其丰富社区庞大支持多种语言和浏览器。但需要单独下载浏览器驱动如chromedriver且对现代Web技术如Shadow DOM、自动等待的支持需要额外配置API相对底层。Playwright后起之秀由微软开发。最大特点是开箱即用自带浏览器内核无需管理驱动。其API设计更现代自动等待机制非常强大几乎无需写time.sleep对复杂场景如网络拦截、移动端模拟、下载文件的支持更友好性能也通常更好。对于bb-browser-skill这类追求开发效率和稳定性的项目我强烈推荐使用 Playwright 作为底层驱动。原因如下简化部署用户无需关心chromedriver版本与Chrome浏览器的匹配问题pip install playwright playwright install即可完成环境准备。更强的稳定性内置的自动等待能极大地减少因页面加载速度导致的“元素找不到”错误这是自动化脚本不稳定的主要来源之一。丰富的内置能力如模拟地理位置、设备型号、拦截网络请求、录制操作等这些都可以很方便地封装成高级技能。因此在项目初始化时可以选择将Playwright作为默认或首选后端同时通过抽象层保留对接Selenium的可能性以兼容一些历史项目或特殊需求。3.2 元素定位策略的鲁棒性处理元素定位是浏览器自动化的核心也是最脆弱的环节。页面结构的微小变动就可能导致选择器失效。bb-browser-skill必须在技能层面提供鲁棒的元素定位机制。策略一多选择器回退在一个“点击”技能里不要只依赖一个CSS选择器。可以设计参数接受一个选择器列表按顺序尝试直到找到一个可用的元素。# 在技能内部实现 def execute(self, context, params): page context[‘page’] selectors params.get(‘selectors’, []) # 支持传入数组 for selector in selectors: element page.query_selector(selector) if element: element.click() return {‘success’: True, ‘selector_used’: selector} raise ElementNotFoundError(f“无法通过任何选择器定位元素: {selectors}”)在YAML中就可以这样配置提高容错率- name: click params: selectors: - “button.primary:has-text(‘提交’)” - “div.submit-area button” - “#submitBtn”策略二智能等待与重试将等待逻辑内置到技能中。例如click技能在执行前可以先调用wait_for_selectorPlaywright的page.wait_for_selector本身就有等待功能。还可以在技能级别或引擎级别设置全局重试策略当操作因元素未稳定而失败时自动重试若干次。策略三相对定位与文本定位鼓励使用包含文本内容的定位方式如Playwright的:has-text()这比纯CSS类名或ID更稳定因为前端可能会改样式但按钮文本往往不变。同时提供基于邻近元素、父元素等关系的相对定位技能作为补充。3.3 状态管理与数据流转自动化流程中数据如何在技能间传递是关键。我们引入“上下文Context”对象的概念。它是一个贯穿整个工作流生命周期的字典存储共享数据。上下文内容通常包括浏览器对象browser,context(Playwright的BrowserContext),page。技能输出每个技能执行后的输出结果可以按技能ID存入上下文。例如get_text技能提取的价格文本可以被后续的save_to_database技能读取。用户变量流程中定义的变量如登录后的用户名、搜索关键词等。配置参数从外部传入的全局配置。引擎负责初始化上下文并在执行每个技能时将其传入。技能可以从上下文中读取所需数据也可以将结果写回上下文。这种设计使得技能之间是松耦合的它们通过明确定义的上下文接口进行通信而不是直接相互调用。3.4 错误处理与日志记录一个用于生产环境的自动化工具必须有完善的错误处理和清晰的日志。错误处理分级技能级错误如元素未找到、超时。这类错误应被技能捕获并转换为统一的错误格式如返回{‘success’: False, ‘error’: ‘...’}或抛出特定异常。引擎根据配置决定是重试、跳过还是终止整个流程。流程级错误如某个关键技能如登录失败导致后续流程无法进行。引擎应能识别这种依赖关系优雅地停止并记录错误。系统级错误如浏览器崩溃、网络断开。引擎需要尝试恢复如重启浏览器或彻底终止。日志记录日志应结构化至少包含时间戳、技能名称、参数可脱敏、执行结果成功/失败、耗时。这对于调试和监控流程健康状态至关重要。可以考虑集成像structlog这样的库方便后续将日志输出到文件、控制台或日志系统。注意日志中要避免记录密码等敏感信息。对于敏感参数应在技能定义或日志配置中将其标记为“脱敏”在记录时自动替换为***。4. 实战构建一个完整的商品比价工作流理论说了这么多我们来实战构建一个具体的自动化流程监控多个电商网站上某款特定商品的价格。这个流程会用到多个技能并涉及数据传递。目标每天定时运行检查三个假设的电商网站SiteA, SiteB, SiteC上商品“XYZ手机”的价格并将结果记录到CSV文件中如果发现任一网站价格低于阈值则发送通知。4.1 工作流定义YAML我们将流程分解为可复用的技能并定义两个子流程登录和获取价格以提高复用性。# config/workflow/price_monitor.yaml name: “每日商品价格监控” vars: product_name: “XYZ手机” price_threshold: 2999.00 notification_email: “your-emailexample.com” skills: # 第一阶段数据获取 - name: parallel_for # 一个并行执行技能需要自定义实现 params: items: [“SiteA”, “SiteB”, “SiteC”] skill_sequence: # 对每个网站执行以下技能序列 - name: open_url params: url: “${item.base_url}“ # item 是循环变量这里需要上下文中有base_url映射 - name: run_subflow params: flow_file: “flows/login_${item}.yaml” # 每个网站的登录流程可能不同 if: “${!context.logged_in.${item}}” # 如果未登录才执行登录子流程 - name: run_subflow params: flow_file: “flows/get_price.yaml” flow_vars: product: “${vars.product_name}” - name: set_var # 将获取到的价格存入上下文以网站名作为key params: key: “price_${item}” value: “${last_skill_result.price}” id: “fetch_prices” # 第二阶段数据处理与通知 - name: python_script # 一个执行Python代码的技能需要自定义实现 params: code: | prices {} for site in [‘SiteA’ ‘SiteB’ ‘SiteC’]: price_key f‘price_{site}’ if price_key in context: prices[site] float(context[price_key]) # 找到最低价 if prices: min_site min(prices, keyprices.get) min_price prices[min_site] context[‘min_price’] min_price context[‘min_site’] min_site # 判断是否低于阈值 if min_price float(context[‘vars’][‘price_threshold’]): context[‘need_alert’] True context[‘alert_message’] f‘{min_site} 上的 {context[“vars”][“product_name”]} 价格降至 {min_price}’ id: “analyze_prices” - name: save_to_csv params: data: date: “${current_date}” siteA_price: “${price_SiteA}” siteB_price: “${price_SiteB}” siteC_price: “${price_SiteC}” min_price: “${min_price}” min_site: “${min_site}” file_path: “./data/price_history.csv” if: “${min_price}” # 仅当分析出最低价时才执行 - name: send_email params: to: “${vars.notification_email}” subject: “价格监控警报” body: “${alert_message}” if: “${need_alert}”4.2 关键技能实现示例get_price子流程get_price.yaml子流程负责在已登录的页面上搜索商品并提取价格。这本身也是一个技能序列。# flows/get_price.yaml name: “获取商品价格” skills: - name: fill params: selector: “#search-box” text: “${flow_vars.product}” - name: click params: selector: “button[type‘submit’]” - name: wait_for_navigation - name: wait_for_selector params: selector: “.product-list” state: “visible” timeout: 10000 - name: get_text id: “extract_price” params: selector: “.product-item:first-child .price” # 可能需要对文本进行清洗如去除货币符号 post_process: | import re text result[‘text’] # 匹配数字和可选的小数点 match re.search(r‘[\d.]’ text) return float(match.group().replace(‘’ ‘’)) if match else None4.3 自定义技能开发parallel_for和python_script核心技能库可能不包含并行执行或内联Python脚本的功能这就需要我们自定义。ParallelForSkill实现思路这个技能接收一个列表和一个技能序列然后并发地对列表中每个元素执行该序列。需要注意Playwright的BrowserContext是隔离的可以并行。# skills/parallel_for.py import asyncio from .base_skill import BaseSkill class ParallelForSkill(BaseSkill): name “parallel_for” async def execute(self, context, params): items params[‘items’] skill_sequence params[‘skill_sequence’] # 假设 engine 是引擎实例可以执行技能序列 engine context[‘engine’] async def run_for_item(item): # 为每个item创建子上下文副本 sub_context context.copy() sub_context[‘item’] item # 将当前循环变量注入子上下文 # 执行技能序列 for skill_config in skill_sequence: await engine.run_skill(skill_config, sub_context) return sub_context # 并发执行所有item的任务 tasks [run_for_item(item) for item in items] results await asyncio.gather(*tasks, return_exceptionsTrue) # 处理结果合并回主上下文需谨慎设计合并策略避免冲突 # ... return {‘success’: True, ‘results’: results}PythonScriptSkill实现思路这个技能允许在流程中动态执行一段Python代码极其灵活。# skills/python_script.py import sys import io from .base_skill import BaseSkill class PythonScriptSkill(BaseSkill): name “python_script” async def execute(self, context, params): code params[‘code’] # 安全考虑可以在沙箱环境中执行这里简单演示 # 将上下文中的变量注入到代码的执行环境中 local_vars {‘context’: context, ‘params’: params} global_vars {} try: # 使用 exec 执行代码代码中可以修改 context exec(code, global_vars, local_vars) # 注意exec执行的代码如果修改了local_vars中的context需要同步回传入的context对象 # 这里假设代码直接操作的是传入的context引用所以无需额外操作 return {‘success’: True} except Exception as e: return {‘success’: False, ‘error’: str(e), ‘traceback’: sys.exc_info()}重要警告PythonScriptSkill非常强大但也极其危险因为它可以执行任意代码。绝对不要在不可信的YAML配置文件中使用此技能。如果必须提供此类灵活性应考虑使用严格的沙箱如restrictedpython或仅允许调用白名单内的安全函数。4.4 运行与调度最后我们需要一个主程序来加载YAML配置初始化引擎并运行。# main.py import asyncio import yaml from engine import WorkflowEngine from skills.registry import SkillRegistry async def main(): # 1. 加载技能库 registry SkillRegistry() registry.discover_skills() # 自动发现 skills/ 目录下的技能 # 2. 初始化引擎 engine WorkflowEngine(registry) # 3. 加载工作流配置 with open(‘config/workflow/price_monitor.yaml’ ‘r’ encoding‘utf-8’) as f: workflow_config yaml.safe_load(f) # 4. 运行工作流 result await engine.run(workflow_config) # 5. 处理结果 if result[‘success’]: print(“工作流执行成功”) print(f“最低价{result[‘context’].get(‘min_price’)}”) else: print(f“工作流执行失败{result[‘error’]}”) if __name__ ‘__main__’: asyncio.run(main())为了定时执行可以结合系统的crontabLinux或Task SchedulerWindows或者使用Python的schedule或APScheduler库在程序内部实现定时触发。5. 常见问题、调试技巧与优化建议在实际使用和开发bb-browser-skill这类工具时会遇到各种各样的问题。这里记录一些典型的坑和解决思路。5.1 元素定位失败最头疼的问题症状技能执行时报错提示无法找到元素TimeoutError或ElementNotFound。排查思路手动验证第一时间在真实的浏览器中打开目标页面使用开发者工具F12检查你使用的CSS选择器或XPath是否唯一、准确地定位到目标元素。页面结构可能已更新。检查等待状态页面是否是动态加载的在操作元素前是否等待了足够长的时间或等待了特定元素出现为相关技能增加wait_for_selector前置技能或使用Playwright的page.wait_for_load_state(‘networkidle’)确保页面加载完成。处理iframe目标元素是否在iframe内部如果是需要使用switch_to_iframe技能先切换到对应的iframe上下文操作完成后再切回。处理Shadow DOM现代前端框架如Vue、React组件可能使用Shadow DOM。Playwright提供了element_handle.shadow_root来穿透Shadow DOM进行定位需要封装对应的技能。使用更稳健的定位器文本定位page.get_by_text(“提交”)或:has-text(“提交”)。角色定位page.get_by_role(“button” name“提交”)。这是WAI-ARIA标准可访问性好相对稳定。测试定位器在Playwright Inspector或Selenium IDE中录制操作查看它们生成的定位器往往更健壮。5.2 流程执行速度慢症状工作流运行时间远超预期。优化建议并行化对于相互独立的操作如同时监控多个不相关的网站使用类似parallel_for的技能进行并发执行充分利用资源。减少不必要的等待精确设置等待条件避免使用固定的time.sleep。多用wait_for_selector、wait_for_function等条件等待。复用浏览器上下文对于需要登录同一域名的多个操作尽量在同一个BrowserContext和Page中完成避免重复登录。bb-browser-skill的上下文管理应支持这一点。禁用非必要资源在启动浏览器时可以设置不加载图片、CSS甚至JavaScript如果操作不需要这能显著提升页面加载速度。# Playwright 示例 browser await playwright.chromium.launch() context await browser.new_context( viewport{‘width’: 1920 ‘height’: 1080} # 拦截并中止图片、样式表等请求 bypass_cspTrue # 可以通过 page.route 进一步控制资源加载 )无头模式生产环境运行务必使用无头模式headlessTrue没有GUI渲染开销会快很多。5.3 反爬虫机制应对症状脚本运行几次后IP被封锁或出现复杂的验证码。应对策略务必在合法合规前提下控制频率在技能间添加随机的延迟技能delay模拟人类操作间隔。避免高频、规律性的访问。轮换User-Agent通过浏览器上下文技能在启动时随机设置合理的User-Agent。使用代理IP在引擎中集成代理池定期更换IP。这需要自定义浏览器启动技能支持代理服务器参数。重要提示使用代理必须遵守目标网站的服务条款和法律法规不得用于非法爬取、攻击或干扰网站正常运行。验证码处理这是一个专门领域。可以封装handle_captcha技能其内部可以集成多种方案人工介入流程暂停弹出截图让用户手动输入然后继续。第三方打码平台调用如2Captcha、DeathByCaptcha等平台的API需要付费。本地OCR识别对于简单的图形验证码可以使用Tesseract等OCR库尝试识别但成功率有限。模拟真人行为使用Playwright的mouse.move()、mouse.click()模拟更自然的鼠标移动轨迹而非直接的元素点击。5.4 配置复杂与维护难题症状YAML配置文件变得冗长、难以理解和维护。优化建议模块化与复用将通用的流程片段如登录、退出、搜索抽离成独立的子流程YAML文件通过run_subflow技能调用。如上文示例。变量与模板支持在YAML中使用变量如${vars.product_name}和简单的模板语法如snapshot_{{current_timestamp}}.png减少硬编码。配置校验为技能定义JSON Schema在加载YAML时进行校验提前发现参数错误而不是等到运行时才报错。可视化编辑器进阶可以考虑开发一个简单的Web界面通过拖拽技能块、配置参数的方式来生成YAML配置文件这对非技术用户非常友好。5.5 调试技巧开启详细日志在引擎和技能中增加详细的DEBUG级别日志记录每个技能的入参、开始结束时间、中间状态。非无头模式运行在开发调试阶段设置headlessFalse亲眼看着浏览器执行每一步操作最直观。录制与回放利用Playwright的Codegen功能playwright codegen先录制一遍手动操作生成基础脚本再将其改造成技能配置可以快速上手。使用Playwright Inspector设置PWDEBUG1环境变量运行脚本会自动打开Inspector工具可以单步调试、查看页面快照、检查选择器。保存错误快照在引擎的全局错误处理中自动截取失败时刻的页面截图和HTML源码保存到指定目录便于事后分析。# 在引擎的异常捕获中添加 except Exception as e: screenshot_path f“./debug/error_{int(time.time())}.png” await context[‘page’].screenshot(pathscreenshot_path, full_pageTrue) html await context[‘page’].content() with open(f“./debug/error_{int(time.time())}.html” ‘w’ encoding‘utf-8’) as f: f.write(html) raise e开发bb-browser-skill的过程是一个不断将具体问题抽象化、将复杂流程模块化的过程。它可能始于某个具体的爬虫或测试需求但最终沉淀下来的是一套应对浏览器自动化这个领域的思维模式和方法论。最大的体会是良好的抽象和设计比实现某个具体功能更重要。一开始就思考如何让技能更独立、接口更清晰、组合更灵活会为后续应对千变万化的需求打下坚实的基础。