Node.js 爬虫实战指南(二):动态页面爬取,Puppeteer 大显身手

当 axios + cheerio 搞不定的时候,是时候请出大杀器了。


🎬 开场:一个让我抓狂的页面

上周,老板让我爬一个招聘网站的数据。

我信心满满地写好了爬虫,运行,结果:

javascript 复制代码
const response = await axios.get("https://xxx.com/jobs")
console.log(response.data)

// 输出:
// <html>
//   <body>
//     <div id="app"></div>
//     <script src="app.js"></script>
//   </body>
// </html>

啥也没有,就一个空的 <div id="app">

原因很简单:这是一个 SPA(单页应用),所有内容都是通过 JavaScript 动态渲染的。

axios 只能拿到原始 HTML,拿不到 JS 执行后的内容。

这时候,就需要请出我们的大杀器:Puppeteer


🎭 Puppeteer 是什么?

Puppeteer 是 Google 出品的 Node.js 库,它提供了一个高级 API 来控制 Chrome/Chromium 浏览器。

简单说,它能:

  • 🌐 打开一个真实的浏览器
  • 📄 加载页面并等待 JS 执行完成
  • 🖱️ 模拟点击、输入、滚动等操作
  • 📸 截图、生成 PDF
  • 🍪 管理 Cookie 和登录状态

本质上,Puppeteer 就是一个可以用代码控制的浏览器。


🛠️ 安装与配置

安装 Puppeteer

bash 复制代码
# 完整版(会下载 Chromium,约 300MB)
npm install puppeteer

# 精简版(需要自己指定浏览器路径)
npm install puppeteer-core

基础用法

javascript 复制代码
const puppeteer = require("puppeteer")

async function basicExample() {
  // 1. 启动浏览器
  const browser = await puppeteer.launch({
    headless: "new", // 无头模式(不显示浏览器窗口)
    // headless: false, // 有头模式(显示浏览器,方便调试)
  })

  // 2. 创建新页面
  const page = await browser.newPage()

  // 3. 设置视口大小
  await page.setViewport({ width: 1920, height: 1080 })

  // 4. 访问页面
  await page.goto("https://example.com", {
    waitUntil: "networkidle2", // 等待网络空闲
    timeout: 30000,
  })

  // 5. 获取页面内容
  const content = await page.content()
  console.log(content)

  // 6. 截图
  await page.screenshot({ path: "screenshot.png" })

  // 7. 关闭浏览器
  await browser.close()
}

basicExample()

🚀 实战:爬取动态渲染的页面

场景:爬取某技术社区的文章列表

假设我们要爬取一个 Vue/React 写的技术社区,页面内容是动态加载的。

javascript 复制代码
// src/dynamic-crawler.js
const puppeteer = require("puppeteer")
const chalk = require("chalk")

class DynamicCrawler {
  constructor() {
    this.browser = null
    this.page = null
  }

  /**
   * 初始化浏览器
   */
  async init() {
    console.log(chalk.blue("🚀 启动浏览器..."))

    this.browser = await puppeteer.launch({
      headless: "new",
      args: [
        "--no-sandbox",
        "--disable-setuid-sandbox",
        "--disable-dev-shm-usage",
        "--disable-accelerated-2d-canvas",
        "--disable-gpu",
        "--window-size=1920,1080",
      ],
    })

    this.page = await this.browser.newPage()

    // 设置 User-Agent
    await this.page.setUserAgent(
      "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
    )

    // 设置视口
    await this.page.setViewport({ width: 1920, height: 1080 })

    // 设置超时
    this.page.setDefaultTimeout(30000)

    console.log(chalk.green("✅ 浏览器启动成功"))
  }

  /**
   * 爬取文章列表
   */
  async crawlArticles(url) {
    console.log(chalk.blue(`\n📄 正在爬取: ${url}`))

    // 访问页面
    await this.page.goto(url, {
      waitUntil: "networkidle2",
    })

    // 等待文章列表加载完成
    await this.page.waitForSelector(".article-item", {
      timeout: 10000,
    })

    // 在浏览器环境中执行 JS,提取数据
    const articles = await this.page.evaluate(() => {
      const items = document.querySelectorAll(".article-item")

      return Array.from(items).map((item, index) => ({
        rank: index + 1,
        title: item.querySelector(".title")?.textContent?.trim(),
        url: item.querySelector("a")?.href,
        author: item.querySelector(".author")?.textContent?.trim(),
        views: item.querySelector(".views")?.textContent?.trim(),
        likes: item.querySelector(".likes")?.textContent?.trim(),
        date: item.querySelector(".date")?.textContent?.trim(),
      }))
    })

    console.log(chalk.green(`✅ 成功爬取 ${articles.length} 篇文章`))
    return articles
  }

