Introduzione§

È prassi molto comune in parecchi siti web, ma soprattutto in blog e testate giornalistiche, quella di citare dei tweet nel corpo del testo. A tal fine, si impiega solitamente il widget ufficiale fornito da Twitter: un copia e incolla sembra sufficiente a compiere la magia. Purtroppo, questa magia si paga a caro prezzo.

Ciò che davvero avviene è che la pagina web che ospita il tweet viene infiltrata da un iframe per ogni tweet, da cui conseguono:

  • Un aumento considerevole del peso della pagina web (quindi più latenza per le connessioni lente e più consumi energetici a carico della rete);
  • L'introduzione di svariati tracker che monitorano l'attività del lettore.

Partendo dal presupposto che si stia scrivendo in Markdown per un generatore di siti statici, come possiamo affrontare il problema?

Obiettivo§

La via più immediata potrebbe consistere nel copiare ed incollare il testo del tweet nel corpo del testo, ma mancherebbero molte informazioni utili, come il nome utente, la data esatta in cui il tweet è stato pubblicato, l'URL eccetera.

Perché il risultato sia accettabile, dobbiamo dare un po' di struttura alla citazione, per esempio ricopiando i campi rilevanti in un blockquote definito da una classe caratteristica (twitter-tweet) e poi stilizzando il gruppo di elementi in CSS. Volendo citare questo tweet del Telescopio Hubble, punteremmo ad un risultato come il seguente:

<blockquote class="twitter-tweet">
  <p lang="en" dir="ltr">Happy 241st anniversary to Uranus, discovered by William Herschel on this day in 1781! This Hubble image shows the planet with faint rings and … what are those six white spots surrounding the planet? <a href="https://t.co/a9zsqNg88V">https://t.co/a9zsqNg88V</a> 
  <a href="https://t.co/gFb70ZqTnh">pic.twitter.com/gFb70ZqTnh</a>
  </p>&mdash; Hubble Space Telescope (@HubbleTelescope) <a href="https://twitter.com/HubbleTelescope/status/1503023123565891584">March 13, 2022</a>
</blockquote>

Soluzione senz'altro più completa di un banale copia-incolla, ma a dir poco scomoda, e noi non possiamo accettare nessun risultato che abbia un rapporto costi/benefici così distante dalla soluzione fornita dal Widget. Ma, se scrivere a mano decine di righe di HTML è senz'altro fuori discussione, nessuno ci vieta di automatizzare questo processo.

L'ipotesi shortcode§

È possibile delegare al generatore la produzione di tutto quel markup per mezzo di uno shortcode ed è proprio l'approccio proposto da Hugo nella documentazione.

Input Pass the tweet’s user (case-insensitive) and id from the URL as parameters to the tweet shortcode.

{{< tweet user="SanDiegoZoo" id="1453110110599868418" >}}

Output Using the preceding tweet example, the following HTML will be added to your rendered website’s markup:

<blockquote class="twitter-tweet"><p lang="en" dir="ltr">Owl bet you&#39;ll lose this staring contest 🦉 <a href="https://t.co/eJh4f2zncC">pic.twitter.com/eJh4f2zncC</a></p>&mdash; San Diego Zoo Wildlife Alliance (@sandiegozoo) <a href="https://twitter.com/sandiegozoo/status/1453110110599868418?ref_src=twsrc%5Etfw">October 26, 2021</a></blockquote><script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

Avendo apprezzato l'idea, ho inizialmente emulato questo approccio sul mio generatore statico, Zola, che ha una sintassi leggermente diversa perché più vicina a quella di Jinja2. Per citare lo stesso tweet, avrei scritto:

{ { twitter(id=1453110110599868418) }}

Il vantaggio è che questo secondo shortcode ha bisogno del solo ID del tweet, ma sotto la scocca quel che accade è molto simile:

  • Sfruttando le API di Twitter, il generatore realizza una chiamata HTTP ed ottiene un piccolo file JSON;
  • Alla key html del nostro JSON corrisponde lo snippet visto in output poco sopra.

