# [メタ情報] # 識別子: マイライブラリに基づく4jsonのM1MacからXserverへの更新_exe # システム名: 未分類 # 技術種別: Misc # 機能名: Misc # 使用言語: Python plist # 状態: 公開用 # [/メタ情報] 要約: このシステムは、Googleスプレッドシートで管理される動画関連メタデータ(カタログ、埋め込みコード、代替IDマップなど)を、Googleドライブ経由でXサーバーに自動同期するためのものです。 主要な構成要素は以下の通りです。 1. **Google Apps Script (`meta_sync_drive.gs`)**: スプレッドシート「F2_動画パッケージ」のデータから`videoct.json`、`alt_video_map.json`、`videoembed.json`を生成し、Googleドライブ上の指定されたファイルに定期的にアップロードします。これにより、スプレッドシートの最新情報がGoogleドライブに反映されます。 2. **Pythonスクリプト群**: * `sync_dropbox_wp_library.py`, `generate_videoct_json.py`, `generate_videoembed_json.py`, `generate_alt_map_json.py`: Googleドライブ上のJSONファイル(GASがアップロードしたものを含む)をMD5チェックサムで差分検知し、変更があった場合のみローカルの指定ディレクトリにダウンロードして保存します。 * `upload_secure_json.py`: 上記のダウンロードスクリプト群を実行してローカルJSONを最新化した後、これらのローカルJSONファイルとXサーバー上の既存ファイルのMD5ハッシュを比較します。差分があれば、SCPとSSHを使い、一時ファイル経由で安全にXサーバーのウェブディレクトリにアップロードします。 3. **macOS LaunchAgent (`com.XXXXXX.push-meta.plist`)**: `upload_secure_json.py`を30秒ごとに自動実行するよう設定されており、定期的な同期処理をトリガーします。 このワークフローにより、スプレッドシートの変更が効率的かつ安全にXサーバー上の公開JSONファイルに反映される、堅牢な自動同期システムが実現されています。全てのスクリプトは環境変数によって設定され、起動ガードロジックや原子的なファイル書き換えを用いて高い信頼性を確保しています。 <任意のフォルダパス>/sync_dropbox_wp_library.py sync_dropbox_wp_library.py ``` #!/usr/bin/env python3 import os, sys, io, json, tempfile from google.oauth2 import service_account from googleapiclient.discovery import build from googleapiclient.http import MediaIoBaseDownload from dotenv import load_dotenv # .envファイルのロード ENV_PATH = "<任意のフォルダパス>" load_dotenv(ENV_PATH) # 環境変数の取得(フォールバック値を完全排除) FILE_ID = os.environ.get("PUSH_META_LIB_JSON_ID") META_DIR = os.environ.get("META_DIR") creds_path = os.environ.get("PUSH_META_SA_JSON") # 起動時ガードロジック(不足時は自律停止) required_envs = { "PUSH_META_LIB_JSON_ID": FILE_ID, "META_DIR": META_DIR, "PUSH_META_SA_JSON": creds_path } missing = [k for k, v in required_envs.items() if not v] if missing: print(f"❌ [sync_dropbox_wp_library.py] {', '.join(missing)} が .env に設定されていません。安全に自律停止します。", file=sys.stderr) sys.exit(1) if not os.path.isfile(creds_path): print(f"❌ [sync_dropbox_wp_library.py] サービスアカウントキーファイルが見つかりません: {creds_path}", file=sys.stderr) sys.exit(1) OUT_PATH = os.path.join(META_DIR, "dropbox_wp_library.json") def main(): os.makedirs(META_DIR, exist_ok=True) creds = service_account.Credentials.from_service_account_file( creds_path, scopes=["https://www.googleapis.com/auth/drive.readonly"] ) svc = build("drive", "v3", credentials=creds, cache_discovery=False) meta = svc.files().get(fileId=FILE_ID, fields="id,name,modifiedTime,md5Checksum").execute() stamp_path = os.path.join(META_DIR, "dropbox_wp_library.stamp") old_md5 = "" if os.path.exists(stamp_path): try: with open(stamp_path) as f: old_md5 = json.load(f).get("md5Checksum", "") except: pass if old_md5 == meta.get("md5Checksum") and os.path.exists(OUT_PATH): print("[sync] 変更なし。スキップします。"); return req = svc.files().get_media(fileId=FILE_ID) fh = io.BytesIO() downloader = MediaIoBaseDownload(fh, req) done = False while not done: status, done = downloader.next_chunk() data = fh.getvalue() fd, tmp = tempfile.mkstemp(prefix="libjson_", suffix=".json", dir=META_DIR) with os.fdopen(fd, "wb") as f: f.write(data) os.replace(tmp, OUT_PATH) with open(stamp_path, "w") as f: json.dump({"md5Checksum": meta.get("md5Checksum"), "modifiedTime": meta.get("modifiedTime")}, f, ensure_ascii=False) print("[sync] 更新完了:", OUT_PATH, meta.get("modifiedTime")) if __name__ == "__main__": main() ``` <任意のフォルダパス>/generate_videoct_json.py generate_videoct_json.py ``` #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ generate_videoct_json.py (M1 Mac用 - Googleドライブダウンロード方式決定版) - スプレッドシートからの直接取得を廃止し、Googleドライブから差分検知でDL """ import os import sys import io import json import tempfile from google.oauth2 import service_account from googleapiclient.discovery import build from googleapiclient.http import MediaIoBaseDownload from dotenv import load_dotenv # .envファイルのロード ENV_PATH = "<任意のフォルダパス>" load_dotenv(ENV_PATH) # 環境変数の取得(フォールバック値を排除した鉄壁のenv化仕様) FILE_ID = os.environ.get("PUSH_META_CT_JSON_ID") META_DIR = os.environ.get("META_DIR") creds_path = os.environ.get("PUSH_META_SA_JSON") # 起動時ガードロジック(不足時は自律停止) required_envs = { "PUSH_META_CT_JSON_ID": FILE_ID, "META_DIR": META_DIR, "PUSH_META_SA_JSON": creds_path } missing = [k for k, v in required_envs.items() if not v] if missing: print(f"❌ [generate_videoct_json.py] 必要な環境変数が .env に設定されていません: {', '.join(missing)}", file=sys.stderr) print("安全に自律停止します。", file=sys.stderr) sys.exit(1) if not os.path.isfile(creds_path): print(f"❌ [generate_videoct_json.py] サービスアカウントキーファイルが見つかりません: {creds_path}", file=sys.stderr) sys.exit(1) OUT_PATH = os.path.join(META_DIR, "videoct.json") def main(): os.makedirs(META_DIR, exist_ok=True) creds = service_account.Credentials.from_service_account_file( creds_path, scopes=["https://www.googleapis.com/auth/drive.readonly"] ) svc = build("drive", "v3", credentials=creds, cache_discovery=False) try: # ドライブ上のファイル情報を取得 meta = svc.files().get(fileId=FILE_ID, fields="id,name,modifiedTime,md5Checksum").execute() except Exception as e: print(f"❌ [generate_videoct_json.py] Googleドライブ上のファイル情報の取得に失敗しました: {e}", file=sys.stderr) sys.exit(1) # 前回の指紋(MD5ハッシュ)を確認 stamp_path = os.path.join(META_DIR, "videoct.stamp") old_md5 = "" if os.path.exists(stamp_path): try: with open(stamp_path) as f: old_md5 = json.load(f).get("md5Checksum", "") except: pass # 指紋が一致し、ローカルにファイルが実在する場合は処理をスキップ if old_md5 == meta.get("md5Checksum") and os.path.exists(OUT_PATH): print("[videoct] no changes. skipped.") return # 内容に変更があるためダウンロードを実行(差分検知) try: req = svc.files().get_media(fileId=FILE_ID) fh = io.BytesIO() downloader = MediaIoBaseDownload(fh, req) done = False while not done: status, done = downloader.next_chunk() data = fh.getvalue() except Exception as e: print(f"❌ [generate_videoct_json.py] ダウンロード失敗: {e}", file=sys.stderr) sys.exit(1) # 原子的な安全書き換え(一時ファイルに保存後、一瞬でリプレース) fd, tmp = tempfile.mkstemp(prefix="videoct_", suffix=".json", dir=META_DIR) with os.fdopen(fd, "wb") as f: f.write(data) os.replace(tmp, OUT_PATH) # 指紋の永続更新 with open(stamp_path, "w") as f: json.dump({"md5Checksum": meta.get("md5Checksum"), "modifiedTime": meta.get("modifiedTime")}, f, ensure_ascii=False) print(f"[videoct] wrote: {OUT_PATH}") if __name__ == "__main__": main() ``` geberate_videoembed_json.py ``` #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ generate_videoembed_json.py (M1 Mac用 - Googleドライブダウンロード方式決定版) - スプレッドシートからの直接取得を廃止し、Googleドライブから差分検知でDL """ import os import sys import io import json import tempfile from google.oauth2 import service_account from googleapiclient.discovery import build from googleapiclient.http import MediaIoBaseDownload from dotenv import load_dotenv # .envファイルのロード ENV_PATH = "<任意のフォルダパス>" load_dotenv(ENV_PATH) # 環境変数の取得(フォールバック値を排除した鉄壁のenv化仕様) FILE_ID = os.environ.get("PUSH_META_EMBED_JSON_ID") META_DIR = os.environ.get("META_DIR") creds_path = os.environ.get("PUSH_META_SA_JSON") # 起動時ガードロジック(不足時は自律停止) required_envs = { "PUSH_META_EMBED_JSON_ID": FILE_ID, "META_DIR": META_DIR, "PUSH_META_SA_JSON": creds_path } missing = [k for k, v in required_envs.items() if not v] if missing: print(f"❌ [generate_videoembed_json.py] 必要な環境変数が .env に設定されていません: {', '.join(missing)}", file=sys.stderr) print("安全に自律停止します。", file=sys.stderr) sys.exit(1) if not os.path.isfile(creds_path): print(f"❌ [generate_videoembed_json.py] サービスアカウントキーファイルが見つかりません: {creds_path}", file=sys.stderr) sys.exit(1) OUT_PATH = os.path.join(META_DIR, "videoembed.json") def main(): os.makedirs(META_DIR, exist_ok=True) creds = service_account.Credentials.from_service_account_file( creds_path, scopes=["https://www.googleapis.com/auth/drive.readonly"] ) svc = build("drive", "v3", credentials=creds, cache_discovery=False) try: # ドライブ上のファイル情報を取得 meta = svc.files().get(fileId=FILE_ID, fields="id,name,modifiedTime,md5Checksum").execute() except Exception as e: print(f"❌ [generate_videoembed_json.py] Googleドライブ上のファイル情報の取得に失敗しました: {e}", file=sys.stderr) sys.exit(1) # 前回の指紋(MD5ハッシュ)を確認 stamp_path = os.path.join(META_DIR, "videoembed.stamp") old_md5 = "" if os.path.exists(stamp_path): try: with open(stamp_path) as f: old_md5 = json.load(f).get("md5Checksum", "") except: pass # 指紋が一致し、ローカルにファイルが実在する場合は処理をスキップ if old_md5 == meta.get("md5Checksum") and os.path.exists(OUT_PATH): print("[videoembed] no changes. skipped.") return # 内容に変更があるためダウンロードを実行(差分検知) try: req = svc.files().get_media(fileId=FILE_ID) fh = io.BytesIO() downloader = MediaIoBaseDownload(fh, req) done = False while not done: status, done = downloader.next_chunk() data = fh.getvalue() except Exception as e: print(f"❌ [generate_videoembed_json.py] ダウンロード失敗: {e}", file=sys.stderr) sys.exit(1) # 原子的な安全書き換え(一時ファイルに保存後、一瞬でリプレース) fd, tmp = tempfile.mkstemp(prefix="videoembed_", suffix=".json", dir=META_DIR) with os.fdopen(fd, "wb") as f: f.write(data) os.replace(tmp, OUT_PATH) # 指紋の永続更新 with open(stamp_path, "w") as f: json.dump({"md5Checksum": meta.get("md5Checksum"), "modifiedTime": meta.get("modifiedTime")}, f, ensure_ascii=False) print(f"[videoembed] wrote: {OUT_PATH}") if __name__ == "__main__": main() ``` upload_secure_json.py ``` #!/usr/bin/env python3 # -*- coding: utf-8 -*- import os import sys import shlex import json import hashlib import random import subprocess as sp from pathlib import Path from typing import Optional, Tuple # 日本時間(JST)の現在日時をログ用に取得する from datetime import datetime, timedelta, timezone JST = timezone(timedelta(hours=9), 'JST') def log(msg: str): ts = datetime.now(JST).strftime("%Y-%m-%d %H:%M:%S") sys.stdout.write(f"[{ts}] {msg}\n") sys.stdout.flush() HOME = Path.home() SCRIPT_DIR = Path(__file__).resolve().parent # .envファイルの探索・ロード from dotenv import load_dotenv env_loaded = False for env_path in [ SCRIPT_DIR / ".env", SCRIPT_DIR.parent / ".env", HOME / "python_scripts" / ".env", HOME / ".env" ]: if env_path.exists(): load_dotenv(env_path) env_loaded = True break # 環境変数からの読み込み SA_JSON_PATH = os.getenv("PUSH_META_SA_JSON") META_DIR_ENV = os.getenv("META_DIR") XS_USER = os.getenv("XS_USER", "XXXXXX") XS_HOST = os.getenv("XS_HOST", "XXXXXX.xsrv.jp") XS_SSH_PORT = os.getenv("XS_SSH_PORT", "10022") SSH_KEY_PATH = os.getenv("SSH_KEY_PATH") XSERVER_DEST_DIR = os.getenv("XSERVER_DEST_DIR") REQUIRED_KEYS = [ "PUSH_META_SA_JSON", "META_DIR", "XS_USER", "XS_HOST", "XS_SSH_PORT", "SSH_KEY_PATH", "XSERVER_DEST_DIR" ] missing = [k for k in REQUIRED_KEYS if not os.getenv(k)] if missing: sys.stderr.write(f"❌ [upload_secure_json.py] {', '.join(missing)} が .env に設定されていません。安全に自律停止します。\n") sys.exit(1) META_DIR = Path(META_DIR_ENV) SSH_TARGET = f"{XS_USER}@{XS_HOST}" TARGETS = [ "videoembed.json", "videoct.json", "alt_video_map.json", "dropbox_wp_library.json" ] def _run(cmd: list[str]) -> int: try: res = sp.run(cmd, check=True, timeout=60) return res.returncode except sp.TimeoutExpired: sys.stderr.write(f"[error] コマンドが60秒でタイムアウトしました: {' '.join(cmd)}\n") return 99 except sp.CalledProcessError as e: sys.stderr.write(f"[error] コマンド失敗: {e}\n") return e.returncode def _ssh(cmd_str: str) -> Tuple[int, str, str]: ssh_cmd = [ "ssh", "-p", XS_SSH_PORT, "-i", SSH_KEY_PATH, "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-o", "ConnectTimeout=15", SSH_TARGET, cmd_str ] try: res = sp.run(ssh_cmd, capture_output=True, text=True, check=True, timeout=60) return res.returncode, res.stdout, res.stderr except sp.TimeoutExpired: return 99, "", "SSH timeout" except sp.CalledProcessError as e: return e.returncode, e.stdout, e.stderr def get_file_md5(p: Path) -> str: if not p.exists() or p.stat().st_size == 0: return "" h = hashlib.md5() with open(p, "rb") as f: for chunk in iter(lambda: f.read(4096), b""): h.update(chunk) return h.hexdigest() def upload_one(local_p: Path, base_name: str) -> bool: # リモート側のMD5ハッシュを取得して差分チェック remote_final = f"{XSERVER_DEST_DIR}/{base_name}" md5_cmd = f"md5sum {shlex.quote(remote_final)} 2>/dev/null || md5 {shlex.quote(remote_final)} 2>/dev/null" code, out, err = _ssh(md5_cmd) local_md5 = get_file_md5(local_p) remote_md5 = "" if code == 0 and out.strip(): parts = out.split() if len(parts) >= 1: remote_md5 = parts[0].strip() if len(remote_md5) != 32: # macOS形式の出力対策 remote_md5 = parts[-1].strip() if local_md5 == remote_md5 and local_p.exists() and local_p.stat().st_size > 0: # 差分がないためアップロードをスキップ return False log(f"📤 {base_name} の新規更新を検知。Xserverへ転送中...") tmp_name = f"__upload_{base_name}.tmp" remote_tmp = f"/home/{XS_USER}/{tmp_name}" scp_opts = [ "-P", XS_SSH_PORT, "-i", SSH_KEY_PATH, "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null" ] scp_cmd = ["scp"] + scp_opts + [str(local_p), f"{SSH_TARGET}:{remote_tmp}"] _run(scp_cmd) _ssh(f"mv -f {shlex.quote(remote_tmp)} {shlex.quote(remote_final)} && chmod 644 {shlex.quote(remote_final)}") log(f"✨ [write] {base_name} updated on Xserver.") return True def generate_all_jsons(): # 元のローカルPython生成スクリプトを安全に実行 scripts = [ "sync_dropbox_wp_library.py", "generate_videoct_json.py", "generate_videoembed_json.py", "generate_alt_map_json.py" ] py_bin = "/usr/bin/python3" script_dir = HOME / "python_scripts" for s in scripts: path = script_dir / s if path.exists(): log(f"[gen] Running {s}...") _run([py_bin, str(path)]) else: sys.stderr.write(f"[error] スクリプトが見つかりません: {path}\n") def main() -> int: # 1. 最初にローカルPythonスクリプトを実行してスプシから最新JSONを生成! generate_all_jsons() # 2. 生成されたローカルJSONの差分をチェックしてXserverへアップロード updated = 0 for name in TARGETS: p = META_DIR / name if p.exists(): if upload_one(p, name): updated += 1 else: sys.stderr.write(f"[warning] ローカルJSONファイルが見つかりません: {p}\n") log(f"[done] Update complete. ({updated} files changed)") return 0 if __name__ == "__main__": sys.exit(main()) ``` <任意のフォルダパス>/generate_alt_map_json.py ``` #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ generate_alt_map_json.py (M1 Mac用 - Googleドライブダウンロード方式決定版) - スプレッドシートからの直接取得を廃止し、Googleドライブから差分検知でDL """ import os import sys import io import json import tempfile from google.oauth2 import service_account from googleapiclient.discovery import build from googleapiclient.http import MediaIoBaseDownload from dotenv import load_dotenv # .envファイルのロード ENV_PATH = "<任意のフォルダパス>" load_dotenv(ENV_PATH) # 環境変数の取得(フォールバック値を排除した鉄壁のenv化仕様) FILE_ID = os.environ.get("PUSH_META_ALT_JSON_ID") META_DIR = os.environ.get("META_DIR") creds_path = os.environ.get("PUSH_META_SA_JSON") # 起動時ガードロジック(不足時は自律停止) required_envs = { "PUSH_META_ALT_JSON_ID": FILE_ID, "META_DIR": META_DIR, "PUSH_META_SA_JSON": creds_path } missing = [k for k, v in required_envs.items() if not v] if missing: print(f"❌ [generate_alt_map_json.py] 必要な環境変数が .env に設定されていません: {', '.join(missing)}", file=sys.stderr) print("安全に自律停止します。", file=sys.stderr) sys.exit(1) if not os.path.isfile(creds_path): print(f"❌ [generate_alt_map_json.py] サービスアカウントキーファイルが見つかりません: {creds_path}", file=sys.stderr) sys.exit(1) OUT_PATH = os.path.join(META_DIR, "alt_video_map.json") def main(): os.makedirs(META_DIR, exist_ok=True) creds = service_account.Credentials.from_service_account_file( creds_path, scopes=["https://www.googleapis.com/auth/drive.readonly"] ) svc = build("drive", "v3", credentials=creds, cache_discovery=False) try: # ドライブ上のファイル情報を取得 meta = svc.files().get(fileId=FILE_ID, fields="id,name,modifiedTime,md5Checksum").execute() except Exception as e: print(f"❌ [generate_alt_map_json.py] Googleドライブ上のファイル情報の取得に失敗しました: {e}", file=sys.stderr) sys.exit(1) # 前回の指紋(MD5ハッシュ)を確認 stamp_path = os.path.join(META_DIR, "alt_video_map.stamp") old_md5 = "" if os.path.exists(stamp_path): try: with open(stamp_path) as f: old_md5 = json.load(f).get("md5Checksum", "") except: pass # 指紋が一致し、ローカルにファイルが実在する場合は処理をスキップ if old_md5 == meta.get("md5Checksum") and os.path.exists(OUT_PATH): print("[alt_map] no changes. skipped.") return # 内容に変更があるためダウンロードを実行(差分検知) try: req = svc.files().get_media(fileId=FILE_ID) fh = io.BytesIO() downloader = MediaIoBaseDownload(fh, req) done = False while not done: status, done = downloader.next_chunk() data = fh.getvalue() except Exception as e: print(f"❌ [generate_alt_map_json.py] ダウンロード失敗: {e}", file=sys.stderr) sys.exit(1) # 原子的な安全書き換え(一時ファイルに保存後、一瞬でリプレース) fd, tmp = tempfile.mkstemp(prefix="alt_video_map_", suffix=".json", dir=META_DIR) with os.fdopen(fd, "wb") as f: f.write(data) os.replace(tmp, OUT_PATH) # 指紋の永続更新 with open(stamp_path, "w") as f: json.dump({"md5Checksum": meta.get("md5Checksum"), "modifiedTime": meta.get("modifiedTime")}, f, ensure_ascii=False) print(f"[alt_map] wrote: {OUT_PATH}") if __name__ == "__main__": main() ``` com.XXXXXX.push-meta.plist <任意のフォルダパス>/com.XXXXXX.push-meta.plist ``` Label com.XXXXXX.push-meta ProgramArguments /usr/bin/python3 <任意のフォルダパス>/upload_secure_json.py RunAtLoad StandardErrorPath <任意のフォルダパス>/push_meta.err.log StandardOutPath <任意のフォルダパス>/push_meta.out.log StartInterval 30 WorkingDirectory <任意のフォルダパス> ``` GAS F2_動画パッケージ meta_sync_drive.gs syncAllMetaToDrive スプレッドシートからー変更時 ``` /** * ============================================================================== * 📌 meta_sync_drive.gs (Vimeo共存・Googleドライブ連携 決定版 Ver 3.0) * ============================================================================== * * 【概要】 * スプレッドシート「動画パッケージ」内の各シートの実態に100%即した、 * 4JSON(動画カタログ・代替マッピング・埋め込みコード)の完璧な自動同期スクリプトです。 * * 1. videoct.json (動画カタログ) ➔ 「動画パッケージ」(または「シート1」)から生成 * 2. alt_video_map.json (代替マッピング) ➔ 「動画id対照表」から生成 * 3. videoembed.json (埋め込みコード) ➔ 「動画パッケージ」(または「シート1」)から生成 * * 【設定方法】 * 1. スプレッドシート「F2_動画パッケージ」(17TMSsh8...)のApps Scriptエディタを開きます。 * 2. 左側の「meta_sync_drive」ファイルを開き、本コード(Ver 3.0)で全文を上書き保存(Cmd + S)します。 * 3. 関数「syncAllMetaToDrive」を実行し、Googleドライブに本物のデータが流し込まれることを確認してください。 */ // ========== 1. Googleドライブ上のファイルID定義 ========== const SYNC_DRIVE_IDS = { videoct: "<固有のランダム文字列>", // videoct.json のGoogleドライブID altMap: "<固有のランダム文字列>", // alt_video_map.json のGoogleドライブID videoembed: "<固有のランダム文字列>" // videoembed.json のGoogleドライブID }; /** * 🚀 メイン同期エントリーポイント * - 定期トリガーまたは手動実行で、各JSONデータをGoogleドライブへ同期します。 */ function syncAllMetaToDrive() { Logger.log("🌊 [meta_sync_drive] メタデータ同期処理を開始します (Ver 3.0)。"); const ss = SpreadsheetApp.getActiveSpreadsheet(); try { // ---------------------------------------------------- // ❶ alt_video_map.json の生成(動画id対照表 シートから) // ---------------------------------------------------- const wsRef = ss.getSheetByName("動画id対照表"); if (wsRef) { Logger.log("🔍 「動画id対照表」シートから代替マッピングを抽出中..."); generateAndSyncAltMap_(wsRef); } else { Logger.log("⚠️ 警告: 「動画id対照表」シートが見つかりません。代替マッピング処理をスキップします。"); } // ---------------------------------------------------- // ❷ videoct.json & videoembed.json の生成(動画パッケージ または シート1 から) // ---------------------------------------------------- const wsPkg = ss.getSheetByName("動画パッケージ") || ss.getSheetByName("シート1"); if (wsPkg) { Logger.log("🔍 「" + wsPkg.getName() + "」シートから動画カタログ&埋め込みデータを抽出中..."); generateAndSyncCatalogAndEmbed_(wsPkg); } else { Logger.log("⚠️ 警告: データ元のシート(動画パッケージ または シート1)が見つかりません。"); } Logger.log("✅ [meta_sync_drive] すべての同期処理が正常完了しました。"); } catch (e) { Logger.log("❌ [meta_sync_drive] 同期処理で重大なエラーが発生しました: " + String(e)); } } /** * 🛠️ ファイル保存ヘルパー (Googleドライブ上書き) */ function saveFileToDrive_(filename, content, fileId) { if (!fileId || fileId.indexOf("[") === 0) { Logger.log("⚠️ 「" + filename + "」のファイルIDが正しく設定されていないため、保存をスキップします。"); return; } try { const file = DriveApp.getFileById(fileId); file.setContent(content); Logger.log("💾 Googleドライブ同期完了 (" + filename + "): ID=" + fileId + " (更新時刻: " + file.getLastUpdated() + ")"); } catch (err) { Logger.log("❌ 「" + filename + "」の書き出しに失敗しました: " + String(err)); throw err; } } /** * 📝 ❶ alt_video_map.json の生成ロジック */ function generateAndSyncAltMap_(sheet) { const values = sheet.getDataRange().getValues(); if (values.length < 2) { Logger.log("⚠️ 「動画id対照表」シートにデータ行が存在しません。"); return; } const headers = values[0].map(function(h) { return String(h).trim(); }); const idxWpidex = findHeaderIndexRobust_(headers, ["wpidex", "動画id", "動画ID", "wpid", "id", "ID", "videoid"]); const idxAlt = findHeaderIndexRobust_(headers, ["代替ID", "代替動画id", "代替動画ID", "alt", "alt_id"]); const idxDel = findHeaderIndexRobust_(headers, ["削除フラグ", "削除", "del", "delete", "deleted"]); let finalIdxWpidex = idxWpidex; if (finalIdxWpidex < 0) { Logger.log("⚠️ 警告: 「wpidex」の見出しが見つからないため、デフォルトA列を仮定します。"); finalIdxWpidex = 0; } const altMapList = []; const rows = values.slice(1); for (let r = 0; r < rows.length; r++) { const row = rows[r]; if (row.length <= finalIdxWpidex) continue; // 削除フラグの判定 let isDeleted = false; if (idxDel >= 0 && idxDel < row.length) { const delVal = String(row[idxDel]).trim().toUpperCase(); isDeleted = (delVal === "TRUE" || delVal === "Y" || delVal === "1"); } if (isDeleted) continue; const wpidex = String(row[finalIdxWpidex]).trim(); if (!wpidex) continue; if (idxAlt >= 0 && idxAlt < row.length) { const altId = String(row[idxAlt]).trim(); if (altId) { altMapList.push({ wpidex: wpidex, alt: altId }); } } } const altMapJson = JSON.stringify(altMapList, null, 2); saveFileToDrive_("alt_video_map.json", altMapJson, SYNC_DRIVE_IDS.altMap); } /** * 📝 ❷ videoct.json & videoembed.json の一括生成ロジック(動画パッケージシートから) */ function generateAndSyncCatalogAndEmbed_(sheet) { const values = sheet.getDataRange().getValues(); if (values.length < 2) { Logger.log("⚠️ データ行が存在しません。"); return; } const headers = values[0].map(function(h) { return String(h).trim(); }); // === 頑健な見出し検索システム(大文字小文字・前後の全角半角スペースを完全吸収) === const idxPublish = findHeaderIndexRobust_(headers, ["公開/非公開", "公開状況", "公開", "publishable"]); const idxVideoid = findHeaderIndexRobust_(headers, ["videoid", "動画id", "動画ID", "wpidex"]); const idxTitle = findHeaderIndexRobust_(headers, ["題名", "動画タイトル", "タイトル", "title", "TITLE", "name"]); const idxImage = findHeaderIndexRobust_(headers, ["初期画像ファイルURL", "画像ファイルURL", "画像URL", "image", "poster", "thumbnail"]); const idxVideo = findHeaderIndexRobust_(headers, ["動画ファイルURL", "動画URL", "video", "play_url", "WP再生URL", "DropboxリンクURL"]); const idxVideo2 = findHeaderIndexRobust_(headers, ["低画質動画ファイルURL", "低画質動画URL", "video2", "low_video"]); const idxSubtitle = findHeaderIndexRobust_(headers, ["字幕ファイルURL_vtt", "字幕URL", "subtitle", "vtt"]); const idxSubList = findHeaderIndexRobust_(headers, ["字幕一覧ファイルURL_txt", "字幕一覧用テキストURL", "字幕一覧", "sublist", "字幕一覧ファイルURL"]); const idxExplain = findHeaderIndexRobust_(headers, ["説明ファイルURL_txt", "説明文用テキストURL", "説明", "explain"]); const idxEmbed = findHeaderIndexRobust_(headers, ["embed_code", "WordPress埋め込みコード", "embed", "埋め込みHTML", "完成埋め込みコード"]); const idxDel = findHeaderIndexRobust_(headers, ["削除フラグ", "削除", "del", "delete", "deleted"]); // 必須見出し(videoid)のフォールバック(見つからなければC列(2)を強制採用) let finalIdxVideoid = idxVideoid; if (finalIdxVideoid < 0) { Logger.log("⚠️ 警告: 「videoid」の見出しが見つからないため、デフォルトC列(2)を採用します。"); finalIdxVideoid = 2; } const catalogList = []; const embedList = []; const rows = values.slice(1); for (let r = 0; r < rows.length; r++) { const row = rows[r]; if (row.length <= finalIdxVideoid) continue; // A. 削除フラグの判定(TRUEならスキップ) let isDeleted = false; if (idxDel >= 0 && idxDel < row.length) { const delVal = String(row[idxDel]).trim().toUpperCase(); isDeleted = (delVal === "TRUE" || delVal === "Y" || delVal === "1"); } if (isDeleted) continue; // B. 公開状況の判定(「公開」または「Y」でなければスキップ) let isPublic = true; if (idxPublish >= 0 && idxPublish < row.length) { const pubVal = String(row[idxPublish]).trim(); isPublic = (pubVal === "公開" || pubVal.toUpperCase() === "Y" || pubVal.toUpperCase() === "TRUE" || pubVal === ""); } if (!isPublic) continue; const videoidRaw = String(row[finalIdxVideoid]).trim(); if (!videoidRaw) continue; const videoidNum = toIntOrStr_(videoidRaw); // ---------------------------------------------------- // ① videoct.json 向けのオブジェクト組み立て // ---------------------------------------------------- const catalogItem = { videoid: videoidNum, title: idxTitle >= 0 && idxTitle < row.length ? String(row[idxTitle]).trim() : "", image: idxImage >= 0 && idxImage < row.length ? String(row[idxImage]).trim() : "", video: idxVideo >= 0 && idxVideo < row.length ? String(row[idxVideo]).trim() : "", subtitle: idxSubtitle >= 0 && idxSubtitle < row.length ? String(row[idxSubtitle]).trim() : "", subtitle_list: idxSubList >= 0 && idxSubList < row.length ? String(row[idxSubList]).trim() : "", explain_line: idxExplain >= 0 && idxExplain < row.length ? String(row[idxExplain]).trim() : "" }; if (idxVideo2 >= 0 && idxVideo2 < row.length) { const v2 = String(row[idxVideo2]).trim(); if (v2) catalogItem.video2 = v2; } catalogList.push(catalogItem); // ---------------------------------------------------- // ② videoembed.json 向けのオブジェクト組み立て // ---------------------------------------------------- if (idxEmbed >= 0 && idxEmbed < row.length) { const embedCode = String(row[idxEmbed]).trim(); if (isValidEmbed_(embedCode)) { const embedItem = { videoid: videoidNum, embedCode: embedCode }; if (idxVideo2 >= 0 && idxVideo2 < row.length) { const v2 = String(row[idxVideo2]).trim(); if (v2) embedItem.video2 = v2; } embedList.push(embedItem); } } } // 並び順を videoid の昇順にソート(Python版と並び順を完全同期し、不要な差分を防止) const sortFunc = function(a, b) { return String(a.videoid).localeCompare(String(b.videoid), undefined, {numeric: true, sensitivity: 'base'}); }; catalogList.sort(sortFunc); embedList.sort(sortFunc); // ① videoct.json の保存 const videoctJson = JSON.stringify(catalogList, null, 2); saveFileToDrive_("videoct.json", videoctJson, SYNC_DRIVE_IDS.videoct); // ② videoembed.json の保存 const embedJson = JSON.stringify(embedList, null, 2); saveFileToDrive_("videoembed.json", embedJson, SYNC_DRIVE_IDS.videoembed); } // ============================================================================== // 📌 共通ユーティリティ(表記ゆれ吸収・型変換) // ============================================================================== /** * 候補リストから一致するヘッダーのインデックスを動的に検索する * - 大文字小文字、前後の半角全角スペースを完全無視して表記ゆれを強力に吸収 */ function findHeaderIndexRobust_(headers, candidates) { const norm = function(s) { return String(s || "") .trim() .toLowerCase() .replace(/[\s ]+/g, ""); // 全半角スペースを完全に消去 }; const normCandidates = candidates.map(norm); for (let i = 0; i < headers.length; i++) { const hNorm = norm(headers[i]); if (normCandidates.indexOf(hNorm) !== -1) { return i; } } return -1; } /** * 埋め込みコードが空、または無効値でないか検証 */ function isValidEmbed_(embed) { if (!embed) return false; const e = embed.trim(); return e !== "" && e !== "null" && e !== '""' && e.indexOf("@@") === -1; } /** * 文字列を可能な限り数値に安全パース(Python の _to_int_or_str と完全互換) */ function toIntOrStr_(val) { const s = val.trim(); if (!s) return ""; const num = Number(s); return isNaN(num) ? s : num; } ```