はじめに
最近、GitHubのプロフィールをもっと魅力的にしようといろいろカスタマイズしていました。その中で、「自分が投稿したQiitaの記事もプロフィール上に表示できたらいいな」と思いました👍
そこで今回は、Goを使ってQiitaのRSSから投稿記事を取得し、GitHubのプロフィールに表示させるまでのプロセスをまとめてみようと思います。
ぜひ最後までお付き合いください!
記事表示の完成イメージ
このような感じでGitHubのプロフィール(README.md
)に掲載します。
ソースコード
早速コードの紹介をしていきます。
package main
import (
"encoding/xml"
"fmt"
"io"
"log"
"net/http"
"os"
"sort"
"time"
)
type QiitaAtom struct {
Entries []struct {
Title string `xml:"title"`
Link string `xml:"link"`
Published string `xml:"published"`
} `xml:"entry"`
}
type Post struct {
Title string
Date time.Time
URL string
}
func fetchQiitaFeed(feedURL string) ([]Post, error) {
resp, err := http.Get(feedURL)
if err != nil {
return nil, fmt.Errorf("Qiitaフィード取得エラー: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("フィードデータ読み込みエラー: %w", err)
}
var feed QiitaAtom
if err := xml.Unmarshal(data, &feed); err != nil {
return nil, fmt.Errorf("フィード解析エラー: %w", err)
}
var posts []Post
for _, entry := range feed.Entries {
date, err := time.Parse(time.RFC3339, entry.Published)
if err != nil {
continue
}
posts = append(posts, Post{
Title: entry.Title,
Date: date,
URL: entry.Link,
})
}
sort.Slice(posts, func(i, j int) bool {
return posts[i].Date.After(posts[j].Date)
})
return posts, nil
}
func main() {
const feedURL = "https://qiita.com/fujifuji1414/feed.atom"
posts, err := fetchQiitaFeed(feedURL)
if err != nil {
log.Fatalf("フィード取得エラー: %v", err)
}
distMD := "**Recent Qiita Articles**\n"
for i, post := range posts {
if i >= 5 {
break
}
distMD += fmt.Sprintf("- ![](img/qiita.png) [%s](%s)\n", post.Title, post.URL)
}
readme, err := os.ReadFile("README.md")
if err != nil {
log.Fatalf("README読み込みエラー: %v", err)
}
newReadme := `<!--[START POSTS]-->` + "\n" + distMD + `<!--[END POSTS]-->`
readmeContent := string(readme)
readmeContent = replaceBetween(readmeContent, "<!--[START POSTS]-->", "<!--[END POSTS]-->", newReadme)
if err := os.WriteFile("README.md", []byte(readmeContent), 0644); err != nil {
log.Fatalf("README書き込みエラー: %v", err)
}
fmt.Println("README.md が更新されました!")
}
// 指定したプレースホルダー間の文字列を置換
func replaceBetween(content, start, end, newContent string) string {
startIdx := indexOf(content, start) + len(start)
endIdx := indexOf(content, end)
if startIdx == -1 || endIdx == -1 || startIdx >= endIdx {
return content // プレースホルダーが見つからない場合はそのまま返す
}
return content[:startIdx] + "\n" + newContent + "\n" + content[endIdx:]
}
// 部分文字列の開始インデックスを取得
func indexOf(content, substr string) int {
return findIndex(content, substr)
}
// 部分文字列を検索して最初に見つかった位置を返す
func findIndex(content, substr string) int {
idx := -1
for i := 0; i+len(substr) <= len(content); i++ {
if content[i:i+len(substr)] == substr {
idx = i
break
}
}
return idx
}
QiitaのRSSフィードを取得し、記事データをMarkdown形式で整形し、README.md を自動更新するGoプログラムとなっています。
それでは、セクションごとに見ていきましょう!
インポートセクション
import (
"encoding/xml"
"fmt"
"io"
"log"
"net/http"
"os"
"sort"
"time"
)
-
encoding/xml
はXMLを構造体にマッピングしてくれるパッケージ -
os
はファイルの読み書きに使用 - その他は必要な標準パッケージとなります
構造体の定義
type QiitaAtom struct {
Entries []struct {
Title string `xml:"title"`
Link struct {
Href string `xml:"href,attr"`
} `xml:"link"`
Published string `xml:"published"`
} `xml:"entry"`
}
- QiitaのAtomフィードに対応した構造体です
-
Entries
は記事ごとのデータを保持します-
Title
,Link
フィールドで記事のタイトルとリンクを保持 - 各記事の情報として以下を取得
-
Title
: 記事のタイトル -
Link
: 記事のURL -
Published
: 記事の公開日時
-
- タグ
(xml:"title", xml:"link")
はXMLの特定フィールドと対応付けるために使用(RSSはxml形式で記述されています)
-
fetchQiitaFeed関数
func fetchQiitaFeed(feedURL string) ([]Post, error) {
resp, err := http.Get(feedURL)
if err != nil {
return nil, fmt.Errorf("Qiitaフィード取得エラー: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("フィードデータ読み込みエラー: %w", err)
}
var feed QiitaAtom
if err := xml.Unmarshal(data, &feed); err != nil {
return nil, fmt.Errorf("フィード解析エラー: %w", err)
}
var posts []Post
for _, entry := range feed.Entries {
date, err := time.Parse(time.RFC3339, entry.Published)
if err != nil {
continue
}
posts = append(posts, Post{
Title: entry.Title,
Date: date,
URL: entry.Link,
})
}
// 日付順にソート
sort.Slice(posts, func(i, j int) bool {
return posts[i].Date.After(posts[j].Date)
})
return posts, nil
}
こちらはRSSデータを取得する関数になっています。
まず、指定したURL(feedURL
)からフィードデータを取得します。
次に、Unmarshal
でio.ReadAll(resp.Body)
で取得したXMLデータをGoの構造体に変換しています。
この際、構造体のフィールドとXMLタグを対応させるために、タグマッピング(xml:"entry"
)を利用します。
おそらくUnmarshal
の部分がイメージしにくいと思うので以下の動作例と一緒に処理を見ていきましょう。
例えば、以下のXMLデータを処理するとします
<feed>
<entry>
<title>記事タイトル</title>
<link href="記事のリンク" />
<published>2023-01-01T00:00:00Z</published>
</entry>
</feed>
こちらをUnmarshal
で構造体に変換させると、、、
QiitaAtom{
Entries: []struct{
Title string
Link struct{ Href string }
Published string
}{
{Title: "記事タイトル", Link: {Href: "記事のリンク"}, Published: "2023-01-01T00:00:00Z"},
},
}
取得したデータがEntries
に格納されていることが確認できると思います。
データの整形
for _, entry := range feed.Entries {
date, err := time.Parse(time.RFC3339, entry.Published)
if err != nil {
continue
}
posts = append(posts, Post{
Title: entry.Title,
Date: date,
URL: entry.Link.Href,
})
}
- 処理された記事が
Post
構造体に変換します -
time.Time
型はソート処理に使用します
main関数
func main() {
const feedURL = "https://qiita.com/Qiita_username/feed.atom"
// Qiitaフィードを取得
posts, err := fetchQiitaFeed(feedURL)
if err != nil {
log.Fatalf("フィード取得エラー: %v", err)
}
// 上位5件をMarkdown形式で整形
distMD := "**Recent Qiita Articles**\n"
for i, post := range posts {
if i >= 5 {
break
}
distMD += fmt.Sprintf("- [%s](%s)\n", post.Title, post.URL)
}
// README.mdの更新
readme, err := os.ReadFile("README.md")
if err != nil {
log.Fatalf("README読み込みエラー: %v", err)
}
newReadme := replaceBetween(string(readme), "<!--[START POSTS]-->", "<!--[END POSTS]-->", distMD)
if err := os.WriteFile("README.md", []byte(newReadme), 0644); err != nil {
log.Fatalf("README書き込みエラー: %v", err)
}
fmt.Println("README.md が更新されました!")
}
まず、QiitaのAフィードURLを feedURL
定数として指定します。
次にfetchQiitaFeed
関数を呼び出し、記事リストの取得をします。
最終的に、取得した記事をREADME.md
に反映させます。
こちらはご自身のQiitaのアカウント名に変更してください。
https://qiita.com/Qiita_username/feed.atom
例) 私の場合はfujifuji1414なので
https://qiita.com/fujifuji1414/feed.atom
記事セクションの更新
newReadme := replaceBetween(string(readme), "<!--[START POSTS]-->", "<!--[END POSTS]-->", distMD)
README.mdへの書き込み
if err := os.WriteFile("README.md", []byte(newReadme), 0644); err != nil {
log.Fatalf("README書き込みエラー: %v", err)
}
更新された内容をREADME.mdに書き込み、ファイルのパーミッションは0644に設定しておきます。
※ 事前にREADME.md
にプレースホルダー<!--[START POSTS]-->
と <!--[END POSTS]-->
を事前に設定しておいてください。
それでは実際にGoを実行して以下のような出力が得られたらOKです!
$ go run main.go
README.md が更新されました!
README.mdは以下のように更新されます。
**Recent Qiita Articles**
- [記事タイトル1](リンク1)
- [記事タイトル2](リンク2)
- [記事タイトル3](リンク3)
- [記事タイトル4](リンク4)
- [記事タイトル5](リンク5)
更新を自動化する
このままだと手動でコマンドを叩く必要があるのでGitHub Actionsで自動化していきましょう。
name: Posts Updater
on:
schedule:
- cron: '0 0 * * *' # 必要に応じて変更可能
jobs:
update-posts:
runs-on: ubuntu-latest
steps:
# リポジトリをチェックアウト
- name: Checkout
uses: actions/checkout@v3
# Gitの設定
- name: Git setting
run: |
git config --local user.email "your_username@users.noreply.github.com"
git config --local user.name "your_username"
# Goのセットアップ
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.20' # 必要に応じてGoのバージョンを変更
# 必要な依存関係のインストール(もしある場合)
- name: Install dependencies
run: go mod tidy
# Goプログラムを実行してREADME.mdを更新
- name: Run updater
run: go run main.go
# README.mdをコミットしてプッシュ
- name: Commit and push changes
run: |
git add README.md
git commit -m "update posts"
git push origin main
- 毎時実行する設定です。必要に応じてCRON形式でスケジュールを調整してください
- 例) 毎日午前9時に実行する場合は 0 9 * * * と指定します(詳しくはこちら)
注意
-
メールアドレスとユーザー名
-
your_username@users.noreply.github.com
とyour_username
を適切な値に置き換えてください - メールアドレスは、リポジトリに関連付けられたものを使用します。
-
-
Goプログラムの依存
-
main.go
の依存関係が正しく管理されているか(go.modが正しく設定されているか)確認してください
-
-
ブランチについて
- デフォルトのブランチ名がmain以外の場合、git push origin mainを適切なブランチ名に変更してください
こういう感じで設定しておくと1日に1回差分があるときだけ更新されます
今回作成にあたり大変参考にさせていただいた方々のGitHubになります!(ありがとうございます!!!!🥹)
mikkameさん
さいごに
今回は、RSSを使ってQiitaの記事を取得し、それをGitHubのプロフィールREADME.md
に表示する仕組みを作成しました。GitHub Actionsを利用した自動化も今回のポイントとなっています!🎉