你想从页面获取的大量内容并不存在于第一次响应中。社交信息流、商品网格、搜索结果和评论列表通常先加载少量条目,然后只有在你向下滚动时才获取并渲染更多内容。这种模式(无限滚动)让初始页面保持轻量,但也意味着单次 HTTP 请求只能拿到你在浏览器中实际可见数据的一小部分。

本指南演示如何使用 JavaScript 和 Node.js 在爬取时滚动网页。你将构建一个小型的可运行爬虫,通过 Crawling APIscrollscroll_interval 选项驱动滚动,让页面加载更多条目,然后用 Cheerio 解析已加载内容并导出。本文使用一个通用占位符列表 URL 作为演示,你可以替换为你自己的公开目标页面,且仅涉及公开数据。

将要构建的内容

一个 Node.js 脚本,指向一个公开的无限滚动列表页面,要求 Crawling API 渲染该页面并持续滚动指定秒数以加载更多条目,然后从返回的 HTML 中为每个条目提取结构化记录。示例爬取了一个通用公开列表,包含以下字段:

  • 标题(Title):卡片上显示的条目标题或标题行。
  • 副标题(Subtitle):第二行信息,如供应商、作者或分类(若存在)。
  • 价格(Price):卡片显示的标价或价值(若存在)。
  • 链接(Link):指向该条目页面的绝对 URL。
  • 结果数(Result count):页面报告的条目数量汇总。

为什么普通请求会遗漏滚动内容

向无限滚动页面发送普通 HTTP 请求,你得到的是服务器在任何滚动发生之前返回的标记。这通常只是第一批条目,仅此而已。列表的其余部分由页面自身的 JavaScript 响应滚动事件获取,通常通过后台 AJAX 调用,并在浏览器向下滚动后才注入到 DOM 中。从未滚动过的请求永远不会触发这些调用,因此额外的条目也不会出现在你收到的 HTML 中。

要获取完整列表,需要在一次请求中同时完成两件事。页面需要在真实浏览器中渲染,且该浏览器需要滚动足够远、持续足够长的时间,以触发每一轮懒加载。你可以自行用无头浏览器循环滚动并在每轮之间等待,再加上轮换 IP 池以确保目标将流量识别为真实访客。Crawling API 将所有这些合并为单次调用:你带着滚动选项发送 URL,它在服务器端以可信 IP 渲染并滚动页面,然后返回完整加载的 HTML 供你用 Cheerio 解析。

三个关键点

通过 Crawling API 实现滚动需要了解三件事。JavaScript token 在真实浏览器中渲染页面。scroll 选项告知 API 在加载后滚动页面。scroll_interval 选项设置持续滚动的秒数,最多 60 秒;之后 API 捕获已加载的页面并返回。

前提条件

在编写任何代码之前,需要先准备好以下几样东西,都不需要太多时间。

基本的 JavaScript 和 Node.js 知识。你应当能够编写并运行 Node 脚本,并使用 npm 安装包。如果你刚接触这些,我们关于使用 Node.js 构建 Web 爬虫的指南涵盖了本教程所需的基础知识。

Node.js 16 或更高版本。node --version 确认你的版本。如果尚未安装,可从 Node.js 官网或 nvm 等版本管理器获取。

Crawlbase 账号和 JavaScript token。注册账号,打开控制台,复制你的 JavaScript token。免费套餐最多提供 20,000 次请求且无需绑定信用卡,且只对成功请求付费。JavaScript 请求比普通请求消耗更多积分,因为它们需要渲染页面,在有滚动的情况下尤其如此。请像对待密码一样保管 token,不要将其纳入版本控制。

初始化项目

创建项目文件夹,初始化项目,并安装爬虫所需的两个库。

bash
node --version

mkdir scroll-scraper && cd scroll-scraper
npm init -y

npm install crawlbase cheerio

两个依赖各司其职:crawlbase 是 Crawling API 的官方 Node 客户端,cheerio 使用类 jQuery 的 API 解析返回的 HTML,从而通过 CSS 选择器提取字段。在此文件夹中创建一个名为 scraper.js 的文件,并将以下步骤中的代码添加进去。

步骤 1:启用滚动后获取页面

首先获取已加载的页面。导入 CrawlingAPI 类,用你的 JavaScript token 初始化,然后带着 scroll 选项请求目标 URL。不设置 scroll_interval 时,API 默认滚动 10 秒,加载第一批额外内容。在解析之前先检查状态,可以让错误更明显而不是悄然失败。

