余計な動画を見ないためのハック（YoutubeAPI、NotionAPI、GAS）

# 余計な動画を見ないためのハック<br>（YoutubeAPI、NotionAPI、GAS）</br>

## 動機

Youtubeを見て時間を浪費する毎日、やめたいなと思ってもやめられない。
アプリを消す、でもウェブで見てしまう。それでまた浪費する。人生を無駄にしていると感じながらも。

というわけで、Youtubeの~~最凶~~アルゴリズムに囚われない方法を考案しました。一読お願いします。忌憚ない意見もお願いします。

## １．全体観

Youtubeの登録チャンネル動画のみを見たいので、YoutubeDataAPIで登録チャンネルの新着動画を毎日取得するという方向性にしました。
実行サーバーはGAS(GoogleAppScript)、可視的なデータベースとしてNotionを選択しました。

全コードはこのリポジトリにあります。
https://github.com/sirayu2525/YoutubeLimit

以下、実際の完成例


## ２．GASのコード

<details open><summary><b>↓全文です↓</b></summary>

```gas:youtubelimit.gs
/**
 * 毎日21時に main() を実行するトリガーを作成する
 */
function createDailyTrigger() {
  const allTriggers = ScriptApp.getProjectTriggers();
  for (const trig of allTriggers) {
    if (trig.getHandlerFunction() === 'main') {
      ScriptApp.deleteTrigger(trig);
    }
  }

  // 新たにトリガーを作成
  ScriptApp.newTrigger('main')         // 実行したい関数名
    .timeBased()
    .everyDays(1)                      // 毎日
    .atHour(21)                        // 21時
    .create();
}

/**
 * スクリプトプロパティからNotion APIトークンとデータベースIDを取得
 */
function getNotionCredentials() {
  const scriptProperties = PropertiesService.getScriptProperties();
  return {
    notionApiToken: scriptProperties.getProperty('NOTION_API_TOKEN'),
    notionDatabaseId: scriptProperties.getProperty('NOTION_DATABASE_ID')
  };
}

/**
 * メイン関数: YouTube の新着動画を取得し、その日付ページの子ブロックとして追加
 *              + 除外された動画のリストも同ページに追記。
 */
function main() {
  const { notionApiToken, notionDatabaseId } = getNotionCredentials();

  // 1. 取得対象チャンネルのリスト (必要に応じて増やす)
  const channels = ["UCXXXXXXX", "UCYYYYYY"];

  // 2. 直近24時間の新着動画を全部まとめる
  //    今回は「includedVideos」「excludedVideos」の2つを返す構造にした
  let allIncluded = [];
  let allExcluded = [];
  channels.forEach(channelId => {
    const { included, excluded } = getNewVideos(channelId);
    allIncluded = allIncluded.concat(included);
    allExcluded = allExcluded.concat(excluded);
  });

  if (allIncluded.length === 0 && allExcluded.length === 0) {
    Logger.log("本日は新着動画がありませんでした。");
    return;
  }

  // 3. 日付（例: "2025-01-31"）を取得し、その名前のページを Notion DB で探す or 作成
  const todayStr = Utilities.formatDate(new Date(), "Asia/Tokyo", "yyyy-MM-dd");
  const dayPageId = findOrCreateDayPage(todayStr, notionApiToken, notionDatabaseId);
  if (!dayPageId) {
    Logger.log("日付ページの取得/作成に失敗したため、終了します。");
    return;
  }

  // 4. 含まれる動画(通常＆プレミア)を埋め込みブロックとして追加
  if (allIncluded.length > 0) {
    const blocks = allIncluded.map(video => {
      const embedUrl = `https://www.youtube.com/embed/${video.videoId}?rel=0&modestbranding=1`;
      return {
        "object": "block",
        "type": "embed",
        "embed": {
          "url": embedUrl
        }
      };
    });
    appendBlocksToPage(dayPageId, blocks, notionApiToken);
  }

  // 5. 除外動画を「除外リスト」としてまとめて追加 (タイトルのみ)
  if (allExcluded.length > 0) {
    // まず「除外リスト」という見出しブロックを1つ入れる
    const headingBlock = {
      "object": "block",
      "type": "heading_2",
      "heading_2": {
        "rich_text": [
          { "text": { "content": "除外リスト" } }
        ]
      }
    };
    // 除外されたタイトルを箇条書きブロックにする例
    const excludedBlocks = allExcluded.map(video => {
      return {
        "object": "block",
        "type": "bulleted_list_item",
        "bulleted_list_item": {
          "rich_text": [
            { "text": { "content": video.title } }
          ]
        }
      };
    });

    // heading + 箇条書きブロック を一括追加
    appendBlocksToPage(dayPageId, [headingBlock, ...excludedBlocks], notionApiToken);
  }

    // 処理が完了したタイミングでメール通知
  MailApp.sendEmail({
    to: "メアド@gmail.com",
    subject: "スクリプト実行通知",
    body: "本日のスクリプトが無事に実行されました。\n" + 
          "実行日時: " + new Date().toLocaleString("ja-JP")
  });
}

