snscrape 停在 2023 年,twscrape 用一个 SQLite 账号池接住了 X 的爬取

发布于 2026年07月24日 11:12 #Github 解读#Agent 抓取 原文链接

snscrape 停在 2023 年,twscrape 用一个 SQLite 账号池接住了 X 的爬取 封面图
  • snscrape 因 X 取消匿名访问停更,twscrape 用账号池管理登录态接替其功能
  • twscrape 使用 SQLite 账号池,通过原子更新锁避免竞态,实现账号轮换和限流管理
  • twscrape 的 _check_rep 方法根据状态码区分限流与封号,做出不同处置避免误封
  • twscrape 逆向 X 的 x-client-transaction-id 签名算法,持续追踪前端构建模拟浏览器
  • twscrape 采用“脆弱优先”架构,承认依赖持续变化,将工程重心放在快速恢复

2023 年 11 月,JustAnotherArchivist 给 snscrape 推了最后一次 commit。这个曾经被几乎所有社交媒体爬虫教程奉为标杆的项目,5400 多颗 star 停在原地,issue 区开始堆满「还能用吗」「为什么不更新」。原因不复杂,X 砍掉了匿名访问的搜索接口,没有登录态的爬虫集体哑火。

snscrape 的设计建立在「不用账号也能查」的前提上,前提没了,整个上层建筑就空了。这时候你需要一个新的思路,不是去绕登录,而是直接把登录态当成一等公民来管理。vladkens 的 twscrape 走的就是这条路,2023 年 5 月立项,到今天 2613 颗 star,三天前刚发了 v0.19.2。

你想想看,同样是爬 X,snscrape 的失败不是代码写得差,是它押错了前提。twscrape 的核心赌注只有一句话,既然 X 只让登录用户查,那就把一堆登录账号管成一个池子,谁被限流了换下一个。听起来简单,真正难的是这背后三件事,账号怎么轮、限流怎么认、X 的反爬怎么破。这三件事的处理方式,决定了它是真能用还是又一个玩具。

账号池,不是账号列表

很多人写多账号爬虫的第一反应是,准备一个账号数组,for 循环里挨个试,失败了 catch 一下继续。这种写法在 demo 阶段没问题,一上量就崩。崩在哪,并发。十个协程同时跑,你怎么保证它们不会同时抢同一个账号,怎么知道某个账号此刻是不是已经被 X 限流了。

twscrape 的答案是把状态全部沉进 SQLite。accounts_pool.py 里每个账号一行,字段里最关键的是两个 JSON 列,locksstatslocks 记录这个账号在各个操作队列上被锁到什么时候,比如 {"SearchTimeline": "2026-07-24T10:00:00Z"},stats 记录每个队列上已经发过多少次请求。

真正精妙的是取账号这条 SQL。get_for_queue 的查询条件是,active = truelocks 里对这个队列要么没有锁,要么锁已经过期,然后 LIMIT 1。配合 _get_and_lock 的原子更新,它用了 SQLite 3.35+ 的 UPDATE ... RETURNING 语法,在一条语句里既选中文档又给它打上锁,避免了「先 SELECT 再 UPDATE」中间的竞态窗口。老版本 SQLite 没有这个语法,代码里还有一套用 _tx 字段做临时标记的降级方案兜底。

这套设计的隐含哲学很值得玩味。它没有上 Redis,没有上任何中间件,一个本地 accounts.db 文件就是全部状态。你可能会觉得这太朴素了,但你去看 issue 区,有人问怎么监控 1400 个账号的实时状态,作者的回复也是围绕这个 SQLite 文件做查询。对于一个「跑在你自己机器上、用你自己账号」的爬虫来说,单文件数据库是最诚实的选择,部署成本为零,备份就是 copy 一个文件。

限流和封号,是两回事

