您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Automatically translate Wistia video subtitles to Chinese using Gemini 2.5 Flash
// ==UserScript== // @name Wistia Subtitle Translator // @namespace http://tampermonkey.net/ // @version 3.3 // @description Automatically translate Wistia video subtitles to Chinese using Gemini 2.5 Flash // @author liby // @license MIT // @match https://learn.getdbt.com/* // @match https://fast.wistia.com/embed/captions/*.json // @grant GM_setValue // @grant GM_getValue // @grant GM_xmlhttpRequest // @run-at document-start // ==/UserScript== (function() { 'use strict'; // Configuration const CONFIG = { API_KEY: GM_getValue('gemini_api_key', ''), API_URL: 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-05-20:generateContent', BATCH_SIZE: 20, TIMEOUTS: { WAIT_TRANSLATION: 1000, WAIT_CAPTIONS: 2000, CAPTION_CHECK: 500, PLAYBACK_DELAY: 300, MAX_RETRIES: 10 } }; const SELECTORS = { WISTIA_CONTAINERS: '.w-chrome, [id*="wistia_chrome"], .wistia_embed', CAPTION_BUTTON: '[data-handle="captionsButton"] button', CAPTION_ITEMS: '[data-handle*="captions-menu-item"], [data-handle="captionsMenuItem"], .w-captions-menu-item', PLAY_BUTTONS: [ '[data-handle="smallPlayButton"] button', '.w-big-play-button' ] }; // Initialize API key if (!CONFIG.API_KEY) { const apiKey = prompt('Please enter your Gemini API key:'); if (apiKey) { GM_setValue('gemini_api_key', apiKey); location.reload(); return; } } // Global state const translationCache = new Map(); const playedVideos = new Set(); let translationComplete = false; // Utility functions const findElement = (container, selectors) => { for (const selector of selectors) { const element = container.querySelector(selector); if (element) return element; } return null; }; const createPrompt = (textArray) => `<agent> <name>Professional Subtitle Translator</name> <role> You are a specialized translator for technical video subtitles, focusing on data engineering and analytics content. </role> <instructions> - KEEP the original English text EXACTLY as provided - ADD Chinese translation separated by "|||" after the English text - PRESERVE technical terms like dbt, SQL, Python, API, etc. in English - ADD a space between Chinese characters and English words/numbers in the translation - ENSURE natural, fluent Chinese that accurately conveys the meaning - MAINTAIN the same JSON array structure - RETURN ONLY the JSON array, no additional text </instructions> <caption-format> "Original English text|||中文翻译" </caption-format> </agent> EXAMPLE: Input: ["Hi everyone.", "So we're talking about who is an analytics engineer?", "Where did this role come from, why is it necessary for data teams."] Output: ["Hi everyone.|||大家好。", "So we're talking about who is an analytics engineer?|||那么我们来谈谈谁是分析工程师?", "Where did this role come from, why is it necessary for data teams.|||这个角色从何而来,为什么它对数据团队是必要的。"] TEXT TO TRANSLATE: ${JSON.stringify(textArray)} Return ONLY the JSON array:`; // Translation function async function translateWithGemini(textArray) { const cacheKey = JSON.stringify(textArray); if (translationCache.has(cacheKey)) { return translationCache.get(cacheKey); } return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'POST', url: `${CONFIG.API_URL}?key=${CONFIG.API_KEY}`, headers: { 'Content-Type': 'application/json' }, data: JSON.stringify({ contents: [{ parts: [{ text: createPrompt(textArray) }] }], generationConfig: { temperature: 0.2, maxOutputTokens: 16384 } }), onload: (response) => { try { const data = JSON.parse(response.responseText); // More flexible response validation const translatedText = data?.candidates?.[0]?.content?.parts?.[0]?.text; if (!translatedText) { console.error('[Wistia Translator] No translation text in API response'); resolve(textArray); return; } const jsonMatch = translatedText.match(/\[.*\]/s); if (jsonMatch) { const translatedArray = JSON.parse(jsonMatch[0]); // Replace separator with newlines for display const processedArray = translatedArray.map(text => text.replace(/\|\|\|/g, '\n') ); translationCache.set(cacheKey, processedArray); resolve(processedArray); } else { console.error('[Wistia Translator] No JSON array found in translation response'); resolve(textArray); } } catch (error) { console.error('[Wistia Translator] Translation error:', error); resolve(textArray); } }, onerror: () => resolve(textArray) }); }); } // Process subtitles async function processSubtitles(subtitleData) { if (!subtitleData.captions?.length) return subtitleData; for (const caption of subtitleData.captions) { if (caption.language !== 'eng' || !caption.hash?.lines) continue; const { allTexts, lineIndexes } = caption.hash.lines.reduce((acc, line, i) => { if (line.text?.length) { const combinedText = line.text.join(' ').trim(); if (combinedText) { acc.allTexts.push(combinedText); acc.lineIndexes.push(i); } } return acc; }, { allTexts: [], lineIndexes: [] }); if (!allTexts.length) continue; try { const translatedTexts = []; for (let i = 0; i < allTexts.length; i += CONFIG.BATCH_SIZE) { const batch = allTexts.slice(i, i + CONFIG.BATCH_SIZE); const batchTranslated = await translateWithGemini(batch); translatedTexts.push(...batchTranslated); } lineIndexes.forEach((lineIndex, i) => { if (i < translatedTexts.length) { caption.hash.lines[lineIndex].text = [translatedTexts[i]]; } }); } catch (error) { console.error('[Wistia Translator] Translation error:', error); } } return subtitleData; } // Video control functions const isValidVideoContainer = (container) => { return container.querySelector('video') && (container.classList.contains('w-chrome') || (container.id && container.id.includes('wistia_chrome'))); }; function playVideo(container) { const playButton = findElement(container, SELECTORS.PLAY_BUTTONS); const video = container.querySelector('video'); if (playButton) { playButton.click(); } else if (video) { video.play(); } } function enableCaptionsAndPlay(container) { if (!container.id || playedVideos.has(container.id)) return; playedVideos.add(container.id); let retryCount = 0; const waitForCaptionsReady = () => { const captionButton = container.querySelector(SELECTORS.CAPTION_BUTTON); if (!captionButton) { playVideo(container); return; } // Check if captions are already available const existingCaptionOptions = container.querySelectorAll(SELECTORS.CAPTION_ITEMS); if (existingCaptionOptions.length > 0) { setTimeout(() => playVideo(container), CONFIG.TIMEOUTS.PLAYBACK_DELAY); return; } // Open caption menu to check for options captionButton.click(); setTimeout(() => { const captionOptions = container.querySelectorAll(SELECTORS.CAPTION_ITEMS); captionButton.click(); // Close menu if (captionOptions.length > 0) { setTimeout(() => playVideo(container), CONFIG.TIMEOUTS.PLAYBACK_DELAY); } else if (retryCount < CONFIG.TIMEOUTS.MAX_RETRIES) { retryCount++; setTimeout(waitForCaptionsReady, CONFIG.TIMEOUTS.WAIT_CAPTIONS); } else { playVideo(container); } }, CONFIG.TIMEOUTS.CAPTION_CHECK); }; setTimeout(waitForCaptionsReady, CONFIG.TIMEOUTS.WAIT_TRANSLATION); } function setupVideoControl() { const processedVideos = new Set(); const handleVideo = (container) => { if (!container.id || processedVideos.has(container.id) || !isValidVideoContainer(container)) { return; } processedVideos.add(container.id); const video = container.querySelector('video'); if (video && !video.paused) video.pause(); const waitAndPlay = () => { if (translationComplete) { enableCaptionsAndPlay(container); } else { setTimeout(waitAndPlay, CONFIG.TIMEOUTS.WAIT_TRANSLATION); } }; setTimeout(waitAndPlay, CONFIG.TIMEOUTS.WAIT_CAPTIONS); }; // Monitor for new videos new MutationObserver(mutations => { mutations.forEach(mutation => { mutation.addedNodes.forEach(node => { if (node.nodeType === 1 && node.querySelectorAll) { [node, ...node.querySelectorAll(SELECTORS.WISTIA_CONTAINERS)] .filter(isValidVideoContainer) .forEach(container => setTimeout(() => handleVideo(container), 500)); } }); }); }).observe(document.body, { childList: true, subtree: true }); // Handle existing videos setTimeout(() => { document.querySelectorAll(SELECTORS.WISTIA_CONTAINERS).forEach(handleVideo); }, CONFIG.TIMEOUTS.WAIT_CAPTIONS); } // Settings UI function addSettingsButton() { if (document.querySelector('#subtitle-translator-settings')) return; const button = document.createElement('button'); Object.assign(button, { id: 'subtitle-translator-settings', innerHTML: '🌐 字幕翻译设置', onclick: () => { const action = prompt('Choose action:\n1 - Update API key\n2 - Clear cache', '1'); if (action === '1') { const newKey = prompt('Enter your Gemini API key:', CONFIG.API_KEY); if (newKey !== null) { GM_setValue('gemini_api_key', newKey); alert('API key updated! Please refresh the page.'); } } else if (action === '2') { translationCache.clear(); alert('Translation cache cleared!'); } } }); button.style.cssText = ` position: fixed; top: 10px; right: 10px; z-index: 10000; padding: 8px 12px; background: #007cba; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; `; document.body.appendChild(button); } // Add CSS for multiline captions function addCaptionStyles() { if (document.querySelector('#wistia-caption-styles')) return; const style = document.createElement('style'); style.id = 'wistia-caption-styles'; style.textContent = ` .w-captions-line span { white-space: pre-line !important; display: inline-block !important; text-align: center !important; line-height: 1.2 !important; } `; document.head.appendChild(style); } // Intercept subtitle requests const originalResponseJson = Response.prototype.json; Response.prototype.json = async function() { if (this.url?.includes('fast.wistia.com/embed/captions/')) { try { const originalData = await originalResponseJson.call(this); const processedData = await processSubtitles(originalData); translationComplete = true; // Add CSS styles for multiline captions addCaptionStyles(); // Trigger auto-play for all valid video containers setTimeout(() => { document.querySelectorAll(SELECTORS.WISTIA_CONTAINERS).forEach(container => { const video = container.querySelector('video'); if (video && video.paused && isValidVideoContainer(container)) { enableCaptionsAndPlay(container); } }); }, 500); // Show success indicator if (processedData.captions?.length) { setTimeout(() => { const indicator = document.createElement('div'); indicator.innerHTML = '✅ 字幕已翻译'; indicator.style.cssText = ` position: fixed; top: 50px; right: 10px; z-index: 10001; background: #4CAF50; color: white; padding: 5px 10px; border-radius: 4px; font-size: 12px; `; document.body.appendChild(indicator); setTimeout(() => indicator.remove(), 3000); }, 1000); } return processedData; } catch (error) { console.error('[Wistia Translator] Error processing subtitles:', error); translationComplete = true; return originalResponseJson.call(this); } } return originalResponseJson.call(this); }; // Initialize const init = () => { addSettingsButton(); addCaptionStyles(); setupVideoControl(); }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();