From 3002def33284c3788a11d3633ce7ea2cd0082e7a Mon Sep 17 00:00:00 2001 From: eltrac Date: Sun, 23 Aug 2026 18:29:50 +0800 Subject: [draft] update --- content/article/skeleton-programing.md | 246 ++++++++++++++++++++++++++++++++- 1 file changed, 245 insertions(+), 1 deletion(-) (limited to 'content') diff --git a/content/article/skeleton-programing.md b/content/article/skeleton-programing.md index 3472eef6..975416b5 100644 --- a/content/article/skeleton-programing.md +++ b/content/article/skeleton-programing.md @@ -448,7 +448,26 @@ Ran 1 tests containing 2 assertions. 0 failures, 0 errors. ``` -测试通过!接下来应该考虑边界条件,写更多的测试,然后还要写 `workers.clj` 的单元测试,看它能不能正常启动 N 个爬虫,能不能正确消耗 `crawl-queue` 里的任务等等。具体的就省略了。 +测试通过!接下来应该考虑边界条件,比方说,如果 `:get-crawl` 没有返回任何结果,`hop` 就应该停下,返回空集。 + +```clojure {hl_lines=[5,7]} +(deftest hop-empty-test + (testing "Hop should be able to handle empty result from :get-crawl" + (let [result-chan (chan) + pls {:put-result (partial put! result-chan) + :get-crawl (fn [_] [])} + ody (->Ody 0 #(>= (last %) 5) pls) + want #{}] + ;; match journey results + (is (= (set @(start ody)) want)) + ;; collect channel and count + (loop [counter 0] + (if (poll! result-chan) + (recur (inc counter)) + (is (= counter (count want)))))))) +``` + +测试依然通过,接下来要考虑更多的边界情况,还要写 `workers.clj` 的单元测试,看它能不能正常启动 N 个爬虫,能不能正确消耗 `crawl-queue` 里的任务等等。具体的就省略了。 总之,合理的抽象、接口和骨架式的编程不仅可以让人在开发核心功能时只关注核心逻辑,而不被技术细节困扰,还能让单元测试变得容易,不必等到整个项目近乎完成时才做集成测试。 @@ -458,6 +477,231 @@ Ran 1 tests containing 2 assertions. 不过,LLM 生成代码的版权状态未知,我不知道 Agent 填进我仓库的代码是从谁哪里偷的,应该遵从什么开源协议,应该署谁的名字,所以我不会用 LLM 生成的代码。接下来我将手写接口实现,把所有组件都装配起来,产生可运行的软件原型。 +#### `eurylochus.clj` + +`Crawler`(爬虫)协议的实现类,我决定命名为 `eurylochus`(欧律洛卡斯),他是奥德修斯的二把手,在登陆瑟茜的岛屿时,也是他带队探路。 + +要发送 HTTP `GET` 请求,最常用的 Clojure 库应该是 [clj-http](https://github.com/dakrone/clj-http)。至于解析 HTML,之前有听说过 [hickory](https://github.com/clj-commons/hickory),可以把 HTML 转换为 Clojure 数据结构,还兼容 [hiccup](https://github.com/weavejester/hiccup) 的 X-Expression(用来表示 XML 的 S-Expression),不过它的选择器语法看起来太吓人了,浏览之后找到了一个 [JSoup](https://jsoup.org/)(Java 版的 BeautifulSoup)的 Clojure 包装,试了试好像也够用了。虽然这个 [html-parser](https://github.com/bschwind/html-parser) 十二年没有人维护,总共只提交过三次 Commit,看着也有点吓人,但大不了回头再换一个。 + +```clojure +(ns ody.eurylochus + "Eurylochus is scout for Odysseus. It implements Crawler protocol." + (:require [ody.protocols :refer [Crawler]] + [clj-http.client :as client] + [html-parser.core :refer [document-from-string select]])) + +(defrecord eurylochus [home] Crawler + (find-ways [_this url] + (let [resp (client/get url) + status (:status resp) + doc (document-from-string (:body resp))] + (when (= status 200) + (->> (select doc "a") + (map #(.attr % "href")) + (map #(if (= \/ (first %)) + (str home %) + %))))))) +``` + +按下快捷键把这段代码发送到 nREPL 里,然后直接在 REPL 里 Eval 看看效果,就用我自己博客的某期周刊 URL 当作例子吧。 + +![](https://img.eltr.ac/geedeapro/article/skeleton-programing/eurylochus_clj_conjure.png "这是 Neovim 的 [Conjure](https://github.com/Olical/conjure) 插件,可以在编辑器里把代码发送给 REPL") + +果然,提取 `` 标签的 `href` 属性会拿到很多不想要的东西,比如锚点和内链,但我并不打算在 `eurylochus` 这里剔除,别忘了,还有一个叫作 `Gatekeeper` 的协议就是来做筛选的。 + +#### `polites.clj` + +`Gatekeeper` 的实现我想用奥德修斯最好的朋友 `polites`(波利特斯)来命名,要做的就是筛选 `eurylochus` 拿到的 URL,同时控制数量。好在 Clojure 的 `filter` 和 `remove` 函数就很适合干这活儿,最后再用 `take` 函数拿一定数量的 URL 返回就好了。 + +```clojure +(ns ody.polites + "Polites is Odysseus' best friend. It implements Gatekeeper protocols and keep unwanted URLs at bay." + (:require [ody.protocols :refer [Gatekeeper]] + [clojure.string :as string]) + (:import (java.net URI))) + +(defn- hostname [url] + (.getHost (URI. url))) + +(defrecord polites [blocklist limit] Gatekeeper + (filter-ways [_this urls] + (->> urls + (filter #(or (string/starts-with? % "https://") + (string/starts-with? % "http://"))) + (remove #((set blocklist) (hostname %))) + (take limit)))) +``` + +在 REPL 里测试,最后得到的列表很干净。 + +![](https://img.eltr.ac/geedeapro/article/skeleton-programing/polites_clj_conjure.png) + +目前还不支持通配符 `*`,没关系,之后再说。 + +#### `zeus.clj` + +让天神宙斯 `zeus` 来判断 `journey` 是否结束再合适不过了。不过我没有创建名为 `zeus` 的对象或者说类,仅仅是在 `ody.zeus` 这个命名空间下定义了 `over?` 函数。 + +```clojure +(ns ody.zeus + "Zeus determines if Odysseus finishes his journey. It provides over? predicate function." + (:import [java.net URI])) + +(defn- ring-by-exact? [journey] + (.equals (URI. (first journey)) + (URI. (last journey)))) + +(defn- ring-by-domain? [journey] + (= (.getHost (URI. (first journey))) + (.getHost (URI. (last journey))))) + +(defn over? [law max-hop journey] + (let [ring? (cond (= law "exact") ring-by-exact? + (= law "domain") ring-by-domain? + :else ring-by-domain?)] + (or (ring? journey) + (>= (count journey) max-hop)))) +``` + +你可能发现 `ody.zeus/over?` 有三个参数,而 `ody.clj` 中使用的却是只有一个参数的 `over?`,参数对不上怎么办?没关系,还记得 `partial` 吗?给 `ody` 传入 `(partial over? law max-hop)`,也就是预先填好两个参数的函数引用,就可以了。 + +### 装配 + +是时候把骨头和肉拼起来了,这一步很简单,只需要把各个组件初始化,按照接口定义装配在一起,然后启动就好: + +```clojure +(ns ody.core + (:require [ody.ody :refer [->Ody]] + [ody.workers :refer [->workers]] + [ody.pipelines :refer [make-pipelines]] + [ody.eurylochus :refer [->eurylochus]] + [ody.polites :refer [->polites]] + [ody.zeus :refer [over?]] + [ody.protocols :refer [start start-workers]] + [clojure.core.async :refer [chan Ody home + (partial over? (:ring-standard config) + (:max-hop config)) + (make-pipelines result-queue crawl-queue)) + workers (->workers (->eurylochus) + (->polites (:domain-blocklist config) + (:url-limit config)) + crawl-queue)] + (start-workers workers (:worker-count config)) + (println "Crawling wokers started.") + (let [all-ref (start ody)] + (println (str "Odysseus is on his way. Starting from " home)) + (go-loop [journey (> urls + (filter #(or (string/starts-with? % "https://") + (string/starts-with? % "http://"))) + (remove #(url-equal current %)) + (remove #((set blocklist) (hostname %))) + (sort-by #(-> % path depth) >) + (take limit)))) +``` + +另外,还有很多路径经过了 Google 等根本不可能导向我自己的网站的大站点,嗯…… 果然还是要写通配符匹配呢。 + +```clojure {hl_lines="7"} +(defrecord polites [blocklist limit] Gatekeeper + (filter-ways [_this urls current] + (->> urls + (filter #(or (string/starts-with? % "https://") + (string/starts-with? % "http://"))) + (remove #(url-equal current %)) + (remove #(some (fn [pattern] (match-glob pattern (hostname %))) blocklist)) + (sort-by #(-> % path depth) >) + (take limit)))) +``` + +`match-glob` 函数来自 [stch-library/glob](https://github.com/stch-library/glob),果然还是偷别人的代码好用啊。不过这种小库竟然没有 `LICENSE` 文件,用起来还挺不放心的,之后要换掉。在配置里面写上一些大网站的黑名单看看: + +```clojure +(def config {:crawl-queue-buffer 10 + :worker-count 5 + :domain-blocklist ["github.com" + "google.com" + "*.google.com" + "bandcamp.com" + "*.bandcamp.com" + "gohugo.io" + "*.gohugo.io" + "codeberg.org" + "*.codeberg.org" + "apple.com" + "*.apple.com" + "creativecommons.org"] + :url-limit 3 + :ring-standard "domain" + :max-hop 10}) +``` + +再次运行,很快就找到了第一条环路。 + +```clojure +["https://www.geedea.pro/weekly/94/" + "https://michaelharley.net/posts/2026/06/15/bloggers-can-we-make-better-titles-for-our-posts/" + "https://social.bubbles.town/@bubbles/statuses/01KV5P7PK0VFSMTGASPRA3C2KV" + "https://bubbles.town/entry/34746979" + "https://michaelharley.net/posts/2026/06/15/bloggers-can-we-make-better-titles-for-our-posts/" + "https://www.geedea.pro/weekly/94/"] +``` + +从我的[第 94 期周刊](/weekly/94),先是去到了 Michael Harley 的《Bloggers, can we make better titles for our posts?》,然后去到了 Bubbles(一个博客聚合平台)的社交媒体贴(GoToSocial 的前端不需要 JavaScript 也能显示内容,所以或去到了外链),然后去到了 Bubbles 上这篇文章的页面,然后由回到了 Harley 的文章,由于我引用了他的文章并且发送了 Webmention,最后通过 Webmention 链接回到了我的网站。挺奇妙的旅程。 + +不过接下来就没有那么顺利了,很多路径都是达到 `max-hop` 限制放弃的路径,不过倒是发现了不少有趣的链接,或许这个软件的作用也可是帮助从自己的网站出发发现更多的内容。目前的限制是,每个 URL 最多发散出三条外链,一条 `journey` 最多包含 `10` 个节点。即便做了黑名单限制,也很难剪枝掉大部分的任务,最后要爬取的网页数量接近 3 的十次方。 + +或许这个软件不适合在本地运行,更适合一直跑在服务器上,每找到一条合格的路径就显示在网页上,或者通过 [Bark](https://github.com/Finb/Bark) 和 [ntfy](https://github.com/binwiederhier/ntfy) 给自己发送通知。好在我们的业务逻辑是高度抽象的,只需要在 `main` 组件里包装一层 HTTP 服务器就好。由此可见,软件要做成命令行应用、GUI 应用还是 Web 服务,都只是外层的细节,只要业务逻辑保持高度抽象,就能够让人机交互层也变成插件。 + +## 反思:对象是必要的吗? + +软件能跑了, + +https://tangled.org/eltrac.tngl.sh/ody/tree/feat_core + ## 推荐阅读 本文的部分观点来自以下书籍: -- cgit v1.2.3