把账号管起来只是第一步,真正考验工程的是,X 怎么告诉你账号出问题了。这里的难点在于,X 不会直接说「你被封了」,它只会用各种 HTTP 状态码和错误码组合来暗示。twscrape 的 queue_client.py 里有一段 _check_rep 方法,专门干这个翻译工作,我读下来觉得这是整个项目最见功力的地方。

它区分了至少五种不同的「账号有问题」情况,每种处理方式都不一样。

第一种,真正的限流。响应头里 x-rate-limit-remaining 归零了,同时 x-rate-limit-reset 给了恢复时间戳。这种情况账号没坏,只是用满了额度,twscrape 调用 lock_until 把它锁到 reset 时间,然后换下一个账号继续。

第二种,封号伪装成限流。错误码 (88) Rate limit exceeded 但响应头里 remaining 还大于 0。这个矛盾很关键,真的限流 remaining 一定是 0,出现这种不一致说明 X 在用限流的话术掩盖封禁。twscrape 直接把账号标记为 inactive,不再使用。

第三种,权限拒绝,错误码 (326) Authorization: Denied by access control。同样是标记 inactive。第四种,认证失败 (32) Could not authenticate you,session 过期了,也是 inactive。第五种,裸 403 但响应体里没有明确错误信息,这种情况最暧昧,twscrape 也按封号处理,标记 inactive 且不写 error_msg。

你注意到了吗,封号是永久处置,限流是临时处置。这个区分不是拍脑袋来的,是作者读了大量 issue 才沉淀出来的规则。每一条规则后面大概率都有一批用户踩过坑、报过 issue。比如那条 (88) 但 remaining>0 的判断,没有真跑过几万次请求的人,很难想到 X 会用这种话术。

这种「把模糊的服务端行为翻译成明确的机器状态」的能力,才是爬虫框架的真正壁垒。新出的项目很容易把这块写成「报错就重试」,一上线全封号。

把前面这几层合在一起看,一条请求从你调 api.search() 到抵达 X 的 GraphQL,要穿过五层。下图把这条链路画清楚了,中间高亮的调度层就是刚说的「翻译官」。

twscrape 请求生命周期架构图
twscrape 请求生命周期架构图

你能看到,账号池和 HTTP 传输层都只是配角,真正决定这个框架「能不能扛住 X 的风控」的,是中间那层 QueueClient 的错误分诊逻辑,以及下面认证签名层对 x-client-transaction-id 的持续逆向。接下来这第二件事,才是整个项目最硬的一块骨头。

最硬的一块骨头,x-client-transaction-id

到这里为止,前面讲的都还是工程层面的功夫。但 twscrape 真正让我觉得硬核的,是它逆向了 X 的请求签名。

2024 年某个时候,X 给 GraphQL 接口加了一道校验,每个请求必须带一个 x-client-transaction-id 头,值是一个动态算出来的 token。算错了直接 404,不是 403,不是 429,是 404。这个 404 非常阴险,你根本不知道是请求的路径不存在,还是签名没过。twscrape 的 issue #248 就是专门追这个问题的,38 条讨论,是整个项目评论区最热的 issue 之一。

破解它的是 xclid.py 这个 378 行的文件。作者在注释里老实地承认,算法主要参考了 iSarabjitDhiman 的 XClientTransaction 项目,并贴了三篇 antibot.blog 的分析文章。逆向的成果是这样的,这个签名依赖三个输入,X 主页 HTML 里的 twitter-site-verification meta 标签,一个隐藏在动态加载的 sign.o-*.js 脚本里的动画索引数组,以及那个加载页的 SVG 动画路径数据。

读这段代码你会真切感受到反爬对抗的猫鼠游戏有多残酷。作者要先用 BeautifulSoup 去解析 X 那个加载动画,从 svg[id^='loading-x-anim'] 里提取 path 的 d 属性,这套 SVG 动画本来是给用户看的 loading 效果,却被 X 偷偷塞进了签名计算的原料。然后要算一条三次贝塞尔曲线的插值,Cubic 类的 get_value 用二分法逼近,最后把颜色和旋转矩阵拼成一个 hex 字符串,再和 verification key、时间戳一起喂进 SHA256。

