网络爬取早在成为解析问题之前,就已经不再是单纯的抓取问题。真正的难点不在于下载 HTML 或提取数据,而在于跨数千个域名协调数百万次抓取:既不能丢失工作,也不能重复爬取同一页面,更不能把时间耗在维护代理池和浏览器基础设施上,而不是构建你的应用。

这正是架构需要改变的地方。一个生产级爬虫需要队列、工作节点、重试、代理轮换、JavaScript 渲染、反爬虫处理,以及一个可持续扩展且不会重复访问相同 URL 的持久化爬取边界。简而言之,你要解决的是一个分布式系统问题。

更好的做法是把爬取编排与爬取执行分离。你的应用决定爬什么、爬多深、提取什么;爬取基础设施负责排队、并发、重试、渲染和网络可靠性,让你的引擎专注于业务逻辑而非基础设施。

本文将用 Node.js 构建一个完全遵循这一设计的分布式爬取引擎。引擎负责爬取策略、URL 去重、HTML 解析、结果存储和递归边界扩展,而 Crawlbase 通过 Crawlbase Enterprise CrawlerCrawlbase Crawling API 提供分布式执行层。Enterprise Crawler 是一个托管队列加工作节点集群,异步地把爬取结果投递到 Webhook;Crawling API 则为对延迟敏感的请求提供内联抓取路径。最终得到的爬虫依托 Crawlbase 的基础设施扩展,同时代码量小到几百行就能完全理解。

本文展示的所有实现都来自配套的 GitHub 仓库。克隆它,跟随文章逐步完成每个实现阶段,你将得到一个可用于生产的基础框架,之后可以用自己的提取逻辑、存储后端和爬取策略进行扩展。

设计分布式爬虫

每个分布式爬虫都在持续解决四个问题:

  1. 管理爬取边界:决定接下来爬取什么。
  2. 执行爬取:大规模可靠地抓取页面。
  3. 处理结果:提取并存储有用数据。
  4. 扩展爬取:把新发现的链接变成后续的爬取任务。

这些职责紧密相连。每次完成的爬取既产出数据,也产出新的 URL。这些 URL 经过爬取策略筛选,进入爬取边界,最终再次被抓取,形成一个持续的反馈循环。

爬取边界

爬取边界是爬虫对待处理工作的记忆。每个新发现的 URL 都会被规范化、按爬取策略检查、去重,然后排入爬取计划。没有受管理的边界,递归爬取很快就会重复访问相同页面,或者游离到预期范围之外。

爬取执行

大规模抓取页面在很大程度上是一个基础设施问题。在解析器看到 HTML 之前,需要先解决工作节点协调、重试、代理轮换、浏览器渲染和反爬虫处理。构建并运维这套基础设施,往往比爬虫本身更复杂。

结果处理

完成的爬取需要流入一条独立的处理流水线:解析页面、提取结构化数据、持久化结果。让获取与处理彼此分离,两个系统都会更易于维护和演进。

递归扩展

每个处理完的页面都会产生新链接。这些链接通过深度、域名和去重检查后,成为新的爬取任务并重新进入边界。这个递归反馈循环会一直持续,直到范围内再无页面可访问。

我们构建的引擎负责爬取边界、爬取策略、解析、存储和递归扩展;Crawlbase 提供分布式队列、工作节点集群、重试、浏览器渲染、代理轮换和反爬虫基础设施。这样的分离让引擎专注于应用逻辑,而不是爬取基础设施。

我们要构建什么

一个小型 Express 服务加一组脚本,共同构成一个自我维持的爬虫:

  • 一次性的初始化脚本创建一个具名的 Crawler 队列,并绑定到你的 Webhook。
  • 种子脚本把起始 URL 推入队列。
  • Crawlbase 爬取每个 URL,并把结果 POST 到你的 Webhook。
  • Webhook 解析页面、存储记录、发现链接,并把范围内且尚未见过的链接推回队列。

最终得到的爬虫随 Crawlbase 的并发能力扩展,而不受你服务器的限制,而且几百行代码就能理清全部逻辑。

架构总览

爬取是一个循环,而不是流水线。种子先填充队列,Crawlbase 在轮换代理背后执行每次爬取,结果落到 Webhook,每个通过边界检查的范围内链接重新进入队列,直到再无新内容可发现。

