📥️

Advent Calendarの新着記事の内容をDiscordで確認したい

に公開

この記事はGDG on Campus Japan Advent Calendar 2025 の12月8日分の記事です.
Google Developer Groups on Campusって何?という方は,こちらの記事をご覧ください!

モチベーション

アドベントカレンダーの時期になりました.
私も含め,毎日新しい記事が投稿されるのを心待ちにしている方も多いのではないでしょうか?
ただ,毎日アドベントカレンダーのページを確認するのは少し手間ですよね.
そこで,アドベントカレンダーに新しい記事が投稿されたら,自動的にDiscordに通知してくれるBot(正確にはWebhook)を作成しました.
記事タイトルを投稿するだけではなく,Geminiを利用して簡単な要約も生成し,記事内容の把握がしやすいようにしています.

概要

プログラムのおおまかな流れは以下の通りです.

  1. アドベントカレンダーのRSSフィードを取得する
  2. 情報をパースして,新着記事を検出する
  3. Gemini 利用して記事内容の要約を生成する
  4. DiscordのWebhookを利用して,記事タイトルと要約を投稿する
  5. 1〜4をCloud Schedulerで定期的に実行する

コードだけみたい方はGitHubリポジトリをご覧ください.

https://github.com/hokkey621/advent-rss-demo

事前準備

今回の実行環境は以下のとおりです.

  • go 1.25.4
  • Google Cloud SDK

また,今回はGoogle AI StudioのAPIではなく,Google CloudのGeminiを利用しました(深い意図はありません).
適当な名前のGoogle Cloudプロジェクトを作成して,Vertex AI APIを有効にしておいてください.

RSSフィードの取得

まずは,アドベントカレンダーのRSSフィードを取得します.
取得したいカレンダーのページにアクセスして,URLの末尾に/feedを追加すると,RSSフィードのURLが取得できます.
例えば,GDG on Campus Japan Advent Calendar 2025の場合,以下のURLになります.

https://qiita.com/advent-calendar/2025/gdgoc-japan/feed

Advent CalendarのRSSフィードは次の構造になっています.
例として農工大アドベントカレンダー2025のフィードを示します.

<feed xml:lang="ja-JP" xmlns="http://www.w3.org/2005/Atom" xmlns:re="http://purl.org/atompub/rank/1.0">
  <id>tag:qiita.com,2012:/advent-calendar/2025/tuat/feed</id>
  <link rel="alternate" type="text/html" href="https://qiita.com"/>
  <link rel="self" type="application/atom+xml" href="https://qiita.com/advent-calendar/2025/tuat/feed"/>
  <title>農工大 Advent Calendarの記事 - Qiita</title>
  <updated>2025-12-07T07:07:14+09:00</updated>
  <entry>
    <id>tag:qiita.com,2012:Public::AdventCalendar::CalendarItem/203521</id>
    <published>2025-12-07T07:07:14+09:00</published>
    <updated>2025-12-07T07:07:14+09:00</updated>
    <link rel="alternate" type="text/html" href="https://qiita.com/SUGAWA197203/items/f6b32e239c7dd723253b"/>
    <title>C++で仮想NICを作る(Linux)</title>
    <content type="text">はじめに
この記事では、C++で仮想NICを作ります。
目標として、作った仮想NICにIPパケットを送ると、仮想NICのコンソールに送られてきたペイロードを表示させるまでやります。

前提条件

Ubuntu 24.04.3 LTS
gcc version 13.3.0
...</content>
    <author>
      <name>SUGAWA197203</name>
    </author>
  </entry>
  以下省略

今回は記事のタイトルと内容を用います.
ただし,自作ブログの場合は,contentタグの要素が取ってこられないので,注意が必要です.

Go言語でRSSフィードを取得するには,github.com/mmcdole/gofeedパッケージを利用すると便利です.

main.go
func parseFeed(ctx context.Context, rssURL string) (*gofeed.Feed, error) {
    fp := gofeed.NewParser()
    return fp.ParseURLWithContext(rssURL, ctx)
}

Geminiを利用した要約生成

必要な情報を取得したら,次にGeminiを利用して要約を生成します.
より詳細な情報を取得したいので,Qiita記事のHTMLから記事内容をスクレイピングして取得します.

