Cuaderno de GNUtas

Crear un árbol de Org (automáticamente) con entradas de Elfeed

Ahora que los blogs y demás publicaciones periódicas de Internet que pretenden juntar más de un párrafo de lectura están de capa caída, en detrimento de las redes ego-sociales, es cuando apetece más seguirlos. Para hacerlo desde GNU Emacs de una forma placentera contamos con la excelente biblioteca elispiana y agregador de feeds Elfeed. Y entre los feeds que tengo incluidos para consultar sus medios desde allí no sólo están los (pocos, bien es cierto) blogs que sigo, sino algunos feeds de noticias más o menos interesantes o intensos, como el que va dando cuenta de los nuevos paquetes añadidos al ecosistema TeX del CTAN, que se halla en un estado de perpetuo crecimiento. Pero como esta consulta la hago muy a menudo, se me ocurrió escribir algo de Elisp para poder generar automáticamente una entrada en un documento de notas y apuntes varios de Org Mode que me recogiera los últimos paquetes subidos en los últimos seis meses y que se actualizase a un golpe M-x. Código que detallaremos a continuación, por si fuese de utilidad a alguien. Verán que no es difícil adaptarlo a cualquier otro tipo de feed periódico.

elfeed1.png

Figura 1: Lista de los últimos paquetes actualizados en CTAN mostrada por Elfeed

Comenzamos por definir esta esta primera función almacena en una lista cada línea de un texto marcado como elemento.

(defun lineas-a-lista ()
  (if (region-active-p)
      (progn
        (narrow-to-region (region-beginning) (region-end))
        (split-string (buffer-string) "\n" nil))
    (error "ninguna región marcada")))

La siguiente función intercala cada línea de entradas de la página de resultados de Elfeed con el contenido de cada entrada. Para ello nos resultará útil la expresión -interleave del paquete dash (aprovechando también una idea de la que ya hablaba aquí a cuento de una función para intercalar líneas entre dos textos):

