ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Playwright Python文件上传下载自动化:从入门到避坑

Playwright Python文件上传下载自动化:从入门到避坑

说实话,做UI自动化的朋友应该都有这种经历:几十条用例,点击、输入、断言全部绿了,一到“上传附件”或者“下载报表”这种环节就直接卡死。我最早用Selenium处理文件下载时,还要在Windows底层模拟键盘弹窗,一套脚本写得又长又脆。后来切到Playwright,发现Python版Playwright把文件上传和下载抽象得特别顺手,几乎是我现在最省心的模块之一。

这篇文章就只聊一个主题:Playwright Python环境下的文件上传、下载与文件操作自动化。我会把上传控件的识别方式、download事件的监听、无头模式下的差异、并发下载和常见坑位一次讲完,代码都是能直接抄走的。

1. 文件上传自动化:先学会识别控件类型

1.1 原生input、遮罩层与自定义控件:三种形态一眼看穿

很多人一上来就调用set_input_files,发现怎么都不生效,为什么?因为你没搞清楚页面上的上传控件真实形态。HTML里的上传源头只有一个,就是input[type=file],这是浏览器唯一允许选择本地文件的入口。但是前端为了实现好看的样式,会在原生input外面盖一层div、button甚至一个canvas,真正被点击的其实是那个可见元素,而不是原生input。这就是“遮罩层”的情况。

还有一类自定义控件,它可能是一个div绑定click事件,然后用JS动态创建一个隐藏的input[type=file],甚至是用第三方上传组件包了一层。判断方法很简单:打开DevTools,在Elements面板里搜file,或者直接在Console里执行document.querySelectorAll('input[type=file]'),数一数有几个。你也可以在Playwright里快速验证:

from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch(headless=False) page = browser.new_page() page.goto("https://example.com/upload-demo") file_inputs = page.locator("input[type=file]") print(file_inputs.count()) for i in range(file_inputs.count()): el = file_inputs.nth(i) print(el.get_attribute("name"), el.is_visible())

这里注意一个细节:is_visible()返回False不代表不能用set_input_files赋值,很多原生input被隐藏后依然能接收文件路径。关键在于你必须先确认这个input确实存在。如果整个页面连input[type=file]都没有,那就是纯自定义控件,需要走后面我讲的file_chooser事件,或者让开发在内部暴露一个可接受文件输入的字段。

1.2 最稳定的set_input_files:用法与避开浏览器的隐藏限制

set_input_files是Playwright对文件上传最直接的API,它绕过系统文件选择弹窗,直接把本地文件路径“塞”给input。和Selenium时代最大的区别是,你完全不需要处理Windows/macOS的弹窗识别,也不用装什么第三方桌面自动化工具,浏览器层面的文件选择逻辑被Playwright接管了。

先看最简单的单文件上传:

from pathlib import Path upload_path = Path("C:/data/订单数据.xlsx") with sync_playwright() as p: browser = p.chromium.launch(headless=False) page = browser.new_page() page.goto("https://example.com/internal/upload") page.set_input_files("input[name='file']", upload_path) page.click("button#submit") page.wait_for_selector("#upload-success")

注意几点。第一,文件路径建议统一用pathlib.Path处理,不要在代码里手写反斜杠字符串拼接,Windows和Linux的路径分隔符不同,这一步能省掉后续大量跨环境问题。第二,set_input_files可以传多个文件路径,只要input本身允许多选,你传一个列表即可:

page.set_input_files("input[type=file]", [ "C:/data/合同A.pdf", "C:/data/合同B.pdf", "C:/data/合同C.pdf" ])

第三,这个API只对真实的input[type=file]有效。如果你的上传控件压根不是input,调用时会直接报错或者没有任何反应。先做一步locator.count()确认元素存在,比盲试效率高得多。

我在实操中还发现一个容易踩的坑:部分页面上传后会触发图片预览、文件大小校验或者进度条动画。这时候不要光看上传框有没有反应,而是要用wait_for_selector去等待真正的反馈元素出现,比如“上传成功”文案、图片img标签的src变化,或者按钮从可用变成禁用再恢复。否则脚本很容易在上传后紧接着点击提交时,因为文件尚未处理完而丢请求。

