Skip to content

Playwright


目录

  1. 安装 & 两种模式
  2. 核心三层架构
  3. 导航 & 等待策略
  4. 元素定位 & 操作
  5. 网络拦截 page.route()
  6. evaluate & 脚本注入
  7. 截图 & 文件操作
  8. 多浏览器 & 设备模拟
  9. NestJS 集成方案
  10. 项目结构点评
  11. 调试技巧
  12. 常见错误 & 解法

1. 安装 & 两种模式

两种用法的本质区别

库模式测试框架模式
包名playwright@playwright/test
用途生产服务、爬虫、自动化E2E 测试、CI/CD
浏览器管理你自己控制生命周期框架自动管理
有没有 HTTP 服务自己决定没有

结论:做服务用 playwright,做测试用 @playwright/test,不要混装。

bash
# 库模式(生产服务)
npm install playwright
npx playwright install chromium   # 浏览器二进制单独下载

# 测试模式(E2E 测试)
npm install -D @playwright/test
npx playwright install

为什么浏览器要单独安装?
Chromium 二进制文件体积约 200MB+,不打进 npm 包,需要 playwright install 单独下载。
Docker 部署时容易忘,建议加到 postinstall 钩子:

json
// package.json
{
  "scripts": {
    "postinstall": "npx playwright install chromium"
  }
}

2. 核心三层架构

Browser(进程级别,重)
  └── BrowserContext(会话隔离单元,轻)
        └── Page(一个 Tab)

三层各自的职责

层级类比隔离了什么生命周期
BrowserChrome 程序本身服务启动→关闭
BrowserContext一个浏览器用户 ProfileCookie / localStorage / IndexedDB / 缓存 / Service Worker每个任务/用户
Page一个 Tab页面 DOM / URL任务内

为什么这样分层

Browser 启动成本高(~300ms,几十 MB 内存),不能每次任务都重建。
BrowserContext 是轻量隔离层,并发任务互不污染,创建成本极低。
多实例共享一个 Browser = 节约资源 + 天然隔离 = BrowserPool 的设计基础。

typescript
import { chromium } from 'playwright';

// ✅ 正确模式:复用 Browser,每次新建 Context
const browser = await chromium.launch({
  headless: true,
  args: [
    '--no-sandbox',                                    // Docker 容器必须加,否则报错
    '--disable-dev-shm-usage',                         // 低内存环境防止 /dev/shm 不够用
    '--disable-blink-features=AutomationControlled',   // 隐藏自动化标志,防反爬检测
  ],
});

const context = await browser.newContext({
  userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)...',
  viewport: { width: 390, height: 844 },
  locale: 'zh-CN',
  timezoneId: 'Asia/Shanghai',
});

const page = await context.newPage();
await page.goto('https://example.com');

// 关闭顺序:page → context → browser
// 关闭 context 会自动关闭其下所有 page,通常不需要单独关 page
await context.close();
// browser 在服务退出时才关闭

3. 导航 & 等待策略

goto 的 waitUntil 选项(从快到慢)

typescript
// commit        → 收到响应头就返回(最快,适合不需要等内容的场景)
// domcontentloaded → HTML 解析完成(适合服务端渲染页面)
// load          → 所有资源加载完(默认值)
// networkidle   → 500ms 内无新请求(最慢,适合 SPA)

// 传统 HTML 页面
await page.goto(url, { waitUntil: 'load', timeout: 15_000 });

// Vue / React 等 SPA 应用(HTML 只是空壳,内容靠 JS 异步渲染)
await page.goto(url, { waitUntil: 'networkidle', timeout: 20_000 });

为什么 SPA 要用 networkidle
load 事件触发时 JS 刚开始执行,DOM 里什么都没有。networkidle 等所有异步请求完成,页面数据才真正渲染出来。

四种等待方式

typescript
// 1. waitForSelector — 等元素出现(最常用)
await page.waitForSelector('#submit-btn', {
  state: 'visible',   // visible(可见)/ attached(在 DOM 里)/ hidden / detached
  timeout: 10_000,
});

// 2. waitForResponse — 等接口返回(比 sleep 精确,强烈推荐)
// ⚠️ 必须和触发操作放在 Promise.all 里!
const [response] = await Promise.all([
  page.waitForResponse(res =>
    res.url().includes('/api/user') && res.status() === 200
  ),
  page.click('#load-btn'),  // 触发请求的操作
]);
const data = await response.json();

