Reddit 是互联网上最大的公开讨论存档之一,子版块上的公开列表是研究的有用信号:哪些话题正在趋势、社区如何对链接排名、哪些外部来源被分享,以及分数和评论数如何随时间变化。本指南展示了如何使用 Crawlbase Crawling API 通过 Python 抓取 Reddit 公开数据,整个演示范围仅限于公开列表页面。

首先明确一点:这里的内容仅涉及公开子版块和话题列表的公开聚合数据。具体包括帖子标题、分数和点赞数、评论数、帖子所属子版块,以及每篇帖子指向的链接。不涉及任何登录后才能访问的内容、私人子版块、私信,或个人用户的个人数据。Reddit 的服务条款限制了自动访问,因此在将本指南应用于任何实际场景之前,请务必阅读末尾附近的合法性章节,并且在任何生产用途中优先使用官方 Reddit API。

你将构建什么

一个小型 Python 脚本,接收一个公开子版块或话题列表的 URL,通过 Crawling API 使用 JavaScript token 获取完全渲染的页面,并从列表中每篇帖子中解析出若干公开字段:

  • 标题 每篇帖子的公开标题文本。
  • 分数/点赞数 帖子显示的总票数。
  • 评论数 帖子的评论数量(数字形式)。
  • 子版块 帖子所属的社区(例如 r/technology)。
  • 链接 帖子指向的永久链接或外链 URL。

请注意刻意省略的内容:没有用户名、没有作者资料、没有评论文本、没有逐票明细。这些都是个人数据,在此范围之外有意排除。我们仅在帖子和社区层面进行聚合,绝不涉及个人。

为什么普通请求在 Reddit 上会失败

使用裸 HTTP 客户端请求 Reddit 列表 URL,通常得到的结果几乎没有用:一个 JavaScript 外壳、Cookie 同意弹窗,或者一个验证页面。Reddit 当前的前端是客户端渲染帖子列表的,因此标题、分数和链接只在页面脚本在浏览器中运行后才会出现。此外,Reddit 会迅速标记自动流量。数据中心 IP 段、缺少浏览器行为以及重复的请求模式,在列表加载之前就会被限速或封锁。

因此,一个可用的 Reddit 爬虫需要在同一个请求中同时满足两点:能渲染页面的真实浏览器,以及被 Reddit 识别为普通访客的 IP 地址。你可以自己用无头浏览器和一组轮换住宅代理来构建这套方案,但保持该方案的健康运行才是主要工作量所在。Crawling API 将两者合并为一次调用。你发送带有 JavaScript token 的 URL,它在可信住宅 IP 背后渲染页面,并返回可供解析的完整 HTML。如需了解更深入的背景,请参阅我们的指南如何抓取 JavaScript 网站

为什么需要 JS token

Crawlbase 提供两种 token 类型。普通 token 获取静态 HTML;JavaScript(JS)token 会先在真实浏览器中渲染页面。Reddit 列表是客户端渲染的,因此这里需要 JS token。普通 token 返回的与普通请求一样只是一个外壳,其中没有任何有用内容可解析。

前提条件

开始之前需要准备几件事,每项都不需要太长时间。

基本的 Python 知识。 你应该能够运行脚本并使用 pip 安装包。如果你是 HTML 解析的新手,我们关于如何使用 BeautifulSoup in Python 的入门指南涵盖了提取部分的内容。

Python 3.8 或更高版本。 使用 python --version 确认版本。如果尚未安装,请从 python.org 安装。

Crawlbase 账号和 JS token。 注册后,打开你的控制台,从账号文档页面复制你的 JavaScript(JS)token。像密码一样保护它:它用于验证你的请求,因此不要将其提交到版本控制。免费套餐最多提供 20,000 次请求用于测试。

搭建项目

创建一个隔离的虚拟环境,然后安装爬虫所需的两个库。

bash
python --version

python -m venv reddit_env
source reddit_env/bin/activate

pip install crawlbase beautifulsoup4

在 Windows 上,使用 reddit_env\Scripts\activate 代替 source 那行来激活。两个依赖项各司其职:crawlbase 是 Crawling API 的官方客户端,beautifulsoup4 解析返回的 HTML,让你可以通过选择器提取各个字段。

步骤 1:获取渲染后的列表

