每次写完一个 Playwright 自动化脚本,总有那么一个瞬间会想:要是能把它变成一个 Windows EXE,发给同事或者客户,双击就能跑,不用装 Python、不用装浏览器,多省事。这个需求很常见,但真正动手做的时候,大部分人会被“含浏览器内核”这几个字卡住,因为 Playwright 和普通脚本不一样,它不仅要带运行环境和库,还要把一个完整的 Chromium 内核打包进去,路径、依赖、驱动、版本号全是坑。
这篇内容就是来解决这个问题的。我会从一个网页长截图小工具出发,完整演示如何把 Python Playwright 脚本打包成不依赖系统 Python、不依赖系统浏览器的独立 Windows 程序,浏览器内核随包携带。适合写爬虫、自动化测试、办公自动化脚本的朋友参考,尤其是需要把工具分发给非技术同事的场景。
1. 先搞清楚“独立EXE”到底要打包什么东西
很多人在第一步就栽了,因为对“独立”二字的理解不够深。一个 Playwright 程序在 Windows 上跑起来,靠的不是单个 Python 脚本,而是一条完整的链路。
1.1 一个Playwright程序在Windows上会用到哪些部件
Playwright 的工作方式和普通 requests 脚本完全不同。脚本入口是 Python,但 Python 库本身并不直接操作浏览器。它内部会启动一个 Node.js 编写的 driver 进程,这个 driver 再通过调试协议去拉起 Chromium 浏览器,之后用 WebSocket 和浏览器通信,发送页面导航、点击、截图等命令。
我一般用一句话给同事解释:脚本是乘客,driver 是调度中心,Chromium 是出租车。乘客要出发,得先让调度中心派一辆车过来,而且这辆车必须真实存在、版本匹配,否则半路就得抛锚。所以打包时真正要收集的是四类东西:
| 部件 | 作用 | 打包难度 |
|---|---|---|
| Python 运行时 | 解释执行 main.py | PyInstaller 自动处理 |
| Playwright 库及依赖 | 提供 Python API,包含 driver 和内部 Node 运行时 | 需要 collect-all |
| 第三方依赖 | greenlet、pyee 等动态加载模块 | 容易漏 |
| Chromium 浏览器内核 | 实际执行渲染、截图 | 最关键也最麻烦 |
这也是为什么很多人直接用pyinstaller main.py打包,看起来成功了,但双击运行立刻报错,要么提示找不到 driver,要么提示 Executable doesn't exist。不是 PyInstaller 不行,而是它默认根本不知道去哪里找浏览器内核。
1.2 为什么“含浏览器内核”这一步卡住最多人
Playwright 安装浏览器时,默认会下载到当前用户目录下的固定位置,Windows 上就是%USERPROFILE%\AppData\Local\ms-playwright。里面不是只有一个 chrome.exe,而是一整棵目录树,目录名还带着版本号,比如chromium-1148。这个版本号和 Playwright 版本是强绑定的,换一个 Playwright 版本,对应目录名就可能变了。
PyInstaller 打包的时候,只会分析 Python 代码里的 import,然后把相关模块和包文件收集进来。它不会去扫描%USERPROFILE%下的浏览器目录,也不会自动把这个几百兆的浏览器塞进产物里。所以“含浏览器内核”这个需求,本质上不是打包工具的问题,而是我们得手动告诉 PyInstaller:请把这个特定路径下的浏览器目录,原样复制到交付物中。
Playwright 在运行时,会通过一个环境变量PLAYWRIGHT_BROWSERS_PATH去定位浏览器目录。默认空值时用系统默认路径,也就是上面那个用户目录。打包后的程序跑在那台干净的机器上,找不到这个目录,自然就崩了。思路一下就清晰了:把这个环境变量指向我们随程序一起发布的 browsers 目录,让 Playwright 在打包产物内部找浏览器。
1.3 onedir、onefile、外置浏览器目录怎么选
很多人一看到“EXE”,第一反应就是一定要生成一个单文件。这其实是误解。PyInstaller 单文件模式叫 onefile,它会把程序运行时解压到系统临时目录。一个带完整 Chromium 内核的程序,体积轻松超过 250MB,onefile 模式下每次启动都要把这些文件从压缩包里解压出来,速度慢不说,还特别容易被杀毒软件盯上。
更合理的做法是 onedir 模式,或者干脆浏览器目录外置。三种交付形态我整理成了对比表:
| 交付形态 | 体积/启动速度 | 维护难度 | 适合场景 |
|---|---|---|---|
| 单个 onefile EXE,浏览器内置 | 很大,启动慢 | 每次改代码要重新打整个包 | 极少见,除非你特别执着单文件 |
| onedir 文件夹,浏览器放在 _internal | 较大,启动快 | 浏览器升级要重新打包 | 内部工具默认配置 |
| EXE + browsers 目录 | 大,启动最快 | 浏览器可单独替换,不用重新打包 | 我最推荐,灵活 |
这里的“独立”指的是运行时不需要目标机器额外安装 Python、Playwright 或浏览器,而不是物理上必须只有一个 .exe 文件。一个 exe 加一个 browsers 文件夹,整个文件夹拷给别人,双击 exe 就能跑,这才是工程上最优解。
2. 环境准备和项目结构设计
这部分我会用一个真实的网页长截图工具做演示,把环境、代码、路径处理一次讲透。
2.1 版本选择:别让 Playwright 和 PyInstaller 打架
先说版本组合。我自己长期用的是 Python 3.10 或 3.11,配 Playwright 最新稳定版,PyInstaller 6.x。Python 版本别追太新,太新的版本有时候第三方库还没跟上,反而多出莫名其妙的兼容问题。系统架构优先选 64 位,PyInstaller 打包后的程序在绝大多数 Windows 上是 64 位,别给自己添麻烦。
强烈建议在虚拟环境里工作。用 venv 隔离项目依赖,避免把机器上杂七杂八的包都打进去。
安装命令很简单:
pip install playwright pyinstaller playwright install chromium第二条命令会下载 Chromium。这一步会输出下载进度,完成后浏览器就出现在%USERPROFILE%\AppData\Local\ms-playwright下了。
2.2 一次说清 playwright install chromium 装到哪里
默认情况下,Playwright 会把浏览器装到用户目录,这一点非常关键。我建议在打包前用一段小代码把实际路径打出来,避免凭记忆找错路径:
import os from pathlib import Path print(Path(os.environ["LOCALAPPDATA"]) / "ms-playwright")打开这个目录后,你会看到类似chromium-1148、chromium_headless_shell-1148这样的目录。不要小看这些版本号,它是 Playwright 和浏览器之间的契约。Playwright 升级后,哪怕只是小版本升级,都可能要求一个新的 Chromium 构建号。所以网上有人直接复制别人旧版本的浏览器目录过来用,经常会遇到版本不匹配的报错。
另外提一句:新版 Playwright 的 headless 模式默认会使用单独的 headless shell 目录,不是完整的 Chromium。如果你确定自己的程序只需要无头模式,可以只打包对应的chromium_headless_shell-*目录,体积能省不少。但如果你不确定,或者代码里有可能跑有头模式,就把整个 ms-playwright 目录下的相关版本目录都带上,省得后面测试时缺这个缺那个。
2.3 演示项目:做一个“网页长截图”小工具
这个工具的功能很简单:输入一个 URL,程序用 Playwright 打开页面,滚动截取整个网页,保存为 PNG。支持指定视口宽度、额外等待时间和是否只截首屏。这个例子麻雀虽小五脏俱全,覆盖了浏览器启动、页面操作、资源释放这些关键点。
完整代码如下:
import argparse import os import sys import time def configure_browser_path(): # 打包后的程序必须手动指定浏览器内核位置 if not getattr(sys, "frozen", False): return base = os.path.dirname(sys.executable) candidates = [ os.path.join(base, "browsers"), os.path.join(base, "_internal", "browsers"), ] for path in candidates: if os.path.isdir(path): os.environ["PLAYWRIGHT_BROWSERS_PATH"] = path return # 找不到目录时也不要静默,宁可启动报错也不能用错路径 os.environ["PLAYWRIGHT_BROWSERS_PATH"] = candidates[0] configure_browser_path() from playwright.sync_api import sync_playwright def take_screenshot(url: str, output: str, width: int = 1280, full_page: bool = True, wait_ms: int = 0) -> str: if not url.startswith(("http://", "https://")): url = "https://" + url with sync_playwright() as p: browser = p.chromium.launch(headless=True, args=["--disable-gpu"]) page = browser.new_page(viewport={"width": width, "height": 900}) page.goto(url, wait_until="networkidle", timeout=30000) if wait_ms > 0: time.sleep(wait_ms / 1000.0) page.screenshot(path=output, full_page=full_page) browser.close() return output def main(): parser = argparse.ArgumentParser(description="网页长截图工具") parser.add_argument("url", help="要截图的网址") parser.add_argument("-o", "--output", default="snapshot.png", help="输出图片路径") parser.add_argument("-w", "--width", type=int, default=1280, help="视口宽度") parser.add_argument("--no-full-page", action="store_true", help="只截首屏") parser.add_argument("--wait", type=int, default=0, help="加载完成后额外等待毫秒数") args = parser.parse_args() output = take_screenshot( args.url, args.output, width=args.width, full_page=not args.no_full_page, wait_ms=args.wait, ) print("截图已保存:", output) if __name__ == "__main__": main()代码里最值得注意的就是configure_browser_path()。开发环境运行时,sys.frozen不存在,函数直接返回,Playwright 走系统默认路径,找到我们在开发机上装好的浏览器。打包之后sys.frozen为真,函数会去 exe 所在目录找 browsers 目录,找到就设置环境变量。这个写法同时兼容 PyInstaller 6.x 在 onedir 模式下把数据放进_internal的情况。
还有一点放在代码开头的原因:环境变量必须在首次启动浏览器之前设置好。import playwright本身不会触发浏览器查找,但保险起见,我把配置函数放在 import 之前,确保时序绝对安全。
2.4 路径兼容:开发环境、onedir、onefile 三种场景
上面代码里的 candidates 列表,看起来有点冗余,其实是为不同 PyInstaller 版本和不同打包模式准备的。PyInstaller 6.x 开始,onedir 模式的数据文件默认放在_internal目录里,而不是和 exe 平级。如果你的程序在打包时用 spec 文件把浏览器目录放到了_internal/browsers,运行时就会命中第二个 candidate。如果你用命令行--add-data或者手动把 browsers 放在 exe 旁边,就会命中第一个。
这段代码值得收藏,因为我见过太多人在打包后遇到这样的报错:
Error: Executable doesn't exist at C:\Users\xxx\AppData\Local\ms-playwright\chromium-1148\chrome-win\chrome.exe报错路径里出现了打包前开发机的用户名,说明 Playwright 用的还是编译期冻结的默认路径。解决办法就是在运行时强制设置PLAYWRIGHT_BROWSERS_PATH,而不是依赖任何默认值。
我在实际项目中还遇到过一种更隐蔽的情况:onefile 模式打包后,exe 运行时会解压到%TEMP%\_MEIxxxxxx目录,此时sys.executable指向临时目录里的 exe,而不是原始分发位置。所以上面代码不适合把浏览器目录作为外部文件放在 exe 旁边的 onefile 模式,因为这时 exe 物理位置变了。这就是为什么我前面强烈不建议做 onefile。如果你的需求必须单文件,那就必须把浏览器作为数据文件打进包内,让它出现在解压后的临时目录里,这属于另一种配置思路。
3. PyInstaller 打包配置逐项解析
这一章是全文的核心技术区。命令行能跑,但要想稳定复现,必须理解 spec 文件。
3.1 快速上手的命令
先给一个能用的命令,适合第一次试水:
pyinstaller -D -n WebSnap ^ --collect-all playwright ^ --collect-all greenlet ^ --collect-all pyee ^ --add-data "C:/Users/dev/AppData/Local/ms-playwright/chromium-1148;browsers/chromium-1148" ^ main.py这里有几个参数必须要解释清楚。-D是 onedir 模式,生成一个文件夹。--collect-all playwright负责把 playwright 包内的所有文件、二进制、数据都收集进来,包括 driver 和它内部的 Node 运行时,这一步是关键,少了它打包出来的程序会提示找不到 playwright driver。--collect-all greenlet和--collect-all pyee是很多人会漏掉的,Playwright 的同步 API 依赖 greenlet 做协程切换,pyee 是事件库,这两个包里有动态生成的模块,PyInstaller 静态分析抓不全,必须手动全收集。
--add-data这里把具体的 Chromium 版本目录映射到目标路径browsers/chromium-1148。注意 Windows 上源路径和目标路径之间用分号分隔,不是冒号。有人直接把这个参数里填了%USERPROFILE%或者整个 ms-playwright 目录,运行时路径就会多一层,导致找不到浏览器。
3.2 编写 spec 文件:比命令行更稳
命令行适合快速验证,但版本目录名一变,命令就要改。更稳定的做法是用 spec 文件,把逻辑固定下来。下面这份 spec 是我在实际项目中使用的模板,可以按需修改:
# WebSnap.spec from pathlib import Path from PyInstaller.utils.hooks import collect_all from PyInstaller.building.datastruct import Tree # 浏览器根目录,按需修改 browser_root = Path("C:/Users/dev/AppData/Local/ms-playwright") # 初始化收集结果 datas, binaries, hiddenimports = [], [], [] # 收集 playwright 及其动态依赖 for pkg in ("playwright", "greenlet", "pyee"): d, b, h = collect_all(pkg) datas += d binaries += b hiddenimports += h # 把浏览器目录逐版本加入数据 for item in browser_root.iterdir(): if item.is_dir(): datas += Tree(str(item), prefix=f"browsers/{item.name}") a = Analysis( ["main.py"], pathex=[str(Path.cwd())], binaries=binaries, datas=datas, hiddenimports=hiddenimports, hookspath=[], runtime_hooks=[], excludes=["tkinter", "unittest", "pytest"], noarchive=False, ) pyz = PYZ(a.pure) exe = EXE( pyz, a.scripts, [], exclude_binaries=True, name="WebSnap", debug=False, bootloader_ignore_signals=False, strip=False, upx=False, console=True, ) coll = COLLECT( exe, a.binaries, a.datas, strip=False, upx=False, name="WebSnap", )这段 spec 里最关键的是Tree的用法。Tree会把整个目录递归加入打包清单,prefix参数控制了它在目标目录中的位置。我用browsers/{item.name}作为前缀,这样最后生成的目录结构就是:
dist/WebSnap/ WebSnap.exe _internal/ browsers/ chromium-1148/ chromium_headless_shell-1148/ playwright/ ...运行时,代码里的configure_browser_path()会命中_internal/browsers,完美对应。
excludes=["tkinter", "unittest", "pytest"]是为了减小体积。PyInstaller 默认会把很多用不到的包也分析进去,显式排除可以省掉不少空间。这里排除 tkinter 是因为我们这工具不需要 GUI。如果你的程序里 import 了这些,就不要乱排除。
3.3 体积与启动效率优化
打包完成后,你可能会惊讶于体积。一个最简单的 Playwright 长截图工具,带上完整 Chromium,体积大约在 250 到 350MB 之间。这是正常的,Chromium 内核本身就是这个体量。
想要瘦身,有几种思路。第一,只带 headless shell,不带完整 Chromium。新版 Playwright 安装的目录里通常会有chromium_headless_shell-*,体积比完整版小很多。如果你的代码只跑无头模式,可以只把这个目录打包进去,体积能降三分之一以上。但请注意,如果你的代码里有任何可能触发有头模式的逻辑,比如headless=False调试,或者要用browser.new_context里某些依赖完整版的功能,就别冒险,还是全量带上。
第二,不要使用 UPX 压缩。很多教程会推荐用 UPX 压缩 exe,但对 Playwright 这种带大量已压缩二进制文件的工具来说,UPX 收益很小,反而显著增加杀毒软件误报概率,得不偿失。
第三,排除无用模块。除了上面 spec 里排除的,还可以根据实际情况排除numpy、pandas这类体积大户。但要注意,排除前必须确认程序里真的没有间接引用。
4. 完整实操:从零打包并验证
下面按时间顺序走一遍完整流程。
4.1 开发机准备
先创建虚拟环境并安装依赖:
python -m venv venv venv\Scripts\activate pip install playwright pyinstaller playwright install chromium把上一章提供的 main.py 保存到项目目录,先直接运行一次,确保在开发机上能正常截图。我习惯用python main.py https://example.com -o test.png做验证,看到输出文件生成,再进入打包环节。
这时候不着急打包。先看一眼%LOCALAPPDATA%\ms-playwright下实际生成了哪些目录,记下版本号。后面 spec 文件里要用到。
4.2 执行打包
把上文的 spec 保存为WebSnap.spec,然后执行:
pyinstaller WebSnap.spec首次打包会花几分钟,日志会很长。看到Building EXE和Building COLLECT完成,没有红色错误,基本就成了。如果报错,九成出现在依赖收集阶段,具体排查方法在下一章。
打包完成后检查目录结构:
dist/WebSnap/ WebSnap.exe _internal/ browsers/ chromium-1148/ chromium_headless_shell-1148/ playwright/ driver/ node.exe package/ ...关键点有两个:_internal/playwright/driver必须在,这里放着 Node 驱动;_internal/browsers必须在,这里放着浏览器内核。
4.3 干净机器验证流程
打包成功的程序,必须在一台没有 Python、没有安装过 Playwright、甚至没有安装任何浏览器的 Windows 机器上验证。这一步不能省略,因为开发机上很多东西是现成的,掩盖了问题。
我常用的流程是这样:
- 把
dist/WebSnap整个文件夹复制到目标机器。 - 断网运行
WebSnap.exe https://example.com -o result.png。 - 观察是否有截图文件生成。
- 打开任务管理器,确认程序运行期间是否出现了
chrome.exe进程。如果出现了,说明浏览器内核确实由我们的程序拉起的,而不是调用了系统浏览器。 - 如果闪退,改成在命令行窗口运行,捕获完整报错。
我还会做一个反向验证:临时在当前进程环境里设置一个无效的PLAYWRIGHT_BROWSERS_PATH,或者把_internal/browsers改名,运行程序看是否立刻报路径错误。如果程序仍然跑通,说明它压根没用我们打包的浏览器,那一定有什么地方配置错了。
4.4 从“能跑”到“好用”:图标、版本信息、无控制台
验证通过后,可以做一些收尾优化。
给 exe 加图标,在 EXE 构造函数里加icon="app.ico",然后重新打包。
去掉控制台窗口,把 spec 里的console=True改成console=False。要注意,改成无控制台模式后,程序里的print输出就看不到了,所有错误信息只能靠日志文件。所以在改无控制台之前,我会先在代码里加上日志功能,写到 exe 同级的app.log。
网络上有一种常见的做法是加版本信息文件。PyInstaller 支持用--version-file指定版本资源,这需要写一个version.txt,格式不复杂但比较啰嗦,主要是为了资源管理器里右键属性能看到产品名、版本号。内部工具可以不做,如果要分发给外部,建议做一下,显得正规。
5. 常见问题速查与独家调试技巧
这部分全都是我在实际打包和用户反馈中踩过的坑,每一个都有真实场景。
5.1 问题速查表
| 报错或现象 | 可能原因 | 解决办法 |
|---|---|---|
| Executable doesn't exist at 开发机路径 | 运行时没有设置 PLAYWRIGHT_BROWSERS_PATH,或者浏览器目录没打包进去 | 检查代码里的路径配置函数,确认_internal/browsers存在 |
| 找不到 playwright driver | 用了裸pyinstaller main.py,没有--collect-all playwright | 补上--collect-all playwright |
| 启动后闪现窗口立即退出 | 无控制台模式下代码抛异常看不到 | 先保留 console 模式,或写日志文件再定位 |
| 浏览器启动失败,进程一闪而过 | 目标机器缺少必要的系统组件,或 GPU 初始化失败 | 启动参数里加--disable-gpu |
| 杀毒软件报毒或删除 exe | PyInstaller 单文件/UPX 压缩触发误报 | 使用 onedir 模式,关闭 UPX,必要时做数字签名 |
_internal下找不到浏览器 | PyInstaller 版本不同导致数据目录位置变化 | 代码里 candidates 列表同时兼容 exe 同级和_internal两级路径 |
| 升级 Playwright 后程序无法运行 | 浏览器版本和 Playwright 版本不匹配 | 重新执行playwright install chromium,重新打包 |
5.2 调试技巧:把看不见的 Windows 程序变成看得见
Windows 下调试打包程序最痛苦的是看不到标准输出。尤其是在加了console=False后,出错信息直接消失。
我的做法是:在代码里写上日志函数,把所有关键状态打到文件里。
import logging logging.basicConfig( filename="app.log", level=logging.DEBUG, format="%(asctime)s [%(levelname)s] %(message)s", ) # 在路径配置后立即记录 logging.info("PLAYWRIGHT_BROWSERS_PATH=%s", os.environ.get("PLAYWRIGHT_BROWSERS_PATH")) logging.info("executable dir=%s", os.path.dirname(sys.executable))观察app.log中记录的实际路径,对比打包产物里的实际目录结构,大多数路径类问题一眼就能定位。
另外还有一个很有效的工具:Process Explorer。在目标机器上运行打包后的 exe,用 Process Explorer 看它访问了哪些文件路径,能非常直观地发现程序在找什么、找不到什么。这个方法尤其适合那种“明明路径看起来没问题但还是报错”的情况。
5.3 关于要不要用 Nuitka 的实话
有人会问,PyInstaller 这么麻烦,Nuitka 是不是更好。我个人的结论是:Playwright 项目优先用 PyInstaller。
Nuitka 确实能把 Python 编译成 C 代码,启动更快,反编译难度更高。但 Playwright 的运行机制决定了它必须依赖外部浏览器文件和 Node driver,这些不属于纯 Python 代码,Nuitka 并不会自动帮你处理得更好。相反,Nuitka 对这类带大量数据文件、动态路径的项目,配置复杂度反而更高,学习成本也更大。除非你后续有强烈的代码保护需求,否则用 PyInstaller 是性价比最高的方案。
不过如果只是担心“别人能反编译我的 Python 代码”,单纯依赖 PyInstaller 的保护程度有限。这个话题可以单独再聊,反正对内部工具来说,大多数人根本没兴趣去逆向你的截图脚本。
6. 收尾
这套流程我反复用过很多次,从最初简单的截图脚本,到后来集成复杂业务逻辑的内部工具,核心要点始终是那几个:环境变量必须在浏览器启动前设置好,浏览器目录必须随包分发,路径兼容逻辑要处理好 PyInstaller 版本差异。只要把握住这三点,Playwright 打包成独立 Windows 程序就是一条完全可以复制的流水线。
最后再分享一个小技巧:交付给非技术同事时,把browsers目录留在 exe 同级,而不是藏在_internal里。这样以后浏览器内核需要升级时,只需要替换browsers文件夹,不用重新打包 exe。工具里的业务代码更新则只需要推送新的 exe 文件,分发和维护都灵活很多。我自己现在做内部通用工具,已经是默认这个结构了。