// 3. waitForNavigation — 等页面跳转完成
// ⚠️ 同样需要 Promise.all
await Promise.all([
  page.waitForNavigation({ waitUntil: 'networkidle' }),
  page.click('#login-btn'),
]);

// 4. waitForFunction — 等自定义条件(最灵活)
await page.waitForFunction(
  () => window.__APP_LOADED__ === true,
  { timeout: 15_000 }
);

为什么 waitForResponse 必须放 Promise.all 里?
如果先 clickwaitForResponse,响应可能在 waitForResponse 注册监听器之前就已经到达,导致永远等不到。
Promise.all 保证监听器先注册,点击后触发,时序正确。

超时三层防护

typescript
// 层级 1:Context 全局默认(所有操作的 fallback)
context.setDefaultTimeout(10_000);
context.setDefaultNavigationTimeout(20_000);

// 层级 2:单次操作覆盖全局设置
await page.click('#btn', { timeout: 5_000 });

// 层级 3:整体任务超时(业务层控制,在 NestJS service 里)
await Promise.race([
  runTask(page),
  new Promise((_, reject) =>
    setTimeout(() => reject(new Error('Task timeout')), 60_000)
  ),
]);

4. 元素定位 & 操作

选择器稳定性(从高到低)

typescript
// ✅ 最稳:语义选择器(不受样式/结构变化影响)
page.getByRole('button', { name: '提交' })
page.getByLabel('手机号')
page.getByPlaceholder('请输入手机号')
page.getByText('登录')

// ✅ 稳定:id 和 data-testid
page.locator('#submit-btn')
page.getByTestId('phone-input')   // 对应 data-testid="phone-input"

// ⚠️ 一般:class(可能被打包工具随机化为 a1b2c3)
page.locator('.submit-button')

// ❌ 脆弱:DOM 路径(HTML 改一点就崩)
page.locator('div.form > div:nth-child(3) > input')

为什么优先语义选择器?
Webpack/Vite 构建后 class 名可能变成随机哈希。但 aria-rolelabelplaceholder 是面向用户的语义,基本不变。选择器越贴近用户感知,代码越稳定。

常用操作 API

typescript
// 填写输入框
await page.fill('#phone', '13800138000');         // 清空后填写(推荐)
await page.locator('#phone').type('138', { delay: 80 }); // 模拟逐字输入(反检测场景)
await page.locator('#phone').clear();

// 点击
await page.click('#submit');
await page.click('#menu', { button: 'right' });   // 右键
await page.dblclick('#item');                     // 双击

// 键盘
await page.locator('#phone').press('Enter');
await page.keyboard.press('Escape');

// 下拉框
await page.selectOption('#city', 'shanghai');
await page.selectOption('#tags', ['a', 'b']);     // 多选

// 复选框
await page.check('#agree');
await page.uncheck('#subscribe');
await page.locator('#agree').isChecked();         // 读取状态

// 读取值
const text = await page.textContent('#result');
const val  = await page.inputValue('#phone');
const attr = await page.getAttribute('img', 'src');
const html = await page.innerHTML('#container');

Locator 链式过滤(多元素精确定位)

typescript
// 过滤包含特定文字的行,再找其中的按钮
const deleteBtn = page
  .locator('tr')
  .filter({ hasText: '张三' })
  .locator('button.delete');

await deleteBtn.click();

// nth:取第 N 个(0-based)
await page.locator('.product-card').nth(2).click();

// 遍历所有同类元素
const cards = page.locator('.product-card');
const count = await cards.count();
for (let i = 0; i < count; i++) {
  const name = await cards.nth(i).textContent();
  console.log(name);
}

5. 网络拦截 page.route()

page.route() 是 Playwright 最强的 API,可以完全控制浏览器发出的所有 HTTP 请求。

三种处理结果

typescript
// 1. abort — 直接阻断请求(屏蔽无用资源,减少内存占用 ~40%)
await page.route('**/*.{png,jpg,gif,webp,svg,woff,woff2}', route => route.abort());