首先获取完整页面。导入 CrawlingAPI,用你的 JS token 初始化它,然后请求一个公开的列表 URL。本教程的前版本指向了一个公开话题列表 https://www.reddit.com/t/technology/,这是一个好的无个人信息起点。在解析之前检查状态码,这样错误就会立即显现而不是悄悄失败。

python
from crawlbase import CrawlingAPI

crawlbase_token = "YOUR_CRAWLBASE_TOKEN"
api = CrawlingAPI({"token": crawlbase_token})

def crawl(page_url):
    options = {"ajax_wait": "true", "page_wait": 5000}
    response = api.get(page_url, options)
    if response["status_code"] == 200:
        return response["body"].decode("utf-8")
    print(f"Request failed: {response['status_code']}")
    return None

if __name__ == "__main__":
    listing_url = "https://www.reddit.com/t/technology/"
    html = crawl(listing_url)
    print(html[:500] if html else "No HTML returned")

两个等待选项对于客户端渲染的目标至关重要。ajax_wait 告诉 API 等待异步内容完成加载,page_wait 在加载后等待固定的毫秒数,以便在捕获页面之前让延迟渲染的帖子出现。五秒是合理的起点;如果列表返回为空,可以适当增加。运行脚本,你应该会看到真实的列表标记,这在你编写任何选择器之前确认了渲染正常工作。

Crawlbase Reddit Scraper

列表之所以能填充完整,是因为页面在一次调用中通过可信 IP 完成了渲染。Crawling API 接收 JS token,在真实浏览器中运行页面,在服务端轮换住宅 IP,并将完整 HTML 返还给你,省去了自行运行无头浏览器集群和代理池的麻烦。先在免费套餐上试试公开的子版块。

步骤 2:解析公开帖子字段

拿到渲染后的 HTML 后,将其加载到 BeautifulSoup 中,从每篇帖子中提取公开字段。Reddit 的列表标记将每篇帖子包装在 shreddit-post 自定义元素内,有用的值存放在该元素的属性中,而非深层嵌套的、频繁更名的 CSS 类中。你需要的属性是 post-titlescorecomment-countsubreddit-prefixed-namepermalink。读取属性远比追踪渲染组件更为稳定可靠。

python
from bs4 import BeautifulSoup

BASE = "https://www.reddit.com"

def to_int(value):
    try:
        return int(value)
    except (TypeError, ValueError):
        return None

def scrape_listing(html):
    soup = BeautifulSoup(html, "html.parser")
    posts = []
    for post in soup.select("shreddit-post"):
        permalink = post.get("permalink", "")
        link = f"{BASE}{permalink}" if permalink.startswith("/") else permalink
        posts.append({
            "title": post.get("post-title"),
            "score": to_int(post.get("score")),
            "comment_count": to_int(post.get("comment-count")),
            "subreddit": post.get("subreddit-prefixed-name"),
            "link": link,
        })
    return posts

每篇帖子都成为包含公开聚合字段的扁平记录。scorecomment-count 属性以字符串形式返回,因此 to_int 将它们强制转换为数字,并在属性缺失时返回 None,而不是中断运行。permalink 属性是类似 /r/technology/comments/<id>/<slug>/ 的站点相对路径,因此我们将其拼接到基础主机上以获得完整链接。注意此记录中任何地方都没有 author 字段,这是有意为之的设计。

选择器会发生变化

Reddit 会在不通知的情况下更改其标记,这正是此代码读取 shreddit-post 元素属性而非脆弱嵌套类的原因。如果某个字段返回 None,请在浏览器开发者工具中重新检查实时页面并更新属性名称。定期维护是任何生产爬虫的正常操作,而非出了问题的信号。关于选择稳健选择器,请参阅如何爬取网站而不被封锁

步骤 3:结合分页整合代码

单页列表只显示第一批帖子。Reddit 的旧版渲染友好列表端点接受 after token 用于翻页,但通过 Crawling API 对 JS 渲染列表进行翻页,最可靠的方法是请求列表的 .json 伴侣端点,该端点是公开的,返回相同的帖子加上 after 游标。这里我们保持简单,翻页公开子版块列表,收集固定数量的页面,并在请求之间暂停。

python
import json
import time
from crawlbase import CrawlingAPI
from bs4 import BeautifulSoup

crawlbase_token = "YOUR_CRAWLBASE_TOKEN"
api = CrawlingAPI({"token": crawlbase_token})
BASE = "https://www.reddit.com"

