☰
Playwright脚本打包成独立Windows EXE:完整方案与避坑指南
2026/9/29 19:56:24 网站建设 项目流程

每次写完一个 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.pyPyInstaller 自动处理
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 机器上验证。这一步不能省略,因为开发机上很多东西是现成的,掩盖了问题。

我常用的流程是这样:

  1. 把dist/WebSnap整个文件夹复制到目标机器。
  2. 断网运行WebSnap.exe https://example.com -o result.png。
  3. 观察是否有截图文件生成。
  4. 打开任务管理器,确认程序运行期间是否出现了chrome.exe进程。如果出现了,说明浏览器内核确实由我们的程序拉起的,而不是调用了系统浏览器。
  5. 如果闪退,改成在命令行窗口运行,捕获完整报错。

我还会做一个反向验证:临时在当前进程环境里设置一个无效的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
杀毒软件报毒或删除 exePyInstaller 单文件/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 文件,分发和维护都灵活很多。我自己现在做内部通用工具,已经是默认这个结构了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询