M.

YouTube への動画アップロードを API で自動化し、OAuth クライアントを複数のアプリで使い回す

自作アプリの紹介動画(Remotion で作ったもの)を YouTube に上げています。HeatDesk の紹介動画、TaktKey のショート9本、別のアプリのショート4本です。

ショートを数日おきに1本ずつ手で上げるのは面倒なので、YouTube Data API で アップロードと予約公開 をまとめてやるスクリプトを Claude Code に書いてもらいました。アプリが増えるたびに同じことをするので、認証情報を使い回す形に落ち着いています。

環境:Windows 10 / Python 3 / google-api-python-client・google-auth-oauthlib / Claude Code

認証情報の置き方

YouTube に動画を上げるには、OAuth の認証が要ります。最初は、アプリごとに GCP で OAuth クライアントを作るつもりでした。でも、投稿先のチャンネルが同じなら、クライアントは1つで足ります。

そこで、次の置き方にしました。

ファイル中身数
~/.claude/credentials/youtube_client_secret.jsonOAuth クライアント(デスクトップアプリ)1つ(全アプリ共通)
~/.claude/credentials/youtube_token_<アプリ名>.json許可したあとのトークンアプリごと
  • クライアントは共通:GCP のコンソールで作るのは1回だけ
  • トークンはアプリごと:アプリによって投稿先のチャンネルを変えられます。スコープ(権限)を変えたいときも、そのアプリのトークンだけ作り直せば済みます
  • リポジトリの外に置く:どのリポジトリにも入らないので、うっかりコミットする心配がありません

HeatDesk のスクリプトは、最初はリポジトリの中にクライアントを置く作りでした。あとから共通の場所を読むように直し、.gitignore の除外も要らなくなりました。

Claude Code にルールとして覚えさせる

新しいアプリで動画を上げるとき、Claude Code は放っておくと「GCP で OAuth クライアントを作ってください」と案内してきます。そこで、置き場所のルールを スキル(~/.claude/skills/youtube-credentials/SKILL.md)に書きました。

---
name: youtube-credentials
description: YouTube Data API v3 の既存OAuthクライアント・トークン配置ルール。YouTube動画アップロード等を実装するときに参照する
---

- OAuthクライアント(デスクトップアプリ種別)は ~/.claude/credentials/youtube_client_secret.json に既存。
  新規プロジェクトでもまずこれを参照し、GCPコンソールでの新規作成は行わない
- トークンはプロジェクトごとに分離: ~/.claude/credentials/youtube_token_<project>.json
- 参照実装: (既存のアップロードスクリプトのパス)
- このディレクトリの一覧表示・cat は auto mode の認証情報保護でブロックされる。
  パスの存在を前提にコードを書き、実行時に検証すること

グローバルの CLAUDE.md にも「YouTube API の認証情報は youtube-credentials スキル参照(新規 OAuth クライアントは作らない)」と1行だけ書いています。これで、3つ目のアプリからは何も言わなくても共通のクライアントを使うようになりました。

最後の行は、実際に困ったことです。Claude が「ファイルがあるか確かめます」と ls しようとすると、auto mode に止められます。認証情報のフォルダを覗く操作として扱われるからです。「中は見なくていいので、パスがある前提で書いて、実行時に確かめて」と書いておくと、止められずに進みます。

アップロードの最小コード

基本の形はこうです(実際のスクリプトを短くしたものです)。

import os, tempfile
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from googleapiclient.http import MediaFileUpload

CRED_DIR = os.path.expanduser("~/.claude/credentials")
CLIENT_SECRET = os.path.join(CRED_DIR, "youtube_client_secret.json")
TOKEN = os.path.join(CRED_DIR, "youtube_token_myapp.json")  # アプリごとに変える
SCOPES = ["https://www.googleapis.com/auth/youtube.upload"]

def get_service():
    creds = Credentials.from_authorized_user_file(TOKEN, SCOPES) if os.path.exists(TOKEN) else None
    if not creds or not creds.valid:
        if creds and creds.expired and creds.refresh_token:
            creds.refresh(Request())
        else:
            # 初回だけブラウザが開く。投稿先チャンネルのアカウントで許可する
            creds = InstalledAppFlow.from_client_secrets_file(CLIENT_SECRET, SCOPES).run_local_server(port=0)
        fd, tmp = tempfile.mkstemp(dir=CRED_DIR)
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            f.write(creds.to_json())
        os.replace(tmp, TOKEN)  # 書きかけのトークンを残さない
    return build("youtube", "v3", credentials=creds)

def upload(youtube, path, title, description, publish_at):
    body = {
        "snippet": {"title": title, "description": description, "categoryId": "28"},
        # 予約公開は private + publishAt(UTC)
        "status": {"privacyStatus": "private", "publishAt": publish_at, "selfDeclaredMadeForKids": False},
    }
    media = MediaFileUpload(path, mimetype="video/mp4", resumable=True)
    request = youtube.videos().insert(part="snippet,status", body=body, media_body=media)
    response = None
    while response is None:
        _, response = request.next_chunk()
    return response["id"]

ポイントは次のとおりです。

  • 予約公開は privacyStatus: "private" と publishAt の組み合わせ。publishAt は UTC で書きます。日本時間の18時なら 09:00:00Z です
  • トークンは一時ファイルに書いてから置き換える。書いている途中で落ちても、壊れたトークンが残りません
  • カテゴリ 28 は「科学と技術」です