  /**
   * 关闭浏览器
   */
  async close() {
    if (this.browser) {
      await this.browser.close()
      console.log(chalk.gray("🔒 浏览器已关闭"))
    }
  }
}

// 使用示例
async function main() {
  const crawler = new DynamicCrawler()

  try {
    await crawler.init()
    const articles = await crawler.crawlArticles("https://example.com/articles")
    console.log(articles)
  } finally {
    await crawler.close()
  }
}

main().catch(console.error)

📜 处理无限滚动加载

很多网站使用"无限滚动"来加载更多内容。我们需要模拟滚动操作:

javascript 复制代码
/**
 * 自动滚动页面,加载更多内容
 * @param {Page} page - Puppeteer 页面对象
 * @param {number} maxScrolls - 最大滚动次数
 * @param {number} scrollDelay - 每次滚动后等待时间(毫秒)
 */
async function autoScroll(page, maxScrolls = 10, scrollDelay = 1000) {
  console.log(chalk.blue("📜 开始自动滚动..."))

  let scrollCount = 0
  let previousHeight = 0

  while (scrollCount < maxScrolls) {
    // 获取当前页面高度
    const currentHeight = await page.evaluate(() => document.body.scrollHeight)

    // 如果高度没变化,说明已经到底了
    if (currentHeight === previousHeight) {
      console.log(chalk.yellow("⚠️ 已到达页面底部"))
      break
    }

    previousHeight = currentHeight

    // 滚动到底部
    await page.evaluate(() => {
      window.scrollTo(0, document.body.scrollHeight)
    })

    // 等待新内容加载
    await page.waitForTimeout(scrollDelay)

    scrollCount++
    console.log(chalk.gray(`   滚动 ${scrollCount}/${maxScrolls}`))
  }

  console.log(chalk.green(`✅ 滚动完成,共滚动 ${scrollCount} 次`))
}

// 使用示例
async function crawlWithScroll(url) {
  const browser = await puppeteer.launch({ headless: "new" })
  const page = await browser.newPage()

  await page.goto(url, { waitUntil: "networkidle2" })

  // 自动滚动加载更多
  await autoScroll(page, 5, 2000)

  // 现在可以获取所有加载的内容了
  const items = await page.evaluate(() => {
    return Array.from(document.querySelectorAll(".item")).map((el) => ({
      title: el.textContent,
    }))
  })

  await browser.close()
  return items
}

🔐 模拟登录

有些数据需要登录后才能访问。Puppeteer 可以模拟登录操作:

javascript 复制代码
/**
 * 模拟登录
 */
async function login(page, username, password) {
  console.log(chalk.blue("🔐 开始登录..."))

  // 访问登录页
  await page.goto("https://example.com/login", {
    waitUntil: "networkidle2",
  })

  // 等待登录表单加载
  await page.waitForSelector("#username")
  await page.waitForSelector("#password")

  // 清空输入框(以防有默认值)
  await page.click("#username", { clickCount: 3 })
  await page.keyboard.press("Backspace")

  // 输入用户名(模拟人类打字速度)
  await page.type("#username", username, { delay: 100 })

  // 输入密码
  await page.type("#password", password, { delay: 100 })

  // 点击登录按钮
  await Promise.all([
    page.waitForNavigation({ waitUntil: "networkidle2" }),
    page.click("#login-button"),
  ])

  // 检查是否登录成功
  const isLoggedIn = await page.evaluate(() => {
    return !!document.querySelector(".user-avatar")
  })

  if (isLoggedIn) {
    console.log(chalk.green("✅ 登录成功"))
  } else {
    throw new Error("登录失败")
  }
}

/**
 * 保存 Cookie(下次可以直接使用,不用重新登录)
 */
async function saveCookies(page, filepath) {
  const cookies = await page.cookies()
  await fs.writeJson(filepath, cookies)
  console.log(chalk.green(`💾 Cookie 已保存到 ${filepath}`))
}

/**
 * 加载 Cookie
 */
async function loadCookies(page, filepath) {
  if (await fs.pathExists(filepath)) {
    const cookies = await fs.readJson(filepath)
    await page.setCookie(...cookies)
    console.log(chalk.green("🍪 Cookie 已加载"))
    return true
  }
  return false
}

