snscrape 脚本突然不能用了?这是迁移方案

19 min readSocialAPI 工程团队

snscrape 脚本突然不工作了?这是迁移方案

跑了好几个月,你一行没动,现在它返回空列表,或者在库内部某处抛异常。你的代码什么都没改。

这是 snscrape 的标准故障模式。挑替代方案之前值得先搞懂它——因为如果你选错了类型,同样的事还会再发生一遍。

下面讲:它为什么会坏、现实的选项有哪些,以及几乎每个 snscrape 脚本都由之组成的三种写法要怎么逐行改。

只想要能跑的版本就直接跳到迁移部分。有个免费 demo 端点,不注册就能先对一下返回结构。


它为什么会坏,以及这不是维护者的错

snscrape 读的是 X 的内部网页端点——网站自己调的那套。那些端点从来就不是公开契约:GraphQL query ID 会轮换、必需的请求头会变、响应结构会在没有通知的情况下重组。

X 一次前端更新,可能同时打断三件事:

变的是什么 你看到什么
GraphQL query ID 轮换 昨天还好好的调用开始 404
必需请求头变了 401,或者 HTTP 200 但结果是空的
响应结构重组 解析器什么都没返回,也不报错

第三行是最危险的。 静默的空结果,看起来和"这个账号没有推文"一模一样。如果你的管道是定时跑的,表停止增长好几天都可能没人发现。

所以第一个决定不是"用哪个库",而是——X 改东西的时候,谁来兜底。 自己维护的爬虫,答案是你,在它半夜坏掉的那个点。


现实的选项

四个,老实说:

1. 继续打补丁(或者用某个 fork)。 免费、完全可控,而且一直能用——直到不能用。如果 X 数据只是业余项目的一路辅助输入、停几天无所谓,这完全合理。依赖某个 fork 前先看它的提交活跃度:废弃的 fork 和健康的长得一模一样,直到你需要修复的那一刻。

2. 官方 X API。 如果你需要写(发推、回复、关注、删除),这是唯一正当的路。读的话按条计费,免费额度小到大多数人测试阶段就用完了。采纳或否决之前,建议认真按自己的量算一遍。

3. 自己照着内部端点造。 你会把 snscrape 已经做过的事重做一遍:query ID、请求头构造、请求签名、翻页游标、解析。代码本身不难,维护才是全部成本,而且是持续性的。

4. 托管的只读 API。 别人替你吸收破坏,你调一个稳定接口。代价是付钱,以及失去自己动手修的能力。这正是我们做的事,所以下面这段请当作"有利益相关方写的"来读——不过迁移代码本身跟用谁无关。

如果你需要写 X,上面的只读选项一个都替代不了官方 API。 没有任何第三方只读服务能以你的身份发推,声称能的一定是在用某人的登录态。


迁移

几乎所有 snscrape 脚本都是三种写法之一。下面逐个给出改法。

写法 1:按关键词搜索

经典的 snscrape 循环:

import snscrape.modules.twitter as sntwitter

tweets = []
for i, t in enumerate(sntwitter.TwitterSearchScraper("bitcoin").get_items()):
    if i >= 100:
        break
    tweets.append({"date": t.date, "user": t.user.username, "content": t.rawContent})

改成用 requests 调数据 API:

import requests

API = "https://api.socialapi.tech/v1"
HEADERS = {"X-API-Key": "your_key"}

def search(query, limit=100):
    r = requests.get(f"{API}/search/advanced",
                     params={"query": query, "limit": limit},
                     headers=HEADERS, timeout=30)
    r.raise_for_status()
    return r.json().get("data", [])

tweets = [
    {"date": t["created_at"], "user": t["author"]["username"], "content": t["text"]}
    for t in search("bitcoin", 100)
]

字段对照表:

snscrape 替代写法 说明
t.date t["created_at"] 是字符串不是 datetime,用 "%a %b %d %H:%M:%S %z %Y" 解析
t.rawContent t["text"]
t.user.username t["author"]["username"] 作者内联嵌套,不用第二次请求
t.id t["id"] 是字符串不是 int——19 位会撑爆 JS 的数字
t.likeCount t["like_count"]
t.retweetCount t["retweet_count"]
t.replyCount t["reply_count"]
t.viewCount t["view_count"]
t.lang t["lang"]
t.sourceLabel t["source"]

完整的响应结构——每个字段、哪些会是 null、什么时候是——在推文 JSON 逐字段拆解里。

写法 2:某个用户的时间线

# snscrape
scraper = sntwitter.TwitterUserScraper("elonmusk")
items = list(itertools.islice(scraper.get_items(), 200))
# 替代
def user_tweets(username, limit=200):
    r = requests.get(f"{API}/user/last_tweets",
                     params={"username": username, "limit": limit},
                     headers=HEADERS, timeout=30)
    r.raise_for_status()
    return r.json().get("data", [])

