この記事は「東葛.dev Advent Calendar 2025」3日目の記事です。
https://adventar.org/calendars/11653
東葛.devがなにかについては以下をご確認ください。
https://toukatsu.dev/
成果物

このような通知が(追加があった場合のみ)毎日10:00に投稿されます。
背景とモチベーション
課題
コミュニティでメンバーが自由に曲を追加できるSpotifyのプレイリストを運用していますが、更新があっても気づきにくいという問題がありました。せっかく曲を追加してもらっても、反応が遅れてしまうことが課題でした。
解決策
この課題を解決するため、曲が追加されたら自動的にDiscordへ通知するBotを開発することにしました。
技術スタック(選定理由付き)
本Botの実装には、Denoのエコシステムを活用し、無料かつ手軽にすべてを完結させることを目指しました。
| 技術 | 役割 | 選定理由 |
|---|---|---|
| Deno | 実装言語/ランタイム | Denoのエコシステムで無料ですべて完結できそうだったため。独自の機能は少なく、Node.jsのように利用可能。 |
| Deno Deploy (Classic) | ホスティング先 | Denoアプリケーションを実行するためのサーバーレスプラットフォーム。Cron機能を利用するためClassic環境を選択。 |
| Deno Cron | 定期実行管理 | Denoランタイムに組み込まれたcronタスクスケジューラ。ローカルマシンを起動しっぱなしにする必要がないため。 |
| Deno KV | 曲の更新管理 | Deno Deployで設定なしで手軽に利用できるKey Valueストア。前回のプレイリスト状態を保存するために利用。 |
| Discord.js | Discord APIラッパー | Discord API操作の省力化のため。デファクトスタンダードとして採用。 |
| Spotify API | プレイリスト情報取得 | 要件上必須。公開プレイリストのため、認証は比較的容易(Client Credentials Flowを利用)。 |
技術スタック解説
- Deno: 次世代JavaScript/TypeScriptランタイム
- Deno Deploy: JavaScript/TypeScriptアプリを実行するためのサーバーレスプラットフォーム
- Deno Cron: Deno Deployで設定不要で動作する、組み込みの定期実行スケジューラ
- Deno KV: Deno Deployで設定不要で利用できる、シンプルで手軽なKey Valueストア
開発環境と依存関係
Denoの特徴として、npm install のような事前インストール作業は不要です。
パッケージのインストールと deno.lock
コード内で import ... from "npm:package-name" と記述するだけで、初回実行時にDenoが自動的にパッケージをダウンロード・キャッシュします。
また、依存関係のバージョンを固定・管理するために deno.lock ファイルが自動生成(または deno cache --lock-write で生成)されます。これにより、チーム開発やデプロイ環境でも同じバージョンのライブラリが使用されます。
実装の解説:3つのファイル構成
コードは、役割に応じて以下の3つのファイルに分割し、シンプルに実装しました。
-
notifyDiscord.ts: Discordへの通知処理 -
notifyNewMusic.ts: Spotifyの情報取得、変更チェック、通知のメインロジック -
main.ts: Deno Cronの設定とエントリポイント
1. Discordへの通知処理 (notifyDiscord.ts)
主にDiscord BotのRESTクライアントの初期化と、指定チャンネルへのEmbedメッセージ送信を担当します。
環境変数 DISCORD_BOT_TOKEN からトークンを読み込んでいます。
import { APIEmbed, REST, Routes } from "npm:discord.js";
const DISCORD_BOT_TOKEN = Deno.env.get("DISCORD_BOT_TOKEN") || "YOUR_BOT_TOKEN";
// RESTクライアントの初期化
export function initRestClient(): REST {
const rest = new REST({ version: '10' }).setToken(DISCORD_BOT_TOKEN);
console.log("RESTクライアントを初期化しました。API操作を開始します...");
return rest;
}
/**
* Discordの指定チャンネルにEmbedメッセージを送信する関数。
* @param rest - discord.jsのRESTクライアントインスタンス。
* @param channelId - メッセージを送信するチャンネルのID。
* @param embed - 送信するAPIEmbedオブジェクト。
*/
export async function sendDiscordNotification(
rest: REST,
channelId: string,
embed: APIEmbed
): Promise<void> {
try {
await rest.post(
Routes.channelMessages(channelId),
{
body: {
embeds: [embed]
}
}
);
console.log(`✅ 通知をチャンネルID ${channelId} へ送信しました。`);
} catch (error) {
console.error(`❌ チャンネルID ${channelId} へのメッセージ送信中にエラーが発生しました:`, error);
throw error; // 呼び出し元にエラーを再スロー
}
}
2. Spotifyの情報取得と変更検知 (notifyNewMusic.ts)
これがBotの中心となるロジックです。Spotify APIとDeno KVを連携させて、プレイリストの変更を検知します。
処理のポイント
- 変更検知の効率化: Spotifyプレイリストは更新されるたびにスナップショットIDが更新されます。このIDをDeno KVに保存し、前回と最新のIDを比較することで、大まかな変更の有無を素早くチェックしています
- 追加曲の特定: スナップショットIDが更新されていた場合、前回保存した全トラックIDリストと最新のIDリストを比較し、差分として追加された曲を特定します
- エラー時の詳細ログ: 試行錯誤の影響で、Spotify API接続失敗時に詳細なエラーメッセージを出力するようにしています
処理の流れ
- アクセストークンの取得 (Client Credentials Flow)
- Deno KVから前回のスナップショットID (
lastSnapshotId) を取得 - Spotifyから最新のプレイリスト情報 (
latestData) を取得 - スナップショットIDを比較し、変更がなければ処理を終了
- 変更があった場合、Deno KVから前回時点の楽曲リスト (
lastTrackIds) を取得 - 差分を計算し、
addedTrackIdsを特定 -
addedTrackIdsの詳細情報(曲名、アーティスト名など)をSpotify APIから取得(最大50件) - 取得した情報を元にDiscord Embedを作成し、
notifyDiscord.ts経由で通知を送信 - Deno KVへ最新のスナップショットIDと楽曲リストを保存(アトミック操作)
// 必要な型やライブラリをインポート
import { APIEmbed } from "npm:discord.js";
import { initRestClient, sendDiscordNotification } from "./notifyDiscord.ts";
const MESSAGE_TARGET_CHANNEL_ID = Deno.env.get("MESSAGE_TARGET_CHANNEL_ID") || "YOUR_TARGET_CHANNEL_ID";
const CLIENT_ID = Deno.env.get("SPOTIFY_CLIENT_ID")!;
const CLIENT_SECRET = Deno.env.get("SPOTIFY_CLIENT_SECRET")!;
const DETAIL_LINK = "この通知についての詳細が書かれたリンク";
const PLAYLIST_ID = "公開プレイリストID";
const KV_KEY_SNAPSHOT = ["spotify", PLAYLIST_ID, "snapshot_id"];
const KV_KEY_TRACKS = ["spotify", PLAYLIST_ID, "track_ids"];
interface SpotifyTrack {
id: string;
name: string;
artists: { name: string }[];
album: { name: string; };
}
// ----------------------------------------------------
// 認証処理: Client Credentials Flow
// ----------------------------------------------------
async function getAccessToken(): Promise<string> {
const authHeader = btoa(`${CLIENT_ID}:${CLIENT_SECRET}`);
const response = await fetch("[https://accounts.spotify.com/api/token](https://accounts.spotify.com/api/token)", {
method: "POST",
headers: {
"Authorization": `Basic ${authHeader}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: "grant_type=client_credentials",
});
if (!response.ok) {
const errorBody = await response.text();
let errorMessage = `HTTP Status: ${response.status} (${response.statusText})`;
try {
const errorJson = JSON.parse(errorBody);
errorMessage += `\nSpotify Error: ${errorJson.error_description || errorJson.error}`;
} catch {
errorMessage += `\nRaw Body: ${errorBody}`;
}
throw new Error(`Failed to get access token: ${errorMessage}`);
}
const data = await response.json();
return data.access_token;
}
// ----------------------------------------------------
// Spotify APIからプレイリストの情報を取得
// ----------------------------------------------------
async function fetchPlaylistData(accessToken: string, playlistId: string): Promise<{ snapshotId: string; trackIds: string[] }> {
// 1. メタデータ (snapshot_id) の取得
const metaRes = await fetch(`https://api.spotify.com/v1/playlists/${playlistId}?fields=snapshot_id,tracks.total`, {
headers: { "Authorization": `Bearer ${accessToken}` }
});
if (!metaRes.ok) throw new Error(`Failed to fetch playlist meta: ${metaRes.statusText}`);
const metaData = await metaRes.json();
// 2. トラックリストの取得 (ページネーションは省略)
const tracksRes = await fetch(`https://api.spotify.com/v1/playlists/${playlistId}/tracks?fields=items(track(id))&limit=100`, {
headers: { "Authorization": `Bearer ${accessToken}` }
});
if (!tracksRes.ok) throw new Error(`Failed to fetch playlist tracks: ${tracksRes.statusText}`);
const tracksData = await tracksRes.json();
const trackIds = tracksData.items
.map((item: any) => item.track?.id)
.filter((id: string) => id);
return { snapshotId: metaData.snapshot_id, trackIds };
}
// ----------------------------------------------------
// メイン処理
// ----------------------------------------------------
export async function notifyNewMusics() {
const kv = await Deno.openKv();
console.log("--- Spotify プレイリスト変更チェックを開始 ---");
let accessToken: string;
try {
accessToken = await getAccessToken();
console.log("✅ アクセストークン取得成功");
} catch (e) {
console.error(e); return;
}
// 1. KVから前回のスナップショットIDを取得
const snapshotResult = await kv.get<string>(KV_KEY_SNAPSHOT);
const lastSnapshotId = snapshotResult.value;
let latestData;
try {
latestData = await fetchPlaylistData(accessToken, PLAYLIST_ID);
} catch (e) {
console.error("❌ プレイリストデータの取得に失敗:", e); return;
}
// 2. スナップショットIDを比較して変更をチェック
if (latestData.snapshotId === lastSnapshotId) {
console.log("ℹ️ スナップショットIDが一致しました。プレイリストに変更はありません。");
kv.close(); return;
}
console.log(`⚠️ プレイリストの変更を検出! (旧ID: ${lastSnapshotId || 'なし'}, 新ID: ${latestData.snapshotId})`);
// 3. 変更があった場合、KVから前回のトラックIDリストを取得
const tracksResult = await kv.get<string[]>(KV_KEY_TRACKS);
const lastTrackIds = new Set(tracksResult.value || []);
// 4. 差分を計算して、追加された曲を特定
const addedTrackIds: string[] = [];
for (const trackId of latestData.trackIds) {
if (!lastTrackIds.has(trackId)) {
addedTrackIds.push(trackId);
}
}
if (addedTrackIds.length > 0) {
console.log(`🎉 新しく ${addedTrackIds.length} 曲が追加されました!`);
// --- トラック詳細情報の取得 ---
const MAX_IDS_PER_REQUEST = 50;
const idsQuery = addedTrackIds.slice(0, MAX_IDS_PER_REQUEST).join(',');
const encodedIdsQuery = encodeURIComponent(idsQuery);
const tracksDetailRes = await fetch(`https://api.spotify.com/v1/tracks?ids=${encodedIdsQuery}`, {
headers: { "Authorization": `Bearer ${accessToken}` }
});
if (tracksDetailRes.ok) {
const tracksDetailData = await tracksDetailRes.json();
// Discord Embed用に整形
const fields = tracksDetailData.tracks.map((track: SpotifyTrack) => ({
name: `🎧️ ${track.name}`,
value: `🎤アーティスト名: ${track.artists.map(a => a.name).join(' & ')} 💿️アルバム: ${track.album.name}`,
inline: false,
})).slice(0, 10);
const rest = initRestClient();
const notificationEmbed: APIEmbed = {
title: `📢 「東葛.devのお気に入り」新曲追加通知`,
description: `新曲が${addedTrackIds.length}曲追加されました!ぜひ聞いてみてください。`,
color: 0x5865F2,
fields: fields,
timestamp: new Date().toISOString(),
footer: { text: DETAIL_LINK }
};
await sendDiscordNotification(rest, MESSAGE_TARGET_CHANNEL_ID, notificationEmbed);
}
} else {
console.log("ℹ️ 追加された曲はありませんでした。(並び替えや削除があった可能性があります)");
}
// 5. Deno KVの情報を更新
const commitResult = await kv.atomic()
.set(KV_KEY_SNAPSHOT, latestData.snapshotId)
.set(KV_KEY_TRACKS, latestData.trackIds)
.commit();
if (commitResult.ok) {
console.log("✅ Deno KVに最新のプレイリスト情報を保存しました。");
} else {
console.error("❌ Deno KVの更新に失敗しました。");
}
kv.close();
console.log("--- 処理を終了 ---");
}
3. Deno Cronの設定 (main.ts)
notifyNewMusics関数を定期的に実行するための設定です。
毎日朝10:00 JSTにチェックが走るように設定しています。(UTC表記では午前1時)
import { notifyNewMusics } from "./notifyNewMusic.ts";
Deno.cron(
"Spotify Add New Music Notification",
"0 1 * * *", // UTC 01:00 = JST 10:00
async () => {
console.log("--- Deno Cron 実行開始 (JST 10:00) ---");
await notifyNewMusics();
console.log("--- Deno Cron 実行終了 ---");
}
);
ローカル実行とユーティリティ
Botをローカル環境で動作確認する際の手順と、開発に便利なツールを紹介します。
環境変数の設定と実行
ローカルで実行する場合は、必要な環境変数を設定した上で以下のコマンドを実行します。必要な権限フラグ(--allow-netなど)を付与する必要があります。
deno run --allow-net --allow-env --allow-sys notifyNewMusic.ts
おまけ:ローカル開発用のKVリセットツール
ローカル実行時、Deno KVはローカルファイルにデータを永続化します。
「プレイリストに変更があった時」の挙動を何度もテストしたい場合、KVに保存されたスナップショットIDをリセットする必要があります。
以下のスクリプト(scripts/kvReset.ts)を使用することで、ローカルのKVデータを全消去できます。
// scripts/kvReset.ts
async function resetKvData() {
console.log("--- Deno KVデータリセットを開始 ---");
// KVデータベースを開く
const kv = await Deno.openKv();
let count = 0;
// 全てのキーを検索 (プレフィックスを何も指定しない)
const entries = kv.list({ prefix: [] });
console.log("検索された全てのキーを削除キューに追加中...");
for await (const entry of entries) {
// キーとその値を削除
await kv.delete(entry.key);
console.log(`- 削除キー: ${entry.key.join(', ')}`);
count++;
}
if (count === 0) {
console.log("ℹ️ 削除対象のデータは見つかりませんでした。");
kv.close();
return;
}
console.log(`✅ 完了: 合計 ${count} 件のデータが削除されました。`);
kv.close();
}
// 実行
resetKvData();
実行コマンド:
deno run --allow-sys --unstable-kv scripts/kvReset.ts
デプロイ環境のセットアップ(環境変数)
Deno Deploy環境では、以下の環境変数の設定が必要です。
また新Deno DeployではCronが利用できないのでClassicを利用してください。
Deno Deploy Classic:https://docs.deno.com/deploy/classic/
-
DISCORD_BOT_TOKEN: Discord Botの認証トークン -
MESSAGE_TARGET_CHANNEL_ID: 通知を送信するDiscordチャンネルのID -
SPOTIFY_CLIENT_ID: Spotify APIのクライアントID -
SPOTIFY_CLIENT_SECRET: Spotify APIのクライアントシークレット
このBotにより、コミュニティのプレイリスト更新に素早く気づき、より活発な反応ができるようになりました。