把推特数据导出成 CSV:能拿到什么,以及文件里的两个坑

16 min readSocialAPI 工程团队

把推特数据导出成 CSV:能拿到什么,以及文件里的两个坑

"导出推特数据"其实是两个不同的问题,而人们通常低估第二个。

把数据取出来。 走哪条路,以及能有多完整。

写出一个能正确打开的文件。导出真正出问题的地方是这里★ —— 而且它是静默失败的:文件看起来没事,直到有人用 Excel 打开,发现列全错了。

两部分这篇都讲。后半部分几乎没人写。


按目标看,你到底能导出什么

你自己的推文 —— 完整

设置 → 你的账号 → 下载你的数据存档。

这是唯一真正完整的导出。★ 从你注册那天起的每一条推文、回复、私信和点赞。等几小时到一两天,拿到一个 ZIP,里面有可浏览的 HTML 和原始 JSON。

如果你要导的是自己的历史,别往下读了,直接去申请这个。★ 在完整性上没有任何东西比得过它,而且免费。

它的局限: 它是快照。明天发的推文需要重新申请一次。

对别人的数据,不存在等价物 —— 那正是接口填补的缺口,但要注意下面说的完整性限制。

别人的推文 —— 部分,而且有上限

对你不拥有的账号,不存在存档申请。 你只能翻他的时间线,导出你够得到的部分。

⚠️ ★你无法导出某人的完整历史。★ X 的索引没法无限往回,读它的任何东西也一样。预期是几个月,不是几年 —— 见现实中能够到多远

粉丝与关注列表 —— 完整但昂贵

分页的,所以大号意味着很多次请求。一个 50 万粉丝的账号是 5,000 页。技术上没问题,商业上有影响 —— 翻页量正是成本的来源

搜索结果 —— 受结果上限限制

⚠️ 搜索导出受每条查询的上限限制,而不是受"有多少条匹配"限制。 用一条查询导六个月,你只会拿到其中一小部分。要么把区间分块,要么你的导出就是静默残缺的。


怎么正确地写出文件

下面是真正会咬人的部分。 这两个不是理论问题 —— 它们是让一次导出看起来成功、实际是错的那两个 bug。

★坑一:公式注入★

CSV 里以 =+-@ 开头的单元格,会被 Excel 和大多数表格软件当作公式执行

推文正文是任意的用户输入。 有人可以把显示名或正文写成以 = 开头,于是你的导出文件变成一个打开就执行它的表格。⚠️ 一个名叫 =HYPERLINK("http://evil","click") 的代币出现在你的导出里,意味着打开文件的人会看到一个活链接,而它看起来像是你自己的数据。

修复只有一行: 给以这些字符开头的单元格加一个单引号前缀。

★坑二:编码★

把 CSV 写成纯 UTF-8,然后在 Windows 的 Excel 里打开 —— 每一个非英文字符都会变成乱码。中文、日文、阿拉伯文、带重音的欧洲文字,全都会。

修复: 写入 UTF-8 BOM。Excel 用它来判断编码。没有它,Excel 会去猜系统代码页,而且会猜错。

这两个 bug 有一个共同属性:导出过程是成功的。★(取数那一半是一个分页调用;这一半完全是你自己的活。) 什么错都不报。你是在别人打开文件时才知道的,而那通常已经在你把文件发给他之后。

正确的做法

import csv, requests

BASE = "https://api.socialapi.tech"
KEY  = "your_api_key"
HDRS = {"X-API-Key": KEY}

DANGEROUS = ("=", "+", "-", "@")

def safe(value):
    """中和公式注入。推文正文是不可信输入。"""
    s = "" if value is None else str(value)
    return "'" + s if s.startswith(DANGEROUS) else s