整个流程是一个循环。种子脚本启动引擎,引擎把 URL 推入 Enterprise Crawler。Crawler 是其中的分布式部分:它持有队列,按配置的并发量通过 Crawlbase 的代理网络执行爬取,并针对目标网站处理重试和反爬虫问题。爬取完成后,Crawlbase 把页面 POST 回引擎的 Webhook。引擎的解析器存储生成一条记录,并产出一份出站链接列表,这些链接经过去重和深度检查后进入下一轮推送。

Crawlbase 负责页面获取、并发、重试以及代理和反爬虫基础设施;引擎负责爬取策略(哪些在范围内、爬多深)以及数据的后续处理。上方主图中还有一条虚线边:Crawling API 用于同步的按需单页抓取,当你需要内联获得结果而不是走队列时使用。

准备项目

架构已经确定,接下来让项目在本地跑起来。配套仓库包含本文用到的完整实现。我们将基于 final/ 项目开展工作,并在构建每个组件时参考 steps/ 下的增量检查点。

你需要:

  • Node.js 18 或更新版本(初始化脚本使用内置的 fetch)。
  • 一个 Crawlbase 账户,用于获取你的请求令牌
  • 一个可公开访问的 Webhook 端点。本地开发时,可用 Cloudflare Tunnelcloudflared)或 ngrok 暴露你的 Express 服务器,然后把 CALLBACK_URL 指向 https://<your-public-host>/webhook

克隆仓库并在 final/ 目录中安装项目:

bash
git clone https://github.com/ScraperHub/building-a-distributed-crawling-engine.git
cd building-a-distributed-crawling-engine/final
npm install
cp .env.example .env

接下来,编辑 .env,填入你的 Crawlbase 令牌和公开回调 URL。在本文余下的部分中,每个实现步骤都直接对应 steps/ 下的一个检查点,而 final/ 始终包含完整可运行的项目。仓库的 README 为每一节提供了文章与代码的对照索引。

第 1 步:集中管理配置

引擎首先为配置建立单一事实来源。所有内容都从环境变量加载并在启动时校验,而不是把密钥和爬取策略值散落在代码库各处。如果缺少 Crawlbase 令牌等必填值,引擎会立即失败,而不是等到爬取过程中才出错。

完整实现位于配套仓库的 final/src/config.js

js
const config = {
  crawlbaseToken: required('CRAWLBASE_TOKEN'),
  crawlerName: process.env.CRAWLER_NAME || 'distributed-engine',
  callbackUrl: process.env.CALLBACK_URL || '',
  port: Number(process.env.PORT || 3000),
  maxDepth: Number(process.env.MAX_DEPTH || 2),
  allowedDomains: (process.env.ALLOWED_DOMAINS || '')
    .split(',')
    .map((domain) => domain.trim().toLowerCase())
    .filter(Boolean),
};

这些值大多定义的是引擎的爬取策略,而非基础设施。MAX_DEPTH 限制爬虫递归扩展的深度,ALLOWED_DOMAINS 则把爬取范围限定在你明确允许的站点内。同一个 CRAWLBASE_TOKEN 同时用于 Enterprise Crawler 和 Crawling API 的鉴权,因此引擎与 Crawlbase 通信只需要这一个凭证。

配置就绪后,下一步是创建执行爬取任务的分布式爬虫。

第 2 步:创建分布式队列

在 Crawlbase 中,分布式队列就是 Enterprise Crawler:一个托管队列加工作节点集群,接收 URL、异步爬取,并把结果投递到 Webhook 或 Cloud Storage。我们在创建爬虫时提供 callback_url,采用 Webhook 投递方式。

初始化逻辑实现在 final/src/setup-crawler.js 中。管理 API 要求把 Crawlbase 令牌放在 URL 路径而不是查询字符串里,脚本因此按这一约定构造端点。

js
const endpoint = `https://api.crawlbase.com/crawler/${config.crawlbaseToken}`;
const response = await fetch(endpoint, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    name: config.crawlerName,
    callback_url: config.callbackUrl,
  }),
});

只需创建一次爬虫:

bash
npm run setup

爬虫绑定到 Webhook 之后,向队列添加工作就变成一次标准的 Crawling API 请求,只需附加两个参数。crawler 指定目标队列,callback=true 告诉 Crawlbase 异步处理请求,而不是立即返回页面。URL 一旦被队列接受,请求就会返回一个请求 ID(rid),实际的爬取则在后台进行。

推送逻辑实现在 final/src/crawlbase-client.js 中。