何本まで上げられるか(クォータ)

YouTube Data API には、1日あたりのクォータ(既定で10,000ユニット)があります。スクリプトを書いた時点のドキュメントでは、動画のアップロードは 1本で1,600ユニット でした。

つまり、1日に上げられるのは6本まで(9,600ユニット)です。TaktKey のショートは9本あったので、1回では上げきれません。

そこで、スクリプトに次の仕組みを入れてもらいました。

  • 1回の実行で6本までにして、そこで止める
  • 上げ終わった動画は記録する(動画ごとに .uploaded ファイルを作り、動画 ID と公開予定日時を書く)
  • 再実行すると、記録のない動画から続ける

翌日にもう一度実行すれば、残りの3本が上がります。

ひとつ気をつけたいのは、公開日時の決め方です。このスクリプトは「実行した時刻+3日×何本目か」で公開日時を決めていました。翌日に続きを実行すると基準の時刻が1日ずれるので、6本目と7本目の間だけ4日空きます。間隔をきっちりそろえたいなら、公開日時は最初から固定の日付で書いておくほうが安全です。別のアプリのショート4本では、公開日時を動画ごとに固定で書く形にしました。

二重アップロードを防ぐ

アップロードの途中でエラーになったり、スクリプトを2回実行してしまったりすると、同じ動画が2本上がります。YouTube は同じファイルでも別の動画として受け付けるので、気づかないうちに重複します。

そこで、上がった動画の ID をすぐに記録して、記録があるものは飛ばすようにしています。

state = json.load(open(STATE_FILE, encoding="utf-8")) if os.path.exists(STATE_FILE) else {}
for v in variants:
    if v in state:
        print(f"{v}: 登録済み https://youtu.be/{state[v]}(スキップ)")
        continue
    state[v] = upload(youtube, ...)
    atomic_write_json(STATE_FILE, state)  # 1本ごとに保存する

1本ごとに保存しておけば、途中で落ちても、上がった分だけが記録に残ります。

この記録は、あとでサムネイルを設定するスクリプトでも使います。動画 ID を探し直さずに済みます。

予約が生きているかを確かめる

ドキュメントには、未審査の API プロジェクトからアップロードした動画は、非公開に制限される という注意があります(2020年7月28日以降に作ったプロジェクトが対象)。予約したつもりでも、公開日時になっても非公開のまま、ということが起こりえます。

そこで、アップロードのあとに動画の状態を読み出して表示するようにしました。スコープに youtube.readonly を足して、videos().list で privacyStatus・publishAt・rejectionReason を見ます。

items = youtube.videos().list(part="status,snippet", id=",".join(ids)).execute().get("items", [])
for item in items:
    s = item["status"]
    print(item["id"], s.get("privacyStatus"), s.get("publishAt"), s.get("rejectionReason"))

publishAt が空になっていたら、予約が外れています。

なお、スコープを足すと、既存のトークンでは足りなくなります。そのアプリのトークンだけを作り直せばいいので、トークンをアプリごとに分けておいてよかったところです。

サムネイル

サムネイルは、アップロードのあとに thumbnails().set で設定します。画像は Remotion の npx remotion still で、動画の代表的なコマを書き出しました。

ただ、ショートのフィードでは、カスタムサムネイルより動画のコマが優先されることがあります。カスタムサムネイルが効くのは、主にチャンネルページ・検索・関連動画です。

まとめ

  • OAuth クライアントは1つだけ作って全アプリで共有し、トークンはアプリごとに分ける
  • 置き場所のルールはスキルに書いて、Claude Code に新しいクライアントを作らせない
  • 予約公開は private + publishAt(UTC)
  • アップロードは1本1,600ユニットなので、1日6本まで。上げた動画を記録して、翌日に続きから再開する
  • 動画 ID は1本ごとに保存して、二重アップロードを防ぐ
  • 未審査プロジェクトの制限に備えて、予約後に状態を読み出して確かめる

参考文献

この記事をシェア

関連する記事

Claude Code の会話ログをブログのネタ帳にする(jsonl から体験を掘り出す)

Claude Code の会話ログには、自分が頼んだこと・Claude が実行したコマンド・その結果が残っています。このブログの記事のいくつかは、このログを掘って書きました。必要なところだけを抜き出すスクリプトと、auto mode に止められた話、文字化けの対処をまとめます。

Alexa-hosted スキルのビルドが jest で落ちた件と、ログを直接読めない環境でエラーを集計する方法

Alexa スキル「安眠ノイズ」に Jest のテストを足したら、Alexa-hosted のビルドが失敗するようになりました。原因の切り分けと、テストを lambda/ の外に出した構成、さらに CloudWatch Logs に直接アクセスできない Alexa-hosted で、エラーを Logs Insights で集計できる1行ログにした話をまとめます。

コード進行アプリの楽譜表示を自作した(大譜表・コード名どおりの綴り・転調で調号を変える)

コード進行生成アプリ Chord Eureka の楽譜表示を、html-midi-player(Magenta)の表示から、SVG を直接組み立てる自作の大譜表に置き換えました。E♭m がシャープで書かれる、進行によって音部記号が変わるといった問題をどう直したかと、置き換えるかどうかの判断をまとめます。

テーマから探す