1.3 file_chooser事件:连动态生成的上传框也能hook住

有些框架在点击上传按钮的瞬间才动态生成input,如果你先定位再赋值,就会扑个空。Playwright为这种场景提供了file_chooser事件,思路是:先注册一个对文件选择框的等待,然后触发点击,最后拿到文件选择框对象并设置文件。

with page.expect_file_chooser() as fc_info: page.click("button#upload-trigger") # 这个按钮可能在点击后创建input file_chooser = fc_info.value file_chooser.set_files("C:/data/项目验收文档.pdf")

这里要解释一下expect_file_chooser()的工作原理:它会在事件循环里等待浏览器出现“文件选择器”请求,而点击操作会触发这个请求,无论这个input是原本就在页面里,还是点击后动态创建的,都能捕获到。所以遇到自定义上传控件,这是一个比硬定位input更稳的兜底方案。

还有一种情况:点击上传按钮后前端弹出了一个自己的对话框(用div模拟的),里面又嵌套了一个上传区域。这时你看到FileChooser事件没有触发,是因为点击源头被前端JS拦截,根本还没走到浏览器文件选择阶段。排查方法是先看按钮绑定的事件,如果它调用了input.click(),那下一步必须先点那个隐藏input,或者直接调用file_chooser相关事件。实战里我通常直接用expect_file_chooser包住“最终引发文件选择的那一次点击”,而不是包住外层按钮。

2. 文件下载自动化:download事件才是正解

2.1 先配置context:accept_downloads与自定义下载目录

上传说完了,再来看下载。Playwright处理下载的思路和上传一样,紧扣浏览器事件模型,它不弹系统窗口,而是触发一个download事件让你在脚本里接管。新建浏览器上下文时,可以显式设置accept_downloads=True,虽然新版本里这个参数默认已经是True,但显式写出来能让排查问题的人一眼看懂。

with sync_playwright() as p: browser = p.chromium.launch() context = browser.new_context( accept_downloads=True, base_url="https://example.com" ) page = context.new_page()

还需要理解“下载文件最终去了哪”。如果不在代码里做任何处理,Playwright会把下载文件存在一个临时目录,随后可能被清理。你需要在下载事件触发后,通过download.path()拿到这个临时文件路径,并用save_as()把它保存到你的目标位置。千万不要直接拿download.path()当最终产物去给用户用,它只是临时文件。

有些文章会建议在new_context里设置downloads_path参数,让下载文件固定落在某个目录。我只能说这个参数在部分版本的Python Playwright里存在,但我实际使用中调用save_as()的机会更多,因为后者可以同时改文件名,更适合自动化的归档需求。你自己跑一遍就知道哪个顺手脚。

2.2 用expect_download捕获下载事件并save_as

捕获下载的标准姿势是:先把expect_download()和触发下载的点击放进同一个with块,点击之后Playwright会等待下载事件,再让脚本做后续处理。

import os from pathlib import Path download_dir = Path("C:/tmp/downloads") download_dir.mkdir(parents=True, exist_ok=True) with sync_playwright() as p: browser = p.chromium.launch() page = browser.new_page() with page.expect_download() as download_info: page.click("button#export-report") download = download_info.value # 等待文件下载完成 download_path = download.path() if download_path is None: raise RuntimeError(f"下载失败: {download.failure}") final_path = download_dir / download.suggested_filename download.save_as(str(final_path)) print(f"已保存到: {final_path}")

这段代码有几个关键点值得展开。第一,expect_download()必须在点击之前进入,否则点击已经触发了下载事件,你再注册监听就晚了。第二,download.path()在下载完成前可能返回None,严格说这是在等待之后才调用的,如果下载失败,download.failure会返回异常信息,可以用来定位服务端是否返回了错误页面。第三,save_as()相当于把临时文件复制到目标位置,它会阻塞到文件写完毕,所以不需要额外sleep。

我还习惯在save_as()之前打印一下suggested_filename,因为有些服务端会用时间戳或随机串当文件名,如果脚本里要按业务规则改名,这一步就必须做映射。建议的逻辑是:保留suggested_filename方便人工核对,再拼接业务前缀。

