ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Playwright脚本打包成独立Windows EXE:完整方案与避坑指南

Playwright脚本打包成独立Windows EXE:完整方案与避坑指南 每次写完一个 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 doesnt 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 内核的程序体积轻松超过 250MBonefile 模式下每次启动都要把这些文件从压缩包里解压出来速度慢不说还特别容易被杀毒软件盯上。更合理的做法是 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(headlessTrue, args[--disable-gpu]) page browser.new_page(viewport{width: width, height: 900}) page.goto(url, wait_untilnetworkidle, timeout30000) if wait_ms 0: time.sleep(wait_ms / 1000.0) page.screenshot(pathoutput, full_pagefull_page) browser.close() return output def main(): parser argparse.ArgumentParser(description网页长截图工具) parser.add_argument(url, help要截图的网址) parser.add_argument(-o, --output, defaultsnapshot.png, help输出图片路径) parser.add_argument(-w, --width, typeint, default1280, help视口宽度) parser.add_argument(--no-full-page, actionstore_true, help只截首屏) parser.add_argument(--wait, typeint, default0, help加载完成后额外等待毫秒数) args parser.parse_args() output take_screenshot( args.url, args.output, widthargs.width, full_pagenot args.no_full_page, wait_msargs.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 doesnt 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), prefixfbrowsers/{item.name}) a Analysis( [main.py], pathex[str(Path.cwd())], binariesbinaries, datasdatas, hiddenimportshiddenimports, hookspath[], runtime_hooks[], excludes[tkinter, unittest, pytest], noarchiveFalse, ) pyz PYZ(a.pure) exe EXE( pyz, a.scripts, [], exclude_binariesTrue, nameWebSnap, debugFalse, bootloader_ignore_signalsFalse, stripFalse, upxFalse, consoleTrue, ) coll COLLECT( exe, a.binaries, a.datas, stripFalse, upxFalse, nameWebSnap, )这段 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-*体积比完整版小很多。如果你的代码只跑无头模式可以只把这个目录打包进去体积能降三分之一以上。但请注意如果你的代码里有任何可能触发有头模式的逻辑比如headlessFalse调试或者要用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 构造函数里加iconapp.ico然后重新打包。去掉控制台窗口把 spec 里的consoleTrue改成consoleFalse。要注意改成无控制台模式后程序里的print输出就看不到了所有错误信息只能靠日志文件。所以在改无控制台之前我会先在代码里加上日志功能写到 exe 同级的app.log。网络上有一种常见的做法是加版本信息文件。PyInstaller 支持用--version-file指定版本资源这需要写一个version.txt格式不复杂但比较啰嗦主要是为了资源管理器里右键属性能看到产品名、版本号。内部工具可以不做如果要分发给外部建议做一下显得正规。5. 常见问题速查与独家调试技巧这部分全都是我在实际打包和用户反馈中踩过的坑每一个都有真实场景。5.1 问题速查表报错或现象可能原因解决办法Executable doesnt 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 下调试打包程序最痛苦的是看不到标准输出。尤其是在加了consoleFalse后出错信息直接消失。我的做法是在代码里写上日志函数把所有关键状态打到文件里。import logging logging.basicConfig( filenameapp.log, levellogging.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 文件分发和维护都灵活很多。我自己现在做内部通用工具已经是默认这个结构了。
返回列表