// 2. continue — 修改请求后放行
await page.route('**/api/**', async route => {
  await route.continue({
    headers: {
      ...route.request().headers(),  // 保留原有 headers
      'X-Token': myToken,            // 注入认证 token
      'X-App-Version': '5.2.1',
    },
    postData: JSON.stringify(modifiedBody),  // 修改请求体
  });
});

// 3. fulfill — 直接返回 mock 数据(不发真实请求)
await page.route('**/config.json', route =>
  route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify({ env: 'test', feature: true }),
  })
);

为什么用 route 而不是 setExtraHTTPHeaders
setExtraHTTPHeaders 对所有请求全局生效,无法按 URL 区分。
route 可以针对不同接口注入不同 header,甚至完全替换请求体,灵活度远高于全局 header。

MITM 模式(拦截 + 读取响应)

typescript
// 让请求正常发出,同时在 Node 侧拿到响应数据
await page.route('**/order/list', async route => {
  // 先让请求真正发出去
  const response = await route.fetch();

  // 在 Node 侧读取响应体(可以解密、分析)
  const body = await response.json();
  console.log('接口响应明文:', body);

  // 把原始响应还给页面,页面感知不到任何拦截
  await route.fulfill({ response });
});

MITM 模式的价值:
页面行为完全正常,但你在 Node 侧已经截获了所有响应数据。
适合分析加密协议、记录接口数据,不需要侵入页面 JS。

监听 WebSocket 帧

typescript
// route() 只拦截 HTTP,WebSocket 需要用 on('websocket')
page.on('websocket', ws => {
  console.log('WS 连接建立:', ws.url());

  ws.on('framesent',     frame => console.log('→ 发送:', frame.payload));
  ws.on('framereceived', frame => console.log('← 接收:', frame.payload));
  ws.on('close',         ()    => console.log('WS 关闭'));
});

取消路由监听

typescript
// 某个路由只需要拦截一次
const handler = async (route: Route) => {
  // 处理完后取消监听
  await page.unroute('**/api/**', handler);
  await route.continue();
};
await page.route('**/api/**', handler);

6. evaluate & 脚本注入

两个世界之间的边界

Node.js 进程          浏览器进程
─────────────         ─────────────
你的 NestJS 代码  ←→  页面 JS / window / DOM
通过 WebSocket 通信(JSON 序列化)

addInitScript — 在页面 JS 之前注入

typescript
// 在每个 frame 创建时、任何页面脚本执行之前注入
// 可以 hook 原生函数(覆盖 Date、fetch 等)

// 方式 1:注入函数(可传参数)
await page.addInitScript(
  ({ aesKey, appId }) => {
    window._aesKey = aesKey;   // 挂载到 window,页面 JS 可以读到
    window._appId = appId;
  },
  { aesKey: process.env.AES_KEY, appId: 'myapp' }
);

// 方式 2:注入外部 JS 文件(推荐,文件有 IDE 支持)
await page.addInitScript({ path: './inject/crypto.js' });

// 方式 3:注入字符串
import { readFileSync } from 'fs';
const script = readFileSync('./inject/crypto.js', 'utf-8');
await page.addInitScript(script);

为什么 inject/ 目录下的文件用 .js 不用 .ts
注入脚本运行在浏览器里,不经过 TypeScript 编译。
.ts 会让你误以为有类型保护,但实际运行的是编译后的 .js,容易造成路径和语法的混乱。直接写 .js 更诚实。

evaluate — 在浏览器上下文执行

typescript
// ✅ 正确:只传 JSON-safe 的数据
const result = await page.evaluate(
  ({ url, token, body }) => {
    // 这里是浏览器世界,可以访问 window / document / DOM
    return window._axios.post(url, body, {
      headers: { Authorization: token }
    });
  },
  { url: '/api/order', token: myToken, body: requestBody }
  //                                        ^^^^
  // 第二个参数通过 JSON 序列化传给函数
);

// ❌ 错误:不能传函数、Buffer、Class 实例
await page.evaluate(fn => fn(), myFunction);        // 报错
await page.evaluate(buf => buf, Buffer.from('x'));  // 报错

为什么只能传 JSON-safe 数据?
Node 进程和浏览器进程之间通过 WebSocket 通信,数据必须序列化为 JSON。函数和 Buffer 无法 JSON 化,所以无法跨越这道边界。

