exitter.el 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820
  1. ;; -*- lexical-binding: t; -*-
  2. ;; Copyright (C) 2024 Free Software Foundation, Inc.
  3. ;; Author: Yuchen Pei <id@ypei.org>
  4. ;; Package-Requires: ((emacs "29.4") (request "0.3.3"))
  5. ;; This file is part of exitter.
  6. ;; exitter is free software: you can redistribute it and/or modify it under
  7. ;; the terms of the GNU Affero General Public License as published by
  8. ;; the Free Software Foundation, either version 3 of the License, or
  9. ;; (at your option) any later version.
  10. ;; exitter is distributed in the hope that it will be useful, but WITHOUT
  11. ;; ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
  12. ;; or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
  13. ;; Public License for more details.
  14. ;; You should have received a copy of the GNU Affero General Public
  15. ;; License along with exitter. If not, see <https://www.gnu.org/licenses/>.
  16. (require 'request)
  17. (defvar exitter-url-endpoint "https://api.twitter.com/1.1")
  18. (defvar exitter-url-activate
  19. (format "%s/guest/activate.json" exitter-url-endpoint))
  20. (defvar exitter-url-task
  21. (format "%s/onboarding/task.json" exitter-url-endpoint))
  22. (defvar exitter-url-token
  23. (format "https://api.twitter.com/oauth2/token"))
  24. (defvar exitter-url-tweet-detail
  25. "https://api.twitter.com/graphql/3XDB26fBve-MmjHaWTUZxA/TweetDetail")
  26. (defvar exitter-tor-param "-x socks5://127.0.0.1:9050/")
  27. (defvar exitter-init-headers
  28. `(
  29. ("Content-Type" . "application/json")
  30. ("User-Agent" . "TwitterAndroid/10.10.0 (29950000-r-0) ONEPLUS+A3010/9 (OnePlus;ONEPLUS+A3010;OnePlus;OnePlus3;0;;1;2016)")
  31. ;; ("User-Agent" . "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36")
  32. ("X-Twitter-API-Version" . "5")
  33. ("X-Twitter-Client" . "TwitterAndroid")
  34. ("X-Twitter-Client-Version" . "10.10.0")
  35. ("OS-Version" . "28")
  36. ("System-User-Agent" . "Dalvik/2.1.0 (Linux; U; Android 9; ONEPLUS A3010 Build/PKQ1.181203.001)")
  37. ("X-Twitter-Active-User" . "yes")
  38. ))
  39. (defvar exitter-default-features
  40. (json-encode
  41. '(
  42. ("android_graphql_skip_api_media_color_palette" . :json-false)
  43. ("blue_business_profile_image_shape_enabled" . :json-false)
  44. ("creator_subscriptions_subscription_count_enabled" . :json-false)
  45. ("creator_subscriptions_tweet_preview_api_enabled" . t)
  46. ("freedom_of_speech_not_reach_fetch_enabled" . :json-false)
  47. ("graphql_is_translatable_rweb_tweet_is_translatable_enabled" . :json-false)
  48. ("hidden_profile_likes_enabled" . :json-false)
  49. ("highlights_tweets_tab_ui_enabled" . :json-false)
  50. ("interactive_text_enabled" . :json-false)
  51. ("longform_notetweets_consumption_enabled" . t)
  52. ("longform_notetweets_inline_media_enabled" . :json-false)
  53. ("longform_notetweets_richtext_consumption_enabled" . t)
  54. ("longform_notetweets_rich_text_read_enabled" . :json-false)
  55. ("responsive_web_edit_tweet_api_enabled" . :json-false)
  56. ("responsive_web_enhance_cards_enabled" . :json-false)
  57. ("responsive_web_graphql_exclude_directive_enabled" . t)
  58. ("responsive_web_graphql_skip_user_profile_image_extensions_enabled" . :json-false)
  59. ("responsive_web_graphql_timeline_navigation_enabled" . :json-false)
  60. ("responsive_web_media_download_video_enabled" . :json-false)
  61. ("responsive_web_text_conversations_enabled" . :json-false)
  62. ("responsive_web_twitter_article_tweet_consumption_enabled" . :json-false)
  63. ("responsive_web_twitter_blue_verified_badge_is_enabled" . t)
  64. ("rweb_lists_timeline_redesign_enabled" . t)
  65. ("spaces_2022_h2_clipping" . t)
  66. ("spaces_2022_h2_spaces_communities" . t)
  67. ("standardized_nudges_misinfo" . :json-false)
  68. ("subscriptions_verification_info_enabled" . t)
  69. ("subscriptions_verification_info_reason_enabled" . t)
  70. ("subscriptions_verification_info_verified_since_enabled" . t)
  71. ("super_follow_badge_privacy_enabled" . :json-false)
  72. ("super_follow_exclusive_tweet_notifications_enabled" . :json-false)
  73. ("super_follow_tweet_api_enabled" . :json-false)
  74. ("super_follow_user_api_enabled" . :json-false)
  75. ("tweet_awards_web_tipping_enabled" . :json-false)
  76. ("tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled" . :json-false)
  77. ("tweetypie_unmention_optimization_enabled" . :json-false)
  78. ("unified_cards_ad_metadata_container_dynamic_card_content_query_enabled" . :json-false)
  79. ("verified_phone_label_enabled" . :json-false)
  80. ("vibe_api_enabled" . :json-false)
  81. ("view_counts_everywhere_api_enabled" . :json-false)
  82. )))
  83. (defvar exitter-oauth-consumer-key nil)
  84. (defvar exitter-oauth-consumer-secret nil)
  85. (defvar exitter-access-token nil)
  86. (defvar exitter-username nil)
  87. (defvar exitter-password nil)
  88. (defvar exitter-email nil)
  89. (defvar exitter-oauth-token nil)
  90. (defvar exitter-oauth-token-secret nil)
  91. (defvar exitter-oauth-token-ctime nil)
  92. (defvar exitter-debug nil)
  93. ;;; for debugging
  94. (if exitter-debug
  95. (setq request-message-level 'blather)
  96. (setq request-message-level -1))
  97. ;;; Get an oauth2 bearer token
  98. ;;; https://developer.x.com/en/docs/authentication/api-reference/token
  99. (defun exitter-get-access-token ()
  100. (let ((oauth-consumer-key-secret
  101. (base64-encode-string
  102. (format "%s:%s" exitter-oauth-consumer-key
  103. exitter-oauth-consumer-secret)
  104. t)))
  105. (request exitter-url-token
  106. :headers `(("Authorization" .
  107. ,(format "Basic %s" oauth-consumer-key-secret))
  108. ("Content-Type" . "application/x-www-form-urlencoded"))
  109. :data "grant_type=client_credentials"
  110. :parser 'json-read
  111. :type "POST"
  112. :success (cl-function
  113. (lambda (&key data &allow-other-keys)
  114. ;; will generate exactly the same token as
  115. ;; `exitter-access-token'
  116. (print (alist-get 'access_token data))))
  117. :error
  118. (cl-function (lambda (&rest args &key error-thrown &allow-other-keys)
  119. (message "Got error: %S" error-thrown)))
  120. )))
  121. ;;; Use the twitter frontend flow and the oauth2 bearer token to
  122. ;;; obtain a user oauth token aka user access token.
  123. ;;; The official way of doing so is by oauth 1.0a documented at
  124. ;;; https://developer.x.com/en/docs/authentication/oauth-1-0a, but
  125. ;;; here we use the twitter frontend flow, similar to how one logs in
  126. ;;; the twitter web client, see also
  127. ;;; https://github.com/fa0311/TwitterFrontendFlow
  128. (defun exitter-get-guest-token ()
  129. (when exitter-debug (message "entering exitter-get-guest-token"))
  130. (request exitter-url-activate
  131. :headers `(("Authorization" . ,(format "Bearer %s" exitter-access-token)))
  132. :parser 'json-read
  133. :type "POST"
  134. :success (cl-function
  135. (lambda (&key data &allow-other-keys)
  136. (exitter-login-flow-token (alist-get 'guest_token data))))
  137. :error
  138. (cl-function (lambda (&rest args &key error-thrown &allow-other-keys)
  139. (message "Got error: %S" error-thrown)))
  140. ))
  141. (defun exitter-login-flow-token (guest-token)
  142. (when exitter-debug (message "entering exitter-login-flow-token"))
  143. (let ((headers `(,@exitter-init-headers
  144. ("Authorization" . ,(format "Bearer %s" exitter-access-token))
  145. ("X-Guest-Token" . ,guest-token)
  146. )))
  147. (request exitter-url-task
  148. :params '(("flow_name" . "login")
  149. ("lang" . "en"))
  150. :headers headers
  151. :data (json-encode
  152. '(("flow_token" . nil)
  153. ("input_flow_data" .
  154. (("country_code" . nil)
  155. ("flow_context" .
  156. (("referral_context" .
  157. (("referral_details" .
  158. "utm_source=google-play&utm_medium=organic")
  159. ("referrer_url" . "")))
  160. ("start_location" . (("location" . "deeplink")))
  161. ("request_variant" . nil)
  162. ("target_user_id" . 0)))))))
  163. :parser 'json-read
  164. :type "POST"
  165. :complete (cl-function
  166. (lambda (&key response &allow-other-keys)
  167. (let* ((att
  168. (request-response-header response "att"))
  169. (data
  170. (request-response-data response))
  171. (flow-token (alist-get 'flow_token data)))
  172. (unless (exitter-find-subtask data "LoginEnterUserIdentifier")
  173. (error "Subtask LoginEnterUserIdentifier not found"))
  174. ;; (pp data)
  175. ;; (message "flow-token: %s\natt: %s"
  176. ;; (alist-get 'flow_token data)
  177. ;; att)
  178. (when att
  179. (setq headers
  180. `(,@headers
  181. ("att" . ,att)
  182. ("cookie" . ,(format "att=%s" att)))))
  183. (exitter-enter-username flow-token headers))))
  184. :error
  185. (cl-function (lambda (&rest args &key error-thrown &allow-other-keys)
  186. (message "Got error: %S" error-thrown)))
  187. )))
  188. (defun exitter-find-subtask (data subtask-id)
  189. (when exitter-debug
  190. (message "entering exitter-find-subtask")
  191. (message "subtask-id: %s" subtask-id))
  192. (seq-find
  193. (lambda (subtask)
  194. (equal (alist-get 'subtask_id subtask) subtask-id))
  195. (alist-get 'subtasks data)))
  196. (defun exitter-report-error (&rest args &key error-thrown &allow-other-keys)
  197. (message "Got error: %S" error-thrown))
  198. (defun exitter-enter-username (flow-token headers)
  199. (when exitter-debug (message "entering exitter-enter-username"))
  200. (request exitter-url-task
  201. :params '(("lang" . "en"))
  202. :headers headers
  203. :data (json-encode
  204. `(("flow_token" . ,flow-token)
  205. ("subtask_inputs" .
  206. [(("enter_text" .
  207. (("suggestion_id" . nil)
  208. ("text" . ,exitter-username)
  209. ("link" . "next_link")))
  210. ("subtask_id" . "LoginEnterUserIdentifier"))])))
  211. :type "POST"
  212. :parser 'json-read
  213. :success (cl-function
  214. (lambda (&key data &allow-other-keys)
  215. (let ((new-flow-token
  216. (alist-get 'flow_token data)))
  217. (cond
  218. ((exitter-find-subtask data "LoginEnterPassword")
  219. (message "LoginEnterPassword")
  220. (exitter-enter-password new-flow-token headers))
  221. ((exitter-find-subtask data "LoginEnterAlternateIdentifierSubtask")
  222. (message "LoginEnterAlternateIdentifierSubtask")
  223. (exitter-enter-email new-flow-token headers))
  224. (t (message "Cannot find any matching subtasks"))))
  225. ))
  226. :error
  227. (cl-function (lambda (&rest args &key error-thrown &allow-other-keys)
  228. (message "Got error: %S" error-thrown)))))
  229. (defun exitter-enter-password (flow-token headers)
  230. (when exitter-debug (message "entering exitter-enter-password"))
  231. (request exitter-url-task
  232. :params '(("lang" . "en"))
  233. :headers headers
  234. :data (json-encode
  235. `(("flow_token" . ,flow-token)
  236. ("subtask_inputs" .
  237. [(("enter_password" .
  238. (("password" . ,exitter-password)
  239. ("link" . "next_link")))
  240. ("subtask_id" . "LoginEnterPassword"))])))
  241. :type "POST"
  242. :parser 'json-read
  243. :success (cl-function
  244. (lambda (&key data &allow-other-keys)
  245. (cond
  246. ((exitter-find-subtask data "LoginSuccessSubtask")
  247. (message "LoginSuccessSubtask")
  248. (let* ((subtask
  249. (exitter-find-subtask data "LoginSuccessSubtask"))
  250. (open-account (alist-get 'open_account subtask)))
  251. (setq exitter-oauth-token
  252. (alist-get 'oauth_token open-account)
  253. exitter-oauth-token-secret
  254. (alist-get 'oauth_token_secret open-account)
  255. exitter-oauth-token-ctime
  256. (format-time-string "%Y-%m-%d %a %H:%M:%S"
  257. (current-time)))))
  258. (t (message "Cannot find any matching subtasks"))) ))
  259. :error
  260. (cl-function (lambda (&rest args &key error-thrown &allow-other-keys)
  261. (message "Got error: %S" error-thrown)))
  262. ))
  263. (defun exitter-enter-email (flow-token headers)
  264. (when exitter-debug (message "entering exitter-enter-email"))
  265. (request exitter-url-task
  266. :params '(("lang" . "en"))
  267. :headers headers
  268. :data (json-encode
  269. `(("flow_token" . ,flow-token)
  270. ("subtask_inputs" .
  271. [(("enter_text" .
  272. (("text" . ,exitter-email)
  273. ("link" . "next_link")))
  274. ("subtask_id" . "LoginEnterAlternateIdentifierSubtask"))])))
  275. :type "POST"
  276. :parser 'json-read
  277. :success (cl-function
  278. (lambda (&key data &allow-other-keys)
  279. (let ((new-flow-token
  280. (alist-get 'flow_token data)))
  281. (cond
  282. ((exitter-find-subtask data "LoginEnterPassword")
  283. (message "LoginEnterPassword")
  284. (exitter-enter-password new-flow-token headers))
  285. (t (message "Cannot find any matching subtasks"))))
  286. ))
  287. :error
  288. (cl-function (lambda (&rest args &key error-thrown &allow-other-keys)
  289. (message "Got error: %S" error-thrown)))
  290. ))
  291. ;;; Example at
  292. ;;; https://developer.x.com/en/docs/authentication/oauth-1-0a/creating-a-signature
  293. ;;; should produce signature "Ls93hJiZbQ3akF3HF3x1Bz8%2FzU4%3D"
  294. (defun exitter-example-sign-oauth ()
  295. (exitter-get-sign-oauth
  296. "https://api.x.com/1.1/statuses/update.json" ;link
  297. '(("status" . "Hello Ladies + Gentlemen, a signed OAuth request!")
  298. ("include_entities" . "true")) ;url-params
  299. "POST" ;method
  300. "xvz1evFS4wEEPTGEFPHBog" ;oauth-consumer-key
  301. "kAcSOqF21Fu85e7zjz7ZN2U4ZRhfV3WpwPAoE3Z7kBw" ;oauth-consumer-secret
  302. "370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb" ;oauth-token
  303. "LswwdoUaIvS8ltyTt5jkRh4J50vUPVVHtR2YPi5kE" ;oauth-token-secret
  304. "kYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg" ;oauth-nonce
  305. "1318622958" ;oauth-timestamp
  306. ))
  307. ;;; see
  308. ;;; https://developer.x.com/en/docs/authentication/oauth-1-0a/creating-a-signature
  309. ;;; https://developer.x.com/en/docs/authentication/oauth-1-0a/authorizing-a-request
  310. (defun exitter-get-sign-oauth (link url-params method &optional
  311. oauth-consumer-key oauth-consumer-secret
  312. oauth-token oauth-token-secret
  313. oauth-nonce oauth-timestamp)
  314. (let* ((oauth-params `(("oauth_consumer_key" .
  315. ,(or oauth-consumer-key exitter-oauth-consumer-key))
  316. ("oauth_nonce" . ,(or oauth-nonce (exitter-nonce)))
  317. ("oauth_signature_method" . "HMAC-SHA1")
  318. ("oauth_timestamp" . ,(or oauth-timestamp (format-time-string "%s" (current-time))))
  319. ("oauth_token" . ,(or oauth-token exitter-oauth-token))
  320. ("oauth_version" . "1.0")))
  321. (all-params (sort (seq-concatenate 'list url-params oauth-params)
  322. (lambda (a b) (string< (car a) (car b)))))
  323. (method-up (upcase method))
  324. (hexed-link (url-hexify-string link))
  325. (param-to-sign-unencoded
  326. (mapconcat
  327. (lambda (pair)
  328. (format "%s=%s" (car pair) (url-hexify-string (cdr pair))))
  329. all-params
  330. "&"))
  331. (param-to-sign
  332. (replace-regexp-in-string
  333. "&" "%26"
  334. (replace-regexp-in-string
  335. "=" "%3D"
  336. (replace-regexp-in-string
  337. "%" "%25"
  338. (replace-regexp-in-string
  339. "\\+" "%20"
  340. param-to-sign-unencoded)))))
  341. (to-sign (format "%s&%s&%s" method-up hexed-link param-to-sign))
  342. (signing-key (format "%s&%s"
  343. (or oauth-consumer-secret
  344. exitter-oauth-consumer-secret)
  345. (or oauth-token-secret
  346. exitter-oauth-token-secret)))
  347. (signature (url-hexify-string
  348. (base64-encode-string
  349. (exitter-hmac-sha1 (encode-coding-string signing-key 'utf-8)
  350. (encode-coding-string to-sign 'utf-8)))))
  351. )
  352. ;; (message "PARAM-TO-SIGN-UNENC: %s" (prin1-to-string param-to-sign-unencoded))
  353. ;; (message "PARAM-TO-SIGN: %s" (prin1-to-string param-to-sign))
  354. ;; (message "TO-SIGN: %s" (prin1-to-string to-sign))
  355. (format "OAuth realm=\"http://api.twitter.com/\", oauth_signature=\"%s\", %s"
  356. signature
  357. (mapconcat
  358. (lambda (pair)
  359. (format "%s=\"%s\"" (car pair) (cdr pair)))
  360. oauth-params
  361. ", "))))
  362. (defun exitter-do-fetch (link params &optional headers cb)
  363. (when exitter-debug (message "entering exitter-do-fetch"))
  364. (let ((authorization (exitter-get-sign-oauth link params "GET")))
  365. (request link
  366. :headers `(,@headers
  367. ("Connection" . "Keep-Alive")
  368. ("Authorization" . ,authorization)
  369. ("Content-Type" . "application/json")
  370. ("X-Twitter-Active-User" . "yes")
  371. ("Authority" . "api.twitter.com")
  372. ("Accept-Encoding" . "gzip")
  373. ("Accept-Language" . "en-US,en;q=0.9")
  374. ("Accept" . "*/*")
  375. ("DNT" . "1")
  376. ("User-Agent" .
  377. "TwitterAndroid/10.10.0 (29950000-r-0) ONEPLUS+A3010/9 \
  378. (OnePlus;ONEPLUS+A3010;OnePlus;OnePlus3;0;;1;2016)")
  379. ("X-Twitter-API-Version" . "5")
  380. ("X-Twitter-Client" . "TwitterAndroid")
  381. ("X-Twitter-Client-Version" . "10.10.0")
  382. ("OS-Version" . "28")
  383. ("System-User-Agent" .
  384. "Dalvik/2.1.0 (Linux; U; Android 9; \
  385. ONEPLUS A3010 Build/PKQ1.181203.001)")
  386. )
  387. :params params
  388. :type "GET"
  389. :parser 'json-read
  390. :success (if cb
  391. (cl-function
  392. (lambda (&key data &allow-other-keys)
  393. (funcall cb data)))
  394. (cl-function
  395. (lambda (&key data &allow-other-keys)
  396. (pp data))))
  397. :error
  398. (cl-function (lambda (&rest args &key error-thrown &allow-other-keys)
  399. (message "Got error: %S" error-thrown)))
  400. )))
  401. (defun exitter-get-tweet (id)
  402. (let ((variables (json-encode
  403. `(
  404. ("focalTweetId" . ,id)
  405. ;; ("referrer" . "tweet")
  406. ;; ("with_rux_injections" . :json-false)
  407. ("includePromotedContent" . :json-false)
  408. ;; ("withCommunity" . t)
  409. ("withQuickPromoteEligibilityTweetFields" . :json-false)
  410. ("includeHasBirdwatchNotes" . :json-false)
  411. ("withBirdwatchNotes" . :json-false)
  412. ("withVoice" . :json-false)
  413. ("withV2Timeline" . t)
  414. ))))
  415. (exitter-do-fetch
  416. exitter-url-tweet-detail
  417. `(("variables" . ,variables)
  418. ("features" . ,exitter-default-features))
  419. nil
  420. (lambda (data)
  421. ;; (pp data)
  422. (exitter-save-posts (exitter-filter-tweet-details data) id)))))
  423. ;;; transform alist
  424. (defun exitter-filter-tweet-details (resp)
  425. (vconcat
  426. (seq-map
  427. 'exitter-filter-tweet-entry
  428. (alist-get
  429. 'entries
  430. (let-alist resp
  431. (elt .data.threaded_conversation_with_injections_v2.instructions 0))))))
  432. ;;; .content.entryType == "TimelineTimelineItem":
  433. ;;; If further its .content.itemContent.itemType=="TimelineTweet",
  434. ;;; then this is a simple case and we could filter its
  435. ;;; .content.itemContent.tweet_results.result. Otherwise if its
  436. ;;; .content.itemContent.itemType=="TimelineTimelineCursor" then it is
  437. ;;; paging which we need to figure out later.
  438. ;;; .content.entryType == "TimelineTimelineModule":
  439. ;;; has ~items~ containing entries. But these entries do not have
  440. ;;; content nor entryType anywhere. Instead they each have an
  441. ;;; item.itemContent.tweet_results.result
  442. (defun exitter-filter-tweet-entry (entry)
  443. (let-alist entry
  444. (cond
  445. ((equal .content.entryType "TimelineTimelineItem")
  446. (let-alist .content.itemContent
  447. (pcase .itemType
  448. ("TimelineTweet"
  449. (exitter-filter-tweet-result .tweet_results.result))
  450. ("TimelineTimelineCursor"
  451. (message "TimelineTimelineCursor encountered. More tweets available"))
  452. (_
  453. (error "TimelineTimelineItem entry with unknown itemType: %s"
  454. (pp entry))))))
  455. ((equal .content.entryType "TimelineTimelineModule")
  456. (vconcat (seq-map 'exitter-filter-tweet-entry .content.items)))
  457. ((equal .item.itemContent.itemType "TimelineTweet")
  458. (exitter-filter-tweet-result .item.itemContent.tweet_results.result))
  459. ((equal .item.itemContent.itemType "TimelineTimelineCursor")
  460. (message "TimelineTimelineCursor encountered. More tweets available"))
  461. (t (error "Entry with unknown entryType or itemType: %s" (pp entry))))))
  462. (defun exitter-filter-tweet-result (result)
  463. (pcase (alist-get '__typename result)
  464. ("Tweet"
  465. (let (ret author quoted post)
  466. (let-alist result
  467. (let-alist .core.user_results.result.legacy
  468. (setq author
  469. `((screen_name . ,.screen_name)
  470. (name . ,.name))))
  471. (when .quoted_status_result
  472. (setq quoted
  473. (exitter-filter-tweet-result .quoted_status_result.result)))
  474. (let-alist .legacy
  475. (setq post
  476. `((id_str . ,.id_str)
  477. (created_at . ,.created_at)
  478. (full_text . ,.full_text)
  479. (urls . ,(vconcat .entities.urls .entities.media))
  480. (reply_count . ,.reply_count)
  481. (retweet_count . ,.retweet_count)
  482. (quote_count . ,.quote_count)
  483. (favorite_count . ,.favorite_count)
  484. (in_reply_to_status_id_str . ,.in_reply_to_status_id_str)
  485. (is_quote_status . ,.is_quote_status)
  486. (author . ,author)
  487. (quoted . ,quoted))))
  488. (when .note_tweet.is_expandable
  489. (let-alist .note_tweet.note_tweet_results.result
  490. (push `(expanded_text . ,.text) post)
  491. (push `(extra_urls . ,.entity_set.urls) post)))
  492. (push `(post . ,post) ret)
  493. )
  494. ret))
  495. ("TweetWithVisibilityResults"
  496. (message "Aaaaaaads"))
  497. (_
  498. (error "result with unknown __typename: %s" result))))
  499. ;;; renderer, similar to mastorg
  500. (require 'hierarchy)
  501. (defun exitter-post-make-parent-fn (posts)
  502. "Given a collection of POSTS, return a function that find the parent post."
  503. (lambda (post)
  504. (let ((id (alist-get 'in_reply_to_status_id_str post)))
  505. (seq-find
  506. (lambda (candidate)
  507. (equal (alist-get 'id_str candidate) id))
  508. posts))))
  509. ;;; Formatting functions
  510. (defun exitter-format-posts (filtered-details)
  511. "Format a post tree of post located at URL.
  512. Including ancestors and descendants, if any."
  513. (let ((posts-hier (hierarchy-new))
  514. (posts (exitter-vector-flatten filtered-details)))
  515. (hierarchy-add-trees
  516. posts-hier
  517. posts
  518. (exitter-post-make-parent-fn posts))
  519. (string-join
  520. (hierarchy-map 'exitter-format-post posts-hier 1)
  521. "\n")))
  522. (defun exitter-save-posts (filtered-details id)
  523. ;; (pp filtered-details)
  524. (exitter-save-text-and-switch-to-buffer
  525. (exitter-format-posts filtered-details)
  526. (format "~/Downloads/%s.org" id)))
  527. (defun exitter-post-url (user post-id)
  528. (format "https://x.com/%s/status/%s" user post-id))
  529. (defun exitter-make-org-link (link desc)
  530. (format "[[%s][%s]]" link desc))
  531. (defun exitter-format-post (post level)
  532. "Format a POST with indent LEVEL."
  533. (let-alist post
  534. (format "%s %s (@%s) %s\n\n%s%s\n\n⤷%s “%s ⇆%s ★%s\n"
  535. (make-string level ?*)
  536. .author.name
  537. .author.screen_name
  538. (exitter-make-org-link
  539. (exitter-post-url .author.screen_name .id_str)
  540. (exitter--relative-time-description .created_at))
  541. (exitter-replace-t-co-links (or .expanded_text .full_text)
  542. (or .extra_urls .urls))
  543. (if .quoted
  544. (format "\n\n----\n%s----"
  545. (replace-regexp-in-string
  546. "^." " \\&" (exitter-format-post .quoted.post 1)))
  547. "")
  548. .reply_count
  549. .quote_count
  550. .retweet_count
  551. .favorite_count
  552. )))
  553. (defun exitter-post-url-p (url)
  554. (string-match-p "^/[^/]+/status/\\([0-9]+\\)"
  555. (url-filename (url-generic-parse-url url))))
  556. (defun exitter-open-post (url)
  557. (interactive "sTwitter link: ")
  558. (let ((path-etc (url-filename (url-generic-parse-url url))))
  559. (unless (string-match "^/[^/]+/status/\\([0-9]+\\)" path-etc)
  560. (error "Not a valid x/twitter (or a frontend) url!"))
  561. (exitter-get-tweet (match-string 1 path-etc))))
  562. ;;; utilities
  563. ;;; code adapted from mastodon.el
  564. (defun exitter--human-duration (seconds &optional resolution)
  565. "Return a string describing SECONDS in a more human-friendly way.
  566. The return format is (STRING . RES) where RES is the resolution of
  567. this string, in seconds.
  568. RESOLUTION is the finest resolution, in seconds, to use for the
  569. second part of the output (defaults to 60, so that seconds are only
  570. displayed when the duration is smaller than a minute)."
  571. (cl-assert (>= seconds 0))
  572. (unless resolution (setq resolution 60))
  573. (let* ((units exitter--time-units)
  574. (n1 seconds) (unit1 (pop units)) (res1 1)
  575. n2 unit2 res2
  576. next)
  577. (while (and units (> (truncate (setq next (/ n1 (car units)))) 0))
  578. (setq unit2 unit1)
  579. (setq res2 res1)
  580. (setq n2 (- n1 (* (car units) (truncate n1 (car units)))))
  581. (setq n1 next)
  582. (setq res1 (truncate (* res1 (car units))))
  583. (pop units)
  584. (setq unit1 (pop units)))
  585. (setq n1 (truncate n1))
  586. (if n2 (setq n2 (truncate n2)))
  587. (cond
  588. ((null n2)
  589. ;; revert to old just now style for < 1 min:
  590. (cons "just now" 60))
  591. ;; (cons (format "%d %s%s" n1 unit1 (if (> n1 1) "s" ""))
  592. ;; (max resolution res1)))
  593. ((< (* res2 n2) resolution)
  594. (cons (format "%d %s%s" n1 unit1 (if (> n1 1) "s" ""))
  595. (max resolution res2)))
  596. ((< res2 resolution)
  597. (let ((n2 (/ (* resolution (/ (* n2 res2) resolution)) res2)))
  598. (cons (format "%d %s%s, %d %s%s"
  599. n1 unit1 (if (> n1 1) "s" "")
  600. n2 unit2 (if (> n2 1) "s" ""))
  601. resolution)))
  602. (t
  603. (cons (format "%d %s%s, %d %s%s"
  604. n1 unit1 (if (> n1 1) "s" "")
  605. n2 unit2 (if (> n2 1) "s" ""))
  606. (max res2 resolution))))))
  607. (defconst exitter--time-units
  608. '("sec" 60.0 ;; Use a float to convert `n' to float.
  609. "min" 60
  610. "hour" 24
  611. "day" 7
  612. "week" 4.345
  613. "month" 12
  614. "year"))
  615. (defun exitter--relative-time-details (timestamp &optional current-time)
  616. "Return cons of (DESCRIPTIVE STRING . NEXT-CHANGE) for the TIMESTAMP.
  617. Use the optional CURRENT-TIME as the current time (only used for
  618. reliable testing).
  619. The descriptive string is a human readable version relative to
  620. the current time while the next change timestamp give the first
  621. time that this description will change in the future.
  622. TIMESTAMP is assumed to be in the past."
  623. (let* ((time-difference (time-subtract current-time timestamp))
  624. (seconds-difference (float-time time-difference))
  625. (tmp (exitter--human-duration (max 0 seconds-difference))))
  626. ;; revert to old just now style for < 1 min
  627. (cons (concat (car tmp) (if (string= "just now" (car tmp)) "" " ago"))
  628. (time-add current-time (cdr tmp)))))
  629. (defun exitter--relative-time-description (time-string &optional current-time)
  630. "Return a string with a human readable TIME-STRING relative to the current time.
  631. Use the optional CURRENT-TIME as the current time (only used for
  632. reliable testing).
  633. E.g. this could return something like \"1 min ago\", \"yesterday\", etc.
  634. TIME-STAMP is assumed to be in the past."
  635. (car (exitter--relative-time-details
  636. (encode-time (parse-time-string time-string)) current-time)))
  637. (defun exitter-save-text-and-switch-to-buffer (text file-name)
  638. "Save TEXT to FILE-NAME and switch to buffer."
  639. (let ((buffer (find-file-noselect file-name))
  640. (coding-system-for-write 'utf-8))
  641. (with-current-buffer buffer
  642. (let ((inhibit-read-only t))
  643. (erase-buffer)
  644. (insert text))
  645. (goto-char (point-min))
  646. (save-buffer)
  647. (revert-buffer t t))
  648. (switch-to-buffer buffer)))
  649. (defun exitter-vector-flatten (v)
  650. (cond
  651. ((and (vectorp v) (length= v 0)) v)
  652. ((vectorp v)
  653. (vconcat (exitter-vector-flatten (elt v 0))
  654. (exitter-vector-flatten (subseq v 1 (length v)))))
  655. ((and (listp v) (alist-get 'post v) (vector (alist-get 'post v))))
  656. (t [])))
  657. (require 'bindat)
  658. (defun exitter-nonce ()
  659. (let ((xs))
  660. (dotimes (_ 32 xs)
  661. (setq xs (cons (random 256) xs)))
  662. (replace-regexp-in-string
  663. "[=/+]" ""
  664. (base64-encode-string
  665. (alist-get 'bs (bindat-unpack '((bs str 32)) (vconcat xs)))))))
  666. (defun exitter-replace-t-co-links (text urls)
  667. (with-temp-buffer
  668. (insert text)
  669. (goto-char (point-min))
  670. (while (re-search-forward "https://t.co" nil t)
  671. (pcase-let* ((`(,beg . ,end) (bounds-of-thing-at-point 'url))
  672. (new-url (exitter-get-redirect-url
  673. (buffer-substring-no-properties beg end)
  674. urls)))
  675. (delete-region beg end)
  676. (insert new-url)))
  677. (buffer-string)))
  678. (defun exitter-get-redirect-url (url urls)
  679. (let-alist (seq-find
  680. (lambda (info) (equal url (alist-get 'url info)))
  681. urls)
  682. (or .media_url_https .expanded_url url)))
  683. ;;; Probably not needed...
  684. ;; (defun exitter-get-redirect-url (url)
  685. ;; "Get redirect link of URL.
  686. ;; Sends a HEAD request."
  687. ;; (let* ((url-request-method "HEAD")
  688. ;; (url-max-redirections 0)
  689. ;; (buffer (url-retrieve-synchronously url))
  690. ;; (inhibit-message t))
  691. ;; (with-current-buffer buffer
  692. ;; (goto-char (point-min))
  693. ;; (when (re-search-forward "^Location: \\(.*\\)$" nil t)
  694. ;; (match-string 1)))))
  695. (require 'sha1)
  696. ;;; Source: https://www.emacswiki.org/emacs/HmacShaOne
  697. (defun exitter-hmac-sha1 (key message)
  698. "Return an HMAC-SHA1 authentication code for KEY and MESSAGE.
  699. KEY and MESSAGE must be unibyte strings. The result is a unibyte
  700. string. Use the function `encode-hex-string' or the function
  701. `base64-encode-string' to produce human-readable output.
  702. See URL:<http://en.wikipedia.org/wiki/HMAC> for more information
  703. on the HMAC-SHA1 algorithm.
  704. The Emacs multibyte representation actually uses a series of
  705. 8-bit values under the hood, so we could have allowed multibyte
  706. strings as arguments. However, internal 8-bit values don't
  707. correspond to any external representation \(at least for major
  708. version 22). This makes multibyte strings useless for generating
  709. hashes.
  710. Instead, callers must explicitly pick and use an encoding for
  711. their multibyte data. Most callers will want to use UTF-8
  712. encoding, which we can generate as follows:
  713. (let ((unibyte-key (encode-coding-string key 'utf-8 t))
  714. (unibyte-value (encode-coding-string value 'utf-8 t)))
  715. (hmac-sha1 unibyte-key unibyte-value))
  716. For keys and values that are already unibyte, the
  717. `encode-coding-string' calls just return the same string."
  718. (when (multibyte-string-p key)
  719. (error "key %s must be unibyte" key))
  720. (when (multibyte-string-p message)
  721. (error "message %s must be unibyte" message))
  722. ;; The key block is always exactly the block size of the hash
  723. ;; algorithm. If the key is too small, we pad it with zeroes (or
  724. ;; instead, we initialize the key block with zeroes and copy the
  725. ;; key onto the nulls). If the key is too large, we run it
  726. ;; through the hash algorithm and use the hashed value (strange
  727. ;; but true).
  728. (let ((+hmac-sha1-block-size-bytes+ 64)) ; SHA-1 uses 512-bit blocks
  729. (when (< +hmac-sha1-block-size-bytes+ (length key))
  730. (setq key (sha1 key nil nil t)))
  731. (let ((key-block (make-vector +hmac-sha1-block-size-bytes+ 0)))
  732. (dotimes (i (length key))
  733. (aset key-block i (aref key i)))
  734. (let ((opad (make-vector +hmac-sha1-block-size-bytes+ #x5c))
  735. (ipad (make-vector +hmac-sha1-block-size-bytes+ #x36)))
  736. (dotimes (i +hmac-sha1-block-size-bytes+)
  737. (aset ipad i (logxor (aref ipad i) (aref key-block i)))
  738. (aset opad i (logxor (aref opad i) (aref key-block i))))
  739. (when (fboundp 'unibyte-string)
  740. ;; `concat' of Emacs23 (and later?) generates a multi-byte
  741. ;; string from a vector of characters with eight bit.
  742. ;; Since `opad' and `ipad' must be unibyte, we have to
  743. ;; convert them by using `unibyte-string'.
  744. ;; We cannot use `string-as-unibyte' here because it encodes
  745. ;; bytes with the manner of UTF-8.
  746. (setq opad (apply 'unibyte-string (mapcar 'identity opad)))
  747. (setq ipad (apply 'unibyte-string (mapcar 'identity ipad))))
  748. (sha1 (concat opad
  749. (sha1 (concat ipad message)
  750. nil nil t))
  751. nil nil t)))))
  752. (provide 'exitter)