您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
輔助將 Twitch VOD 匯出到 YouTube,自動填寫日期和遊戲標題(保留原有描述),追蹤已處理影片(可設快取時效),並支援自動化順序匯出、多頁處理、清理快取、單獨清除影片快取及拖動控制面板。新增可客製化的 YouTube 匯出資訊模板及描述附加選項。根據 URL 控制面板顯隱,使用 JS 過濾改進卡片選擇器。
// ==UserScript== // @name Twitch VOD 自動匯出助手 By Minidoracat // @namespace https://github.com/Minidoracat // @version 0.9.5 // @description 輔助將 Twitch VOD 匯出到 YouTube,自動填寫日期和遊戲標題(保留原有描述),追蹤已處理影片(可設快取時效),並支援自動化順序匯出、多頁處理、清理快取、單獨清除影片快取及拖動控制面板。新增可客製化的 YouTube 匯出資訊模板及描述附加選項。根據 URL 控制面板顯隱,使用 JS 過濾改進卡片選擇器。 // @author Minidoracat // @homepageURL https://github.com/Minidoracat/twitch-vod-auto-exporter // @supportURL https://github.com/Minidoracat/twitch-vod-auto-exporter/issues // @icon https://www.google.com/s2/favicons?sz=64&domain=twitch.tv // @match https://dashboard.twitch.tv/u/* // @grant GM.setValue // @grant GM.getValue // @grant GM.addStyle // @run-at document-idle // ==/UserScript== (function () { 'use strict'; console.log('Twitch VOD 自動匯出助手腳本已載入! (v0.9.5)'); // --- 使用者可配置參數 --- const CACHE_EXPIRY_HOURS = 0; // 快取過期時間(小時),0 表示永不過期 const DEBOUNCE_SCAN_DELAY = 500; // DOM 掃描延遲(毫秒),用於延遲處理頻繁的 DOM 變動事件 const INITIAL_SCRIPT_DELAY = 2000; // 腳本整體初始化延遲(毫秒),等待頁面初步加載完成 const SCAN_RETRY_COUNT = 10; // 初始掃描影片卡片的重試次數 const SCAN_RETRY_DELAY = 1000; // 每次重試掃描影片卡片的間隔(毫秒) // YouTube 匯出資訊客製化模板 // 可用變數: // {originalTitle} - Twitch 影片的原始標題 // {videoDate} - 影片發布日期 (格式 YYYY-MM-DD) // {videoRawDate} - 影片發布日期 (Twitch 原始格式,例如 "2025年4月25日") // {gameName} - 影片的遊戲/分類名稱 // {gameNameNoSpace} - 影片的遊戲/分類名稱 (無空格) // {existingDescription} - YouTube 描述欄位中已有的內容 (如果 YOUTUBE_APPEND_TO_EXISTING_DESCRIPTION 為 true 且原有描述存在) const YOUTUBE_TITLE_TEMPLATE = "{originalTitle} [{videoDate}]"; // YouTube 影片標題模板 const YOUTUBE_DESCRIPTION_PREPEND_TEXT = ""; // 加在 YouTube 原有描述之前的文字 (僅當 YOUTUBE_APPEND_TO_EXISTING_DESCRIPTION 為 true 且原有描述存在時有效) const YOUTUBE_DESCRIPTION_APPEND_TEMPLATE = "剪輯日期:{videoRawDate}\n#{gameName}\n#{gameNameNoSpace}\n"; // 要附加到 YouTube 描述的內容模板,或當不附加時作為完整描述 const YOUTUBE_TAGS_TEMPLATE = "{gameName}"; // YouTube 影片標籤模板 const YOUTUBE_VISIBILITY = "private"; // YouTube 影片能見度,可選 "private" (私人) 或 "public" (公開) const YOUTUBE_APPEND_TO_EXISTING_DESCRIPTION = true; // 是否將生成的描述附加到 YouTube 現有描述之後 (true),或完全覆蓋 (false) const PROCESSED_VIDEOS_STORAGE_KEY = 'twitch_youtube_exporter_processed_videos_v3'; let isAutoExporting = false; let autoExportQueue = []; let currentExportPromise = null; const controlPanel = document.createElement('div'); controlPanel.id = 'auto-exporter-control-panel'; controlPanel.style.display = 'none'; const dragHandle = document.createElement('div'); dragHandle.id = 'auto-exporter-drag-handle'; dragHandle.textContent = '匯出控制面板 (可拖動)'; const startButton = document.createElement('button'); startButton.id = 'start-auto-export-button'; startButton.textContent = '開始自動匯出'; const stopButton = document.createElement('button'); stopButton.id = 'stop-auto-export-button'; stopButton.textContent = '停止自動匯出'; stopButton.disabled = true; const clearCacheButton = document.createElement('button'); clearCacheButton.id = 'clear-cache-button'; clearCacheButton.textContent = '清理已處理快取'; const statusDisplay = document.createElement('div'); statusDisplay.id = 'auto-exporter-status'; statusDisplay.textContent = '狀態:待命中'; controlPanel.appendChild(dragHandle); controlPanel.appendChild(startButton); controlPanel.appendChild(stopButton); controlPanel.appendChild(clearCacheButton); controlPanel.appendChild(statusDisplay); const videoProducerRegex = /^https:\/\/dashboard\.twitch\.tv\/u\/[^\/]+\/content\/video-producer/; let panelInitialized = false; let initialScanComplete = false; function getVideoCardElements() { const allLinks = Array.from(document.querySelectorAll('a')); return allLinks.filter(link => link.href && link.href.includes('/content/video-producer/edit/')); } function tryScanWithRetries(maxRetries = SCAN_RETRY_COUNT, delayBetweenRetries = SCAN_RETRY_DELAY) { let attempt = 0; console.log(`tryScanWithRetries: 準備開始嘗試掃描,最多 ${maxRetries} 次,間隔 ${delayBetweenRetries}ms。`); function scan() { attempt++; const currentTime = new Date().toLocaleTimeString(); console.log(`tryScanWithRetries: 第 ${attempt}/${maxRetries} 次嘗試掃描 (${currentTime})。`); if (typeof initialScanAndAttachListeners === 'function') { initialScanAndAttachListeners(true, (foundCards) => { if (foundCards > 0) { console.log(`tryScanWithRetries: 第 ${attempt} 次嘗試成功找到 ${foundCards} 個卡片,停止重試。`); initialScanComplete = true; return; } else { console.log(`tryScanWithRetries: 第 ${attempt} 次嘗試未找到卡片。`); if (attempt < maxRetries) { console.log(`tryScanWithRetries: ${delayBetweenRetries}ms 後進行下一次嘗試。`); setTimeout(scan, delayBetweenRetries); } else { console.warn(`tryScanWithRetries: 已達到最大嘗試次數 ${maxRetries},仍未找到影片卡片。`); initialScanComplete = true; } } }); } else { console.error("tryScanWithRetries: initialScanAndAttachListeners 函數未定義!"); if (attempt < maxRetries) setTimeout(scan, delayBetweenRetries); else initialScanComplete = true; } } scan(); } function checkUrlAndTogglePanel() { if (!panelInitialized || !controlPanel || (document.body && !document.body.contains(controlPanel))) { return; } const currentUrl = window.location.href; const isOnVideoProducerPage = videoProducerRegex.test(currentUrl); if (isOnVideoProducerPage) { if (controlPanel.style.display === 'none') { console.log("URL 符合 video-producer 且面板隱藏,顯示面板並準備掃描。"); controlPanel.style.display = 'flex'; initialScanComplete = false; tryScanWithRetries(); } else if (!initialScanComplete) { console.log("URL 符合 video-producer,面板已顯示,但初始掃描未完成,嘗試再次掃描。"); tryScanWithRetries(); } } else { if (controlPanel.style.display !== 'none') { console.log("URL 不符合 video-producer 且面板顯示,隱藏面板。"); controlPanel.style.display = 'none'; } initialScanComplete = false; } } const originalPushState = history.pushState; history.pushState = function(...args) { const r = originalPushState.apply(this, args); setTimeout(checkUrlAndTogglePanel, 50); return r; }; const originalReplaceState = history.replaceState; history.replaceState = function(...args) { const r = originalReplaceState.apply(this, args); setTimeout(checkUrlAndTogglePanel, 50); return r; }; window.addEventListener('popstate', () => { setTimeout(checkUrlAndTogglePanel, 50); }); function initializeControlPanelDOM() { if (document.body && !document.body.contains(controlPanel)) { document.body.appendChild(controlPanel); panelInitialized = true; checkUrlAndTogglePanel(); } else if (document.body && document.body.contains(controlPanel)) { panelInitialized = true; checkUrlAndTogglePanel(); } } GM.addStyle(` #auto-exporter-control-panel { position: fixed; bottom: 20px; right: 20px; background-color: #2c2c2e; flex-direction: column; padding: 0; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.4); z-index: 9999; border: 1px solid #444; } #auto-exporter-drag-handle { padding: 8px 15px; background-color: #3a3a3d; color: #f0f0f0; cursor: move; text-align: center; font-size: 13px; border-top-left-radius: 7px; border-top-right-radius: 7px; border-bottom: 1px solid #444; user-select: none; } #auto-exporter-control-panel button { background-color: #772ce8; color: white; border: none; padding: 10px 15px; border-radius: 5px; cursor: pointer; font-size: 14px; transition: background-color 0.2s ease; margin: 8px 15px 0px 15px; } #auto-exporter-control-panel button#clear-cache-button { background-color: #e91e63; } #auto-exporter-control-panel button#clear-cache-button:hover { background-color: #c2185b; } #auto-exporter-control-panel button:hover { background-color: #5c1f99; } #auto-exporter-control-panel button:disabled { background-color: #555; cursor: not-allowed; } #auto-exporter-status { color: #e0e0e0; font-size: 12px; text-align: center; padding: 10px 15px 12px 15px; } .video-status-label-minidoracat { position: absolute !important; top: 8px !important; right: 8px !important; padding: 2px 6px !important; font-size: 10px !important; font-weight: bold !important; border-radius: 3px !important; z-index: 1001 !important; color: white; text-shadow: 0 0 2px rgba(0,0,0,0.7); } .clear-single-cache-button-minidoracat { position: absolute !important; top: 8px !important; right: 65px !important; padding: 1px 5px !important; font-size: 10px !important; font-weight: bold !important; line-height: 1.2 !important; border-radius: 3px !important; z-index: 1002 !important; background-color: #e74c3c; color: white; border: none; cursor: pointer; box-shadow: 0 1px 2px rgba(0,0,0,0.2); display: none; } .clear-single-cache-button-minidoracat:hover { background-color: #c0392b; } a[href*="/content/video-producer/edit/"] div[data-target="video-card"] { position: relative !important; } `); let isDragging = false; let initialMouseX, initialMouseY, initialPanelLeft, initialPanelTop; dragHandle.addEventListener('mousedown', (e) => { if (e.button !== 0) return; isDragging = true; initialMouseX = e.clientX; initialMouseY = e.clientY; const rect = controlPanel.getBoundingClientRect(); controlPanel.style.left = `${rect.left}px`; controlPanel.style.top = `${rect.top}px`; controlPanel.style.right = 'auto'; controlPanel.style.bottom = 'auto'; initialPanelLeft = controlPanel.offsetLeft; initialPanelTop = controlPanel.offsetTop; dragHandle.style.cursor = 'grabbing'; document.body.style.userSelect = 'none'; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); e.preventDefault(); }); function onMouseMove(e) { if (!isDragging) return; e.preventDefault(); const dx = e.clientX - initialMouseX; const dy = e.clientY - initialMouseY; let newLeft = initialPanelLeft + dx; let newTop = initialPanelTop + dy; if (newLeft < 0) newLeft = 0; if (newTop < 0) newTop = 0; if (newLeft + controlPanel.offsetWidth > window.innerWidth) newLeft = window.innerWidth - controlPanel.offsetWidth; if (newTop + controlPanel.offsetHeight > window.innerHeight) newTop = window.innerHeight - controlPanel.offsetHeight; controlPanel.style.left = `${newLeft}px`; controlPanel.style.top = `${newTop}px`; } function onMouseUp() { if (!isDragging) return; isDragging = false; dragHandle.style.cursor = 'move'; document.body.style.userSelect = ''; document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); } function debounce(func, delay) { let timeout; return function (...args) { const context = this; clearTimeout(timeout); timeout = setTimeout(() => func.apply(context, args), delay); }; } async function getProcessedVideosWithExpiryCheck() { const d = await GM.getValue(PROCESSED_VIDEOS_STORAGE_KEY, '{}'); let pd; try { pd = JSON.parse(d); } catch (e) { return {}; } if (CACHE_EXPIRY_HOURS <= 0) { const v = {}; for (const k in pd) if (pd[k] === true || typeof pd[k] === 'number') v[k] = pd[k]; return v; } const n = Date.now(), exp = CACHE_EXPIRY_HOURS * 3600000, vv = {}; for (const k in pd) { const t = pd[k]; if (typeof t === 'number' && (n - t < exp)) vv[k] = t; else if (pd[k] === true && CACHE_EXPIRY_HOURS <= 0) vv[k] = true; } return vv; } async function saveProcessedVideo(videoId) { let p = await getProcessedVideosWithExpiryCheck(); p[videoId] = Date.now(); await GM.setValue(PROCESSED_VIDEOS_STORAGE_KEY, JSON.stringify(p)); console.log(`影片 ${videoId} 已標記處理。`); } async function clearProcessedVideoCache() { if (confirm("確定清除所有已處理影片的快取嗎?")) { await GM.setValue(PROCESSED_VIDEOS_STORAGE_KEY, '{}'); statusDisplay.textContent = '狀態:快取已清除。'; if (typeof initialScanAndAttachListeners === 'function') await initialScanAndAttachListeners(true, ()=>{}); alert("快取已清除!"); } } clearCacheButton.addEventListener('click', clearProcessedVideoCache); function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } function isElementVisible(el) { if (!el) return false; const s = window.getComputedStyle(el); return s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0' && (el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0); } function waitForElement(selector, timeout = 10000, parent = document) { return new Promise((resolve, reject) => { const iT = 100; let eT = 0; const i = setInterval(() => { const el = parent.querySelector(selector); if (el && isElementVisible(el)) { clearInterval(i); resolve(el); } else if (eT >= timeout) { clearInterval(i); reject(new Error(`E ${selector} not found`)); } eT += iT; }, iT); }); } function dispatchEventOnElement(element, eventName) { if (element) { try { element.dispatchEvent(new Event(eventName, { bubbles: true, cancelable: true })); } catch (e) {} } } function setNativeValue(element, value) { const vs = Object.getOwnPropertyDescriptor(element, 'value')?.set; const p = Object.getPrototypeOf(element); const pvs = Object.getOwnPropertyDescriptor(p, 'value')?.set; if (vs && vs !== pvs) pvs.call(element, value); else element.value = value; } async function clearSingleVideoCache(videoId, videoCardElement) { let p = await getProcessedVideosWithExpiryCheck(); if (p.hasOwnProperty(videoId)) { delete p[videoId]; await GM.setValue(PROCESSED_VIDEOS_STORAGE_KEY, JSON.stringify(p)); statusDisplay.textContent = `狀態:影片 ${videoId} 快取已清除。`; if (videoCardElement && typeof addStatusLabel === 'function') addStatusLabel(videoCardElement, videoId, false); } } function addStatusLabel(videoCardElement, videoId, isProcessed) { const iCD = videoCardElement.querySelector('div[data-target="video-card"]'); if (!iCD) return; let sL = iCD.querySelector('.video-status-label-minidoracat'); if (!sL) { sL = document.createElement('div'); sL.classList.add('video-status-label-minidoracat'); iCD.appendChild(sL); } sL.textContent = isProcessed ? '已處理' : '未處理'; sL.style.backgroundColor = isProcessed ? 'green' : 'orange'; sL.style.color = isProcessed ? 'white' : 'black'; let cB = iCD.querySelector(`.clear-single-cache-button-minidoracat[data-videoid="${videoId}"]`); if (!cB) { cB = document.createElement('button'); cB.classList.add('clear-single-cache-button-minidoracat'); cB.dataset.videoid = videoId; cB.title = `清除影片 ${videoId} 快取`; cB.textContent = '✕'; iCD.appendChild(cB); cB.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); if (confirm(`確定清除影片 ${videoId} 快取?`)) await clearSingleVideoCache(videoId, videoCardElement); }); } cB.style.display = isProcessed ? 'inline-block' : 'none'; } async function processSingleVideoForAutoExport(videoCardElement) { if (!isAutoExporting) return Promise.reject("Auto export stopped"); const vIdEl = videoCardElement.querySelector('div[data-video-id]'); const vId = vIdEl?.dataset.videoId; if (!vId) return; statusDisplay.textContent = `狀態:處理影片 ${vId}...`; const oTitle = videoCardElement.querySelector('h5.CoreText-sc-1txzju1-0.crZNHn')?.textContent.trim() || '無標題'; const rDate = videoCardElement.querySelector('div[data-test-selector="video-card-publish-date-selector"]')?.textContent.trim() || '未知日期'; const gName = videoCardElement.querySelector('p.CoreText-sc-1txzju1-0.hufCyP > a > span')?.textContent.trim() || '未知遊戲'; try { const cT = videoCardElement.querySelector('div[data-a-target="video-card-container"]') || videoCardElement; if (!cT) return; cT.click(); await delay(2500); const eMSel = 'div.edit-video-properties-modal__content'; const eM = await waitForElement(eMSel, 8000); const exBtnEM = await waitForElement('button[data-test-selector="export-selector"]', 3500, eM); exBtnEM.click(); await delay(2200); const yM = await waitForElement('div.export-youtube-modal', 8000); const tI = yM.querySelector('input#ye-title'); const dA = yM.querySelector('textarea#ye-description'); const tagsI = yM.querySelector('input#ye-tags'); const sExBtn = yM.querySelector('button[data-test-selector="save"]'); if (!tI || !dA || !tagsI || !sExBtn) { yM.querySelector('button[aria-label="關閉強制回應"]')?.click(); document.querySelector(`${eMSel} button[data-test-selector="CANCEL_TEST_SELECTOR"], ${eMSel} button[aria-label*="關閉"]`)?.click(); return; } let fVD = ''; if (rDate !== '未知日期') { const dM = rDate.match(/(\d{4})年(\d{1,2})月(\d{1,2})日/); if (dM) fVD = `${dM[1]}-${dM[2].padStart(2, '0')}-${dM[3].padStart(2, '0')}`; } const dD = fVD || rDate; const tVs = { originalTitle: oTitle, videoDate: dD, videoRawDate: rDate, gameName: gName, gameNameNoSpace: gName.replace(/\s+/g, '') }; const fT = YOUTUBE_TITLE_TEMPLATE.replace(/{originalTitle}/g, tVs.originalTitle).replace(/{videoDate}/g, tVs.videoDate).replace(/{videoRawDate}/g, tVs.videoRawDate); let nDC = YOUTUBE_DESCRIPTION_APPEND_TEMPLATE.replace(/{videoRawDate}/g, tVs.videoRawDate).replace(/{gameName}/g, tVs.gameName).replace(/{gameNameNoSpace}/g, tVs.gameNameNoSpace); let fD; const eD = dA.value.trim(); if (YOUTUBE_APPEND_TO_EXISTING_DESCRIPTION) { fD = YOUTUBE_DESCRIPTION_PREPEND_TEXT; if (eD) fD += eD + "\n" + nDC; else fD += nDC; } else fD = nDC; const fTags = YOUTUBE_TAGS_TEMPLATE.replace(/{gameName}/g, tVs.gameName).replace(/{gameNameNoSpace}/g, tVs.gameNameNoSpace); const fs = [{ el: tI, v: fT, n: "標題" }, { el: dA, v: fD, n: "描述" }, { el: tagsI, v: fTags, n: "標籤" }]; for (const f of fs) { if (f.el) { f.el.focus(); await delay(100); setNativeValue(f.el, f.v); dispatchEventOnElement(f.el, 'input'); await delay(100); dispatchEventOnElement(f.el, 'change'); await delay(100); f.el.blur(); await delay(100); } } const tRV = YOUTUBE_VISIBILITY === "private" ? "true" : "false"; const vR = yM.querySelector(`input[type="radio"][value="${tRV}"]`); if (vR && !vR.checked) { vR.click(); await delay(500); } sExBtn.click(); await saveProcessedVideo(vId); if (typeof addStatusLabel === 'function') addStatusLabel(videoCardElement, vId, true); await delay(1200); yM.querySelector('button[aria-label="關閉強制回應"]')?.click(); await delay(200); document.querySelector(`${eMSel} button[data-test-selector="CANCEL_TEST_SELECTOR"], ${eMSel} button[aria-label*="關閉"]`)?.click(); await delay(200); } catch (error) { statusDisplay.textContent = `狀態:處理影片 ${vId} 失敗。`; document.querySelector('div.export-youtube-modal button[aria-label="關閉強制回應"]')?.click(); await delay(200); document.querySelector(`div.edit-video-properties-modal__content button[data-test-selector="CANCEL_TEST_SELECTOR"]`)?.click(); } } async function startAutoExport() { if (isAutoExporting) return; isAutoExporting = true; startButton.disabled = true; stopButton.disabled = false; statusDisplay.textContent = '狀態:開始自動匯出多頁...'; let cP = 1; let hNP = true; do { if (!isAutoExporting) break; statusDisplay.textContent = `狀態:掃描第 ${cP} 頁...`; await delay(1500); let foundOnPage = 0; await new Promise(resolve => initialScanAndAttachListeners(true, (count) => { foundOnPage = count; resolve(); })); const pVids = await getProcessedVideosWithExpiryCheck(); const cards = getVideoCardElements(); const toProc = cards.filter(c => { const id = c.querySelector('div[data-video-id]')?.dataset.videoId; return id && !pVids[id]; }); if (toProc.length === 0) { statusDisplay.textContent = `狀態:第 ${cP} 頁無未處理影片。`; } else { statusDisplay.textContent = `狀態:第 ${cP} 頁找到 ${toProc.length} 個影片。`; for (let i = 0; i < toProc.length; i++) { if (!isAutoExporting) break; const card = toProc[i]; const id = card.querySelector('div[data-video-id]')?.dataset.videoId || '未知ID'; try { currentExportPromise = processSingleVideoForAutoExport(card); await currentExportPromise; currentExportPromise = null; if (isAutoExporting) await delay(4500 + Math.random() * 2000); } catch (e) { if (String(e).includes("Auto export stopped")) break; } } if (!isAutoExporting) break; } const nPB = document.querySelector('button[aria-label="下一頁"]:not([disabled])'); if (nPB && isAutoExporting) { statusDisplay.textContent = `狀態:第 ${cP} 頁處理完畢,前往下一頁...`; nPB.click(); cP++; hNP = true; await delay(5000); } else { hNP = false; if (isAutoExporting) statusDisplay.textContent = '狀態:已到達最後一頁或下一頁按鈕不可用。'; } } while (isAutoExporting && hNP); if (isAutoExporting) statusDisplay.textContent = '狀態:所有頁面影片已處理完畢!'; stopAutoExport(false); } function stopAutoExport(manual = true) { isAutoExporting = false; startButton.disabled = false; stopButton.disabled = true; if (manual) statusDisplay.textContent = '狀態:自動匯出已停止。'; else if (statusDisplay.textContent !== '狀態:所有頁面影片已處理完畢!') statusDisplay.textContent = '狀態:自動匯出已完成或停止。'; if (currentExportPromise) {} autoExportQueue = []; } startButton.addEventListener('click', startAutoExport); stopButton.addEventListener('click', () => stopAutoExport(true)); async function initialScanAndAttachListeners(forceUpdate = false, callback = () => {}) { if (controlPanel.style.display === 'none' && !forceUpdate) { callback(0); return; } const processedVideos = await getProcessedVideosWithExpiryCheck(); const allVideoCardElements = getVideoCardElements(); // 使用新的 JS 過濾方法 console.log(`initialScanAndAttachListeners: 使用 JS 過濾找到 ${allVideoCardElements.length} 個影片卡片元素。`); if (allVideoCardElements.length === 0 && videoProducerRegex.test(window.location.href)) { console.warn("initialScanAndAttachListeners: 在 video-producer 頁面未找到任何影片卡片 (即使使用 JS 過濾)。開始詳細調試..."); const allLinks = document.querySelectorAll('a'); let foundMatchByHrefIncludes = 0; allLinks.forEach(link => { if(link.href && link.href.includes('/content/video-producer/edit/')) foundMatchByHrefIncludes++; }); console.log(`initialScanAndAttachListeners (調試): 頁面中所有 <a> 標籤數量: ${allLinks.length}`); console.log(`initialScanAndAttachListeners (調試): 透過 href.includes('/content/video-producer/edit/') 找到 ${foundMatchByHrefIncludes} 個 <a> 標籤。`); const allDivsWithVideoId = document.querySelectorAll('div[data-video-id]'); console.log(`initialScanAndAttachListeners (調試): 頁面中所有 div[data-video-id] 數量: ${allDivsWithVideoId.length}`); const currentUsername = window.location.pathname.split('/u/')[1]?.split('/')[0]; if (currentUsername) { const specificUserLinkSelector = `a[href*="/u/${currentUsername}/content/video-producer/edit/"]`; const specificUserLinks = document.querySelectorAll(specificUserLinkSelector); console.log(`initialScanAndAttachListeners (調試): 使用特定用戶名 CSS 選擇器 '${specificUserLinkSelector}' 找到 ${specificUserLinks.length} 個元素。`); } else { console.log(`initialScanAndAttachListeners (調試): 未能從 URL (${window.location.pathname}) 提取用戶名。`); } } allVideoCardElements.forEach((cardElement) => { const videoId = cardElement.querySelector('div[data-video-id]')?.dataset.videoId; if (videoId) { if (forceUpdate || !cardElement.dataset.scriptProcessed) { if (typeof addStatusLabel === 'function') addStatusLabel(cardElement, videoId, !!processedVideos[videoId]); } } cardElement.dataset.scriptProcessed = 'true'; }); callback(allVideoCardElements.length); } const debouncedScan = debounce(async () => { if (controlPanel.style.display !== 'none') { if (typeof initialScanAndAttachListeners === 'function') await initialScanAndAttachListeners(true, ()=>{}); } }, DEBOUNCE_SCAN_DELAY); const observer = new MutationObserver(async (mutationsList) => { if (controlPanel.style.display === 'none') return; let significantChange = false; for (const mutation of mutationsList) { if (mutation.type === 'childList' && (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0)) { const nodes = [...mutation.addedNodes, ...mutation.removedNodes]; if (nodes.some(node => { if (node.nodeType !== Node.ELEMENT_NODE) return false; // 直接檢查節點本身是否是我們感興趣的連結 if (node.matches && node.matches('a') && node.href && node.href.includes('/content/video-producer/edit/')) return true; // 或者檢查節點內部是否包含我們感興趣的連結 (針對容器節點被添加/移除的情況) if (node.querySelector && node.querySelector('a[href*="/content/video-producer/edit/"]')) return true; return false; })) { significantChange = true; break; } } } if (significantChange && !isAutoExporting) debouncedScan(); }); function initializeObserverAndScan() { let targetNodeForObserver; const mainContentArea = document.querySelector('main div[class*="video-producer-page"], main div[class*="producer-content-wrapper"]'); const mainElement = document.querySelector('main'); if (mainContentArea) targetNodeForObserver = mainContentArea; else if (mainElement) targetNodeForObserver = mainElement; else { targetNodeForObserver = document.body; console.warn("initializeObserverAndScan: 未找到 mainContentArea 或 main 元素,MutationObserver 將觀察 document.body。"); } console.log('initializeObserverAndScan: 最終觀察器目標:', targetNodeForObserver.id || targetNodeForObserver.className || targetNodeForObserver.tagName); observer.observe(targetNodeForObserver, { childList: true, subtree: true }); } function initializeScript() { console.log("initializeScript: 開始初始化腳本..."); initializeControlPanelDOM(); initializeObserverAndScan(); } if (document.readyState === 'complete' || document.readyState === 'interactive') { setTimeout(initializeScript, INITIAL_SCRIPT_DELAY); } else { window.addEventListener('load', () => { setTimeout(initializeScript, INITIAL_SCRIPT_DELAY); }); } })();