def crawl(page_url):
    options = {"ajax_wait": "true", "page_wait": 5000}
    response = api.get(page_url, options)
    if response["status_code"] == 200:
        return response["body"].decode("utf-8")
    print(f"Request failed: {response['status_code']}")
    return None

def scrape_subreddit(subreddit, max_pages=3):
    records = []
    after = None
    for _ in range(max_pages):
        url = f"{BASE}/r/{subreddit}/.json?limit=25"
        if after:
            url += f"&after={after}"
        body = crawl(url)
        if not body:
            break
        data = json.loads(body)["data"]
        for child in data["children"]:
            post = child["data"]
            records.append({
                "title": post.get("title"),
                "score": post.get("score"),
                "comment_count": post.get("num_comments"),
                "subreddit": f"r/{post.get('subreddit')}",
                "link": f"{BASE}{post.get('permalink', '')}",
            })
        after = data.get("after")
        if not after:
            break
        time.sleep(3)
    return records

if __name__ == "__main__":
    posts = scrape_subreddit("technology", max_pages=3)
    print(json.dumps(posts, indent=2, ensure_ascii=False))

公开的 .json 端点返回相同的聚合字段,且键名更清晰:titlescorenum_commentssubredditpermalink。每次响应中的 after 游标是分页所需的唯一状态,因此循环会持续请求下一页,直到 Reddit 停止返回游标或达到 max_pages 上限。time.sleep(3) 页间延迟不是装饰性代码:节奏是决定运行是否健康的最重要因素。如果你更倾向于解析渲染的 HTML,可以将 crawl(url) 换成步骤 1 中的列表 URL,并将 body 传入 scrape_listing

输出结果示例

运行脚本,你会得到一份整洁的公开聚合记录列表,可直接写入 JSON 或 CSV。

json
[
  {
    "title": "Researchers demo a swallowable device that tracks vital signs",
    "score": 8421,
    "comment_count": 312,
    "subreddit": "r/technology",
    "link": "https://www.reddit.com/r/technology/comments/17xmvmg/swallowable_device_tracking_vital_signs_inside/"
  }
]

若要持久化这些记录,几行标准库代码即可将列表转换为 CSV 或 JSON 文件。字段扁平且统一,无需特殊处理。

python
import csv
import json

def save_json(records, path="reddit_posts.json"):
    with open(path, "w", encoding="utf-8") as f:
        json.dump(records, f, indent=2, ensure_ascii=False)

def save_csv(records, path="reddit_posts.csv"):
    fields = ["title", "score", "comment_count", "subreddit", "link"]
    with open(path, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=fields)
        writer.writeheader()
        writer.writerows(records)

数据准备好后可用于分析:按中位分数对子版块排名、追踪社区链接最多的外部域名,或在若干页面窗口内绘制评论活动图表。如果你打算之后将数据输入模型,我们关于如何整理和清洗网络爬取数据供 AI 和 ML 使用的指南涵盖了规范化步骤,而机器学习的网络爬取则讲述了接下来该如何处理。

处理速率限制和错误

Reddit 运行可能遭遇两层限流,弹性脚本需要同时应对两种情况。Reddit 会对自动流量进行速率限制,并在请求过于频繁时返回 429 Too Many Requests403 Forbidden;Crawling API 本身也有按计划设定的限额。以下习惯可让运行保持在两者的范围之内。

  • 控制请求节奏。 页间的 time.sleep(3) 是下限,而非最大值。在紧密循环中频繁请求列表是最快遭到限流的方式,因此应增加真实延迟,避免激进的并行化。
  • 读懂状态码。 如果运行开始返回 429403,说明当前速率已不再被允许。应退后而非继续加压,并考虑指数退避加若干次重试。
  • 依赖轮换。 一组住宅 IP 将请求分散到众多真实用户地址上,使任何单一 IP 都不会触发限制。Crawling API 已为你处理这些;如果自建方案,这一环节是关键。
  • 保持低请求量和多样化目标。 公开数据研究不需要爬取子版块的完整历史记录。按需采样所需页面后停止。

抓取 Reddit 合法吗?

这一节是在编写生产代码之前必须阅读的部分。Reddit 的用户协议及其公开内容政策限制了自动访问和内容的批量收集,Reddit 的 robots.txt 也规定了爬虫可以访问哪些内容。无论你的工具多么谨慎,自动爬取都可能与这些条款相违背,而以上代码都无法改变这一点。代码只是让技术部分可行。在开始采集之前,请先阅读 Reddit 的条款和 robots.txt,并将两者视为你采集内容的边界。

