用 Python 获取 Reddit 帖子评论:解析嵌套回复并导出 CSV

发布于 2026年10月15日 · 更新于2026年10月16日

真正决定「这是一条评论还是一场对话」的字段叫 replies。post-details 返回的每个节点都可以再挂一组节点,子节点还能再挂。产品同学常把导出当成「这一楼的全部发言」;接口给的其实是当时拿到的那棵树。带 depth 的 CSV 只是它的工作副本,不是楼里每一条回复的公证档案。

把森林拉下来只需要一个查询参数:公开讨论的完整 url。打开本页或复制示例不会发请求,只有你拿自己的 Key 跑起来才会。

评论是树,不是列表

平台信封是 {code, message, data}。HTTP 2xx 只说明字节到了;信封 code 为 200 只说明平台受理了。两者都不是评论树。进 data 之后先看 meta.status,不是 200 就停,别把错误体摊成假评论。

可用载荷里,body.post 是主帖:id、title、author、subreddit、selftext、created_utc、score、ups、upvote_ratio、num_comments、permalink、url。selftext 是发帖正文,可能空(外链帖常见)。body.post_comments 是顶层评论数组,不是整棵树的节点总数。数组长度写成「评论数」会把整串对线压成一条条互不相关的吐槽。

每个评论节点形状相同、可以嵌套:

  • id:当字符串留着,别让表格软件改成数字。
  • author:可能空,空就空着。
  • content:正文。楼层评论不用 body 这个键。
  • up_votes、score、created_utc:当时快照。嵌套层里这些值曾以字符串出现,加减或排序前先做类型归一。
  • replies:同形状节点的数组,或空数组。

节点上没有可靠的 parent_id。父子关系就是你走过来的路径:顶层父是帖子,嵌套父是把它装进 replies 的那条评论。同一条反对意见挂在功能建议下面,和另起一楼说同一句话,对产品复盘完全不是一回事。摊平时丢掉路径,表格就只剩一袋句子,分不清谁在回谁,也容易把回复条数当成独立用户数。

subreddit-comments 看起来像邻居,其实是另一份对象:正文在 body,带着 link_permalink、parent_id 这类列表字段,实测里 replies 是空字符串,不是树。两套解析不要混。

meta.totalComments 和帖子上的 num_comments 是报告口径。文档里那条 wallstreetbets 历史帖(p0esdp)曾报 meta.totalComments=194,同时只给出 76 个顶层对象,外加当时跟来的嵌套回复。三个数三种意思:报告总数、顶层长度、实际遍历节点数。哪个都不能当「楼已收齐」的证明。

用帖子 URL 把楼拉下来

登录后从控制台复制有权使用的 Key,放到服务端环境变量 OPENDATA_API_KEY。文中的 YOUR_API_KEY 只是占位。Key 走查询串,可能进访问日志,不要写进工单、截图和浏览器前端。这次只读请求不需要 Reddit Cookie 或登录会话。

url 必须是带 /r/<社区>/comments/<帖子 id>/ 的完整讨论链接。少一段、只丢社区名、或把子版块首页当讨论页,都对不上这棵树。子版块首页是另一类对象,属于 posts,返回的是帖子列表,不是回复树。文档示例:

https://www.reddit.com/r/wallstreetbets/comments/p0esdp/do_hedge_funds_beat_the_market_i_analyzed_the

公开示例不保证楼还在、字段还齐。手里没有讨论链接时,可以先从 posts 列表挑一条 permalink,再单独打 post-details。第二次调用仍然只有一个 url,不是评论翻页。

安装 requests,在服务端跑下面的脚本。传输失败、HTTP 非 2xx、信封 code 不是 200、meta.status 不是 200,是三道不同的停点。只有第一类才考虑重试。树比 totalComments 小、但结构完整,不是传输故障。

展开回复时别丢掉层深

深度优先:先产出当前行,再带着 depth + 1 和当前 id 作为父,走进 replies。replies 缺失或不是数组,就在这个节点停,不要编子节点,也不要默默跳过坏节点假装树还完整。content、author 空着就空着——删帖、屏蔽、取不到文本,都应原样留下,补「[deleted]」是在改证据。

能保住森林的列:post_id、comment_id、parent_id、depth、author、text、created_utc、source_post_url、observed_at_utc。depth=0 是直接回帖;depth=2 已经是「回复的回复」,复盘功能争议时这层往往才是真正的反对意见。投票字段如果要进表,当快照另列,并写上采集时间,不要当成发帖当时的分数。评论链接如果要写,用帖子 permalink 拼评论 id 自己拼,接口并不返回这条链接。

接口给的是 JSON,不是表格。有人会用 Excel 打开时,给看起来像公式的单元格(=、+、-、@)加前缀——评论里以等号开头的计算式并不少见。文件独占创建,不要覆盖昨天的快照;要比两次观察,就把旧文件归档。observed_at_utc 才是「我们看见这棵树」的时间。

import csv
import os
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urljoin

import requests

