# [メタ情報] # 識別子: bunny更新パイプライン_exe # 補足: 実行用 env終了 # [/メタ情報] 要約: このPythonスクリプト「f1_bunny_factory.py」は、GoogleスプレッドシートとBunnyCDN(動画ストリーミングおよびストレージ)を連携させ、コンテンツのアップロードと管理を自動化するツールです。環境変数からAPIキーやスプレッドシートIDを読み込み、必須項目をチェック。スプレッドシートの「システム設定」にあるマスターON/OFFスイッチが「ON」の場合にのみ処理を実行します。 スクリプトは、スプレッドシートの「処理待ち」ステータスの行を検知すると、指定された動画ファイルまたは音声ファイルをBunnyCDNにアップロードします。アップロードが成功すると、BunnyCDNから取得した動画ID、生成されたURL(高画質/低画質/WordPress再生用)、更新日時などの情報をスプレッドシートの該当列に書き込み、ステータスを「完了」に更新します。同時に、以前の古いファイルが存在すればBunnyCDNから削除します。処理中にエラーが発生した場合は、スプレッドシートのステータスを「エラー」に変更し、ローカルにエラーログを記録する堅牢な設計です。 付属のLaunchAgent設定ファイル「com.XXXXXX.bunny_factory.plist」は、macOS上でこのPythonスクリプトを1分間隔で自動実行するためのものです。これにより、スプレッドシートのコンテンツ更新依頼に対して、BunnyCDNへのデプロイ作業が自動的かつ継続的に行われます。標準出力とエラー出力は指定されたログファイルに記録されます。 /Users/XXXXXX/python_scripts/bunny_upd/f1_bunny_factory.py ```python # -*- coding: utf-8 -*- import os import sys import requests import gspread import csv import random import string import json import urllib.parse import unicodedata import time from datetime import datetime from dotenv import load_dotenv from oauth2client.service_account import ServiceAccountCredentials SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) # ===== 1. 環境設定(金庫 .env からの動的ロード & 環境差の自動解決) ===== load_dotenv(os.path.join(SCRIPT_DIR, ".env")) load_dotenv(os.path.join(os.path.dirname(SCRIPT_DIR), ".env")) load_dotenv(os.path.expanduser("~/python_scripts/.env")) BUNNY_API_KEY = os.getenv("BUNNY_API_KEY") BUNNY_LIBRARY_ID = os.getenv("BUNNY_LIBRARY_ID") BUNNY_PULL_ZONE_NAME = os.getenv("BUNNY_PULL_ZONE_NAME", "") BUNNY_STORAGE_API_KEY = os.getenv("BUNNY_STORAGE_API_KEY") BUNNY_STORAGE_ZONE_NAME = os.getenv("BUNNY_STORAGE_ZONE_NAME", "") SHEET_ID = os.getenv("BUNNY_SPREADSHEET_ID") or os.getenv("F1_SPREADSHEET_ID") JSON_KEY_FILE = os.getenv("BUNNY_SA_JSON") or os.getenv("GOOGLE_APPLICATION_CREDENTIALS") # 必須環境変数のチェック required_envs = { "BUNNY_API_KEY": BUNNY_API_KEY, "BUNNY_LIBRARY_ID": BUNNY_LIBRARY_ID, "BUNNY_PULL_ZONE_NAME": BUNNY_PULL_ZONE_NAME, "SHEET_ID": SHEET_ID, "JSON_KEY_FILE": JSON_KEY_FILE } missing = [k for k, v in required_envs.items() if not v] if missing: print("❌ エラー: 以下の必要な環境変数が .env に設定されていません:") for item in missing: print(f" - {item}") sys.exit(1) # Pull Zoneホスト名のサニタイズ(https:// や末尾スラッシュを除去してドメインのみ抽出) CLEAN_PULL_ZONE = BUNNY_PULL_ZONE_NAME.replace("https://", "").replace("http://", "").strip("/") LOG_FILE_PATH = os.path.join(SCRIPT_DIR, "bunny_deletion_history.csv") CACHE_FILE = os.path.join(SCRIPT_DIR, "bunny_mtime_cache.json") LOCAL_ERROR_LOG = os.path.join(SCRIPT_DIR, "bunny_error_log.txt") def get_spreadsheet(): scope = ['https://spreadsheets.google.com/feeds', 'https://www.googleapis.com/auth/drive'] creds = ServiceAccountCredentials.from_json_keyfile_name(JSON_KEY_FILE, scope) client = gspread.authorize(creds) return client.open_by_key(SHEET_ID) def log_local_error(message): now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") try: with open(LOCAL_ERROR_LOG, "a", encoding="utf-8") as f: f.write(f"[{now}] {message}\n") except: pass def generate_random_wpidex(extn="mp4"): """8桁ランダム英数字 + 拡張子を生成""" chars = string.ascii_letters + string.digits rand_id = ''.join(random.choice(chars) for _ in range(8)) ext = extn.lstrip(".") if extn else "mp4" return f"{rand_id}.{ext}" def safe_sheet_update(row_index, updates, final_w_status): max_retries = 3 for attempt in range(max_retries): try: spreadsheet = get_spreadsheet() sheet = spreadsheet.sheet1 cells_range = f"O{row_index}:X{row_index}" cell_list = sheet.range(cells_range) # O列 (15列目 / idx 0) から X列 (24列目 / idx 9) までの更新 for idx, val in enumerate(updates): col_name_idx = 14 + idx # 0-based: 14 -> O列 (15列目) if col_name_idx == 22: # 22 -> W列 (23列目: bnステータス) は個別更新のためスキップ continue if idx < len(cell_list): cell_list[idx].value = val sheet.update_cells(cell_list) time.sleep(1) # 最後にW列 (23列目) のステータスを最終ステータス(「完了」等)に更新 sheet.update_cell(row_index, 23, final_w_status) return except Exception as e: time.sleep(2) # --------------------------------------------------------- # ▼ Bunny API 関数(動画 Stream 用) # --------------------------------------------------------- def upload_to_bunny(file_path, video_title): headers = { "AccessKey": BUNNY_API_KEY, "Content-Type": "application/json" } url = f"https://video.bunnycdn.com/library/{BUNNY_LIBRARY_ID}/videos" payload = {"title": video_title} res = requests.post(url, json=payload, headers=headers, timeout=30) res.raise_for_status() video_id = res.json()["guid"] upload_url = f"https://video.bunnycdn.com/library/{BUNNY_LIBRARY_ID}/videos/{video_id}" upload_headers = {"AccessKey": BUNNY_API_KEY} for attempt in range(3): try: with open(file_path, "rb") as f: upload_res = requests.put(upload_url, headers=upload_headers, data=f, timeout=600) upload_res.raise_for_status() return video_id except Exception as e: if attempt == 2: raise Exception(f"Bunnyへの動画送信に3回失敗しました: {str(e)}") time.sleep(10) def delete_old_video(old_id, movid): clean_id = str(old_id).strip() if not clean_id or len(clean_id) < 10: return "Skip" url = f"https://video.bunnycdn.com/library/{BUNNY_LIBRARY_ID}/videos/{clean_id}" headers = {"AccessKey": BUNNY_API_KEY} try: res = requests.delete(url, headers=headers, timeout=30) return "Deleted" if res.status_code == 200 else f"Failed({res.status_code})" except Exception as e: return f"Error({str(e)})" # --------------------------------------------------------- # ▼ Bunny API 関数(音声 Storage 用) # --------------------------------------------------------- def upload_to_bunny_storage(file_path, file_name, subfolder="music"): clean_filename = os.path.basename(file_path) url_encoded_filename = urllib.parse.quote(clean_filename) url = f"https://storage.bunnycdn.com/{BUNNY_STORAGE_ZONE_NAME}/{subfolder}/{url_encoded_filename}" headers = { "AccessKey": BUNNY_STORAGE_API_KEY, "Content-Type": "application/octet-stream" } with open(file_path, "rb") as f: res = requests.put(url, headers=headers, data=f, timeout=300) res.raise_for_status() return clean_filename def delete_from_bunny_storage(file_name, movid, subfolder="music"): clean_filename = os.path.basename(file_name) url_encoded_filename = urllib.parse.quote(clean_filename) url = f"https://storage.bunnycdn.com/{BUNNY_STORAGE_ZONE_NAME}/{subfolder}/{url_encoded_filename}" headers = {"AccessKey": BUNNY_STORAGE_API_KEY} try: res = requests.delete(url, headers=headers, timeout=30) return "Deleted" if res.status_code == 200 else f"Failed({res.status_code})" except Exception as e: return f"Error({str(e)})" # --------------------------------------------------------- # ▼ メイン処理 # --------------------------------------------------------- def main(): try: spreadsheet = get_spreadsheet() except Exception as e: print(f"❌ スプレッドシート接続エラー: {e}") log_local_error(f"スプレッドシート接続エラー: {e}") sys.exit(1) # 🟢 マスターSW(安全装置)の確認 try: try: settings_sheet = spreadsheet.worksheet("F_システム設定") except Exception: settings_sheet = spreadsheet.worksheet("システム設定") settings_records = settings_sheet.get_all_records() master_switch = 'OFF' for row in settings_records: if row.get('設定項目名') == 'Bunny自動更新マスター': master_switch = str(row.get('ステータス', 'OFF')).strip() break except Exception as e: print(f"⚠️ システム設定シート参照エラー: {e}") sys.exit(1) if master_switch != 'ON': print(f"ℹ️ Bunny自動更新マスターが '{master_switch}' のため、処理を停止します。") sys.exit(0) sheet = spreadsheet.sheet1 records = sheet.get_all_values() COL_B, COL_C, COL_F, COL_G, COL_H = 1, 2, 5, 6, 7 COL_O, COL_P, COL_Q, COL_R = 14, 15, 16, 17 COL_S, COL_T, COL_U, COL_V, COL_W = 18, 19, 20, 21, 22 audio_exts = ['mp3', 'aac', 'wav', 'm4a', 'flac', 'm4r'] # F1スプレッドシートの巡回 for i, row in enumerate(records[1:], start=2): if len(row) <= COL_W: continue w_status = row[COL_W].strip() # 🟢 A. 「処理待ち」の登録・更新処理 if w_status == "処理待ち": extn = row[COL_H].strip().lower() # 【爆走防止ガード】重複起動によるBunny上のUploading乱立を防ぐため、W列を即座に「処理中」に変更 try: sheet.update_cell(i, 23, "処理中") except Exception as e: print(f"⚠️ ステータス更新(処理中)エラー: {e}") # S列 (19列目 / index 18): bn_low_wpidex raw_low_wpidex = row[COL_S].strip() if len(row) > COL_S else "" if not raw_low_wpidex or raw_low_wpidex.startswith("http"): low_wpidex = generate_random_wpidex(extn) else: low_wpidex = raw_low_wpidex print(f"🎬 処理開始 (行: {i}): {row[COL_G]}") now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") try: if extn in audio_exts: safe_file_name = upload_to_bunny_storage(row[COL_F], row[COL_G], "music") high_url = f"https://{BUNNY_STORAGE_ZONE_NAME}.b-cdn.net/music/{safe_file_name}" low_rd_url = f"https://XXXXXX.com/rd.php?id={low_wpidex}" updates = [ "FALSE", # O: bn低画質動画単独生成 row[COL_G], # P: bn_old_videoid "", # Q: bn_high_resolution "", # R: bn_low_resolution low_wpidex, # S: bn_low_wpidex high_url, # T: bn_high_url high_url, # U: bn_low_url low_rd_url, # V: WP再生URL (低画質rd.php) "完了", # W: bnステータス now_str # X: 更新日時 ] safe_sheet_update(row_index=i, updates=updates, final_w_status="完了") if len(row) > COL_P and row[COL_P] and row[COL_P] != row[COL_G]: delete_from_bunny_storage(row[COL_P], row[COL_C] or row[COL_B], "music") else: new_id = upload_to_bunny(row[COL_F], row[COL_G]) high_p = row[COL_Q] if len(row) > COL_Q and row[COL_Q] else "1080" low_p = row[COL_R] if len(row) > COL_R and row[COL_R] else "360" # 正確なURL生成 high_url = f"https://{CLEAN_PULL_ZONE}/{new_id}/playlist.m3u8" low_url = f"https://{CLEAN_PULL_ZONE}/{new_id}/play_{low_p}p.mp4" low_rd_url = f"https://XXXXXX.com/rd.php?id={low_wpidex}" updates = [ "FALSE", # O: bn低画質動画単独生成 new_id, # P: bn_old_videoid high_p, # Q: bn_high_resolution low_p, # R: bn_low_resolution low_wpidex, # S: bn_low_wpidex high_url, # T: bn_high_url low_url, # U: bn_low_url low_rd_url, # V: WP再生URL (低画質rd.php) "完了", # W: bnステータス now_str # X: 更新日時 ] safe_sheet_update(row_index=i, updates=updates, final_w_status="完了") if len(row) > COL_P and row[COL_P] and row[COL_P] != new_id: delete_old_video(row[COL_P], row[COL_C] or row[COL_B]) except Exception as e: print(f"❌ 処理失敗 (行: {i}): {e}") log_local_error(f"行 {i} 処理エラー: {e}") # エラー発生時はステータスを「エラー」に変更し無限ループを防止 try: sheet.update_cell(i, 23, "エラー") except: pass if __name__ == "__main__": main() ``` /Users/XXXXXX/Library/LaunchAgents/com.XXXXXX.bunny_factory.plist ```xml Label com.XXXXXX.bunny_factory ProgramArguments /usr/bin/python3 /Users/XXXXXX/python_scripts/bunny_upd/f1_bunny_factory.py RunAtLoad StartInterval 60 StandardOutPath /Users/XXXXXX/Library/Logs/bunny_factory.log StandardErrorPath /Users/XXXXXX/Library/Logs/bunny_factory_err.log ```