Come avrete già intuito, ho abbandonato questa via perché nonostante gli indubbi vantaggi, presenta comunque dei problemi piuttosto gravi. Certo, questo shortcode si scrive velocemente, ma non è fruibile per se stesso. Come si legge su markdownguide.org

Markdown is future proof. Even if the application you’re using stops working at some point in the future, you’ll still be able to read your Markdown-formatted text using a text editing application.

Lo shortcode, invece, oscura completamente il contenuto del tweet che stiamo citando e lega la leggibilità del testo al lavoro compiuto dal generatore, e non da un generatore qualunque, ma da quel generatore specifico con quel tema specifico e quindi quello shortcode specifico, che a sua volta deve essere sistematicamente aggiornato perché sia in linea con le attuali API di Twitter. E qui veniamo a quello che è forse il problema più grave di tutti: poiché abbiamo affidato il recupero dei dati al generatore, questo significa che le informazioni verranno recuperate ogni volta che modifichiamo il sito (da cui una certa inefficienza) e quindi dovremo la nostra capacità di rigenerare il sito dipenderà da:

  • La presenza di una connessione;
  • L'accesso a Twitter;
  • L'accesso ad ogni specifico tweet citato.

Basta che solo una persona elimini uno dei tweet citati nel nostro sito e saremo costretti ad intervenire manualmente con uno dei metodi seguenti:

  • Eliminando la citazione (influenzando in maniera irreversibile la fruizione dei contenuti)
  • Prelevando lo snippet generato in una precedente versione del sito ed incollandolo in corrispondenza dello shortcode disfunzionale.

A questo punto ho realizzato che il problema non era risolvibile con ulteriori modifiche allo shortcode, perché l'errore era di carattere logico, stava a monte e consisteva, cioè, nel pensare che fosse compito del generatore occuparsi di gestire le nostre citazioni, che invece devono persistere nel documento.

Ma se copiare i contenuti non è compito del browser (widget di Twitter), né dello scrittore (copia-incolla), né del generatore (shortcode), chi dovrebbe occuparsene? Esatto, l'editor.

Arriva Emacs§

spongebob-squarepants-episode-3

Ci viene in soccorso Emacs1 assieme ad una bella manciata di lisp. Sono sicuramente numerosi i modi in cui potremmo agevolare il nostro lavoro, per esempio si potrebbe facilitare la compilazione dello shortcode, svincolando il generatore dal dover recuperare i dati dal web, ma lasciando che preservi il suo ruolo nella produzione di markup. Questa via, però, implica la complicazione della struttura ed un notevole incremento dei parametri. Ho esplorato altrove questa idea perché utile in altri contesti, ma nel caso di twitter è più comodo evitare lo shortcode ed implementare l'intera logica in Lisp.

(^ Qui sopra potete vedere all'opera il prodotto finito)

Requisiti e metodo§

L'obiettivo rimane quello di partenza, cioè includere nel corpo del testo un blockquote con tutte le informazioni necessarie, ma sfruttando solo l'ID del tweet (o, ancora più comodamente, un URL adeguato).

Perché Emacs sia capace di generare lo snippet, c'è bisogno che:

  • Accetti in input una stringa con il nostro URL;
  • Effettui una richiesta ai server di Twitter con le API corrette;
  • Interpretare correttamente la risposta HTTP;
  • Interpretare correttamente la stringa JSON;
  • Popolare il buffer con i dati corretti.

Per chiunque fosse interessato esclusivamente alla soluzione già pronta, è possibile saltare in fondo all'articolo e saltare i prossimi paragrafi.

Invece a chi, come me, stesse ancora imparando a farsi strada tra le parentesi di Emacs Lisp, potrebbe interessare il metodo con cui ho costruito la funzione. Iniziamo.

Accettare un input dall'utente nel minibuffer§

Perché una funzione accetti un input, è sufficiente introdurre la funzione (interactive):

(defun get-uri (uri)
  "Get my URI and do some stuff"
  (interactive "sEnter URI: ")
  ...
  )

Manipolazione dell'URL§

Ci torna più che utile URL Package, in particolar modo per l'encoding dell'URL con hex code con url-hexify-string. A seguire l'URL per ottenere il file JSON con tutte le informazioni sul tweet in uso, per lo meno sfruttando le attuali API di Twitter oEmbed.

(concat "https://publish.twitter.com/oembed?url=" (url-hexify-string uri))))

