diff options
-rw-r--r-- | exitter.el | 266 |
1 files changed, 256 insertions, 10 deletions
@@ -136,7 +136,8 @@ ;;; The official way of doing so is by oauth 1.0a documented at ;;; https://developer.x.com/en/docs/authentication/oauth-1-0a, but ;;; here we use the twitter frontend flow, similar to how one logs in -;;; the twitter web client +;;; the twitter web client, see also +;;; https://github.com/fa0311/TwitterFrontendFlow (defun exitter-get-guest-token () (when exitter-debug (message "entering exitter-get-guest-token")) (request exitter-url-activate @@ -366,9 +367,9 @@ (or oauth-token-secret exitter-oauth-token-secret))) (signature (url-hexify-string - (print (base64-encode-string - (exitter-hmac-sha1 (encode-coding-string signing-key 'utf-8) - (encode-coding-string to-sign 'utf-8)))))) + (base64-encode-string + (exitter-hmac-sha1 (encode-coding-string signing-key 'utf-8) + (encode-coding-string to-sign 'utf-8))))) ) ;; (message "PARAM-TO-SIGN-UNENC: %s" (prin1-to-string param-to-sign-unencoded)) ;; (message "PARAM-TO-SIGN: %s" (prin1-to-string param-to-sign)) @@ -381,7 +382,7 @@ oauth-params ", ")))) -(defun exitter-do-fetch (link params &optional headers) +(defun exitter-do-fetch (link params &optional headers cb) (when exitter-debug (message "entering exitter-do-fetch")) (let ((authorization (exitter-get-sign-oauth link params "GET"))) (request link @@ -409,9 +410,13 @@ ONEPLUS A3010 Build/PKQ1.181203.001)") :params params :type "GET" :parser 'json-read - :success (cl-function - (lambda (&key data &allow-other-keys) - (pp data))) + :success (if cb + (cl-function + (lambda (&key data &allow-other-keys) + (funcall cb data))) + (cl-function + (lambda (&key data &allow-other-keys) + (pp data)))) :error (cl-function (lambda (&rest args &key error-thrown &allow-other-keys) (message "Got error: %S" error-thrown))) @@ -431,13 +436,254 @@ ONEPLUS A3010 Build/PKQ1.181203.001)") ("withVoice" . :json-false) ("withV2Timeline" . t) )))) - (message "VARIABLES: ") (exitter-do-fetch exitter-url-tweet-detail `(("variables" . ,variables) - ("features" . ,exitter-default-features))))) + ("features" . ,exitter-default-features)) + nil + (lambda (data) + ;; (pp data) + (exitter-save-posts (exitter-filter-tweet-details data) id))))) + +;;; transform alist +(defun exitter-filter-tweet-details (resp) + (vconcat + (seq-map + 'exitter-filter-tweet-entry + (alist-get + 'entries + (let-alist resp + (elt .data.threaded_conversation_with_injections_v2.instructions 0)))))) + +;;; .content.entryType == "TimelineTimelineItem": + +;;; If further its .content.itemContent.itemType=="TimelineTweet", +;;; then this is a simple case and we could filter its +;;; .content.itemContent.tweet_results.result. Otherwise if its +;;; .content.itemContent.itemType=="TimelineTimelineCursor" then it is +;;; paging which we need to figure out later. + +;;; .content.entryType == "TimelineTimelineModule": +;;; has ~items~ containing entries. But these entries do not have +;;; content nor entryType anywhere. Instead they each have an +;;; item.itemContent.tweet_results.result + +(defun exitter-filter-tweet-entry (entry) + (let-alist entry + (cond + ((equal .content.entryType "TimelineTimelineItem") + (let-alist .content.itemContent + (pcase .itemType + ("TimelineTweet" + (exitter-filter-tweet-result .tweet_results.result)) + ("TimelineTimelineCursor" + (message "TimelineTimelineCursor encountered. More tweets available")) + (_ + (error "TimelineTimelineItem entry with unknown itemType: %s" + (pp entry)))))) + ((equal .content.entryType "TimelineTimelineModule") + (vconcat (seq-map 'exitter-filter-tweet-entry .content.items))) + ((equal .item.itemContent.itemType "TimelineTweet") + (exitter-filter-tweet-result .item.itemContent.tweet_results.result)) + ((equal .item.itemContent.itemType "TimelineTimelineCursor") + (message "TimelineTimelineCursor encountered. More tweets available")) + (t (error "Entry with unknown entryType or itemType: %s" (pp entry)))))) + +(defun exitter-filter-tweet-result (result) + (pcase (alist-get '__typename result) + ("Tweet" + (let (ret author quoted) + (let-alist result + (let-alist .core.user_results.result.legacy + (setq author + `((screen_name . ,.screen_name) + (name . ,.name)))) + (when .quoted_status_result + (setq quoted + (exitter-filter-tweet-result .quoted_status_result.result))) + (let-alist .legacy + (push + `(post . + ((id_str . ,.id_str) + (created_at . ,.created_at) + (full_text . ,.full_text) + (reply_count . ,.reply_count) + (retweet_count . ,.retweet_count) + (quote_count . ,.quote_count) + (favorite_count . ,.favorite_count) + (in_reply_to_status_id_str . ,.in_reply_to_status_id_str) + (is_quote_status . ,.is_quote_status) + (author . ,author) + (quoted . ,quoted))) + ret)) + ) + ret)) + ("TweetWithVisibilityResults" + (message "Aaaaaaads")) + (_ + (error "result with unknown __typename: %s" result)))) + +;;; renderer, similar to mastorg +(require 'hierarchy) +(defun exitter-post-make-parent-fn (posts) + "Given a collection of POSTS, return a function that find the parent post." + (lambda (post) + (let ((id (alist-get 'in_reply_to_status_id_str post))) + (seq-find + (lambda (candidate) + (equal (alist-get 'id_str candidate) id)) + posts)))) + +;;; Formatting functions +(defun exitter-format-posts (filtered-details) + "Format a post tree of post located at URL. + +Including ancestors and descendants, if any." + (let ((posts-hier (hierarchy-new)) + (posts (exitter-vector-flatten filtered-details))) + (hierarchy-add-trees + posts-hier + posts + (exitter-post-make-parent-fn posts)) + (string-join + (hierarchy-map 'exitter-format-post posts-hier 1) + "\n"))) + +(defun exitter-save-posts (filtered-details id) + ;; (pp filtered-details) + (exitter-save-text-and-switch-to-buffer + (exitter-format-posts filtered-details) + (format "~/Downloads/%s.org" id))) + +(defun exitter-format-post (post level) + "Format a POST with indent LEVEL." + (let-alist post + (format "%s %s (@%s) %s\n\n%s%s\n\nā¤·%s ā%s ā%s ā
%s\n" + (make-string level ?*) + .author.name + .author.screen_name + (exitter--relative-time-description .created_at) + .full_text + (if .quoted + (format "\n\n----\n%s----" + (replace-regexp-in-string + "^." " \\&" (exitter-format-post .quoted.post 1))) + "") + .reply_count + .quote_count + .retweet_count + .favorite_count + ))) + +(defun exitter-open-post (url) + (interactive "sTwitter link: ") + (let ((path-etc (url-filename (url-generic-parse-url url)))) + (unless (string-match "^/[^/]+/status/\\([0-9]+\\)" path-etc) + (error "Not a valid x/twitter (or a frontend) url!")) + (exitter-get-tweet (match-string 1 path-etc)))) ;;; utilities + +;;; code adapted from mastodon.el +(defun exitter--human-duration (seconds &optional resolution) + "Return a string describing SECONDS in a more human-friendly way. +The return format is (STRING . RES) where RES is the resolution of +this string, in seconds. +RESOLUTION is the finest resolution, in seconds, to use for the +second part of the output (defaults to 60, so that seconds are only +displayed when the duration is smaller than a minute)." + (cl-assert (>= seconds 0)) + (unless resolution (setq resolution 60)) + (let* ((units exitter--time-units) + (n1 seconds) (unit1 (pop units)) (res1 1) + n2 unit2 res2 + next) + (while (and units (> (truncate (setq next (/ n1 (car units)))) 0)) + (setq unit2 unit1) + (setq res2 res1) + (setq n2 (- n1 (* (car units) (truncate n1 (car units))))) + (setq n1 next) + (setq res1 (truncate (* res1 (car units)))) + (pop units) + (setq unit1 (pop units))) + (setq n1 (truncate n1)) + (if n2 (setq n2 (truncate n2))) + (cond + ((null n2) + ;; revert to old just now style for < 1 min: + (cons "just now" 60)) + ;; (cons (format "%d %s%s" n1 unit1 (if (> n1 1) "s" "")) + ;; (max resolution res1))) + ((< (* res2 n2) resolution) + (cons (format "%d %s%s" n1 unit1 (if (> n1 1) "s" "")) + (max resolution res2))) + ((< res2 resolution) + (let ((n2 (/ (* resolution (/ (* n2 res2) resolution)) res2))) + (cons (format "%d %s%s, %d %s%s" + n1 unit1 (if (> n1 1) "s" "") + n2 unit2 (if (> n2 1) "s" "")) + resolution))) + (t + (cons (format "%d %s%s, %d %s%s" + n1 unit1 (if (> n1 1) "s" "") + n2 unit2 (if (> n2 1) "s" "")) + (max res2 resolution)))))) + +(defconst exitter--time-units + '("sec" 60.0 ;; Use a float to convert `n' to float. + "min" 60 + "hour" 24 + "day" 7 + "week" 4.345 + "month" 12 + "year")) + +(defun exitter--relative-time-details (timestamp &optional current-time) + "Return cons of (DESCRIPTIVE STRING . NEXT-CHANGE) for the TIMESTAMP. +Use the optional CURRENT-TIME as the current time (only used for +reliable testing). +The descriptive string is a human readable version relative to +the current time while the next change timestamp give the first +time that this description will change in the future. +TIMESTAMP is assumed to be in the past." + (let* ((time-difference (time-subtract current-time timestamp)) + (seconds-difference (float-time time-difference)) + (tmp (exitter--human-duration (max 0 seconds-difference)))) + ;; revert to old just now style for < 1 min + (cons (concat (car tmp) (if (string= "just now" (car tmp)) "" " ago")) + (time-add current-time (cdr tmp))))) + +(defun exitter--relative-time-description (time-string &optional current-time) + "Return a string with a human readable TIME-STRING relative to the current time. +Use the optional CURRENT-TIME as the current time (only used for +reliable testing). +E.g. this could return something like \"1 min ago\", \"yesterday\", etc. +TIME-STAMP is assumed to be in the past." + (car (exitter--relative-time-details + (encode-time (parse-time-string time-string)) current-time))) + +(defun exitter-save-text-and-switch-to-buffer (text file-name) + "Save TEXT to FILE-NAME and switch to buffer." + (let ((buffer (find-file-noselect file-name)) + (coding-system-for-write 'utf-8)) + (with-current-buffer buffer + (let ((inhibit-read-only t)) + (erase-buffer) + (insert text)) + (goto-char (point-min)) + (save-buffer) + (revert-buffer t t)) + (switch-to-buffer buffer))) + +(defun exitter-vector-flatten (v) + (cond + ((and (vectorp v) (length= v 0)) v) + ((vectorp v) + (vconcat (exitter-vector-flatten (elt v 0)) + (exitter-vector-flatten (subseq v 1 (length v))))) + ((and (listp v) (alist-get 'post v) (vector (alist-get 'post v)))) + (t []))) + (require 'bindat) (defun exitter-nonce () |