说实话,我第一次在Ubuntu服务器上跑Playwright的时候,差点被系统依赖折腾到怀疑人生。那会儿刚从Windows本地切换到Linux环境,以为代码复制过去就能无缝迁移,结果浏览器起不来、字体缺失、沙箱报错……这些坑我一个个踩过来之后,慢慢摸清了规律。所以这次我把整个流程重新梳理了一遍,从零环境开始,到写出一套能稳定跑自动化任务的脚本,每一步都给出可以直接照抄的方案。如果你也正准备在Ubuntu上使用Playwright,这篇文章应该能帮你少走至少一晚上的弯路。
整个内容不会只停留在“能跑起来”的层面,我会把环境配置背后的原理、脚本设计时的取舍、以及真正投入实际任务时那些文档里不太会写的东西,一并摊开来讲。
1. 为什么是Ubuntu加Playwright,这套组合到底能做什么
1.1 我眼中的Playwright定位
Playwright是微软开源的一套浏览器自动化框架,底层通过CDP协议与浏览器通信,支持Chromium、Firefox和WebKit三种内核。相比同类工具,它最大的特点是把“自动化的稳定性”放在了第一位:内置了自动等待机制,元素定位不到时会重试,页面跳转时会自动等待网络空闲,这让脚本的容错率提高了不少。
在Ubuntu上跑Playwright,其实是一个非常典型的服务端方案。你可以把它部署在一台普通的Linux机器上,定时执行巡检、批量截图、数据采集、表单提交之类的任务。Ubuntu作为服务器系统,内存占用比桌面版低,稳定性高,再加上systemd定时任务,就可以把自动化脚本做成一个长期运行的服务。
我在选择这套组合之前,也对比过在Windows上跑,但最终服务器还是选了Ubuntu。原因很简单:同样的脚本,在Ubuntu上跑,内存占用能低出20%左右,而且配合无头模式,一台2G内存的小机器就能同时跑三四个浏览器实例。
1.2 适合哪些场景与人群
这套方案适合的群体很明确:
- 需要批量处理网页操作的运维或测试人员,比如每天定时巡检几十个页面是否正常。
- 做数据采集的开发者,用Playwright替代简单的HTTP请求库,能拿到更多渲染后的数据。
- 前端或全栈工程师,做自动化回归测试,尤其是需要跨浏览器覆盖的团队。
- 个人开发者想做网页截图服务、监控页面变化、生成PDF报告。
如果你是第一次接触Playwright,或者之前用过类似工具但对Ubuntu部署不熟,这篇文章的实操部分可以直接跟着敲。我尽量把每个步骤都写清楚,包括为什么这么做,踩坑时怎么判断。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手之前的环境盘点与准备
2.1 Ubuntu版本与Node.js环境
我目前使用的Ubuntu版本是22.04 LTS,但整体操作在20.04和24.04上也通用。LTS版本的好处是软件源稳定,很多依赖包不会出现版本冲突。
第一步先确认系统架构和Python、Node.js的环境。Playwright官方支持JavaScript/TypeScript和Python两种主流语言,我这边主要讲Node.js版本,因为它的生态更完整,API设计也更顺手。
安装Node.js时,我建议用nvm管理版本,而不是直接apt安装。原因在于apt源里的Node版本往往偏旧,而且升级麻烦。比如Ubuntu 22.04默认源里的Node是12.x,而Playwright对Node版本的要求是18以上,直接apt装完还得折腾升级。
bash复制curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc
nvm install 20
nvm use 20
node -v
这里有个细节:不推荐用最新版Node,除非你有明确的特性需求。我遇到过某个版本在Playwright的依赖编译上有点小毛病,换成20 LTS之后一切正常。20这个版本目前是社区验证最充分的。
2.2 装Playwright之前先解决系统依赖
很多人装完Playwright后直接运行脚本,发现浏览器起不来,报错提示缺少各种.so文件。这就是典型的系统依赖没有装全。
在Ubuntu上,安装Playwright自带的Chromium时,它会尝试下载浏览器二进制,但浏览器运行所需的系统库不会被自动安装。你需要手动执行一行命令:
bash复制npx playwright install --with-deps chromium
这行命令会自动检查并安装所有Chromium运行所需的系统库,包括libnss3、libnspr4、libatk等。如果你已经装过浏览器,只需要补装依赖,可以单独执行:
bash复制sudo npx playwright install-deps chromium
我建议把 chromium 换成一个变量,比如同时需要 firefox 和 webkit 时:
bash复制npx playwright install --with-deps
这会一次性把三种浏览器内核和所有系统依赖都装上。当然,如果你只需要跑Chromium,没必要装三个内核,浪费时间和磁盘。
2.3 项目目录与基础配置
环境准备前期,先初始化一个干净的项目目录:
bash复制mkdir playwright-demo && cd playwright-demo
npm init -y
npm install @playwright/test
这里装的是 @playwright/test,而不是直接装 playwright。两者区别在于:@playwright/test 自带测试运行器,支持断言、钩子、并行执行等能力;而 playwright 只是一个库,适合写普通脚本。如果你要做的是定时任务而不是单元测试,那装 @playwright/test 其实也不影响,它内部同样可以启动浏览器做任何操作。
初始化完成后,建议在项目根目录加一个 .gitignore,把test-results和playwright-report目录忽略掉,不然跑几次测试就会留下一堆报告垃圾。
3. 安装与浏览器内核配置实操
3.1 安装Playwright与浏览器
环境准备好之后,正式安装Playwright。刚才已经执行了npm install,这里再补一个浏览器的下载。
bash复制npx playwright install chromium
如果你在中国大陆的网络环境,这一步可能会遇到下载速度极慢或者失败的问题。我当时的处理方式是设置镜像环境变量后再执行安装。
bash复制export PLAYWRIGHT_DOWNLOAD_HOST=https://npmmirror.com/mirrors/playwright/
npx playwright install chromium
等下载完成,可以用一个最小脚本验证浏览器能否正常启动:
javascript复制const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto('https://example.com');
console.log(await page.title());
await browser.close();
})();
如果控制台能输出 Example Domain,说明整个链路已经通了。
3.2 无头模式与有头模式的取舍
Ubuntu服务器上默认是没有图形界面的,所以Playwright必须跑在无头模式下。但很多开发者在写脚本时习惯开有头模式调试,跑在服务器上又忘了改成无头,结果脚本直接报错,或者弹出一个看不见的窗口挂死。
我的建议是:本地调试用有头模式,服务器上统一用无头模式。Playwright的launch参数里控制这个行为:
javascript复制const browser = await chromium.launch({
headless: true,
});
这里补充一个容易忽略的点:headless参数改成 false,在Ubuntu服务器上如果缺少图形库,是起不来的。所以要么装一个xvfb作为虚拟显示服务,要么就老老实实用无头模式。无头模式下浏览器仍在渲染页面,只是没有显示窗口,绝大多数自动化任务是够用的。
3.3 系统依赖安装的坑
我踩过的第一个坑是字体缺失。无头Chromium跑起来之后,页面上所有中文都显示成方块,截图里的文字全是乱码。排查了半天,发现是系统里没有中文字体。
解决方案:
bash复制sudo apt install -y fonts-noto-cjk
这个包是Google的Noto字体,包含简体中文、繁体中文和日文、韩文字形,装完之后截图效果立竿见影。
第二个坑是沙箱问题。在以root身份运行的服务器上,浏览器默认启用的sandbox会因权限不足而报错,提示类似Running as root without --no-sandbox is not supported。解决方法是启动时加上:
javascript复制const browser = await chromium.launch({
args: ['--no-sandbox'],
});
但这只是应急方案。更好的做法是新建一个普通用户来跑Playwright任务,既安全又不用加这个参数。在自动化巡检服务器上,我一直是用专用账号运行,而不是root。
4. 核心API实战:从一个登录脚本说起
4.1 启动浏览器与页面对象的理解
Playwright里最重要的概念就是Browser、BrowserContext和Page。简单理解:Browser是浏览器实例,BrowserContext是独立的会话环境,Page是具体的标签页。
三个对象的关系可以类比成:浏览器就像一台手机,BrowserContext就像手机里的应用分身,Page则是分身里打开的聊天窗口。每个BrowserContext都是完全隔离的,包括cookie、缓存、localStorage,互不干扰。
启动浏览器并创建页面:
javascript复制const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://example.com/login');
// 后续操作...
})();
我特别建议每个独立任务都创建新的BrowserContext,而不是复用同一个。这样即使脚本中途出错,也不会影响其他任务的运行。
4.2 定位元素与交互动作
Playwright支持多种元素定位方式,包括CSS选择器、文本选择器、XPath、以及基于ARIA的标签定位。实际写脚本时,这些方式可以叠加使用。
举一个登录表单的例子:
javascript复制await page.fill('#username', 'testuser');
await page.fill('#password', 'pass123');
await page.click('button[type="submit"]');
fill和type的区别是:fill会清空输入框再填值,type模拟键盘逐字输入。如果表单有联动校验,比如输入时实时检查格式,用type更真实;如果只是简单填值,用fill更快。
点击操作也要注意:不是所有按钮都是button标签。有的按钮用span或div实现,这时候直接用CSS选择器可能会定位到不可点击的元素。Playwright提供了一个强力的click方法,它会滚动元素到可视区域,等元素可操作再点击。
javascript复制await page.getByRole('button', { name: '登录' }).click();
getByRole和getByText是Playwright的特色选择器,对语义化不明确的页面特别好用。我写页面操作时,优先用getByRole,因为它模拟了用户可感知的交互方式,比对CSS类名稳定得多。
4.3 等待策略与自动等待机制
新手最常犯的错误是写死等待时间,比如sleep(3000)。这既浪费大量时间,又增加不稳定因素。Playwright本身有自动等待机制,元素定位时会等待到元素可见、可交互,默认超时时间是30秒。
但自动等待并非永远正确。某些场景下,元素已经出现在DOM里,但动画还在进行,这时点击可能不生效。我的做法是显式等待特定条件:
javascript复制await page.waitForSelector('.modal__content');
await page.click('.modal__confirm');
或者是等待网络请求结束:
javascript复制await page.waitForLoadState('networkidle');
这里提醒一下,networkidle有时候会非常耗时,因为页面的长连接或轮询接口会导致它迟迟不返回。我一般只用在需要准确截图的场景,普通操作不会依赖它。
4.4 截图与页面录制
自动化脚本有一个高频需求:截图留证。Playwright提供了非常简洁的截屏API:
javascript复制await page.screenshot({ path: 'screenshot.png', fullPage: true });
fullPage参数表示截取整页,而不是只截首屏。这个在网页巡检场景中很实用,可以完整看到页面渲染结果。
除了截图,Playwright还能录制页面视频。这需要在创建BrowserContext时设置recordVideo:
javascript复制const context = await browser.newContext({
recordVideo: {
dir: './videos/',
size: { width: 1280, height: 720 },
},
});
视频录制对排查复杂交互问题特别有用。之前我一个表单提交脚本偶发失败,本地复现不了,后来靠录制的视频才发现是某个接口响应慢导致元素被误判。视频会持续记录整个会话,跑完脚本后生成的webm文件直接播放就能定位问题。
5. 更复杂的自动化场景:会话保持、多标签与文件上传
5.1 登录状态保持与StorageState
很多任务需要登录后才能操作。如果每个脚本每次都从登录开始,不仅慢,还容易触发验证码,增加不确定性。
Playwright提供了storageState机制,可以将登录后的状态保存到文件,下次启动时直接加载。
第一次登录并保存状态:
javascript复制const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://example.com/login');
await page.fill('#username', 'testuser');
await page.fill('#password', 'pass123');
await page.click('button[type="submit"]');
await context.storageState({ path: 'state.json' });
await browser.close();
})();
之后每次任务直接加载:
javascript复制const context = await browser.newContext({
storageState: 'state.json',
});
这里要注意,storageState保存的是cookie和localStorage信息。如果目标网站的登录态有效期很短,比如几个小时,那state.json里的信息就会过期,需要设计一个自动重新登录的逻辑。我在定时巡检任务里是加了一个登录态检测,如果访问后发现跳转到了登录页,就自动重新执行登录流程。
5.2 多标签页与窗口切换
有些自动化任务需要操作多个标签页,比如从一个搜索结果页打开多个链接,分别提取内容。Playwright处理多标签页的方式是用BrowserContext来统一管理。
javascript复制const [newPage] = await Promise.all([
context.waitForEvent('page'),
page.click('a[target="_blank"]'),
]);
这句话的意思是:点击链接的同时监听新页面创建事件,新页面创建后立即返回Page对象。这种方式避免了等待新页面加载完成的时间差问题。
如果你只是想遍历页面里的链接,并不需要真正打开标签页,可以使用page.evaluate直接在页面内收集所有a标签的href属性,再逐个通过HTTP请求或Playwright访问,效率会高很多。
5.3 文件上传与下载处理
文件上传是网页自动化里比较麻烦的一环。好在Playwright封装得足够好用。
上传单个文件:
javascript复制await page.setInputFiles('input[type="file"]', '/path/to/file.pdf');
上传多个文件:
javascript复制await page.setInputFiles('input[type="file"]', ['/path/to/1.pdf', '/path/to/2.pdf']);
需要注意的是,有些页面的事件是动态绑定的,比如先选择文件再执行其他操作,这时setInputFiles会自动触发change事件,正常情况下不需要额外处理。
而下载文件则稍微复杂一点,需要监听下载事件:
javascript复制const downloadPromise = page.waitForEvent('download');
await page.click('#downloadBtn');
const download = await downloadPromise;
await download.saveAs('./downloads/report.pdf');
5.4 并发执行与并行化思路
自动化脚本如果只是单线程跑,对于大量任务来说太慢了。Playwright基于Node.js的异步特性,天然支持并发。
比如要同时巡检10个页面,可以这样设计:
javascript复制const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const urls = [/* 10个URL */];
await Promise.all(urls.map(async (url) => {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.screenshot({ path: `${url.replace(/[^a-zA-Z]/g, '_')}.png` });
await context.close();
}));
await browser.close();
})();
这段代码会并发创建多个独立的BrowserContext,每个Context互不干扰。这样10个页面的巡检任务,通常几秒钟就能完成,而不是逐个页面依次等待。
并发数不宜过高,否则内存会耗尽。以2G内存的服务器为例,建议并发控制在4到5个以内。如果任务量特别大,可以用p-limit这类库做限流。
6. 常见问题排查与避坑记录
6.1 浏览器无法启动的依赖问题
这是Ubuntu上最常见的问题。执行脚本时报错提示缺少共享库,一般集中在两个阶段:安装浏览器时,或者启动浏览器时。
安装阶段报错,是因为没有执行系统依赖安装命令。启动阶段报错,则可能是依赖缺失和权限问题并存。
判断方法很简单,先执行:
bash复制ldd ~/.cache/ms-playwright/chromium-*/chrome-linux/chrome | grep "not found"
如果输出里有not found字样,说明有依赖缺失,再执行:
bash复制sudo npx playwright install-deps chromium
如果ldd检查结果全是正常的,则可能是权限问题。确认当前用户对浏览器缓存目录有完整读写权限:
bash复制ls -la ~/.cache/ms-playwright/
6.2 超时与等待策略不当
Playwright默认30秒超时,对大部分操作来说足够了。但遇到慢接口或者大页面,就会出现各种timeout报错。
我遇到最多的是goto超时和click超时。goto超时通常是因为页面包含大量资源请求,waitUntil默认是load,要等所有资源加载完。如果只是获取页面文本,可以改成:
javascript复制await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
click超时则一定要检查元素定位是否正确,而不是盲目调大超时时间。前面提到自动等待机制会等待元素可交互,如果元素一直在动画或不可见状态,调再大的超时也没意义。
6.3 元素定位不稳定
网站在改版后,CSS类名经常变化,导致脚本大面积失效。这是自动化脚本的通病,Playwright也无法彻底避免,但可以通过选择策略来降低影响。
我的排序是:getByRole优先于getByText,getByText优先于CSS class选择器,class选择器优先于XPath绝对路径。
原因很简单:角色的含义比文本更稳定,文本比类名更稳定,类名比页面结构更稳定。举个例子,登录按钮的class可能从login-btn改成了submit-btn,但它的角色永远是button,文本永远是“登录”。
另外,代码中尽量减少对页面层级结构的依赖。像这种选择器就很容易失效:
css复制div#main > div > div > button
一旦页面插入一个新的div,这个选择器就全废了。改为语义选择器能大幅提高稳定性。
6.4 无头模式下的行为差异
有些网站在无头模式下会返回不同的页面内容,或者直接触发反爬虫机制。这其实是因为网站通过浏览器的User-Agent或者其他特征识别了无头环境。
解决办法是在创建Context时自定义User-Agent:
javascript复制const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
});
也可以使用一些常规参数来模拟真实环境,比如设置语言、时区、视口大小:
javascript复制const context = await browser.newContext({
locale: 'zh-CN',
timezoneId: 'Asia/Shanghai',
viewport: { width: 1920, height: 1080 },
});
这并不会完全消除被识别的风险,但对于多数场景已经足够用。如果你遇到更严格的反爬策略,那就需要具体分析特征来源,这属于另一个话题了。
6.5 内存泄漏与长跑任务的稳定性
定时任务跑了一周之后,内存占用越来越高,最后服务器卡死。这是长跑任务最经典的问题。
根本原因通常是脚本在循环中没有正确关闭浏览器或Context。注意:browser.close()只关闭浏览器进程,但如果你在循环里反复创建Context而不关闭,内存就会一直涨。
正确的循环写法:
javascript复制for (const url of urls) {
const context = await browser.newContext();
const page = await context.newPage();
try {
await page.goto(url);
} finally {
await context.close();
}
}
context.close()会释放该会话占用的内存和渲染进程。如果整个脚本运行完毕都不需要后续操作,最好把browser也一并关闭。
另外,可以在启动浏览器时加上参数限制资源:
javascript复制const browser = await chromium.launch({
headless: true,
args: [
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--single-process',
],
});
--disable-dev-shm-usage这个参数特别重要。在容器或内存受限环境下,/dev/shm默认只有64M,浏览器很容易因为共享内存不足而崩溃。加上这个参数会让Chromium使用临时文件代替共享内存,就能避免崩溃问题。
7. 从单机自动化到持续集成的扩展思路
7.1 定时任务与systemd托管
脚本写好了,真正跑起来还是要有个调度机制。我建议用systemd定时器,而不是cron,因为systemd能记录日志、控制依赖关系、处理失败重试。
一个基础的systemd服务文件长这样:
ini复制[Unit]
Description=Playwright daily check
[Service]
Type=oneshot
User=automation
WorkingDirectory=/home/automation/playwright-demo
ExecStart=/usr/bin/node /home/automation/playwright-demo/check.js
[Install]
WantedBy=multi-user.target
然后再写一个对应的timer文件:
ini复制[Unit]
Description=Run playwright daily check at 9am
[Timer]
OnCalendar=*-*-* 09:00:00
Persistent=true
[Install]
WantedBy=timers.target
启用:
bash复制sudo systemctl daemon-reload
sudo systemctl enable playwright-check.timer
sudo systemctl start playwright-check.timer
这样,每天早上9点服务器会自动执行巡检脚本,所有日志都可以通过journalctl -u playwright-check查看。比cron多出来的一个好处是:服务执行过程中的标准输出和错误输出都能被正确捕获,定位问题方便很多。
7.2 结合测试运行器做断言与报表
前面提到装的 @playwright/test 自带完整断言库。如果只是做定时任务,用普通Node脚本就够了。但如果你的场景是自动化测试,跑完之后要出测试报告,那强烈建议直接用 @playwright/test。
它的测试用例写法如下:
javascript复制const { test, expect } = require('@playwright/test');
test('登录流程验证', async ({ page }) => {
await page.goto('https://example.com/login');
await page.fill('#username', 'testuser');
await page.fill('#password', 'pass123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/dashboard/);
});
跑完后它会自动生成HTML测试报告,带每个步骤的截图和持续时间,整理给团队看非常清晰。
7.3 集成到CI流水线中的做法
如果项目已经上了CI,比如GitLab CI或者GitHub Actions,把Playwright测试加进去基本是水到渠成的事。
一个基础的流水线步骤是:
- 安装Node依赖。
- 执行npx playwright install chromium。
- 运行测试。
- 上传测试报告作为artifact。
CI环境通常是临时容器,每次跑都要重新下载浏览器。为了加速,可以启用Playwright的缓存机制,把浏览器二进制缓存到CI的持久化目录里。
7.4 真实任务中的经验补充
实际跑自动化任务半年之后,我最大的体会是:脚本本身只占整个工作量的一半,另一半在数据验证、异常通知和日志处理上。
比如定时巡检任务,脚本成功运行不等于任务成功。页面能否打开、关键元素是否存在、接口响应码是否为200,这些都需要显式判断并通过邮件或消息推送发出来。我在实际项目中会专门写一个结果汇总函数,把每次巡检的通过与否、截图路径、失败原因整理成结构化数据,再推送到团队群。
这种设计的好处是,你不用每天早上人工打开报表页面查看,只要群里没有告警消息,就说明一切正常。
我个人在实际使用中的体会是,不要试图把脚本写得“什么都能干”。Playwright再强大,也只是个浏览器操作工具,复杂业务的判断还是得靠自己的代码逻辑。与其在脚本里堆一堆繁琐的条件判断,不如把核心流程拆成几个独立的小脚本,各司其职,再通过系统层面的调度把它们串起来。这样单个脚本出问题了,排查起来特别快。
最后再分享一个小技巧:在Ubuntu上跑Playwright,如果经常遇到莫名其妙的崩溃,先看一眼服务器的空闲内存和磁盘空间。很多问题其实不是代码的锅,而是系统资源不够了。给Playwright任务留出足够的内存,它基本能一直稳定运行很久。