(defun intercala-elfeed ()
  (setq lineas-b (lineas-a-lista))
  (setq lineas-c (-interleave lineas-b entradas-elfeed))
  (replace-regexp ".+" "" nil (region-beginning) (region-end))
  (deactivate-mark)
  (insert
   (mapconcat 'identity lineas-c "\n")))

Y esta otra función que recoge una lista con el contenido de cada entrada de Elfeed. Usa un simple bucle que busca por líneas que comienzan por dígitos, ya que la página de resultados de Elfeed siempre muestra una fecha al comienzo de cada línea (véase fig. 1).

(defun mi-elfeed-almacena-entradas ()
  "almacena los contenidos de cada entrada marcada en la lista de
Elfeed"
  (setq entradas-elfeed nil)
  (save-excursion
    (goto-char (point-min))
    (save-window-excursion
      (while
          (re-search-forward "\\(^[[:digit:]]+-\\)" nil t)
        (call-interactively 'elfeed-search-show-entry)
        (add-to-list 'entradas-elfeed (buffer-string)
                     (switch-to-buffer "*elfeed-search*"))))))

Y ya, por último, la función que hace el resto del trabajo. Consistirá en lo siguiente: insertar, en la posición del cursor un encabezado de primer nivel que contenga todas las entradas que sigan el criterio de búsqueda de Elfeed (ver línea 12 la expresión elfeed-search-set-filter) como subárboles. Y cada vez que evaluemos la función a la altura del primer nivel de ese árbol se actualizará todo. El resultado lo podemos ver en la fig. 2.

(defun inserta-ctan-nuevo-elfeed ()
  (interactive)
  (when
      (save-excursion
        (beginning-of-line)
        (looking-at-p "\\*+\s+Paquetes nuevos en CTAN"))
    (save-restriction
      (org-narrow-to-subtree)
      (delete-region (point-min) (point-max))))
  (save-window-excursion
    (call-interactively 'elfeed)
    (elfeed-search-set-filter "@6-months-ago +TeX New on CTAN ")
    (setq entradas-marcadas (replace-regexp-in-string "\\([[:graph:]]+\\)\s\s+.+$" "\\1" (buffer-string)))
    (mi-elfeed-almacena-entradas)
    (kill-buffer))
  (let*
      ((entradas (with-temp-buffer
                   (insert entradas-marcadas)
                   ;; un carácter `temporal' para evitar falsos positivos
                   (reemplaza "\\(^[[:digit:]]+-\\)" "¡>¡\\1")
                   (mark-whole-buffer)
                   (intercala-elfeed)
                   (deactivate-mark)
                   (whitespace-cleanup)
                   (buffer-string)))
       (final (with-temp-buffer
                (org-mode)
                (org-insert-heading)
                (insert (concat "Paquetes nuevos en CTAN (últimos 6 meses)"
                                " -- "
                                (format-time-string "%d/%m/%y")))
                (save-restriction
                  (org-narrow-to-subtree)
                  (goto-char (point-max))
                  (insert entradas)
                  (save-excursion
                    (goto-char (point-min))
                    (while (re-search-forward "\\(^¡>¡\\)" nil t)
                      (replace-match "" t nil)
                      (beginning-of-line)
                      (org-insert-heading))
                    (goto-char (point-min))
                    (forward-line)
                    (while (re-search-forward "^* " nil t)
                      (org-do-demote))))
                (buffer-string))))
    (insert (format "%s" final))))

elfeed2.png

Figura 2: Nuestra lista de entradas de Elfeed como un árbol de Org Mode

Estrambote: una versión para publicar en un blog la lista de entradas actualizada

Realmente esta función anterior es la segunda versión de otra anterior que escribí con el objeto de mantener (con actualización periódica y automática) una lista de paquetes nuevos de CTAN en mi blog tipográfico, hermano de éste, La lunotipia. A diferencia de la otra, lo que viene a hacer esta función es devolver el resultado como una cadena de texto:

(defun ctan-nuevo-elfeed ()
  (interactive)
  (save-window-excursion
    (call-interactively 'elfeed)
    (elfeed-search-set-filter "@6-months-ago +TeX New on CTAN ")
    (setq entradas-marcadas (replace-regexp-in-string "\\([[:graph:]]+\\)\s\s+.+$" "\\1" (buffer-string)))
    (mi-elfeed-almacena-entradas)
    (kill-buffer))
  (let*
      ((entradas (with-temp-buffer
                   (insert entradas-marcadas)
                   ;; un carácter temporal para evitar falsos positivos
                   (reemplaza "\\(^[[:digit:]]+-\\)" "¡>¡\\1")
                   (mark-whole-buffer)
                   (intercala-elfeed)
                   (deactivate-mark)
                   (whitespace-cleanup)
                   (buffer-string)))
       (final (with-temp-buffer
                (org-mode)
                (org-insert-heading)
                (insert (concat "Paquetes nuevos en CTAN (últimos 6 meses)"
                                " -- "
                                (format-time-string "%d/%m/%y")))
                (save-restriction
                  (org-narrow-to-subtree)
                  (goto-char (point-max))
                  (insert entradas)
                  (save-excursion
                    (goto-char (point-min))
                    (while (re-search-forward "\\(^¡>¡\\)" nil t)
                      (replace-match "" t nil)
                      (beginning-of-line)
                      (org-insert-heading))
                    (goto-char (point-min))
                    (forward-line)
                    (while (re-search-forward "^* " nil t)
                      (org-do-demote))))
                (buffer-string))))
    (format "%s" final)))

Y en La Lunotipia sólo tengo que añadir este bloque de código que se evalúa durante la exportación y exporta los resultados en bruto (resultado de la exportación en la fig. 3):

#+begin_src emacs-lisp :exports results :results raw
(ctan-nuevo-elfeed)
#+end_src

elfeed3.png

Figura 3: Lista de paquetes nuevos en CTAN en mi blog La Lunotipia

Actualización de 04/06/21

Se me ocurrió esta otra posibilidad bastante más elegante, que hace uso de org-map-entries. La ventaja evidente es que podemos crear varios árboles con distíntos parámentros de Elfeed, cada uno añadido como valor de una propiedad, llamada :ELFEED:. En la siguiente función org-map-entries sólo escaneará en el documento actual, y únicamente en aquellas entradas que tengan dicha propiedad.

Definimos antes esta función, que es una versión de la primera, pero con algunas mejoras:

(defun mi-org/prepara-elfeed ()
  (interactive)
  (let* ((element (org-element-at-point))
         (prop (org-element-property :ELFEED element))
         (level (org-element-property :level element)))
    (save-restriction
      (org-narrow-to-subtree)
      ;; este condicional es necesario para vaciar el contenido del
      ;; sub-árbol, y añadir una nueva actualización
      (when
          (re-search-forward "\\*+\s+Entradas" nil t)
        (save-restriction
          (org-narrow-to-subtree)
          (delete-region (point-min) (point-max)))))
    (save-restriction
      (org-narrow-to-subtree)
      (goto-char (point-max))
      (save-window-excursion
        (elfeed-update)
        (call-interactively 'elfeed)
        (elfeed-search-set-filter prop)
        (setq entradas-marcadas (replace-regexp-in-string "\\([[:graph:]]+\\)\s\s+.+$" "\\1" (buffer-string)))
        (mi-elfeed-almacena-entradas)
        (kill-buffer))
      (let*
          ((entradas (with-temp-buffer
                       (insert entradas-marcadas)
                       ;; un carácter temporal para evitar falsos positivos
                       (reemplaza "\\(^[[:digit:]]+-\\)" "¡>¡\\1")
                       (mark-whole-buffer)
                       (intercala-elfeed)
                       (deactivate-mark)
                       ;; añadimos un carácter de espacio cero a
                       ;; aquellas líneas que comienzan por un
                       ;; asterisco, para que no se traten luego como
                       ;; encabezados de Org
                       (reemplaza "\\(^\\*\\)" "\u200B\\1")
                       (whitespace-cleanup)
                       (buffer-string)))
           (entradas-final (with-temp-buffer
                             (org-mode)
                             (org-insert-heading)
                             (insert (concat "Entradas " " -- " "última actualización: " (format-time-string "%d/%m/%y")))
                             (save-restriction
                               (org-narrow-to-subtree)
                               (goto-char (point-max))
                               (insert entradas)
                               (save-excursion
                                 (goto-char (point-min))
                                 (while (re-search-forward "\\(^¡>¡\\)" nil t)
                                   (replace-match "" t nil)
                                   (beginning-of-line)
                                   (org-insert-heading))
                                 (goto-char (point-min))
                                 (forward-line)
                                 (while (re-search-forward "^\\* " nil t)
                                   (replace-match "** "))))
                             (buffer-substring (point-min) (point-max)))))
        (org-paste-subtree (1+ level) entradas-final)))))

Y, finalmente, la función con org-map-entries:

(defun mi-org/insertar-entradas-elfeed ()
  (interactive)
  (org-map-entries #'mi-org/prepara-elfeed
                   "+ELFEED={.+}" 'file nil))

Y para expandir la lista de entradas en mi blog automáticamente, me basta con tener esta entrada así:

* Lista de paquetes nuevos en CTAN (últimos 6 meses)
  :PROPERTIES:
  :ELFEED: @6-months-ago +TeX New on CTAN
  :END:

#+begin_src emacs-lisp :exports results :results silent
  (save-restriction
    (org-narrow-to-subtree)
    (goto-char (point-min))
    (mi-org/insertar-entradas-elfeed))
#+end_src

Publicado: 10/05/21

Última actualización: 16/08/23


Índice general

Acerca de...

Esta obra está bajo una licencia de Creative Commons Reconocimiento-NoComercial 4.0 Internacional.

© Juan Manuel Macías
Creado con esmero en
GNU Emacs