# [メタ情報] # 識別子: 動画パッケージ_VimeoWorker_GAS_exe # システム名: 動画パッケージ_VimeoWorker_GAS # 技術種別: Misc # 機能名: Misc # 使用言語: GAS # 状態: 実行用 # [/メタ情報] プロジェクト: 動画パッケージ_VimeoWorker vimeoWorkers.gs トリガー: runPipelinePoll 1分毎 /*************** Pipeline: Step1(動画) → Step2(サムネ) → Step3(字幕) ***************/ /** ========= 動画パッケージ表 ========= */ const PKG_SHEET_ID = ""; const PKG_SHEET_NAME = 'シート1'; const COL = { JOB: '更新状況', // B: 状態機械(pending → ... → step3_finished) VID: '動画id', // C(Vimeo videoId) TITLE: '題名', // D LOC: '動画所在場所', // E (vimeo / dynamic / youtube) THUMB_URL: '初期画像ファイルURL', // F(rd.php可) VIDEO_URL: '動画ファイルURL', // H(rd.php = WP再生URL) SUB_URL: '字幕ファイルURL_vtt', // J(rd.php可) NOTE: '補足説明', // Q(進捗・ログ) PLAY_URL: '再生URL', // R(vm5?movid=...) CUSTOM: 'WordPress埋め込みコード', // S REPL: '差し替えステータス', // AA PUB: 'publishable' // AB }; /** ========= メディアライブラリ表 ========= */ const MEDIA_SHEET_ID = ""; const MEDIA_SHEET_NAME = 'sheet1'; const MEDIA_COL = { DROPBOX: 'DropboxリンクURL', PLAY: 'WP再生URL' }; /** ========= Vimeo API ========= */ const VIMEO_API = 'https://api.vimeo.com'; const VIMEO_VER = 'application/vnd.vimeo.*+json;version=3.4'; function vimeoHeaders_(){ const t=(PropertiesService.getScriptProperties().getProperty('VIMEO_TOKEN')||'').trim(); if(!t) throw new Error('VIMEO_TOKEN 未設定(スクリプトプロパティに保存してください)'); return { Authorization:'bearer '+t, Accept: VIMEO_VER }; } /** URL→file_name(ASCII, <=64) */ function safeFileNameFromUrl_(u, fallback){ try{ const base = String(u||'').split('?')[0].split('/').pop() || ''; let name = base || String(fallback||'file.bin'); if (!/\.[A-Za-z0-9]{2,5}$/.test(name)) name += '.bin'; name = name.normalize('NFKD') .replace(/[^\x20-\x7E]/g,'_') .replace(/[^A-Za-z0-9_.-]/g,'_'); if (name.length>64){ const ext = name.includes('.') ? ('.' + name.split('.').pop()) : ''; name = name.slice(0,64-ext.length)+ext; } return name; }catch(_){ return String(fallback||'file.bin'); } } /** ---------- リダイレクト確認 ---------- */ function dereferenceOnce_(url){ try{ const res = UrlFetchApp.fetch(url, {followRedirects:false, muteHttpExceptions:true}); const code = res.getResponseCode(); if (code >= 300 && code < 400) { const loc = res.getHeaders()['Location'] || res.getAllHeaders()['Location']; if (loc) return String(loc); } }catch(_){} return ''; } /** ---------- 直ファイルか検査 ---------- */ function probeDirectMediaUrl_(url){ try{ const res = UrlFetchApp.fetch(url, {method:'get', followRedirects:true, muteHttpExceptions:true}); const code = res.getResponseCode(); const ct = String(res.getHeaders()['Content-Type']||''); const okCT = /video\/|octet-stream|application\/mp4/i.test(ct); const looksLikeFile = /\.(mp4|mov|m4v|webm)(\?|$)/i.test(url); return (code < 300) && (okCT || looksLikeFile); }catch(_){} return false; } /** ========= rd.php / vm5 → 原本直リンクに解決 ========= */ function lookupDropboxFromPlayback_(playUrl){ if(!playUrl) return ''; const sh=SpreadsheetApp.openById(MEDIA_SHEET_ID).getSheetByName(MEDIA_SHEET_NAME); const vals=sh.getDataRange().getValues(); if(!vals||vals.length<2) throw new Error('メディアライブラリが空です'); const hd=vals[0], iDBX=hd.indexOf(MEDIA_COL.DROPBOX), iPLY=hd.indexOf(MEDIA_COL.PLAY); if(iDBX<0||iPLY<0) throw new Error('メディアライブラリ見出しが一致しません'); const key=String(playUrl).trim(); for(let r=1;ri<0)) return; const map = { 'pending': 'pending', // Step1からやり直す(新規 or 差し替え) 'step2_finished': 'step2_finished' // Step3を直行させたいときのショートカット }; for(let r=1;r=0) sh.getRange(r+1,iQ+1).setValue((String(vals[r][iQ]||'')+'\n').trim()+`[${_nowTs_()}] [AA無視] 未対応値: ${req}`); sh.getRange(r+1,iREPL+1).setValue(''); continue; } // 同一値なら no-op(履歴だけ残してAAをクリア) if(next===cur){ if(iQ>=0) sh.getRange(r+1,iQ+1).setValue((String(vals[r][iQ]||'')+'\n').trim()+`[${_nowTs_()}] [AA無視] 同一値: ${req}`); sh.getRange(r+1,iREPL+1).setValue(''); continue; } // 反映:Bへ設定 → AAクリア → NOTE追記 sh.getRange(r+1,iJOB+1).setValue(next); sh.getRange(r+1,iREPL+1).setValue(''); if(iQ>=0){ const curNote = String(vals[r][iQ]||'').trim(); sh.getRange(r+1,iQ+1).setValue( (curNote?curNote+'\n':'') + `[${_nowTs_()}] AA→B 反映: ${cur||'-'} → ${next}` ); } } } /* ========================= * NOTE(補足説明)をステータス+履歴に使うユーティリティ * ========================= */ function _progressLabel_(status){ if(status==='reading') return '読み込み中'; if(status==='error') return 'エラー'; if(status==='done') return '完了'; return '-'; } function renderProgressNoteWithHistory_(sh, row, iQ){ const cur = String(sh.getRange(row, iQ+1).getValue()||''); const parts = cur.split('\n'); const history = parts.length > 1 ? parts.slice(1).join('\n') : ''; const s1 = _progressLabel_(qGetMeta_(cur,'s1_status')); const t1 = qGetMeta_(cur,'s1_time') || '-'; const s2 = _progressLabel_(qGetMeta_(cur,'s2_status')); const t2 = qGetMeta_(cur,'s2_time') || '-'; const s3 = _progressLabel_(qGetMeta_(cur,'s3_status')); const t3 = qGetMeta_(cur,'s3_time') || '-'; const head = `Step1: ${s1} (${t1}) Step2: ${s2} (${t2}) Step3: ${s3} (${t3})`; sh.getRange(row, iQ+1).setValue(history ? head + '\n' + history : head); } function appendLog_(sh, row, iQ, msg){ const ts = _nowTs_(); const cur = String(sh.getRange(row, iQ+1).getValue()||''); const parts = cur.split('\n'); const head = parts[0] || ''; const hist = parts.length > 1 ? parts.slice(1).join('\n') : ''; const addLine = `[${ts}] ${msg}`; const next = hist ? (head + '\n' + addLine + '\n' + hist) : (head + '\n' + addLine); sh.getRange(row, iQ+1).setValue(next); } function setStepStatus_(sh, row, iQ, stepNo, status){ const ts = _nowTs_(); qSetMeta_(sh, row, iQ, `s${stepNo}_status`, status); qSetMeta_(sh, row, iQ, `s${stepNo}_time`, ts); renderProgressNoteWithHistory_(sh, row, iQ); } function markError_(sh, row, iQ){ qSetMeta_(sh, row, iQ, 'has_error', '1'); } function clearHistoryLines_(sh, row, iQ){ const cur = String(sh.getRange(row, iQ+1).getValue()||''); const parts = cur.split('\n'); const head = parts[0] || ''; sh.getRange(row, iQ+1).setValue(head); } function clearHistoryIfNoErrors_(sh, row, iQ){ const cur = String(sh.getRange(row, iQ+1).getValue()||''); const hasErr = qGetMeta_(cur, 'has_error') === '1'; if (!hasErr) clearHistoryLines_(sh, row, iQ); } function resetErrorFlag_(sh, row, iQ){ qClearMetaKeys_(sh, row, iQ, ['has_error']); } /* ========================= * 補助:ヘッダー/ID抽出 * ========================= */ function getHeaderIndex_(headerRow, name){ const norm=s=>String(s||'').trim(); const want=norm(name); for(let i=0;i=0 ? String(row[iVID]||'').trim() : ''; if(!vid && iR>=0){ const m=String(row[iR]||'').match(/[\?&]movid=(\d{6,})/); vid = m?m[1]:''; } return vid; } /** Vimeo: 新規(pull)— エラー本文を含めてthrow */ function vimeoUploadPull_(link,name,desc){ const p = { file_name: safeFileNameFromUrl_(link,'upload.mp4'), upload:{approach:'pull', link}, name, description: desc, privacy:{ view:'anybody', embed:'whitelist' } }; const r=UrlFetchApp.fetch(`${VIMEO_API}/me/videos`,{ method:'post', headers:{...vimeoHeaders_(), 'Content-Type':'application/json'}, payload:JSON.stringify(p), muteHttpExceptions:true }); const code=r.getResponseCode(); if(code>=300){ const body = r.getContentText().slice(0,800); throw new Error(`[Vimeo upload pull] code=${code} body=${body}`); } return JSON.parse(r.getContentText()); } /** Vimeo: 既存差し替え(pull)— エラー本文を含めてthrow */ function vimeoReplacePull_(videoId, link){ const payload = { file_name: safeFileNameFromUrl_(link, `${videoId}.mp4`), upload:{approach:'pull', link} }; const r=UrlFetchApp.fetch(`${VIMEO_API}/videos/${encodeURIComponent(videoId)}/versions`,{ method:'post', headers:{...vimeoHeaders_(), 'Content-Type':'application/json'}, payload:JSON.stringify(payload), muteHttpExceptions:true }); const code=r.getResponseCode(); if(code>=300){ const body = r.getContentText().slice(0,800); throw new Error(`[Vimeo replace pull] code=${code} body=${body}`); } return JSON.parse(r.getContentText()); } /** Vimeo: 再生可否 */ function vimeoIsPlayable_(videoId){ const r=UrlFetchApp.fetch(`${VIMEO_API}/videos/${encodeURIComponent(videoId)}?fields=is_playable`,{ headers:vimeoHeaders_(), muteHttpExceptions:true }); if(r.getResponseCode()>=300) throw new Error(r.getContentText()); return JSON.parse(r.getContentText()).is_playable===true; } /** 埋め込みドメイン許可(リトライ付) */ function ensureEmbedDomainWithRetry_(videoId, domain, attempts, waitMs){ if(!videoId) return; domain = domain || 'xxxxxxxx.com'; attempts = attempts || 5; waitMs = waitMs || 3000; for (let i=1; i<=attempts; i++){ try{ // 1) 埋め込みモード & 公開 const res1 = UrlFetchApp.fetch(`${VIMEO_API}/videos/${encodeURIComponent(videoId)}`,{ method:'patch', headers:{...vimeoHeaders_(), 'Content-Type':'application/json'}, payload:JSON.stringify({ privacy:{ embed:'whitelist', view:'anybody' } }), muteHttpExceptions:true }); const c1 = res1.getResponseCode(); if (c1>=300) throw new Error(`patch privacy failed code=${c1} body=${res1.getContentText().slice(0,200)}`); // 2) ドメイン追加(冪等 PUT) const res2 = UrlFetchApp.fetch( `${VIMEO_API}/videos/${encodeURIComponent(videoId)}/privacy/domains/${encodeURIComponent(domain)}`, { method:'put', headers:vimeoHeaders_(), muteHttpExceptions:true } ); const c2 = res2.getResponseCode(); if (c2<300) return; // 成功 throw new Error(`whitelist put failed code=${c2} body=${res2.getContentText().slice(0,200)}`); }catch(e){ if (i===attempts) { console.log(`ensureEmbedDomain retry exhausted for ${videoId}: ${String(e).slice(0,200)}`); return; } Utilities.sleep(waitMs); } } } /* ========================= * Step1: 動画(最古1件) * B: pending/step1_pending → uploading_step1 → encoding_step1 → step1_finished * ========================= */ function processStep1_(){ const lock=LockService.getScriptLock(); if(!lock.tryLock(120000)) return; try{ const sh=SpreadsheetApp.openById(PKG_SHEET_ID).getSheetByName(PKG_SHEET_NAME); const vals=sh.getDataRange().getValues(); if(!vals||vals.length<2) return; const hd=vals[0]; const iJOB=hd.indexOf(COL.JOB), iLOC=hd.indexOf(COL.LOC), iTTL=hd.indexOf(COL.TITLE), iH=hd.indexOf(COL.VIDEO_URL), iR=hd.indexOf(COL.PLAY_URL), iQ=hd.indexOf(COL.NOTE), iVID=hd.indexOf(COL.VID); let target=-1; for(let r=1;r=0){ setStepStatus_(sh, R, iQ, 1, 'error'); markError_(sh, R, iQ); appendLog_(sh, R, iQ, 'H:動画ファイルURL(再生URL)が空です'); } return; } // 解決後URLを先に算出してログ const raw=resolveToRaw_(srcPlayback); if(iQ>=0) appendLog_(sh, R, iQ, 'Step1: resolved URL = ' + raw); sh.getRange(R,iJOB+1).setValue('uploading_step1'); if(iQ>=0){ resetErrorFlag_(sh, R, iQ); // 新規開始 → エラーフラグを消す setStepStatus_(sh, R, iQ, 1, 'reading'); // 1行目:読み込み中 clearHistoryLines_(sh, R, iQ); // 2行目以降をクリア appendLog_(sh, R, iQ, 'Step1: Vimeo 取り込み開始'); } const existingId=parseVideoIdFromPlayUrl_(row[iR]||''); let videoId=''; if(existingId){ vimeoReplacePull_(existingId, raw); videoId=existingId; if(iVID>=0) sh.getRange(R,iVID+1).setValue(videoId); if(iQ>=0) appendLog_(sh, R, iQ, `Step1: 差し替え送信: ${existingId}(エンコード待ち)`); }else{ const up=vimeoUploadPull_(raw, title, ''); videoId=String(up.uri||'').replace('/videos/',''); const newPlay=`https://xxxxxxxx.com/vm5?movid=${encodeURIComponent(videoId)}`; if(iVID>=0) sh.getRange(R,iVID+1).setValue(videoId); sh.getRange(R,iR+1).setValue(newPlay); if(iQ>=0) appendLog_(sh, R, iQ, `Step1: 新規作成: ${videoId}(エンコード待ち)`); } // 埋め込み許可(リトライ付き) ensureEmbedDomainWithRetry_(videoId,'xxxxxxxx.com', 5, 3000); sh.getRange(R,iJOB+1).setValue('encoding_step1'); }catch(e){ try{ const sh2=SpreadsheetApp.openById(PKG_SHEET_ID).getSheetByName(PKG_SHEET_NAME); const vals2=sh2.getDataRange().getValues(); if(!vals2||vals2.length<2) return; const hd2=vals2[0], iJOB2=hd2.indexOf(COL.JOB), iQ2=hd2.indexOf(COL.NOTE); for(let r=1;r=0){ setStepStatus_(sh2, r+1, iQ2, 1, 'error'); markError_(sh2, r+1, iQ2); appendLog_(sh2, r+1, iQ2, 'Step1 エラー: '+String(e).slice(0,500)); } break; } } }catch(_){} }finally{ lock.releaseLock(); } } /* Step1: エンコード完了ポーリング */ function pollStep1_(){ const sh=SpreadsheetApp.openById(PKG_SHEET_ID).getSheetByName(PKG_SHEET_NAME); const vals=sh.getDataRange().getValues(); if(!vals||vals.length<2) return; const hd=vals[0], iLOC=hd.indexOf(COL.LOC), iJOB=hd.indexOf(COL.JOB), iR=hd.indexOf(COL.PLAY_URL), iQ=hd.indexOf(COL.NOTE); const now=Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm:ss'); for(let r=1;r=0){ setStepStatus_(sh, r+1, iQ, 1, 'done'); appendLog_(sh, r+1, iQ, `Step1: 再生可能になりました (${now})`); } } }catch(e){ if(iQ>=0){ appendLog_(sh, r+1, iQ, 'Step1: playable確認失敗: '+String(e).slice(0,200)); } } } } /* ========================= * Step2: サムネイル処理 * B: step1_finished → step2_uploading → step2_finished * ========================= */ function vimeoSetThumbnail_FileUpload_(videoId, imageUrl){ if(!videoId||!imageUrl) return; const imgRes=UrlFetchApp.fetch(imageUrl,{followRedirects:true,muteHttpExceptions:true}); if(imgRes.getResponseCode()>=300) throw new Error('[thumb] 画像取得失敗 code='+imgRes.getResponseCode()); let blob=imgRes.getBlob(); const ct='image/jpeg'; blob=Utilities.newBlob(blob.getBytes(), ct, `${videoId}_thumbnail.jpg`); const createRes=UrlFetchApp.fetch(`${VIMEO_API}/videos/${encodeURIComponent(videoId)}/pictures`,{ method:'post', headers:{...vimeoHeaders_(), 'Content-Type':'application/json'}, payload:JSON.stringify({active:false, type:'custom'}), muteHttpExceptions:true }); if(createRes.getResponseCode()>=300) throw new Error('[thumb] pictures作成失敗: '+createRes.getContentText()); const pic=JSON.parse(createRes.getContentText()); const uploadLink=pic.upload_link || pic.link || pic.upload_link_secure; if(!uploadLink) throw new Error('[thumb] upload_linkなし'); const putRes=UrlFetchApp.fetch(uploadLink,{ method:'put', contentType:ct, payload:blob.getBytes(), followRedirects:true, muteHttpExceptions:true }); if(putRes.getResponseCode()>=300) throw new Error('[thumb] PUT失敗: '+putRes.getContentText()); if(pic.uri){ const act=UrlFetchApp.fetch(`${VIMEO_API}${pic.uri}`,{ method:'patch', headers:{...vimeoHeaders_(), 'Content-Type':'application/json'}, payload:JSON.stringify({active:true}), muteHttpExceptions:true }); if(act.getResponseCode()>=300) throw new Error('[thumb] active化失敗: '+act.getContentText()); } } function processStep2_(){ const sh=SpreadsheetApp.openById(PKG_SHEET_ID).getSheetByName(PKG_SHEET_NAME); const vals=sh.getDataRange().getValues(); if(!vals||vals.length<2){ console.log('Step2: シート空'); return; } const hd=vals[0]; const iJOB=getHeaderIndex_(hd,COL.JOB); const iLOC=getHeaderIndex_(hd,COL.LOC); // 参照はするが選定条件には使わない const iVID=getHeaderIndex_(hd,COL.VID); const iF =getHeaderIndex_(hd,COL.THUMB_URL); const iR =getHeaderIndex_(hd,COL.PLAY_URL); const iQ =getHeaderIndex_(hd,COL.NOTE); if([iJOB,iF,iR,iQ].some(i=>i<0)) throw new Error('Step2: 必要見出し不足'); // ▼ ターゲット選定:LOCは不問。B=step1_finished かつ videoId を導出できる見込みがある行 let target=-1, foundReason=''; for(let r=1;r=0) ? String(vals[r][iVID]||'').trim() : ''; const vidR = parseVideoIdFromPlayUrl_(vals[r][iR]||''); if(vidC || vidR){ target=r; foundReason = vidC ? 'C:videoidあり' : 'R:movid抽出OK'; break; } } if(target<0){ console.log('Step2: 対象行なし'); return; } const R=target+1, row=vals[target]; const videoId = (iVID>=0 && String(row[iVID]||'').trim()) ? String(row[iVID]||'').trim() : parseVideoIdFromPlayUrl_(row[iR]||''); const iLocVal = (iLOC>=0 ? String(row[iLOC]||'') : ''); if(iQ>=0) appendLog_(sh, R, iQ, `Step2: 対象選定 (${foundReason}) loc="${iLocVal}"`); if(!videoId){ sh.getRange(R,iJOB+1).setValue('error_step2'); setStepStatus_(sh, R, iQ, 2, 'error'); markError_(sh, R, iQ); appendLog_(sh, R, iQ, 'Step2: videoId 取得不可(C/R確認)'); return; } // ▼ サムネURL解決(画像用の緩い解決ルート) const thumbSrc = String(row[iF]||'').trim(); const thumbUrlRaw = thumbSrc ? resolveToRawImage_(thumbSrc) : ''; sh.getRange(R,iJOB+1).setValue('step2_uploading'); setStepStatus_(sh, R, iQ, 2, 'reading'); appendLog_(sh, R, iQ, 'Step2: サムネイル反映開始'); if (iQ>=0) appendLog_(sh, R, iQ, 'Step2: resolved thumb = ' + (thumbUrlRaw || '(empty)')); try{ if(!thumbUrlRaw){ // サムネ未指定 → スキップで完了 sh.getRange(R,iJOB+1).setValue('step2_finished'); setStepStatus_(sh, R, iQ, 2, 'done'); appendLog_(sh, R, iQ, 'Step2: サムネURLなし(スキップ)'); return; } vimeoSetThumbnail_FileUpload_(videoId, thumbUrlRaw); sh.getRange(R,iJOB+1).setValue('step2_finished'); setStepStatus_(sh, R, iQ, 2, 'done'); appendLog_(sh, R, iQ, 'Step2: サムネイル反映完了'); }catch(e){ sh.getRange(R,iJOB+1).setValue('error_step2'); setStepStatus_(sh, R, iQ, 2, 'error'); markError_(sh, R, iQ); appendLog_(sh, R, iQ, 'Step2 エラー: '+String(e).slice(0,500)); } } function runStep2ForRow(rowNumber){ const sh=SpreadsheetApp.openById(PKG_SHEET_ID).getSheetByName(PKG_SHEET_NAME); const hd=sh.getRange(1,1,1,sh.getLastColumn()).getValues()[0]; const iJOB=getHeaderIndex_(hd,COL.JOB), iVID=getHeaderIndex_(hd,COL.VID), iF =getHeaderIndex_(hd,COL.THUMB_URL), iR =getHeaderIndex_(hd,COL.PLAY_URL), iQ =getHeaderIndex_(hd,COL.NOTE); if([iJOB,iF,iR,iQ].some(i=>i<0)) throw new Error('runStep2ForRow: 見出し不足'); const rowVals=sh.getRange(rowNumber,1,1,hd.length).getValues()[0]; const job=String(rowVals[iJOB]||'').toLowerCase(); if(job!=='step1_finished') sh.getRange(rowNumber,iJOB+1).setValue('step1_finished'); // 形だけ整える const vid = (iVID>=0 && String(rowVals[iVID]||'').trim()) ? String(rowVals[iVID]||'').trim() : parseVideoIdFromPlayUrl_(rowVals[iR]||''); if(!vid) throw new Error('runStep2ForRow: videoId 不明(C/Rを確認)'); const thumbSrc = String(rowVals[iF]||'').trim(); const thumbUrlRaw = thumbSrc ? resolveToRawImage_(thumbSrc) : ''; setStepStatus_(sh, rowNumber, iQ, 2, 'reading'); appendLog_(sh, rowNumber, iQ, 'Step2(runStep2ForRow): resolved thumb = ' + (thumbUrlRaw || '(empty)')); if(!thumbUrlRaw){ sh.getRange(rowNumber,iJOB+1).setValue('step2_finished'); setStepStatus_(sh, rowNumber, iQ, 2, 'done'); appendLog_(sh, rowNumber, iQ, 'Step2(runStep2ForRow): サムネURLなし(スキップ)'); return; } vimeoSetThumbnail_FileUpload_(vid, thumbUrlRaw); sh.getRange(rowNumber,iJOB+1).setValue('step2_finished'); setStepStatus_(sh, rowNumber, iQ, 2, 'done'); appendLog_(sh, rowNumber, iQ, 'Step2(runStep2ForRow): サムネイル反映完了'); } function resolveToRawImage_(url){ if(!url) return ''; let s = String(url).trim(); // すでに画像直リンクっぽい if (/\.(png|jpe?g|gif|webp|bmp|svg)(\?|$)/i.test(s)) { // Dropbox共有は直リンク化 if (/dropbox\.com/i.test(s)) { s = s.replace('://www.dropbox.com','://dl.dropboxusercontent.com') .replace(/[?&](dl|raw)=[01]/g,''); s += (s.includes('?')?'&':'?') + 'dl=1'; } return s; } // rd.php なら 1段だけ Location を覗く if (/\/rd\.php\?/.test(s)) { let loc = dereferenceOnce_(s); if (/dropbox\.com/i.test(loc)) { loc = loc.replace('://www.dropbox.com','://dl.dropboxusercontent.com') .replace(/[?&](dl|raw)=[01]/g,''); loc += (loc.includes('?')?'&':'?') + 'dl=1'; } return loc || s; // 取れなければ元のURLを返す(サムネ無し扱いにさせない) } // Dropbox共有 → 直リンク化 if (/dropbox\.com\//i.test(s)) { s = s.replace('://www.dropbox.com','://dl.dropboxusercontent.com') .replace(/[?&](dl|raw)=[01]/g,''); s += (s.includes('?')?'&':'?') + 'dl=1'; } return s; } /* ========================= * Step3: 字幕処理 * 状態: step2_finished → step3_creating → step3_putting → step3_finished * ========================= */ /** AB列 publishable を Y にマーク */ function markPublishableY_(sh, rowIndex /*1-based*/, headerRow){ const iPUB = headerRow.indexOf(COL.PUB); if (iPUB >= 0) { sh.getRange(rowIndex, iPUB+1).setValue('Y'); } } /** VTT をサニタイズ&検証(失敗時 throw) */ function sanitizeAndValidateVtt_(rawText) { let vtt = String(rawText || '') .replace(/^\uFEFF/, '') // BOM除去 .replace(/\r\n/g, '\n') .replace(/\r/g, '\n') .replace(/^\s+/, ''); // 先頭空白除去 const firstLine = vtt.split('\n')[0] || ''; const hasHeader = /^WEBVTT(?:[ \t].*)?$/.test(firstLine); if (!hasHeader) { if (/\-\-\>/.test(vtt)) vtt = 'WEBVTT\n\n' + vtt; else throw new Error('[sub] VTT形式ではない可能性(WEBVTT も "-->" も検出できません)'); } else { vtt = vtt.replace(/^WEBVTT[^\n]*\n(?!\n)/, m => m + '\n'); } const TS = '(?:\\d{2}:)?\\d{2}:\\d{2}\\.\\d{3}'; const cueRe = new RegExp('^\\s*(?:.*\\S.*\\n)?\\s*' + TS + '\\s+-->\\s+' + TS + '(?:\\s+.*)?$', 'm'); if (!cueRe.test(vtt)) throw new Error('[sub] 有効なタイムスタンプ行が見つかりません'); if (!/\n$/.test(vtt)) vtt += '\n'; return vtt; } /** Dropbox共有URL → 直ダウンロードURLに強制変換(字幕用は dl=1 を優先) */ function toDropboxDirectSub_(u){ if(!u) return ''; let s = String(u).trim(); if(!/dropbox\.com/i.test(s)) return s; s = s.replace('://www.dropbox.com','://dl.dropboxusercontent.com'); s = s.replace(/[?&](dl|raw)=[01]/g,''); s += (s.includes('?') ? '&' : '?') + 'dl=1'; return s; } /** VTT取得(多段フォールバック) */ function fetchVttWithFallback_(srcUrl){ function isBadContentType_(ct){ return /html|json|xml/i.test(String(ct||'')); } function tryFetch_(url){ const res = UrlFetchApp.fetch(url, { followRedirects:true, muteHttpExceptions:true }); const code = res.getResponseCode(); const ct = String(res.getHeaders()['Content-Type']||''); return { ok:(code<300 && !isBadContentType_(ct)), code, ct, text:res.getContentText('UTF-8') }; } const cands = []; cands.push(srcUrl); cands.push(toDropboxDirectSub_(srcUrl)); cands.push(srcUrl.replace(/([?&])(dl=1|dl=0)/,'$1raw=1').replace(/([?&])raw=0/,'$1raw=1')); cands.push(srcUrl.replace(/([?&])raw=1/,'$1dl=1').replace(/([?&])raw=0/,'$1dl=1').replace(/([?&])dl=0/,'$1dl=1')); let last = ''; for (const u of cands){ if (!u) continue; try{ const r = tryFetch_(u); if (r.ok) return { text:r.text, tried:u }; last = `code=${r.code}, ct=${r.ct}, url=${u}`; }catch(e){ last = `exception=${String(e).slice(0,200)}, url=${u}`; } } throw new Error('[sub] VTT取得失敗: ' + last); } /** [[key::value]] ヘルパ */ function _escReg_(s){ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function qSetMeta_(sh, row, iQ, key, val) { const cur = String(sh.getRange(row, iQ+1).getValue() || ''); const re = new RegExp('\\[\\[' + _escReg_(key) + '::[^\\]]*\\]\\]', 'g'); const next = (cur.replace(re, '').trim() + ' [[' + key + '::' + val + ']]').trim(); sh.getRange(row, iQ+1).setValue(next); } function qGetMeta_(text, key) { const re = new RegExp('\\[\\[' + _escReg_(key) + '::([^\\]]+)\\]\\]'); const m = String(text || '').match(re); return m ? m[1] : ''; } function qClearMetaKeys_(sh, row, iQ, keys){ let cur = String(sh.getRange(row, iQ+1).getValue() || ''); for (const key of keys){ const re = new RegExp('\\[\\[' + _escReg_(key) + '::[^\\]]*\\]\\]', 'g'); cur = cur.replace(re, '').trim(); } sh.getRange(row, iQ+1).setValue(cur); } /** Vimeo: texttracks 一覧→日本語字幕削除→器作成→PUT */ function vimeoListTextTracks_(videoId){ const res = UrlFetchApp.fetch(`${VIMEO_API}/videos/${encodeURIComponent(videoId)}/texttracks`, { method: 'get', headers: { ...vimeoHeaders_() }, muteHttpExceptions: true }); if (res.getResponseCode() >= 300) { throw new Error('[sub] texttracks一覧取得失敗: ' + res.getContentText()); } const json = JSON.parse(res.getContentText()); return Array.isArray(json?.data) ? json.data : (Array.isArray(json) ? json : []); } function vimeoDeleteExistingJaSubtitles_(videoId){ const tracks = vimeoListTextTracks_(videoId); for (const t of tracks){ const isJa = (String(t.language||'').toLowerCase()==='ja') || /japanese|日本語/i.test(String(t.name||'')); if (t.type === 'subtitles' && isJa && t.uri){ UrlFetchApp.fetch(`${VIMEO_API}${t.uri}`, { method: 'delete', headers: { ...vimeoHeaders_() }, muteHttpExceptions: true }); } } } function vimeoCreateJaSubtitleContainer_(videoId){ const createRes = UrlFetchApp.fetch(`${VIMEO_API}/videos/${encodeURIComponent(videoId)}/texttracks`, { method: 'post', headers: { ...vimeoHeaders_(), 'Content-Type': 'application/json' }, payload: JSON.stringify({ type:'subtitles', language:'ja', active:true, name:'Japanese' }), muteHttpExceptions: true }); if (createRes.getResponseCode() >= 300) { throw new Error('[sub] texttracks作成失敗: ' + createRes.getContentText()); } const tt = JSON.parse(createRes.getContentText()); const uploadLink = tt.upload_link || tt.link || tt.upload_link_secure; if (!uploadLink) throw new Error('[sub] upload_linkなし'); return uploadLink; } function vimeoPutSubtitle_(uploadLink, vttText, videoId){ const bytes = Utilities.newBlob(vttText, 'text/vtt', `${videoId}_ja.vtt`).getBytes(); let putRes = UrlFetchApp.fetch(uploadLink, { method: 'put', contentType: 'text/vtt', payload: bytes, followRedirects: true, muteHttpExceptions: true }); if (putRes.getResponseCode() >= 300) { // フォールバック putRes = UrlFetchApp.fetch(uploadLink, { method: 'put', contentType: 'text/plain', payload: bytes, followRedirects: true, muteHttpExceptions: true }); if (putRes.getResponseCode() >= 300) { throw new Error('[sub] PUT失敗: ' + putRes.getContentText()); } } } /** Step3 フェーズ1: 字幕トラック器作成 */ function processStep3Create_(){ const sh=SpreadsheetApp.openById(PKG_SHEET_ID).getSheetByName(PKG_SHEET_NAME); const vals=sh.getDataRange().getValues(); if(!vals||vals.length<2) return; const hd=vals[0]; const iJOB=hd.indexOf(COL.JOB), iLOC=hd.indexOf(COL.LOC), iVID=hd.indexOf(COL.VID), iJ=hd.indexOf(COL.SUB_URL), iR=hd.indexOf(COL.PLAY_URL), iQ=hd.indexOf(COL.NOTE); if([iJOB,iLOC,iJ,iR,iQ].some(i=>i<0)) throw new Error('Step3-Create: 見出し不足'); let target=-1; for(let r=1;r=0?String(row[iVID]||'').trim():''; if(!videoId) videoId=parseVideoIdFromPlayUrl_(row[iR]||''); if(!videoId){ sh.getRange(R,iJOB+1).setValue('error_step3'); setStepStatus_(sh, R, iQ, 3, 'error'); markError_(sh, R, iQ); appendLog_(sh, R, iQ, 'Step3: videoId 不明'); return; } sh.getRange(R,iJOB+1).setValue('step3_creating'); setStepStatus_(sh, R, iQ, 3, 'reading'); appendLog_(sh, R, iQ, 'Step3: 字幕トラック作成準備'); const jurl=String(row[iJ]||'').trim(); if(!jurl){ sh.getRange(R,iJOB+1).setValue('step3_finished'); setStepStatus_(sh, R, iQ, 3, 'done'); appendLog_(sh, R, iQ, 'Step3: 字幕URLなし(スキップ完了)'); clearHistoryIfNoErrors_(sh, R, iQ); markPublishableY_(sh, R, hd); // 完了でYマーク return; } const subUrlDirect=toDropboxDirectSub_(resolveToRaw_(jurl)); qSetMeta_(sh, R, iQ, 'tt_src', encodeURIComponent(subUrlDirect)); try { vimeoDeleteExistingJaSubtitles_(videoId); } catch(_){} try{ const uploadLink=vimeoCreateJaSubtitleContainer_(videoId); qSetMeta_(sh, R, iQ, 'tt_upload', encodeURIComponent(uploadLink)); sh.getRange(R,iJOB+1).setValue('step3_putting'); appendLog_(sh, R, iQ, 'Step3: PUT準備OK'); }catch(e){ sh.getRange(R,iJOB+1).setValue('error_step3'); setStepStatus_(sh, R, iQ, 3, 'error'); markError_(sh, R, iQ); appendLog_(sh, R, iQ, 'Step3-Create エラー: '+String(e).slice(0,500)); } } /** Step3 フェーズ2: PUT */ function processStep3Put_(){ const sh=SpreadsheetApp.openById(PKG_SHEET_ID).getSheetByName(PKG_SHEET_NAME); const vals=sh.getDataRange().getValues(); if(!vals||vals.length<2) return; const hd=vals[0]; const iJOB=hd.indexOf(COL.JOB), iLOC=hd.indexOf(COL.LOC), iVID=hd.indexOf(COL.VID), iR=hd.indexOf(COL.PLAY_URL), iQ=hd.indexOf(COL.NOTE); if([iJOB,iLOC,iR,iQ].some(i=>i<0)) throw new Error('Step3-Put: 見出し不足'); let target=-1; for(let r=1;r=0?String(row[iVID]||'').trim():''; if(!videoId) videoId=parseVideoIdFromPlayUrl_(row[iR]||''); if(!videoId){ sh.getRange(R,iJOB+1).setValue('error_step3'); setStepStatus_(sh, R, iQ, 3, 'error'); markError_(sh, R, iQ); appendLog_(sh, R, iQ, 'Step3: videoId 不明'); return; } const qCur=String(row[iQ]||''); let uploadLink=decodeURIComponent(qGetMeta_(qCur,'tt_upload')||''); const sourceUrl=decodeURIComponent(qGetMeta_(qCur,'tt_src')||''); if(!uploadLink||!sourceUrl){ sh.getRange(R,iJOB+1).setValue('error_step3'); setStepStatus_(sh, R, iQ, 3, 'error'); markError_(sh, R, iQ); appendLog_(sh, R, iQ, 'Step3: 一時情報欠落'); return; } try{ const got=fetchVttWithFallback_(sourceUrl); const vtt=sanitizeAndValidateVtt_(got.text); try{ vimeoPutSubtitle_(uploadLink, vtt, videoId); }catch(e1){ uploadLink=vimeoCreateJaSubtitleContainer_(videoId); qSetMeta_(sh, R, iQ, 'tt_upload', encodeURIComponent(uploadLink)); vimeoPutSubtitle_(uploadLink, vtt, videoId); } sh.getRange(R,iJOB+1).setValue('step3_finished'); qClearMetaKeys_(sh, R, iQ, ['tt_upload','tt_src']); setStepStatus_(sh, R, iQ, 3, 'done'); appendLog_(sh, R, iQ, 'Step3: 字幕反映完了'); clearHistoryIfNoErrors_(sh, R, iQ); markPublishableY_(sh, R, hd); // 完了でYマーク }catch(e){ sh.getRange(R,iJOB+1).setValue('error_step3'); setStepStatus_(sh, R, iQ, 3, 'error'); markError_(sh, R, iQ); appendLog_(sh, R, iQ, 'Step3-Put エラー: '+String(e).slice(0,500)); } } /** ラッパー */ function runStep3Once(){ processStep3Create_(); processStep3Put_(); } /* ========================= * JSON生成: videoembed.json (PHPエンドポイントにPOSTして原子的に更新) * ========================= */ function generateEmbedJsonSafe_(){ const sh = SpreadsheetApp.openById(PKG_SHEET_ID).getSheetByName(PKG_SHEET_NAME); const vals = sh.getDataRange().getValues(); if(!vals || vals.length<2){ console.log('generateEmbedJsonSafe_: シートが空'); return; } const hd = vals[0]; const iVID = hd.indexOf(COL.VID), iTTL = hd.indexOf(COL.TITLE), iR = hd.indexOf(COL.PLAY_URL), iS = hd.indexOf(COL.CUSTOM), iPUB = hd.indexOf(COL.PUB); if([iVID,iTTL,iR,iS,iPUB].some(i => i<0)) throw new Error('generateEmbedJsonSafe_: 見出し不足'); const out = []; for(let r=1;r= 300){ throw new Error('generateEmbedJsonSafe_: POST失敗 code=' + code + ' body=' + text.slice(0,200)); } } /* 1回だけJSON生成を呼びたいとき */ function run_generateEmbedJsonSafe_once(){ generateEmbedJsonSafe_(); } /* ========================= * パイプライン統合ラッパー * ========================= */ function runPipelinePoll(){ const lock = LockService.getScriptLock(); if(!lock.tryLock(30000)) return; try { applyRequestsFromAA_(); // 0) AA→B 橋渡し processStep1_(); // 1) Step1 pollStep1_(); // 2) Step1 再生可能チェック(ここでも whitelist 再試行) processStep2_(); // 3) Step2 runStep3Once(); // 4) Step3 generateEmbedJsonSafe_(); // 5) JSON生成(POSTで安全更新) } catch(e) { Logger.log('runPipelinePoll error: ' + String(e)); } finally { lock.releaseLock(); } } /* 手動でまとめて進めたいとき */ function runPipelineOnce(){ applyRequestsFromAA_(); processStep1_(); pollStep1_(); processStep2_(); runStep3Once(); generateEmbedJsonSafe_(); } /* POSTエンドポイントの疎通デバッグ */ function debug_postEndpoint() { const url = PropertiesService.getScriptProperties().getProperty('EMBED_JSON_ENDPOINT'); const payload = JSON.stringify({ _test: true, time: new Date().toISOString() }); const res = UrlFetchApp.fetch(url, { method: 'post', contentType: 'application/json', headers: { 'Accept': 'application/json', 'User-Agent': 'AppsScript/UrlFetch' }, payload, muteHttpExceptions: true, }); Logger.log('URL : %s', url); Logger.log('STATUS : %s', res.getResponseCode()); Logger.log('RESP : %s', res.getContentText()); }