js
async function pushToCrawler(url, depth) {
  const response = await api.get(url, {
    crawler: config.crawlerName,
    callback: true,
    callbackHeaders: `X-Crawl-Depth|${depth}`,
  });

  // Surface auth/quota errors instead of silently dropping the URL.
  if (response.statusCode !== 200) {
    throw new Error(`Crawler push failed (${response.statusCode}): ${response.body}`);
  }

  // The push response is the small JSON envelope { "rid": "..." }, but it is
  // not served with a JSON content-type, so parse the body ourselves.
  const parsed = response.json || parseRid(response.body);
  return parsed && parsed.rid;
}

有两个实现细节值得注意。第一,推送响应虽然包含 JSON 负载,但返回时并未带 JSON 内容类型,所以辅助函数自行解析响应体,而不依赖自动反序列化。第二,callbackHeaders 让引擎保持无状态。当前爬取深度以 X-Crawl-Depth 的形式附加在请求上,Crawlbase 投递 Webhook 时会原样带回。这样引擎无需维护自己的请求状态,就能恢复爬取深度。

种子脚本(实现在 final/src/seed.js 中)使用同一个辅助函数,将初始的一组 URL 入队。

js
for (const url of SEED_URLS) {
  if (!shouldCrawl(url, 0)) {
    console.log(`Skipped (filtered or duplicate): ${url}`);
    continue;
  }
  const rid = await pushToCrawler(url, 0);
  console.log(`Queued ${url} -> rid ${rid}`);
}

运行 npm run seed 会为每个被接受的种子打印一行 Queued <url> -> rid <rid>。至此,分布式爬虫已经在运行。这些 URL 现在位于 Crawlbase 的队列中,第一批爬取结果将开始到达我们接下来要实现的 Webhook。

第 3 步:用 Webhook 处理爬取结果

爬虫开始处理 URL 后,引擎需要一种接收结果的方式。每次完成的爬取都会投递到之前配置的 callback_url。HTML 页面作为请求体到达,而 pc_statusoriginal_statusridurl 等元数据以及任何自定义回调头都通过 HTTP 头发送。

先确认,后干活。Crawlbase 期望在约 200 ms 内收到空的 2xx 响应,所以 Webhook 立即结束响应,把解析、存储和入队推迟到 setImmediate 中执行;监控机器人的健康探测则以空操作方式应答。

Webhook 的实现位于 final/src/server.js。这里有两个要点。第一,Crawlbase 投递的响应经过 gzip 压缩,因此服务器使用 express.raw()Buffer 形式接收请求体,Express 会透明地解压。第二,Webhook 确认应尽可能快:立即确认,响应发送之后再处理。

js
app.use(express.raw({ type: '*/*', limit: '10mb' }));

app.post('/webhook', (req, res) => {
  // The monitoring bot probes this endpoint to detect outages. Acknowledge
  // it as a no-op - it carries no real crawl result to process. Crawlbase
  // expects a 2xx with an empty body, so end the response without content.
  if ((req.headers['user-agent'] || '').includes('Crawlbase Monitoring Bot')) {
    return res.status(200).end();
  }

  const pcStatus = Number(req.headers['pc_status']);
  const rid = req.headers['rid'];
  const url = req.headers['url'];
  const depth = Number(req.headers['x-crawl-depth'] || 0);

  const html = req.body.toString('utf8');
  res.status(200).end();

  if (pcStatus !== 200 || !rid || !url) {
    return;
  }

  setImmediate(() => handleResult({ rid, url, depth, html }));
});
Webhook 健康约定

Crawlbase 通过在约 200 毫秒内返回的空 2xx 响应来验证 Webhook 的健康状况,并会用监控机器人定期探测该端点。这就是处理函数用 res.status(200).end() 应答而不带响应体的原因,也是机器人探测被当作空操作确认的原因。端点缓慢或不可用会触发投递重试。

处理函数还基于 pc_status 而不是目标网站的 original_status 来分支。pc_status200 表示 Crawlbase 已在执行必要的重试或反爬虫处理后成功完成爬取,因此失败的请求引擎可以直接忽略。

注意,实际处理发生在 setImmediate() 内部,此时响应已经确认完毕。无论后续要做多少解析或存储工作,Webhook 都能保持响应迅速,Crawlbase 也可以继续投递新的爬取结果,而无需等待下游处理完成。

到这个阶段,引擎已经在接收爬取结果,但还没有对它们做任何有用的事情。下一步是解析每个页面、提取结构化数据、发现新链接,并把它们重新送回爬虫。

第 4 步:提取数据并扩展爬取边界