XClIdGen.calc 方法里有几行尤其值得看。payload 拼接是 METHOD!path!timestamp!obfiowerehiring!anim_key,这个 obfiowerehiring 是 X 前端代码里硬编码的默认关键词,drn=3 是默认随机数。最后还要做一层异或混淆,用 0 到 255 的随机数 XOR 整个 payload 再 base64 编码。整套流程的目的只有一个,让 twscrape 发出的请求,在签名层面看起来和真实浏览器一模一样。

更麻烦的是这套签名不是静态的。X 每次改前端构建,script bundle 的命名规则就变,原来直接在页面里能找到的脚本现在变成动态 import。xclid.py 里有两套正则,一套匹配老版本 webpack 构建,一套匹配新的 Vite x-web 构建,_find_indices_url 还要并发去扫 16 个 chunk 文件才能定位到那个签名脚本。changelog 里 v0.19.0、v0.19.2 连着两次修这个 ID 生成,说明 X 这边一直在动,twscrape 这边一直在追。

你能感受到这种维护成本有多高吗。这不是写一次就完事的代码,是每隔几周 X 一更新构建,作者就得重新跑一遍逆向,更新 GraphQL operation ID,改正则。这大概也解释了为什么这个项目 bus factor 这么危险,vladkens 一个人 233 次提交,排第二的贡献者只有 6 次。能干这种持续逆向活的人本来就少,愿意长期免费维护的更少。

登录这件事,被它做成了状态机

既然要账号,就得登录。twscrape 支持两种入号方式,直接喂 cookie,或者用户名密码走登录流程。

cookie 方式是推荐路径,README 里明说「最稳定的设置」。只要你的 cookie 里有 auth_tokenct0,账号立刻激活,不需要 login_accountshas_required_cookies 这个函数就是干这个判断的,缺了这俩 cookie 直接标 inactive。说实话这也是务实的做法,自己手动登录一次把 cookie 抠出来,比让脚本去模拟登录流程稳定十倍,X 的登录流程本身就是反爬重灾区。

但你非要走脚本登录,twscrape 也实现了完整的 flow。login.py 把 X 的登录做成了一个状态机。它不是写死「先输入用户名,再输密码」的固定流程,而是每次请求后从响应的 subtasks 数组里读取下一步该干嘛。next_login_task 里那个 dispatch 很有意思,它要处理的子任务包括 LoginJsInstrumentationSubtaskLoginEnterUserIdentifierSSOLoginEnterPasswordAccountDuplicationCheckLoginAcid 邮箱验证、LoginTwoFactorAuthChallenge 双因子,甚至还有 LoginEnterAlternateIdentifierSubtask 这种账号撞车时的备用验证。

状态机的好处是 X 调整登录步骤顺序时,代码不用大改。X 可能今天让你先输密码明天突然插一个 JS 指纹校验,固定流程的脚本直接懵,状态机能根据实际返回的 subtask 见招拆招。

邮箱验证码这块尤其巧。imap.py 里维护了一个域名到 IMAP 服务器的映射表,yahoo、icloud、outlook、hotmail 都有专门处理,其他域名默认 imap.{域名}。登录流程触发 LoginAcid 时,imap_get_email_code 会去你邮箱里翻最近 30 秒内来自 info@x.com、主题包含 confirmation code is 的邮件,把验证码抠出来自动填回去。整个超时默认 30 秒,TWS_WAIT_EMAIL_CODE 可调。没有 IMAP 的邮箱也能用 --manual 手动输。

不过我必须诚实地说,login.py 第 272 行那条 assert 是个隐患,assert "ct0" in client.cookies, "ct0 not in cookies (most likely ip ban)"。这个断言信息直接点出了最常见的失败原因,你的 IP 被风控了。issue #214「ct0 not in cookies」29 条讨论排第二热,说明脚本登录这条路本身就很脆弱,X 对自动化登录的识别比接口请求更严。所以我的建议很明确,能用 cookie 就别折腾脚本登录,把登录这件事交给真实浏览器。