evaluate vs evaluateHandle

typescript
// evaluate:返回序列化后的值(适合拿数据)
const token = await page.evaluate(
  () => localStorage.getItem('token')
); // 在 Node 侧是普通 string,可以直接用

// evaluateHandle:返回 JSHandle(适合操作 DOM 对象引用)
const handle = await page.evaluateHandle(
  () => document.querySelector('#app')
);
// handle 是浏览器里 DOM 的引用,不是实际值
// 需要再次 evaluate 才能读属性
const text = await handle.evaluate(el => el.textContent);

exposeFunction — 从浏览器调用 Node 函数

typescript
// 把 Node 侧的函数暴露给浏览器(反向调用)
await page.exposeFunction('nodeDecrypt', async (ciphertext: string) => {
  // 这里运行在 Node 侧,可以用 Node 原生 crypto、读文件、访问 DB
  return nodeCryptoLib.decrypt(ciphertext);
});

// 浏览器 JS 里就可以调用:
// const plain = await window.nodeDecrypt(encrypted);

exposeFunction 的使用场景:
当浏览器内的解密逻辑需要依赖 Node 原生模块(如 node:crypto 的特定算法),或需要访问 Node 侧的文件系统、数据库时使用。
它打通了 浏览器 → Node 的调用链,与 evaluate 的 Node → 浏览器 方向相反。


7. 截图 & 文件操作

截图

typescript
// 全页面截图(包含滚动区域)
await page.screenshot({
  path: './output/full.png',
  fullPage: true,
  type: 'png',
});

// 截取特定元素(验证码场景)
const captcha = page.locator('#captcha-img');
const buffer = await captcha.screenshot();
// buffer 是 Node Buffer,可以直接传给 OCR 库(如 ddddocr)

// 转 base64(用于传 API)
const base64 = buffer.toString('base64');

生成 PDF

typescript
// 只支持 Chromium headless 模式
await page.goto('https://example.com/report', { waitUntil: 'networkidle' });
await page.waitForSelector('.data-loaded');  // 等数据渲染完再截

const pdfBuffer = await page.pdf({
  format: 'A4',
  printBackground: true,   // 打印背景色和背景图片
  margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' },
});

fs.writeFileSync('./output/report.pdf', pdfBuffer);

文件下载

typescript
// ⚠️ 和 waitForResponse 一样,必须放 Promise.all
const [download] = await Promise.all([
  page.waitForEvent('download'),  // 先注册监听
  page.click('#export-btn'),      // 再触发下载
]);

await download.saveAs('./output/data.xlsx');
console.log('文件名:', download.suggestedFilename());

// 不保存文件,直接读 Buffer
const stream = await download.createReadStream();

文件上传

typescript
// 直接设置 input[type=file] 的值(不需要弹出系统文件对话框)
await page.setInputFiles('#upload', './upload/image.jpg');

// 多文件
await page.setInputFiles('#upload', ['./a.jpg', './b.jpg']);

// 从 Buffer 上传(不需要临时文件)
await page.setInputFiles('#upload', {
  name: 'report.png',
  mimeType: 'image/png',
  buffer: imageBuffer,
});

8. 多浏览器 & 设备模拟

三种浏览器切换

typescript
import { chromium, firefox, webkit } from 'playwright';

// 同一套代码,只换一行
const browser = await chromium.launch(); // Chrome / Edge 内核
const browser = await firefox.launch();  // Firefox
const browser = await webkit.launch();   // Safari 内核

爬虫为什么几乎只用 Chromium?
大多数中国互联网产品只针对 Chrome 测试,加密/签名逻辑有时依赖 V8 特性。用 Firefox 或 WebKit 可能触发降级逻辑或直接报错,Chromium 最保险。

内置设备模拟

typescript
import { devices } from 'playwright';

// 内置 100+ 种设备(iPhone、Android、iPad 等)
const iPhone = devices['iPhone 14 Pro'];

const context = await browser.newContext({
  ...iPhone,                  // userAgent + viewport + deviceScaleFactor 一次性设置
  locale: 'zh-CN',
  timezoneId: 'Asia/Shanghai',
  geolocation: { latitude: 31.23, longitude: 121.47 },
  permissions: ['geolocation'],
});

自定义指纹(反检测)

