Blaugust 2026 Feeds - 以及我是如何用 Elisp 製作的

Blaugust 2026 Feeds

以及我是如何用 Elisp 製作的

生成了一份 Blaugust 2026 Feeds (更新於 2026-08-05,137 / 162),如果你需要可以點擊下載,然後导入到你的 RSS 閱讀器中。如果你想 參加 Blaugust,現在就是最好的時間!

這份 OMPL 文件裡沒有完全包擴 Blaugust 2026 – Meet the Mentors & Participant List 中的博客,因為有的博客無法訪問,有的在頁面上找不到訂閱連結,所以缺失了几個博客。如果在連結裡找不到你的博客,歡迎邮件聯系我,我很樂意更新進去。連結我會定期更新(可能每隔 7 天),你可以看連結後的更新日期了解是否更新過。

我主要是通過博客主頁中的 去查找博客的訂閱連結,一般可以在主頁的 <head> 裡找到:

但我發現有的博客并沒有添加,我建議有空可以加上去,這有助於 RSS 閱讀工具 (和我) 發現你博客的訂閱連結。

如果頁面上有 ,可以打開頁面的 Web Developer Tools,执行這段 JS 找到頁面下所有的訂閱連結。

Array.from(document.querySelectorAll("link[rel=alternate][type*=application]"))
  .map(n => console.log(n.href))

有的博客沒有設置 ,我會在頁面中查找有沒有 RSS 訂閱連結;如果還是沒有,我也會嘗試在博客域名後面加上 /feed.xml/rss.xml 看看能不能找到。大多時候我都能找到,但有的博客不提供我就沒辦法了,如果你的博客也是這樣的,我請求你加上 以及在頁面上提供明顯的訂閱連結。

另外 owls 也製作了一份 OPML 訂閱文件 ⸺ The Blaugust 2026 Post Firehose,他也分享了他是如何製作的,可以去看看。

截止目前 Blaugust 的參加者大約有 90 多個,如果一個個博客點開去找訂閱連結會花不少時間,所以我是用一段 Elisp 脚本去抓取的。為什麼是 Elisp?因為我不熟悉 Elisp,想著可以順便學習一下如何通過 Elisp 抓取 HTML 頁面信息。

大體的流程是這樣的:

  • 拉取 Blaugust 2026 – Meet the Mentors & Participant List 的 HTML,解析所有參加者的域名和博客名字。
  • 遍歷所有參加者的域名,获取主頁的 HTML,從中查找 和 ,获取到訂閱 URL。(有的博客沒提供,或者有的做了爬虫防护,可能會获取不到;另外也可能會获取到多個連結,除了文章的,還有評論的;有的是 /feed/ ,還要拼接上域名。)
  • 生成一份文件,包含所有訂閱數據,我會檢查一遍,处理找不到訂閱連結的博客。
  • 用文件數據按照 OPML 格式生成 OPML 文件。

如果你也用 Emacs,你或許會對實現的 Elisp 代碼感興趣。

Elisp 代碼

;;; init-blaugust.el --- Fetch Blaugust Participant -*- lexical-binding: t -*-
;;; Commentary:
;;; Code:

(require 'plz)
(require 'dom)
(require 'rx)
(require 'cl-lib)

(defun spike-leung/blaugust--fetch-parse-dom (url then else)
  "Fetch URL and parse html to dom.
THEN and ELSE are callback pass to plz, see `plz' for more info."
  (plz 'get url
    :as (lambda () (libxml-parse-html-region (point-min) (point-max)))
    :then then
    :else else))

(defun spike-leung/blaugust--get-feeds-from-link (url callback)
  "Get RSS or Atom feed links from URL.
Call CALLBACK with a list of feeds."
  (spike-leung/blaugust--fetch-parse-dom
   url
   (lambda (dom)
     (let* (
            ;; find all feed  tag
            (feed-link-tags
             (and dom
                  (seq-filter
                   (lambda (elt)
                     (and (equal "alternate" (dom-attr elt 'rel))
                          (member (dom-attr elt 'type) '("application/rss+xml"
                                                         "application/atom+xml"))))
                   (dom-by-tag dom 'link))))
            ;; get feed url from 
            (feeds
             (if feed-link-tags
                 (mapcar (lambda (link-tag)
                           (let ((href (dom-attr link-tag 'href)))
                             (if (string-match-p (rx bos (or "https://" "http://")) href)
                                 href
                               (url-expand-file-name href url))))
                         feed-link-tags)
               nil)))
       (funcall callback feeds)))
   ;; ignore error
   (lambda (_err)
     (message "Request failed: %S" _err)
     (funcall callback nil))))

(defun spike-leung/blaugust--save-blogroll (blogroll output-file start-time &optional old-blogroll)
  "Save BLOGROLL to OUTPUT-FILE.
Calc duration with START-TIME.
If OLD-BLOGROLL not nil, merge BLOGROLL with EXSITING-BLOGROLL."
  (let* ((elapsed (float-time (time-subtract (current-time) start-time)))
         (merged-blogroll
          (if old-blogroll
              (spike-leung/blaugust--merge-blogroll blogroll old-blogroll)
            blogroll))
         (sorted-blogroll (sort (copy-sequence merged-blogroll)
                                (lambda (a b)
                                  (string< (downcase (plist-get a :title))
                                           (downcase (plist-get b :title)))))))
    (message "Collect finish (%.1fs). Write result to %s..." elapsed output-file)
    (make-directory (file-name-directory output-file) t)
    (with-temp-file output-file
      (insert (pp-to-string sorted-blogroll)))
    (message "Wrote blogroll to %s (%.1fs)" output-file elapsed)))

(defun spike-leung/blaugust--merge-blogroll (new old)
  "Merge curated OLD blogroll into NEW by :domain.
If a NEW entry has no :feeds but OLD has :feeds for the same domain,
reuse OLD's :feeds.  Also keep OLD entries not present in NEW."
  (let ((old-table (make-hash-table :test #'equal))
        result)
    ;; init table
    (dolist (o old)
      (puthash (plist-get o :domain) o old-table))
    (dolist (n new)
      ;; if n's feeds is nil while o's feeds not, merge
      (let* ((key (plist-get n :domain))
             (old-entry (gethash key old-table))
             (old-feeds (and old-entry (plist-get old-entry :feeds)))
             (new-feeds (plist-get n :feeds)))
        (when (and old-feeds (null new-feeds))
          (message "Reusing curated feeds for %s" key)
          (setq n (plist-put (copy-sequence n) :feeds old-feeds)))
        (push n result)
        (remhash key old-table)))
    ;; Any old entries whose domain did not appear in new are pushed into result too.
    (maphash (lambda (_key o) (push o result)) old-table)
    ;; Reverse result because push builds it backwards.
    (nreverse result)))

(defun spike-leung/blaugust--collect-feed-for-participant (participant callback)
  "Collect feeds for PARTICIPANT.
Call CALLBACK with PARTICIPANT augmented by :feeds."
  (spike-leung/blaugust--get-feeds-from-link
   (plist-get participant :domain)
   (lambda (feeds)
     (funcall callback (plist-put participant :feeds feeds)))))

(defun spike-leung/blaugust--collect-participant-links (participant-list-url callback)
  "Collect participant entries from PARTICIPANT-LIST-URL.
Call CALLBACK with a list of plists (:title :link)."
  (spike-leung/blaugust--fetch-parse-dom
   participant-list-url
   (lambda (dom)
     (let ((participant-a-tags
            (and dom
                 (mapcan (lambda (wp-block-list)
                           (dom-by-tag wp-block-list 'a)
                           )
                         (cdr (dom-by-class dom "wp-block-list"))))))
       (funcall callback
                (mapcar
                 (lambda (atag)
                   (let ((title (dom-text atag))
                         (domain (dom-attr atag 'href)))
                     `(:title ,title :domain ,domain)))
                 participant-a-tags))))
   (lambda (_err)
     (funcall callback nil))))

(defun spike-leung/blaugust--collect-feeds (participant-list-url output-file)
  "Collect feeds from PARTICIPANT-LIST-URL.
Save data to OUTPUT-FILE."
  (interactive "sParticipant List URL: \nFOutput File:")
  (let ((start-time (current-time))
        (existing-blogroll (and (file-exists-p output-file)
                                (ignore-errors
                                  (spike-leung/blaugust--read-blogroll output-file)))))
    (message "Start collecting Blaugust feeds...")
    (condition-case err
        (spike-leung/blaugust--collect-participant-links
         participant-list-url
         (lambda (participants)
           (let ((total (length participants))
                 (completed 0)
                 (blogroll nil))
             (if (zerop total)
                 (message "No blogroll found.")
               (message "Collecting feeds from %d blogs..." total)
               (dolist (participant participants)
                 (spike-leung/blaugust--collect-feed-for-participant
                  participant
                  (lambda (participant-with-feeds)
                    (push participant-with-feeds blogroll)
                    (cl-incf completed)
                    (message "Collected %d/%d: %s" completed total (plist-get participant :title))
                    (when (= completed total)
                      (spike-leung/blaugust--save-blogroll blogroll output-file start-time existing-blogroll)))))))))
      (error
       (message "Failed to fetch participant page: %s" participant-list-url)
       nil))))

