Stack Overflow 是开发者最大的公开知识库之一,每个问题列表页面都承载着值得收集的结构化信号:问题标题、所属标签、票数、回答数、浏览量,以及指向完整讨论帖的链接。聚合一个标签下的这些数据,可以告诉你哪些话题正在升温、哪些问题无人解答,以及某项技术的相关问题随时间的变化趋势。

本指南将向你展示如何使用 JavaScript 和 Node.js 配合 cheerio 抓取 Stack Overflow 问题。你将构建一个小型、可运行的爬虫,通过 Crawling API 获取公开的问题列表页面,为每个问题解析一条结构化记录,处理标签的分页,并将结果导出为 JSON 和 CSV。整个演示仅限于公开列表数据,结尾处的合法性部分并非套话,请在进行任何真实量级的抓取之前仔细阅读。

你将构建什么

一个 Node.js 脚本,接收公开的 Stack Overflow 标签 URL,通过 Crawling API 获取页面 HTML,并为列表中的每个问题提取结构化记录。我们以 javascript 标签作为运行示例,每个问题提取以下字段:

  • 标题:问题文本,例如"How do I return the response from an asynchronous call?"。
  • 标签:该问题所属的标签列表,如"javascript, async-await, promise"。
  • 票数:摘要卡片上显示的净票数。
  • 回答数:回答数量,无人回答时显示"0 answers"。
  • 浏览量:卡片上显示的浏览量。
  • 链接:指向单个问题页面的绝对 URL。

为什么普通请求在 Stack Overflow 上会有局限

Stack Overflow 在服务器端渲染了相当多的列表标记,因此普通的 HTTP 请求比在重度客户端渲染的网站上能获取更多内容。问题在于大量抓取时的稳定性。Stack Overflow 会监控自动化流量,来自数据中心 IP 进行快速、重复请求的行为会遭到限速,或被返回质询页面而非问题标记。发生这种情况时,解析器看到的是意外的布局,整次运行会悄无声息地降级。

因此,一个可靠的 Stack Overflow 爬虫需要一个网站视为真实访客的 IP,以及在需要时能在解析前渲染页面的浏览器。你可以自己用轮换住宅代理池和无头浏览器来搭建这套方案,但维护这套栈本身就是大部分工作量。Crawling API 将两者合并为一次调用:你发送 URL,它在可信 IP 后面获取页面(传入 JavaScript token 时还会渲染页面),并返回处理完毕的 HTML 供你解析。

普通 token 与 JS token

Crawlbase 提供两种 token 类型。普通 token 获取静态 HTML,对本文使用的服务器渲染 Stack Overflow 列表页面已足够。JavaScript(JS)token 则先在真实浏览器中渲染页面,适用于内容在客户端加载的目标网站。对这些列表页面先用普通 token;如果目标页面返回的内容缺少字段,再切换到 JS token。

前提条件

编写任何代码之前,你需要准备几样东西。这些都不需要太长时间。

Node.js 16 或更高版本。node --version 确认你的版本。如果没有安装,请从 Node.js 官网或通过 nvm 等版本管理器安装。

基本 JavaScript 和 Node.js 知识。你应该能熟练编写和运行 Node 脚本,并用 npm 安装包。如果你是 Node 新手,官方文档和任何入门课程都能带你达到本教程所需的水平。更完整的演练,请参阅我们的如何用 Node.js 构建网络爬虫指南。

Crawlbase 账号和 token。注册账号,打开控制台,从账号文档页面复制你的普通请求 token。将 token 视为密码:它用于验证你的请求,不要放入版本控制。

配置项目

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

bash
node --version

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

npm install crawlbase cheerio

两个依赖各司其职:crawlbase 是 Crawling API 的官方 Node 客户端,cheerio 用 jQuery 风格的 API 解析返回的 HTML,让你能通过 CSS 选择器提取各个字段。如果你对选择器不熟悉,XPath 和 CSS 选择器入门是很好的参考。

第一步:获取问题列表页面

从获取页面开始。导入 CrawlingAPI 类,用你的 token 初始化,然后请求标签 URL。在解析之前检查状态码,可让失败明显暴露而不是悄无声息。

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

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

async function crawl(pageUrl) {
  const response = await api.get(pageUrl);
  if (response.statusCode === 200) {
    return response.body;
  }
  console.error(`Request failed: ${response.statusCode}`);
  return null;
}

const tagUrl = 'https://stackoverflow.com/questions/tagged/javascript';
crawl(tagUrl).then((html) => {
  console.log(html ? html.slice(0, 500) : 'No HTML returned');
});

