VTuberなどで活動をしていると「YouTubeに自分の動画を投稿したらDiscordのチャンネルに自動的で通知して欲しいな」といった考えが及んでここを読んでいる人もいるかと思いますが、そういった人のために少しでもお役立てられたらいいなと思い手順を以下に示します。
なお、ここではGoogleのスプレッドシートを活用してGASと連携させたAPIスクリプトを用意します。
2026.04.04 の修正では、時間トリガー実行中の一時的な基盤不安定や外部呼び出し時の異常終了を修正しました。このスクリプトは、時間トリガーで動き、YouTube API・Discord Webhook・Spreadsheet の3系統に触るので、失敗点を細かく分けて再試行できるように変更しました。時間主導トリガーは installable trigger として動作し、LockService は同時実行の衝突回避に使用。
修正はこの3点です。
1. 外部呼び出しに再試行を入れる
2. getActiveSpreadsheet() 依存をやめて openById() にする
3. 実行の最後の到達地点が必ず分かるログを増やす
SpreadsheetApp.openById(id) は公式に用意されていて、スプレッドシートIDで明示的に開けます。時間トリガーのようなヘッドレス実行では、こちらの方が安定した切り分けがしやすいため採用しました。
YouTube 公式の実装ガイドにその流れを案内しているため。
時間トリガーが重なると、同じシートへ同時アクセスしたり、同じ通知を二重送信したりして不安定になるため。
毎回 channels.list しても良いですが、初回取得後に保存しておくと無駄なAPI呼び出しが減ることを知ったため。
今は大枠でしか失敗原因が見えないので、
YouTube取得
シート読込
Discord送信
シート書込
を分けてログに出すと切り分けしやすくした。
1件ずつ送るなら残しても良いですが、件数が多いと実行時間を無駄に使うため。Discord Webhook は通常そこまで厳しくないので、まずは 300ms 程度でもよいです。
eventType は completed, live, upcoming のいずれかです。"none" を指定すると意図しない挙動になるか、パラメータが無視されます。通常の動画のみを取得したい場合はフィルタリングロジックをJS側で行うのが安全のため。
sheet.getRange("A2:A").getValues() はシートの最終行(デフォルトで1000行以上)まで空白を含めて読み込むため、処理が重くなります。データがある行まで (getLastRow) に限定するよう変更。
複数の動画が同時に見つかった場合、ループで一瞬でPOSTするとDiscord側でレート制限(Rate Limit)にかかる可能性があります。Utilities.sleep(1000) などで少し待機時間を挟むのがマナーのために変更。
APIは「新しい順」で返ってきますが、ループでそのまま処理すると「最新の動画」が先に通知され、その後に「その前の動画」が通知されます。Discord上では時系列順(古い→新しい)に並んだほうが自然なため、配列を逆順にするか、後ろから処理することをお勧めします。
まずは最初にYouTubeに投稿された動画情報をリスト化し蓄積するためのスプレッドシートをGoogleドライブの任意場所に作成します。このリストが無いと過去に投稿された内容かどうかGASが判断できなくなり、毎回決められた時刻または間隔でDiscordに投稿されてしまいます。
名前は「getYouTubeData」など分かりやすいタイトルで付けましょう。
シート名は「SentVideos_001」などで付けましょう。
SentVideos_001のシートの1行目に「VideoID」というヘッダーを追加します。
先ほどの手順1で用意した動画リストのスプレッドシート「getYouTubeData」を開きメニューから「拡張機能 / Apps Script」を選択します。
Apps Script が作成されたら「コード」のボタンから任意のタイトルを付けましょう。
例えば「getyoutube」などです。
YoutubeのAPIを呼び出すため「サービス」のボタンから「YouTube Data API v3」を選択してサービスを追加しましょう。
GASからDiscordに情報を投稿できるようウェブフックを作成しましょう。
Discordのサーバー設定から「連携サービス / ウェブフック」を開き「新しいウェブフック」を押します。すると「アイコン」「お名前」「チャンネル」がそれぞれ出てきますので、任意の設定と入力をしましょう。Botが自動的に入力していく事になりますので、チャンネルは通知Bot用などで用意してあげるとよいかもしれません。
一通り設定出来たら「ウェブフックURLをコピー」を押下してGASのコードの「function setupScriptProperties」に入力する準備をします。
GASに中核となるコードを作成しましょう。コードは以下に用意しました。
function checkYouTubeAndNotify() {
const lock = LockService.getScriptLock();
if (!lock.tryLock(10000)) {
console.warn("Another execution is already running. Skipped.");
return;
}
const startedAt = new Date();
console.log("========================================");
console.log(`Start: ${startedAt.toISOString()}`);
try {
const config = getConfig_();
validateConfig_(config);
console.log("Checkpoint: config loaded");
const sheet = getOrSetupSheet_(config.spreadsheetId, config.sheetName);
console.log("Checkpoint: sheet opened");
const sentVideoIds = getSentVideoIds_(sheet);
console.log(`Known video IDs: ${sentVideoIds.size}`);
const uploadPlaylistId = getUploadPlaylistId_(config.channelId);
console.log(`Upload playlist ID: ${uploadPlaylistId}`);
const items = fetchLatestUploads_(uploadPlaylistId, config.maxResults);
console.log(`Fetched items: ${items.length}`);
console.log("Checkpoint: latest uploads fetched");
processVideos_(items, sheet, sentVideoIds, config);
console.log("Checkpoint: processVideos end");
console.log(`Finished successfully. Duration(ms): ${new Date() - startedAt}`);
} catch (err) {
console.error(`Fatal error: ${err && err.stack ? err.stack : err}`);
throw err;
} finally {
lock.releaseLock();
console.log("End.");
console.log("========================================");
}
}
/* =========================
CONFIG
========================= */
function getConfig_() {
const props = PropertiesService.getScriptProperties();
return {
channelId: (props.getProperty("CHANNEL_ID") || "").trim(),
webhookUrls: (props.getProperty("WEBHOOK_URLS") || "")
.split(/\r?\n|,/)
.map(s => s.trim())
.filter(Boolean),
spreadsheetId: (props.getProperty("SPREADSHEET_ID") || "").trim(),
botName: (props.getProperty("BOT_NAME") || "YouTube通知").trim(),
sheetName: (props.getProperty("SHEET_NAME") || "SentVideos_001").trim(),
maxResults: parseInt(props.getProperty("MAX_RESULTS") || "10", 10),
sleepMs: parseInt(props.getProperty("SLEEP_MS") || "300", 10),
};
}
function validateConfig_(config) {
if (!config.channelId) throw new Error("CHANNEL_ID 未設定");
if (!config.webhookUrls.length) throw new Error("WEBHOOK_URLS 未設定");
if (!config.spreadsheetId) throw new Error("SPREADSHEET_ID 未設定");
}
/* =========================
RETRY CORE
========================= */
function withRetries_(fn, label, maxAttempts = 3, baseSleep = 1000) {
let lastError;
for (let i = 1; i <= maxAttempts; i++) {
try {
console.log(`[TRY ${i}] ${label}`);
return fn();
} catch (err) {
lastError = err;
console.error(`[FAIL ${i}] ${label}: ${err.message}`);
if (i < maxAttempts) {
Utilities.sleep(baseSleep * i);
}
}
}
throw lastError;
}
/* =========================
YOUTUBE
========================= */
function getUploadPlaylistId_(channelId) {
const props = PropertiesService.getScriptProperties();
const key = `UPLOAD_PLAYLIST_ID__${channelId}`;
let id = props.getProperty(key);
if (id) return id;
const res = withRetries_(() =>
YouTube.Channels.list("contentDetails", { id: channelId })
, "YouTube.Channels.list");
id = res.items[0].contentDetails.relatedPlaylists.uploads;
props.setProperty(key, id);
return id;
}
function fetchLatestUploads_(playlistId, max) {
const res = withRetries_(() =>
YouTube.PlaylistItems.list("snippet,contentDetails", {
playlistId: playlistId,
maxResults: max
})
, "YouTube.PlaylistItems.list");
return res.items || [];
}
/* =========================
PROCESS
========================= */
function processVideos_(items, sheet, sent, config) {
const ordered = items.slice().reverse();
for (const item of ordered) {
try {
const videoId = item?.contentDetails?.videoId;
if (!videoId || sent.has(videoId)) continue;
const title = item.snippet.title;
const url = `https://youtu.be/${videoId}`;
const payload = {
username: config.botName,
content:
`📺 新しい動画が投稿されました!
🎬 ${title}
🔗 ${url}`
};
const ok = postDiscord_(payload, config.webhookUrls);
if (ok) {
sheet.appendRow([videoId, title, url, new Date()]);
sent.add(videoId);
console.log(`Sent: ${title}`);
Utilities.sleep(config.sleepMs);
}
} catch (e) {
console.error("Item error:", e);
}
}
}
/* =========================
DISCORD
========================= */
function postDiscord_(payload, urls) {
let success = true;
for (const url of urls) {
try {
const res = withRetries_(() =>
UrlFetchApp.fetch(url, {
method: "post",
contentType: "application/json",
payload: JSON.stringify(payload),
muteHttpExceptions: true
})
, "Discord fetch");
if (res.getResponseCode() >= 300) {
success = false;
}
} catch (e) {
success = false;
}
}
return success;
}
/* =========================
SHEET
========================= */
function getOrSetupSheet_(spreadsheetId, sheetName) {
const ss = withRetries_(() =>
SpreadsheetApp.openById(spreadsheetId)
, "Spreadsheet open");
let sheet = ss.getSheetByName(sheetName);
if (!sheet) {
sheet = ss.insertSheet(sheetName);
sheet.appendRow(["VideoID", "Title", "URL", "Date"]);
}
return sheet;
}
function getSentVideoIds_(sheet) {
const lastRow = sheet.getLastRow();
if (lastRow <= 1) return new Set();
const values = sheet.getRange(2, 1, lastRow - 1, 1).getValues().flat();
return new Set(values);
}
/**
* 初期設定を一括で入れたい場合だけ手動実行してください。
* 必要に応じて値を書き換えてから実行。
*/
function setupScriptProperties() {
const props = PropertiesService.getScriptProperties();
props.setProperties({
CHANNEL_ID: "チャンネルIDを記載",
WEBHOOK_URLS: "Discordのウェブフックを指定",
BOT_NAME: "DiscordのBOT名を記載",
SPREADSHEET_ID: "あなたのスプレッドシートID",
SHEET_NAME: "スプレッドシート名を指定",
MAX_RESULTS: "10",
SLEEP_MS: "300",
SKIP_OLD_ON_FIRST_RUN: "false"
}, true);
console.log("Script Properties have been set.");
}
コードには4つの要素として「チャンネルID」と「ウェブフックID」、そして「スプレッドシートのID」と「シート名」を指定する必要があります。このコードではシート名が不明な場合には紐づけが出来るシートを自動で追加する機能を実装しています。
property は「function setupScriptProperties」にあり、プロパティは次のように構成されています。
CHANNEL_ID: "チャンネルIDを記載",
WEBHOOK_URLS: "Discordのウェブフックを指定",
BOT_NAME: "DiscordのBOT名を記載",
SPREADSHEET_ID: "あなたのスプレッドシートID",
SHEET_NAME: "スプレッドシート名を指定",
YouTubeのIDの取得方法はYouTubeチャンネルの概要欄から「チャンネルを共有」のボタンを押下します。「チャンネルIDをコピー」が選択できるようになりますので押下して取得します。コピーができたらGASのコード入力に戻ってチャンネルIDをペーストします。
例えば、私のチャンネルIDであれば「UC7ig1kLH30YH7cEfxE83ZWA」というのがチャンネルIDになります。
プロパティの1行目にチャンネルIDをペーストしてください。
手順5で用意したウェブフックIDをプロパティの2行目に追加しましょう。
このボットに使用している名前を3行目に追加しましょう。
Script Properties に SPREADSHEET_ID を追加してください。
値は通知履歴を保存している Google スプレッドシートのURLの /d/ と /edit の間です。
例:https://docs.google.com/spreadsheets/d/123ABC=0
であれば
ID:123ABC
です。
現在使用しているスプレッドシートIDをプロパティの4行目に追加、続いて記録しているスプレッドシートの名前を5行目に追加しましょう。
コードの入力が完了したら「プロジェクトの保存」をしてください。
ここまで入力出来たら実際に実行してテストしてみましょう。
まずはプロパティからプロジェクト設定を反映させるために「function setupScriptProperties() 」を実行します。
実行が成功すると「プロジェクトの設定」にある「スクリプト プロパティ」に設定が反映されたことが判ります。
つづいて「function checkYouTubeAndNotify()」を実行します。
実行が成功すると右の図のようにDiscordに投稿が成功したことが分かります。
作成したAPIは実行したときのみに動作しますので、これを繰り返し確認させるためのトリガーを設定してあげましょう。
Apps Script のメニューから目覚まし時計のアイコン「トリガー」を押下。少しすると右下に「+トリガーを追加」というボタンを押下します。
そうすると様々な設定ができる項目が出てきます。例えば15分ごとに確認させたい場合は「時間ペースのトリガーのタイプを選択」から「分ペースのタイマー」を選び「時間の感覚を選択」から15分を選んでください。
このようにトリガーの設定は非常に手軽ですので、自分のチャンネルに合った使い方でDiscordを充実させていきましょう!
最後に「プロジェクトの保存」をしてください。
すべての内容に問題が無ければ「保存」して閉じてください。
お疲れさまでした。これでYouTube通知Botの構築は完了です!
YouTube Data APIは1日あたりの使用量は10,000まで利用できます。
ここで紹介している仕様ではスプレッドシートに情報が累積されていくため、長期間の運用や検索先のVideoIDが増えていくにつれて、追加されていくレコードが増えていきます。これによって動画情報の重複を検索する際に1件1件検索しています。
この場合ではVideoIdのカラムは配列に格納し、indexOfで検索することで処理を効率化することが出来ます。
このエラーが出る原因は、「スクリプト内の関数名を変更(または削除)したのに、古い関数名を実行する『トリガー』が残っている」ためです。
修正版のスクリプトにて関数名を getYouTubeData から checkYouTubeAndNotify に変更しました。しかし、設定済みのトリガー(自動実行の設定)はまだ getYouTubeData を探しているため、「実行しようとしたが見つからない(削除された)」というエラーになっています。
以下の手順でトリガーを再設定すれば直ります。
修正手順
トリガー画面を開く
Apps Scriptエディタの左側にあるメニューから「時計のマーク(トリガー)」をクリックします。
古いトリガーを削除
リストの中に getYouTubeData という関数を実行する設定があるはずです。
行の右端にある「︙(三点リーダー)」をクリックし、「トリガーを削除」を選択します。
新しいトリガーを作成
右下の「トリガーを追加」ボタンをクリックします。
実行する関数を選択: checkYouTubeAndNotify (修正版スクリプトのメイン関数名)を選択してください。
イベントのソース: 「時間主導型」
トリガーのタイプ: 「分ベースのタイマー」
間隔: 「5分おき」(または10分、15分などお好みで)
「保存」をクリックします。
これでエラーは解消され、新しいスクリプトが定期的に動くようになります。