【实战】用 Node.js 写一个简单的爬虫

从 0 到 1 实现一个可运行的 Node.js 爬虫:请求页面、解析内容、控制并发、重试失败、保存结果。适合前端转全栈和爬虫入门。

8 分钟阅读
小明

【实战】用 Node.js 写一个简单的爬虫

大多数人第一次写爬虫的节奏是这样的:

  • 能抓到一个页面,开心。
  • 连抓十个页面,开始报错。
  • 抓一百个页面,被限流。
  • 抓完发现数据脏到没法用。

所以这篇不讲“玩具爬虫”,讲一个最小可用版本(MVP):

  1. 请求页面
  2. 解析标题和摘要
  3. 控制并发
  4. 失败重试
  5. 输出 JSON

仅抓取公开页面,遵守目标站点 Robots 与服务条款,控制请求频率。


1. 项目初始化

mkdir simple-crawler && cd simple-crawler
pnpm init
pnpm add axios cheerio p-limit

我们选三件工具:

  • axios:发 HTTP 请求
  • cheerio:用 jQuery 风格解析 HTML
  • p-limit:限制并发,避免把对方站点打挂

2. 先写一个“能跑通”的单页抓取

import axios from 'axios'
import * as cheerio from 'cheerio'

async function crawlOne(url: string) {
  const { data: html } = await axios.get(url, {
    timeout: 10_000,
    headers: {
      'User-Agent':
        'Mozilla/5.0 (compatible; XiaomingCrawler/1.0; +https://xiaoming.wiki)'
    }
  })

  const $ = cheerio.load(html)
  const title = $('title').text().trim()
  const description = $('meta[name="description"]').attr('content')?.trim() ?? ''

  return { url, title, description }
}

核心只有两步:

  • 拿 HTML
  • 用选择器提取你关心的数据

3. 升级成批量抓取(并发 + 重试)

实际场景一定是 URL 列表,所以我们加两件工程化能力:

  • 并发限制:比如同一时刻最多 3 个请求
  • 失败重试:偶发超时不要立刻判死刑
import fs from 'node:fs/promises'
import axios from 'axios'
import * as cheerio from 'cheerio'
import pLimit from 'p-limit'

type CrawlResult = {
  url: string
  title: string
  description: string
  ok: boolean
  error?: string
}

async function sleep(ms: number) {
  return new Promise(resolve => setTimeout(resolve, ms))
}

async function withRetry<T>(fn: () => Promise<T>, retries = 2): Promise<T> {
  let lastError: unknown
  for (let i = 0; i <= retries; i++) {
    try {
      return await fn()
    } catch (error) {
      lastError = error
      if (i < retries) await sleep(800 * (i + 1))
    }
  }
  throw lastError
}

async function crawlOne(url: string): Promise<CrawlResult> {
  try {
    const { data: html } = await withRetry(() =>
      axios.get<string>(url, {
        timeout: 10_000,
        headers: {
          'User-Agent':
            'Mozilla/5.0 (compatible; XiaomingCrawler/1.0; +https://xiaoming.wiki)'
        }
      }).then(r => r.data)
    )

    const $ = cheerio.load(html)
    const title = $('title').text().trim()
    const description = $('meta[name="description"]').attr('content')?.trim() ?? ''

    return { url, title, description, ok: true }
  } catch (error) {
    return {
      url,
      title: '',
      description: '',
      ok: false,
      error: error instanceof Error ? error.message : 'unknown error'
    }
  }
}

async function run() {
  const urls = [
    'https://example.com',
    'https://www.wikipedia.org',
    'https://developer.mozilla.org'
  ]

  const limit = pLimit(3)
  const tasks = urls.map(url => limit(async () => {
    const result = await crawlOne(url)
    await sleep(500)
    return result
  }))

  const results = await Promise.all(tasks)

  await fs.writeFile('crawl-result.json', JSON.stringify(results, null, 2), 'utf-8')
  console.log(`完成:${results.filter(r => r.ok).length}/${results.length}`)
}

void run()

4. 躲过反爬的几个技巧

真实网站不会白白给你爬。常见防线:

防线表现绕过方案
User-Agent 检查识别机器人直接拒绝换成真实浏览器 UA 字符串
频率限制(Rate Limit)短时高频返回 429加延迟、用代理 IP 池
JavaScript 渲染返回空页面用 Puppeteer/Playwright
登陆墙需要认证才能看爬虫模拟登陆或走官方 API
验证码随机出现人工标记或用 OCR 库

对于简单防线(User-Agent、频率限制),上面的代码已经覆盖。

对于 JS 渲染,需要换方案:

// 如果 Cheerio 解析出的页面是空的,说明需要渲染
// 改用 Puppeteer(重型但能解决 JS 问题)
import puppeteer from 'puppeteer'

async function crawlWithJS(url: string) {
  const browser = await puppeteer.launch()
  const page = await browser.newPage()
  await page.goto(url, { waitUntil: 'networkidle2' })
  const html = await page.content()
  await browser.close()
  
  // 后续用 Cheerio 解析就是了
  const $ = cheerio.load(html)
  // ...
}

成本警告:Puppeteer 启动浏览器进程很重(每个浏览器实例 100MB+ 内存)。不要无脑用,先试试 Cheerio 够不够。


5. 爬虫最常见的 5 个坑

  1. 无节制并发:很容易触发 429/封禁。
  2. 无超时设置:网络卡住时进程长时间挂起。
  3. 不做重试:偶发失败被误判为永久失败。
  4. 不做字段兜底:解析不到就写入 undefined,后续清洗成本翻倍。
  5. 忽略编码与反爬:某些网站二进制编码、需要代理、要求 User-Agent,上面都讲了。
  6. 一次性爬完不留信息:如果失败,重新跑时还得从头来。建议边抓边存(流式写入)。
  7. 没有日志与监控:小爬虫跑 2 分钟看不出问题,大爬虫跑一周才发现数据全脏。

5. 进阶路线(下一步怎么做)

  • URL 发现:从首页提取内部链接,递归抓取
  • 去重:用 Set 或布隆过滤器避免重复抓
  • 持久化:落 MySQL / SQLite,而不是只写 JSON
  • 调度:用队列(BullMQ)做任务管理
  • 可观测:记录成功率、耗时、错误类型

总结

一个能用于小规模任务的爬虫,关键不在“会抓”,而在“抓得稳”:

  • 有并发控制
  • 有失败重试
  • 有节流策略
  • 有结构化输出

先把这套最小闭环跑通,你已经超过很多“只会写 demo”的入门爬虫。