typescript
const context = await browser.newContext({
  userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)...',
  viewport: { width: 390, height: 844 },
  deviceScaleFactor: 3,   // 视网膜屏 DPR
  isMobile: true,
  hasTouch: true,
  colorScheme: 'dark',
  extraHTTPHeaders: {
    'Accept-Language': 'zh-CN,zh;q=0.9',
  },
});

// 隐藏 webdriver 标志(在 addInitScript 里覆盖原生属性)
await context.addInitScript(() => {
  Object.defineProperty(navigator, 'webdriver', {
    get: () => undefined,  // 让 navigator.webdriver 返回 undefined 而不是 true
  });
  // 让 plugins 列表不为空(headless 默认为空,是检测指标之一)
  Object.defineProperty(navigator, 'plugins', {
    get: () => [1, 2, 3],
  });
});

9. NestJS 集成方案

无 HTTP 服务的正确写法

typescript
// main.ts
// createApplicationContext 启动完整 DI 容器,但不监听任何端口
async function bootstrap() {
  const app = await NestFactory.createApplicationContext(AppModule);

  // 必须加!监听 SIGTERM/SIGINT,触发 OnModuleDestroy 生命周期
  // 不加的后果:Kubernetes 发 SIGTERM 时 browser.close() 不会执行
  // → Chromium 子进程变孤儿进程 → 反复发版后内存持续增长
  app.enableShutdownHooks();
}
bootstrap();

BrowserService — 生命周期管理

typescript
// browser/browser.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { chromium, Browser, BrowserContext, Page } from 'playwright';

@Injectable()
export class BrowserService implements OnModuleInit, OnModuleDestroy {
  private browser: Browser;

  // NestJS 模块初始化时自动调用(相当于构造函数后执行)
  async onModuleInit(): Promise<void> {
    this.browser = await chromium.launch({
      headless: true,
      args: ['--no-sandbox', '--disable-dev-shm-usage'],
    });
  }

  // 收到 SIGTERM/SIGINT 时自动调用(enableShutdownHooks 触发)
  async onModuleDestroy(): Promise<void> {
    await this.browser?.close();
  }

  // 核心方法:创建隔离 Context,任务跑完自动销毁
  async runInContext<T>(
    fn: (page: Page) => Promise<T>
  ): Promise<T> {
    const context = await this.browser.newContext({
      locale: 'zh-CN',
      userAgent: 'Mozilla/5.0 ...',
    });
    const page = await context.newPage();
    try {
      return await fn(page);
    } finally {
      // 无论成功还是异常,context 必须关闭
      // 否则每次调用都会泄漏一个 Context(~30-80MB 内存)
      await context.close();
    }
  }
}

为什么用 OnModuleInit 而不是构造函数?
NestJS 构造函数必须是同步的,await chromium.launch() 是异步操作。OnModuleInit 专门为异步初始化设计,是 NestJS 的最佳实践。

BrowserPool — 并发控制

typescript
// browser/browser-pool.service.ts
// 当需要限制并发数量时(防止浏览器内存溢出),在 BrowserService 上再加一层

@Injectable()
export class BrowserPoolService implements OnModuleInit, OnModuleDestroy {
  private browsers: Browser[] = [];
  private queue: Array<() => void> = [];
  private activeCount = 0;
  private readonly MAX_CONCURRENCY = 5;  // 根据服务器内存调整

  // 带并发控制的执行方法
  async run<T>(fn: (page: Page) => Promise<T>): Promise<T> {
    // 超过并发上限时排队等待
    if (this.activeCount >= this.MAX_CONCURRENCY) {
      await new Promise<void>(resolve => this.queue.push(resolve));
    }
    this.activeCount++;

    const browser = this.pickBrowser();
    const context = await browser.newContext();
    const page = await context.newPage();
    try {
      return await fn(page);
    } finally {
      await context.close();
      this.activeCount--;
      // 唤醒队列里等待的下一个任务
      this.queue.shift()?.();
    }
  }

  private pickBrowser(): Browser {
    // 轮询选择浏览器实例(负载均衡)
    const idx = this.activeCount % this.browsers.length;
    return this.browsers[idx];
  }
}

路由拆分(routes/ 目录)

