5月28日 07:10

Puppeteer 如何处理动态网页和单页应用(SPA)?

Puppeteer 在处理动态网页和单页应用(SPA)时拥有天然优势——它运行完整的 Chromium 浏览器,能够执行 JavaScript、等待异步加载完成、捕获路由变化,这些都是传统 HTTP 爬虫无法做到的。但真正写出健壮的 SPA 爬虫,关键在于选择正确的等待策略、合理拦截网络请求、以及处理各种边界情况。

等待动态内容加载的正确方式

SPA 的核心特征是页面内容由 JavaScript 动态渲染,因此"等待"是 Puppeteer 爬虫的第一要务。三种等待策略各有适用场景:

waitForSelector — 等待元素出现

最常用的等待方式,适合目标元素有明确选择器的场景:

javascript
const puppeteer = require('puppeteer'); async function scrapeDynamicContent() { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto('https://example.com'); // visible: true 确保元素不仅存在于 DOM,而且可见 await page.waitForSelector('.dynamic-content', { visible: true }); const content = await page.$eval('.dynamic-content', el => el.textContent); await browser.close(); return content; }

waitForFunction — 等待自定义条件

当等待条件无法用单一选择器表达时,用 waitForFunction 编写判断逻辑:

javascript
// 等待列表项数量超过阈值 await page.waitForFunction(() => { return document.querySelectorAll('.item').length > 10; }); // 等待全局状态就绪 await page.waitForFunction(() => { return window.__APP_READY__ === true; });

waitUntil 选项 — 等待网络状态

page.gotowaitUntil 参数控制何时认为页面加载完成:

  • domcontentloaded:DOM 解析完成,不等样式和图片
  • load:所有资源加载完毕
  • networkidle0:500ms 内无网络请求(适合纯 API 驱动的页面)
  • networkidle2:500ms 内不超过 2 个网络请求(适合有长连接或分析脚本的页面)
javascript
// SPA 最常用的加载策略 await page.goto('https://example.com', { waitUntil: 'networkidle2' });

处理无限滚动与懒加载

无限滚动是 SPA 中最常见的加载模式,核心思路是循环滚动并检测新内容是否出现。

基础版:检测页面高度变化

javascript
async function scrapeInfiniteScroll(page, maxItems = 100) { const items = []; let previousHeight = 0; while (items.length < maxItems) { await page.evaluate(() => { window.scrollTo(0, document.body.scrollHeight); }); // 等待新内容渲染,优先等待选择器而非固定时间 try { await page.waitForSelector('.item:last-child', { timeout: 3000 }); } catch { break; // 超时说明没有更多内容 } const currentHeight = await page.evaluate(() => document.body.scrollHeight); if (currentHeight === previousHeight) break; previousHeight = currentHeight; const newItems = await page.$$eval('.item', els => els.map(el => el.textContent.trim()) ); items.push(...newItems); } return [...new Set(items)]; // 去重 }

进阶版:等待加载指示器消失

更可靠的方式是观察"加载中"指示器的出现和消失:

javascript
async function scrapeInfiniteScrollRobust(page) { const items = []; let noNewItemsCount = 0; while (noNewItemsCount < 3) { const countBefore = items.length; await page.evaluate(() => { window.scrollTo(0, document.body.scrollHeight); }); // 等加载指示器消失 try { await page.waitForSelector('.loading-spinner', { hidden: true, timeout: 5000 }); } catch { noNewItemsCount++; continue; } const currentItems = await page.$$eval('.item', els => els.map(el => ({ id: el.dataset.id, text: el.textContent.trim() })) ); // 只添加新项目 const existingIds = new Set(items.map(i => i.id)); const freshItems = currentItems.filter(i => !existingIds.has(i.id)); if (freshItems.length === 0) { noNewItemsCount++; } else { noNewItemsCount = 0; items.push(...freshItems); } } return items; }

处理 SPA 路由变化

SPA 的路由切换不会触发页面刷新,URL 变了但浏览器不会重新加载,因此需要主动监听和等待。

等待 URL 变化到目标路径

javascript
async function waitForRoute(page, targetPath, timeout = 10000) { await page.waitForFunction( (path) => window.location.pathname === path, { timeout }, targetPath ); } // 使用 await page.click('#about-link'); await waitForRoute(page, '/about');

监听所有路由变化

通过 framenavigated 事件捕获 SPA 内的导航:

javascript
page.on('framenavigated', (frame) => { if (frame === page.mainFrame()) { console.log('路由变化:', frame.url()); } }); // 触发导航 await page.click('#nav-link');

等待 SPA 渲染完成再提取数据

路由切换后,新页面的 DOM 还没渲染出来,直接提取会拿到空数据:

javascript
async function navigateAndExtract(page, linkSelector, contentSelector) { await Promise.all([ page.waitForNavigation({ waitUntil: 'networkidle2' }), page.click(linkSelector), ]); // 路由已切换,等待新内容渲染 await page.waitForSelector(contentSelector, { visible: true }); return page.$eval(contentSelector, el => el.textContent); }

拦截和监控网络请求

掌握 SPA 的网络请求是高效爬取的关键——你可以直接拿到 API 返回的 JSON 数据,无需解析 DOM。

等待特定 API 响应

javascript
async function waitForAPIResponse(page, urlPattern) { return page.waitForResponse( response => response.url().includes(urlPattern) && response.status() === 200 ); } // 点击触发请求,同时等待响应 const [response] = await Promise.all([ waitForAPIResponse(page, '/api/data'), page.click('#load-data'), ]); const data = await response.json(); console.log(data);

拦截请求:屏蔽不需要的资源

减少不必要的网络请求能显著提升爬取速度:

javascript
await page.setRequestInterception(true); page.on('request', (request) => { const blockedTypes = ['image', 'font', 'media', 'stylesheet']; if (blockedTypes.includes(request.resourceType())) { request.abort(); } else { request.continue(); } });

修改请求:注入认证信息

javascript
await page.setRequestInterception(true); page.on('request', (request) => { if (request.url().includes('/api/')) { request.continue({ headers: { ...request.headers(), 'Authorization': 'Bearer your-token', }, }); } else { request.continue(); } });

注意: setRequestInterception 开启后,所有请求都必须手动调用 continue()abort(),否则请求会挂起。

处理 WebSocket 实时数据

SPA 中的实时功能(聊天、行情、通知)通常依赖 WebSocket,Puppeteer 可以通过 Chrome DevTools Protocol 监听 WebSocket 消息。

javascript
const client = await page.createCDPSession(); await client.send('Network.enable'); // 接收 WebSocket 消息 client.on('Network.webSocketFrameReceived', (params) => { console.log('收到:', params.response.payloadData); }); // 发送 WebSocket 消息 client.on('Network.webSocketFrameSent', (params) => { console.log('发送:', params.response.payloadData); }); // WebSocket 关闭 client.on('Network.webSocketClosed', () => { console.log('WebSocket 连接已关闭'); });

这种方式适合监听实时推送数据,比轮询 DOM 更高效。

处理 React/Vue 等 SPA 框架

不同框架的渲染机制略有差异,但核心思路一致:等待框架渲染完成标志。

React 应用

javascript
async function scrapeReactApp(page, url) { await page.goto(url, { waitUntil: 'networkidle2' }); // React 18+ 使用 createRoot,应用挂载到 root 节点 await page.waitForSelector('#root'); // 等待数据加载(如果框架暴露了全局状态) await page.waitForFunction(() => { return window.__INITIAL_STATE__?.loaded === true; }); // 或直接等待目标元素 await page.waitForSelector('[data-loaded="true"]'); return page.$$eval('.data-item', els => els.map(el => el.textContent.trim()) ); }

Vue 应用

javascript
async function scrapeVueApp(page, url) { await page.goto(url, { waitUntil: 'networkidle2' }); // Vue 3 应用挂载到 app 节点 await page.waitForSelector('#app'); // 等待 Vue 组件渲染 await page.waitForFunction(() => { return document.querySelector('.v-cloak') === null; }); return page.content(); }

通用方案:等待 DOM 稳定

如果无法判断框架类型,可以等待 DOM 变化趋于稳定:

javascript
async function waitForDOMStable(page, checkInterval = 500, stableThreshold = 3) { let lastHTML = ''; let stableCount = 0; while (stableCount < stableThreshold) { const currentHTML = await page.evaluate(() => document.body.innerHTML.length); if (currentHTML === lastHTML) { stableCount++; } else { stableCount = 0; lastHTML = currentHTML; } await new Promise(r => setTimeout(r, checkInterval)); } }

实战场景:完整的 SPA 爬虫

把上述技巧组合起来,写一个能应对真实 SPA 的爬虫:

javascript
async function scrapeSPA(url) { const browser = await puppeteer.launch({ headless: 'new' }); const page = await browser.newPage(); // 屏蔽无关资源,加速加载 await page.setRequestInterception(true); page.on('request', (req) => { ['image', 'font', 'media'].includes(req.resourceType()) ? req.abort() : req.continue(); }); await page.goto(url, { waitUntil: 'networkidle2' }); // 收集 API 数据(比解析 DOM 更可靠) const apiData = []; page.on('response', async (response) => { if (response.url().includes('/api/') && response.status() === 200) { try { apiData.push(await response.json()); } catch {} } }); // 处理无限滚动 const allItems = await scrapeInfiniteScroll(page, 50); await browser.close(); return { allItems, apiData }; }

关键陷阱与应对

1. waitForTimeout 已废弃

page.waitForTimeout() 在 Puppeteer 21+ 中已移除,用原生 setTimeout 替代:

javascript
// 旧写法(已废弃) await page.waitForTimeout(2000); // 新写法 await new Promise(resolve => setTimeout(resolve, 2000));

2. 超时与错误处理

SPA 加载时间不确定,所有等待操作都应设置超时并提供降级方案:

javascript
try { await page.waitForSelector('.content', { timeout: 10000 }); } catch { // 降级:尝试其他选择器或返回默认值 const content = await page.evaluate(() => document.querySelector('.fallback-content')?.textContent || '' ); }

3. SPA 中的内存泄漏

长时间运行的爬虫中,事件监听器会累积:

javascript
// 用完即移除 const handler = (response) => { /* ... */ }; page.on('response', handler); // 完成后 page.off('response', handler);

4. 反爬虫检测

SPA 站点通常有更复杂的反爬机制:

javascript
// 伪装浏览器指纹 await page.setViewport({ width: 1920, height: 1080 }); await page.setUserAgent( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' ); // 注入 stealth 插件 const StealthPlugin = require('puppeteer-extra-plugin-stealth'); const puppeteerExtra = require('puppeteer-extra'); puppeteerExtra.use(StealthPlugin());
标签:Puppeteer