ミニツール開発 #1 (YouTube動画情報＆文字起こし取得ツール)

# 「ミニツール開発」

日常の用事を解決するための小規模なスクリプト(or デスクトップアプリ)の開発メモです。

# YouTube動画情報＆文字起こし取得ツール

![youtube_moji_app_2024-10-23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/294824/95bfe1e9-e040-8e96-d67f-58746a5b5df5.png)


## 背景

YouTubeでは文字数の多い動画を視聴することが多いため、度々レジュメが欲しくなる。
そのために、ClaudeでYouTubeの文字起こしを貼り付けると、その内容を整理したMarkdownファイルを出力するProjectを作成して使用している。

従来の方法として、完全手動でmdファイルを出力させるまでの手順は以下の通り。

1. ブラウザ(YouTube)：要約させたい動画URLへジャンプ
2. ブラウザ(YouTube)：動画タイトルからチャンネル名までを選択してコピー
3. ブラウザ(Claude)：Projectのプロンプトフォームに貼り付け
4. ブラウザ(YouTube)：「共有」ボタンを押下
5. ブラウザ(YouTube)：「コピー」ボタンを押下して動画URLをコピー
6. ブラウザ(Claude)：Projectのプロンプトフォームに貼り付け
    - (タイトル、チャンネル名、URLは適宜区切る。これらはClaudeのスレッド上に残す動画情報のメモ)
7. ブラウザ(YouTube)：概要欄をクリックして展開する
8. ブラウザ(YouTube)：「文字起こしを表示」ボタンを押下
9. ブラウザ(YouTube)：画面右側に現れる文字起こしフォームの本文枠外上部から枠外下部までを選択（文字起こし本文を全選択）し、コピー
10. ブラウザ(Claude)：Projectのプロンプトフォームに貼り付け
11. ブラウザ(Claude)：Enterキー押下

大して手間では無いが、手数は多い。ページの行き来が多いとストレスではある。
次に完成したツールを使用した場合の半自動の手順

1. ブラウザ(YouTube)：要約させたい動画URLへジャンプ
2. ブラウザ(YouTube)：「共有」ボタンを押下
3. ブラウザ(YouTube)：「コピー」ボタンを押下して動画URLをコピー
4. ミニツールUI：「ペースト＆取得」ボタンを押下
5. ミニツールUI：「動画情報をクリップボードにコピー」ボタンを押下
    - (タイトル、チャンネル名、URLがすでに区切られている)
6. ブラウザ(Claude)：Projectのプロンプトフォームに貼り付け
7. ミニツールUI：「文字起こしをクリップボードにコピー」ボタンを押下
8. ブラウザ(Claude)：Projectのプロンプトフォームに貼り付け
9. ブラウザ(Claude)：Enterキー押下

追記：
　コピー＆ペーストが２段階になっている理由はClaudeの入力フォームは一度に多量の文字列をペーストすると添付ファイルとして扱われてしまうため、動画の基本情報はUIの吹き出し内でちゃんと表示したかったから。
　また、ClaudeのAPI経由で出力しない理由は、ClaudeのWebアプリ上でProjectのスレッド内に蓄積したいため。

## 評価
手順の数自体はあまり減っていないが、完全手動ではYouTubeページ上の複数の箇所を操作（クリックやドラッグ、Ctrl+C）する必要があったのに対して、ミニツール上での操作行程はボタンのクリックのみなので、操作の複雑性とマウスの移動行程を削減できたので体感ストレスが減った。
また、操作が単調になったため所要時間も短縮された。


PyAutoGUIを使用すれば、URLコピー後の行程を全て自動化できるが、各ウインドウの配置は気分次第な都合上、画像の走査範囲を4Kモニタ2枚分全体とする必要があり、ウインドウやアプリの配置によっては走査時間が長くなってしまうかもしれないので、今はそこまではしない。

## 開発フロー

1. １つのスクリプト上で実現したい機能＆仕様を書き出す
2.  Claude のpython開発専用のProjectでチャットに投入する
    - 軽く目視チェック or 実行でおよその動作が実現できれば次へ
    - どう見ても要件とかけ離れたものであればチャットで修正要求
