YouTube動画管理自動化プロジェクト

プロジェクト概要

本プロジェクトは、Google Apps Script(GAS)とYouTube Data APIを活用して、YouTube上の動画情報を自動で取得・更新する仕組みを構築するものです。具体的には、Google Sheetsを管理画面として利用し、動画情報(タイトル、概要、タグ、放送日時など)をバルクで更新することで、手動でのデータ入力作業を自動化しました。これにより、従来の手作業による入力コストを削減し、効率的な運用を実現しました。

  • 開発期間:2024年8月(7日)
  • 開発人数:1人
奈良

1週間の作業で年間100万円の削減です。プログラミングって凄いなって感動したのを覚えています。

背景と課題

とある事業所では、毎日10本~15本の生配信を行い、レギュラー番組は毎週同じ曜日・同じ時間に放送されていました。生配信の番組情報をもとに録画された動画がアップロードされるため、各動画の番組情報(番組名、概要、放送日時など)を書き換える作業に非常に多くの時間と手間がかかっていました。

週3日のアルバイトに8~9万円程度支払い、定型文ベースのデータ入力を行っていた結果、年間約100万円の人件費が発生していました。

そこで、この手間を削減するために自動化の提案がなされ結果としてその人件費削減に成功しました。

奈良

タイトルは番組名+年月日、概要欄もほぼ定型文。自動化しやすい条件でした。開発には「環境構築も楽でGoogleやYoutubeとの相性が良さそう」なGoogle Apps Script (GAS)を選択しました。

使用技術・ツール

  • Google Apps Script (GAS)
    GASを利用して、Google Sheetsと外部API(YouTube Data API)を連携。自動実行や定期スケジュール実行も可能にしています。
  • YouTube Data API v3
    動画リストの取得、各動画の詳細情報(タイトル、概要、タグなど)の取得および更新を行っています。
  • OAuth2 認証
    プロジェクトでは、OAuth2ライブラリを用いて安全にYouTube APIにアクセス。アクセストークンの管理とリフレッシュ処理も実装しています。
  • Google Sheets
    動画情報の一覧表示や更新対象の設定はGoogle Sheetsで管理。Sheet1にAPIから取得した動画情報、Sheet2に更新後の値(タイトル、概要、タグ、再生リストIDなど)を出力します。また、Configシートからは更新内容を読み取り、動画ごとに突合せた上で値を上書きします。

主な機能

1. OAuth2認証とアクセストークン取得

  • setupOAuthService()
    OAuth2サービスを設定し、YouTube APIへの認証情報を構築。
  • getAccessToken()
    アクセストークンを取得し、必要に応じてリフレッシュ処理を行うことで、常に有効なトークンを保持。
const CLIENT_ID = 'YOUR_CLIENT_ID_HERE';
const CLIENT_SECRET = 'YOUR_CLIENT_SECRET_HERE';

/**
 * OAuth2サービスを作成して返す関数
 * @returns {OAuth2Service} YouTube 用のOAuth2サービスオブジェクト
 */
function setupOAuthService() {
  return OAuth2.createService('youtube')
      .setAuthorizationBaseUrl('https://accounts.google.com/o/oauth2/auth')
      .setTokenUrl('https://accounts.google.com/o/oauth2/token')
      .setClientId(CLIENT_ID)
      .setClientSecret(CLIENT_SECRET)
      .setCallbackFunction('authCallback')
      .setPropertyStore(PropertiesService.getUserProperties())
      .setScope('https://www.googleapis.com/auth/youtube')
      .setParam('access_type', 'offline')
      .setParam('prompt', 'consent')
      .setCache(CacheService.getUserCache());
}

/**
 * OAuth2サービスを利用してアクセストークンを取得する関数
 * @returns {string|null} アクセストークンまたはnull(取得できなかった場合)
 */
function getAccessToken() {
  const service = setupOAuthService();
  
  if (!service.hasAccess()) {
    Logger.log('リフレッシュトークンがありません。認証が必要です。');
    service.reset();
    return null;
  }

  let accessToken = service.getAccessToken();

  if (!accessToken) {
    Logger.log('アクセストークンの取得に失敗しました。リフレッシュトークンを使用して再取得を試みます。');
    if (service.refresh()) {
      accessToken = service.getAccessToken();
      Logger.log('新しいアクセストークンを取得しました。');
    } else {
      Logger.log('アクセストークンの再取得に失敗しました。認証が必要です。');
      service.reset();
      return null;
    }
  }
  return accessToken;
}
奈良

まずは普通に認証してから・・・

2. 動画情報の取得とシートへの出力

  • fetchVideos()
    YouTube APIから自分の動画リストを最大50件単位で取得し、Sheet1に「曜日」「時間」「動画ID」「YouTubeリンク」「タイトル」「アップロード年月日」「概要欄」「タグ」「再生リストID」の順に書き出します。
    ※ ステータスに関する列は最初加えましたが、不都合がいろいろ出てきたので削除しました。
奈良

ステータス系を一つでもいじると、他のも定義しないとデフォルトになってしまう影響があるようで今回はステータスはブラウザ(YouTube管理画面)で管理していただくようにしました。