2.3 download对象的常用属性:suggested_filename、path、failure

download对象虽然不像page那样有那么多上层方法,但属性信息足够日常排障。这里分享一张速查表,是我平时排查下载问题时一定先过一遍的:

属性/方法类型用途说明
suggested_filenamestr服务端建议的文件名,浏览器通常从响应头解析
path()str/None临时文件路径,下载完成后可用
failurestr/None下载失败时返回异常信息,成功为None
urlstr触发下载的请求地址
save_as(path)none保存下载文件到指定路径
cancel()none取消尚未完成的下载

用suggested_filename保存文件时注意两点。一是如果文件名包含Windows非法字符(<>:"/|?*),直接拼接路径会报错,需要先用正则清理;二是中文文件名在某些系统下可能显示成了URL编码格式,保存后名字是%E5%90%88%E5%90%8C.pdf这种,出现这种问题多半是服务端没有正确设置Content-Disposition头,这种我建议直接按业务规则重命名,不要依赖服务端的返回值。

3. 无头模式下文件操作照样能跑:关键配置差异

3.1 headless=True下download一样能触发,但要注意校验方式

很多人一听说无头模式就慌,觉得没有界面还能弹下载框吗?事实是,Playwright的下载机制并不依赖可见窗口里的下载栏,它通过浏览器底层的Download事件与脚本通信,所以headless=False和headless=True都能正常触发下载。我做批量报表导出时,基本全是在无头模式下跑的。

with sync_playwright() as p: browser = p.chromium.launch(headless=True) context = browser.new_context(accept_downloads=True) page = context.new_page() with page.expect_download(timeout=60000) as download_info: page.click("button#export-all") download = download_info.value if download.failure: print("下载失败:", download.failure) else: target = Path("output/") / download.suggested_filename download.save_as(str(target))

无头模式下载真正需要操心的是超时和资源清理。比如报表数据量大,后端要生成几十秒,expect_download默认30秒超时就可能不够用,建议根据业务复杂度设置合理的timeout,我一般设为60秒甚至120秒。另外,无头模式下出错看不到浏览器界面,排查问题主要靠日志和保存的页面快照,所以我习惯在download.failure不为空时,调用page.screenshot(path=...)把触发下载那一刻的页面截下来,这能帮你快速区分是按钮点击没生效,还是后端返回了错误。

3.2 下载目录生命周期管理:每轮清空、归档、校验hash

自动化跑得越久,下载目录里的文件堆积问题越明显。一轮增删改查跑下来,几十个文件堆在同一个目录,第二次执行时看到同名文件就可能懵。我现在的习惯是:给每一轮自动化建一个独立的临时下载目录,跑完后做断言和归档,最后清空目录,绝不留垃圾。

import shutil import tempfile from pathlib import Path def create_clean_dir(prefix="auto_download_"): base = Path(tempfile.gettempdir()) / prefix if base.exists(): shutil.rmtree(base) base.mkdir(parents=True, exist_ok=True) return base

如果你要验证文件内容没被篡改,推荐加一步SHA256校验。常见场景是:页面上传一个文件到系统,再从某个下载入口把同一份文件取回来,比对前后两个文件的哈希值。只要哈希一致,就证明存储链路没有丢数据。示例我放在下一节。

3.3 上传后下载的闭环测试设计:一条用例覆盖两个操作

业务上最有价值的用例往往是“上传后能正确下载”。比如某内部审批系统要求用户上传合同扫描件,流程走完后还能在归档页下载。这时我会把上传、提交、下载、校验四个动作做成一条自动化用例:

import hashlib SOURCE_FILE = Path("C:/data/合同扫描件.pdf") def sha256_of(path): h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(4096), b""): h.update(chunk) return h.hexdigest() with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page() page.goto("https://example.com/archive/upload") page.set_input_files("input[type=file]", str(SOURCE_FILE)) page.click("button#submit") with page.expect_download(timeout=60000) as info: page.click("button#download-original") download = info.value target = Path("output/") / download.suggested_filename download.save_as(str(target)) assert sha256_of(SOURCE_FILE) == sha256_of(target), "文件内容不一致"

