【Go言語】Qiitaの投稿をGitHubのプロフィールに反映させてみた

## はじめに
最近、GitHubのプロフィールをもっと魅力的にしようといろいろカスタマイズしていました。その中で、「***自分が投稿したQiitaの記事もプロフィール上に表示できたらいいな***」と思いました👍

そこで今回は、Goを使ってQiitaのRSSから投稿記事を取得し、GitHubのプロフィールに表示させるまでのプロセスをまとめてみようと思います。
ぜひ最後までお付き合いください！

## 記事表示の完成イメージ
![スクリーンショット 2025-01-24 18.44.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3763692/469c42b9-1976-5a77-5ae8-58398ba53f17.png)

このような感じでGitHubのプロフィール(`README.md`)に掲載します。

https://github.com/anton-fuji/anton-fuji


## ソースコード
早速コードの紹介をしていきます。

```golang:main.go
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プログラムとなっています。

それでは、セクションごとに見ていきましょう！
### インポートセクション
```golang:main.go
import (
	"encoding/xml"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"sort"
	"time"
)
```
- `encoding/xml`はXMLを構造体にマッピングしてくれるパッケージ
- `os`はファイルの読み書きに使用
- その他は必要な標準パッケージとなります

### 構造体の定義
```golang:main.go
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関数
```golang:main.go
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データを処理するとします
```xml:xml
<feed>
  <entry>
    <title>記事タイトル</title>
    <link href="記事のリンク" />
    <published>2023-01-01T00:00:00Z</published>
  </entry>
</feed>
```
こちらを`Unmarshal`で構造体に変換させると、、、
```golang
QiitaAtom{
    Entries: []struct{
        Title string
        Link struct{ Href string }
        Published string
    }{
        {Title: "記事タイトル", Link: {Href: "記事のリンク"}, Published: "2023-01-01T00:00:00Z"},
    },
}
```
取得したデータが`Entries`に格納されていることが確認できると思います。

### データの整形
```golang
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関数
```golang:main.go
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`に反映させます。

:::note warn
こちらはご自身のQiitaのアカウント名に変更してください。
https://qiita.com/Qiita_username/feed.atom

例) 私の場合はfujifuji1414なので
https://qiita.com/fujifuji1414/feed.atom
:::

### 記事セクションの更新
```golang
newReadme := replaceBetween(string(readme), "<!--[START POSTS]-->", "<!--[END POSTS]-->", distMD)
```

### README.mdへの書き込み
```golang
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です！
```terminal:terminal
$ go run main.go
README.md が更新されました！
```

README.mdは以下のように更新されます。
```md:README.md
**Recent Qiita Articles**
- [記事タイトル1](リンク1)
- [記事タイトル2](リンク2)
- [記事タイトル3](リンク3)
- [記事タイトル4](リンク4)
- [記事タイトル5](リンク5)
```

## 更新を自動化する
このままだと手動でコマンドを叩く必要があるのでGitHub Actionsで自動化していきましょう。
```yaml:posts.yml
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 * * * と指定します([詳しくはこちら](https://qiita.com/nemutas/items/3f5816eabbf0eda5e6a9))

:::note warn
注意
- **メールアドレスとユーザー名**
    - `your_username@users.noreply.github.com` と `your_username` を適切な値に置き換えてください
    - メールアドレスは、リポジトリに関連付けられたものを使用します。
- **Goプログラムの依存**
    - `main.go`の依存関係が正しく管理されているか（go.modが正しく設定されているか）確認してください
- **ブランチについて**
    - デフォルトのブランチ名がmain以外の場合、git push origin mainを適切なブランチ名に変更してください
:::
こういう感じで設定しておくと1日に1回差分があるときだけ更新されます

今回作成にあたり大変参考にさせていただいた方々のGitHubになります！（ありがとうございます！！！！🥹）
[mikkameさん](https://github.com/mikkame)

https://github.com/mikkame/mikkame/tree/master

[ikawahaさん](https://github.com/ikawaha)

https://github.com/ikawaha/feedsnippet

## さいごに
今回は、RSSを使ってQiitaの記事を取得し、それをGitHubのプロフィール`README.md`に表示する仕組みを作成しました。GitHub Actionsを利用した自動化も今回のポイントとなっています！🎉