Google App ScriptでYouTubeチャンネルの投稿動画一覧を取得する

表題の通り、Google App Scriptを用いてYouTubeチャンネルの投稿動画一覧をHTTP GETでJSON形式やCSV形式で取得するWebアプリケーションを、YouTube Data API v3を利用して書いてみました。

※当スクリプトで使用しているYouTubeチャンネルの「YouTubeハンドル」はYouTubeチャンネルのページのURLなどから取得できます。

使い方：
https://script.google.com/macros/s/***/exec?type=csv&order=desc&part=url,title,date,time,views,likes
でスクリプト内指定のYouTubeチャンネルの投稿動画情報（URL、動画タイトル、投稿日、再生時間、再生回数、いいね数）を投稿日の新しい順で並び替えてCSV形式の文字列で取得

:::note info
当GASをカスタムして取得結果をGoogleスプレッドシートに出力する、などもできるかもしれません。
:::

:::note warn
この機能を持つGASを公開すればいいのかもしれないですけど、筆者は一旦実行権限を「全員」にするのをやめています。YouTube Data API v3利用に関する料金体系について調べきれていないため、自己責任で各自GASのWebアプリケーション作成の上ご利用をお願いします。
:::

```javascript:main.gs
/**
 * @typedef {Object} Video 当APIで取得できる動画情報.
 * @prop {string} [id] ID.
 * @prop {string} [videoId] 動画ID.
 * @prop {string} [date] 動画投稿日（yyyy/MM/dd HH:mm:ss形式）.
 * @prop {string} [url] 動画URL.
 * @prop {string} [title] 動画名.
 * @prop {string} [channel] チャンネル名.
 * @prop {string} [time] 再生時間（H:mm:ss形式）.
 * @prop {string} [description] 動画の概要欄.
 * @prop {string} [thumbnail] 動画サムネイルURL.
 * @prop {string} [views] 動画の再生回数.
 * @prop {string} [likes] 動画のいいね数.
 */

/** @type {Object.<string, string>} カラム（プロパティ名）定義（※カスタム可能）. */
const COLUMNS = {
  /** ID. */
  ID: 'id',
  /** 動画ID. */
  VIDEO_ID: 'videoId',
  /** 動画投稿日（yyyy/MM/dd HH:mm:ss形式）. */
  PUBLISHED_DATE: 'date',
  /** 動画URL. */
  VIDEO_URL: 'url',
  /** 動画名. */
  VIDEO_TITLE: 'title',
  /** チャンネル名. */
  CHANNEL_TITLE: 'channel',
  /** 再生時間（H:mm:ss形式）. */
  DURATION: 'time',
  /** 動画の概要欄. */
  VIDEO_DESCRIPTION: 'description',
  /** 動画サムネイルURL. */
  THUMBNAIL_URL: 'thumbnail',
  /** 動画の再生回数. */
  VIEW_COUNT: 'views',
  /** 動画のいいね数. */
  LIKE_COUNT: 'likes',
};

// 独自実装（改編可能）：チャンネルキーとYouTubeハンドルのマッピング
// MEMO: ここに任意のキーと検索したいチャンネルのYouTubeハンドルを書いておく
// 後述のように、APIのクエリ文字列に「?channels=main,second」などと指定することにより、指定キーに対応するYouTubeハンドルの投稿動画一覧をフィルタして取得可能
//（channels未指定の場合、targetChannelに定義されている全てのYouTubeハンドルを対象にして取得）
const targetChannel = {
  // 例）Naokiman Show
  'main': '@naokimanshow8230',
  // 例）Naokiman 2nd Channel
  'second': '@naokiman2ndchannel922',
};

/** GETリクエスト時. */
function doGet(e) {
  /** @type {Video[]} */
  let videos = [];

  // 動画情報取得オプション
  const option = {
    // 独自実装（改編推奨）：チャンネルキーと投稿者ハンドル名のマッピング（例：?channels=main,second）
    channels: e.parameter.channels || Object.keys(targetChannel).join(','),
    // クエリ文字列で出力内容制御（例：?type=json|csv）：
    //   'json': JSON形式でGETする（デフォルト）
    //   'csv': CSV形式のテキストファイルでGETする
    type: e.parameter.type || 'json',
    // クエリ文字列で出力内容制御（例：?order=asc|desc）：
    //   'asc': 昇順、動画投稿日の古い順（デフォルト）
    //   'desc': 降順、動画投稿日の新しい順
    order: e.parameter.order || 'asc',
    // クエリ文字列で出力内容制御（例：?part=videoId,date,url,title,thumbnail,channel）：
    //   カラム（プロパティ名）定義の値で利用したいものをカンマ繋ぎで指定
    //   CSV出力をする場合、カラムの出力順も指定することが可能
    //   デフォルト値：videoId,date,url,title,thumbnail,channel
    part: e.parameter.part ? e.parameter.part.split(',') : [
      COLUMNS.VIDEO_ID,
      COLUMNS.PUBLISHED_DATE,
      COLUMNS.VIDEO_URL,
      COLUMNS.VIDEO_TITLE,
      COLUMNS.THUMBNAIL_URL,
      COLUMNS.CHANNEL_TITLE
    ]
  };

  // 指定のカラムを出力するかどうか
  option.containsPart = {
    id: option.part.indexOf(COLUMNS.ID) > -1,
    videoId: option.part.indexOf(COLUMNS.VIDEO_ID) > -1,
    date: option.part.indexOf(COLUMNS.PUBLISHED_DATE) > -1,
    url: option.part.indexOf(COLUMNS.VIDEO_URL) > -1,
    time: option.part.indexOf(COLUMNS.DURATION) > -1,
    title: option.part.indexOf(COLUMNS.VIDEO_TITLE) > -1,
    description: option.part.indexOf(COLUMNS.VIDEO_DESCRIPTION) > -1,
    thumbnail: option.part.indexOf(COLUMNS.THUMBNAIL_URL) > -1,
    channel: option.part.indexOf(COLUMNS.CHANNEL_TITLE) > -1,
    views: option.part.indexOf(COLUMNS.VIEW_COUNT) > -1,
    likes: option.part.indexOf(COLUMNS.LIKE_COUNT) > -1,
  };

  // 独自実装（改編推奨）：チャンネルキーと投稿者ハンドル名のマッピングを用いて複数チャンネルの動画を同時取得
  for (const channelLabel in targetChannel) {
    if (option.channels.indexOf(channelLabel) > -1) {
      // 指定のチャンネルの投稿動画一覧を取得する
      // MEMO: 動画一覧取得の本質はココ
      // concatを使えば複数のチャンネルの動画を混ぜて取得可能
      videos = videos.concat(getUploadedVideoList(targetChannel[channelLabel], option));
    }
  }

  if (option.order === 'desc') {
    // 降順、動画投稿日の新しい順でソート
    videos.sort((a, b) => b.date.localeCompare(a.date));
  } else {
    // 昇順、動画投稿日の古い順でソート
    videos.sort((a, b) => a.date.localeCompare(b.date));
  }

  switch (option.type) {
    case 'array':
      // 配列形式のJSONで取得
      const array = createVideoArray(videos, option);
      return ContentService.createTextOutput(JSON.stringify(array))
      .setMimeType(ContentService.MimeType.JSON);

    case 'csv':
      // CSV形式のテキストで取得
      const records = createVideoCSVRecords(videos, option);
      return ContentService.createTextOutput(records.join('\n'))
      .setMimeType(ContentService.MimeType.CSV);
      // MEMO: ↑MimeType.CSVだと、APIアクセス時にファイルのDLが始まる。WebAPIとしてテキストで処理をしたいならば、MimeType.TEXTにセットすればよい
    case 'json':
    default:
      // JSON形式で取得
      return ContentService.createTextOutput(JSON.stringify({
        videos: videos
      }))
      .setMimeType(ContentService.MimeType.JSON);
  }
}

/**
 * 指定のチャンネルの投稿動画一覧を取得する.
 * @param {string} handleName YouTubeチャンネルのチャンネルID（YouTubeハンドル）.
 * @param {Object} option 動画情報取得オプション.
 * @returns {Video[]} 投稿動画一覧.
 */
function getUploadedVideoList(handleName, option) {
  // YouTube Data API実行
  // https://developers.google.com/youtube/v3/docs/channels/list?hl=ja
  // MEMO: channels:list APIでYouTubeハンドルをもとにチャンネルIDと投稿動画のプレイリストIDを取得する
  const channelListResponse = YouTube.Channels.list(
    ['id', 'snippet', 'contentDetails'],
    {
      forHandle: handleName
    });

  /** @type {Video[]} */
  let videos = [];

  channelListResponse.items.forEach((channel) => {
    let nextPage;

    do {
      // YouTube Data API実行
      // https://developers.google.com/youtube/v3/docs/playlists/list?hl=ja
      // MEMO: playlists:list APIで投稿動画のプレイリストIDを取得しても一気に動画一覧情報が取得できない
      // nextPageTokenがなくなるまで（最後のページに行くまで）繰り返し取得する
      const playlistItemListResponse = YouTube.PlaylistItems.list(
        ['id', 'snippet', 'contentDetails'],
        {
          channelId: channel.id,
          playlistId: channel.contentDetails.relatedPlaylists.uploads,
          // MEMO: 後に複数の動画IDを使ってリクエストをするため、30件ずつリクエストするようにしている（50に増やしておいいかも）
          maxResults: 30,
          pageToken: nextPage || void 0
        });

      if (playlistItemListResponse.items.length > 0) {
        const currentPageVideos = [];
        // 動画IDと配列インデックスのマッピング
        const idIndexMap = {};

        playlistItemListResponse.items.forEach((playlistItem) => {
          // 動画ID
          const videoId = playlistItem.contentDetails.videoId;

          /** @type {Video} */
          const video = {};

          // IDを動画情報に設定
          if (option.containsPart.id) {
            video[COLUMNS.ID] = playlistItem.id;
          }

          // 動画IDを動画情報に設定
          if (option.containsPart.videoId) {
            video[COLUMNS.VIDEO_ID] = videoId;
          }

          // 動画投稿日を動画情報に設定
          // MEMO: ソートのためcontainsPartに関係なく取得する
          video[COLUMNS.PUBLISHED_DATE] = getDispDate(new Date(playlistItem.snippet.publishedAt));

          // 動画名を動画情報に設定
          if (option.containsPart.title) {
            video[COLUMNS.VIDEO_TITLE] = playlistItem.snippet.title;
          }

          // 動画URLを動画情報に設定
          if (option.containsPart.url) {
            video[COLUMNS.VIDEO_URL] = 'https://www.youtube.com/watch?v=' + videoId;
          }
          
          // チャンネル名を動画情報に設定
          if (option.containsPart.channel) {
            video[COLUMNS.CHANNEL_TITLE] = channel.snippet.title;
          }
          
          // 動画の概要欄を動画情報に設定
          if (option.containsPart.description) {
            video[COLUMNS.VIDEO_DESCRIPTION] = playlistItem.snippet.description;
          }

          // 動画サムネイルURLを動画情報に設定
          // MEMO: 一番高画質な画像を取得
          if (option.containsPart.thumbnail) {
            if (playlistItem.snippet.thumbnails.maxres && playlistItem.snippet.thumbnails.maxres.url) {
              video[COLUMNS.THUMBNAIL_URL] = playlistItem.snippet.thumbnails.maxres.url;
            } else if (playlistItem.snippet.thumbnails.standard && playlistItem.snippet.thumbnails.standard.url) {
              video[COLUMNS.THUMBNAIL_URL] = playlistItem.snippet.thumbnails.standard.url;
            } else if (playlistItem.snippet.thumbnails.high && playlistItem.snippet.thumbnails.high.url) {
              video[COLUMNS.THUMBNAIL_URL] = playlistItem.snippet.thumbnails.high.url;
            } else if (playlistItem.snippet.thumbnails.medium && playlistItem.snippet.thumbnails.medium.url) {
              video[COLUMNS.THUMBNAIL_URL] = playlistItem.snippet.thumbnails.medium.url;
            } else if (playlistItem.snippet.thumbnails['default'] && playlistItem.snippet.thumbnails['default'].url) {
              video[COLUMNS.THUMBNAIL_URL] = playlistItem.snippet.thumbnails['default'].url;
            }
          }

          currentPageVideos.push(video);
          idIndexMap[videoId] = currentPageVideos.length - 1;
        });

        if ((option.containsPart.time || option.containsPart.views || option.containsPart.likes) && currentPageVideos.length > 0) {
          // YouTube Data API実行
          // https://developers.google.com/youtube/v3/docs/videos/list?hl=ja
          // MEMO: playlists:list APIで取得できない動画時間、再生数、いいね数を取得
          const videoListResponse = YouTube.Videos.list(
            ['id', 'contentDetails', 'statistics'],
            {
              id: Object.keys(idIndexMap).join(','),
              maxResults: currentPageVideos.length
            });
          
          if (videoListResponse.items.length > 0) {
            videoListResponse.items.forEach((videoItem) => {
              if (videoItem.contentDetails.duration && videoItem.id in idIndexMap) {
                // 動画の再生時間を動画情報に設定
                if (option.containsPart.time) {
                  currentPageVideos[idIndexMap[videoItem.id]][COLUMNS.DURATION] = getDispTime(videoItem.contentDetails.duration);
                }
                // 動画の再生回数を動画情報に設定
                if (option.containsPart.views) {
                  currentPageVideos[idIndexMap[videoItem.id]][COLUMNS.VIEW_COUNT] = videoItem.statistics.viewCount;
                }
                // 動画のいいね数を動画情報に設定
                if (option.containsPart.likes) {
                  currentPageVideos[idIndexMap[videoItem.id]][COLUMNS.LIKE_COUNT] = videoItem.statistics.likeCount;
                }
              }
            });
          }
        }
        videos = videos.concat(currentPageVideos);
      }

      nextPage = playlistItemListResponse.nextPageToken;

    } while (nextPage)
  });

  return videos;
}

/**
 * 動画一覧を配列形式で取得する.
 * @param {Video[]} videos 動画一覧.
 * @param {Object} option 動画情報取得オプション.
 * @returns {Array.<Array.<string|null>>} 配列形式情報.
 */
function createVideoArray(videos, option) {
  return videos.map((video) => {
    const item = [];

    option.part.forEach((part) => {
      switch (part) {
        case COLUMNS.ID:
        case COLUMNS.VIDEO_ID:
        case COLUMNS.PUBLISHED_DATE:
        case COLUMNS.VIDEO_URL:
        case COLUMNS.DURATION:
        case COLUMNS.VIDEO_TITLE:
        case COLUMNS.THUMBNAIL_URL:
        case COLUMNS.CHANNEL_TITLE:
        case COLUMNS.VIEW_COUNT:
        case COLUMNS.LIKE_COUNT:
          item.push(video[part] || null);
          break;
        case COLUMNS.VIDEO_DESCRIPTION:
          item.push(video[COLUMNS.VIDEO_DESCRIPTION] ? video[COLUMNS.VIDEO_DESCRIPTION].replace(/\n/g,'\\n') : null);
          break;
        default:
          item.push(null);
          break;
      }
    });

    return item;
  });
}

/**
 * 動画一覧をCSV形式で取得する.
 * @param {Video[]} videos 動画一覧.
 * @param {Object} option 動画情報取得オプション.
 * @returns {Array.<string>} CSV形式情報.
 */
function createVideoCSVRecords(videos, option) {
  const records = [
    option.part.join(',')
  ];

  videos.forEach((video) => {
    const item = [];

    option.part.forEach((part) => {
      switch (part) {
        case COLUMNS.ID:
        case COLUMNS.VIDEO_ID:
        case COLUMNS.PUBLISHED_DATE:
        case COLUMNS.VIDEO_URL:
        case COLUMNS.DURATION:
        case COLUMNS.THUMBNAIL_URL:
        case COLUMNS.VIEW_COUNT:
        case COLUMNS.LIKE_COUNT:
          item.push(video[part]);
          break;
        case COLUMNS.VIDEO_TITLE:
        case COLUMNS.CHANNEL_TITLE:
        case COLUMNS.VIDEO_DESCRIPTION:
          item.push(`"${video[part].replace(/"/g,'""')}"`);
          break;
        default:
          item.push('');
          break;
      }
    });

    records.push(item.join(','));
  });

  return records;
}

