最新のYoutube動画要約をDiscordに送信する

## 目次
- [目次](#目次)
- [はじめに](#はじめに)
- [Githubリポジトリ](#githubリポジトリ)
- [使用技術](#使用技術)
- [設計](#設計)
  - [YoutubeAPI 経由で動画情報を取得してくる場合](#youtubeapi-経由で動画情報を取得してくる場合)
  - [RSS 経由で動画を取得してくる場合](#rss-経由で動画を取得してくる場合)
  - [字幕情報を持ってくる](#字幕情報を持ってくる)
- [実際にできある形](#実際にできある形)
  - [構成図](#構成図)
  - [フローチャート](#フローチャート)
- [Youtube API で動画情報を取得してきてDBに保存する](#youtube-api-で動画情報を取得してきてdbに保存する)
  - [DBを作成する](#dbを作成する)
  - [Youtube API から動画情報を取得する](#youtube-api-から動画情報を取得する)
  - [DBに保存する](#dbに保存する)
- [字幕情報を取得し、DBに保存する](#字幕情報を取得しdbに保存する)
- [字幕情報をLLMに渡して要約を生成してもらう](#字幕情報をllmに渡して要約を生成してもらう)
- [Discordに要約を送信する](#discordに要約を送信する)
  - [main.py](#mainpy)
  - [bot クラス](#bot-クラス)
  - [実際に送信されるメッセージ](#実際に送信されるメッセージ)
  - [懸念](#懸念)
    - [要約失敗の例](#要約失敗の例)
- [まとめ](#まとめ)


## はじめに
最近、AIを使った開発をよく目にするため自分も何か作っておきたいと思っていました。  
よく耳にするAIサービスのAPIを使うと費用が嵩んでしまうので、OllamaでローカルにLLMを立てて、Youtubeの動画を要約して、Discordのフォーラムチャンネルに送信するBotを作成してみようと思います。

特定のYoutubeチャンネルを監視して、新しい動画が出たら要約を生成、送信という形でやっていこうと思います。

## Githubリポジトリ
https://github.com/hoshimins/youtube_summary_bot

## 使用技術
- Ollama
- Youtube API
- Discord.py

それぞれの環境構築方法はすでにたくさんあるので各自で調べてみてください。

## 設計
設計というほどがっつりは考えませんが、、どのように特定のチャンネルの動画の要約を実現するのかというと、、、  
① Youtube API or Youtube RSSフィード で動画情報を取得  
② 該当の動画の字幕を取得  
③ LLMに要約してもらう  

の流れでやっていこうと思います。  

それぞれ取得した動画・字幕情報や、生成した要約データはDBに保存するようにもします。  


### YoutubeAPI 経由で動画情報を取得してくる場合
初回に使用します。  
大半のチャンネルはすでに１００本以上動画がありますが、  
RSS では直近の動画しか持ってこれないので網羅することができません。  
ですので、APIを使って全動画の動画を取得します。  


### RSS 経由で動画を取得してくる場合
２回目以降に使用します。  
動画が投稿されたら拾ってきて要約を生成するというようにします。

### 字幕情報を持ってくる
こちらは、Youtube API で字幕情報は持ってくることは可能なのですが、  
OAuth認証が必要となり、サーバー環境でやろうとすると少し手間になってしまいます。  

ですので、別途ライブラリを使用し、OAuthを回避して字幕情報を取得しようと思います。  

## 実際にできある形
### 構成図
```mermaid
graph TD
    subgraph Crontab
        LatestVideo[get_summary_latest]
        AllChannelVideo[all_channel_video]
    end

    subgraph Discord
        DChannel[Discord Forum Channel]
    end

    subgraph YoutubeSummaryFeed
        MainScript[Main Script]
        RSSFeed[RSS Feed]
        YoutubeFetcher[YoutubeFetcher]
        CaptionFetcher[CaptionFetcher]
        SummaryGenerator[SummaryGenerator]
        DatabaseManager[DatabaseManager]
    end

    subgraph External
        YTAPI["YouTube Data API"]
        Ollama["Summary API (Ollama or Local LLM)"]
    end

    subgraph Database
        Postgres["PostgreSQL"]
    end

    %% Crontab flow
    LatestVideo -->|Cron Trigger| MainScript
    AllChannelVideo -->|Cron Trigger| MainScript


    %% MainScript internal flow
    MainScript <--> |最新情報が存在するか確認する|RSSFeed
    MainScript <--> |最新情報が存在する場合は、字幕を取得する|CaptionFetcher
    MainScript <--> |要約を生成する|SummaryGenerator
    MainScript <--> |データベースを管理する|DatabaseManager
    MainScript <--> |YouTube APIを使用して動画情報を取得する|YoutubeFetcher

    RSSFeed -->|取得した情報を保存| DatabaseManager 
    CaptionFetcher -->|取得した字幕を保存| DatabaseManager
    SummaryGenerator -->|生成した要約を保存| DatabaseManager
    YoutubeFetcher -->|取得した動画情報を保存| DatabaseManager

    %% External API calls
    YoutubeFetcher --> YTAPI
    SummaryGenerator --> Ollama

    %% Database interactions
    DatabaseManager --> Postgres

    %% MainScript outputs
    MainScript -->|Cron Trigger| DChannel
```

### フローチャート
<details>
<summary>
最新の動画を取得して要約を生成するフロー
</summary>

```mermaid
graph TD

Cron["Crontab get_summary_latest"] -->|Cron Trigger| YoutubeSummaryFeed["Main"]
YoutubeSummaryFeed --> |DBの最新情報を取得| DB["DatabaseManager get_db_data(channel_id)"]
DB --> |RSSから最新の情報を取得| RSS["fetch_rss_feed get_latest_videos(rss_url)"]
RSS --> |DBとRSSをで差分があるか|compare{"compare_data"}

compare -->|差分あり| NewDataFound["新着動画は◯件と出力"]
NewDataFound --> |DBに新着動画を保存| DBUpdateNewData["DatabaseManager save_db_new_data()"]
DBUpdateNewData --> |字幕情報を保存していないレコードを取得| GetNoCaptionRecord["DatabaseManager get_none_caption_record()"]
GetNoCaptionRecord --> |動画の字幕を取得| CaptionFetcher["get_caption get_caption(video_id)"]
CaptionFetcher --> |字幕をDBに保存| DBUpdateCaption["DatabaseManager save_caption_data(video_id, caption)"]
DBUpdateCaption --> |要約を生成| SummaryGenerator["get_summary get_summary(caption)"]
SummaryGenerator --> |要約をDBに保存| DBUpdateSummary["DatabaseManager save_summary_data(video_id, summary)"]
DBUpdateSummary --> End["終了"]

compare  --> |差分なし| End 
```
</details>  

<details>
<summary>
チャンネルの全動画を取得するフロー
</summary>

```mermaid
graph TD

Cron["Crontab get_all_channel_video"] -->|Cron Trigger| YoutubeSummaryFeed["Main"]
YoutubeSummaryFeed --> |指定ちゃんねるの全動画情報を取得| YoutubeFetcher["YoutubeFetcher get_all_videos(channel_id)"]
YoutubeFetcher --> |取得した情報をDBに保存| SaveDB["DatabaseManager save_db_new_data(new_data, channel_id, channel_name)"]
SaveDB --> End["終了"]
```
</details>


<details>
<summary>
要約をDiscordに投稿するフロー
</summary>

```mermaid
graph TD
Cron["Crontab send_summary_for_discord"] -->|Cron Trigger| DiscordSend["Main"]
DiscordSend --> |要約をDiscordに送信| SendMessage["send_message_to_discord(summary)"]
SendMessage --> End["終了"]
```
</details>


## Youtube API で動画情報を取得してきてDBに保存する

動画件数は結構な量があると思います。  
ローカルLLMに字幕情報を渡すとしても全件見るのは時間がかかると思うので、DBに保存しておいて  
好きなタイミングで要約生成ができるようにしておきましょう

### DBを作成する

下記のようにクエリを作成して、実行します。  
<details>
<summary>
スキーマとテーブル作成
</summary>

```sql
-- スキーマの作成
CREATE SCHEMA IF NOT EXISTS youtube_feed_summary;

-- チャンネル情報保存テーブル
CREATE TABLE IF NOT EXISTS youtube_feed_summary.channel (
  channel_id VARCHAR(255) NOT NULL PRIMARY KEY,
  channel_name VARCHAR(255) NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 動画情報保存テーブル
CREATE TABLE IF NOT EXISTS youtube_feed_summary.video (
  video_id VARCHAR(255) NOT NULL PRIMARY KEY,
  title VARCHAR(255) NOT NULL,
  channel_id VARCHAR(255) NOT NULL,
  published DATE NOT NULL,
  link VARCHAR(255),
  summary_send_flag BOOLEAN DEFAULT FALSE,
  FOREIGN KEY (channel_id) REFERENCES youtube_feed_summary.channel (channel_id) ON DELETE CASCADE
);

-- 動画字幕情報保存テーブル
CREATE TABLE IF NOT EXISTS youtube_feed_summary.captions (
  video_id VARCHAR(255) NOT NULL PRIMARY KEY,
  caption TEXT,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (video_id) REFERENCES youtube_feed_summary.video (video_id) ON DELETE CASCADE
);

-- 要約情報保存テーブル
CREATE TABLE IF NOT EXISTS youtube_feed_summary.summary (
  video_id VARCHAR(255) NOT NULL PRIMARY KEY,
  summary TEXT,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (video_id) REFERENCES youtube_feed_summary.video (video_id) ON DELETE CASCADE
);

```
</details>  

### Youtube API から動画情報を取得する

ではまず、Youtube API から特定チャンネルの動画情報を取得してくるところから始めます。  
Youtube動画にはそれぞれを識別するために `videoId` が設定されています。  
それらをキーとして、字幕情報取得、DB保存をやっていこうと思います。

以下コード↓  

<details>
<summary>
指定したチャンネルから全件動画情報を持ってくるスクリプト
</summary>

```python

from googleapiclient.discovery import build

API_KEY = "～～～～"

youtube = build("youtube", "v3", developerKey=API_KEY)

def get_all_videos(channel_id):
    # チャンネルの全動画を取得
    playlist_id = get_uploads_playlist_id(channel_id)
    all_videos = get_all_videos_from_playlist(playlist_id)

    return all_videos

def get_uploads_playlist_id(channel_id):
    response = youtube.channels().list(
        part="contentDetails",
        id=channel_id
    ).execute()

    return response["items"][0]["contentDetails"]["relatedPlaylists"]["uploads"]

def get_all_videos_from_playlist(playlist_id):
    videos = []
    next_page_token = None

    while True:
        response = youtube.playlistItems().list(
            part="snippet",
            playlistId=playlist_id,
            maxResults=50,
            pageToken=next_page_token
        ).execute()

        for item in response["items"]:
            videos.append({
                "video_id": item["snippet"]["resourceId"]["videoId"],
                "title": item["snippet"]["title"],
                "published": item["snippet"]["publishedAt"],
                "link": f'https://www.youtube.com/watch?v={item["snippet"]["resourceId"]["videoId"]}'
            })

        next_page_token = response.get("nextPageToken")
        if not next_page_token:
            break

    return videos
```
</details>  
  
こんな感じで指定したチェンネルの動画をすべて持ってきます。  
最初は `youtube.channnels()` からそのまま動画情報を持ってこようとしたのですが、数件数が合わず、、、  
調べていく内に、`playlist` から持ってきたら全件持ってくることができるとのことだったのでそちらに修正しました。

### DBに保存する
DB操作関連をまとめたクラスを作っておきます。

<details>
<summary>
DB操作をまとめたクラス
</summary>

```python
import psycopg2


class DBManager():
  def __init__(self):
    self.connection = None
    self.cursor = None
    # 接続情報
    self.DB_URL = "～～～"
    self._connect()

  def _connect(self):
    self.connection = psycopg2.connect(self.DB_URL)
    self.cursor =  self.connection.cursor()

  def _close(self):
    if self.cursor:
      self.cursor.close()
    if self.connection:
      self.connection.close()

  def save_new_data(self, data, channel_id, channel_name):
    try:
      # データをvideoテーブルに挿入
      for video in data:
          # video テーブル
          self.cursor.execute(
              "INSERT INTO youtube_feed_summary.video (video_id, title, channel_id, published, link) VALUES (%s, %s, %s, %s, %s)",
              (video['video_id'], video['title'], channel_id,
                video['published'], video['link'])
          )

          # captions テーブル
          self.cursor.execute(
              "INSERT INTO youtube_feed_summary.captions (video_id) VALUES (%s)",
              (video['video_id'],)
          )

          # summary テーブル
          self.cursor.execute(
              "INSERT INTO youtube_feed_summary.summary (video_id) VALUES (%s)",
              (video['video_id'],)
          )

      self.connection.commit()
    except Exception as e:
        print("Error:", e)
        self.connection.rollback()
        print("Rollback executed")
    finally:
        self.cursor.close()
        self.connection.close()
```

</details>  

## 字幕情報を取得し、DBに保存する
字幕情報を取得します。  
Youtube API を使用しても取得可能なのですが、先述したようにOAuthを通さないといけないので面倒です。  
ドキュメント：https://developers.google.com/youtube/v3/guides/implementation/captions?hl=ja  
ですので別途ライブラリを使用します。それが `youtube-transcript-api` です。  
Githubリポジトリ：https://github.com/jdepoix/youtube-transcript-api  
こちらを使用すれば、OAuthを通さず、`videoId` を渡すだけで字幕情報を取得することができます。  

以下コード↓  
<details>
<summary>
動画IDから字幕情報を持ってくるスクリプト
</summary>

```python
from youtube_transcript_api import YouTubeTranscriptApi

def get_caption(video_id):
    # 対象のYouTube動画のIDを指定

    # 日本語の字幕を取得
    transcript = YouTubeTranscriptApi.get_transcript(video_id, languages=['ja'])

    # 字幕テキストを結合して一つの文字列にする
    transcript_text = ' '.join([item['text'] for item in transcript])

    return transcript_text
```


</details>


<details>
<summary>
取得した字幕をDBに保存する
</summary>

こちらは、先ほど書いた、`DBManager` クラスに追加します。


```python
def save_caption_data(video_id, caption):
    try:
        conn = psycopg2.connect(DB_URL)
        cur = conn.cursor()

        # 字幕データを保存
        cur.execute(
            "UPDATE youtube_feed_summary.captions SET caption = %s, updated_at = NOW() WHERE video_id = %s",
            (caption, video_id)
        )

        conn.commit()
    except Exception as e:
        print("Error:", e)
        conn.rollback()
    finally:
        cur.close()
        conn.close()
```

</details>

## 字幕情報をLLMに渡して要約を生成してもらう
Ollama を用いて要約を生成してもおうと思います。  
環境構築等はすでに多くの記事が回っていると思いますのでそちらを参考にしていただければと思います。  

モデルによっては日本語を全然返してくれないモデルもアルので日本語に対応しているモデルを探しました。  
使用する人によって変わると思いますが、今回は `tanuki-dpo-v1.0` を使用しました。  
URL:https://ollama.com/7shi/tanuki-dpo-v1.0  

>[!NOTE]
> こちらは作成したあとに気づいたのですが、上記のURLにはこのように記載されています。  
> ！！！！注意！！！！  
> 本モデルは、性能低下のため非推奨となっているGGUF版を使用しています。性能低下を踏まえた上で、体験版として提供するものです。  
> NFKC正規化がサポートされていないため、全角英数字や半角カナが認識できません。入力しないようにご注意ください。  
> とあるため他のモデルを使用したほうがいいかもしれません。

こちらで要約を生成していきます。  
今回は `Modelfile` を作成して、Youtubeの動画要約に特化させるように事前にプロンプトを渡しておこうと思います。  

<details>
<summary>
Modelfile
</summary>

```modelfile
FROM 7shi/tanuki-dpo-v1.0:latest

SYSTEM """
あなたは優秀な日本語の長文要約エキスパートです。
以下に示す「YouTube動画の書き起こし全文」を読み、その内容を「約5,000文字程度の日本語」で丁寧に要約してください。

【必須条件】
- 書き起こしの中で話者が特に強調している主張やメッセージを中心に要約してください。
- 細かすぎるエピソードや繰り返される表現は省略または簡潔化し、要点が明確に伝わるようにしてください。
- 元の文脈やニュアンスを損なわないよう注意し、話者の意図や感情も適切に表現してください。

【出力構成】
# 要約内容
要約した内容をタイトルとして記載します。

## はじめに
- 話者が視聴者に伝えたいこと、話し始めた動機、背景を1000文字程度で整理します。

## 主なメッセージと重要なアドバイス
- 話者が具体的に語っている内容を整理し、項目ごとに適切な小見出しを付けてください。
- ポイントごとに簡潔かつ明快に5000文字程度に要約してください。

## おわりに
- 話者が視聴者に最後に伝えたいことや、励ましのメッセージを1000文字程度でまとめます。
"""

PARAMETER temperature 0.2
PARAMETER top_p 0.7
PARAMETER num_ctx 8192
```

</details>


```bash
ollama create youtube-summary-model -f Modelfile
```

でモデルをビルドします。  
これでカスタムモデルを作成して、 ollama に登録することができます。 


## Discordに要約を送信する
公開は普通のチャンネルではなく、フォーラムチャンネルに送信しようと思います。  
普通のチャンネルだと流れてしまいますが、フォーラムに送ることで全部がスレッドになり、  
要約したものを見返しやすいと思ったからです。  

Discord.py を使用して、要約を送信していきます。  
今回は特にコマンドを指定するとかはなく、cron で定期的に起動させて、  
要約を送信する形にしようと思います。

<details>
<summary>
要約をDiscordに送信するスクリプト
</summary>

### main.py
```python
import discord
from classes import youtube_summary_bot
from utils.config import load_env


def main():
    load_env()
    intents = discord.Intents.default()
    intents.message_content = True
    bot = youtube_summary_bot.YoutubeSummaryBot(intents=intents)
    bot.run(bot.token)


if __name__ == "__main__":
    main()
```

### bot クラス
```python
import os
import discord
from classes.database_manager import DatabaseManager


class YoutubeSummaryBot(discord.Client):
    def __init__(self, intents: discord.Intents):
        super().__init__(intents=intents)
        self.token = os.getenv("DISCORD_TOKEN")
        self.forum_channel_id = os.getenv("FORUM_CHANNEL_ID")
        if self.token is None:
            raise ValueError(
                "DISCORD_TOKEN is not set in the environment variables.")

    async def on_ready(self):
        print(f'Logged in as {self.user} (ID: {self.user.id})')
        print('------')
        await self.get_summary()

    async def on_message(self, message):
        if message.author == self.user:
            return

    async def get_summary(self):
        db_manager = DatabaseManager()
        summary_data = db_manager.get_not_send_summaries_data()
        if not summary_data:
            return
        await self._send_summary_message_to_forum(summary_data)
        db_manager.update_summary_send_flag(summary_data[0][3])
        db_manager._close()

    async def _send_summary_message_to_forum(self, data):
        forum_channel = self.get_channel(self.forum_channel_id)

        if isinstance(forum_channel, discord.ForumChannel):
            title = f"【要約】{data[0][0]}"
            content = f"{data[0][1]} \n\n{data[0][2]}"

            await forum_channel.create_thread(
                name=title,
                content=content,
                auto_archive_duration=10080
            )

        else:
            print("指定されたチャンネルはフォーラムチャンネルではありません。")
```

</details>

### 実際に送信されるメッセージ
今回は、要約がイラストの添削や、リスナーからの相談を受け付けているさいとうなおき先生の動画をお借りして試してみます。  
今回要約する動画がこちら↓  
URL：https://www.youtube.com/watch?v=1xp-o1jksSY  


<details>
<summary>
そして要約したものがこちら↓
</summary>

```md
【要約内容】
斎藤直樹さんは、YouTubeチャンネル「斎藤直樹のイラスト講座」でリスナーから寄せられた質問に丁寧に回答しています。今回の質問は、40代のAさんがイラストレーターやポスター制作を目指しているが、時間がないと感じているというものでした。斎藤さんはまず、Aさんの状況に対して共感し、励ましのメッセージを送ります。

斎藤さんは、Aさんが絵を描くことに情熱を持っていることを評価し、無理に完璧なスタイルを追求するのではなく、まずは自分が楽しめる範囲で始めることが重要だとアドバイスします。具体的には、1日1時間程度の制作時間を確保し、その中で様々なジャンルや技法に挑戦することを勧めます。また、SNSやオンラインコミュニティを活用して他のクリエイターと交流し、フィードバックを得ることも有益だと述べています。

さらに、斎藤さんはAさんが過去に経験した挫折や困難も理解し、それを乗り越えるための具体的なステップを提案します。例えば、簡単なテストプロジェクトを通じてスキルを磨きつつ、徐々に複雑な作品に挑戦していくことが効果的だと説明します。また、Aさんが描くキャラクターや背景に対するフィードバックを積極的に求めることで、自分のスタイルを確立しやすくなるとアドバイスしています。

最後に、斎藤さんはAさんが諦めずに努力を続けることの大切さを強調し、応援していることを伝えます。このメッセージは、Aさんにとって大きな励みとなるでしょう。

【出力構成】
# 要約内容
斎藤直樹さんはYouTubeチャンネル「斎藤直樹のイラスト講座」でリスナーから寄せられた質問に丁寧に回答しています。今回の質問は、40代のAさんがイラストレーターやポスター制作を目指しているが、時間がないと感じているというものでした。斎藤さんはまずAさんの状況に対して共感し、励ましのメッセージを送ります。

斎藤さんは、Aさんが絵を描くことに情熱を持っていることを評価し、無理に完璧なスタイルを追求するのではなく、まずは自分が楽しめる範囲で始めることが重要だとアドバイスします。具体的には、1日1時間程度の制作時間を確保し、その中で様々なジャンルや技法に挑戦することを勧めます。また、SNSやオンラインコミュニティを活用して他のクリエイターと交流し、フィードバックを得ることも有益だと述べています。

さらに、斎藤さんはAさんが過去に経験した挫折や困難も理解し、それを乗り越えるための具体的なステップを提案します。例えば、簡単なテストプロジェクトを通じてスキルを磨きつつ、徐々に複雑な作品に挑戦していくことが効果的だと説明します。また、Aさんが描くキャラクターや背景に対するフィードバックを積極的に求めることで、自分のスタイルを確立しやすくなるとアドバイスしています。

最後に、斎藤さんはAさんが諦めずに努力を続けることの大切さを強調し、応援していることを伝えます。このメッセージは、Aさんにとって大きな励みとなるでしょう。

# 終わりに
斎藤直樹さんのアドバイスと励ましの言葉は、Aさんにとって非常に有益であり、イラストレーターとしての道を進むための具体的な指針となるでしょう。時間がない中でも、楽しみながら学び続けることで、必ず成長できるという信念が伝わってきます。 

https://www.youtube.com/watch?v=1xp-o1jksSY
```
</details>

いい感じに要約してくれていますね！

### 懸念
やはり、要約部分をAIというブラックボックスに投げているので、どうしても意図していない要約が返されることもあります。  
例えば以下のような要約です。  

#### 要約失敗の例

```md
このYoutube動画は、Aさんのイラストについての質問に対して回答しています
特に強調されているポイントやアドバイスを以下のように要約します。
（５０００文字）
```


５０００文字に要約してと送ったら、５０００文字と返してくれました。笑  
ゲームをやっている裏で要約を動かしたらこのようになったのでおそらくメモリ不足だったと思いますが、  
安定的に要約を生成するのであれば、APIを素直に使用したほうがいいかもしれませんね。  

## まとめ
今回は、Youtubeの動画を要約してDiscordに送信するBotを作成してみました。
Ollamaを使用することで、ローカルでLLMを立てることができ、APIのコストを気にせずに要約を生成することができました。
ただし、安定性や精度を求めるのであれば、APIを使用したほうがいいかもしれませんね。