数月前,我留意到一则 RSSHub 的 issue 留言,这位作者在留言中留下了自己利用 selenium 自带的 GCM 推送接收 Twitter 的最新消息的代码和思路,我看完以后大受震撼,决定自己也来研究一下……
Mozilla Autopush
绕了一圈我跑回来研究Mozilla Autopush
设置订阅
Twitter 的推送设置是在全端同步的,只需要在任意已登录的网页/客户端操作即可:关注对应的帐号后点旁边的小铃铛,如果是客户端就勾选所有推文和回复(All Tweets & Replies/All Posts & Replies),或者根据个人喜好选择。
为了减少其他不相关的推送导致脚本运行被干扰的情况,我建议到推送的设置(设定->通知->偏好设置->推送通知)里面关闭或者取消勾选除了你关注的人的帖子以外的所有选项
我猜测 Twitter 的订阅设备数有一个隐藏的上限,达到上限时会从前往后踢掉已有的设备,支撑这个猜想的是经过几次反复开关订阅以后我的脚本就彻底收不到推送了
而关闭/勾选选项可能需要反复开关推送开关,所以我把这一节放在前面
获取 uaid
初次连接到接收端点时,我们需要立刻发送一个json,否则连接会被关闭
请保护好这个 uaid
,任何拥有这个 uaid
的人都能接收和管理你所订阅的内容,并且这个 uaid
的最后登录者才能接收到推送
// first time
↑|{"messageType":"hello","broadcasts":{},"use_webpush":true}
↓|{"messageType":"hello","uaid":"53221452835a4e8000cc9175f0b1af68","status":200,"use_webpush":true,"broadcasts":{}}
// later
↑|{"messageType":"hello","broadcasts":{},"use_webpush":true,"uaid":"53221452835a4e8000cc9175f0b1af68"}
↓|{"messageType":"hello","uaid":"53221452835a4e8000cc9175f0b1af68","status":200,"use_webpush":true,"broadcasts":{}}
获取订阅端点
首先我们需要生成一个 channelID
,这是一个随机的 UUID
const channelID = crypto.randomUUID()
// -> e.g. a06946f8-8f6f-48f8-8b3f-0d1b2f2dda17
然后找到发布方的 VAPID,这个 ID 用于识别发布方的身份,对于普通用户并没有什么用,下面这串 base64url
字符串就是 Twitter 的 VAPID
BF5oEo0xDUpgylKDTlsd8pZmxQA1leYINiY-rSscWYK_3tWAkz4VMbtf1MLE_Yyd6iII6o-e3Q9TCN5vZMzVMEs
↑|{"channelID":"a06946f8-8f6f-48f8-8b3f-0d1b2f2dda17","messageType":"register","key":"BF5oEo0xDUpgylKDTlsd8pZmxQA1leYINiY-rSscWYK_3tWAkz4VMbtf1MLE_Yyd6iII6o-e3Q9TCN5vZMzVMEs"}
↓|{"messageType":"register","channelID":"a06946f8-8f6f-48f8-8b3f-0d1b2f2dda17","status":200,"pushEndpoint":"https://updates.push.services.mozilla.com/wpush/v2/gAAAAABlhC62rALdnqu3lCUIHoKWomQU3MIGDYCLW8H67XvyCL24kQXhlOUqIt_VHg1Ias0cdqmCC0X9KCD4UH503yPYGjOWhHfwlFw8Xw32xMTPNJb23YqW9Yx6Rxv0MLjG31jlPReYMuRg1N79WX2r5FAQgkKV6a8gfPiBGsEBclgbpx1nTyM"}
这个 pushEndpoint
就是我们所需要的端点
提交订阅信息
接下来参考我的前一篇文章取得 p256dh
和 auth
,然后在浏览器登录 Twitter。由于爬虫的特殊性质,我不建议使用主要帐号,各位读者可以自行注册一个小号用于爬虫
打开浏览器的开发者控制台,将以下内容粘贴到 Console,补充前三项变量(都是 base64url
)然后运行,如果是在 node.js 跑就还需要补充 Cookie,代码非常简单,应该不需要我过多解释了
const p256dh = ''//your ECC public key
const auth = ''//your auth key
const endpoint = ''//push endpoint
// for node.js
//const cookie = {auth_token: '', ct0: ''}
// for browser
const cookie = Object.fromEntries(document.cookie.split('; ').map(cookie => cookie.split('=', 2)))
const push_device_info = {
os_version: 'Windows/Firefox',
udid: 'Windows/Firefox',
env: 3,
locale: 'en',
protocol_version: 1,
token: endpoint,
encryption_key1: p256dh,
encryption_key2: auth,
}
fetch('https://twitter.com/i/api/1.1/notifications/settings/login.json', {
headers: {
//'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0',
'Content-Type': 'application/json',
'x-twitter-auth-type': 'OAuth2Session',
//cookie: 'auth_token=' + cookie.auth_token + ';ct0=' + cookie.ct0 + ';',
'x-csrf-token': cookie.ct0,
'x-twitter-client-language': 'en',
'x-twitter-active-user': 'yes',
authorization: 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
referrer: 'https://twitter.com/settings/push_notifications'
},
method: 'POST',
body: JSON.stringify({
push_device_info
})
})
取消订阅
一般都用不上……
先在 websocket 发送请求
↑|{"messageType":"unregister","channelID":"a06946f8-8f6f-48f8-8b3f-0d1b2f2dda17"}
↓|{"messageType":"unregister","channelID":"a06946f8-8f6f-48f8-8b3f-0d1b2f2dda17","status":200}
Autopush 不会验证 uaid
的订阅里面有没有这个 channelID
,只要传进去的是一个合法的 UUID 都会返回 200
然后又是向 Twitter 发送请求,前半部分就是上一小节的代码,我就不重复了
fetch('https://twitter.com/i/api/1.1/notifications/settings/logout.json', {
headers: {
//'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0',
'Content-Type': 'application/json',
'x-twitter-auth-type': 'OAuth2Session',
//cookie: 'auth_token=' + cookie.auth_token + ';ct0=' + cookie.ct0 + ';',
'x-csrf-token': cookie.ct0,
'x-twitter-client-language': 'en',
'x-twitter-active-user': 'yes',
authorization: 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
referrer: 'https://twitter.com/settings/push_notifications'
},
method: 'POST',
body: JSON.stringify(push_device_info)
})
接收推送
终于到了最关键的一步了,我从前面抄来了一份示例
{
"messageType": "notification",
"channelID": "1ad1e3c9-23b2-42d6-a6e6-9e201628bf8b",
"version": "gAAAAABlgVc3Icf8DQm6I-vltZfKDvi0t1K1jXfEW6t-icQf9Bfp3yROt-QK-oqQ20nL8rMixkkGew2P9ViW-SA4m6TTiJLLOuQ98FV8j-y-Dnb4JTQRcJaN7cLHsV7aYXYH2-RaEpmIddQOJKLv4K3p0sr-1fUi4UJb3UKw8Zu4j3YiOEou4Z9XPdCbaf7ApWN-WugwUzFU",
"data": "5DqqPsQnYgL_SvYJjIeyCEpDxqan7wgaBvJT6pPQ6CArCkgMRGu9tnpc7cJRfWR1arx-GaDlRoKOcTE--AOQ3OAs0Iwcn9eIJnx_5XFiubtEBe5ez1uzbZClw4myn9LGe1NUYCz-Kfx5-qIXO-lLd1eHsBeBcpMyI2wt8fzV1rTtKzgJPo8hLPSckOQ-PDqjZHbbbl8Yu5i2fsXMN_qZcBHYRwmyMOrqlXhN7P4RqvhNQTbZTBzgkA2xIMGneYujY8I8roiq30b6yy-izIp6PE_7y5Bv_E5s_M6pQod6ZqQPjMa2Bjq5M51nyeNG5AYnNwLu8lHfa5SgJif2s7atGar33ibg7keUnfagREUHEVAU388P9toYLN7jIZQmfYGRUa9GNABtWFeRG_qgaTmsneIsr4AU2EEJiJmlWAvV58oWIlRk98kLtJtGRCQmzp29ZgkRJ5RYlwZEVUq9GuA2fLxa48nyj1gNHliSHO39CdrnO9i59OMcJEyXf1IDZOmhV0TVRPCXOOxFF0efLLeRdn16EUHLyb5iyMmlPSJKS4Skd9yM6FAlyUVWp_p1_Ww0nUphGwC2dZPIJyVEcp3t-UzcXmaskhNipmQNvfuF-b0Zz3Fo9yKRl4twFpqGPuEJavaiB_lehHnVBZRrxVh653M-cyKfc63j39J9e8S_aBcuJkr5nebucpC6WY1UCE_ImOidJFuH0mBdy2D677R58qdbLL_KDFyRnaQYMUeaSsQLb1W-cPjY9eQjWgaJGammZ_B8oub4vXt9i6D00lgXk-3bYYhSGEo-KQYzuIHSXAAwEy6LPpxzU4rjJzDKn8IcebSKhEknuU1aMdvIenrbHIiv_3mva6g4CuBGFJp4qTRdZB6VotLrd_vc8HvsNzGQQEugO9U7v9m_GM57RAapsMQU8xi6gyiksb3vShEBxYpVH2bTfN433e-Xb4-S3mG26tD0GQnHss8U3uGBkySd8HTTFAvnIJyr0nXf9z9D4fYG_XIYz21F2lmdMEvSqvs0T9HdEY-xAN986RQ5TGeUwc4Lzaty0Lw9aD00Tq-6XiB8Hi0br1qc0i-R5-sm2bHWZiTJev0MS9-NFUFITpCZ57uFaNTZhAYxxe8t_BtLP392TZXKxNbF9d_xI5A6h1ArusFjCYIF7LTDK6UnNcnhN1-tvVICw7FIhdTEVoev2S_qng7BueWPQBFu84QY0fAM1W7TokfxSeaUs0EQDlT6PxIKrVY9SUpRgooNN_EejClxU4uYUMCHYlKXGKhRFF3OaOFYXNDTPsJ6H9GPjA80Yo8_SEH5ID1VOOf3U7u6n524f93Oiuw0eQ_yK6pUqTT2xKwV6PKMwyGaNd05TCwecw",
"headers": {
"encryption": "salt=FIBbGEB_Pvu9RjiD_eIikg",
"crypto_key": "p256ecdsa=BF5oEo0xDUpgylKDTlsd8pZmxQA1leYINiY-rSscWYK_3tWAkz4VMbtf1MLE_Yyd6iII6o-e3Q9TCN5vZMzVMEs;dh=BAvjLA_jh3hS_0Mb9I0tYzfXo_1onC7_YqEuPh4FOur-2ggugEoRd-_d0zSDh2TWUoh-4JB13eIpw_zEgWyAvXA",
"encoding": "aesgcm"
}
}
首先需要发送 ACK 包
↑|{"messageType":"ack","updates":[{"channelID":"1ad1e3c9-23b2-42d6-a6e6-9e201628bf8b","version":"gAAAAABlgVc3Icf8DQm6I-vltZfKDvi0t1K1jXfEW6t-icQf9Bfp3yROt-QK-oqQ20nL8rMixkkGew2P9ViW-SA4m6TTiJLLOuQ98FV8j-y-Dnb4JTQRcJaN7cLHsV7aYXYH2-RaEpmIddQOJKLv4K3p0sr-1fUi4UJb3UKw8Zu4j3YiOEou4Z9XPdCbaf7ApWN-WugwUzFU","code":100}]}
至于如何解密接收到的内容请看上一篇
最后就得到了这个 json,timestamp
应该是 Autopush 收到推送的时刻,而最开始设置的语言(zh-tw
)似乎对内容没太大影响
{
"registration_ids" : ["https://updates.push.services.mozilla.com/wpush/v2/gAAAAABlgEsJR6WeYoTxZKdbf1GNO1IuKSRyorx8VtnpJqLeLmHOLrKYrI6ToQha8_VTbbCPem-Af9YDFm7TOSeVzPe2aqaqwV0WR34M5CHq4vVukAbC2aM8qriscH8bbGD2vj6Q_glpLyn1lumQQGKuNCgXcKO2-ZPSkcsjR4FRavfL7jqJA0s"],
"title": "BBC News (UK)",
"body": "Llanberis mountain rescuers face burnout after busiest year bbc.in/4756iUr",
"icon": "https://pbs.twimg.com/profile_images/1529107486271225859/03qcVNIk_reasonably_small.jpg",
"timestamp": "1702975287450",
"tag": "tweet-1737030363371716721",
"data": {
"lang": "zh-tw",
"bundle_text": "{num_total, number} 個新{num_total, plural, other {互動}}",
"type": "tweet",
"uri": "/BBCNews/status/1737030363371716721",
"impression_id": "<SUBSCRIBER_TWITTER_UID>-<UNKNOWN_NUMBER>",
"title": "BBC News (UK)",
"body": "Llanberis mountain rescuers face burnout after busiest year bbc.in/4756iUr",
"tag": "tweet-1737030363371716721",
"scribe_target": "tweet"
}
}
杂项
- Autopush 服务端每 5 分钟下发一个 ping 帧,需要回复 pong,如果超时没有收到 ping 帧说明连接已断开,直接重连即可,我的示例设置的阈值是 6 分钟。浏览器会自动处理 ping 帧
- Autopush 有时会广播
"remote-settings/monitor_changes"
,这是一个时间戳字符串,可以在附加在 hello 请求上,没搞明白有什么特别的用处// broadcast ↓|{"messageType":"broadcast","broadcasts":{"remote-settings/monitor_changes":"\"1703239035420\""}} // next hello ↑|{"messageType":"hello","broadcasts":{"remote-settings/monitor_changes":"\"1703044634233\""},"use_webpush":true,"uaid":"53221452835a4e8000cc9175f0b1af68"} ↓|{"messageType":"hello","uaid":"53221452835a4e8000cc9175f0b1af68","status":200,"use_webpush":true,"broadcasts":{"remote-settings/monitor_changes":"\"1703239035420\""}}
- 这种方案适合对时间线完整性和消息即时性不太敏感的人群,毕竟有很多限制:
- 推送有延迟,而且有延迟的是 Twitter 将消息推送到推送服务这一步,普通账号大约有 100ms,关注者比较多的帐号为 1~100s 不等,可能还会更多
- *不会推送自己回复自己的推文,但我已经发现例外,所以还有待继续观察
- *推送过的推文被 Retweet 时不会推送,我猜是因为推送根据 tag 来判断是否推送过,转推用的是原推文的 tag
- @BBCNews 发推很频繁,很适合作为观察对象
- 回复的推文不会有
@
前缀,甚至无法从内容识别出这是一条回复 - 不含有任何媒体(图片/视频)
- 刷推看到有推友吐槽收不到回复通知,所以被 shadow ban 的帐号可能不会推送……
也许需要想办法解决客户端的推送,因为客户端会推送带图推文的图片。拿到 tweet_id 以后就能有一堆办法获取到完整推文,反正这个办法实时性也很一般,多几秒钟也无妨- 2023-12-21 Twitter 的时间线 API 崩溃了一两个小时,但推送服务一直都在正常运作
- 关注和可订阅数量可能会有上限,我不知道上限是多少,也没有尝试找到这个上限的兴趣
- 一次订阅能使用多长时间我还不清楚
- 长期不使用(检测方式应该是那个 ACK)Twitter 会停止推送,这个长期是多长我不清楚
- 从 Twitter 的角度来看所有的请求都是从正常的浏览器或客户端发出的,所以理论上非常安全
- 由于不需要频繁抓取,实际上还降低了 Twitter 的服务器压力
- 理论上这种思路还适用于任何使用 Web Push 推送最新内容的网站
- Autopush 的连接不限制跨域,所以请自行想象还能有什么玩法。另外请不要滥用 Autopush,限制了大家都没得玩
- Twitter Monitor 的示例已更新
- 而在线小工具请看这里
*GCM
* 这段是吐槽,在我完成前可不看
一开始我想着不靠 selenium,直接接收来自 GCM 的消息,翻了一圈还真的让我找到了介绍方法的文章,一年前有博主介绍了自己如何借助 push-receiver 接收来自 Google voice 的通知,然后我尝试照拷贝粘贴代码,结果自然是什么都收不到
也许还有我没搞明白的地方,未来我将会继续研究补充这部分
*直接接收推送
既然 GCM 不可用,我能不能自己架设一个推送服务器呢?于是我真的随便写了一个接收后端,前后折腾了几天只收到几个扫描互联网漏洞的 bot 的请求……好吧,也许 Twitter 对推送服务提供商有一个过滤列表