fetch.go
func fetchQiitaSummary(ctx context.Context, url string) (string, error) {
	ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return "", err
	}
	req.Header.Set("User-Agent", "advent-rss-demo/0.1 (+github.com/yourname)")

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		b, _ := io.ReadAll(resp.Body)
		return "", fmt.Errorf("status=%d body=%s", resp.StatusCode, string(b))
	}

	doc, err := goquery.NewDocumentFromReader(resp.Body)
	if err != nil {
		return "", err
	}

	if og := strings.TrimSpace(doc.Find(`meta[property="og:description"]`).AttrOr("content", "")); og != "" {
		return og, nil
	}
	if desc := strings.TrimSpace(doc.Find(`meta[name="description"]`).AttrOr("content", "")); desc != "" {
		return desc, nil
	}

	articleText := strings.TrimSpace(doc.Find("article").Text())
	if articleText != "" {
		if len(articleText) > 200 {
			articleText = articleText[:200] + "..."
		}
		return articleText, nil
	}

	return "", nil
}

Qiita 記事を直接 HTTP GET し,goquery でパースして,メタタグや記事本文から要約を取得しています.
Qiita記事以外や取得に失敗した場合はRSS の content / descriptionをフォールバックします.

内容が取得できたらGemini 2.5 flashを利用して要約を生成します.
まず,環境変数を設定します.

export GOOGLE_CLOUD_PROJECT="your-project-id"
export GOOGLE_CLOUD_LOCATION="us-central1"

Geminiはgoogle.golang.org/genaiパッケージを利用して呼び出せます.

gemini.go
resp, err := client.Models.GenerateContent(
	ctx,
	"gemini-2.5-flash",
	genai.Text(prompt),
	cfg,
)
if err != nil {
	return "", fmt.Errorf("GenerateContent: %w", err)
}

// レスポンス全体のテキストをまとめて取得
out := strings.TrimSpace(resp.Text())
if out == "" {
	return "", fmt.Errorf("empty summary")
}

プロンプトには次の内容を採用しました.

あなたはQiita Advent Calendarの紹介文を書くボットです。
以下の記事情報を読んで、日本語で140字以内の要約を1つ作ってください。

条件:
- 読者は技術系のエンジニアです。
- この記事がどんな内容か、どんな人に刺さりそうかが分かるように書いてください。
- 出力は要約文のみとし、「要約:」などのラベルは付けないでください。
- ですます調で140文字程度でまとめてください。
- 途中で切れた単語だけを返さず、必ず1文以上の完結した文章を出力してください。

[記事情報]
タイトル: %s
著者: %s
URL: %s

本文候補: %s

ここで,%sにはそれぞれ先程取得した記事タイトル,著者名,URL,記事内容が入ります.
おそらく今回うまく実行ができないとしたら,Google Cloudの設定関連だと思いますので,公式ドキュメントを参考に設定を進めてください.

Discord Webhookの設定

要約までできたらあとはDiscordに投稿するだけです.
今回は双方向通信が不要なので,Webhookを利用します.
もし,記事の内容をメンションしてききたい場合は,Botを作成するのがいいと思います.
Webhook URLのの取得方法については,ここでは割愛します.
参考になりそうな記事を貼っておきます.

https://zenn.dev/discorders/articles/discord-webhook-guide

URLを取得したら環境変数として設定しておきます.

export DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/xxxx/yyyy"

送るプログラムはこのようになります.埋め込みメッセージを利用して,見やすくしています.