/**
 * YouTube チャンネルから新着動画を取得
 * @param {string} channelId
 * @returns {Object} - { included: [{videoId, title}], excluded: [{videoId, title}] }
 */
function getNewVideos(channelId) {
  const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
  Logger.log(`24時間前(UTC): ${oneDayAgo}`);

  const included = [];
  const excluded = [];

  try {
    // Search.list で24時間以内の動画を取得
    const searchResponse = YouTube.Search.list('snippet', {
      channelId: channelId,
      maxResults: 50,
      order: 'date',
      publishedAfter: oneDayAgo,
      type: 'video'
    });

    const items = searchResponse.items || [];
    if (!items.length) {
      Logger.log(`チャンネルID: ${channelId} に新着動画はありません。`);
      return { included, excluded };
    }

    // 動画IDリスト
    const videoIds = items.map(item => item.id.videoId);

    // 詳細情報 (snippet, contentDetails, liveStreamingDetails)
    const videosResponse = YouTube.Videos.list('snippet,contentDetails,liveStreamingDetails', {
      id: videoIds.join(',')
    });

    const videosItems = videosResponse.items || [];
    if (!videosItems.length) {
      Logger.log('動画の詳細情報が取得できませんでした。');
      return { included, excluded };
    }

    // 「プレミア or ライブで終了後 (actualEndTimeあり)」の場合は、description に "vocal" "ボーカル" が含まれているかチェック
    // 通常アップロード (liveStreamingDetails が無い) は無条件で included に追加
    for (const videoItem of videosItems) {
      const snippet = videoItem.snippet;
      const description = (snippet.description || "").toLowerCase(); // 小文字化
      const title = snippet.title;
      const isLiveType = !!videoItem.liveStreamingDetails; // true/false

      if (!isLiveType) {
        // 通常アップロード → そのまま included
        Logger.log(`通常アップロード動画 -> videoId: ${videoItem.id}, title: ${title}`);
        included.push({ videoId: videoItem.id, title: title });
      } else {
        // liveStreamingDetails がある → 何らかの配信 or プレミア
        const actualEnd = videoItem.liveStreamingDetails.actualEndTime;
        if (!actualEnd) {
          // 現在配信中 or 予約中 → 除外
          Logger.log(`配信中or予約中 -> 除外: ${videoItem.id}, title: ${title}`);
          excluded.push({ videoId: videoItem.id, title: title });
        } else {
          // 配信終了（アーカイブ状態）
          // 概要欄にキーワードの文字が含まれているか
          const keywords = ["vocal", "ボーカル"]
          if (keywords.some(word => description.includes(word))) {
            Logger.log(`ライブ/プレミア終了 + キーワードあり -> included: ${videoItem.id}, title: ${title}`);
            included.push({ videoId: videoItem.id, title: title });
          } else {
            Logger.log(`ライブ/プレミア終了 + キーワードなし -> 除外: ${videoItem.id}, title: ${title}`);
            excluded.push({ videoId: videoItem.id, title: title });
          }
        }
      }
    }

    return { included, excluded };
  } catch (e) {
    Logger.log(`YouTube API呼び出し中にエラーが発生しました（チャンネルID: ${channelId}）: ${e}`);
    return { included, excluded };
  }
}

/**
 * 指定した "Name" プロパティを持つページを Notion DB で探し、存在しなければ作成
 * この際、「日付」プロパティにも dayTitle をセットする例
 * @param {string} dayTitle - 例 "2025-01-31"
 * @param {string} notionApiToken
 * @param {string} databaseId
 * @return {string|null} pageId
 */