javascript
const { CrawlingAPI } = require('crawlbase');

const api = new CrawlingAPI({ token: 'YOUR_CRAWLBASE_TOKEN' });

const targetUrl = 'https://example.com/listings';

api
  .get(targetUrl, { scroll: true })
  .then((response) => {
    if (response.statusCode === 200) {
      console.log(response.body.length, 'bytes of rendered HTML');
      console.log(response.body.slice(0, 500));
    }
  })
  .catch((error) => console.error('API request error:', error));

node scraper.js 运行脚本。传入 { scroll: true } 会告知 Crawling API 在返回之前先渲染并滚动页面,因此你拿到的 HTML 中已经包含了滚动时加载的条目,而不仅仅是第一批。响应体的字节数和顶部片段可以确认你收到的是真实渲染页面,而非空壳。由于还没有设置间隔,这里默认为 10 秒滚动,自然加载的条目会少于更长时间的滚动。

Crawlbase Crawling API

这次单一调用就已经为你渲染了一个无限滚动页面并完成了滚动,你无需在自己这边循环运行无头浏览器或维护代理池。Crawling API 在真实浏览器中运行页面,按 scroll_interval 指定的时长持续滚动,在服务器端轮换住宅 IP,并处理 CAPTCHA,因此你从一次请求中获得完整加载的 HTML。先在免费额度内用一个公开列表页面测试,然后再添加解析器。

步骤 2:用 scroll_interval 加载更多条目

默认的 10 秒滚动只加载第一批额外内容。要加载更多,将 scroll_interval 设置为希望 API 持续滚动的秒数。最大值为 60 秒;超过后 API 会捕获已加载的内容并返回。每多滚动一秒,页面就有更多时间获取和渲染额外条目。

javascript
const { CrawlingAPI } = require('crawlbase');

const api = new CrawlingAPI({ token: 'YOUR_CRAWLBASE_TOKEN' });

async function fetchScrolled(url, seconds) {
  const response = await api.get(url, {
    scroll: true,
    scroll_interval: seconds,
  });

  if (response.statusCode === 200) return response.body;
  console.error(`Request failed: ${response.statusCode}`);
  return null;
}

fetchScrolled('https://example.com/listings', 20)
  .then((html) => html && console.log(html.length, 'bytes after 20s scroll'));

这里的调用滚动了 20 秒而非默认的 10 秒,因此页面有时间加载更多条目,返回的 HTML 也随之变大。请保持连接足够长时间以等待滚动完成:如果你滚动整整 60 秒,在加载完的页面返回之前,请为请求预留约 90 秒的完成时间。从较小的间隔开始,只在条目数量不再增加之前逐步提高,因为更长的渲染消耗更多积分,而一旦列表耗尽,再长的滚动也不会带来额外内容。

调优间隔值

scroll_interval 没有唯一正确的值。简短的信息流可能在 10 到 20 秒内完全加载,而较长的列表可能一直加载到 60 秒上限。逐步提高间隔并比较解析出的条目数;一旦数量趋于稳定,继续延长滚动时间只会消耗积分而不增加任何数据行。

步骤 3:用 Cheerio 解析已加载的条目

拿到已加载的 HTML 后,将其传入 Cheerio 并遍历每个条目卡片。使用与你真实目标匹配的选择器;以下选择器假设一个通用列表页,每个条目位于 .listing-item 卡片中。防御性地读取每个字段,避免某个字段缺失导致整个运行崩溃。

javascript
const cheerio = require('cheerio');

function parseItems(html, baseUrl) {
  const $ = cheerio.load(html);
  const result = {
    resultCount: $('.results-count').text().trim(),
    items: [],
  };

  $('.listing-item').each((_, element) => {
    const card = $(element);

    const title = card.find('.item-title').text().trim();
    const subtitle = card.find('.item-subtitle').text().trim();
    const price = card.find('.item-price').text().trim();
    let link = card.find('a.item-link').attr('href');
    if (link && link.startsWith('/')) {
      link = new URL(link, baseUrl).href;
    }

    if (title) {
      result.items.push({
        title,
        subtitle: subtitle || '',
        price: price || 'N/A',
        link: link || '',
      });
    }
  });

  return result;
}

结果数量来自 .results-count,每个条目位于 .listing-item 卡片中。卡片内部,标题来自 .item-title,副标题来自 .item-subtitle,价格来自 .item-price,链接来自 a.item-link 锚点,并解析为绝对 URL 以便在页面之外使用。由于滚动期间加载的条目已经存在于 HTML 中,同一个循环在一次遍历中就能捕获第一批和之后所有批次的内容。

