【实战】用 Node.js 写一个简单的爬虫
从 0 到 1 实现一个可运行的 Node.js 爬虫:请求页面、解析内容、控制并发、重试失败、保存结果。适合前端转全栈和爬虫入门。
8 分钟阅读
小明
【实战】用 Node.js 写一个简单的爬虫
大多数人第一次写爬虫的节奏是这样的:
- 能抓到一个页面,开心。
- 连抓十个页面,开始报错。
- 抓一百个页面,被限流。
- 抓完发现数据脏到没法用。
所以这篇不讲“玩具爬虫”,讲一个最小可用版本(MVP):
- 请求页面
- 解析标题和摘要
- 控制并发
- 失败重试
- 输出 JSON
仅抓取公开页面,遵守目标站点 Robots 与服务条款,控制请求频率。
1. 项目初始化
mkdir simple-crawler && cd simple-crawler
pnpm init
pnpm add axios cheerio p-limit
我们选三件工具:
axios:发 HTTP 请求cheerio:用 jQuery 风格解析 HTMLp-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 个坑
- 无节制并发:很容易触发 429/封禁。
- 无超时设置:网络卡住时进程长时间挂起。
- 不做重试:偶发失败被误判为永久失败。
- 不做字段兜底:解析不到就写入
undefined,后续清洗成本翻倍。 - 忽略编码与反爬:某些网站二进制编码、需要代理、要求 User-Agent,上面都讲了。
- 一次性爬完不留信息:如果失败,重新跑时还得从头来。建议边抓边存(流式写入)。
- 没有日志与监控:小爬虫跑 2 分钟看不出问题,大爬虫跑一周才发现数据全脏。
5. 进阶路线(下一步怎么做)
- URL 发现:从首页提取内部链接,递归抓取
- 去重:用
Set或布隆过滤器避免重复抓 - 持久化:落 MySQL / SQLite,而不是只写 JSON
- 调度:用队列(BullMQ)做任务管理
- 可观测:记录成功率、耗时、错误类型
总结
一个能用于小规模任务的爬虫,关键不在“会抓”,而在“抓得稳”:
- 有并发控制
- 有失败重试
- 有节流策略
- 有结构化输出
先把这套最小闭环跑通,你已经超过很多“只会写 demo”的入门爬虫。