标签 URL 遵循固定格式:https://stackoverflow.com/questions/tagged/<tag>。将 javascript 替换为你要研究的任何标签,如 pythonnode.js。用 node scraper.js 运行脚本,你应该看到真实的问题标记,而不是质询页面。这在你编写任何选择器之前,就能确认请求已正常工作。

Crawlbase Crawling API

那一次 api.get 调用所做的,远不止一个普通请求:它在可信 IP 后面获取标签页面,并在服务器端轮换住宅地址,这样 Stack Overflow 看到的是真实访客流量而不是需要限速的爬虫。你跳过了自己运行无头浏览器集群和代理池的麻烦,当目标需要渲染时只需添加 JavaScript token。先用免费套餐指向一个公开标签页面试试。

第二步:用 cheerio 解析每个问题

拿到 HTML 后,将其加载到 cheerio 并遍历问题卡片。Stack Overflow 将每个问题布局在 #questions 内的 .js-post-summary 块中,因此你选取每个摘要,再从中读取标题、标签、票数、回答数、浏览量和链接。.replace(/\s+/g, ' ').trim() 链将 Stack Overflow 标记中的空白符压缩为整洁的单空格文本。

javascript
const cheerio = require('cheerio');

const clean = (text) => text.replace(/\s+/g, ' ').trim();

function parseQuestions(html) {
  const $ = cheerio.load(html);
  const questions = [];

  $('#questions .js-post-summary').each((_, element) => {
    const el = $(element);
    const title = clean(el.find('.s-post-summary--content-title').text());
    const link = el.find('.s-link').attr('href') || '';
    const votes = clean(
      el.find('.js-post-summary-stats .s-post-summary--stats-item:first-child').text()
    );
    const answers =
      clean(el.find('.js-post-summary-stats .has-answers').text()) || '0 answers';
    const views = clean(
      el.find('.js-post-summary-stats .s-post-summary--stats-item:last-child').text()
    );
    const tags = el
      .find('.js-post-tag-list-item')
      .map((__, tag) => clean($(tag).text()))
      .get()
      .filter(Boolean);

    questions.push({
      title,
      tags,
      votes,
      answers,
      views,
      link: link.includes('https://') ? link : `https://stackoverflow.com${link}`,
    });
  });

  return questions;
}

有几个细节保证了对页面的忠实解析。票数和浏览量都位于 .js-post-summary-stats 下的 .s-post-summary--stats-item 条目中,因此第一个匹配票数,最后一个匹配浏览量。回答数只有在问题有回答时才带有 .has-answers 类,这就是选择器返回空时回退到 '0 answers' 的原因。标签来自每个 .js-post-tag-list-item,映射为数组以保持结构化。链接从锚点的 href 读取,并转换为绝对 URL,因为 Stack Overflow 返回的是 /questions/123/... 这样的相对路径。

选择器会漂移

Stack Overflow 的类名(js-post-summarys-post-summary--content-titlejs-post-tag-list-item 等)可能在没有通知的情况下发生变化。将上面的选择器视为起始模板,而非合同。当某个字段返回空值时,在浏览器开发工具中重新检查实时页面并更新选择器。定期进行选择器维护对任何生产爬虫来说都是正常的,不代表有什么地方坏了。

第三步:完整脚本与导出

现在将抓取和解析整合到一个可运行的脚本中,然后将记录写入 JSON 和 CSV 两种格式。JSON 保留嵌套的标签数组,方便编程使用;CSV 将每条记录展平为一行,用分隔符连接标签,方便在电子表格中使用。

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

const api = new CrawlingAPI({ token: 'YOUR_CRAWLBASE_TOKEN' });
const clean = (text) => text.replace(/\s+/g, ' ').trim();

async function crawl(pageUrl) {
  const response = await api.get(pageUrl);
  if (response.statusCode === 200) return response.body;
  console.error(`Request failed: ${response.statusCode}`);
  return null;
}

function parseQuestions(html) {
  const $ = cheerio.load(html);
  const questions = [];
  $('#questions .js-post-summary').each((_, element) => {
    const el = $(element);
    const link = el.find('.s-link').attr('href') || '';
    questions.push({
      title: clean(el.find('.s-post-summary--content-title').text()),
      tags: el
        .find('.js-post-tag-list-item')
        .map((__, tag) => clean($(tag).text()))
        .get()
        .filter(Boolean),
      votes: clean(
        el.find('.js-post-summary-stats .s-post-summary--stats-item:first-child').text()
      ),
      answers:
        clean(el.find('.js-post-summary-stats .has-answers').text()) || '0 answers',
      views: clean(
        el.find('.js-post-summary-stats .s-post-summary--stats-item:last-child').text()
      ),
      link: link.includes('https://') ? link : `https://stackoverflow.com${link}`,
    });
  });
  return questions;
}

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

