第三篇内容里,我们来聊聊把结构化数据转换为可以订阅的 RSS 订阅数据源。
写在前面
通过前两篇文章《RSS Can:使用 Golang 实现更好的 RSS Hub 服务(一)》和《RSS Can:借助 V8 让 Golang 应用具备动态化能力(二)》,我们已经能够将网站上的资讯信息,通过动态配置的方式整理成结构化的数据。
本篇文章,我们来简单聊聊,如何将这些结构化的数据变成可订阅的 RSS 订阅源,让网站的数据能够和我们的 RSS 阅读器“连通”起来。
RSS 格式标准
在聊代码实现之前,不论是作为开发者、还是作为 RSS 产品用户,了解下 RSS 格式标准还是非常有必要的。
互联网上关于 “RSS” 的格式标准比较出名的有三种流派,分别是:Atom、 RSS、JSON Feed,第三种出现于 RSS 式微,应用和呼声都不大,因此主要网络应用支持的格式都在集中在前两者:RSS 和 Atom。
TLDR,简单来说,如果你是内容提供方,你希望你的内容能够被更多的人用各种各样的 RSS 客户端访问,选择一定被支持的 RSS 2.0 将保持非常好的兼容性。如果你是读者,考虑到持续追踪文章的更新,以及更好的阅读体验,当网站同时提供多种 RSS 订阅格式时,不妨优先选择 Atom 格式的 RSS 订阅源 。
当然,本文中我们将借助开源软件库一并将前两篇文章中整理好的数据,一并输出为三种格式。(反正没什么成本)
Atom 格式相比较 RSS 2.0 的主要优势
如果你不想针对 “RSS” 进行细致的开发,我们只了解使用即可,这个小节的内容可以跳过。
- 能够标记字段中的 HTML 内容是否经过转义或编码,方便开发者在渲染时使用数据。
- 不再需要将内容的“正文”和“摘要”都混在
description
字段中,提供了新的summary
字段,可以区分“摘要”和“正文”,同时允许在正文中添加非文本内容。 - “RSS” 存在几个变体版本,Atom 更为稳定和一致。
- 提供了符合 XML 标准的命名空间、能够使用 XML 内置的标签来支持相对地址的描述、能够使用 XML 内置标签告诉订阅者内容语言、支持 XML Schema,这些 RSS 2.0 都不具备。
- 每一个信息条目具备唯一 ID,订阅者能够追踪具体的内容的更新。
- 有统一明确的时间表示规范,方便程序进行处理。
- 在 IANA 注册了
application/atom+xml
的 MIME 媒体类型,将其变成了标准规范,RSS 使用的application/rss+xml
还没有纳入标准。
使用 Go 转换数据为 RSS Feed 格式
Go 生态中支持生成 RSS Feed 的软件包有很多,我选择的是有十年维护历史的 gorilla/feeds。虽然在这个月的 9 号,维护团队宣布开源组织内的仓库都将进入“休眠状态”(存档),不再进行维护。
但是,对于我们的需求来说,RSS 是一个“古老、稳定”的协议,gorilla/feeds 已经经过了长时间的验证,所以选择使用它还是比较合适的。加之,对于这类不活跃维护或者停止维护的项目,还可以通过 Go 的特殊的包管理方式,来帮助我们管理代码,做代码维护变更,这块我们后续的文章中会提到。
Gorilla Feeds 的一般使用
我们先来了解如何使用 Gorilla Feeds 来生成 RSS Feed 格式的订阅源,先引入软件包:
import (
"time"
"github.com/gorilla/feeds"
)
这里之所以同时引入了 time
,是因为我不想麻烦的手动造数据。因为不同的 RSS 格式,对于时间的要求并不相同,所以关于时间的处理,后续展开一篇内容来聊,或许更为合适。
我们先以之前发布过的文章为例,编写一段 Mock 数据,等会用来测试 RSS 订阅源的生成:
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,
},
}
接着,编写简单的调用语句,数据就可以“转换”成我们需要的结果啦:
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
),程序执行后,我们将看到类似下面的结果:
<?xml version="1.0" encoding="UTF-8"?><feed xmlns="http://www.w3.org/2005/Atom">
<title>苏洋博客</title>
<id>https://soulteary.com/</id>
<updated>2022-12-14T12:29:55+08:00</updated>
<subtitle>醉里不知天在水,满船清梦压星河。</subtitle>
<link href="https://soulteary.com/"></link>
<author>
<name>soulteary</name>
<email>soulteary@gmail.com</email>
</author>
<entry>
<title>RSS Can:借助 V8 让 Golang 应用具备动态化能力(二)</title>
<updated>2022-12-14T12:29:55+08:00</updated>
<id>tag:soulteary.com,2022-12-14:/2022/12/13/rsscan-make-golang-applications-with-v8-part-2.html</id>
<link href="https://soulteary.com/2022/12/13/rsscan-make-golang-applications-with-v8-part-2.html" rel="alternate"></link>
<summary type="html">继续聊聊之前做过的一个小东西的踩坑历程,如果你也想高效获取信息,或许这个系列的内容会对你有用。</summary>
<author>
<name>soulteary</name>
<email>soulteary@qq.com</email>
</author>
</entry>
...
...
</feed>
<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>苏洋博客</title>
<link>https://soulteary.com/</link>
<description>醉里不知天在水,满船清梦压星河。</description>
<managingEditor>soulteary@gmail.com (soulteary)</managingEditor>
<pubDate>Wed, 14 Dec 2022 12:29:55 +0800</pubDate>
<item>
<title>RSS Can:借助 V8 让 Golang 应用具备动态化能力(二)</title>
<link>https://soulteary.com/2022/12/13/rsscan-make-golang-applications-with-v8-part-2.html</link>
<description>继续聊聊之前做过的一个小东西的踩坑历程,如果你也想高效获取信息,或许这个系列的内容会对你有用。</description>
<author>soulteary</author>
<pubDate>Wed, 14 Dec 2022 12:29:55 +0800</pubDate>
</item>
<item>
<title>RSS Can:使用 Golang 实现更好的 RSS Hub 服务(一)</title>
<link>https://soulteary.com/2022/12/12/rsscan-better-rsshub-service-build-with-golang-part-1.html</link>
<description>聊聊之前做过的一个小东西的踩坑历程,如果你也想高效获取信息,或许这个系列的内容会对你有用。这个事情涉及的东西比较多,所以我考虑拆成一个系列来聊,每篇的内容不要太长,整理负担和阅读负担都轻一些。本篇是系列第一篇内容。</description>
<author>soulteary</author>
<pubDate>Wed, 14 Dec 2022 12:29:55 +0800</pubDate>
</item>
...
...
</channel>
</rss>
{
"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 格式的资讯订阅源啦。
首先,针对前文中提到的“根据配置解析网站信息”的函数做一些调整:
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 订阅源:
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 生成结果打印到终端日志里:
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
执行程序,我们将得到符合预期的结果:
<?xml version="1.0" encoding="UTF-8"?><feed xmlns="http://www.w3.org/2005/Atom">
<title>36Kr</title>
<id>https://36kr.com/</id>
<updated>2022-12-14T13:41:37+08:00</updated>
<link href="https://36kr.com/"></link>
<entry>
<title>iOS 16.2来了,这7个新功能值得关注</title>
<updated>2022-12-14T13:41:37+08:00</updated>
<id>tag:,2022-12-14:/p/2043412066405640</id>
<link href="/p/2043412066405640" rel="alternate"></link>
<summary type="html">Apple 画的饼终于来了。</summary>
<author>
<name>少数派</name>
</author>
<entry>
<title>如何更好地思考:人只能获得自己认知内的成就</title>
<updated>2022-12-14T13:41:37+08:00</updated>
<id>tag:,2022-12-14:/p/2018320727015942</id>
<link href="/p/2018320727015942" rel="alternate"></link>
<summary type="html">5个原则,让你成为一个更好的思考者。</summary>
<author>
<name>神译局</name>
</author>
</entry>
...
搞定了 RSS 客户端可以使用的数据格式,我们来解决“RSS 可订阅”的最后一步,启动一个简单的 Web 服务,将上面的数据变成可访问的接口地址。
使用 Gin 搞定 RSS Web 服务
Gin 是一个优秀的 HTTP Web 框架,它不见得是 Go 生态所有框架中最快的框架,但要论社区活跃度和易用性,妥妥名列前茅。
使用 Gin 启动一个简单的 Web 服务
Gin 对 Golang 的 net/http
能力进行了封装,提供了简单的调用方式,让我们能够启动一个 Web 服务,比如下面这段不到 20 行的代码:
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 的函数,让它支持根据请求参数中的类型来生成内容:
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 订阅源:
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 服务的 /
根目录:
const hello = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RSS Feed Discovery.</title>
<link rel="alternate" type="application/rss+xml" title="RSS 2.0 Feed" href="http://localhost:8080/rss">
<link rel="alternate" type="application/atom+xml" title="RSS Atom Feed" href="http://localhost:8080/atom">
<link rel="alternate" type="application/rss+json" title="RSS JSON Feed" href="http://localhost:8080/json">
</head>
<body>
RSS Feed Discovery.
</body>
</html>`
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 订阅工具订阅的信息源的订阅问题。至于前两篇文章中提到的“关键词筛选”,“NLP 内容摘要聚合”,我们将在后续的文章中继续展开。
其他:一个隐蔽的内存泄漏隐患
在上篇文章里,为了安全的运行可能出现“死循环”的外部 JavaScript 代码,我们使用了下面的代码来解决问题:
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 提醒,这里存在一处隐秘的内存泄漏问题,time.After()
可能晚于我们接收到 vals
数据执行,导致计时器没有被正确释放。
那么,要如何解决这个问题呢?修正代码很简单:
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