GASでYouTube Data APIを使ってみる(検索サイトを作成)

YouTube動画を検索するサイトを作ります(自分専用のためエラー処理は省略^^;)
GASを使えばAPIキーの取得やサーバーが不要です

目次
  1. スクリプトの準備
  2. YouTube.Searchで検索
  3. Webサイトとして公開
  4. クライアント側の実装
  5. Videos: list

スクリプトの準備

スタンドアロン(GoogleドライブからGASを新規ファイルとして開きます)で開きます

拡張サービス(YouTube Data API)を追加します
「サービスの+ボタン」をクリックし「YouTube Data API」を選択し追加します

HTMLファイルを追加します
「ファイルの+ボタン」をクリックし「HTML」を選択「index」と入力します

YouTube.Searchで検索

YouTube Data APIの「Search: list」メソッドを利用します
「Search: list」はリクエストで指定したクエリパラメータに一致する検索結果を返します
検索結果の構造(itemsにidやタイトルなどの情報が含まれます)

{
  "kind": "youtube#searchListResponse",
  "etag": etag,
  "nextPageToken": string,
  "prevPageToken": string,
  "pageInfo": {
    "totalResults": integer,
    "resultsPerPage": integer
  },
  "items": [
    検索リソース
  ]
}

Search.list(part, option)メソッドの引数
part:必須でレスポンスに含めるリソースのプロパティ'id,snippet'を指定します(YouTube Data APIのパラメータ)
snippetも指定することで検索結果(items)に動画のIDだけでなくタイトルや説明などが含まれます
maxResults:結果の最大数を0以上50以下で指定します(デフォルト値は5)
order:並べ替え方法date(作成日の新しい順)rating (評価の高い順)viewCount(再生回数の多い順)など
q :検索クエリを指定します

function searchWord() {
  const results = YouTube.Search.list('id,snippet', {
    maxResults:2,
    q:'Google App Script',
    //再生回数の多い順
    order:'viewCount'   
  });
//itemsをログで確認してみる
  results.items.forEach(item=>{
   console.log(item.id);
   console.log(item.snippet);
  })
}

searchWord関数を実行します(保存→実行)

アクセス権限の承認をします(初回のみ)

権限を確認→アカウントを選択して→詳細→プロジェクト名(安全ではないページ)に移動→許可をクリック

実行ログの抜粋

---id---
{ kind: '・・・', videoId: '・・・' }
---snippet---
{ description: '・・・',
  publishTime: '2019-01-10T23:13:15Z',
  channelId: '・・・',
  title: '・・・',
  liveBroadcastContent: 'none',
  channelTitle: '・・・',
  publishedAt: '2019-01-10T23:13:15Z',
  thumbnails: 
   { medium: 
      { width: 320,
        url: 'https://・・・',
        height: 180 },
     high: 
      { url: 'https://・・・',
        height: 360,
        width: 480 },
     default: 
      { url: 'https://・・・',
        height: 90,
        width: 120 } 
   } 
}

Webサイトとして公開

1:doGet()関数でindex.htmlファイルをHtmlOutputオブジェクトにして返します
HtmlService.createHtmlOutputFromFile(‘index’)
doGetは「公開したURLにアクセスがあったとき」というトリガーです
2:searchWord(keyword)関数でクライアント側から検索キーワードを受け取り、結果をクライアント側に返却します(後でクライアント側スクリプトでgoogle.script.runを実装します)
*検索クエリはエンコードされたキーワードが渡ってくるのでデコードします

function doGet(){
  return HtmlService.createHtmlOutputFromFile('index');
}
function searchWord(keyword) {
  const results = YouTube.Search.list('id,snippet', {
    maxResults:2,
//エンコードされたキーワードを受け取るのデコードする
    q:decodeURIComponent(keyword),
    order:'viewCount'   
  });
 return results;
}

とりあえず公開する

  1. 右上「デプロイ」から「新しいデプロイ」をクリック「アクセスできるユーザー」を選択
  2. 左上「歯車アイコン」のをクリック「ウェブアプリ」を選択して「デプロイ」をクリック
  3. 右上「デプロイ」から「デプロイをテスト」を選択し「URL」をコピー「完了」をクリック

コピーしたURLにアクセスしたときの画面表示

備考:「デプロイをテスト(常に最後に保存されたコードを実行)」にするとリロードするだけで変更が反映されます( コードと同期されます)
「新しいデプロイ」では、変更するたびにテプロイする必要があります(バージョン付きデプロイ)
*開発中は「デプロイをテスト」で取得したURLで変更を確認します
*本番では、完成したコードを「新しいデプロイ」で公開したURLを使います
*バージョン付きデプロイはアーカイブできますが、削除はできません

クライアント側の実装