async function main() {
  const tagUrl = 'https://stackoverflow.com/questions/tagged/javascript';
  const html = await crawl(tagUrl);
  if (!html) return;
  const questions = parseQuestions(html);
  fs.writeFileSync('questions.json', JSON.stringify(questions, null, 2));
  fs.writeFileSync('questions.csv', toCsv(questions));
  console.log(`Saved ${questions.length} questions to questions.json and questions.csv`);
}

main();

node scraper.js 运行完整脚本。它获取标签页面,解析每张问题卡片,并将 questions.jsonquestions.csv 都写入你的项目文件夹。CSV 对引号进行转义,并用竖线连接标签数组,让一个问题的多个标签保留在同一个单元格中。

输出示例

JSON 文件每个问题保存一个对象,标签保持为结构化数组,方便加载到分析脚本或数据库中。

json
[
  {
    "title": "How do I return the response from an asynchronous call?",
    "tags": ["javascript", "ajax", "asynchronous", "promise"],
    "votes": "8632 votes",
    "answers": "42 answers",
    "views": "2.1m views",
    "link": "https://stackoverflow.com/questions/14220321/how-do-i-return-the-response-from-an-asynchronous-call"
  },
  {
    "title": "What does \"use strict\" do in JavaScript?",
    "tags": ["javascript", "syntax", "jslint", "use-strict"],
    "votes": "9201 votes",
    "answers": "32 answers",
    "views": "1.0m views",
    "link": "https://stackoverflow.com/questions/1335851/what-does-use-strict-do-in-javascript"
  }
]

相同数据的 CSV 版本是一行表头加上每个问题一行,标签连接为单个以竖线分隔的单元格。

csv
title,tags,votes,answers,views,link
"How do I return the response from an asynchronous call?","javascript|ajax|asynchronous|promise","8632 votes","42 answers","2.1m views","https://stackoverflow.com/questions/14220321/..."
"What does ""use strict"" do in JavaScript?","javascript|syntax|jslint|use-strict","9201 votes","32 answers","1.0m views","https://stackoverflow.com/questions/1335851/..."

循环遍历标签页面

一页问题是个演示;真实任务需要遍历整个分页。Stack Overflow 通过 page 查询参数暴露页码,因此你可以在循环中构建每个页面 URL,通过 Crawling API 获取,用同一个函数解析,并收集所有记录。因为每个列表页面共享相同的卡片结构,你已经编写的解析器无需修改就能跨所有页面工作。

javascript
async function scrapeTag(tag, totalPages) {
  const all = [];
  for (let page = 1; page <= totalPages; page++) {
    const url =
      `https://stackoverflow.com/questions/tagged/${tag}?tab=newest&page=${page}`;
    const html = await crawl(url);
    if (html) all.push(...parseQuestions(html));
  }
  return all;
}

scrapeTag('javascript', 3).then((rows) => {
  console.log(`Collected ${rows.length} questions`);
});

要为每条记录补充完整的问题正文、被采纳的答案或评论串,从每张卡片的 link 出发,通过同一个 crawl 函数获取该问题页面,然后为问题页面布局编写一个小型解析器。模式完全相同:获取,然后解析。对于渲染较重的目标,请参阅如何爬取 JavaScript 网站

保持不被封锁

即使 IP 信任问题已由 API 代劳,Stack Overflow 仍会监控爬虫形态的流量。以下几个习惯有助于保持运行顺畅,适用于任何大量抓取的网站。

  • 控制请求节奏。在紧凑循环中轰炸页面是被限速的最快方式。将请求分散开,轮换标签,而不是全速爬取一条路径。
  • 依靠轮换。住宅 IP 池将请求分散到许多真实用户地址,避免单个地址触发频率限制。Crawling API 为你处理这些;如果你自己搭建方案,这是需要做好的部分。
  • 关注状态码。运行开始返回质询或错误,说明当前的请求频率或 IP 层级已不够用。将这视为退出的信号,而不是可以忽略的噪音。

更广泛的操作手册,参见如何在不被封锁的情况下抓取网站。如果你想比较 cheerio 之外的其他解析方案,顶级开源爬虫库综述是很好的参考地图。如果你正在更广泛地收集开发者社区数据,同样的获取后解析模式同样适用于抓取 GitHub 仓库和个人主页

抓取 Stack Overflow 合法吗?

是否允许抓取 Stack Overflow 取决于其服务条款、你所在的司法管辖区以及你对数据的用途。Stack Overflow 作为 Stack Exchange 网络的一部分,发布了公共网络服务条款和可接受使用政策,限制自动化访问,因此无论你的工具多么谨慎,抓取行为都可能违反这些条款。本文的任何代码都不会改变这一现实,它只是让技术层面的工作得以实现。阅读 Stack Exchange 条款和网站的 robots.txt,并将两者视为你收集内容和收集速度的边界。

