- // ==UserScript==
- // @name Youtube 双语字幕版
- // @version 1.4.0
- // @author LR
- // @license MIT
- // @description YouTube双语字幕,任何语言翻译成中文或用户选择的目标语言。支持YouTube翻译和谷歌翻译,自动切换。支持移动端和桌面端,适配Via浏览器。
- // @match *://www.youtube.com/*
- // @match *://m.youtube.com/*
- // @require https://unpkg.com/ajax-hook@latest/dist/ajaxhook.min.js
- // @grant GM_registerMenuCommand
- // @run-at document-start
- // @namespace https://gf.qytechs.cn/users/1210499
- // @icon https://www.youtube.com/s/desktop/b9bfb983/img/favicon_32x32.png
- // ==/UserScript==
-
- (function () {
- 'use strict';
-
- // 设置
- const DEFAULT_LANG = 'zh';
- let TARGET_LANG = DEFAULT_LANG;
- const DEFAULT_TRANS_SERVICE = 'youtube';
- let TRANS_SERVICE = DEFAULT_TRANS_SERVICE;
- let LAST_FAILED_SERVICE = null; // 记录上次失败的服务
-
- // 获取用户设置
- function getUserSettings() {
- return {
- lang: localStorage.getItem('dualSubTargetLang') || DEFAULT_LANG,
- service: localStorage.getItem('dualSubTransService') || DEFAULT_TRANS_SERVICE
- };
- }
-
- // 保存用户设置
- function saveUserSettings(lang, service) {
- localStorage.setItem('dualSubTargetLang', lang);
- localStorage.setItem('dualSubTransService', service);
- TARGET_LANG = lang;
- TRANS_SERVICE = service;
- }
-
- // 添加设置菜单
- function addSettingsMenu() {
- if (typeof GM_registerMenuCommand === 'function') {
- GM_registerMenuCommand('设置翻译语言', async () => {
- const userInput = prompt('请输入目标语言的ISO 639-1代码(例如:zh 中文, en 英文, ja 日语):', TARGET_LANG);
- if (userInput) {
- saveUserSettings(userInput.trim(), TRANS_SERVICE);
- alert(`翻译目标语言已设置为:${userInput.trim()}`);
- }
- });
-
- GM_registerMenuCommand('选择翻译引擎', async () => {
- const userInput = prompt('请选择翻译引擎(输入数字):\n1. YouTube 翻译\n2. Google 翻译', TRANS_SERVICE === 'youtube' ? '1' : '2');
- if (userInput) {
- const service = userInput.trim() === '1' ? 'youtube' : 'google';
- saveUserSettings(TARGET_LANG, service);
- LAST_FAILED_SERVICE = null; // 重置失败记录
- alert(`翻译引擎已设置为:${service === 'youtube' ? 'YouTube 翻译' : 'Google 翻译'}`);
- }
- });
- }
- }
-
- // 初始化设置
- const settings = getUserSettings();
- TARGET_LANG = settings.lang;
- TRANS_SERVICE = settings.service;
- addSettingsMenu();
-
- // 谷歌翻译API
- async function googleTranslate(text) {
- try {
- const response = await fetch(`https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=${TARGET_LANG}&dt=t&q=${encodeURIComponent(text)}`);
- if (!response.ok) throw new Error('Google API请求失败');
- const data = await response.json();
- return data[0][0][0];
- } catch (error) {
- console.error('Google翻译失败:', error);
- return null; // 返回null表示翻译失败
- }
- }
-
- async function enableDualSubtitles() {
- // 获取翻译后的字幕数据
- async function fetchTranslatedSubtitles(url, preferredService = TRANS_SERVICE) {
- // 如果上次失败的服务和当前首选服务相同,自动切换到另一个服务
- if (LAST_FAILED_SERVICE === preferredService) {
- preferredService = preferredService === 'youtube' ? 'google' : 'youtube';
- console.log(`上次${LAST_FAILED_SERVICE}翻译失败,自动切换到${preferredService}`);
- }
-
- async function tryYouTubeTranslation() {
- const cleanUrl = url.replace(/(^|[&?])tlang=[^&]*/g, '') + `&tlang=${TARGET_LANG}&translate_h00ked`;
- try {
- const response = await fetch(cleanUrl, { method: 'GET' });
- if (!response.ok) throw new Error('YouTube翻译请求失败');
- const data = await response.json();
- // 验证返回的数据是否有效
- if (!data.events || data.events.length === 0) throw new Error('YouTube返回的字幕数据无效');
- return data;
- } catch (error) {
- console.error('YouTube翻译失败:', error);
- LAST_FAILED_SERVICE = 'youtube';
- return null;
- }
- }
-
- async function tryGoogleTranslation() {
- try {
- const response = await fetch(url, { method: 'GET' });
- if (!response.ok) throw new Error('获取原字幕失败');
- const data = await response.json();
- const translatedData = JSON.parse(JSON.stringify(data));
-
- // 批量收集需要翻译的文本
- const textToTranslate = translatedData.events
- .filter(event => event.segs)
- .map(event => ({
- text: event.segs.map(seg => seg.utf8).join('').trim(),
- event: event
- }))
- .filter(item => item.text);
-
- // 批量翻译
- const results = await Promise.all(
- textToTranslate.map(async ({ text, event }) => {
- const translatedText = await googleTranslate(text);
- if (translatedText === null) throw new Error('Google翻译失败');
- return { event, translatedText };
- })
- );
-
- // 更新翻译结果
- results.forEach(({ event, translatedText }) => {
- event.segs = [{ utf8: translatedText }];
- });
-
- return translatedData;
- } catch (error) {
- console.error('谷歌翻译失败:', error);
- LAST_FAILED_SERVICE = 'google';
- return null;
- }
- }
-
- // 尝试首选服务
- let result = null;
- if (preferredService === 'youtube') {
- result = await tryYouTubeTranslation();
- if (!result) {
- console.log('YouTube翻译失败,尝试使用谷歌翻译');
- result = await tryGoogleTranslation();
- }
- } else {
- result = await tryGoogleTranslation();
- if (!result) {
- console.log('谷歌翻译失败,尝试使用YouTube翻译');
- result = await tryYouTubeTranslation();
- }
- }
-
- return result;
- }
-
- // 编辑距离计算
- function levenshteinDistance(s1, s2) {
- if (s1.length === 0) return s2.length;
- if (s2.length === 0) return s1.length;
-
- const matrix = Array.from({ length: s1.length + 1 }, (_, i) => Array(s2.length + 1).fill(0).map((_, j) => (i === 0 ? j : i)));
-
- for (let i = 1; i <= s1.length; i++) {
- for (let j = 1; j <= s2.length; j++) {
- matrix[i][j] = (s1[i - 1] === s2[j - 1])
- ? matrix[i - 1][j - 1]
- : Math.min(
- matrix[i - 1][j - 1] + 1,
- matrix[i][j - 1] + 1,
- matrix[i - 1][j] + 1
- );
- }
- }
-
- return matrix[s1.length][s2.length];
- }
-
- // Jaccard相似度计算
- function jaccardSimilarity(str1, str2) {
- const set1 = new Set(str1.split(''));
- const set2 = new Set(str2.split(''));
- const intersection = [...set1].filter(x => set2.has(x)).length;
- const union = new Set([...set1, ...set2]).size;
- return intersection / union;
- }
-
- // 相似度计算
- function calculateSimilarity(s1, s2) {
- const maxLength = Math.max(s1.length, s2.length);
- const levenshteinSimilarity = 1 - (levenshteinDistance(s1, s2) / maxLength);
- const jaccardSim = jaccardSimilarity(s1, s2);
- return (levenshteinSimilarity * 0.7) + (jaccardSim * 0.3);
- }
-
- // 合并字幕
- function mergeSubtitles(defaultSubs, translatedSubs) {
- const mergedSubs = JSON.parse(JSON.stringify(defaultSubs));
- const translatedEvents = translatedSubs.events.filter(event => event.segs);
- const translatedMap = new Map(translatedEvents.map(event => [event.tStartMs, event]));
-
- for (let i = 0; i < mergedSubs.events.length; i++) {
- const defaultEvent = mergedSubs.events[i];
- if (!defaultEvent.segs) continue;
-
- const translatedEvent = [...translatedMap.keys()].reduce((closest, tStartMs) => {
- return (Math.abs(tStartMs - defaultEvent.tStartMs) < Math.abs(closest - defaultEvent.tStartMs)) ? tStartMs : closest;
- }, Infinity);
-
- const eventToMerge = translatedMap.get(translatedEvent);
- if (eventToMerge) {
- const defaultText = defaultEvent.segs.map(seg => seg.utf8).join('').trim();
- const translatedText = eventToMerge.segs.map(seg => seg.utf8).join('').trim();
-
- const timeOverlap = Math.min(defaultEvent.tStartMs + defaultEvent.dDurationMs, eventToMerge.tStartMs + eventToMerge.dDurationMs) - Math.max(defaultEvent.tStartMs, eventToMerge.tStartMs);
-
- if (timeOverlap > 0) {
- const similarity = calculateSimilarity(defaultText, translatedText);
- // 使用0.6容错率
- if (similarity < 0.6) {
- defaultEvent.segs = [{
- utf8: `${defaultText}\n${translatedText}`,
- tStartMs: defaultEvent.tStartMs,
- dDurationMs: defaultEvent.dDurationMs
- }];
- }
- }
- }
- }
-
- return JSON.stringify(mergedSubs);
- }
-
- // ajax-hook代理
- ah.proxy({
- onResponse: async (response, handler) => {
- if (response.config.url.includes('/api/timedtext') && !response.config.url.includes('&translate_h00ked')) {
- try {
- const defaultSubs = JSON.parse(response.response);
- const translatedSubs = await fetchTranslatedSubtitles(response.config.url);
- if (translatedSubs) {
- response.response = mergeSubtitles(defaultSubs, translatedSubs);
- }
- } catch (error) {
- console.error("处理字幕时出错:", error);
- }
- }
- handler.resolve(response);
- }
- });
- }
-
- enableDualSubtitles();
- })();