#!/usr/bin/env python3 """youtube_schedule.py — 自分のチャンネルの動画を、公開予約中のものまで含めて書き出す. YouTube の公式の仕組み(YouTube Data API v3)で読む。読むだけで、動画には何も書き込まない。 Python の標準の道具だけで動く(pip で入れるものはない)。 許可の鍵は Mac の中だけに置く(Claude には渡さない。ElevenLabs の鍵と同じ考え): ~/.config/pixelstomotors/client_secret.json Google Cloud で作った「デスクトップ アプリ」の OAuth クライアント ~/.config/pixelstomotors/youtube_token.json ログインの許可(このスクリプトが作る) 書き出すもの(鍵は入らない): このファイルと同じフォルダの youtube_videos.json → status_scan.py が読み、進み具合のページの「予約済み」に使う 使い方(Mac のターミナルで): python3 youtube_schedule.py --login 最初の1回。ブラウザで許可して、一覧を書き出す python3 youtube_schedule.py 一覧を書き出し直す python3 youtube_schedule.py --install 1時間ごとに自動で書き出すように登録する(launchd・毎時5分) 2026-10-16: 自動の登録を StartInterval(3600秒ごと)から StartCalendarInterval(毎時5分)に替えた。 StartInterval は Mac が眠っている間は時間を数えないため、6:51 のあと 8:29 まで一度も書き出されなかった。 StartCalendarInterval は、眠っていて時刻を過ぎたときは、起きたときに1回動く。ログの行に時刻も付けた python3 youtube_schedule.py --uninstall 自動の登録を外す """ import argparse, base64, hashlib, http.server, json, os, re, secrets, subprocess, sys, threading import urllib.parse, urllib.request, webbrowser from datetime import datetime, timezone, timedelta HERE = os.path.dirname(os.path.abspath(__file__)) OUT = os.path.join(HERE, "youtube_videos.json") CONF = os.path.expanduser("~/.config/pixelstomotors") SECRET = os.path.join(CONF, "client_secret.json") TOKEN = os.path.join(CONF, "youtube_token.json") LOG = os.path.join(CONF, "youtube_schedule.log") SCOPE = "openid email https://www.googleapis.com/auth/youtube.readonly" API = "https://www.googleapis.com/youtube/v3" LABEL = "jp.adaptdesign.pixelstomotors.youtube-schedule" PLIST = os.path.expanduser(f"~/Library/LaunchAgents/{LABEL}.plist") JST = timezone(timedelta(hours=9)) CHANNEL = "Pixels to Motors" CHANNEL_ID = "UC3vr_5p9kJch9zCMb44_rsQ" # @PixelstoMotors # このチャンネル以外を許可したときは書き出さない def call(url, data=None, headers=None): body = urllib.parse.urlencode(data).encode() if isinstance(data, dict) else data req = urllib.request.Request(url, data=body, headers=headers or {}) try: with urllib.request.urlopen(req, timeout=30) as r: return json.loads(r.read().decode()) except urllib.error.HTTPError as e: raise SystemExit(f"YouTube / Google から断られました({e.code}): {e.read().decode(errors='ignore')[:400]}") def client(): if not os.path.isfile(SECRET): raise SystemExit(f"{SECRET} がありません。Google Cloud でダウンロードした JSON を、この名前で置いてください") c = json.load(open(SECRET)) c = c.get("installed") or c.get("web") or c return c["client_id"], c["client_secret"] def save_token(t): os.makedirs(CONF, exist_ok=True) with open(TOKEN, "w") as f: json.dump(t, f) os.chmod(TOKEN, 0o600) def login(): """ブラウザで許可をもらう(この Mac の中だけで受け取る)""" cid, csec = client() verifier = secrets.token_urlsafe(64) challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode() got = {} class H(http.server.BaseHTTPRequestHandler): def do_GET(self): q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) got.update({k: v[0] for k, v in q.items()}) self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.end_headers() msg = "許可を受け取りました。この画面は閉じてください。" if "code" in got else "許可されませんでした。" self.wfile.write(f"
{msg}
".encode()) def log_message(self, *a): pass srv = http.server.HTTPServer(("127.0.0.1", 0), H) redirect = f"http://127.0.0.1:{srv.server_port}/" url = "https://accounts.google.com/o/oauth2/v2/auth?" + urllib.parse.urlencode({ "client_id": cid, "redirect_uri": redirect, "response_type": "code", "scope": SCOPE, "access_type": "offline", "prompt": "select_account consent", # 毎回アカウントを選ばせる "code_challenge": challenge, "code_challenge_method": "S256", }) print("ブラウザで Google の許可の画面を開きます。チャンネル「Pixels to Motors」のアカウントを選んで許可してください。") print("開かないときは、このアドレスをブラウザに貼ってください:\n" + url) webbrowser.open(url) th = threading.Thread(target=srv.handle_request) th.start(); th.join(timeout=300) srv.server_close() if "code" not in got: raise SystemExit("許可を受け取れませんでした(5分で止めました)。もう一度 --login から") t = call("https://oauth2.googleapis.com/token", { "code": got["code"], "client_id": cid, "client_secret": csec, "redirect_uri": redirect, "grant_type": "authorization_code", "code_verifier": verifier, }) if "refresh_token" not in t: raise SystemExit("許可の鍵(refresh_token)が返りませんでした。--login をもう一度") email = "" try: # どのアカウントで許可したかを表示する(id_token の中のメールアドレス) part = t.get("id_token", "").split(".")[1] email = json.loads(base64.urlsafe_b64decode(part + "=" * (-len(part) % 4))).get("email", "") except Exception: pass save_token({"refresh_token": t["refresh_token"], "email": email}) print("許可を保存しました:", TOKEN) print("許可したアカウント:", email or "(分からない)") def access_token(): if not os.path.isfile(TOKEN): raise SystemExit("まだ許可がありません。python3 youtube_schedule.py --login を先に") cid, csec = client() rt = json.load(open(TOKEN))["refresh_token"] t = call("https://oauth2.googleapis.com/token", { "refresh_token": rt, "client_id": cid, "client_secret": csec, "grant_type": "refresh_token"}) return t["access_token"] def seconds(iso): m = re.match(r"P(?:(\d+)D)?T?(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?", iso or "") if not m: return None d, h, mi, s = (int(x or 0) for x in m.groups()) return ((d * 24 + h) * 60 + mi) * 60 + s ID_RE = re.compile(r"(?:youtu\.be/|watch\?v=|shorts/)([A-Za-z0-9_-]{11})") def fetch(): tok = access_token() hd = {"Authorization": "Bearer " + tok} # チャンネルは ID で決め打ちする(許可したアカウントの「自分のチャンネル」が別のこともあるため) me = call(f"{API}/channels?part=snippet&mine=true", headers=hd) me_names = [i["snippet"]["title"] for i in me.get("items", [])] ch = call(f"{API}/channels?part=contentDetails,snippet&id={CHANNEL_ID}", headers=hd) if not ch.get("items"): raise SystemExit("チャンネル Pixels to Motors が見つかりませんでした") owner = any(CHANNEL in n for n in me_names) print("許可したアカウント:", json.load(open(TOKEN)).get("email") or "(前の許可。--login し直すと表示されます)") print("許可したアカウントのチャンネル:", "・".join(me_names) or "(なし)") uploads = ch["items"][0]["contentDetails"]["relatedPlaylists"]["uploads"] ids, page = [], "" while True: r = call(f"{API}/playlistItems?part=contentDetails&maxResults=50&playlistId={uploads}" + (f"&pageToken={page}" if page else ""), headers=hd) ids += [i["contentDetails"]["videoId"] for i in r.get("items", [])] page = r.get("nextPageToken") if not page or len(ids) >= 500: break out = [] for k in range(0, len(ids), 50): r = call(f"{API}/videos?part=snippet,status,contentDetails&id={','.join(ids[k:k + 50])}", headers=hd) for v in r.get("items", []): st, sn = v["status"], v["snippet"] sec = seconds(v["contentDetails"].get("duration")) parent = next((m.group(1) for m in ID_RE.finditer(sn.get("description", "")) if m.group(1) != v["id"]), "") out.append({ "id": v["id"], "title": sn.get("title", ""), "privacy": st.get("privacyStatus"), # public / private / unlisted "publishAt": st.get("publishAt"), # 公開予約の日時(予約中だけ) "published": sn.get("publishedAt") if st.get("privacyStatus") == "public" else None, "seconds": sec, "is_short": sec is not None and sec <= 180, # 3分以内をショートとみなす "parent": parent, # 説明文にある本編の ID(ショート用) "manga_ep": (lambda m: int(m.group(1)) if m else None)(re.search(r"/manga/ep(\d+)", sn.get("description", ""))), # 説明文にあるマンガの話の番号(ショート専用のマンガのショート。2026-10-24・SNS の予約に使う) "url": f"https://www.youtube.com/watch?v={v['id']}", }) n_priv = sum(1 for v in out if v["privacy"] != "public") if not owner and n_priv == 0: print("注意: 許可したアカウントが Pixels to Motors の持ち主ではないので、公開予約中の動画は読めていません。--login で持ち主のアカウント(とチャンネル)を選び直してください") data = {"作成": datetime.now(JST).strftime("%Y-%m-%d %H:%M"), "チャンネル": ch["items"][0]["snippet"]["title"], "予約まで読めた": bool(owner or n_priv), "動画": out} with open(OUT, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=1) n_s = sum(1 for v in out if v["publishAt"] and v["privacy"] != "public") print(f"{data['作成']} {len(out)} 本を書き出しました(うち公開予約 {n_s} 本): {OUT}") def install(): os.makedirs(os.path.dirname(PLIST), exist_ok=True) os.makedirs(CONF, exist_ok=True) plist = f"""