「index.html」を編集してサーバー側に検索キーワードを送り、サーバー側から返却された結果を表示します

サーバー側に検索キーワードを送る準備をします
*スタイルはBootstrapを使って見た目を整えました

<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <!-- Bootstrapの読み込み -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
  </head>
  <body>
     <div class="container my-4">
        <div class="col-auto d-flex justify-content-center" >
         <input type="text" class="form-control w-75" placeholder="検索キーワード">
         <button type="submit" class="btn btn-primary">検索する</button>
        </div>
       <!-- 検索結果を表示 -->
         <ul id="contents" class="d-flex flex-wrap justify-content-around mt-3" style="padding:0"></ul>
     </div>

     <script>
       const contents = document.querySelector('#contents');
       const searchTerm = document.querySelector('input');
       const btn = document.querySelector('button');
//検索ボタンをクリックした時
       btn.addEventListener('click', (e)=>{
        e.preventDefault();
        //入力された値を取得してURLエンコードします
        let search = encodeURIComponent(searchTerm.value)
        console.log(search);
       });
     </script>
  </body>
</html>

余談:URL全体をエンコードする場合はencodeURI
パラメータなどの部分的な文字列をエンコードする場合はencodeURIComponent

日本語で検索してコンソールで確認
URLエンコーディングされていればOK

GAS関数とやり取りできるように実装します

google.script.run.withSuccessHandler(callback).GAS関数
callbackはGAS関数(searchWord)が応答したときに実行するクライアント側の関数
callbackの引数にGAS関数(searchWord)が返す値が入ります

btn.addEventListener('click', (e)=>{
    e.preventDefault();
    let search = encodeURIComponent(searchTerm.value)
    //Gas関数searchWordに検索ワードを渡し、YouTubeAPIで取得した結果はonSuccessで処理
    google.script.run.withSuccessHandler(onSuccess).searchWord(search);
   });  
 //onSuccess関数 
 function onSuccess(results) { 
 //必要な情報をまとめたオブジェクトの配列(data)を作る
  let data = results.items.map(result => {
   return {
    id: result.id.videoId,
    title: result.snippet.title,
    date: result.snippet.publishTime,
    description: result.snippet.description,
    img: result.snippet.thumbnails.medium.url      
     }
   })
  console.log(data)
 //ここで取得したdataからHTMLをレンダリングする関数を呼び出す render(data)
}

備考google.script.runは「HTMLサービスページ」が「GAS関数」を呼び出せるようにする非同期のクライアント側 「JavaScript API 」です
withSuccessHandler(callback)はクライアント側のコードは「GAS関数」の完了を待たずに次に進むため、引数に「GAS関数が応答したときに実行するクライアント側のコールバック関数」を指定できます
「GAS関数」が値を返す場合、その値を引数として新しい関数に渡します

YouTubeAPIで取得した結果からHTMLをレンダリングする関数

  function render(data) {
      contents.innerHTML = '';
      let html = data.reduce((accu, str) => {
         return accu + `
         <li class="card mb-2" style="width: 18rem;">
         <a href="http://www.youtube.com/watch?v=${str.id}" target="_blank">
         <img src="${str.img}" class="card-img-top"></a>
         <div class="card-body">
         <h5 class="card-title">${str.title}</h5>
         <p class="card-text">${str.description}</p>
         <p class="card-text">${str.date.split('T')[0]}</p>
         </div></li>
         `
      }, '')
      contents.innerHTML = html
    }

2023年11月追記:Google Apps Scriptのセキュリティポリシーが更新されたようで、以前は許可されていた innerHTMLの使用が、CSPによってブロックされているようなので、コードを変更しました
注意:innerHTMLを使っているコードはエラーになります

全てのコードを見る