这段代码把File uploaded和Downloads两条链路串了起来,跑一遍等于回归了存储与读取两个核心环节。有一点要提醒:上传的文件路径和下载的文件路径尽量不要在同一个目录,否则脚本清理时可能把自己的源文件删掉。源文件单独放assets/下,下载文件放临时目录,边界很清晰。

4. 让文件操作稳定起来:并发、超时与等待细节

4.1 用expect_download的timeout和URL过滤:别在无头模式下等死

下载操作最让人头疼的问题不是“怎么触发”,而是“怎么等”。如果在点击下载按钮后,服务端迟迟不返回文件流,expect_download就会一直等到超时,默认30秒意味着整个测试可能被这一个等待拖垮。这种情况下,你要给expect_download设置一个针对性的超时,超时后快速失败并记录现场。

更灵活的做法是给下载事件加上过滤条件。当一个页面上有多个不同的下载按钮,或者同一次操作可能触发多个下载时,你可以在expect_download里传入expected_url或match回调,只关注你想等的那个下载:

with page.expect_download( timeout=30000, match=lambda d: "monthly-report.xlsx" in d.suggested_filename ) as info: page.click("button#download-excel")

这样即使页面上同时还有另一个pdf下载事件,也不会干扰你的断言。注意,match回调是在下载事件触发时调用的,所以里面的download.suggested_filename是可靠的。如果你不筛选,只是单纯等待“任意下载发生”,在多下载场景下很容易取到错误的download对象。

4.2 多文件并发下载:同步API开线程,还是推荐async API

有些测试场景需要批量下载几十个文件,逐个顺序下载也不是不行,但耗时长。这里可以分两条路走。如果主要用sync_playwright同步API,最简单的提速方式就是开线程池,每个线程独立启动浏览器实例或独立的context,分别处理一个下载任务。

from concurrent.futures import ThreadPoolExecutor def download_one(item_id): with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page() page.goto("https://example.com/items/" + item_id) with page.expect_download(timeout=30000) as info: page.click("button#download") info.value.save_as(f"output/{item_id}.pdf") browser.close() with ThreadPoolExecutor(max_workers=5) as pool: list(pool.map(download_one, ["A001", "A002", "A003", "A004", "A005"]))

需要注意,线程模式下每个线程内部都要单独创建和关闭browser实例,不要让多个线程共享同一个playwright实例。如果你用的是async_playwright,那可以维护多个page并发触发下载,本质上逻辑相同,但代码结构会复杂一点。多数项目里,一个页面最多同时出现三五个下载按钮,我反而建议优先串行,保证顺序和稳定性,除非你有明确的性能压力。

4.3 .crdownload残留与临时文件:等待“消失”比等待“出现”更可靠

Chrome和Chromium在下载文件时,会先生成一个以.crdownload结尾的临时文件,下载完成后才把它重命名为最终文件名。如果你看到目标目录里出现了目标文件就立刻拿去做断言,有可能撞上文件还没写完的窗口期。更稳妥的方式是等待.crdownload伴随文件消失,或者用轮询判断目标文件大小不再变化。

import time from pathlib import Path def wait_for_download_complete(directory, filename, timeout=60): target = Path(directory) / filename tmp = Path(directory) / f"{filename}.crdownload" deadline = time.time() + timeout while time.time() < deadline: if tmp.exists(): time.sleep(0.5) continue if target.exists() and target.stat().st_size > 0: return target time.sleep(0.5) raise TimeoutError(f"下载未完成: {target}")

为什么“等待消失”比“等待出现”更可靠?因为最终文件只要出现,大概率说明下载管理器已经完成了重命名,而.crdownload文件只要还存在,就说明写入还没结束。用这个思路做轮询,几乎不会遇到文件半截的尴尬。若是不存在于.crdownload后缀的下载器,可以直接检查目标文件大小连续两次读取不变。这个函数我在好几个项目里复用,尤其在批量导出场景下避免了大量偶发失败。

5. 文件操作避坑清单:我从真实自动化项目里捡回来的教训

5.1 点击下载后页面立刻跳转,download被中断怎么办