// 使用示例
async function crawlWithLogin() {
  const browser = await puppeteer.launch({ headless: "new" })
  const page = await browser.newPage()

  const cookiePath = "./data/cookies.json"

  // 尝试加载已保存的 Cookie
  const hasCookies = await loadCookies(page, cookiePath)

  if (!hasCookies) {
    // 没有 Cookie,需要登录
    await login(page, "your-username", "your-password")
    await saveCookies(page, cookiePath)
  }

  // 现在可以访问需要登录的页面了
  await page.goto("https://example.com/dashboard")

  // ... 爬取数据

  await browser.close()
}

🛡️ 处理常见反爬措施

1. 检测 Headless 浏览器

很多网站会检测你是不是用的无头浏览器。我们可以用插件来隐藏特征:

bash 复制代码
npm install puppeteer-extra puppeteer-extra-plugin-stealth
javascript 复制代码
const puppeteer = require("puppeteer-extra")
const StealthPlugin = require("puppeteer-extra-plugin-stealth")

// 使用 Stealth 插件
puppeteer.use(StealthPlugin())

async function stealthCrawl() {
  const browser = await puppeteer.launch({ headless: "new" })
  const page = await browser.newPage()

  // 现在浏览器的特征更像真实用户了
  await page.goto("https://bot.sannysoft.com/")
  await page.screenshot({ path: "stealth-test.png" })

  await browser.close()
}

2. 处理验证码

简单的验证码可以用 OCR 识别,复杂的建议用打码平台:

javascript 复制代码
// 简单示例:截图验证码,手动输入
async function handleCaptcha(page) {
  // 等待验证码图片加载
  await page.waitForSelector("#captcha-image")

  // 截图验证码
  const captchaElement = await page.$("#captcha-image")
  await captchaElement.screenshot({ path: "captcha.png" })

  console.log(chalk.yellow("⚠️ 请查看 captcha.png 并输入验证码:"))

  // 从命令行读取输入
  const readline = require("readline")
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
  })

  const captchaCode = await new Promise((resolve) => {
    rl.question("验证码: ", (answer) => {
      rl.close()
      resolve(answer)
    })
  })

  // 输入验证码
  await page.type("#captcha-input", captchaCode)
}

3. 使用代理 IP

javascript 复制代码
const browser = await puppeteer.launch({
  headless: "new",
  args: ["--proxy-server=http://proxy-server:port"],
})

// 如果代理需要认证
await page.authenticate({
  username: "proxy-user",
  password: "proxy-pass",
})

4. 随机化请求特征

javascript 复制代码
// 随机 User-Agent
const userAgents = [
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36...",
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36...",
  "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36...",
]

await page.setUserAgent(
  userAgents[Math.floor(Math.random() * userAgents.length)]
)

// 随机视口大小
const viewports = [
  { width: 1920, height: 1080 },
  { width: 1366, height: 768 },
  { width: 1536, height: 864 },
]

await page.setViewport(viewports[Math.floor(Math.random() * viewports.length)])

// 随机延迟
function randomDelay(min, max) {
  const ms = Math.floor(Math.random() * (max - min + 1)) + min
  return new Promise((resolve) => setTimeout(resolve, ms))
}

await randomDelay(1000, 3000)

📸 实用技巧

拦截请求(加速加载)

javascript 复制代码
// 拦截不需要的资源,加快页面加载
await page.setRequestInterception(true)

page.on("request", (request) => {
  const resourceType = request.resourceType()

  // 阻止加载图片、字体、样式表
  if (["image", "font", "stylesheet"].includes(resourceType)) {
    request.abort()
  } else {
    request.continue()
  }
})

监听网络请求

javascript 复制代码
// 监听 XHR/Fetch 请求,直接获取 API 数据
page.on("response", async (response) => {
  const url = response.url()

  if (url.includes("/api/articles")) {
    const data = await response.json()
    console.log("捕获到 API 数据:", data)
  }
})

执行自定义 JavaScript

javascript 复制代码
// 在页面中注入并执行 JS
const result = await page.evaluate(() => {
  // 这里的代码在浏览器环境中执行
  return {
    title: document.title,
    url: window.location.href,
    cookies: document.cookie,
  }
})

// 传递参数到浏览器环境
const selector = ".article"
const texts = await page.evaluate((sel) => {
  return Array.from(document.querySelectorAll(sel)).map((el) => el.textContent)
}, selector)

🎯 完整示例:爬取掘金热门文章

javascript 复制代码
// src/juejin-crawler.js
const puppeteer = require("puppeteer-extra")
const StealthPlugin = require("puppeteer-extra-plugin-stealth")
const { Storage } = require("./storage")
const chalk = require("chalk")