这是爬虫变得自我维持的关键一步。投递到 Webhook 的每个页面都产生两类输出:可存储的结构化记录,以及可能成为后续爬取任务的一组新链接。爬取边界位于这两个阶段之间,确保爬取有界、确定,并且没有重复工作。

爬取边界的实现位于 final/src/frontier.js

js
function shouldCrawl(rawUrl, depth) {
  if (depth > config.maxDepth) {
    return false;
  }
  const url = normalize(rawUrl);
  if (!url || !isAllowed(url) || seen.has(url)) {
    return false;
  }
  seen.add(url);
  return true;
}

shouldCrawl 辅助函数充当引擎的守门人。每个新发现的 URL 在被重新纳入爬取之前,都会先规范化、检查是否属于允许的域名、校验最大爬取深度,并记录为已见。这一个函数就落实了爬虫的范围约束、保证了终止性,也防止了重复工作。

页面通过 Webhook 之后,引擎提取其数据、持久化结果,并评估每个出站链接是否进入下一轮爬取。提取与存储逻辑实现在 final/src/extract.js 中,编排则发生在 final/src/server.js 的 Webhook 处理函数内。

js
async function handleResult({ rid, url, depth, html }) {
  const { record, links } = extract(html, url);
  save(rid, { rid, url, depth, crawledAt: new Date().toISOString(), ...record });

  let queued = 0;
  for (const link of links) {
    if (shouldCrawl(link, depth + 1)) {
      try {
        await pushToCrawler(link, depth + 1);
        queued += 1;
      } catch (error) {
        console.error(`Failed to queue ${link}: ${error.message}`);
      }
    }
  }
  console.log(`[${rid}] depth=${depth} url=${url} queued=${queued} new links`);
}

每个出站链接都要先经过爬取边界,才能成为新的爬取任务。有效的 URL 立即被推回 Enterprise Crawler,重复项、范围外域名以及超出配置深度的页面则被丢弃。引擎从不等待这些页面被爬取;它只是把它们入队,然后继续处理当前结果。分布式执行由 Crawlbase 负责,下一张完成的页面就绪后会送达 Webhook。

这个递归反馈循环正是整个架构的核心。每次完成的爬取都会产生更多工作,直到范围内再无新 URL 可发现。引擎因此能够持续扩展爬取,而无需维护自己的工作节点池或爬取队列。

完整的爬取生命周期

至此,引擎的每个部分都已就位。隧道运行起来并让 CALLBACK_URL 指向它之后,整个爬取只需三条命令即可启动:

bash
npm run setup   # once: create the crawler and bind it to your webhook
npm start       # start the webhook consumer
npm run seed    # enqueue the initial URLs

从这里开始,引擎自主运行。种子 URL 入队,Crawlbase 异步处理,完成的页面投递到 Webhook,结构化数据被提取并存储,每个新发现的链接都会先经过评估再推回队列。这个循环不断重复,直到范围内再无页面可爬。

由于引擎只负责编排工作流,吞吐量由 Crawlbase 的工作节点并发量决定,而不取决于你应用的资源。扩展爬取不需要额外的工作进程,也不需要改动引擎本身;只需让 Crawlbase 并发处理更多爬取任务。随着爬取推进,你可以通过服务器日志和 results/ 目录下不断增长的 JSON 记录来监控进展。

当排队爬取不合适时,这套架构也支持同步获取页面。fetchInline 辅助函数(位于 final/src/crawlbase-client.js)使用 Crawling API,凭同一个 Crawlbase 令牌立即抓取单个页面。实践中,Enterprise Crawler 适合大批量的异步爬取,而 Crawling API 更适合对延迟敏感的请求,例如用户查询,或 AI 智能体按需获取上下文。

生产环境注意事项