这种情况常发生在“点击下载后跳转到详情页”或者“下载按钮本身是一个导航链接”的设计里。如果expect_download包住了点击,但页面同时开始导航,Playwright的download事件可能会因为context被关闭而丢失。我踩过之后总结出一个处理方案:把expect_download放在导航发生前,使用new_context里的base_url理顺页面关系,或先拦截导航用page.expect_navigation配合等待,确认download事件触发后再允许跳转。

更实用的做法是:如果后端直接返回文件流,但你发现页面是window.open方式打开下载链接,那download事件会落在新开的page上,而不是当前page。这时要改用context.expect_page()捕获新页面,再去新页面里处理download。代码大致长这样:

with context.expect_page() as page_info: page.click("button#download-in-new-tab") new_page = page_info.value with new_page.expect_download() as download_info: new_page.wait_for_load_state() download = download_info.value download.save_as("output/新标签页下载文件.pdf")

遇到这个问题别慌,先在新页面里等待network idle状态,再处理download。大多数崩溃场景不是事件没触发,而是你等错了页面。

5.2 中文文件名与URL编码:显示的%XX怎么处理

很多被测系统的后端没有正确设置响应头,导致浏览器拿到的suggested_filename是URL编码后的字符串,比如%E8%AE%A2%E5%8D%95.xlsx,保存出来的文件完全是乱码。遇到这种问题,最简单的处理是不要依赖服务端文件名,而是自己在脚本里定义映射规则,比如“下载订单导出”按钮固定保存为order_{当前日期}.xlsx。

from datetime import datetime timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") local_name = f"order_{timestamp}.xlsx" download.save_as(f"output/{local_name}")

如果你还是想还原中文名,可以先从suggested_filename里做一次URL decode。Python里直接用urllib.parse.unquote即可,但前提是确认文件名确实经过了URL编码,而不是本来就包含%字符。这个判断我没法给你一条万能公式,最快的验证方法是在调试时打印suggested_filename,肉眼确认后再决定处理方式。

5.3 不要在代码里手搓路径:pathlib才是文件操作的好朋友

文件自动化最隐蔽的Bug来自Windows的路径分隔符。我在初期写脚本时习惯用双反斜杠拼接地址,比如"C:\\files\\new\\report.pdf"。一旦脚本挪到Linux服务器上,双反斜杠直接失效。现在所有文件路径统一用pathlib.Path处理,它会根据当前操作系统自动选择正确分隔符,而且在拼接文件名时比字符串格式化安全很多。

base_dir = Path("output") base_dir.mkdir(parents=True, exist_ok=True) target = base_dir / download.suggested_filename

注意Path对象和字符串不是完全等价的,调用save_as()时有些版本要求传入字符串,所以我通常写str(target)。还有一个小习惯:判断文件是否存在时用Path.exists()而不是os.path.exists(),代码意图更清晰,也方便链式调用。

5.4 环境相关坑:playwright install与系统依赖

在干净服务器上用Playwright跑文件下载,第一件要做的就是安装对应浏览器内核。只有安装完,headless模式下才能真正触发下载事件。经常有人发现本机能跑,服务器上报错提示找不到可执行路径,十有八九是漏了这步:

python -m playwright install chromium playwright install-deps

install-deps会安装Linux环境下运行Chromium所需的系统依赖库,比如libnss3、libatk等。如果你用的是Docker镜像,建议直接基于官方Playwright镜像构建,省掉一堆系统包安装步骤。文件下载如果触发后没有任何反应,先去命令行执行一次playwright install chromium --with-deps,跑通之后再看代码逻辑。

写在最后的几句实在话

这套文件上传下载的自动化方案我前前后后在好几个项目里跑过,从最开始的Excel报表导入导出,到后来批量合同归档,Playwright的稳定性确实比早期Selenium加系统弹窗方案高了一个量级。如果你们团队正准备给UI自动化增加文件操作场景,我强烈建议从一条最简单的“上传单文件后立即下载”用例开始,跑通它,再扩展并发或者复杂控件场景。

最后再分享一个小技巧:下载目录的清理要放在用例的teardown阶段,而不是收集阶段之前。因为断言失败时,你往往需要保留现场文件去分析,清得太早反而丢了证据。文件自动化没有太多高深理论,把等待机制、事件模型、目录生命周期这三件事想清楚,后面都是体力活。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进