puppeteer.use(StealthPlugin())

const storage = new Storage("./data/juejin")

async function crawlJuejin() {
  console.log(chalk.blue("\n🚀 开始爬取掘金热门文章\n"))

  const browser = await puppeteer.launch({
    headless: "new",
    args: ["--no-sandbox", "--disable-setuid-sandbox"],
  })

  try {
    const page = await browser.newPage()

    // 设置视口和 UA
    await page.setViewport({ width: 1920, height: 1080 })
    await page.setUserAgent(
      "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
    )

    // 拦截不必要的资源
    await page.setRequestInterception(true)
    page.on("request", (req) => {
      if (["image", "font"].includes(req.resourceType())) {
        req.abort()
      } else {
        req.continue()
      }
    })

    // 访问掘金首页
    await page.goto("https://juejin.cn/", {
      waitUntil: "networkidle2",
      timeout: 30000,
    })

    // 等待文章列表加载
    await page.waitForSelector(".entry-list .item")

    // 滚动加载更多
    for (let i = 0; i < 3; i++) {
      await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
      await page.waitForTimeout(2000)
      console.log(chalk.gray(`   滚动 ${i + 1}/3`))
    }

    // 提取文章数据
    const articles = await page.evaluate(() => {
      const items = document.querySelectorAll(".entry-list .item")

      return Array.from(items)
        .map((item, index) => {
          const titleEl = item.querySelector(".title")
          const metaEl = item.querySelector(".meta-container")
          const statEl = item.querySelector(".action-list")

          return {
            rank: index + 1,
            title: titleEl?.textContent?.trim(),
            url: titleEl?.closest("a")?.href,
            author: metaEl
              ?.querySelector(".user-message .name")
              ?.textContent?.trim(),
            date: metaEl?.querySelector(".date")?.textContent?.trim(),
            views: statEl?.querySelector(".view")?.textContent?.trim(),
            likes: statEl?.querySelector(".like")?.textContent?.trim(),
            comments: statEl?.querySelector(".comment")?.textContent?.trim(),
          }
        })
        .filter((item) => item.title) // 过滤掉空数据
    })

    console.log(chalk.green(`\n✅ 成功爬取 ${articles.length} 篇文章\n`))

    // 打印前 5 篇
    articles.slice(0, 5).forEach((article) => {
      console.log(`${article.rank}. ${article.title}`)
      console.log(
        chalk.gray(
          `   👤 ${article.author} | 👍 ${article.likes} | 💬 ${article.comments}`
        )
      )
      console.log("")
    })

    // 保存数据
    const filename = `hot-${new Date().toISOString().split("T")[0]}.json`
    await storage.saveJson(filename, articles)

    return articles
  } finally {
    await browser.close()
  }
}

crawlJuejin().catch(console.error)

🔮 下集预告

这篇文章讲了 Puppeteer 的使用方法和动态页面爬取技巧。

下一篇,我们将探讨:

《Node.js 爬虫实战指南(三):分布式爬虫架构,让你的爬虫飞起来》

内容包括:

  • 为什么需要分布式爬虫?
  • 任务队列设计(Redis + Bull)
  • 多进程/多机器协作
  • 数据去重与增量爬取
  • 监控与告警

💬 互动时间:你用 Puppeteer 爬过什么有趣的网站?遇到过什么坑?评论区分享一下!

觉得有用的话,点赞 + 在看 + 转发,让更多人学会动态页面爬取~


相关推荐
嫂子的姐夫2 小时前
017-续集-贝壳登录(剩余三个参数)
爬虫·python·逆向
李昊哲小课3 小时前
Selenium 自动化测试教程
爬虫·selenium·测试工具
Direction_Wind4 小时前
抖音视频下载,直播间监控,直播间发言采集,最新加密算法
python·node.js
深蓝电商API4 小时前
Scrapy爬取Ajax动态加载页面三种实用方法
爬虫·python·scrapy·ajax
cnxy18817 小时前
Python爬虫进阶:反爬虫策略与Selenium自动化完整指南
爬虫·python·selenium
奶糖的次元空间17 小时前
带你用 Javascript 生成器玩转「会暂停」的函数
node.js
深蓝电商API18 小时前
Scrapy管道Pipeline深度解析:多方式数据持久化
爬虫·python·scrapy
噎住佩奇18 小时前
(Win11系统)搭建Python爬虫环境
爬虫·python
qq_3363139318 小时前
java基础-IO流(网络爬虫/工具包生成假数据)
java·爬虫·php