typescript
// browser/routes/base.route.ts — 通用规则,每次都加载
export async function applyBaseRoutes(page: Page): Promise<void> {
  // 屏蔽无用资源,减少内存和带宽消耗
  await page.route('**/*.{png,jpg,gif,webp,svg,ico,woff,woff2}', route =>
    route.abort()
  );
}

// browser/routes/auth.route.ts — 需要认证的接口
export async function applyAuthRoutes(page: Page, token: string): Promise<void> {
  await page.route('**/api/**', async route => {
    await route.continue({
      headers: {
        ...route.request().headers(),
        'Authorization': `Bearer ${token}`,
        'X-App-Version': '5.2.1',
      },
    });
  });
}

// 在 task 里组合使用
async execute(page: Page, opts: TaskOpts): Promise<void> {
  await applyBaseRoutes(page);
  await applyAuthRoutes(page, opts.token);
  // 再加 task 专用的路由规则...
}

为什么要把路由规则拆到单独文件?
不同的 task 可能需要不同的拦截规则组合,拆分后可以像积木一样自由组合,而不是每个 task 都重复写一遍相同的屏蔽图片代码。

消息队列集成(MQTT / BullMQ)

typescript
// tasks/order.task.ts
@Injectable()
export class OrderTask {
  constructor(private readonly browserPool: BrowserPoolService) {}

  async execute(task: OrderPayload): Promise<OrderResult> {
    return this.browserPool.run(async page => {
      // 业务逻辑:只关心"做什么",不关心浏览器怎么管
      await applyBaseRoutes(page);
      await page.goto(TARGET_URL, { waitUntil: 'networkidle' });
      await page.fill('#phone', task.phone);
      await page.click('#submit');

      const [response] = await Promise.all([
        page.waitForResponse(r => r.url().includes('/order/submit')),
        page.click('#confirm'),
      ]);
      return response.json();
    });
  }
}

10. 项目结构点评

你的当前结构:

browser_test/
├── core/
├── scripts/
├── services/
├── tasks/
├── utils/
├── browser.enum.ts
├── browser.exception.ts
├── browser.interface.ts
└── browser.module.ts

✅ 做得好的地方

文件/目录评价
browser.enum.ts单独枚举文件,避免 magic string,赞
browser.exception.ts自定义异常类,错误类型清晰
browser.interface.ts接口/类型定义分离,TypeScript 最佳实践
browser.module.ts模块文件独立,NestJS 标准做法
tasks/ 目录业务逻辑与浏览器控制分离,思路正确

⚠️ 建议改进的地方

1. core/ 命名太模糊
core 放什么?服务?配置?建议根据实际内容重命名:

core/ → browser-pool/  (如果放池管理相关)
     → config/         (如果放配置)
     → base/           (如果放基类)

2. scripts/inject/ 没有区分
爬虫项目常见两类脚本:

  • Node 侧执行的脚本(启动、调试用)→ 放项目根的 scripts/
  • 注入到浏览器的 JS → 应该叫 inject/,且只放 .js 文件

建议:

browser_test/
└── inject/            ← 注入到浏览器的 .js 文件
scripts/               ← Node 侧工具脚本(项目根目录层面)

3. services/ 太宽泛,建议按职责细分

services/
├── browser.service.ts      ← 浏览器生命周期
└── browser-pool.service.ts ← 并发控制

4. 缺少 routes/ 目录(如果有路由拦截逻辑)
拦截规则如果全写在 service 或 task 里,复用性差。建议:

routes/
├── base.route.ts     ← 通用规则(屏蔽图片等)
├── auth.route.ts     ← 认证注入
└── crypto.route.ts   ← 加解密拦截

建议的完整结构

browser_test/
├── core/                         ← 建议重命名为更具体的名字
├── inject/                       ← 注入到浏览器的 .js 文件(不经 tsc)
│   ├── crypto.js
│   └── fingerprint.js
├── routes/                       ← 路由/拦截规则(可选,看是否有拦截需求)
│   ├── base.route.ts
│   └── auth.route.ts
├── services/
│   ├── browser.service.ts
│   └── browser-pool.service.ts
├── tasks/
│   ├── order.task.ts
│   └── query.task.ts
├── utils/
├── browser.enum.ts
├── browser.exception.ts
├── browser.interface.ts
└── browser.module.ts