选择器会发生变化

上面的类名只是通用页面的占位符。对于真实目标,在浏览器开发者工具中打开实时页面,向下滚动直到条目加载出来,然后读取每个卡片上实际的类名或属性。许多生产站点使用会不定期更改的自动生成类名,因此把选择器当作起始模板而非固定规范,字段返回空值时重新检查。

步骤 4:组合完整脚本并导出 JSON 和 CSV

现在将带滚动的请求和解析步骤整合成一个可运行的脚本,然后将记录写入磁盘,同时输出 JSON 和 CSV 格式。

javascript
const fs = require('fs');
const { CrawlingAPI } = require('crawlbase');
const cheerio = require('cheerio');

const api = new CrawlingAPI({ token: 'YOUR_CRAWLBASE_TOKEN' });

async function fetchScrolled(url, seconds) {
  const response = await api.get(url, {
    scroll: true,
    scroll_interval: seconds,
  });
  if (response.statusCode === 200) return response.body;
  console.error(`Request failed: ${response.statusCode}`);
  return null;
}

function toCsv(rows) {
  const headers = ['title', 'subtitle', 'price', 'link'];
  const escape = (value) =>
    `"${String(value).replace(/"/g, '""')}"`;
  const lines = [headers.join(',')];
  for (const row of rows) {
    lines.push(headers.map((h) => escape(row[h])).join(','));
  }
  return lines.join('\n');
}

async function main() {
  const url = 'https://example.com/listings';
  const html = await fetchScrolled(url, 20);
  if (!html) return;

  const data = parseItems(html, url);
  fs.writeFileSync('items.json', JSON.stringify(data, null, 2));
  fs.writeFileSync('items.csv', toCsv(data.items));
  console.log(`Saved ${data.items.length} items to JSON and CSV`);
}

main();

将步骤 3 中的 parseItems 函数粘贴到同一文件中,以便 main 能够调用它。用 node scraper.js 运行,你将得到两个文件:包含完整结构化记录的 items.json,以及可直接在电子表格中打开的 items.csvtoCsv 辅助函数对每个字段加引号并对内嵌引号进行转义,这很重要,因为标题和价格中经常包含逗号。这样你就拥有了一个不到 50 行代码的可用滚动爬虫,可以将其集成到现有爬虫中,或在需要时封装为一个端点。

输出结果示例

JSON 文件包含结果数量以及每个已加载条目的一个对象,每个对象包含标题、副标题、价格和链接。

json
{
  "resultCount": "248 results",
  "items": [
    {
      "title": "Wireless Headphones",
      "subtitle": "AudioWorks",
      "price": "$59.00",
      "link": "https://example.com/listings/wireless-headphones"
    },
    {
      "title": "Mechanical Keyboard",
      "subtitle": "KeyForge",
      "price": "$89.00",
      "link": "https://example.com/listings/mechanical-keyboard"
    }
  ]
}

CSV 以带表头行的格式镜像相同的条目行,可直接导入 Excel、Google Sheets 或任何能读取分隔符文件的数据管道。

csv
title,subtitle,price,link
"Wireless Headphones","AudioWorks","$59.00","https://example.com/listings/wireless-headphones"
"Mechanical Keyboard","KeyForge","$89.00","https://example.com/listings/mechanical-keyboard"

扩展到多个滚动页面

滚动能在单个 URL 上加载更多条目,但它不能替代分页,而且存在实际上限。60 秒的时间限制和页面自身的加载速度限制了单次请求能够呈现的条目数量。对于更大规模的获取,可以将两种技术结合:将每个页面滚动到加载完整批次,然后在站点支持分页时翻到下一页,并用同一个函数解析每页内容。

javascript
async function scrapeAllPages(baseUrl, maxPages, seconds) {
  const allItems = [];

  for (let page = 1; page <= maxPages; page++) {
    const pageUrl = `${baseUrl}?page=${page}`;
    const html = await fetchScrolled(pageUrl, seconds);
    if (!html) break;

    const { items } = parseItems(html, baseUrl);
    if (items.length === 0) break;

    allItems.push(...items);
    console.log(`Page ${page}: ${items.length} items`);

    await new Promise((r) => setTimeout(r, 2000));
  }

  return allItems;
}