function fetchVideos(maxResults = 50, totalResults = 50) { // 50件更新
  const sheet1 = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('シート1');
  const sheet2 = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('シート2');
  const accessToken = getAccessToken();
  let nextPageToken = '';
  let videoCount = 0;

  sheet1.clear();
  sheet1.appendRow(['曜日', '時間', '動画ID', 'YouTubeリンク', 'タイトル', 'アップロード年月日', '概要欄', 'タグ', '再生リストID']);

  while (videoCount < totalResults) {
    const searchUrl = `https://www.googleapis.com/youtube/v3/search?part=snippet&forMine=true&type=video&order=date&maxResults=${maxResults}&pageToken=${nextPageToken}`;
    const searchOptions = {
      headers: { Authorization: 'Bearer ' + accessToken },
      method: 'get',
      contentType: 'application/json',
      muteHttpExceptions: true
    };

    const searchResponse = UrlFetchApp.fetch(searchUrl, searchOptions);
    const searchResult = JSON.parse(searchResponse.getContentText());
    if (searchResult.error) {
      Logger.log('Error: ' + searchResult.error.message);
      return;
    }
    if (!searchResult.items) {
      Logger.log('Error: No items found in search results.');
      break;
    }

    searchResult.items.forEach(function(video) {
      if (videoCount >= totalResults) return;

      const videoId = video.id.videoId;
      const videoDetailsUrl = `https://www.googleapis.com/youtube/v3/videos?part=snippet,liveStreamingDetails&id=${videoId}`;
      const videoDetailsOptions = {
        headers: { Authorization: 'Bearer ' + accessToken },
        method: 'get',
        contentType: 'application/json',
        muteHttpExceptions: true
      };

      const videoDetailsResponse = UrlFetchApp.fetch(videoDetailsUrl, videoDetailsOptions);
      const videoDetails = JSON.parse(videoDetailsResponse.getContentText());
      if (videoDetails.error || !videoDetails.items || !videoDetails.items[0]) {
        Logger.log('Error retrieving video details: ' + (videoDetails.error ? videoDetails.error.message : 'No video details available'));
        return;
      }

      const snippet = videoDetails.items[0].snippet;
      const liveStreamingDetails = videoDetails.items[0].liveStreamingDetails;
      const actualStartTime = liveStreamingDetails && liveStreamingDetails.actualStartTime
                              ? new Date(liveStreamingDetails.actualStartTime)
                              : new Date(snippet.publishedAt);
      const dayOfWeek = Utilities.formatDate(actualStartTime, Session.getScriptTimeZone(), 'EEEE');
      const time = Utilities.formatDate(actualStartTime, Session.getScriptTimeZone(), 'HH:mm');
      const uploadDate = Utilities.formatDate(actualStartTime, Session.getScriptTimeZone(), 'yyyy/MM/dd');
      const title = snippet.title;
      const description = snippet.description;
      const tags = snippet.tags ? snippet.tags.join(', ') : '';
      const videoLink = `https://www.youtube.com/watch?v=${videoId}`;

      sheet1.appendRow([dayOfWeek, time, videoId, videoLink, title, uploadDate, description, tags, '']);
      videoCount++;
    });

    nextPageToken = searchResult.nextPageToken;
    if (!nextPageToken) break;
  }

  sheet2.clear();
  const range = sheet1.getDataRange();
  sheet2.getRange(1, 1, range.getNumRows(), range.getNumColumns()).setValues(range.getValues());
}
奈良

この処理で既存の動画情報を一旦スプレッドシートに書き出します。

3. Configシートによる動画更新情報の適用

  • updateSheetFromConfig()
    Configシートの各行に記載された更新情報(タイトル、概要、タグ、再生リストID)と、Sheet2の動画情報を突合せ。対象の行が一致した場合、動画情報を上書きします。
    ※ ここでは公開ステータスに関する設定は扱わず、対象の値のみを更新しています。
  • 下記がConfigシートのスプレッドシートイメージ
奈良

Configシートにあらかじめ定型文の番組情報を登録しておきます。各動画は番組ごとに固定のアップロード時刻に沿ってアップロードされるため、その時刻を基準にして該当する番組情報で上書きする仕組みを採用しました。ただし、放送開始時刻が前後する場合を考慮し、アップロード時刻の前後5〜10分の範囲は許容する処理も加えています。

4. YouTube動画の更新

  • updateVideoDetails()
    Sheet2に記載された情報を元に、各動画のタイトル、概要、タグをYouTubeに更新する処理を実装。
奈良

シート2に反映された最新の情報を基に、YouTube APIへ送信し動画情報を更新する処理です。なお、公開設定やその他のデフォルト値の調整は、const payload 部分で制御しています。