(defun spike-leung/blaugust--read-blogroll (input-file)
  "Read INPUT-FILE as blogroll data."
  (seq-filter (lambda (feed)
                (not (null (plist-get feed :feeds))))
              (with-temp-buffer
                (insert-file-contents input-file)
                (read (current-buffer)))))

(defun spike-leung/blaugust--export-opml (title input-file output-file)
  "Export feeds from INPUT-FILE plist to OPML-formatted OUTPUT-FILE.
TITLE is the title in opml file.
Use `spike-leung/blaugust--collect-feeds' to generate INPUT-FILE."
  (declare (completion elfeed--mode-p))
  (interactive "sTitle in OPML file: \nFInput file: \nFOutput OPML file: ")
  (let* ((feeds (spike-leung/blaugust--read-blogroll input-file)))
    (with-temp-file output-file
      (let ((standard-output (current-buffer)))
        (princ "\n")
        (xml-print
         `((opml ((version . "1.0"))
                 (head ()
                       (title () ,title))
                 (body ()
                       ,@(cl-loop for feed in feeds
                                  for url = (car (plist-get feed :feeds))
                                  for domain = (plist-get feed :domain)
                                  for title = (or (plist-get feed :title) "")
                                  collect `(outline ((xmlUrl . ,url)
                                                     (htmlUrl . ,domain)
                                                     (title . ,title)
                                                     (text . ,title))))))))))))

(defun spike-leung/blaugust--generate-elfeed-feeds (input-file)
  "Generate elfeed feeds from blaugust feeds.
INPUT-FILE is the blaugust feeds generated
by `spike-leung/blaugust--collect-feeds'."
  (interactive "FInput file:")
  (let* ((feeds (spike-leung/blaugust--read-blogroll input-file))
         (existing-feed-urls
          (mapcar (lambda (feed)
                    (cond
                     ((stringp feed) feed)
                     ((listp feed) (car feed))
                     (t nil)))
                  elfeed-feeds))
         (blaugust-elfeed-feeds
          (mapcar (lambda (feed)
                    `(
                      ;; feed url
                      ,(car (plist-get feed :feeds))
                      ;;title
                      :title
                      ,(plist-get feed :title)
                      ;; set no-update t as default
                      ;; :no-update t
                      ;; tags
                      blaugust2026)
                    )
                  feeds))
         (existing-feeds
          (seq-filter (lambda (feed)
                        (member (car feed) existing-feed-urls))
                      blaugust-elfeed-feeds))
         (new-feeds
          (mapcar (lambda (feed)
                    ;; add tag for new feeds
                    (append feed '(new-feeds)))
                  (seq-filter (lambda (feed)
                                (not (member (car feed) existing-feed-urls)))
                              blaugust-elfeed-feeds))))
    (with-current-buffer (get-buffer-create "*blaugust-elfeed-feeds*")
      (erase-buffer)
      (insert ";; Already exist\n")
      (insert (pp-to-string existing-feeds))
      (insert "\n;; New\n")
      (insert (pp-to-string new-feeds))
      (lisp-mode)
      (display-buffer (current-buffer)))))

(provide 'init-blaugust)
;;; init-blaugust.el ends here

Elisp 代碼主要用到了:

  • alphapapa/plz.el,用來發起請求,plz 提供了回調方法,也提供了多種返回體格式,讓处理請求更容易。
  • dom.el 是 Emacs 內置的,提供了一些处理 DOM 的方法:
    • libxml-parse-html-region 解析 HTML
    • dom-by-class 等方法查找 DOM
    • dom-attr 等获取 DOM 信息

使用時:

  • spike-leung/blaugust--collect-feeds ,輸入 Blaugust 2026 – Meet the Mentors & Participant List 的連結和一個文件名,就會從連結拉取所有的 participant 的信息保存到文件中。
  • 檢查文件中的信息 (可以通過 consult-lineembark-export 過濾出 :feeds nil 的部分),补全沒拉取到的博客信息。沒拉取到的博客,會顯示成 :feeds nil
  • spike-leung/blaugust--export-opml ,基於上面生成的文件生成 OPML 文件。
  • 如果你用 elfeed,還可以調用 spike-leung/blaugust--generate-elfeed-feeds ,這會和己有的 elfeed-feeds 比對,顯示一個临時 buffer,列出哪些己經在 elfeed-feeds 裡、哪些是新的,數據格式和 elfeed-feeds 是一樣的,可以方便地添加到 elfeed 里 (默認會加上 blaugust2026 的 tag,對原來 elfeed-feeds 中沒有的還會加上 new-feeds 的 tag,便於過濾)。

感谢你的阅读!

欢迎 邮件 跟我分享你的想法 :)

你也可以 訂閱 我的博客,保持更新 :P

祝好,素未某面的读者。

純文本版本 原始 org 文件

添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论