以下是应当遵守的诚实且严格的规则。仅收集公开聚合数据:帖子标题、分数、评论数、所属子版块和链接,这些都是任何人无需登录即可看到的内容。将用户名、作者身份、个人资料信息及个人评论文本视为个人数据,不要采集,不要建立可识别个人的画像,也不要将内容与个人关联。永远不要爬取私人子版块、需要登录的内容、私信,或任何需要身份验证才能访问的内容,也不要绕过登录或验证来访问这些内容。当涉及个人数据时,GDPR 和 CCPA 等隐私法律适用:你需要合法依据来处理数据,并且必须响应删除请求。本指南中的爬虫刻意保持在所有这些界限的聚合、非个人数据一侧。

对于任何实际或商业用途,正确的工具是官方 Reddit API。它专为经授权的访问而构建,提供有保障的数据结构,公布明确的速率限制,并确保你遵守 Reddit 的服务条款。本文是一个严格限定在公开聚合列表数据范围内的技术演示,不是对大规模个人数据收集的认可,也不涵盖任何登录后才能访问的内容。如果你的项目需要的不仅仅是少量公开字段的样本,Reddit API 或正式数据协议才是正确路径,而非更聪明的爬虫。

回顾

核心要点

  • Reddit 列表是客户端渲染且有反爬虫保护的。 普通请求返回的只是一个外壳或验证页面,因此在解析之前必须先渲染页面。
  • 渲染和可信 IP 需要在同一次调用中完成。 使用 JS token 的 Crawling API 可同时完成两者;ajax_waitpage_wait 控制等待内容加载的时长。
  • 解析稳定的信号。 shreddit-post 属性(或公开的 .json 端点键名)比脆弱的嵌套类更耐久。
  • 仅限聚合公开字段。 提取标题、分数、评论数、子版块和链接;不要采集用户名、作者资料或评论文本。
  • 控制节奏、使用轮换,并优先使用官方 API。 保持低请求量,依赖住宅 IP 轮换,并对任何实际或商业用途使用 Reddit API。

常见问题

为什么普通请求从 Reddit 得不到数据?

因为 Reddit 当前的前端使用 JavaScript 在客户端渲染帖子列表,并且它会对自动流量发起验证。原始 HTTP 请求返回的是近乎空白的外壳、Cookie 拦截页或封锁页面。要获取真实的公开数据,必须先渲染页面,而这正是 Crawling API 的 JS token 为你处理的工作。

Reddit 需要普通 token 还是 JS token?

渲染后的列表页面需要 JS token,因为普通 token 返回的与普通请求一样只是一个外壳。如果你改用公开的 .json 端点翻页,该响应是纯 JSON,但通过 Crawling API 路由仍然受益于可信 IP 和轮换,使运行不会被封锁。

哪些 Reddit 数据可以安全抓取?

仅限公开聚合数据:帖子标题、分数和点赞数、评论数、子版块,以及每篇帖子指向的链接。用户名、作者资料以及个人评论文本是个人数据,在此范围之外。私人子版块、需要登录的内容和私信完全不在范围之内。

我应该使用官方 Reddit API 还是爬取网站?

对于任何实际、持续或商业用途,请使用官方 Reddit API。这是经过授权的途径,提供有保障的数据结构,并公布明确的速率限制。在没有 API 访问权限且仅需少量公开列表字段的轻量级公开数据研究场景下,这里介绍的方法才适合使用,但前提是你要遵守 Reddit 的服务条款、robots.txt 和速率限制。

如何处理多页分页?

请求公开列表的 .json 端点时带上 limit,从每次响应中读取 after 游标,然后在下一个请求中以 &after=<cursor> 形式传回。循环直到 Reddit 停止返回游标或达到你的页数上限,并在页面之间暂停几秒,以保持在速率限制之内。

如何避免爬取 Reddit 时被封锁?

保持低请求频率,在页面之间添加真实延迟,变换目标而非爬取某个子版块的完整历史,并通过轮换住宅 IP 路由,确保没有单一地址触发限制。Crawling API 为你管理轮换和可信 IP 池。留意 429403 响应,一旦出现立即退后。

开始构建

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

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

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