第三篇内容里,我们来聊聊把结构化数据转换为可以订阅的 RSS 订阅数据源。

写在前面

通过前两篇文章《RSS Can:使用 Golang 实现更好的 RSS Hub 服务(一)》《RSS Can:借助 V8 让 Golang 应用具备动态化能力(二)》,我们已经能够将网站上的资讯信息,通过动态配置的方式整理成结构化的数据。

本篇文章,我们来简单聊聊,如何将这些结构化的数据变成可订阅的 RSS 订阅源,让网站的数据能够和我们的 RSS 阅读器“连通”起来。

RSS 格式标准

在聊代码实现之前,不论是作为开发者、还是作为 RSS 产品用户,了解下 RSS 格式标准还是非常有必要的。

互联网上关于 “RSS” 的格式标准比较出名的有三种流派,分别是:AtomRSSJSON Feed,第三种出现于 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。虽然在这个月的 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/rsshttp://localhost:8080/atomhttp://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 订阅源有效性

点击“订阅”按钮,来自网站的信息就出现在了 Reeder 的信息列表中啦。

RSS 客户端获取的 RSS 信息列表

至此,我们就初步解决了第一篇文章中提到的,某些不能被 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