3. 出力されたコードを目視確認しながらレイアウトや例外処理、その他で気になるところを最低限手動で編集して Ver. 1.0 を完成する

# スクリプト全文

（コードリーディングは別記事）
OS: Windows 10
language: Python 3.11.5
```py
# -*- coding: utf-8 -*-
# アプリ名: 0. YouTube動画情報＆文字起こし取得ツール

import sys
import traceback
import tkinter as tk
from tkinter import messagebox
import csv
import socket

import yt_dlp
from youtube_transcript_api import YouTubeTranscriptApi

# 定数の定義
WINDOW_TITLE = "YouTube 文字起こしアプリ"
WINDOW_SIZE = "350x350"
URL_LABEL_TEXT = "YouTube URL:"
FETCH_BUTTON_TEXT = "取得"
PASTE_FETCH_BUTTON_TEXT = "ペースト＆取得"
INFO_LABEL_TEXT = "動画情報:"
TRANSCRIPT_LABEL_TEXT = "文字起こし:"
COPY_INFO_BUTTON_TEXT = "動画情報をクリップボードにコピー"
COPY_TRANSCRIPT_BUTTON_TEXT = "文字起こしをクリップボードにコピー"

# 定数としてウィンドウ位置情報を保存するファイル名を設定
POSITION_FILE = 'app_video_info_and_transcript.csv'

# 定数としてホスト名を取得
HOSTNAME = socket.gethostname()


def save_position(root):
    """
    ウィンドウの位置のみをCSVファイルに保存する。異なるホストの情報も保持。
    """
    print("ウィンドウ位置を保存中...")
    # root.geometry()から位置情報のみを取り出す
    position_info = root.geometry().split('+')[1:]
    position_str = '+' + '+'.join(position_info)
    position_data = [HOSTNAME, position_str]
    existing_data = []
    try:
        # 既存のデータを読み込んで、現在のホスト以外の情報を保持
        with open(POSITION_FILE, newline='', encoding="utf_8_sig") as csvfile:
            reader = csv.reader(csvfile)
            existing_data = [row for row in reader if row[0] != HOSTNAME]
    except FileNotFoundError:
        print("ファイルが存在しないため、新規作成します。")
    
    # 現在のホストの情報を含む全データを書き込む
    existing_data.append(position_data)
    with open(POSITION_FILE, 'w', newline='', encoding="utf_8_sig") as csvfile:
        writer = csv.writer(csvfile)
        writer.writerows(existing_data)
    print("保存完了")


def restore_position(root):
    """
    CSVファイルからウィンドウの位置のみを復元する。
    """
    print("ウィンドウ位置を復元中...")
    try:
        with open(POSITION_FILE, newline='', encoding="utf_8_sig") as csvfile:
            reader = csv.reader(csvfile)
            for row in reader:
                if row[0] == HOSTNAME:
                    position_str = row[1].strip()  # 余計な空白がないか確認
                    if position_str.startswith('+'):
                        position_str = position_str[1:]  # 先頭の余分な '+' を取り除く
                    print(f"復元データ: {position_str}")
                    # サイズ情報なしで位置情報のみを設定
                    root.geometry('+' + position_str)
                    break
    except FileNotFoundError:
        print("位置情報ファイルが見つかりません。")


def get_exception_trace():
    """例外のトレースバックを取得"""
    t, v, tb = sys.exc_info()
    trace = traceback.format_exception(t, v, tb)
    return trace


class YouTubeApp:
    def __init__(self, master):
        self.master = master
        master.title(WINDOW_TITLE)
        master.geometry(WINDOW_SIZE)

        # URLの入力フィールド
        self.url_label = tk.Label(master, text=URL_LABEL_TEXT)
        self.url_label.pack()
        self.url_entry = tk.Entry(master, width=45)
        self.url_entry.pack()

        # ボタンフレーム
        button_frame = tk.Frame(master)
        button_frame.pack()

        # 取得ボタン
        self.fetch_button = tk.Button(button_frame, text=FETCH_BUTTON_TEXT, command=self.fetch_info)
        self.fetch_button.pack(side=tk.LEFT, padx=5)

        # ペースト＆取得ボタン
        self.paste_fetch_button = tk.Button(button_frame, text=PASTE_FETCH_BUTTON_TEXT, command=self.paste_and_fetch)
        self.paste_fetch_button.pack(side=tk.LEFT, padx=5)

        # 動画情報の表示エリア
        self.info_label = tk.Label(master, text=INFO_LABEL_TEXT)
        self.info_label.pack()
        self.info_text = tk.Text(master, height=3, width=45)
        self.info_text.pack()

        # 文字起こしの表示エリア
        self.transcript_label = tk.Label(master, text=TRANSCRIPT_LABEL_TEXT)
        self.transcript_label.pack()
        self.transcript_text = tk.Text(master, height=6, width=45)
        self.transcript_text.pack()

        # コピーボタンフレーム
        copy_button_frame = tk.Frame(master)
        copy_button_frame.pack(side=tk.BOTTOM, pady=10)

        # 動画情報をコピーボタン
        self.copy_info_button = tk.Button(copy_button_frame, text=COPY_INFO_BUTTON_TEXT, command=self.copy_info)
        self.copy_info_button.pack(pady=5)

        # 文字起こしをコピーボタン
        self.copy_transcript_button = tk.Button(copy_button_frame, text=COPY_TRANSCRIPT_BUTTON_TEXT, command=self.copy_transcript)
        self.copy_transcript_button.pack(pady=5)

    def fetch_info(self):
        """YouTubeの情報を取得し、表示する"""
        url = self.url_entry.get()
        try:
            # yt-dlpの設定
            ydl_opts = {'skip_download': True}
            with yt_dlp.YoutubeDL(ydl_opts) as ydl:
                info = ydl.extract_info(url, download=False)

            # 動画情報の取得
            title = info.get('title', 'タイトル不明')
            channel = info.get('uploader', 'チャンネル名不明')
            video_info = f"{title}\ \n {channel}\ \n{url}"
            self.info_text.delete('1.0', tk.END)
            self.info_text.insert(tk.END, video_info)

            # 文字起こしの取得
            video_id = info['id']
            transcript = YouTubeTranscriptApi.get_transcript(video_id, languages=['ja'])
            
            # 文字起こしのフォーマット
            formatted_transcript = ""
            for entry in transcript:
                start_time = int(entry['start'])
                text = entry['text']
                timestamp = f"{start_time // 60:02d}:{start_time % 60:02d}"
                formatted_transcript += f"[{timestamp}] {text}\n"

            self.transcript_text.delete('1.0', tk.END)
            self.transcript_text.insert(tk.END, formatted_transcript)

        except Exception as e:
            error_message = f"エラーが発生しました: {str(e)}\n\n"
            error_message += "".join(get_exception_trace())
            messagebox.showerror("エラー", error_message)

    def paste_and_fetch(self):
        """クリップボードの内容をペーストし、情報を取得する"""
        clipboard_content = self.master.clipboard_get()
        self.url_entry.delete(0, tk.END)
        self.url_entry.insert(0, clipboard_content)
        self.fetch_info()

    def copy_info(self):
        """動画情報をクリップボードにコピーする"""
        info = self.info_text.get('1.0', tk.END)
        self.master.clipboard_clear()
        self.master.clipboard_append(info)

    def copy_transcript(self):
        """文字起こしをクリップボードにコピーする"""
        transcript = self.transcript_text.get('1.0', tk.END)
        self.master.clipboard_clear()
        self.master.clipboard_append(transcript)


# アプリケーションの終了時の処理をカスタマイズする
def on_close():
    save_position(root)  # ウィンドウの位置を保存
    root.destroy()  # ウィンドウを破壊する


# メインプロセス
try:
    root = tk.Tk()
    restore_position(root)
    app = YouTubeApp(root)
    root.protocol("WM_DELETE_WINDOW", on_close)  # 終了時処理の設定
    root.mainloop()
except Exception as e:
    error_message = f"予期せぬエラーが発生しました: {str(e)}\n\n"
    error_message += "".join(get_exception_trace())
    messagebox.showerror("致命的エラー", error_message)
    sys.exit(1)
```