function updateVideoDetails() {
  const sheet2 = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("シート2");
  const lastRow = sheet2.getLastRow();
  const accessToken = getAccessToken();
  const categoryId = '22'; // カテゴリID

  for (let i = 2; i <= lastRow; i++) {
    const videoId = sheet2.getRange(i, 3).getValue();
    const title = sheet2.getRange(i, 5).getValue();
    const description = sheet2.getRange(i, 7).getValue();
    const tags = sheet2.getRange(i, 8).getValue();
    const playlistId = sheet2.getRange(i, 9).getValue();
    const tagsArray = tags ? tags.split(',').map(tag => tag.trim()).filter(tag => tag.length > 0) : [];

    const payload = {
      id: videoId,
      snippet: {
        title: title,
        description: description,
        tags: tagsArray,
        categoryId: categoryId
      }
    };

    const options = {
      headers: { Authorization: 'Bearer ' + accessToken },
      method: 'put',
      contentType: 'application/json',
      payload: JSON.stringify(payload),
      muteHttpExceptions: true
    };

    const response = UrlFetchApp.fetch("https://www.googleapis.com/youtube/v3/videos?part=snippet", options);
    Logger.log(response.getContentText());

    if (playlistId) {
      addToPlaylist(videoId, playlistId);
    }
  }

  /**
   * 指定の再生リストに動画を追加する関数
   * @param {string} videoId - 動画ID
   * @param {string} playlistId - 再生リストID
   */
  function addToPlaylist(videoId, playlistId) {
    const url = "https://www.googleapis.com/youtube/v3/playlistItems?part=snippet";
    const payload = {
      snippet: {
        playlistId: playlistId,
        resourceId: {
          kind: "youtube#video",
          videoId: videoId
        }
      }
    };
    const options = {
      headers: { Authorization: 'Bearer ' + accessToken },
      method: 'post',
      contentType: 'application/json',
      payload: JSON.stringify(payload),
      muteHttpExceptions: true
    };
    const response = UrlFetchApp.fetch(url, options);
    Logger.log(response.getContentText());
  }
}

5. 全体の実行

runAllProcesses() 関数で、上記の処理をまとめて実行します。認証、動画リスト取得、Configシートによる更新、そして最終的なYouTube動画の更新が順次行われます。

function runAllProcesses() {
  const accessToken = getAccessToken();
  if (!accessToken) {
    Logger.log('アクセストークンの取得に失敗しました。処理を中断します。');
    return;
  }
  fetchVideos();           // 動画リストをシートに書き出し
  updateSheetFromConfig(); // Configシートの情報でシート2に上書き
  updateVideoDetails();    // シート2の情報でYouTube動画を更新
}
奈良

以前は、アクセストークンの期限切れなどにより処理が中断されるケースがあり、チェック用に挿入していたif文がそのまま残っていました。現在では、アクセストークンの有効性を確認し、必要に応じて自動で再取得してから処理を実行するように改善されています。

修正が必要だったポイント

  • 公開設定の影響排除
    これまでの実装では、公開設定(privacyStatus)やその他のステータスが更新時に初期化されるリスクがありました。今回、更新リクエストからstatusパートを完全に省略することで、ブラウザで設定した既存の公開状態を維持しつつ、必要なsnippet情報だけを更新するようにしました。
  • シート構成のシンプル化
    ConfigシートおよびSheet1/Sheet2から不要な「ステータス」列を削除し、運用上の混乱を防止。これにより、ユーザーが管理画面でデータを確認する際の視認性が向上しています。

今後修正したいところ

  • ウェブアプリとしてデプロイ
    GASはログインできるユーザーがプログラム内容にアクセス・改変可能な性質があるため、コードの改変リスクが常に存在します。完全に防ぐのは難しいですが、必要なユーザーのみ権限を付与する、またはWebアプリとしてデプロイして実行結果のみを公開するなど、アクセス管理の運用改善を検討します。
奈良

できるらしいと人から聞いたレベルなので、行う場合は調べてから実行したい。

習得したスキル・経験

  • Google Apps Scriptの実践的運用
    Sheetsとの連携や定期実行の自動化を実装し、GASの効率的な利用方法を習得。
  • YouTube Data APIとの統合
    RESTful APIの利用方法、動画リソースの取得・更新、ページネーション処理など、API連携に関する知識を深めました。
  • OAuth2認証の理解と実装
    セキュアなAPIアクセスのための認証フローの構築、アクセストークンの管理・リフレッシュ処理を経験。
  • 自動化による業務改善の実践
    手作業によるデータ入力の自動化を通じ、業務プロセスの効率化およびコスト削減の効果を実感。
  • APIトークン制限解除の申請経験
    API利用時のトークン制限を解除または増量するための申請プロセスを経験。調整や必要なリソースの確保に関する知識と交渉スキルを習得しました。
  • 問題解決能力とプロジェクト管理
    短期間でのシステム構築および実装、要件定義から運用までの一連のプロセスを経験し、プロジェクトマネジメント能力を強化。

まとめ

このプロジェクトは、GASとYouTube Data APIの連携を通じて、動画管理の自動化および効率化を実現した実践例です。特に、公開設定の更新を行わずに必要な動画情報のみをバルク更新するアプローチは、既存の設定を崩さずに運用できる点で大きなメリットがあります。こうした事例とニーズは少数かもしれませんが、問題解決ができて良かったです。

良かったらフォローお願いします

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です