总体评价:结构思路是对的,主要问题是命名不够精确。核心文件(enum/exception/interface/module)独立出来的做法很好,继续保持。


11. 调试技巧

有头模式(最直观)

typescript
// 开发调试时用有头模式
chromium.launch({
  headless: false,  // 打开浏览器窗口
  slowMo: 500,      // 每步慢 500ms,便于观察操作
  devtools: true,   // 自动打开 DevTools
});

pause() — 代码级断点

typescript
await page.goto(url);
await page.pause();         // 脚本在这里暂停,可以在 Inspector 里手动操作
// 在 Playwright Inspector 里点"继续",脚本才往下走
await page.click('#btn');

Trace Viewer — 最强调试工具

typescript
// 生产环境出问题时,开启 trace 录制
await context.tracing.start({
  screenshots: true,  // 每步截图
  snapshots: true,    // DOM 快照
  sources: true,      // 关联源代码
});

// 执行业务逻辑...

await context.tracing.stop({ path: 'trace.zip' });
bash
# 在本地查看 trace(可以回放整个操作时间线)
npx playwright show-trace trace.zip

Trace Viewer 能看什么?

  • 完整操作时间线(每步操作的时间戳)
  • 每步操作前后的 DOM 快照
  • 所有网络请求的 header / body / response
  • 控制台输出和 JS 错误

出了线上 bug 不需要重新复现,下载 trace.zip 即可完整回放。

监听浏览器控制台

typescript
// 把浏览器的 console.log 输出到 Node 侧(调试注入脚本时很有用)
page.on('console', msg => {
  console.log(`[Browser ${msg.type()}]`, msg.text());
});

// 监听页面 JS 报错
page.on('pageerror', error => {
  console.error('[Page Error]', error.message);
});

// 监听所有请求(快速查看接口列表)
page.on('request', req => {
  if (req.url().includes('/api/')) {
    console.log(`→ ${req.method()} ${req.url()}`);
  }
});

环境变量调试

bash
# 打印所有 Playwright 内部日志
DEBUG=pw:* node dist/main.js

# 只打印 API 调用记录
DEBUG=pw:api node dist/main.js

# 打印浏览器协议通信(底层 CDP)
DEBUG=pw:protocol node dist/main.js

12. 常见错误 & 解法

TimeoutError: waiting for selector

Error: Timeout 10000ms exceeded while waiting for selector '#submit'

原因排查顺序:

  1. waitUntil 用的是 load 但页面是 SPA → 改成 networkidle
  2. 选择器写错了 → 有头模式打开页面,在 DevTools Console 里验证 document.querySelector('#submit')
  3. 元素在 iframe 里 → 需要先 page.frameLocator('#iframe').locator('#submit')
  4. 网络太慢 → 适当加大 timeout

Context already closed

Error: browserContext.newPage: Target page, context or browser has been closed

原因:context.close() 之后还在使用这个 context 或其下的 page。
解法: 检查并发代码,确保每个 context 只被一个任务独占使用。

evaluate 报错 "Argument of type ... is not serializable"

Error: Argument of type 'Buffer' is not serializable

原因: evaluate 的参数必须是 JSON-safe 的,Buffer 无法序列化。
解法: 将 Buffer 转成 base64 字符串再传:

typescript
await page.evaluate(
  (base64) => window.processImage(base64),
  buffer.toString('base64')  // Buffer → string
);

并发时数据互相污染

症状: A 任务的数据出现在 B 任务的结果里。
原因: 多个任务共享了同一个 BrowserContext 或 Page。
解法: 确保每个任务都创建独立的 Context,任务结束后立即 context.close()

Docker 里 Chromium 启动失败

Error: Failed to launch chromium: error while loading shared libraries

解法: Dockerfile 里安装系统依赖:

dockerfile
FROM node:20-slim
RUN apt-get update && apt-get install -y \
    chromium \
    fonts-noto-cjk \
    --no-install-recommends && rm -rf /var/lib/apt/lists/*

ENV PLAYWRIGHT_BROWSERS_PATH=/usr/bin

或在 launch() 里指定路径:

typescript
chromium.launch({
  executablePath: '/usr/bin/chromium',
  args: ['--no-sandbox', '--disable-dev-shm-usage'],
});