我们构建的实现刻意保持精简,以便架构易于理解。在生产环境使用之前,有几个方面值得加强。

  • 把爬取边界移入共享存储。示例把 seen 集合存在内存中,这意味着去重仅限于单个进程,并且重启后即丢失。对于多引擎实例或长时间运行的爬取,应把边界移入 Redis 或其他共享数据存储,以规范化后的 URL 作为键。
  • 在应用之外管理密钥。示例在本地开发时从 .env 加载 Crawlbase 令牌。生产环境应通过平台的密钥管理器注入凭证,而不是把它们存放在配置文件里。
  • 保持 Webhook 快速可靠。Webhook 应立即确认请求,把开销大的处理推迟到响应发送之后。端点缓慢或不可用会触发投递重试,因此用共享密钥或自定义回调头保护端点,有助于同时提升可靠性和安全性。
  • 异步处理可考虑 Crawlbase Cloud Storage如果你想在爬取结果一就绪就立即处理,Webhook 是理想选择,但它不是唯一的投递方式。当 Enterprise Crawler 配置为使用 Cloud Storage 时,每次完成的爬取都会自动持久化,无需 Webhook,你的应用可以按自己的节奏通过存储 API 获取结果。这适合批处理、不便暴露 HTTPS 端点的环境,或希望每个已爬页面都有永久 URL 的工作流。详情参见存储投递模式的文档。
  • 监控队列增长。Enterprise Crawler 通过并发、队列容量和推送速率限制来保证爬取可预测。如果生产者速度超过消费者,应监控队列大小,并在失控的爬取消耗不必要的资源之前,通过管理 API 暂停或清空它们。随着工作负载增长,你可以在 Crawlbase 支持仪表板申请更高的并发或推送速率上限。
  • 选择合适的渲染模式。尽可能先使用 Normal 令牌。如果目标站点严重依赖客户端渲染或返回质询页面,则切换到 JavaScript 爬虫,并使用 page_waitajax_wait 等渲染参数,在提取前等待动态内容加载。
  • 尊重爬取边界。让爬虫只访问你拥有或获得授权的域名,在适用时遵循每个站点的 robots.txt 和服务条款,并配置合理的爬取上限,避免产生不必要的流量。

结语

我们通过把爬取编排爬取执行分离,构建了一个分布式爬取引擎。引擎负责爬取策略、URL 去重、解析、存储和递归边界扩展;Crawlbase 提供分布式队列、工作节点集群、重试、浏览器渲染和代理基础设施。这样的分离让应用保持小巧、易于推理,并专注于让它与众不同的逻辑。

示例实现刻意偏向简单,而非生产就绪。随着工作负载增长,你可以把内存中的边界换成 Redis、把爬取结果持久化到数据库,或者在同一个分布式队列后面运行多个无状态引擎实例,而无需改变整体架构。

Crawlbase Enterprise Crawler

面向大规模异步爬取的托管队列加工作节点集群:推入 URL,完成的页面就会送达你的 Webhook 或 Cloud Storage,重试、JavaScript 渲染、代理轮换和反爬虫处理均已应用。你的引擎掌管策略,Crawlbase 负责抓取。创建账户,从免费套餐开始。

常见问题

什么时候应该用 Enterprise Crawler 而不是 Crawling API?

这两项服务解决的是不同的问题。需要排队、重试、并发以及 Webhook 或 Cloud Storage 投递的异步大规模爬取,用 Enterprise Crawler;需要立即拿到页面的场景,比如支撑面向用户的请求或为 AI 智能体提供实时上下文,用 Crawling API。一个实用的经验法则是:追求吞吐量用队列,追求低延迟用 Crawling API。

我必须暴露公开的 Webhook 吗?

不必。本文使用 Webhook,是因为它是在爬取结果一就绪就进行处理的最及时方式。你也可以把 Enterprise Crawler 配置为将结果投递到 Crawlbase Cloud Storage,之后再通过存储 API 获取。对于批处理工作流,或不便暴露公开 HTTPS 端点的环境,这通常是更合适的选择。

什么时候应该用 JavaScript 令牌而不是 Normal 令牌?

尽可能先使用 Normal 令牌,因为它更快、更划算。如果目标网站在客户端渲染内容、返回空的 HTML 壳,或出现需要浏览器才能应对的反爬虫质询,则切换到 JavaScript 请求,并在需要时使用 page_waitajax_wait 等渲染参数。

如何让爬虫更快地处理 URL?

引擎本身刻意保持轻量,它只负责编排爬取。整体吞吐量由 Enterprise Crawler 的并发量决定,而不取决于你应用的资源。如果工作负载需要的吞吐量超出当前限额,可以通过支持仪表板申请提升。

可以运行这个引擎的多个实例吗?

可以。这套架构就是为水平扩展设计的。唯一需要改动的组件是内存中的爬取边界。把本地的 seen 集合换成 Redis 等共享存储后,多个 Webhook 消费者就能协调爬取状态,同时共享同一个 Enterprise Crawler 队列。

开始构建

大规模爬取任何站点,无需与基础设施对抗。

Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。

自助开通 · 无需销售通话 · 提供企业级爬取量