4.JavaScript 执行与数据提取
在浏览器自动化过程中,经常会遇到这样的场景:页面通过 JavaScript 动态渲染内容,或者需要获取页面内部状态,而这些信息无法通过简单的 DOM 查询直接获得。Puppeteer 提供了强大的 JavaScript 执行能力,让我们能够在页面上下文中运行任意代码,这就像在浏览器控制台里输入命令一样灵活,只不过这一切都由程序自动控制。
page.evaluate 执行 JavaScript
Puppeteer 执行 JavaScript 的核心方法是 page.evaluate。这个方法允许我们将一个函数发送到页面上下文中执行,并返回执行结果。看起来简单,但里面有些细节需要特别注意。
基本用法与执行上下文
先看一个最简单的例子:
import puppeteer from 'puppeteer';
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://example.com');
const result = await page.evaluate(() => {
return 1 + 2;
});
console.log(result); // 输出:3
await browser.close();
})();
这段代码在页面上下文中执行了一个简单的加法运算。关键点在于,page.evaluate 接收的函数并不是在当前 Node.js 环境中执行,而是被 Puppeteer 序列化成字符串,发送到浏览器页面中,再由浏览器的 JavaScript 引擎执行。这个过程有点像远程过程调用(RPC),但更简单直接。
正因为这样,函数内部无法访问外部作用域的变量,也不能调用在 Puppeteer 脚本中定义的其他函数。所有需要的逻辑都必须完整地写在函数体内部。这是一个常见的坑,很多初学者会尝试这样写:
// 错误示例
const multiplier = 10;
const result = await page.evaluate(() => {
return 5 * multiplier; // 报错:multiplier is not defined
});
上面的代码会失败,因为 multiplier 变量在页面上下文中不存在。后面会讲到如何正确传递参数。
除了传递函数,还可以直接传入字符串形式的 JavaScript 代码:
const result = await page.evaluate(`1 + 2`);
console.log(result); // 输出:3
这种方式虽然简洁,但失去了 TypeScript 的类型检查能力,也容易出错。字符串中的代码在页面上下文中执行,我们无法确保其中引用的变量和类型是正确的。因此,除非特殊情况,推荐使用函数形式。
返回值类型与序列化
page.evaluate 的返回值可以是各种类型,但处理方式不同。原始类型(number、string、boolean、null、undefined)会自动转换,直接在 Node.js 环境中使用。
const data = await page.evaluate(() => {
return {
number: 42,
string: 'hello',
boolean: true,
null: null,
undefined: undefined,
date: new Date()
};
});
console.log(data);
// 输出:
// {
// number: 42,
// string: 'hello',
// boolean: true,
// null: null,
// undefined: undefined,
// date: '2024-01-01T00:00:00.000Z' // Date 对象被序列化为字符串
// }
注意,即使是原始类型,也有一些特殊情况。比如 Date 对象会被序列化为 ISO 字符串,undefined 在对象属性中会被忽略。这些细节在数据处理时需要格外小心。
当返回值是对象时,Puppeteer 会将其序列化为 JSON,然后在脚本端重建。这个过程对于大多数普通对象工作良好,但对于某些特殊对象会出问题,最典型的就是 DOM 节点。
const body = await page.evaluate(() => {
return document.body;
});
console.log(body); // 输出:{},一个空对象
为什么会这样?因为 DOM 节点包含循环引用和大量方法,无法通过 JSON 序列化。Puppeteer 遇到这种情况会返回一个空对象,这通常不是我们想要的结果。
JSHandle 与 ElementHandle
为了处理无法序列化的对象,Puppeteer 提供了 page.evaluateHandle 方法。这个方法返回一个句柄(Handle),而不是序列化后的值。
const bodyHandle = await page.evaluateHandle(() => {
return document.body;
});
console.log(bodyHandle instanceof ElementHandle); // 输出:true
evaluateHandle 返回的是 JSHandle 或 ElementHandle 实例。ElementHandle 继承自 JSHandle,专门用于 DOM 元素。通过这些句柄,我们可以在 Node.js 环境中操作页面内的 JavaScript 对象。
拿到句柄后,可以调用它的各种方法:
// 获取元素的文本内容
const text = await bodyHandle.evaluate(el => el.textContent);
console.log(text);
// 获取元素的属性
const id = await bodyHandle.evaluate(el => el.id);
console.log(id);
// 在元素上执行点击操作
await bodyHandle.click();
// 释放句柄,避免内存泄漏
await bodyHandle.dispose();
句柄提供了一种桥梁机制,让我们能够在 Node.js 环境中持有对页面内对象的引用,并通过后续的 evaluate 调用来操作这些对象。使用完句柄后,记得调用 dispose() 方法释放,否则可能导致内存泄漏。
处理 Promise 异步返回值
现代 Web 应用大量使用异步操作,page.evaluate 对 Promise 提供了原生支持。如果在页面上下文中返回一个 Promise,page.evaluate 会自动等待其 resolve,然后返回 resolve 的值。
自动等待机制
await page.evaluate(() => {
return new Promise(resolve => {
setTimeout(() => resolve('延迟完成'), 1000);
});
});
// 这里的代码会在上面的 Promise resolve 后才执行
console.log('继续执行');
这个特性非常有用,可以处理各种异步场景。比如等待某个条件满足:
const result = await page.evaluate(() => {
return new Promise(resolve => {
const check = () => {
const element = document.querySelector('.async-data');
if (element && element.textContent) {
resolve(element.textContent);
} else {
setTimeout(check, 100);
}
};
check();
});
});
虽然 Puppeteer 提供了更优雅的等待机制(后面章节会专门介绍),但在某些复杂场景下,手动在 evaluate 中处理 Promise 仍然很有价值。
处理异步 API 调用
页面中的 fetch 请求、IndexedDB 操作等异步 API 也可以在 evaluate 中处理:
const apiData = await page.evaluate(async () => {
const response = await fetch('/api/data');
const data = await response.json();
return data;
});
这里使用了 async/await 语法,函数返回的是一个 Promise,page.evaluate 会自动处理。需要注意的是,如果 Promise 被 reject,page.evaluate 会抛出错误,需要用 try-catch 捕获。
try {
await page.evaluate(() => {
return Promise.reject(new Error('故意失败'));
});
} catch (error) {
console.error('执行失败:', error.message);
}
向 evaluate 传递复杂参数
前面提到,函数内部无法访问外部变量。Puppeteer 提供了参数传递机制来解决这个问题。
基本参数传递
page.evaluate 可以接受多个参数,第一个参数是函数,后面的参数会传递给该函数:
const multiplier = 10;
const result = await page.evaluate((factor) => {
return 5 * factor;
}, multiplier);
console.log(result); // 输出:50
参数可以是多个,按顺序传递:
const sum = await page.evaluate((a, b, c) => {
return a + b + c;
}, 1, 2, 3);
console.log(sum); // 输出:6
复杂对象的传递
参数可以是复杂对象,Puppeteer 会自动序列化:
const config = {
selector: '.item',
attributes: ['id', 'data-value'],
transform: (el) => ({
id: el.id,
value: el.dataset.value
})
};
const elements = await page.evaluate((cfg) => {
const items = document.querySelectorAll(cfg.selector);
return Array.from(items).map(item => {
const result = {};
cfg.attributes.forEach(attr => {
result[attr] = item.getAttribute(attr);
});
return result;
});
}, config);
注意,对象中的函数(如上面的 transform)无法被序列化传递。如果尝试传递函数,会得到 undefined 或报错。这是 JSON 序列化的限制。
使用 JSHandle 传递复杂对象
对于无法序列化的对象,比如前面提到的 DOM 句柄,可以通过 JSHandle 传递:
// 先获取一个元素句柄
const buttonHandle = await page.$('button.submit');
// 将句柄作为参数传递给 evaluate
const isEnabled = await page.evaluate((btn) => {
return !btn.disabled;
}, buttonHandle);
console.log(isEnabled);
// 释放句柄
await buttonHandle.dispose();
这种方式允许我们在不同的 evaluate 调用之间共享对页面内对象的引用。比如,可以先获取一组元素句柄,然后逐个处理:
const itemHandles = await page.$$('.list-item');
for (const handle of itemHandles) {
const data = await page.evaluate((el) => {
return {
text: el.textContent,
id: el.id,
className: el.className
};
}, handle);
console.log(data);
await handle.dispose();
}
参数传递的最佳实践
传递参数时,有几点需要注意:
- 避免传递大型对象:大型对象会增加序列化开销,影响性能。只传递必要的数据。
- 不要传递循环引用:循环引用会导致序列化失败。
- 函数无法传递:如果需要传递逻辑,可以在字符串形式的 evaluate 中定义,或者将逻辑拆分成多个步骤。
- 句柄需要释放:使用
JSHandle或ElementHandle后,务必调用dispose()。
页面数据提取与序列化
数据提取是 Puppeteer 最常见的应用场景之一。结合前面讲的知识,可以构建强大的数据提取方案。
提取结构化数据
假设要提取一个商品列表的信息:
const products = await page.evaluate(() => {
const items = document.querySelectorAll('.product');
return Array.from(items).map(item => {
const title = item.querySelector('.title').textContent.trim();
const price = item.querySelector('.price').textContent.trim();
const link = item.querySelector('a').href;
return {
title,
price,
link
};
});
});
console.log(products);
// 输出:[{ title: '...', price: '...', link: '...' }, ...]
这种方式简洁高效,所有操作都在页面上下文中完成,只需要一次序列化传输结果。
处理序列化限制
有些数据类型无法直接序列化,需要特殊处理。比如 Map、Set、Date 等:
const complexData = await page.evaluate(() => {
const map = new Map();
map.set('key1', 'value1');
map.set('key2', 'value2');
const set = new Set([1, 2, 3, 3]);
return {
// 将 Map 转换为普通对象
mapData: Object.fromEntries(map),
// 将 Set 转换为数组
setData: Array.from(set),
// Date 会被自动转换为字符串
timestamp: new Date()
};
});
console.log(complexData);
// 输出:{ mapData: { key1: 'value1', key2: 'value2' }, setData: [1, 2, 3], timestamp: '...' }
对于函数、Symbol、undefined 等无法序列化的值,会被忽略或转换为 null:
const weirdData = await page.evaluate(() => {
return {
func: () => console.log('无法序列化'),
sym: Symbol('test'),
undef: undefined,
nan: NaN,
inf: Infinity
};
});
console.log(weirdData);
// 输出:{ func: {}, sym: {}, undef: {}, nan: null, inf: null }
大数据量提取优化
当需要提取大量数据时,直接返回大数组可能导致性能问题。可以考虑分批提取:
// 先获取总数
const total = await page.evaluate(() => {
return document.querySelectorAll('.item').length;
});
// 分批提取
const batchSize = 100;
const allData = [];
for (let i = 0; i < total; i += batchSize) {
const batch = await page.evaluate((start, size) => {
const items = document.querySelectorAll('.item');
const end = Math.min(start + size, items.length);
const result = [];
for (let j = start; j < end; j++) {
const item = items[j];
result.push({
id: item.id,
text: item.textContent.trim()
});
}
return result;
}, i, batchSize);
allData.push(...batch);
console.log(`已提取 ${allData.length} / ${total}`);
}
这种方式虽然增加了代码复杂度,但可以有效控制内存使用,避免一次性传输过多数据。
结合句柄的混合方案
有时需要结合序列化和句柄两种方式。比如,先获取元素句柄,再提取详细信息:
// 获取所有商品元素的句柄
const productHandles = await page.$$('.product');
// 逐个处理
const products = [];
for (const handle of productHandles) {
// 使用句柄作为上下文,提取数据
const data = await page.evaluate((el) => {
// 这里的 el 是句柄对应的 DOM 元素
const details = {
title: el.querySelector('.title').textContent.trim(),
price: el.querySelector('.price').textContent.trim(),
// 可以访问元素的更多属性
dataset: el.dataset,
classList: Array.from(el.classList)
};
return details;
}, handle);
products.push(data);
await handle.dispose();
}
console.log(`共提取 ${products.length} 个商品`);
这种方式既利用了句柄的灵活性,又通过序列化传输结构化数据,是处理复杂页面的常用模式。
错误处理与调试
数据提取过程中难免遇到错误,比如元素不存在、网络延迟等。良好的错误处理很重要:
const safeExtract = await page.evaluate(() => {
try {
const element = document.querySelector('.maybe-exists');
if (!element) {
return { success: false, error: '元素不存在' };
}
return {
success: true,
data: {
text: element.textContent,
id: element.id
}
};
} catch (error) {
return {
success: false,
error: error.message
};
}
});
if (safeExtract.success) {
console.log('提取成功:', safeExtract.data);
} else {
console.error('提取失败:', safeExtract.error);
}
在 evaluate 内部使用 try-catch,可以捕获页面上下文中的错误,并以结构化方式返回,避免整个脚本崩溃。
本章介绍了 Puppeteer 执行 JavaScript 和提取数据的核心机制。page.evaluate 是连接 Node.js 环境和浏览器页面的桥梁,理解其序列化机制、Promise 处理和参数传递方式,是编写稳定自动化脚本的基础。数据提取时,需要根据数据类型和规模选择合适的策略,平衡简洁性和性能。
掌握了这些技术,就能灵活地操作页面、获取动态数据。下一章将探讨更高级的主题——网络请求拦截与 Mock,这让我们能够控制页面的网络通信,模拟各种网络场景,为测试和开发提供更强大的控制能力。