function findOrCreateDayPage(dayTitle, notionApiToken, databaseId) {
  // 1. DB内検索
  const queryUrl = `https://api.notion.com/v1/databases/${databaseId}/query`;
  const queryPayload = {
    "filter": {
      "property": "Name",
      "title": {
        "equals": dayTitle
      }
    }
  };
  const queryOptions = {
    "method": "post",
    "headers": {
      "Authorization": "Bearer " + notionApiToken,
      "Content-Type": "application/json",
      "Notion-Version": "2022-06-28"
    },
    "payload": JSON.stringify(queryPayload)
  };

  try {
    const queryResponse = UrlFetchApp.fetch(queryUrl, queryOptions);
    if (queryResponse.getResponseCode() === 200) {
      const data = JSON.parse(queryResponse.getContentText());
      if (data.results && data.results.length > 0) {
        const pageId = data.results[0].id;
        Logger.log(`既存の日付ページが見つかりました: ${dayTitle}, pageId = ${pageId}`);
        return pageId;
      }
    } else {
      Logger.log("Notion DB クエリに失敗: " + queryResponse.getContentText());
    }
  } catch (err) {
    Logger.log("Notion DB クエリ中にエラー: " + err);
  }

  // 2. 見つからなかった場合 → 新規作成
  Logger.log(`日付ページが見つからないため、新規作成します: ${dayTitle}`);
  const createUrl = "https://api.notion.com/v1/pages";

  // 「日付」プロパティを追加したい場合の例
  // Notion データベースで "日付" というDate型プロパティが存在する前提
  // dayTitle が "2025-01-31" などの日付文字列であれば、そのまま start に入れられる
  const createPayload = {
    "parent": { "database_id": databaseId },
    "properties": {
      "Name": {
        "title": [
          { "text": { "content": dayTitle } }
        ]
      },
      "日付": {
        "date": {
          "start": dayTitle
        }
      }
    }
  };

  const createOptions = {
    "method": "post",
    "headers": {
      "Authorization": "Bearer " + notionApiToken,
      "Content-Type": "application/json",
      "Notion-Version": "2022-06-28"
    },
    "payload": JSON.stringify(createPayload)
  };

  try {
    const createResponse = UrlFetchApp.fetch(createUrl, createOptions);
    if (createResponse.getResponseCode() === 200 || createResponse.getResponseCode() === 201) {
      const createdData = JSON.parse(createResponse.getContentText());
      const newPageId = createdData.id;
      Logger.log(`日付ページを作成しました: ${dayTitle}, pageId = ${newPageId}`);
      return newPageId;
    } else {
      Logger.log(`日付ページ作成に失敗: ${createResponse.getContentText()}`);
      return null;
    }
  } catch (error) {
    Logger.log(`日付ページ作成中にエラー: ${error}`);
    return null;
  }
}

/**
 * 指定した pageId の末尾に、子ブロックをまとめて追加
 * @param {string} pageId
 * @param {Array} blocks - Notion API 形式のブロック配列
 * @param {string} notionApiToken
 */
function appendBlocksToPage(pageId, blocks, notionApiToken) {
  const url = `https://api.notion.com/v1/blocks/${pageId}/children`;
  const payload = { "children": blocks };
  const options = {
    "method": "patch",  // blocks/{block_id}/children への追加は PATCH
    "headers": {
      "Authorization": "Bearer " + notionApiToken,
      "Content-Type": "application/json",
      "Notion-Version": "2022-06-28"
    },
    "payload": JSON.stringify(payload)
  };

  try {
    const res = UrlFetchApp.fetch(url, options);
    if (res.getResponseCode() === 200) {
      Logger.log(`子ブロックの追加に成功しました。pageId: ${pageId}`);
    } else {
      Logger.log(`子ブロックの追加に失敗: ${res.getContentText()}`);
    }
  } catch (err) {
    Logger.log(`子ブロックの追加中にエラー: ${err}`);
  }
}