def export_posts(username, path, pages=10):
    rows, cursor = [], None
    for _ in range(pages):
        params = {"username": username, "limit": 100}
        if cursor:
            params["cursor"] = cursor
        r = requests.get(f"{BASE}/v1/user/last_tweets",
                         params=params, headers=HDRS, timeout=60)
        r.raise_for_status()
        body = r.json()
        batch = body["data"]
        if not batch:
            break
        rows.extend(batch)
        cursor = body.get("meta", {}).get("next_cursor")
        if not cursor:
            break                      # ★唯一正确的停止条件★

    # utf-8-sig 会写入 Excel 需要的 BOM
    with open(path, "w", newline="", encoding="utf-8-sig") as f:
        w = csv.writer(f, quoting=csv.QUOTE_ALL)
        w.writerow(["id","created_at","text","likes","reposts",
                    "replies","quotes","views","url"])
        for p in rows:
            w.writerow([
                safe(p["id"]), safe(p["created_at"]), safe(p["text"]),
                p.get("like_count",0), p.get("retweet_count",0),
                p.get("reply_count",0), p.get("quote_count",0),
                p.get("view_count") or 0,
                safe(f"https://x.com/{username}/status/{p['id']}"),
            ])
    return len(rows)

print(f"导出了 {export_posts('nasa', 'nasa.csv')} 条")

这里有三处在真正干活:

  • utf-8-sig★ 写入 BOM。一个参数,决定了文件是可读还是乱码。
  • QUOTE_ALL★ —— 推文正文里经常有逗号、引号和换行。不加引号的字段会在第一个逗号处把表格撕开
  • 每个文本字段都过 safe()★,作用在上,不是表头上。

⚠️ 不要因为"我的数据是干净的"就跳过 QUOTE_ALL 推文正文是任意的 —— 一条含逗号的推文会裂成两列,并把那一行后面的每个字段都错位


选什么格式

格式 适合 要注意
CSV 表格、发给不写代码的人 上面那两个坑。不支持嵌套数据
JSON 喂给另一个系统、保留结构 不适合表格软件
JSONL 大批量导出、持续追加 同上,外加一行一个对象
Excel (.xlsx) 需要格式化 要用库;★以文本写入时天然免疫公式注入★

任何周期性的导出,JSONL 都胜过 CSV★ —— 它能干净地追加、能在中断后幸存,而且没有转义问题只在需要人来读的时候再转成 CSV。


大家会问的问题

怎么导出我的推文? 设置 → 你的账号 → 下载你的数据存档。完整、免费,而且是唯一完整的选项。

怎么下载某个用户的全部推文? 翻他的时间线并写出结果。你能拿到的是索引里还留着的部分,通常是几个月而不是几年。

能把推文导到 Excel 吗? 能 —— 写 CSV 时带 UTF-8 BOM,或者直接写 .xlsx没有 BOM,非英文内容一定乱码。

怎么导出我的推特粉丝? 翻粉丝列表并逐条写出。大号需要很多次请求。

能导出推特列表吗? 列表成员和其它账号列表一样可读,所以同一套"翻页+写出"的做法适用

怎么导出搜索结果? 跑搜索、翻页、写出 —— 但要把时间区间分块,否则上限会静默截断你的导出

该用什么格式导? 给人看用 CSV,给系统用 JSON 或 JSONL。持续追加的场景用 JSONL。

我的 CSV 为什么显示乱码? 缺 UTF-8 BOM。utf-8-sig 写入,Excel 就能正确读取。

我的表格为什么执行了推文里的公式? 公式注入 —— 以 =+-@ 开头的单元格会被执行。写入时给这类单元格加单引号前缀。

我的 CSV 列为什么错位了? 字段含逗号却没加引号。QUOTE_ALL;推文正文里逗号非常常见。

一次能导出多少条推文? 是分页的,所以问题是你愿意取多少页,而不是单次请求的硬上限。

能导出已删除的推文吗? 只有你在删除之前捕获过才行 —— 见什么能找回

怎么导出推特分析数据? 自己的从分析界面导。跨账号的公开互动数据要从导出的推文里自己算 —— 各指标是什么意思

能定时自动导出吗? X 界面里不能。用代码的话,定时跑你的导出并追加。

