提到程序自动化填写网页表单数据可能很多人第一反应是爬虫、抢票脚本这类偏“灰色”的用途。但实际上日常工作中最常见的需求反而是非常朴素的重复录入每天从Excel里整理一批新信息打开后台系统一条条复制粘贴到网页表单里点提交再重复下一单。量大一点、字段多一点的时候不光手累还特别容易串行把“手机号”填到“备注”里点完提交才发现又要回头改。我前阵子就接过这么一档子事内部管理系统月底要集中登记一批设备资产信息小一千条记录全靠人肉填的话一天都未必填得完眼睛还得一直盯着屏幕。所以我就用 Python 配合 Playwright 写了一个批量表单自动化填写的小脚本把读取数据、定位元素、填写表单、提交校验、记录日志这一整套流程串了起来。实测下来原来需要大半天的工作量压缩到几分钟跑完中间偶尔有填错的记录也能通过日志快速定位修掉。这篇文章就把这套方案的完整思路和实操细节展开讲一遍包括方案选型、Playwright 环境搭建、各种表单控件的处理方式、批量数据的读取组织、常见坑的排查以及运行稳定性的设计。适合有 Python 基础、想把手头重复的网页录入工作自动化但不知道从哪入手的同学参考。当然所有自动化操作都要用在自己有权限、被允许自动化的系统上不要去做任何违反平台规则的事。1. 项目整体设计与方案选型1.1 这个项目到底在解决什么问题一句话说清楚这个项目的目标用程序代替人工把外部数据源里的结构化数据自动填写到网页表单中并完成提交确认。看起来很简单但拆开之后会涉及几个关键环节。首先是数据怎么来通常是从 Excel、CSV、JSON 这类文件里读取也可能是调用接口拉取数据质量参差不齐字段可能为空、格式可能不对。其次是表单怎么识别不同系统的前端实现差异很大有的是老式同步页面有的是前后端分离的 SPA单页应用还有的表单控件是第三方组件封装的定位元素的难度完全不同。再次是表单怎么填普通的输入框、下拉框、单选按钮、日期选择器、文件上传每种控件的操作方式都不一样。最后是提交后怎么确认页面是跳转还是局部刷新成功提示是什么失败提示是什么都需要脚本能够准确判断否则跑了一遍也不知道到底成没成。这个项目关注的核心不是某一个孤立环节而是“数据 - 页面 - 表单 - 提交 - 校验”的完整链路。把它打通之后受益的不仅是录入效率更重要的是准确率。人工填表在几千条数据的场景下出错几乎是必然的程序填表只要定位逻辑写对、数据校验做好每一条都能按同样的标准执行。所以在规划这个项目的时候我从一开始就没打算写“一次性临时脚本”而是按一个小工具的标准来做数据文件可替换浏览器行为可观察运行日志可追踪失败记录可重试。后面所有方案选型都是围绕这几个目标展开的。1.2 技术选型为什么是 Playwright 而不是 Selenium提到网页自动化很多人的第一反应是 Selenium。它资历老、资料多、网上教程一抓一大把很多测试团队的老框架也都是基于 Selenium 的。但我这次没有选它原因其实挺实际的。一是驱动管理。Selenium 需要单独下载对应浏览器版本的 driver比如 Chrome 升级了driver 版本不匹配脚本就直接跑不了。Playwright 把这一步封装掉了安装的时候会连带把浏览器内核一起装好版本配套问题基本不用操心。二是等待机制。Selenium 默认的隐式等待、显式等待写起来比较啰嗦而 Playwright 的定位操作自带自动等待元素没有出现就等超时再报错脚本写起来干净很多。三是 API 设计。Playwright 的 API 风格更现代支持链式调用对 iframe、新标签页、弹窗这些场景的处理也更顺手。我把两者做个小对比方便你自己判断对比项SeleniumPlaywright浏览器驱动管理手动下载并匹配版本安装时自动下载配套浏览器自动等待需要显式编写等待逻辑定位操作内置自动等待元素定位 APIfind_element 系列locator 链式定位iframe / 多标签处理需要切换 contextframe_locator / 多 page 原生支持调试手段依赖外部工具自带 trace、截图、录屏当然Playwright 也不是没有缺点。比如它较新的 API 设计需要一点学习成本社区的中文资料比 Selenium 少一些不过这两年文档和教程已经非常丰富了。还有一个实际问题是如果目标系统只支持非常老的浏览器Playwright 的兼容性可能不如 Selenium 那么广。但对我手里这种内部管理系统场景来说Playwright 的方案完全够用开发效率还高出一截所以最终确定用它。2. 环境准备与最小可运行示例2.1 环境安装Python Playwright这个项目我用的是 Python 环境版本 3.10 以上就行。整体安装分两步。第一步装 Python 包pip install playwright第二步安装浏览器内核。这一步别漏只装包不装内核是跑不起来的playwright install chromium这里解释一下Playwright 支持 Chromium、Firefox、WebKit 三种内核日常做 Web 自动化选 Chromium 就够了。它下载的是 Playwright 自己管理的浏览器版本和系统里装的 Chrome 互不影响这样可以保证驱动和内核始终是配套的。装完之后可以先用一小段代码验证一下环境是否正常from playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch(headlessFalse) page browser.new_page() page.goto(https://example.com) print(page.title()) browser.close()运行这个脚本如果能看到浏览器窗口打开并打印出页面标题说明环境已经就绪了。一个小提醒在公司内网环境有时默认源下载 Chromium 会比较慢或失败可以设置 Playwright 的下载源镜像或者通过环境变量指定浏览器下载路径。这个不是标准配置但实践中经常遇到卡住的话优先检查这一步。2.2 第一个自动化脚本打开页面并填入数据环境确认之后我建议先别急着写批量逻辑先拿一个最简单的页面跑通“打开表单 - 填写字段 - 提交”的完整流程。举个例子假设目标页面上有一个登录表单包含用户名和密码两个输入框一个提交按钮。Playwright 的代码是这样的from playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch(headlessFalse) page browser.new_page() page.goto(http://your-system.com/login) # 填写用户名 page.locator(#username).fill(admin) # 填写密码 page.locator(#password).fill(123456) # 点击登录按钮 page.locator(button[typesubmit]).click() # 等待跳转或出现某个登录后的元素 page.wait_for_url(**/index) print(登录成功) browser.close()这段代码看起来简单但里面藏着一个关键点fill 和 click 这两个操作都带有自动等待能力。fill 会等元素出现在 DOM 里并且可交互click 会等元素处于稳定可点击状态不需要像 Selenium 那样在每个操作前手动加 WebDriverWait。我在实际中见过很多初学者在这时候犯一个错误一上来就用 sleep 等待页面加载。不是说 sleep 完全不能用而是它固定等多少秒就等多少秒网络慢的时候等不够快的时候又浪费大量无用时间。项目里等元素的核心手段应该是 Playwright 自带的动作等待sleep 最多在极少数“实在没有合适等待条件”的场景里兜底。2.3 等待机制为什么不能无脑 sleep展开说下等待机制。很多人有固化思维“写完点击之后页面要跳转那就先等两秒再操作下一页。”这个思路的问题在于页面加载速度受网络、服务器状态、页面复杂度影响很大固定等 2 秒只是赌它恰好 2 秒内完成。Playwright 的推荐做法是等待一个“条件”。这个条件可以是元素出现、URL 变化、接口响应也可以是某个元素消失。比如上面登录的例子点击提交按钮之后页面可能跳转到/index那就用wait_for_url等 URL 变成目标地址如果跳转后某个侧边栏菜单需要加载出来那就等这个菜单元素可见。我自己的实践习惯是表单提交后优先等一个结果类元素出现比如“提交成功”的提示文本、列表中新增的记录。如果页面会跳转用wait_for_url。如果上述都没有稳定的标记再考虑用page.wait_for_timeout作为兜底但会避免在核心流程里大量依赖它。这套规则在后面的批量脚本里非常重要因为批量场景下如果每一步都靠 sleep 等累计时间会非常可观而且稳定性极差。自动等待看着只是省了几行代码实际上是把脚本从“靠运气跑”变成了“按条件跑”。3. 核心细节元素定位与常见表单控件处理3.1 元素定位让脚本“看得见”页面表单填写的核心操作就是定位元素然后往里面填数据。Playwright 的定位器用locator方法支持 CSS 选择器、文本、层级关系等多种方式比 Selenium 的 find_element 灵活不少。先看几种最常用的定位思路# 通过 id 定位 page.locator(#username) # 通过属性定位 page.locator(input[nameemail]) page.locator(input[placeholder请输入手机号]) # 通过文本定位按钮 page.locator(button:has-text(提交)) # 通过标签关联的文本定位输入框 page.get_by_label(手机号) # 链式定位先找到某个表单容器再找里面的输入框 page.locator(#main-form).locator(input)在我个人经验里一个非常实用的原则是优先选择不易变化的定位方式。有些前端系统里输入框的 id 是动态生成的每次打开页面都可能变化这种时候用name或者placeholder往往更稳定。而像“提交”“保存”这种按钮用文本定位通常比用深层 CSS 路径直观得多也更不容易被前端改成别的选择器后失效。还有一个隐藏的坑页面上可能同时存在多个匹配相同选择器的元素。比如有两个表单里面都有input[namename]直接定位就会报错或者选中第一个。这时候需要收紧范围把locator限定到正确的表单内部。Playwright 还提供了.first、.last、.nth(index)来处理多元素情况但我的建议是尽量通过更精确的定位器来避免这种问题而不是依赖“取第一个”。3.2 不同表单控件的填写方式不同的表单控件在 Playwright 里的操作方法差异比较大。我整理了常见的几种场景普通文本框page.locator(#title).fill(测试标题)多行文本域和输入框一样用 fill只是内容里可能包含换行符page.locator(#description).fill(第一行\n第二行)下拉选择框原生 select 用 select_option 方法可以根据 value、label 或 index 选择page.locator(#category).select_option(valueasset) page.locator(#category).select_option(label固定资产) page.locator(#category).select_option(index1)需要注意select_option只对原生select有效。很多现代前端框架用了自定义下拉组件比如点击一个 div 弹出列表然后选中的那种这时候select_option会直接报错。我遇到这种情况时常规做法是先点击下拉框的当前展示区域再在弹出的列表中定位要选的项进行点击本质上是模拟人的操作。单选框和复选框# 选中 page.locator(#agreement).check() # 取消选中 page.locator(#agreement).uncheck()用check的好处是如果元素已经处于选中状态它会跳过操作不会报错如果用 click 硬点可能会导致状态反转反而把已选中的给点掉了。日期输入框HTML 的日期输入框可以直接用 fill 写入2025-03-15格式的文本page.locator(#purchaseDate).fill(2025-03-15)但如果是日期组件是自定义的比如常见的时间选择器插件直接 fill 往往没用。这种我一般是点击日期框后直接用键盘输入日期或者逐一切换年、月、日然后点击具体日期。处理方式视组件而定。文件上传Playwright 的set_input_files可以直接设置上传文件的路径不需要真的操作系统文件选择对话框page.locator(input[typefile]).set_input_files(/path/to/file.png)多个文件就传一个路径列表。这个方法也可以用来覆盖已选择的文件比如上传错了需要换文件。对比来看绝大多数表单控件都有对应的自动化操作方式关键是先判断控件是原生 HTML 还是自定义组件。判断方法很简单在浏览器开发者工具里看这个元素的标签。如果是input、select、textarea原生方法基本都能处理如果标签是div或有很重封装的组件类名就得走“模拟点击”路线。3.3 数据来源从 Excel / JSON 批量读取脚本本身只是“手”数据源才是“脑”。批量填写的第一个准备工作就是设计好数据从哪读、怎么读。我这次的项目里数据是存在 Excel 里的字段有设备编号、设备名称、类别、所属部门、采购日期、备注、照片路径。用openpyxl读取比较直接from openpyxl import load_workbook wb load_workbook(设备登记清单.xlsx) ws wb.active rows [] for row in ws.iter_rows(min_row2, values_onlyTrue): code, name, category, department, date, remark, photo row rows.append({ code: code, name: name, category: category, department: department, date: date.strftime(%Y-%m-%d) if date else , remark: remark, photo: photo, })这里有一个很关键的处理Excel 里的日期单元格读出来是datetime对象直接往页面上的日期输入框填会出现格式问题所以需要先格式化成字符串。另一个常见问题是 Excel 里某些列可能有空值或者单元格前后带空格这些都要在读数据的时候做好清洗不然填到表单里容易校验失败。如果数据是 JSON 格式读取更简单import json with open(data.json, r, encodingutf-8) as f: rows json.load(f)无论数据源是什么我建议在真正开始填表单前先打印或保存一条汇总日志比如“本次共读取到 962 条数据其中采购日期为空的数据有 12 条”。这一步能提前拦截很多数据问题避免脚本跑到一半突然因为某条脏数据抛异常。4. 实操过程一个完整的批量填写示例4.1 场景设计内部设备信息登记光讲语法和原理比较零散我把整个项目串成一个完整例子。假设现在有一个内部设备管理系统页面上有一个设备信息登记表单包含以下字段设备编号文本框设备名称文本框设备类别下拉选择框可选电脑、打印机、网络设备、办公家具所属部门自定义下拉组件采购日期日期输入框备注多行文本域设备照片文件上传所有数据来自一个 Excel 文件大约有 900 多条记录。我的目标是把这 900 多条全部自动填完每条填写完会自动提交然后进入下一条。这个场景非常典型字段种类覆盖了基本的表单控件文本框、下拉框、自定义组件、日期、文件上传。跑通它之后换到别的系统上改改定位器就能用。4.2 脚本完整实现脚本的整体逻辑是读取数据 - 打开登记页面 - 按字段逐项填写 - 提交表单 - 等待提交成功标记 - 回到列表页/或继续下一条 - 记录日志。完整脚本如下已脱敏import time from pathlib import Path from openpyxl import load_workbook from playwright.sync_api import sync_playwright BASE_URL http://your-system.com EXCEL_PATH 设备登记清单.xlsx PHOTO_DIR Path(photos) def read_excel(path): wb load_workbook(path) ws wb.active rows [] for row in ws.iter_rows(min_row2, values_onlyTrue): code, name, category, department, date, remark, photo row rows.append({ code: str(code).strip() if code else , name: str(name).strip() if name else , category: str(category).strip() if category else , department: str(department).strip() if department else , date: date.strftime(%Y-%m-%d) if date else , remark: str(remark).strip() if remark else , photo: str(photo).strip() if photo else , }) return rows def fill_form(page, item): page.locator(#deviceCode).fill(item[code]) page.locator(#deviceName).fill(item[name]) page.locator(#deviceCategory).select_option(labelitem[category]) # 自定义下拉组件点击后选择对应项 page.locator(#department-select).click() page.locator(.department-option:has-text( item[department] )).click() # 日期输入 if item[date]: page.locator(#purchaseDate).fill(item[date]) # 备注 page.locator(#remark).fill(item[remark]) # 文件上传 photo_path PHOTO_DIR / item[photo] if photo_path.exists(): page.locator(input[typefile]).set_input_files(str(photo_path)) else: print(f[警告] 照片文件不存在: {photo_path}) def main(): rows read_excel(EXCEL_PATH) total len(rows) print(f共读取 {total} 条数据) with sync_playwright() as p: browser p.chromium.launch(headlessFalse) page browser.new_page() page.goto(BASE_URL /login) # 这里略去登录处理登录成功后保持会话 page.goto(BASE_URL /device/add) for idx, item in enumerate(rows, start1): try: fill_form(page, item) page.locator(button:has-text(提交)).click() # 等待成功提示出现 page.locator(.success-tip).wait_for(timeout8000) print(f[成功] {idx}/{total} {item[code]}) except Exception as e: print(f[失败] {idx}/{total} {item[code]}: {e}) page.screenshot(pathffail_{idx}.png) # 出现失败时暂停方便人工排查 input(按回车继续处理下一条数据...) # 回到新增页面清理表单状态 page.goto(BASE_URL /device/add) time.sleep(0.5) browser.close() if __name__ __main__: main()这个脚本里的几个设计点都值得说明。第一fill_form函数把单条数据的填写逻辑独立出来后续如果要适配不同的表单页面只需要改这一个函数里的定位器即可。第二每条记录都进入 try-except 包裹失败时截图并打印错误信息不会因为一条脏数据让整个程序崩溃。第三失败后使用input(按回车继续)暂停这是在有人值守的情况下最直接的人工介入方式我可以趁机看看页面到底出了什么问题手动处理完再继续。有一个细节需要注意每次提交后表单可能还会保留上一次填写的数据直接开始填下一条会串数据。所以脚本在每条记录处理完之后主动跳转回新增页面强制刷新出一个干净的表单。这也是一种很实用的“状态隔离”技巧。4.3 运行结果与日志优化跑通第一版之后我并没有直接用终端 print 去盯结果而是加了一层简单的日志输出把每次填写的情况写入文件方便跑完全程后拉出来复盘。一个比较简单的做法是用 logging 模块import logging logging.basicConfig( filenamerun.log, levellogging.INFO, format%(asctime)s %(levelname)s %(message)s )然后把 print 都换成 logging。跑完之后用 grep 统计成功和失败的数量grep 成功 run.log | wc -l grep 失败 run.log | wc -l如果失败超过 0 条就把失败的行号、设备编号和报错信息提取出来针对性地修数据或修定位器再单独补跑失败的部分。日志里面还应记录一条关键信息每一条数据的原始内容和程序实际填进去的字段值。比如填写完某条数据后把 item 字典的完整内容打一条日志万一有字段数据本身不对回头对照日志就能确认是数据问题还是脚本问题。5. 常见问题与排查技巧实录5.1 元素找不到 / 定位失败做网页自动化遇到最多的报错基本就是定位不到元素。Playwright 的报错信息一般会明确指出是“等待超时”还是“找到多个元素”但光看报错还不够必须学会自己排查。我总结了一套排查顺序等没等够元素是否出现在 DOM 里动态渲染的页面需要点击某个 tab 才出现对应表单或者接口返回慢导致列表没出来。这时候不要扩大超时时间应该先确认触发条件是否满足。选没选对打开浏览器开发者工具手动复制实际页面的 HTML 结构看看 id、name、类名是否和代码里写的一致。有时候后天改版过定位器全部失效就是这种原因。是不是同一个元素页面上可能有多个相同 id 或相同文案的元素尤其常见于表格和弹窗并存的页面。Playwright 会提示 strict mode violation也就是严格模式冲突。解决办法是收紧定位范围比如先限定在弹窗内。是不是在 iframe 里如果目标元素被嵌在 iframe 中主文档里直接定位是永远找不到的。这种要先用 frame_locator 切进 iframe 再定位。5.2 iframe、弹窗与新标签页iframe 处理是这个项目里绕不开的话题。不少后台系统的表单其实都嵌在 iframe 里或者附件上传区域是独立 iframe。Playwright 的使用方式是frame page.frame_locator(iframe[src/form]) frame.locator(#username).fill(test)注意这里frame_locator返回的对象可以直接继续调用 locator所有后续操作都限定在这个 iframe 内部不需要像 Selenium 那样来回 switch。弹窗方面常见的 alert、confirm 框用 dialog 事件处理page.on(dialog, lambda dialog: dialog.accept())如果系统是点击按钮后新开标签页Playwright 的expect_page可以捕获这个新页面from playwright.sync_api import expect with context.expect_page() as new_page_info: page.locator(button[target_blank]).click() new_page new_page_info.value new_page.wait_for_load_state()多标签页的场景下每个page对象是独立的操作哪个页面的元素就在哪个 page 上定位千万不要跨 page 混用选择器。5.3 验证码与登录态保持网页表单自动化绕不开的一个话题是登录和验证。我的原则很明确不要尝试去破解或绕过任何验证机制这既不合规也不稳定还可能给你自己和目标系统带来麻烦。如果目标系统有验证码我的处理方式是分两步。第一步尽量复用登录态。通过 Playwright 手动登录一次后把浏览器的存储状态保存下来后续脚本直接加载避免每次运行都重新登录# 手动登录后保存状态 context.storage_state(pathstate.json) # 下次直接加载 context browser.new_context(storage_statestate.json)很多内部系统登录后会话可以维持几天这个技巧能省掉大量重复登录的时间。第二步如果系统在操作过程中出现了人机验证页面比如那种滑块或点选验证脚本识别到特征后主动暂停等我人工处理完再继续。这相当于在自动流程中设置了一个“人工介入闸口”。做法上可以监控特定元素出现一旦出现就触发输入等待if page.locator(#captcha-panel).count() 0: input(检测到人工验证请手动处理处理完成后按回车继续...)这种“自动化为主、人工兜底”的思路在真实业务场景里往往是最靠谱的方案既保证了效率又不越界。5.4 稳定性设计超时、重试与断点续跑批量跑几百上千条数据时稳定性比功能本身更重要。脚本挂了不可怕可怕的是跑到一半挂了你不知道从哪条继续跑重了还会产生重复数据。我的经验是几个维度同时做。超时控制。所有等待操作都要设置超时时间。Playwright 的wait_for默认是 30 秒批量场景里我会压到 8-10 秒因为正常情况下提交结果几秒内就该出来等太久说明大概率出问题了。失败重试与暂停策略。单条数据失败先重试一次如果还是失败就跳过或暂停。重试逻辑要小心如果失败原因是重复提交、表单校验不通过重试一百次也没用反而可能造成脏数据。我一般在重试前判断失败类型只有超时类错误才值得重试。断点续跑。简单实现方式是在日志里记录当前处理到的行号或者单独维护一个“已处理列表”重启脚本时跳过这些行。这样即使中途浏览器崩溃重新跑一遍也只会补充处理未完成的部分。在我的项目里最稳定的一版方案是这样每条数据处理前先检查“已处理记录集合”处理成功才写入集合重启后自动跳过集合里的行单条失败先重试一次重试仍失败则截图并暂停等待人工处理。这套组合下来近千条数据的投放过程基本做到了无人值守。最后再分享一点个人感受。表单自动化的门槛其实不高Python 基础加一点 Playwright 的 API 知识就能上手难点从来不是“能不能填”而是“填得稳不稳、准不准”。我在这个项目里踩过的坑大部分都集中在等待条件、元素定位和数据清洗上这三块做好整个脚本的骨架就立住了。另外这种自动化工具做出来之后也别忘了守住边界——只在你有权限、被允许自动化的系统和场景下使用别拿它去刷接口、跑数据、突破平台规则。工具本身是中性的用在哪、怎么用才是决定它价值的关键。