在编写任何爬虫之前,先确认官方路径是否能满足你的需求,因为对 Stack Overflow 来说,官方路径通常已经足够。Stack Exchange 提供官方的 Stack Exchange API,以整洁的 JSON 格式返回问题、标签、票数、回答和浏览量;同时还发布基于知识共享许可的完整公开内容的定期数据转储。对于研究、分析或任何大量使用场景,API 和数据转储才是正确的工具:它们结构化、在你同意的条款下进行限速,且让你始终处于网络政策允许的范围内。只有在 API 无法服务的少量、公开、一次性需求时,才考虑使用爬虫。

将工作范围限定在公开的非个人数据。本指南使用的问题标题、标签以及聚合的票数、回答数和浏览量是公开的列表信号。用户内容则是另一回事:用户名、声誉、个人资料详情以及人们撰写的文字属于个人数据,转载个人内容或将其与具体身份关联,可能触发 GDPR 和 CCPA 等隐私法律规定的义务,包括合法处理依据和响应删除请求。本指南不涉及任何需要登录才能访问的内容、私信,或为可识别用户建立档案。尽可能进行聚合处理,一旦项目涉及用户级数据,优先使用官方 API 或数据转储。

回顾

核心要点

  • 在可信 IP 后面获取页面。Stack Overflow 会限速爬虫形态的流量,因此 Crawling API 从轮换住宅 IP 获取每个标签页面,返回整洁的 HTML 供解析。
  • cheerio 完成提取。选取 #questions 内的每个 .js-post-summary 卡片,然后将标题、标签、票数、回答数、浏览量和链接映射到当前选择器,并预期这些选择器会发生漂移。
  • 保持标签的结构化。将每个 .js-post-tag-list-item 读入数组,这样一个问题的标签在 JSON 中保持可查询,在 CSV 中折叠为一个单元格。
  • 通过循环页面扩展规模。page 参数可遍历标签的列表页,同一个解析器在合理节奏下可跨所有页面工作。
  • 优先使用官方路径。Stack Exchange API 和 CC 许可的数据转储是大量使用的官方途径;坚守公开数据,遵守服务条款和 robots.txt,避免涉及用户级个人数据。

常见问题

抓取 Stack Overflow 需要普通 token 还是 JS token?

本指南中的问题列表页面使用普通 token 即可,因为 Stack Overflow 在服务器端渲染这些标记。当目标页面在客户端加载内容,导致返回的字段缺失时,才需要 JS token。这里先用普通 token,只有在某个你抓取的页面返回空选择器时再切换。

从 Stack Overflow 问题列表可以提取哪些字段?

从每张摘要卡片可以提取问题标题、所属标签、净票数、回答数、浏览量以及指向完整问题的链接。本指南将每个字段映射到一个 CSS 选择器,并组装为每个问题一条记录,导出为 JSON 和 CSV。

我的选择器返回空值,是什么变了?

几乎可以肯定是 Stack Overflow 的标记发生了变化。其 js-post-summary 卡片类、s-post-summary--content-title 标题包装器和 js-post-tag-list-item 标签标记可能在没有通知的情况下改变。在浏览器开发工具中重新检查实时页面并更新选择器。定期进行选择器维护对任何生产爬虫来说都是正常的。

我应该使用 Stack Exchange API 还是直接抓取网站?

如果你需要大量数据、有保障的结构或完整的公开内容,请使用官方 Stack Exchange API 或其知识共享数据转储。它们就是为此而生的,且让你始终处于网络条款允许的范围内。本指南中抓取公开列表页面的方式适用于 API 无法服务的少量、公开数据需求,前提是你尊重服务条款、robots.txt 和频率限制。

我可以从 Stack Overflow 抓取用户个人主页或声誉吗?

本指南刻意回避了这些内容。用户名、声誉以及人们撰写的内容属于个人数据,为可识别用户建立档案可能触发 GDPR 和 CCPA 等隐私法律规定的义务。坚守公开的列表信号,如标题、标签和聚合计数;尽可能进行聚合处理;如果你的项目确实需要用户级数据,请使用官方 API。

抓取 Stack Overflow 时如何避免被封锁?

降低每个 IP 的请求频率,轮换标签而不是循环一条路径,并通过轮换住宅 IP 路由流量,以免单个地址触发频率限制。Crawling API 为你管理轮换和可信 IP 池;如果你自己搭建方案,那是需要投入的部分。关注状态码,一旦开始遇到质询就退出。

开始构建

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

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

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