Performare la richiesta§

URL Package ci mette a disposizione la funzione url-retrieve e url-retrieve-synchronously. Entrambi si occupano di performare la richiesta, ma il primo fa una chiamata asincrona e l'altro sincrona (bloccante). Optiamo qui per l'approccio sincrono, che è il più semplice e si adatta bene alla nostra situazione.

(defun get-json-tweet (uri)
  "Get tweet as JSON via Twitter API"
  (interactive "sEnter URL: ")
  (with-current-buffer (url-retrieve-synchronously
   (concat "https://publish.twitter.com/oembed?url=" (url-hexify-string uri)))))

Al fine di non incasinare il buffer in uso con i contenuti in entrata, apriamo lo scratchpad (SPC+x in Doom Emacs) e richiamiamo questa funzione. Ci si aspetterebbe di visualizzare il nostro JSON, magari decorato dall'header della risposta HTTP ed altri elementi. Purtroppo, in risposta il minibuffer ci mostra: "Contacting host" senza mai consentirci di visualizzare il contenuto del JSON richiesto. Non si vedono errori, quindi la richiesta dovrebbe essere andata a buon fine, ma con una funzione come questa non facciamo nulla.

Esploriamo un po' di soluzioni individuate online. Partendo da questo suggerimento, introduciamo una piccola variazione alla funzione precedente:

(defun get-tweet-json (uri)
  "Get tweet as JSON via Twitter API"
  (interactive "sEnter URL: ")
  (with-current-buffer
    (url-insert-file-contents
     (concat "https://publish.twitter.com/oembed?url=" (url-hexify-string uri)))))

Finalmente il nostro buffer mostra il contenuto del file JSON! Congratulazioni. Purtroppo, però, il minibuffer pare lamentarsi di qualcosa:

save-current-buffer: Wrong type argument: stringp, ("<hexified-url>" <chars int number>)

Poiché l'unica modifica effettuata consiste nell'introduzione di url-insert-file-contents, è evidente che qualcosa sia andato storto nell'esecuzione di questa funzione, ma solo in un momento successivo alla parte che si occupa di mandare al buffer il contenuto della nostra stringa json.