一些 README 不会主动告诉你的事

聊了这么多实现,有几个现实约束需要你提前知道,它们不在 README 的显眼位置,但会直接影响你能不能用起来。

第一,硬编码 Bearer Token。account.py 第 13 行那个 TOKEN 常量,是一串 X 网页端用的通用授权令牌,所有请求都带它。这个 token 是公开的,X 网页前端本来就用它,但你得知道 twscrape 的「伪装成浏览器」是建立在复用这个公开 token 之上的。哪天 X 把这个 token 收紧到每个账号独立授权,twscrape 这套就得重写。

第二,这个项目的商业气息比一般开源项目重一些。README 里插了一个 Swiftproxy 住宅代理的广告位,还给了 PROXY90 九折优惠码。另外有两处推荐号商和代理商的 kutt 短链,标注了是 referral link。这不是什么大问题,作者也要吃饭,但你要清楚,如果你用 twscrape 跑出问题,某些「推荐方案」背后是有商业动机的,不是纯粹的技术推荐。结合下面这点一起看会更清楚。

第三,默认开启遥测。v0.19.0 加了匿名 telemetry,收集 GraphQL operation 名和 HTTP backend 类型,需要手动设 TWS_TELEMETRY=0 关掉。作者强调不收集用户名、cookie、代理、查询内容。这个说法我倾向相信,但你如果是给客户做项目、对数据外发敏感,这个开关要记得关。

第四,user_tweetsuser_tweets_and_replies 大约只能拉到 3200 条。这不是 twscrape 的锅,是 X 服务端硬限制,但你做需求评估时得把这个天花板算进去,别跟老板承诺能拉完某个大 V 的全部历史推文。

第五,认证依赖 cookie 的两个特定字段。一旦 X 调整 cookie 机制,或者你的账号被要求重新验证,这套就断了。issue #248、#214、#298 全在反复提醒,这个项目的可用性高度依赖 X 当下的风控水位。

这个项目教会我的一件事

拆完 twscrape 我脑子里留下最深的,不是它的账号轮换算法,也不是那个逆向得漂亮的签名计算,而是它对「脆弱」这件事的接受态度。

snscrape 失败是因为它假装 X 不会变,把接口当稳定的基建来用。twscrape 从第一天就知道 X 一定会变,所以它把全部精力放在「变了之后怎么快速恢复」上。GraphQL operation ID 是会被改的,所以它写了 scripts/update_gql_ops.py 一键更新。签名算法是会被换的,所以 xclid.py 里同时挂着新旧两套正则。账号是会被封的,所以整个 pool 的设计都在围绕「封了之后怎么优雅降级」。

这是一种我愿意叫它**「脆弱优先」的架构思维**。它不追求一个永久的正确解,而是承认核心依赖每天都在腐烂,把工程重心从「写对一次」挪到「能持续修」。它的 _check_rep 那 100 多行错误分支,它的双正则兼容,xclid 的持续追赶,全是这种思维的产物。

这种思维其实不止适用于爬虫。你做任何依赖第三方平台、依赖 LLM API、依赖别人 schema 的系统,底层都在 rot。区别只在于你是假装它不会变,还是像 twscrape 这样,把「追着变化跑」当成架构本身的一部分。

当然,代价是真实的。这种项目维护起来极耗心神,一个人扛 233 次 commit,v0.19 系列光修 x-client-transaction-id 就修了三轮。twscrape 能活多久,取决于 vladkens 愿意追多久。但至少在今天,在 snscrape 沉默了两年多之后,它是 X 爬取这个生态里你能找到的最接近「一直在更新」的答案。这份坚持本身,比代码更值得记住。

评论互动

© 2026 王若风的技术博客 · Powered by Astro