const YYYYMMDDHHMMSS_FORMAT = new Intl.DateTimeFormat(
  undefined,
  {
    year:   'numeric',
    month:  '2-digit',
    day:    '2-digit',
    hour:   '2-digit',
    minute: '2-digit',
    second: '2-digit',
  }
);

/**
 * 動画の投稿日をyyyy/MM/dd HH:mm:ss形式の文字列で取得.
 * @param {Date} time 投稿日.
 * @returns {string} yyyy/MM/dd HH:mm:ss形式の文字列.
 */
function getDispDate(date) {
  return YYYYMMDDHHMMSS_FORMAT.format(date);
}

const ISO8601_DURATION_REGEXP = /(-)?P(?:([.,\d]+)Y)?(?:([.,\d]+)M)?(?:([.,\d]+)W)?(?:([.,\d]+)D)?T(?:([.,\d]+)H)?(?:([.,\d]+)M)?(?:([.,\d]+)S)?/;

/**
 * ISO8601形式の日時を(H:)mm:ss形式の文字列で取得.
 * @param {string} time ISO8601形式の日時.
 * @returns {string} (H:)mm:ss形式の文字列.
 */
function getDispTime(time) {
  if (!time || !ISO8601_DURATION_REGEXP.test(time)) {
    return '';
  }
  const matches = ISO8601_DURATION_REGEXP.exec(time);

  const h = matches[6];
  let m = matches[7] || '0';
  let s = matches[8] || '0';
  if (s.length === 1) {
    s = '0' + s;
  }
  if (h && m.length === 1) {
    m = '0' + m;
  }

  return h ? `${h}:${m}:${s}` : `${m}:${s}`;
}
```
:::note info
当コードはMITライセンスでお願いします。
:::