BASE = "https://api.opendata-api.com/api/project/reddit"
THREAD_URL = (
    "https://www.reddit.com/r/wallstreetbets/comments/p0esdp/"
    "do_hedge_funds_beat_the_market_i_analyzed_the"
)


def read_thread():
    key = os.environ.get("OPENDATA_API_KEY", "").strip()
    if not key:
        raise RuntimeError("Missing OPENDATA_API_KEY")
    try:
        response = requests.get(
            f"{BASE}/post-details",
            params={"key": key, "url": THREAD_URL},
            timeout=30,
        )
    except requests.RequestException:
        raise RuntimeError("Transport failure; inspect redacted logs") from None
    if not 200 <= response.status_code < 300:
        raise RuntimeError(f"HTTP failure: {response.status_code}")
    try:
        envelope = response.json()
    except ValueError:
        raise RuntimeError("Expected JSON") from None
    if not isinstance(envelope, dict) or envelope.get("code") != 200:
        raise RuntimeError("Gateway business failure")
    payload = envelope.get("data")
    if not isinstance(payload, dict):
        raise RuntimeError("Unexpected operation payload")
    meta = payload.get("meta")
    if not isinstance(meta, dict) or meta.get("status") != 200:
        raise RuntimeError("Operation failed or has an unexpected status")
    body = payload.get("body")
    if (
        not isinstance(body, dict)
        or not isinstance(body.get("post"), dict)
        or not isinstance(body.get("post_comments"), list)
    ):
        raise RuntimeError("Expected post details and a comments array")
    return payload


def walk(nodes, parent_id, depth):
    if not isinstance(nodes, list):
        raise RuntimeError("Expected a replies array")
    for node in nodes:
        if not isinstance(node, dict):
            raise RuntimeError("Expected a comment object")
        comment_id = str(node.get("id") or "")
        replies = node.get("replies", [])
        if replies is None:
            replies = []
        if not isinstance(replies, list):
            raise RuntimeError("Unexpected replies structure")
        yield {
            "comment_id": comment_id,
            "parent_id": parent_id,
            "depth": depth,
            "author": node.get("author"),
            "text": node.get("content"),
            "created_utc": node.get("created_utc"),
        }
        yield from walk(replies, comment_id, depth + 1)


def spreadsheet_cell(value):
    if isinstance(value, str) and value.lstrip().startswith(("=", "+", "-", "@")):
        return "'" + value
    return value


def export_thread():
    payload = read_thread()
    post = payload["body"]["post"]
    post_id = str(post.get("id") or "")
    post_url = urljoin("https://www.reddit.com", post.get("permalink") or THREAD_URL)
    observed_at = datetime.now(timezone.utc).isoformat()
    rows = list(walk(payload["body"]["post_comments"], post_id, 0))
    fields = [
        "post_id", "comment_id", "parent_id", "depth", "author", "text",
        "created_utc", "source_post_url", "observed_at_utc",
    ]
    output = Path("reddit_comments.csv")
    if output.exists():
        raise RuntimeError("Output already exists; archive it first")
    with output.open("x", encoding="utf-8-sig", newline="") as target:
        writer = csv.DictWriter(target, fieldnames=fields)
        writer.writeheader()
        for row in rows:
            row.update(
                post_id=post_id,
                source_post_url=post_url,
                observed_at_utc=observed_at,
            )
            writer.writerow({k: spreadsheet_cell(row.get(k)) for k in fields})
    print({
        "reported": payload["meta"].get("totalComments"),
        "top_level": len(payload["body"]["post_comments"]),
        "traversed": len(rows),
    })


if __name__ == "__main__":
    export_thread()

把三个计数打出来,分开记。不要相减去「算出」缺了几条。评论 ID 保持字符串。

CSV 不是完整讨论

不要把 HTTP 200、信封 code 200、meta.status 200 读成「评论收齐了」。这三层只说明请求跑过。覆盖范围就是你实际走完的那棵树。

不要用 totalComments 减行数,把差额标成已删除。你不知道缺席的原因。

不要给 post-details 传 cursor。posts、subreddit-comments 的列表游标扩不开这棵树。接口没有文档化的「补全缺失评论」参数。官方客户端里的 replace_more 一类能力,这里没有。

不要把摊平后的 CSV 当成对话本身。depth 和 parent_id 能在笔记本里把树拼回去,拼不回从未到达的回复。情感分、关键词告警、「有多少独立用户在抱怨」都是下游的事——回复条数也不是独立用户数。

不要混形状:楼里用 content 加 replies[],社区评论流用 body 加 link_permalink。不要发明关键词搜索接口,本项目没有声明。结构完整但偏小的树,不要当传输失败去重试。不要把平台 Key 放进要分享的笔记本,查询 URL 会把它漏出去。

这些行只适合做你已经选定的那一场讨论的带源证据。覆盖范围要写清楚:报告总数、顶层条数、实际遍历条数三列并排,比在摘要里写「已获取全部评论」诚实。需要更新就再拉一次快照,不要把一张 CSV 缓存成「就是这栋楼」。

字段级说明仍在 Reddit 数据接口 和 post-details。