function doGet(){
  return HtmlService.createHtmlOutputFromFile('index');
}
function searchWord(keyword) {
  const results = YouTube.Search.list('id,snippet', {
    maxResults:30,
    q:decodeURIComponent(keyword),
    //再生回数の多い順
    order:'viewCount'   
  });
 return results;
}
<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <!-- Bootstrapの読み込み -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
  </head>
  <body>
     <div class="container my-4">
        <div class="col-auto d-flex justify-content-center" >
         <input type="text" class="form-control w-75" placeholder="検索キーワード">
         <button type="submit" class="btn btn-primary">検索する</button>
        </div>
        <!-- 検索結果を表示 -->
        <ul id="contents" class="d-flex flex-wrap justify-content-around mt-3" style="padding:0"></ul>
     </div>

     <script>
       const searchTerm = document.querySelector('input');
       const btn = document.querySelector('button');
       //クリックしたときの処理
        btn.addEventListener('click', (e)=>{
        e.preventDefault();
        //入力された値を取得してURLエンコードします
        let search = encodeURIComponent(searchTerm.value)
        //Gas関数searchWordに検索ワードを渡し、YouTubeAPIの検索結果はonSuccessで処理
        google.script.run.withSuccessHandler(onSuccess).searchWord(search);
     });  

   //onSuccess関数
      function onSuccess(results) { 
       let data = results.items.map(result => {
        return {
          id: result.id.videoId,
          title: result.snippet.title,
          date: result.snippet.publishTime,
          description: result.snippet.description,
          img: result.snippet.thumbnails.medium.url      
        }
       })
       render(data)
      }

  function render(data) {
      contents.innerHTML = '';
      let html = data.reduce((accu, str) => {
         return accu + `
         <li class="card mb-2" style="width: 18rem;">
         <a href="http://www.youtube.com/watch?v=${str.id}" target="_blank">
         <img src="${str.img}" class="card-img-top"></a>
         <div class="card-body">
         <h5 class="card-title">${str.title}</h5>
         <p class="card-text">${str.description}</p>
         <p class="card-text">${str.date.split('T')[0]}</p>
         </div></li>
         `
      }, '')
      contents.innerHTML = html
    }
     </script>
  </body>
</html>

変更後のコード

<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <!-- Bootstrapの読み込み -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
  </head>
  <body>
     <div class="container my-4">
        <div class="col-auto d-flex justify-content-center" >
         <input type="text" class="form-control w-75" placeholder="検索キーワード">
         <button type="submit" class="btn btn-primary">検索する</button>
        </div>
        <!-- 検索結果を表示 -->
        <ul id="contents" class="d-flex flex-wrap justify-content-around mt-3" style="padding:0"></ul>
     </div>

     <script>
       const contents = document.querySelector('#contents');
       const searchTerm = document.querySelector('input');
       const btn = document.querySelector('button');
       //クリックしたときの処理
        btn.addEventListener('click', (e)=>{
        e.preventDefault();
        //入力された値を取得してURLエンコードします
        let search = encodeURIComponent(searchTerm.value)
        //Gas関数searchWordに検索ワードを渡し、YouTubeAPIの検索結果はonSuccessで処理
        google.script.run.withSuccessHandler(onSuccess).searchWord(search);
        google.script.run.withFailureHandler(onFailure).searchWord(search);
      });

   //onFailure
    function onFailure(error) {
    while (contents.firstChild) {
      contents.removeChild(contents.firstChild);
    }

    const errorMsg = document.createElement('p');
    errorMsg.textContent = `ERROR: ${error.message}`;
    contents.appendChild(errorMsg);
  }
   //onSuccess関数
   function onSuccess(results) {
    while (contents.firstChild) {
      contents.removeChild(contents.firstChild);
    }

  results.forEach(item => {
    const id = item.items[0].id;
    const title = item.items[0].snippet.title;
    const date = item.items[0].snippet.publishedAt;
    const description = item.items[0].snippet.description.slice(0, 100);
    const imgURL = item.items[0].snippet.thumbnails.medium.url;
    const duration = item.items[0].contentDetails.duration.replace('PT', '').replace('S','秒').replace('M', '分').replace('H', '時間');
    const viewCount = item.items[0].statistics.viewCount;
    const likeCount = item.items[0].statistics.likeCount;
    const commentCount =item.items[0].statistics.commentCount;
    const listItem = document.createElement('li');
    listItem.className = "card mb-2";
    listItem.style.width = "18rem";
    const anchor = document.createElement('a');
    anchor.href = 'http://www.youtube.com/watch?v=' + id;
    anchor.target = "_blank";
    const image = document.createElement('img');
    image.src = imgURL;
    image.className = "card-img-top";
    anchor.appendChild(image);
    listItem.appendChild(anchor);
    const cardBody = document.createElement('div');
    cardBody.className = "card-body";
    const dateSpan = document.createElement('span');
    dateSpan.textContent = date.split('T')[0];
    cardBody.appendChild(dateSpan);
    const titleHeading = document.createElement('h5');
    titleHeading.className = "card-title";
    titleHeading.textContent = title;
    cardBody.appendChild(titleHeading);
    const descriptionParagraph = document.createElement('p');
    descriptionParagraph.className = "card-text";
    descriptionParagraph.textContent = description;
    cardBody.appendChild(descriptionParagraph);
    const detailsParagraph = document.createElement('p');
    detailsParagraph.className = "card-text";
    const durationSpan = document.createElement('span');
    durationSpan.textContent = duration;
    detailsParagraph.appendChild(durationSpan);
    detailsParagraph.appendChild(document.createElement('br'));
    const viewCountSpan = document.createElement('span');
    viewCountSpan.textContent = `再生回数:${viewCount}`;
    detailsParagraph.appendChild(viewCountSpan);
    detailsParagraph.appendChild(document.createElement('br'));
    const likeCountSpan = document.createElement('span');
    likeCountSpan.textContent = `いいね:${likeCount}`;
    detailsParagraph.appendChild(likeCountSpan);
    detailsParagraph.appendChild(document.createElement('br'));
    const commentCountSpan = document.createElement('span');
    commentCountSpan.textContent = `コメントの数:${commentCount}`;
    detailsParagraph.appendChild(commentCountSpan);
    detailsParagraph.appendChild(document.createElement('br'));
    cardBody.appendChild(detailsParagraph);
    listItem.appendChild(cardBody);
    contents.appendChild(listItem);
  });
}
  </script>
  </body>