discord.go
func (c *discordClient) Post(ctx context.Context, content string, embed *discordEmbed) error {
    payload := discordPayload{
        Content: content,
    }
    if embed != nil {
        payload.Embeds = []discordEmbed{*embed}
    }

    b, err := json.Marshal(payload)
    if err != nil {
        return fmt.Errorf("marshal discord payload: %w", err)
    }

    req, err := http.NewRequestWithContext(ctx, "POST", c.webhookURL, bytes.NewReader(b))
    if err != nil {
        return fmt.Errorf("new request: %w", err)
    }
    req.Header.Set("Content-Type", "application/json")

    resp, err := c.httpClient.Do(req)
    if err != nil {
        return fmt.Errorf("http post: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        return fmt.Errorf("discord webhook status: %d", resp.StatusCode)
    }
    return nil
}

ローカルで実行してみる

これで一通りのコードが揃いました.
いったんローカルで動作確認をしてみましょう.

main.go
func runOnce(ctx context.Context, rssURL string) error {
    feed, err := parseFeed(ctx, rssURL)
    if err != nil {
        return fmt.Errorf("RSS取得に失敗しました: %w", err)
    }

    printFeedInfo(feed)
    if len(feed.Items) == 0 {
        log.Println("feed has no items")
        return nil
    }

    dc, err := newDiscordClientFromEnv()
    if err != nil {
        return fmt.Errorf("Discord設定エラー: %w", err)
    }

    processed := 0
    for _, item := range feed.Items {
        if processed >= maxItemsPerRun {
            break
        }

        t, ok := itemTimeJST(item)
        if !ok {
            continue
        }
        if !isTodayJST(t) {
            continue
        }

        processed++
        log.Printf("Processing: %s (%s)", item.Title, t.Format(time.RFC3339))

        src := buildSummaryText(ctx, item)
        log.Printf("SummarySourceKind: %s", src.kind)

        bodyForLLM := truncate(src.text, bodyMaxLength)
        summary, err := summarizeWithGemini(ctx, item.Title, authorName(item), item.Link, bodyForLLM)
        if err != nil {
            log.Printf("Gemini error (%s): %v", item.Title, err)
            continue
        }

        fmt.Println("===== Gemini 要約結果 =====")
        fmt.Println(summary)
        fmt.Println()

        contentText := fmt.Sprintf(
            "【Advent Calendar】\n"+
                "📌 タイトル: %s\n"+
                "📝 要約:\n%s\n\n"+
                "🔗 %s",
            item.Title,
            summary,
            item.Link,
        )

        embed := &discordEmbed{
            Title:       item.Title,
            URL:         item.Link,
            Description: summary,
            Author: &discordAuthor{
                Name: authorName(item),
            },
            Footer: &discordFooter{
                Text: feed.Title,
            },
            Color: 0x00b8ff,
        }

        if err := dc.Post(ctx, contentText, embed); err != nil {
            log.Printf("Discord error (%s): %v", item.Title, err)
            continue
        }
        log.Printf("Posted to Discord: %s", item.Title)
    }

    if processed == 0 {
        log.Println("今日(JST)の記事は見つかりませんでした")
    }

    return nil
}

go run . {RSS_FEED_URL}で実行できます.
実行後暫く経つと,Discordに投稿が反映されているはずです.
もし権限周りのエラーがでたら,Google Cloudの設定を見直してみてください.
GOOGLE_APPLICATION_CREDENTIALSが設定されたままの人は,

unset GOOGLE_APPLICATION_CREDENTIALS
gcloud auth application-default login

とすれば,Vertex AIが正しいADCを取得できるようになります.

Google Cloudへデプロイ

ここまでできれば,あとはCloud RunとCloud Schedulerにデプロイするだけです.
まずはCloud Runにデプロイします.

# プロジェクトIDを設定
gcloud config set project "$PROJECT_ID"
# 必要なAPIを有効化
gcloud services enable \
  run.googleapis.com \
  aiplatform.googleapis.com \
  secretmanager.googleapis.com \
  cloudscheduler.googleapis.com

環境変数を使ってもいいですが,今回はSecret Managerを利用してみます.

# 初回のみ Secret を作成
gcloud secrets create discord-webhook \
  --replication-policy="automatic"

# Discord Webhook URLをSecret Managerに登録
# ターミナルで入力してもいいけど改行が入ると面倒なので,ファイルに保存してから登録する方法を紹介します
echo -n "$DISCORD_WEBHOOK_URL" > webhook.txt
gcloud secrets versions add discord-webhook \
  --data-file=webhook.txt

# Cloud Run実行SAにSecret Managerのアクセス権を付与
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
  --member="serviceAccount:${PROJECT_NUMBER}-compute@developer.gserviceaccount.com" \
  --role="roles/secretmanager.secretAccessor"

# PROJECT_NUMBER が未定義の場合は次で取得できます
# PROJECT_NUMBER=$(gcloud projects describe "$PROJECT_ID" --format="value(projectNumber)")

Cloud Runにデプロイして,ログを確認します.

gcloud logging read \
  'resource.type="cloud_run_revision" AND resource.labels.service_name="advent-rss-bot"' \
  --limit=50 \
  --format='value(textPayload)'

正常に動いているか確認したい場合は,Cloud RunのURLを叩いてみましょう.

CLOUD_RUN_URL={your-cloud-run-url}
curl "$CLOUD_RUN_URL/"

okが返ってくれば成功です.

最後にCloud Schedulerで定期実行を設定します.

# 初回は update ではなく create を使います
gcloud scheduler jobs create http advent-rss-job \
  --schedule="0 8 * * *" \
  --time-zone="Asia/Tokyo" \
  --location="asia-northeast1" \
  --uri="$CLOUD_RUN_URL" \
  --http-method=GET

gcloud scheduler jobs update http advent-rss-job \
  --schedule="0 8 * * *" \
  --time-zone="Asia/Tokyo" \
  --location="asia-northeast1"

これで毎日午前8時にCloud Runが実行されるようになります.

まとめ

実際にこちらのシステムは自鯖で動かしていて,毎朝Discordに新着記事が投稿されるようになっています.
アドベントカレンダーの記事を見逃したくない方は,ぜひ導入を検討してみてください.

また,Google Developer Groups on Campusでは,アプリ開発をはじめとしてメンバー同士で教え合いながら,楽しくプロダクト開発をしています.興味のある方はぜひお近くのチャプターに参加してみてください!

Discussion