在浏览器中检查真实的"下一页"链接,根据你的目标调整分页模式。对于大体量任务,你不必逐个串行等待这些请求。异步 Crawler 允许你推送多个 URL 并在结果就绪时收集,比紧密的串行循环更适合大规模的滚动加分页作业。关于这类渲染密集的脚本页面,参见我们关于爬取 JavaScript 网站的指南;对于完全通过后台 AJAX 提供数据的信息流,参见从 AJAX 网站抓取数据

负责任地抓取

将抓取范围限定在公开数据,并以合理的方式运行。在将滚动爬虫指向某个网站之前,请先阅读该网站的服务条款和 robots.txt,并将两者视为你采集内容的边界。只抓取公开列表,不触碰任何需要登录的内容,并适当控制请求节奏以避免给服务器造成压力:滚动本身已经让渲染保持开启状态许多秒,因此应在页面之间添加延迟,而不是在紧密循环中连续发送。当数据涉及可识别的个人时,GDPR 和 CCPA 等隐私法律适用,因此避免组建个人画像,不要重新发布与某人身份相关联的个人数据。更全面的操作规范,参见如何不被封禁地抓取网站

回顾

核心要点

  • 无限滚动让普通请求无法获取完整数据。普通 HTTP 调用只能返回第一批条目;其余内容在页面滚动时加载,从未滚动的请求永远看不到它们。
  • Crawling API 替你完成滚动。带着 scroll: true 发送 URL,它会在真实浏览器中渲染页面,在服务器端以轮换 IP 滚动页面,并在一次调用中返回完整加载的 HTML。
  • scroll_interval 控制加载量。默认为 10 秒滚动;将间隔提高到最多 60 秒可加载更多条目,全长请求返回前请预留约 90 秒的等待时间。
  • Cheerio 解析已加载的 HTML。选取每个条目卡片,防御性地读取标题、副标题、价格和链接,预期自动生成的类名会发生变化,并导出为 JSON 和 CSV。
  • 滚动加分页实现规模扩展。将每个页面滚动到加载完整批次,在站点支持分页时翻页,控制请求节奏,大规模任务使用异步 Crawler。

常见问题

什么是无限滚动,为什么它会让普通抓取失效?

无限滚动是一种页面先加载少量条目、然后在用户向下滚动时通过后台 AJAX 调用获取并渲染更多内容的模式。普通 HTTP 请求在任何滚动发生之前就捕获了页面,因此只能看到第一批。要获取其余内容,必须在真实浏览器中渲染页面并实际滚动,这正是 Crawling API 在你传入滚动选项时所做的事情。

scroll 和 scroll_interval 选项有什么作用?

scroll 选项告知 Crawling API 在页面加载后滚动页面,这会触发带来更多条目的懒加载。scroll_interval 选项设置持续滚动的秒数,最多 60 秒。开启 scroll 但不设置间隔时,API 默认滚动 10 秒。两者都需要 JavaScript token,因为页面必须在真实浏览器中渲染,滚动才有意义。

scroll_interval 应该设置为多少秒?

从小值开始逐步增加。简短的信息流可能在 10 到 20 秒内完全加载,而较长的列表可能一直加载到 60 秒上限。逐步提高间隔并比较解析出的条目数;一旦数量趋于稳定,再延长滚动时间只会消耗额外积分而不增加任何数据行。如果你滚动整整 60 秒,请预留约 90 秒的时间等待请求完成。

滚动页面是否需要 JavaScript token?

是的。滚动只对在真实浏览器中渲染的页面有意义,而 JavaScript token 正是启用该渲染的关键。普通 token 返回未渲染的 HTML,在这种情况下滚动没有任何效果,额外条目也不会加载。JavaScript 请求比普通请求消耗更多积分(因为需要渲染),在规划大规模任务时请将这一点纳入考量。

滚动能替代分页吗?

不能。滚动在单个 URL 上加载更多条目,但 60 秒的时间限制和页面的加载速度限制了单次请求能够呈现的数量。对于同时支持分页的站点,要获取完整数据集,需要将两者结合:将每个页面滚动到加载完整批次,然后翻到下一页并用同一个函数解析。对于大规模任务,异步 Crawler 可以并行处理多个此类请求。

滚动后我的选择器返回空值,是什么问题?

通常是以下两种情况之一。要么页面滚动时间不够长,导致你想要的条目从未加载,这时需要提高 scroll_interval 并确认返回的 HTML 变大了。要么站点的标记使用了与你的选择器预期不同或自动生成的类名。在浏览器开发者工具中打开实时页面,向下滚动直到条目出现,读取每个卡片上的真实类名或属性,然后更新 parseItems 中的选择器使其匹配。

开始构建

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

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

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