本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 [署名 4.0 国际 (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.zh) 本文作者: 苏洋 创建时间: 2022年12月14日 统计字数: 11361字 阅读时间: 23分钟阅读 本文链接: https://soulteary.com/2022/12/14/rsscan-convert-website-information-stream-to-rss-feed-part-3.html ----- # RSS Can:将网站信息流转换为 RSS 订阅源(三) 第三篇内容里,我们来聊聊把结构化数据转换为可以订阅的 RSS 订阅数据源。 ## 写在前面 通过前两篇文章[《RSS Can:使用 Golang 实现更好的 RSS Hub 服务(一)》](https://soulteary.com/2022/12/12/rsscan-better-rsshub-service-build-with-golang-part-1.html)和[《RSS Can:借助 V8 让 Golang 应用具备动态化能力(二)》](https://soulteary.com/2022/12/13/rsscan-make-golang-applications-with-v8-part-2.html),我们已经能够将网站上的资讯信息,通过动态配置的方式整理成结构化的数据。 本篇文章,我们来简单聊聊,如何将这些结构化的数据变成可订阅的 RSS 订阅源,让网站的数据能够和我们的 RSS 阅读器“连通”起来。 ## RSS 格式标准 在聊代码实现之前,不论是作为开发者、还是作为 RSS 产品用户,了解下 RSS 格式标准还是非常有必要的。 互联网上关于 “RSS” 的格式标准比较出名的有三种流派,分别是:[Atom](https://validator.w3.org/feed/docs/atom.html)、 [RSS](https://validator.w3.org/feed/docs/rss2.html)、[JSON Feed](https://www.jsonfeed.org/version/1/),第三种出现于 RSS 式微,应用和呼声都不大,因此主要网络应用支持的格式都在集中在前两者:RSS 和 Atom。 TLDR,简单来说,如果你是内容提供方,你希望你的内容能够被更多的人用各种各样的 RSS 客户端访问,选择一定被支持的 RSS 2.0 将保持非常好的兼容性。如果你是读者,考虑到持续追踪文章的更新,以及更好的阅读体验,当网站同时提供多种 RSS 订阅格式时,**不妨优先选择 Atom 格式的 RSS 订阅源** 。 当然,本文中我们将借助开源软件库一并将前两篇文章中整理好的数据,一并输出为三种格式。(**反正没什么成本**) ### Atom 格式相比较 RSS 2.0 的主要优势 如果你不想针对 “RSS” 进行细致的开发,我们只了解使用即可,这个小节的内容可以跳过。 1. 能够标记字段中的 HTML 内容是否经过转义或编码,方便开发者在渲染时使用数据。 2. 不再需要将内容的“正文”和“摘要”都混在 `description` 字段中,提供了新的 `summary` 字段,可以区分“摘要”和“正文”,同时允许在正文中添加非文本内容。 3. “RSS” 存在几个变体版本,Atom 更为稳定和一致。 4. 提供了符合 XML 标准的命名空间、能够使用 XML 内置的标签来支持相对地址的描述、能够使用 XML 内置标签告诉订阅者内容语言、支持 XML Schema,这些 RSS 2.0 都不具备。 5. 每一个信息条目具备唯一 ID,订阅者能够追踪具体的内容的更新。 6. 有统一明确的时间表示规范,方便程序进行处理。 7. 在 IANA 注册了 `application/atom+xml` 的 MIME 媒体类型,将其变成了标准规范,RSS 使用的 `application/rss+xml` 还没有纳入标准。 ## 使用 Go 转换数据为 RSS Feed 格式 Go 生态中支持生成 RSS Feed 的软件包有很多,我选择的是有十年维护历史的 [gorilla/feeds](https://github.com/gorilla/feeds)。虽然在这个月的 9 号,维护团队宣布开源组织内的仓库都将进入“休眠状态”(存档),不再进行维护。 但是,对于我们的需求来说,RSS 是一个“古老、稳定”的协议,gorilla/feeds 已经经过了长时间的验证,所以选择使用它还是比较合适的。加之,对于这类不活跃维护或者停止维护的项目,还可以通过 Go 的特殊的包管理方式,来帮助我们管理代码,做代码维护变更,这块我们后续的文章中会提到。 ### Gorilla Feeds 的一般使用 我们先来了解如何使用 Gorilla Feeds 来生成 RSS Feed 格式的订阅源,先引入软件包: ```go import ( "time" "github.com/gorilla/feeds" ) ``` 这里之所以同时引入了 `time` ,是因为我不想麻烦的手动造数据。因为不同的 RSS 格式,对于时间的要求并不相同,所以关于时间的处理,后续展开一篇内容来聊,或许更为合适。 我们先以之前发布过的文章为例,编写一段 Mock 数据,等会用来测试 RSS 订阅源的生成: ```go now := time.Now() feed := &feeds.Feed{ Title: "苏洋博客", Link: &feeds.Link{Href: "https://soulteary.com/"}, Description: "醉里不知天在水,满船清梦压星河。", Author: &feeds.Author{Name: "soulteary", Email: "soulteary@gmail.com"}, Created: now, } feed.Items = []*feeds.Item{ { Title: "RSS Can:借助 V8 让 Golang 应用具备动态化能力(二)", Link: &feeds.Link{Href: "https://soulteary.com/2022/12/13/rsscan-make-golang-applications-with-v8-part-2.html"}, Description: "继续聊聊之前做过的一个小东西的踩坑历程,如果你也想高效获取信息,或许这个系列的内容会对你有用。", Author: &feeds.Author{Name: "soulteary", Email: "soulteary@qq.com"}, Created: now, }, { Title: "RSS Can:使用 Golang 实现更好的 RSS Hub 服务(一)", Link: &feeds.Link{Href: "https://soulteary.com/2022/12/12/rsscan-better-rsshub-service-build-with-golang-part-1.html"}, Description: "聊聊之前做过的一个小东西的踩坑历程,如果你也想高效获取信息,或许这个系列的内容会对你有用。这个事情涉及的东西比较多,所以我考虑拆成一个系列来聊,每篇的内容不要太长,整理负担和阅读负担都轻一些。本篇是系列第一篇内容。", Author: &feeds.Author{Name: "soulteary", Email: "soulteary@gmail.com"}, Created: now, }, { Title: "在搭载 M1 及 M2 芯片 MacBook设备上玩 Stable Diffusion 模型", Link: &feeds.Link{Href: "https://soulteary.com/2022/12/10/play-the-stable-diffusion-model-on-macbook-devices-with-m1-and-m2-chips.html"}, Description: "本篇文章,我们聊了如何使用搭载了 Apple Silicon 芯片(M1 和 M2 CPU)的 MacBook 设备上运行 Stable Diffusion 模型。", Created: now, }, { Title: "使用 Docker 来快速上手中文 Stable Diffusion 模型:太乙", Link: &feeds.Link{Href: "https://soulteary.com/2022/12/09/use-docker-to-quickly-get-started-with-the-chinese-stable-diffusion-model-taiyi.html"}, Description: "本篇文章,我们聊聊如何使用 Docker 快速运行中文 Stable Diffusion 模型:太乙。 ", Created: now, }, } ``` 接着,编写简单的调用语句,数据就可以“转换”成我们需要的结果啦: ```go atom, err := feed.ToAtom() if err != nil { log.Fatal(err) } rss, err := feed.ToRss() if err != nil { log.Fatal(err) } json, err := feed.ToJSON() if err != nil { log.Fatal(err) } fmt.Println(atom, "\n", rss, "\n", json) ``` 将上面的代码放到可以被调用的函数中进行测试(比如 `main`),程序执行后,我们将看到类似下面的结果: ```go 苏洋博客 https://soulteary.com/ 2022-12-14T12:29:55+08:00 醉里不知天在水,满船清梦压星河。 soulteary soulteary@gmail.com RSS Can:借助 V8 让 Golang 应用具备动态化能力(二) 2022-12-14T12:29:55+08:00 tag:soulteary.com,2022-12-14:/2022/12/13/rsscan-make-golang-applications-with-v8-part-2.html 继续聊聊之前做过的一个小东西的踩坑历程,如果你也想高效获取信息,或许这个系列的内容会对你有用。 soulteary soulteary@qq.com ... ... 苏洋博客 https://soulteary.com/ 醉里不知天在水,满船清梦压星河。 soulteary@gmail.com (soulteary) Wed, 14 Dec 2022 12:29:55 +0800 RSS Can:借助 V8 让 Golang 应用具备动态化能力(二) https://soulteary.com/2022/12/13/rsscan-make-golang-applications-with-v8-part-2.html 继续聊聊之前做过的一个小东西的踩坑历程,如果你也想高效获取信息,或许这个系列的内容会对你有用。 soulteary Wed, 14 Dec 2022 12:29:55 +0800 RSS Can:使用 Golang 实现更好的 RSS Hub 服务(一) https://soulteary.com/2022/12/12/rsscan-better-rsshub-service-build-with-golang-part-1.html 聊聊之前做过的一个小东西的踩坑历程,如果你也想高效获取信息,或许这个系列的内容会对你有用。这个事情涉及的东西比较多,所以我考虑拆成一个系列来聊,每篇的内容不要太长,整理负担和阅读负担都轻一些。本篇是系列第一篇内容。 soulteary Wed, 14 Dec 2022 12:29:55 +0800 ... ... { "version": "https://jsonfeed.org/version/1", "title": "苏洋博客", "home_page_url": "https://soulteary.com/", "description": "醉里不知天在水,满船清梦压星河。", "author": { "name": "soulteary" }, "items": [ { "id": "", "url": "https://soulteary.com/2022/12/13/rsscan-make-golang-applications-with-v8-part-2.html", "title": "RSS Can:借助 V8 让 Golang 应用具备动态化能力(二)", "summary": "继续聊聊之前做过的一个小东西的踩坑历程,如果你也想高效获取信息,或许这个系列的内容会对你有用。", "date_published": "2022-12-14T12:29:55.50867+08:00", "author": { "name": "soulteary" } }, ... ... ] } ``` 上面输出的日志结果中,就包含了前文中提到的三种格式,能够覆盖绝大多数的 RSS 客户端的订阅使用。 ### 连接来自网站的信息 在之前的文章中,我们将前文中通过动态配置解析目标网站,并将网站中信息转换为了 Go 中的数据结构。在了解了 Gorilla Feeds 是如何输出 RSS 格式之后,我们只需要将两者“连接”到一起,就能够得到 RSS 格式的资讯订阅源啦。 首先,针对前文中提到的“根据配置解析网站信息”的函数做一些调整: ```go func getWebsiteDataWithConfig(config define.JavaScriptConfig) (result define.BodyParsed) { doc := network.GetRemoteDocument("https://36kr.com/", "utf-8") if doc.Body == "" { return result } return parser.ParsePageByGoQuery(doc, func(document *goquery.Document) []define.InfoItem { var items []define.InfoItem document.Find(config.ListContainer).Each(func(i int, s *goquery.Selection) { var item define.InfoItem title := strings.TrimSpace(s.Find(config.Title).Text()) author := strings.TrimSpace(s.Find(config.Author).Text()) time := strings.TrimSpace(s.Find(config.DateTime).Text()) category := strings.TrimSpace(s.Find(config.Category).Text()) description := strings.TrimSpace(s.Find(config.Description).Text()) href, _ := s.Find(config.Link).Attr("href") link := strings.TrimSpace(href) item.Title = title item.Author = author item.Date = time item.Category = category item.Description = description item.Link = link items = append(items, item) }) return items }) } ``` 上面的函数正常运行的情况下,就可以得到一个包含了结构化数据的数组。 接下来,写一个简单的函数,调用 Gorilla Feeds 生成我们需要的 RSS 订阅源: ```go func generateFeeds(data define.BodyParsed) { now := time.Now() rssFeed := &feeds.Feed{ Title: "36Kr", Link: &feeds.Link{Href: "https://36kr.com/"}, Created: now, } for _, data := range data.Body { feedItem := feeds.Item{ Title: data.Title, Author: &feeds.Author{Name: data.Author}, Description: data.Description, Link: &feeds.Link{Href: data.Link}, // 时间处理这块比较麻烦,后续文章再展开 Created: now, } rssFeed.Items = append(rssFeed.Items, &feedItem) } atom, err := rssFeed.ToAtom() if err != nil { log.Fatal(err) } rss, err := rssFeed.ToRss() if err != nil { log.Fatal(err) } json, err := rssFeed.ToJSON() if err != nil { log.Fatal(err) } fmt.Println(atom, "\n", rss, "\n", json) } ``` 最后,调整程序的调用函数,以便于我们进行测试,将 RSS 生成结果打印到终端日志里: ```go func main() { jsApp, _ := os.ReadFile("./config/config.js") inject := string(jsApp) jsConfig, err := javascript.RunCode(inject, "JSON.stringify(getConfig());") if err != nil { fmt.Println(err) return } config, err := parser.ParseConfigFromJSON(jsConfig) if err != nil { fmt.Println(err) return } data := getWebsiteDataWithConfig(config) generateFeeds(data) } ``` 使用 `go run main.go` 执行程序,我们将得到符合预期的结果: ```go 36Kr https://36kr.com/ 2022-12-14T13:41:37+08:00 iOS 16.2来了,这7个新功能值得关注 2022-12-14T13:41:37+08:00 tag:,2022-12-14:/p/2043412066405640 Apple 画的饼终于来了。 少数派 如何更好地思考:人只能获得自己认知内的成就 2022-12-14T13:41:37+08:00 tag:,2022-12-14:/p/2018320727015942 5个原则,让你成为一个更好的思考者。 神译局 ... ``` 搞定了 RSS 客户端可以使用的数据格式,我们来解决“RSS 可订阅”的最后一步,启动一个简单的 Web 服务,将上面的数据变成可访问的接口地址。 ## 使用 Gin 搞定 RSS Web 服务 [Gin](https://github.com/gin-gonic/gin) 是一个优秀的 HTTP Web 框架,它不见得是 Go 生态所有框架中最快的框架,但要论社区活跃度和易用性,妥妥名列前茅。 ### 使用 Gin 启动一个简单的 Web 服务 Gin 对 Golang 的 `net/http` 能力进行了封装,提供了简单的调用方式,让我们能够启动一个 Web 服务,比如下面这段不到 20 行的代码: ```go package main import ( "net/http" "github.com/gin-gonic/gin" ) func main() { r := gin.Default() r.GET("/ping", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "message": "pong", }) }) r.Run() } ``` 上面的代码在运行之后,会启动一个 Web 服务,默认提供服务的地址是 `http://localhost:8080` 。当我们在浏览器中访问 `/ping`,服务器将响应并返回 `pong`。 ### 制作 RSS 订阅数据接口 上文提到过,因为生成不同格式的 RSS 并没有什么成本,所以我们可以将其全部都支持起来,应对各种 RSS 客户端的请求。 实际提供服务的时候,我们需要根据客户端请求的 RSS 格式类型,来输出不同的数据。所以,需要先调整下上文中我们用来生成 RSS Feed 的函数,让它支持根据请求参数中的类型来生成内容: ```go func generateFeeds(data define.BodyParsed, rssType string) string { now := time.Now() rssFeed := &feeds.Feed{ Title: "36Kr", Link: &feeds.Link{Href: "https://36kr.com/"}, Created: now, } for _, data := range data.Body { feedItem := feeds.Item{ Title: data.Title, Author: &feeds.Author{Name: data.Author}, Description: data.Description, Link: &feeds.Link{Href: data.Link}, // 时间处理这块比较麻烦,后续文章再展开 Created: now, } rssFeed.Items = append(rssFeed.Items, &feedItem) } var rss string var err error switch rssType { case "RSS": rss, err = rssFeed.ToRss() case "ATOM": rss, err = rssFeed.ToAtom() case "JSON": rss, err = rssFeed.ToJSON() default: rss = "" } if err != nil { fmt.Println(err) return "" } return rss } ``` 完成了生成函数的调整之后,我们来完成一个简单的功能实现,支持根据不同的 API 请求路径,调用上面的函数输出不同格式的 RSS 订阅源: ```go route := gin.Default() route.GET("/:type/", func(c *gin.Context) { var rssType RSSType if err := c.ShouldBindUri(&rssType); err != nil { c.JSON(http.StatusNotFound, gin.H{"msg": err}) return } var response string var mimetype string switch strings.ToUpper(rssType.Type) { case "RSS": mimetype = "application/rss+xml" response = generateFeeds(data, "RSS") case "ATOM": mimetype = "application/atom+xml" response = generateFeeds(data, "ATOM") case "JSON": mimetype = "application/feed+json" response = generateFeeds(data, "JSON") } c.Data(http.StatusOK, mimetype, []byte(response)) }) route.Run(":8080") ``` 启动服务,我们访问 `http://localhost:8080/rss` 、`http://localhost:8080/atom`、`http://localhost:8080/json` 中的任意一个地址,就能在浏览器中看到 RSS 订阅源的数据啦。 有不少 RSS 订阅工具支持根据网页中的标签,对 RSS 订阅源进行自动探测,比如 Reeder。 为了方便我们在 Reeder 中进行测试,我们可以将上面的 RSS 订阅源地址都写到一个 HTML 页面中,然后“绑定”到这个 Web 服务的 `/` 根目录: ```go const hello = ` RSS Feed Discovery. RSS Feed Discovery. ` route.GET("/", func(c *gin.Context) { c.Data(http.StatusOK, "text/html", []byte(hello)) }) ``` 重新运行程序,当我们在 Reeder 等 RSS 订阅工具中输入 `http://127.0.0.1:8080` 的时候,Reeder 会告知我们发现了三个订阅源。因为三个订阅源的数据是一样的,所以这里随便选择哪一个都行(推荐 Atom)。 ![使用 Reeder 验证 RSS 订阅源有效性](https://attachment.soulteary.com/2022/12/14/prompt-rss.jpg) 点击“订阅”按钮,来自网站的信息就出现在了 Reeder 的信息列表中啦。 ![RSS 客户端获取的 RSS 信息列表](https://attachment.soulteary.com/2022/12/14/rss-inbox.jpg) 至此,我们就初步解决了第一篇文章中提到的,某些不能被 RSS 订阅工具订阅的信息源的订阅问题。至于前两篇文章中提到的“关键词筛选”,“NLP 内容摘要聚合”,我们将在后续的文章中继续展开。 ## 其他:一个隐蔽的内存泄漏隐患 在上篇文章里,为了安全的运行可能出现“死循环”的外部 JavaScript 代码,我们使用了下面的代码来解决问题: ```bash duration := time.Since(start) select { case val := <-vals: fmt.Fprintf(os.Stderr, "cost time: %v\n", duration) return val, nil case err := <-errs: return nil, err case <-time.After(JS_EXECUTE_TIMEOUT): vm := ctx.Isolate() vm.TerminateExecution() err := <-errs fmt.Fprintf(os.Stderr, "execution timeout: %v\n", duration) time.Sleep(JS_EXECUTE_THORTTLING) return nil, err } ``` 今天折腾群里的同学 @Etran 提醒,这里存在一处[隐秘的内存泄漏问题](https://www.arangodb.com/2020/09/a-story-of-a-memory-leak-in-go-how-to-properly-use-time-after/),`time.After()` 可能晚于我们接收到 `vals` 数据执行,导致计时器没有被正确释放。 那么,要如何解决这个问题呢?修正代码很简单: ```bash duration := time.Since(start) timeout := time.NewTimer(define.JS_EXECUTE_TIMEOUT) select { case val := <-vals: if !timeout.Stop() { <-timeout.C } fmt.Fprintf(os.Stderr, "cost time: %v\n", duration) return val, nil case err := <-errs: return nil, err case <-timeout.C: timeout.Stop() vm := ctx.Isolate() vm.TerminateExecution() err := <-errs fmt.Fprintf(os.Stderr, "execution timeout: %v\n", duration) time.Sleep(define.JS_EXECUTE_THORTTLING) return nil, err } ``` ## 最后 写在这篇文章的时候,我再次回顾了 RSS 的发展史,以及核心灵魂人物 David Winter 的从业历史,尝试用我的视角来概要的描绘 RSS 历史长河里的精彩瞬间。 在文章即将发布的时候,我改变了想法,关于 RSS 的故事,或许应该在本系列文章结束的时候再发布更为合适。 --EOF