items = user_tweets("elonmusk", 200)

写法 3:翻过第一页

大多数迁移是在这里出错的,因为 snscrape 的生成器把翻页完全藏起来了——你只管一直迭代就行。

# snscrape: 翻页是隐形的
for t in sntwitter.TwitterSearchScraper(q).get_items():
    ...   # 一直走到你自己 break

换成 HTTP API,翻页要显式用游标:

def search_all(query, want=1000, page=100):
    out, cursor = [], None
    while len(out) < want:
        params = {"query": query, "limit": min(page, want - len(out))}
        if cursor:
            params["cursor"] = cursor
        r = requests.get(f"{API}/search/advanced", params=params,
                         headers=HEADERS, timeout=30)
        r.raise_for_status()
        body = r.json()
        batch = body.get("data", [])
        if not batch:                    # 没有更多结果
            break
        out.extend(batch)
        cursor = (body.get("meta") or {}).get("next_cursor")
        if not cursor:                   # 没有下一页
            break
    return out

这里有两处必须写对: 批次为空和游标为空,两个都要 break——只判其中一个,结果要么是死循环要么是结果被截断。另外别拿"这页满了"当作"还有下一页",要看游标。

如果你是横向跨很多账号而不是纵向深挖一个,把它们合并成一次请求是另一种量级的节省——账在批量化 X API 请求里算过。

snscrape 没逼你写的错误处理

snscrape 抛的是库异常。HTTP API 给的是状态码,反而更好对症处理:

import time

def call(path, params, attempt=0):
    r = requests.get(f"{API}{path}", params=params, headers=HEADERS, timeout=30)
    if r.status_code == 429:                       # 被限流
        if attempt < 4:
            time.sleep(2 ** attempt)               # 指数退避
            return call(path, params, attempt + 1)
        raise RuntimeError("重试后仍被限流")
    if r.status_code == 401:
        raise RuntimeError("API key 不对")
    if r.status_code == 402:
        raise RuntimeError("额度用完了")
    r.raise_for_status()
    return r.json()

429 退避重试,401 和 402 直接失败。重试一个错的 key 纯属浪费时间,重试一个空余额永远不会成功。


你实际放弃了什么

这点值得说直白,因为取舍是真实的。

你放弃了自己动手修的能力。 用 snscrape,坏了很烦但归你管——你可以读源码、打补丁、继续走。用托管 API,你提工单然后等。如果这点对你的项目不可接受,选项 1 或 3 才是更好的选择。

你放弃了"免费"。 snscrape 只花你的时间。任何托管服务都要钱。问题只在于你的时间值不值那个账单。

你换来的是不会被它半夜叫醒。 整个卖点就这一句,说完了。


信任它之前先验证迁移

两边并行跑一天,然后对比——不是对比条数,是对比 ID:

old_ids = {t.id for t in snscrape_results}
new_ids = {int(t["id"]) for t in api_results}

print("只在 snscrape 里:", len(old_ids - new_ids))
print("只在新 API 里  :", len(new_ids - old_ids))
print("重叠           :", len(old_ids & new_ids))

不要期待完美重叠。 搜索结果本来每次拉都会有差异——排序会变、有删帖、实时结果在动。你要找的是系统性缺失:整个账号不见了,或者某个日期之前的一条都没有。

我们的 API 返回的就是本文通篇用的那套结构,所以迁移后的代码原样就能跑。用 Google 一键注册送 $0.20 额度、不用信用卡,足够你把上面那个对比跑完再决定。


常见问题

snscrape 死了吗? 代码还在,fork 也有人在用,但它读的是没有文档、随时会变的端点。请预期周期性损坏,并想清楚谁来修。"死了"这个说法不准确,更贴切的是"相对 X 的变化速度而言维护跟不上"。

snscrape 为什么突然就不能用了? 基本都是 X 那边变了:query ID 轮换(你看到 404)、请求头要求变了(401 或者静默返回空)、响应结构重组(解析器什么都不返回且不报错)。你的代码没变,它底下的契约变了。

snscrape 为什么返回空列表而不是报错? 因为 HTTP 请求本身成功了,只是解析没找到东西。端点可以返回 200,但结构是你的解析器不认识的。这是最危险的故障模式——它看起来和"确实没有结果"一模一样。

2026 年 snscrape 最好的替代是什么? 取决于维护责任该归谁。想要控制权、能承受停机就用自维护的 fork;需要写操作就用官方 API;想让破坏变成别人的问题就用托管只读 API。