</html>

Videos: list

ビデオの詳細データを取得

動画の長さも表示したかったのでその場合は
Videos: listで各動画の詳細データを取得する必要があります
ついでに、再生された回数・高く評価したユーザーの数・コメントの数も表示します
YouTube.Videos.list('snippet, statistics, contentDetails', {id:result.id.videoId})

詳細情報も表示する場合

function doGet(){
  return HtmlService.createHtmlOutputFromFile('index');
}
function searchWord(keyword) {
  const results = YouTube.Search.list('id', {
    maxResults:30,
    q:decodeURIComponent(keyword),
    //再生回数の多い順
    order:'viewCount'   
  });
  const newResults = results.items.map(result=>{
    if(result.id.videoId){
      return YouTube.Videos.list('snippet, statistics, contentDetails', {id:result.id.videoId})
    }
  })
  //nullは除く
  return newResults.filter(Boolean)
}
<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <!-- Bootstrapの読み込み -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
  </head>
  <body>
     <div class="container my-4">
        <div class="col-auto d-flex justify-content-center" >
         <input type="text" class="form-control w-75" placeholder="検索キーワード">
         <button type="submit" class="btn btn-primary">検索する</button>
        </div>
        <!-- 検索結果を表示 -->
        <ul id="contents" class="d-flex flex-wrap justify-content-around mt-3" style="padding:0"></ul>
     </div>

     <script>
       const searchTerm = document.querySelector('input');
       const btn = document.querySelector('button');
       //クリックしたときの処理
        btn.addEventListener('click', (e)=>{
        e.preventDefault();
        //入力された値を取得してURLエンコードします
        let search = encodeURIComponent(searchTerm.value)
        //Gas関数searchWordに検索ワードを渡し、YouTubeAPIの検索結果はonSuccessで処理
        google.script.run.withSuccessHandler(onSuccess).searchWord(search);
     });  

   //onSuccess関数
      function onSuccess(results) { 
       let data = results.map(result => {
        // console.log(result)
          return {
            id: result.items[0].id,
            title: result.items[0].snippet.title,
            date: result.items[0].snippet.publishedAt,
           //全文表示されるので100文字だけ表示
            description: result.items[0].snippet.description.slice(0, 100),
            img: result.items[0].snippet.thumbnails.medium.url,
            duration:result.items[0].contentDetails.duration.replace('PT', '').replace('S','秒').replace('M', '分').replace('H', '時間'),
            viewCount:result.items[0].statistics.viewCount,
            likeCount:result.items[0].statistics.likeCount,
            commentCount:result.items[0].statistics.commentCount,      
          }
       })
       render(data)
      }

  function render(data) {
      contents.innerHTML = '';
      let html = data.reduce((accu, str) => {
         return accu + `
         <li class="card mb-2" style="width: 18rem;">
         <a href="http://www.youtube.com/watch?v=${str.id}" target="_blank">
         <img src="${str.img}" class="card-img-top"></a>
         <div class="card-body">
         <h5 class="card-title">${str.title}</h5>
         <p class="card-text">${str.description}</p>
         <p class="card-text">${str.date.split('T')[0]}</p>
         <p class="card-text">
         <span>${str.duration}</span></br>
         <span>再生回数:${str.viewCount}</span></br>
         <span>いいね:${str.likeCount}</span></br>
         <span>コメントの数:${str.commentCount}</span></br>
         </p>
         </div></li>
         `
      }, '')
      contents.innerHTML = html
    }
     </script>
  </body>
</html>

「Videos: list」で各動画の詳細データを取得するとキーワードを絞らないとエラーで検索できないことがあり
ごちょごちょしていたら1日の上限を超えてしまいました〜
「videoIdがないもの」が含まれることが原因のようなので、とりあえずは「videoId」があればという条件をつけたりしています

備考:YouTube Data APIを使う場合、1日に使えるクォータの量は10,000のようなのですが(ちなみにsearchは100・videosは1(1回の検索で30件)で計算するみたいなので75回くらいが目安なのかな…?)
太平洋時間午前0時(日本時間では午後4時~5時)にリセットされるようです