4. JavaScript 执行与数据提取

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 返回的是 JSHandleElementHandle 实例。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();
}

参数传递的最佳实践

传递参数时,有几点需要注意:

  1. 避免传递大型对象:大型对象会增加序列化开销,影响性能。只传递必要的数据。
  2. 不要传递循环引用:循环引用会导致序列化失败。
  3. 函数无法传递:如果需要传递逻辑,可以在字符串形式的 evaluate 中定义,或者将逻辑拆分成多个步骤。
  4. 句柄需要释放:使用 JSHandleElementHandle 后,务必调用 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: '...' }, ...]

这种方式简洁高效,所有操作都在页面上下文中完成,只需要一次序列化传输结果。

处理序列化限制

有些数据类型无法直接序列化,需要特殊处理。比如 MapSetDate 等:

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,这让我们能够控制页面的网络通信,模拟各种网络场景,为测试和开发提供更强大的控制能力。