はじめに
企業のテックブログは日々大量に更新されますが、全部を追いかけるのは現実的ではありません。かといって、キーワードフィルタだけでは「なんとなく気になる記事」を拾いきれない。
そこで、LLMに自分の興味プロファイルを渡して「これは読みたいと思うか」を判定させ、通過したものだけをDiscordに通知する仕組みを作りました。本記事では、要件定義から実装、そしてVPSへのデプロイでハマったポイントまでを一通り振り返ります。
やりたかったこと
- 国内企業のテックブログ(日本語)を横断的に収集する
- 興味関心に合致する記事だけをDiscordに通知する
- Wi-Fi制限がある環境でもオフラインで直近のダイジェストを確認できる(最終的にはこの要件は落としました。詳細は後述)
技術選定
収集元: 統合RSSフィードを自作せず既存OSSに乗る
個別に企業ブログのRSSを集めて回るのは大変だろうと思っていたのですが、調べてみるとyamadashy/tech-blog-rss-feedという、日本企業のテックブログを網羅的に集約しているOSSプロジェクトが見つかりました。
- GitHub Actionsで定期更新
- 日本語以外の言語で書かれた記事が多いブログは対象外という運営方針
- 統合RSS(
https://yamadashy.github.io/tech-blog-rss-feed/feeds/rss.xml)を配信
「日本語限定」という要件と方針が一致していたので、これをそのまま利用することにしました。自前でブログリストをメンテナンスする工程がまるごと不要になったのは大きかったです。
言語: Go
- 単一バイナリで配置できる手軽さを優先
- クロスコンパイルにより、開発機(WSL2)でビルドしたものをそのままVPSに置くだけで動く
本文抽出: readability系ライブラリ
RSSのdescriptionだけだと判定材料として薄いので、記事本文まで取得することにしました。ただし発信元がはてなブログ・Zenn・note・企業独自ドメインなど多岐にわたるため、サイトごとの個別パーサーは非現実的です。Mozilla Readabilityアルゴリズムを移植したGo実装(github.com/cixtor/readability)を使い、汎用的に本文を抽出しています。抽出に失敗した場合はRSSのdescriptionにフォールバックする設計にしました。
永続化: SQLite(Pure Go実装)
当初はmattn/go-sqlite3(CGO依存)を使っていましたが、「本番環境に余計なツールを入れたくない」という要件から、途中でmodernc.org/sqlite(Pure Go実装)に切り替えました。これによりVPS側にGoもgccも不要になり、開発機でビルドしたバイナリをscpで置くだけのデプロイが実現できました。
興味判定: Gemini 3.5 Flash
- 直近半年に「いいね」した記事のタイトル・概要をプロンプトに埋め込み、興味プロファイルとして扱う
- 未判定記事を複数件まとめてバッチでプロンプトに渡し、二値判定(興味あり/なし)を
responseSchemaで構造化出力させる - 運用開始直後は「いいね」記事が0件なので、プロンプト側で分岐し「技術的に具体的で読み応えのある記事はやや広めに興味ありと判定する」というコールドスタート対応を入れている
通知・フィードバック収集: Discord Bot
Webhookではなく、あえてBotトークンを使っています。理由は、ユーザーが記事に👍リアクションを付けたことを検知し、それを「いいね記事」としてDBに蓄積したかったためです。Webhookは送信専用でリアクションを取得できません。
Botが投稿時に自ら👍リアクションを1つ先に付けておき、ユーザーはそこに自分のリアクションを重ねるだけで「いいね」を記録できるようにしています。常時接続のゲートウェイ(WebSocket)は不要で、REST APIのポーリングだけで完結する設計です。
システム構成
[VPSのcron] --1時間おき--> [Goバイナリを直接実行]
|
+-------------------------+-------------------------+
| | |
[収集]統合RSS取得 [判定]Gemini API [通知]Discord Bot API
| | |
+------------[SQLite(単一ファイル)]-----------------+
当初はcron-job.orgのような外部スケジューラ経由でHTTPエンドポイントを叩く構成も検討しましたが、最終的には「VPS自体のcronから直接バイナリを実行する」形に落ち着きました。これにより、外部から叩かれるエンドポイントの認証をどう守るか、という懸念そのものが不要になりました。
ハマったポイント
1. HTTPタイムアウト未設定によるハングアップ
http.DefaultClientはタイムアウトが設定されていないため、応答しないブログサーバーに当たると処理全体が無限に止まってしまいました。全てのHTTP通信(RSS取得・本文フェッチ・Gemini API・Discord API)で共有するhttp.Client{Timeout: 15 * time.Second}を1つ用意し、全ステージに渡すことで解決しました。
2. 未処理記事が大量に溜まった状態での初回実行
コードの手直しを重ねている間に未判定記事が400件超溜まってしまい、1回の実行が長時間化する問題がありました。1回の実行で処理する件数に上限(maxArticlesPerRun)を設け、残りは次回以降のcron実行に持ち越す設計に変更しました。
3. Gemini APIの一時的な高負荷(503)
運用開始直後、Gemini APIが503 Service Unavailable(高負荷時の一時エラー)を返すことがありました。当初は1バッチ失敗すると即座に全体を諦める実装になっていたため、短いバックオフを挟んだリトライ(2回)を追加し、それでも失敗したバッチは次回実行に持ち越すよう変更しました。
4. SQLiteのNULLスキャンエラー
RSSのdescriptionが空の記事があり、body_excerpt列がNULLのままDBに保存されてしまうケースがありました。Go側でNULLをstring型にスキャンできずクラッシュしていたため、COALESCE(body_excerpt, '')をクエリに追加しつつ、INSERT時にも明示的に空文字列で初期化するよう修正しました。
5. Discord Botの招待でハマる
Discord Developer Portalの画面構成が変わっており、「Integration requires code grant」というエラーに遭遇しました。これは「Requires OAuth2 Code Grant」という設定がオンになっていたことが原因で、シンプルなBot招待では不要な機能でした。この設定をオフにすることで解決しました。
運用の考え方
いいね記事のDBが最初は空なので、初期の判定はやや広めになる設計にしています。運用しながら気に入った記事に👍を重ねていくことで、次第に精度が上がっていく想定です。「気に入らない記事のいいねを外す」のではなく、「気に入った記事にだけ能動的に👍を重ねる」オプトイン方式にしているのがポイントです。
今後の課題
- Gemini APIのレート制限が本格的に厳しくなった場合の代替(バッチサイズ調整、Flash-Liteへの切り替えなど)
- 本文抽出の成功率が低いサイトへの個別対応
- いいね記事が一定数溜まってきた後の判定精度の検証
まとめ
既存のOSS(統合RSSフィード)にうまく乗ることで自前実装の範囲を減らし、Pure Go実装への切り替えで「本番環境にツールを入れたくない」という要件も満たせました。細かいデバッグは多かったものの、個人開発として無理のない規模に収まったと思います。