没有 API key 还能抓推文吗? 公开数据不需要 X 的凭据就能读,这正是 snscrape 在做的事。第三方 API 需要的是它自己的 key,不是 X 的。不认证成用户就做不了的事是:写操作,以及读任何受保护的内容。

抓取推特合法吗? 读公开数据,和用别人的凭据做自动化、或访问私密内容,是不同性质的事。实践中的红线是:不用别人的凭据、不碰受保护内容、不要攻击级流量。数据拿到之后怎么用另有合规义务——这不构成法律意见。

TwitterSearchScraper 怎么替换? 换成一次搜索端点调用,查询词作为参数传。原来 get_items() 的循环要改成显式的游标翻页循环,因为生成器之前把翻页藏起来了。

TwitterUserScraper 怎么替换? 换成用户时间线端点,传用户名。要拉超过一页同样用游标翻页。

t.rawContent 对应什么? text 字段。t.date 对应 created_at(是推特自有格式的字符串,不是 datetime),t.user.username 对应嵌套 author 对象上的 author.username。

推文 ID 为什么现在是字符串? 它是 19 位的 snowflake,超出 JavaScript 的安全整数范围。snscrape 给你的是 Python int,在 Python 里没问题,但一旦进 JSON 或 JS 就会损坏。字符串是可移植的选择。

没有 snscrape 的生成器,怎么翻页? 当还想要更多时循环,把上一次响应的 cursor 传进去,批次为空和游标缺失两种情况都要 break。只判一种会导致结果截断或死循环。

snscrape 抓 Telegram、Reddit 还能用吗? snscrape 覆盖多个平台,各自的稳定性互相独立——X 模块坏了不代表别的也坏。本文只讲 X 这一侧。

有没有 drop-in 的替代库? 没有库级别的无缝替换,因为迁移的全部意义就是不再依赖无文档端点。不过迁移过程是机械的:三种调用写法加一张字段改名表,也就是这篇文章的内容。

替换 snscrape 要花多少钱? 按调用计费的话,你用多少付多少,所以诚实的答案是"看量"。算出你每月的调用次数乘一下——如果比你打补丁花的工时便宜,这个决定就不用做了。

迁移期间能并行跑 snscrape 和新 API 吗? 能,而且应该这么做。两边跑一天,对比推文 ID 而不是条数。不要期待完美重叠,要找的是系统性的缺口。

snscrape 和 tweepy 有什么区别? tweepy 是官方 API 的客户端,需要开发者凭据。snscrape 读公开网页端点,不需要凭据。访问模型完全不同,所以迁移路径也取决于你原来用的是哪个。

托管 API 会不会以同样的方式坏掉? 底层端点对所有人都会变,区别在于谁来修。托管服务那边修好了直接生效,你什么都不用做;自己的代码,半夜读 diff 的那个人是你。

迁移后怎么处理限流? 429 指数退避,401 和 402 不要重试——key 错了或余额空了,重试多少次都不会变好。更完整的讲法在限流到底意味着什么。

snscrape 停摆后还能拿到历史推文吗? 取决于替代方案。深度历史归档是所有来源里最难便宜拿到的东西——如果你的场景需要回补好几年而不是近期数据,请单独按这个需求去问价,别默认它包含在内。

迁移时数据库表结构要改吗? 通常只有 ID 那一列——如果你把推文 ID 存成了整数,改成字符串或 64 位类型。其余字段一一对应,名字变了含义没变。

不用官方 API 怎么抓推特数据? 要么自己跑抓取库、然后随着 X 变化一直维护它,要么调一个托管的只读 API 让别人维护。取舍是「维护负担」换「按请求付费」——不存在「没人承担坏掉的成本」这个选项。

能把一个人的所有推文都抓下来吗? 可以向后翻页,但不能无限翻——时间线有深度上限,换什么工具都一样。真要完整历史,得用按日期切片的搜索,而不是一次深挖时间线。

怎么按关键词抓而不是按账号抓? 换成搜索端点而不是时间线端点。翻页模式完全一样,只有查询参数不同。 值得先学会收窄查询的操作符——它削减数据量的效果比任何代码改动都大。

2026 年用 Python 抓推特还可行吗? 可行,但那些多年无人看管也能跑的库,现在会在 X 轮换内部实现时坏掉。变的不是可行性,是「谁来承担坏掉之后的修复」。

为什么我的爬虫昨天还好、今天就挂了? 几乎总是上游变了:轮换了 query ID、多了必需请求头、或者响应结构挪了位置。你的代码一行没改——这恰恰是这类故障持有成本高的原因。


迁移是几个小时的工作量,换来的是下次 X 再改东西时不需要你出现的代码。用 Google 注册送 $0.20 额度、不绑卡,够你把一个脚本移过来、拿输出和 snscrape 原来给你的对一遍。