怎么导出私信? 只能通过你自己的数据存档。 它们是私密的,没有其它途径。

数据存档里有什么? 推文、回复、私信、点赞和账号历史 —— 可浏览的 HTML 加原始 JSON。要做处理的话 JSON 才是有用的那部分。

存档要等多久? 通常几小时,有时一两天。准备好了 X 会发邮件。

能导出别人的粉丝吗? 公开账号可以 —— 列表是公开的,而且是分页的

怎么按时间范围导出推文?since:until:,并且把区间切成小到不会撞上限的窗口

我的导出为什么少了推文? 两个常见原因:★在"短页"处停止翻页而不是在游标为空时停★,或者时间区间撞上了结果上限。两者都是静默截断。

能批量导出很多账号吗? 迭代就行。成本随总页数增长,不随账号个数增长。

导出推特数据被允许吗? 读取公开数据是被广泛实践的。但无论你从哪获取,再分发都受 X 条款限制 —— 见合规讨论

怎么导出推特关注列表? 对 followings 端点翻页,把每个账号写进 CSV。一份大列表是很多页,不是一个调用★ —— 要按游标翻页来规划。

怎么导出我的粉丝列表? 对 followers 端点用同样的模式。 资料页上看到的数字就是你该预期的行数,减去期间已注销的账号。

能导出别人的粉丝列表吗?公开账号可以★ —— 那份列表本来就是公开的。受保护账号不行。

怎么导出推特帖子? 对账号时间线翻页并序列化。 ★真正的约束是存档深度★ —— 能往回翻多远

怎么从推特抓数据? 要么驱动一个浏览器并持续维护它,要么调接口。 完整的取舍

有推特抓取接口吗? 一个读取接口实际上就是。 ★区别在于:一个会在页面标记变化时崩掉,另一个不会。★

导出一个大账号最好的方式是什么?按游标翻页、边走边记录检查点、失败后从游标续跑★,而不是从头重来。

能直接导成 CSV 吗? 能 —— 响应是 JSON,转 CSV 是本地变换。★如果文件要用 Excel 打开,记得加 UTF-8 BOM★,否则非拉丁文本会乱码。

CSV 里以 = 开头的字段要注意吗?要 —— 前面加一个单引号。显示名是任意文本,而一个以 = 开头的名字在表格软件里打开时会作为公式执行

怎么导出搜索结果? 对搜索端点用同样的翻页方式。 ★搜索能往回覆盖的范围比用户时间线更浅。★

能定时重复导出吗? 能,而且★对任何你可能会失去的东西,都应该这么做★ —— 删除之后是无法追回的

一个账号的完整导出有多大? 作为 JSON 大约每条帖子几百字节。 几万条帖子仍然是个小文件。

导出包含媒体文件吗?是 URL,不是二进制文件本身。★ 下载文件是你自己控制的另一个步骤。

能导出互动计数吗? 能 —— 每条帖子上都带,★但它们是抓取那一刻的快照,不是一份历史★。

怎么从一个已注销的账号导出数据?做不到。★ 注销会移除账号及其帖子 —— 什么会留下

该用什么格式归档?按行分隔的 JSON(NDJSON)。追加成本低、能扛住写入中断、之后转成任何格式都容易。


如果你要做导出功能

取数那部分就是一个翻页循环。★决定别人信不信任你导出的,是写文件那部分★ —— BOM、引号、注入转义,这三样全都是静默失败的

还有一个常坑住团队的:★要翻到游标为空,绝不能翻到"这页看起来短"就停。列表中间出现短页是正常的,在那里停会产生一个缺数据的导出,而且没有任何错误来解释它

我们的 API 覆盖取数这一段 —— 对时间线、粉丝和搜索做基于游标的翻页,每条推文都带完整互动数据,所以你的导出不必为每一行再查一次。只读、固定按次价格、没有属于你的限流要管。

延伸阅读:一个账号的历史能够到多远 · 为什么时间区间会截断 · 正确导出粉丝列表 · 翻页的成本