【Go】複数のRSSフィードを一つに統合して配信する

7 min read読了の目安(約6300字 3

ZennとかQiitaとかnoteとか、各ブログプラットフォームがそれぞれRSSフィードを生成してくれるおかげで自らフィードを作ることなくFeedlyで記事を読めている訳ですが、いざというときに自分の記事を一発で取れるRSSがあれば便利ですよね(個人のWebサイトに記事一覧載せるときとか)。てな訳で複数のRSSを取ってきて、新しい統合RSSを作るというのをGoでやります。

これを実現するWebサービスは結構あります。ただドメインを自分でいじりたかったのと単純にGoを書きたかったので自分で書きました

大まかな手順

大きく手順を分けると、

  • 複数のRSSのアイテムを全て取得して結合する
  • 新しいRSSのアイテムとして上で結合した配列を挿入する
  • 新しいRSSをhttpで配信する

そこまで複雑な手順は踏んでませんがライブラリ選定に若干手間取ったのでそこも含めて書きたいと思います。

Feederを使いたかった..

どうにか実現できないかググっていたときに真っ先にヒットしたのがこちらの記事

https://blog.p1usoon.com/posts/feeder/
ここで紹介されているFeederというライブラリが良さげだったので使おうとしたのですが、どうやらフィードのアイテムを取得してパースする際の日付フォーマット処理をミスっているっぽく、エラーが消えなかったためこのライブラリは諦めました。時間見つけてPR出したい。

gofeedとfeeds

Feeder以外でRSSの取得と配信を一度にできるライブラリが見つからなかったのでしゃあなしに2つのライブラリを使うことにしました。

GoでRSSをいじるライブラリというのは割と定番があるらしく、RSSのパースはmmcdole/gofeed、新しいフィードの生成はgorilla/feedsが文献も多かったのでこれを使うことにしました。

前提

今回紹介するコードの中でエラーハンドリングはErrorHandling(err)という関数にしてます。これが出てきたら「ああエラーハンドリングしてるんだな」と思ってください。

複数のRSSのアイテムを取得する

今回まとめたいRSSは

  • note
  • Zenn
  • 個人ブログ

の3つです。幸い個人ブログもRSSを自動で作ってくれるGatsbyテンプレートを使っていたのでRSSを作る段階はスキップできました。エコシステムに乾杯。

https://blog.kota-yata.com/

各RSSのアイテム部分のみを取得して結合する

getFeeds.go
func GetAllItem()[]*gofeed.Item {
	parser := gofeed.NewParser()
	var combinedFeedArray []*gofeed.Item
	layout := "20060102150405"
	timeZoneJST, err := time.LoadLocation("Asia/Tokyo")
	ErrorHandling(err)
	individualFeedArray := []*individualFeed{
		{
			service: "owned",
			url:     "https://blog.kota-yata.com/rss.xml",
		},
		{
			service: "zenn",
			url:     "https://zenn.dev/kota_yata/feed",
		},
		{
			service: "note",
			url:     "https://note.com/kotay/rss",
		},
	}
	for i := 0; i < len(individualFeedArray); i++ {
		feed, err := parser.ParseURL(individualFeedArray[i].url)
		ErrorHandling(err)
		// フィード内の記事一つ一つの公開日時をソート用にフォーマットする
		for _, item := range feed.Items {
			item.Published = formatTime(individualFeedArray[i].service, item.Published, timeZoneJST, layout)
		}
		combinedFeedArray = append(combinedFeedArray, feed.Items...)
	}
	sortByTime(combinedFeedArray, layout)
	return combinedFeedArray
}

なぜindividualFeedArrayにサービス名を入れているかというと、下で説明する時間表記のフォーマットに必要だからです。
ループの中でやっていることは、RSSをパースした後、その結果のアイテムの配列をさらにループし、一つ一つの記事の時間表記をフォーマットしています。全てのアイテムの時間表記が揃ったら最終的なアイテムになるcombinedFeedArrayに追加しています。

時間表記を直す

この関数の戻り値はcombinedFeedArrayな訳ですが、従来のRSSと同じように最新の記事を上に持ってくるために返す前に時間で記事をソートします。そのために先に一つ一つの記事の時間表記を揃えておく必要があります。

getFeeds.go
func formatTime(service string, publishedDate string, timeZone *time.Location, layout string) string {
	originalLayout := "Mon, 02 Jan 2006 15:04:05 GMT" // RFC1123
	if service == "note" {
		originalLayout = "Mon, 02 Jan 2006 15:04:05 -0700" // RFC1123Z
	}
	formattedTime, err := time.ParseInLocation(originalLayout, publishedDate, timeZone)
	ErrorHandling(err)
	formattedTimeString := formattedTime.Format(layout)
	return formattedTimeString
}

個人ブログとZennの時間表記はRFC1123で規定されたMon, 02 Jan 2006 15:04:05 GMTの表記になっていますが、noteだけはRFC1123の拡張系でタイムゾーンを数値で表すRFC1123 with numeric zoneが使われています。なのでサービス名を受け取ってnoteだけ条件分岐でレイアウトを変えてパースしています。Item.Publishedの型は文字列なので最後は適当なレイアウトで再び文字列に戻して返しています。これは別にソートする際にやっても良いのですが、新しいRSSを作るときも時間表記が絡むので先に綺麗にしておきます。

時間でソートする

このままRSSのアイテムとして返してしまうと、個人のブログ⇨Zenn⇨noteの順に時間関係なく配信されてしまい、わけわかんなくなるのでソートをしておきます。

getFeeds.go
func sortByTime(combinedFeedArray []*gofeed.Item, layout string) {
	sort.Slice(combinedFeedArray, func(i, j int) bool {
		timei, err := time.Parse(layout, combinedFeedArray[i].Published)
		ErrorHandling(err)
		timej, err := time.Parse(layout, combinedFeedArray[j].Published)
		ErrorHandling(err)
		return timei.After(timej)
	})
}

sort.Slice便利っすね

これで新しいRSSフィードに追加するアイテムの準備はできました。

新しいRSSを作る

今度はgorilla/feedsを使って新しいRSSを作っていきます。

createFeed.go
func CreateFeeds(items []*gofeed.Item) string {
	now := time.Now()
	feed := &feeds.Feed{
		Title:       "kota-yata integrated RSS",
		Link:        &feeds.Link{
			Href: "https://feed.kota-yata.com",
		},
		Description: "The integrated RSS feed of blogs kota-yata wrote. Including Zenn, Qiita, blog.kota-yata.com, note",
		Author:       &feeds.Author{
			Name: "Kota Yatagai",
			Email: "kota@yatagai.com",
		},
		Created:     now,
		Items: consistFeedItems(items),
	}
	rss, err := feed.ToRss()
	ErrorHandling(err)
	return rss
}

ItemsのconsisteFeedItems(items)は下で説明しますが、その他の部分はfeedsの恩恵を受けています。ここは新しいRSSのヘッダー的な情報なのでよしなに入れていってください。

createFeed.go
func consistFeedItems(items []*gofeed.Item) []*feeds.Item {
	var resultItems []*feeds.Item
	for _, item := range items {
		publishedTime, err := time.Parse("20060102150405", item.Published)
		ErrorHandling(err)
		result := &feeds.Item{
			Title: item.Title,
			Link: &feeds.Link{
				Href:item.Link,
			},
			Author: &feeds.Author{
				Name: "Kota Yatagai",
				Email: "kota@yatagai.com",
			},
			Description: item.Description,
			Updated: publishedTime,
			Created: publishedTime,
			Id: item.GUID,
			Content: item.Content,
		}
		resultItems = append(resultItems, result)
	}
	return resultItems
}

注意点としては、先ほど結合したRSSアイテムをそのままItemsにぶち込むと型エラーが出ます。二つのライブラリの型が微妙に違う、例えば時間表記が文字列かtime.Timeかみたいな違いがあるので一つ一つ丁寧に真心込めて指定していきましょう。Authorの部分は今回は僕以外あり得ないので直接書き込んでますが、複数人いる場合はitem.Authorsで取ってこれるのでそれを入れれば良いと思います。

新しいRSSをhttpで配信する

ここはRSSの話というかGoでHTTPサーバーを立ててみましょうという話なので他の良い文献とともにご覧ください。

hostFeeds.go
func SetHost(rssFeeds string) {
	http.HandleFunc("/", func(writer http.ResponseWriter, req *http.Request) {
		reader := strings.NewReader(rssFeeds)
		_, err := io.Copy(writer, reader)
		ErrorHandling(err)
		req.Header.Set("Content-Type", "application/rss+xml")
		return
	})
	uploadCertChallenge()
	portName := os.Getenv("PORT")
	if portName == "" {
		portName = "3432"
	}
	fmt.Println("RSS feed has been published at http://localhost:" + portName)
	err := http.ListenAndServe(":" + portName, nil)
	ErrorHandling(err)
}

注意点としては、リクエストヘッダーのContent-Typeをapplication/rss+xmlに設定しておきましょう。

portName := os.Getenv("PORT")
if portName == "" {
	portName = "3432"
}

この部分は、デプロイの際に環境変数でポート番号を設定している場合はそれを、していない場合は3432を指定しています。僕のlocalhostはキャッシュが残って変なことになっているのでこんなトリッキーなポート番号にしていますが、基本は8080とかで良いと思います。
go run main.goで正常に動いたらHerokuなり、GCPなりにデプロイしましょう。僕の場合は独自ドメインのSSLしたくてGCPにしました。

おわりに

ソースコードはこちらにあります。

https://github.com/kota-yata/integrated-rss
最初に独自ドメイン云々いっておいてまだ設定してませんがそのうちします。
では