Per capire in che modo operi esattamente la funzione, possiamo approfittare della documentazione interna ad Emacs, accessibile con estrema facilità: è sufficiente spostare il cursore sul simbolo di interesse (in questo caso url-insert-file-contents) e lanciare Ctrl+h seguito da f (più brevemente, con la notazione che utilizzerò d'ora in avanti, C-h f).

Alla sezione "Source Code", possiamo leggere il sorgente:

;; Defined in /usr/share/emacs/27.2/lisp/url/url-handlers.el.gz
;;;###autoload
(defun url-insert-file-contents (url &optional visit beg end replace)
  (let ((buffer (url-retrieve-synchronously url)))
    (unless buffer (signal 'file-error (list url "No Data")))
    (when (fboundp 'url-http--insert-file-helper)
      ;; XXX: This is HTTP/S specific and should be moved to url-http
      ;; instead.  See bug#17549.
      (url-http--insert-file-helper buffer url visit))
    (url-insert-buffer-contents buffer url visit beg end replace)))

Analizziamo riga per riga.2

Prima riga§
(defun url-insert-file-contents (url &optional visit beg end replace)
  • defun la macro che definisce la funzione;
  • url-insert-file-contents: è il simbolo che assegniamo alla funzione;
  • url: è l'argomento necessario alla funzione;
  • &optional: introduce i restanti argomenti (visit beg end replace), che sono facoltativi (?).
Seconda riga§
(let ((buffer (url-retrieve-synchronously url))))
  • let (uno special form) ci consente di creare un cosiddetto local scope (per un'introduzione meno impegnativa, leggi qua). Ogni nostra operazione da qui in avanti all'interno della funzione sarà confinata in questo scope, il quale viene quindi restituito come output della funzione. Si tratta di un dettaglio non trascurabile, visto che in altri linguaggi si opera esplicitando il return (come in Python) oppure, dando per scontato che l'ultimo elemento della funzione sia restituito (come in Rust).
  • buffer: si tratta di un elemento citato qui e non dichiarato prima, quindi si suppone che sia una variabile che riprendiamo dal global scope. Per verificare che una variabile sia stata dichiarata, utilizziamo boundp, come suggerito qui. Se la variabile è stata dichiarata, la restituisce, in caso sia "void", allora restituisce nil.
(boundp 'buffer)
;; => nil

Da qui apprendiamo che no, buffer non è una variabile globalmente conosciuta. Sta accadendo qualcosa di più raffinato: la variabile viene legata ad un valore al volo senza passare da setq, proprio grazie al fatto che abbiamo introdotto un local scope con let. In questo modo:

(let ((abracadabra 5))         ; Locally bind it.
    (boundp 'abracadabra))
  => t

L'esempio viene dalla documentazione.

Il resto della riga acquisisce finalmente senso compiuto.

  • buffer è una variabile locale che ci stiamo impegnando a creare sul momento.
  • (url-retrieve-synchronously url) è la funzione che restituisce il valore che stiamo assegnando a buffer.

Abbiamo già incontrato url-retrieve-synchronously, quindi sappiamo che si tratta della funzione in grado di eseguire il download vero e proprio del file richiesto, per mezzo dell'URL fornito da noi. Il contenuto del nostro JSON, che nella funzione iniziale veniva recuperato per poi restare sospeso nel vuoto, stavolta viene inizialmente storato in buffer. Non ci resta che lavorare su questa variabile.

Terza riga§
(unless buffer (signal 'file-error (list url "No Data")))

Questa riga si occupa di emettere un segnale di errore in caso di fallimento dell'operazione di download.

Quarta riga§
(when (fboundp 'url-http--insert-file-helper) ...)
  • when è un altro special form e si spiega da sé;
  • fboundp somiglia molto alla funzione boundp introdotta appena: se la variabile è void restituisce nil. A differenza di boundp, se la variabile è presente non restituisce la variabile stessa, ma t.
  • 'url-http--insert-file-helper: è la variabile sottoposta al controllo di fboundp.

In pratica, questa quarta riga è un controllo supplementare volto a verificare la presenza di una funzione che torna utile nella riga successiva.

Quinta riga§
(url-http--insert-file-helper buffer url visit))
  • url-http--insert-file-helper è una funzione poco documentata, ma come si può evincere dal sorgente si occupa di analizzare il response status della risposta HTTP.
  • buffer, url e visit sono gli argomenti che passiamo alla funzione.

Come dicevo, si tratta di una funzione poco documentata e non c'è verso di trovarla citata in giro per il web, quindi, senza che nessuno ci prenda per mano, è necessario compiere un altro passo nella tana del bianconiglio (in fondo l'ingresso di ogni tana somiglia a delle parentesi).

Un altro piccolo excursus§

Sorgente di url-http--insert-file-helper:

;; Defined in /usr/share/emacs/27.2/lisp/url/url-http.el.gz
(defun url-http--insert-file-helper (buffer url &optional visit)
  (with-current-buffer buffer
    (when (bound-and-true-p url-http-response-status)
      ;; Don't signal an error if VISIT is non-nil, because
      ;; 'insert-file-contents' doesn't.  This is required to
      ;; support, e.g., 'browse-url-emacs', which is a fancy way of
      ;; visiting the HTML source of a URL: in that case, we want to
      ;; display a file buffer even if the URL does not exist and
      ;; 'url-retrieve-synchronously' returns 404 or whatever.
      (unless (or visit
                  (or (and (>= url-http-response-status 200)
                           (< url-http-response-status 300))
                      (= url-http-response-status 304))) ; "Not modified"
        (let ((desc (nth 2 (assq url-http-response-status url-http-codes))))
          (kill-buffer buffer)
          ;; Signal file-error per bug#16733.
          (signal 'file-error (list url desc)))))))

Fermi tutti.
Niente paura.
Molte righe sono semplici commenti e non analizzeremo riga per riga.
Possiamo farcela.

La funzione accetta il buffer in uso, l'url (che serve solo per restituire eventuali errori all'utente e far capire cosa l'abbia generato) ed un argomento facoltativo detto visit. Se ho ben capito, se questo valore è nullo siamo dinnanzi ad una 404, altrimenti si procede. La funzione procede esplorando le altre possibili risposte (200, 300, 304), quindi dichiara un local scope in cui succedono cose interessanti:

(let ((desc (nth 2 (assq url-http-response-status url-http-codes))))

Per comprendere assq bisogna prima comprendere che in lisp non esistono vere e proprie hashmap (Rust, C++, Java...) o dictionaries (Python), ma possiamo ottenere un tipo simile col corrispettivo di una matrice, cioè una lista bidimensionale o, nel corretto gergo di Emacs Lisp, "Association List" (ALIST):

(assq 'y '((x . 1)
           (y . 2)
           (z . 3)))
           
;; => (y . 2) 

Questo esempio viene direttamente dalla documentazione della funzione assq ed ha già tutto: vediamo che la funzione necessita di due argomenti, entrambi citati: un simbolo (KEY) ed una lista bidimensionale (ALIST).

Return non-nil if KEY is eq to the car of an element of ALIST.

In pratica cicla tutti gli elementi della lista e restituisce la lista che contiene la KEY che abbiamo fornito come primo argomento. Volevamo y? Ecco, è presente in (y . 2). Da qui, recuperare il terzo valore della lista è semplicissimo: basta usare nth 2, proprio come è stato fatto nella funzione che stiamo analizzando. E che valore abbiamo recuperato? Niente meno che la descrizione corrispondente al nostro response status http. Trattandosi della descrizione, non possiamo che assegnarla alla variabile locale desc, la quale viene subito restituita all'esaurirsi dello scope. Missione compiuta, chiudiamo il buffer o comunichiamo eventuali errori, se presenti.

Insomma, in conclusione la quinta riga è servita solo a recuperare status HTTP di risposta e descrizione relativa. Supponendo che tutto sia andato per il meglio, si passa all'ultima riga, la sesta.

Sesta riga§
(url-insert-buffer-contents buffer url visit beg end replace)))
  • url-insert-buffer-contents: inserisce il contenuto del buffer che diamo come argomento (cioè quello dichiarato nello scope locale) nel buffer corrente.

Dalla documentazione:

Insert the contents of BUFFER into current buffer. This is like url-insert, but also decodes the current buffer as if it had been inserted from a file named URL.

Performare la richiesta, il reboot§

Finito il nostro viaggio all'interno di questa funzione, credo si possa intuire il perché dell'errore iniziale: bisogna distinguere tra il buffer in cui abbiamo trascritto la stringa e l'elemento effettivamente restituito dalla funzione! La funzione, infatti, restituisce una lista contenente due elementi:

  • Una stringa contenente l'URL
  • Il numero di caratteri occupati nel buffer

E quale argomento è necessario fornire a with-current-buffer? Esatto, controlliamo nella documentazione.

Execute the forms in BODY with BUFFER-OR-NAME temporarily current.

BUFFER-OR-NAME must be a buffer or the name of an existing buffer. The value returned is the value of the last form in BODY. See also with-temp-buffer.

Non una lista, né una stringa: "DEVE essere un buffer o il nome di un buffer esistente".

Riformuliamo quanto abbiamo imparato leggendo la funzione url-insert-file-contents per ottenere il solo buffer con il minor numero di passaggi, mantenendo tutte le utili precauzioni:

;; Assign your buffer to a new variable
(setq uri <your-actual-uri>)

(setq buff (let ((buffer (url-retrieve-synchronously uri)))
    (unless buffer (signal 'file-error (list uri "no data")))
    (when (fboundp 'url-http--insert-file-helper)
      (url-http--insert-file-helper buffer uri))
    buffer))
    
;; Display the buffer content as string
(with-current-buffer buff (buffer-string))

Il minibuffer, a questo punto, dovrebbe mostrare qualcosa di simile:

"HTTP/1.1 200 OK
date: Mon, 14 Mar 2022 00:28:23 GMT
server: tsa_o
expires: Wed, 18 Feb 2122 00:28:24 GMT
content-type: application/json; charset=utf-8
[...]
content-disposition: attachment; filename=json.json
[...]

{ YOUR JSON }"

JSON Parsing§

Dal buffer ottenuto qui sopra, dobbiamo prima togliere gli headers e dopo effettuare il parsing del body contenente il nostro JSON vero e proprio. La buona notizia è che da Emacs 27 in avanti sono state messe a disposizione delle funzioni dedicate, tra cui json-read e json-parse-buffer.

Dalla documentazione di json-read:

;; If called with the following JSON after point

  {"a": [1, 2, {"c": false}],
   "b": "foo"}

;; you will get the following structure returned:

  ((a .
      [1 2
         ((c . :json-false))])
   (b . "foo"))

Sembra adatto al nostro caso.
Applichiamolo al nostro buffer, detto buff:

(with-current-buffer buff
  (goto-char (point-min))  ; torniamo all'inizio del buffer
  (re-search-forward "^$") ; saltiamo gli headers
  (json-read))             ; parsiamo la stringa json

In conclusione, la funzione§

Immaginando di avere assegnato alla variabile parsed la nostra alist, recuperare il valore della key "html" è semplice:

(cdr (assq 'html parsed))

Raccogliamo tutta la logica vista finora in un'unica funzione:

 (defun quote-tweet (uri)
  "Get tweet HTML in Emacs Lisp"
  (interactive "sEnter URL: ")
  (setq uri (concat "https://publish.twitter.com/oembed?url=" (url-hexify-string uri)))
  (let ((parsed (with-current-buffer (let ((buffer (url-retrieve-synchronously uri)))
                                       (unless buffer (signal 'file-error (list uri "no data")))
                                       (when (fboundp 'url-http--insert-file-helper)
                                         (url-http--insert-file-helper buffer uri))
                                       buffer)
                  (goto-char (point-min))
                  (re-search-forward "^$")
                  (json-read))))
    (insert (cdr (assq 'html parsed))))
    ;; A final message for the user would be nice
    (message "Here is your tweet!"))

Suggerisco di evitare lo script di twitter, se non necessario, modificando lievemente la quarta riga:

(setq uri (concat "https://publish.twitter.com/oembed?url=" (url-hexify-string uri) "&omit_script=true"))

Demo§

Che altro?§

Ovviamente, non è finita, anzi, è solo l'inizio: partendo da questa funzione è sicuramente possibile:

  • Pretty printing dell'XML in uscita dalla funzione;
  • Intervenire sul codice del blockquote perché l'immagine sia mostrata e non solo linkata;

Come extra, pensavo anche di lavorare ad una funzione di unrolling per i thread su Twitter da rilasciare come modulo separato, ma questo dipende dalla mia disponibilità di tempo libero e da quanto si riveleranno essere amichevoli le restanti API di Twitter, con cui non ho mai avuto a che fare (finora).

Ah, quasi dimenticavo, se questo post vi è stato utile per imparare lisp e/o per la funzione in sé, offritemi un caffè o due su Ko-fi o Paypal e condividetelo ovunque crediate possa essere d'aiuto.

Note§

1

Sì, è probabile che si possano raggiungere risultati simili anche con vim, ma vuoi veramente implementare questa roba in Vimscript? O_o

2

Un pensiero va al mio amico Luca, che recentemente mi ha chiesto se programmare mi diverta: situazioni come questa non sono certo le più entusiasmanti; solitamente è più eccitante lasciarsi trasportare dallo sviluppo della logica che si vuole implementare che dal debugging o dall'apprendimento della sintassi, MA sono proprio queste le parti più istruttive, che ci pongono al di fuori della nostra comfort zone e ci spingono a ragionare insieme a chi ha concepito la libreria che stiamo adoperando, quasi come fossimo in una mente a sciame asincrona costituita da quella nicchia internazionale di sviluppatori. Nel caso di Elisp, si tratta di aggiungere il proprio filo ad un groviglio di processi psichici che procede da oltre trent'anni: affascinante, no?