```
</details>

注意点１：チャンネルIDの部分は自身の登録している人のチャンネルIDを複数入力してください。
（後でチャンネル登録しているすべてのチャンネルからIDを出力するコードを紹介します）

注意点２：生配信のアーカイブは除外して除外リストに入れてます。その影響で、プレミア公開された動画は自動的に除外リストに入ってしまうと思います。ただし、プレミア公開されてても「歌ってみた動画」などは除外されないです。

<details open><summary><b>↓チャンネル登録しているすべてのチャンネルからチャンネルIDを取得するコード↓</b></summary>

```javascript:Subscription.gs
/**
 * ユーザーが現在登録しているチャンネルのチャンネルID一覧を取得する
 * @return {string[]} channelIds
 */
function getMySubscriptions() {
  // 取得したチャンネルIDを格納する配列
  const channelIds = [];

  // ページネーション対応のため、nextPageTokenを追跡
  let nextPageToken = '';

  // nextPageToken が無くなるまで繰り返し取得
  do {
    // Subscriptions.list() で 'mine: true' を指定すると
    // 認証ユーザー本人の登録チャンネル情報を取得できます。
    const response = YouTube.Subscriptions.list(
      'snippet',
      {
        mine: true,         // 自分自身のアカウントのチャンネル登録を取得
        maxResults: 100,     // 1回の呼び出しで取得する上限 (最大100)
        pageToken: nextPageToken
      }
    );

    // 取得結果がない場合は終了
    if (!response || !response.items) {
      break;
    }

    // 取得したアイテムから、チャンネルIDを取り出して配列に追加
    response.items.forEach(item => {
      // item.snippet.resourceId.channelId に登録チャンネルのIDが入っている
      if (item.snippet && item.snippet.resourceId) {
        channelIds.push(item.snippet.resourceId.channelId);
      }
    });

    // 次ページが存在する場合は nextPageToken をセット
    nextPageToken = response.nextPageToken;

  } while (nextPageToken);

  // ログに出力して確認
  Logger.log('Subscribed Channel IDs: ' + JSON.stringify(channelIds));
  return channelIds;
}
```


</details>



## ３．環境構築から実行まで

### 3-1. Notionの設定方法

以下の記事を参考にしました。

https://note.com/kohaku935/n/n1ee543bb2cd0

### 3-2. GASの設定方法
https://script.google.com/home
これにアクセスするとおそらく以下のような画面になると思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3853935/a0e216db-521b-e716-d01d-24f596e922ae.png)

「新しいプロジェクト」をクリックすると
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3853935/177b4ec5-5c58-3256-b6d5-b2155a87fefa.png)

こんな感じになります。
そして先ほどのコードを丸ごと貼り付けます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3853935/2e4ba3f9-9355-adea-2fe8-863d7093db90.png)

実行の前にいくつか設定を行います。

まずはスクリプトプロパティの設定です。APIキーはコードにベタ貼りしてもいいのですが、安全性のため一応この機能を使います。


「設定画面（左の歯車）を選択」→「（一番下にある）スクリプトプロパティを編集」→「先ほどの”Notion_API_TOKEN”と”NOTION_DATABASE_ID”を入力して保存」
これでNotionとの接続ができるようになります。

次に、YoutubeDataAPIとの連携です。
これにはGASの拡張機能を使います。
左の「サービス」をクリックすると以下の画面が出てくるので、「YoutubeDataAPI」を選択して追加してください。できたら、左に「Youtube」の文字が出てくるはずです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3853935/dc876fe5-3b81-8105-0481-c09e3e794bf7.png)

これでやっと実行に移れます！
実行方法はまず任意の関数を選択し（ここではmain関数）、「実行」するだけです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3853935/18d2ed1f-3f05-2b16-5687-2a30e02cfeea.png)

おそらく最初は権限が必要となりますが、すべての警告を無視して許可すると毎日21時にこのコードがGASによって実行されます。

必要があれば、この前にgetMySubscriptions関数を実行してチャンネルＩＤリストを取得してみてください。（同じファイルに貼り付けてgetMySubscriptions関数を選択してもいいし、別のファイルを作ってもいい）



## ３．改善案

今のところメール通知では実行できたかどうかだけで正常に動作したかも通知できるようにしたい。
LineやDiscordと連携したい。

## ４．結び

ここまで読んでいただきありがとうございました。
よければこれを活用して生活を充実させていただくことを切に願います。

感想や改善案、質問などございましたら気軽に投稿ください。