YouTube 直播聊天实时翻译

Same as the name

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name        YouTube 直播聊天实时翻译
// @version     1.5
// @author      lslqtz
// @license     GPL
// @grant       GM.xmlHttpRequest
// @inject-into content
// @run-at      document-end
// @match       *://*.youtube.com/live_chat*
// @namespace https://greasyfork.org/users/155581
// @description Same as the name
// ==/UserScript==

window.YTGMFetch = function (url, options = {}) {
	return new Promise((resolve, reject) => {
		const method = options.method || 'GET';
		const headers = options.headers || {};
		const body = options.body || null;

		GM.xmlHttpRequest({
			method: method,
			url: url,
			headers: headers,
			data: body,
			responseType: options.responseType || 'text',
			onload: function (response) {
				const fetchResponse = {
					ok: response.status >= 200 && response.status < 300,
					status: response.status,
					statusText: response.statusText,
					url: response.finalUrl,
					text: () => Promise.resolve(response.responseText),
					json: () => Promise.resolve(JSON.parse(response.responseText)),
					blob: () => Promise.resolve(new Blob([response.response])),
					arrayBuffer: () => Promise.resolve(response.response),
					headers: {
						get: (header) => {
							const headersArray = response.responseHeaders.split('\r\n');
							const headerMap = headersArray.reduce((acc, curr) => {
								const [key, value] = curr.split(': ');
								if (key && value) acc[key.toLowerCase()] = value;
								return acc;
							}, {});
							return headerMap[header.toLowerCase()] || null;
						},
					},
				};

				resolve(fetchResponse);
			},
			onerror: function () {
				reject(new TypeError('Network request failed.'));
			},
			ontimeout: function () {
				reject(new TypeError('Network request timed out.'));
			},
			onabort: function () {
				reject(new DOMException('The operation was aborted.', 'AbortError'));
			},
		});
	});
};
window.ytTextEncoder = new TextEncoder();
window.ytIsSolvingCaptcha = false;
window.ytSymbolRegex = /^([\u{2190}-\u{21FF}]|[\u{2300}-\u{23FF}]|[\u{2460}-\u{24FF}]|[\u{2500}-\u{25FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{2B00}-\u{2BFF}]|[\u{1F000}-\u{1F02F}]|[\u{1F0A0}-\u{1F0FF}]|[\u{1F100}-\u{1F6FF}]|[\u{1F900}-\u{1FAFF}]|[\u{1FB00}-\u{1FBFF}]|[ !"#$%&'()*+,-./:;<=>?@\[\\\]^_`\{|\}~。,“”、:;?!〝〞〟~〜])+$/u;

// 停止翻译脚本.
function StopYouTubeLiveChatTranslator() {
	console.log("停止 YouTube 直播聊天翻译脚本");

	if (window.ytLiveChatInterval) {
		clearInterval(window.ytLiveChatInterval);
		console.log("已清除计时器");
	}

	if (window.ytObserver) {
		window.ytObserver.disconnect();
		window.ytObserver = null;
		console.log("已清除观察器");
	}
	
	window.chatContainerNotFoundCount = 0;
	window.ytIsSolvingCaptcha = false;
}

// 启动翻译脚本.
function StartYouTubeLiveChatTranslator() {
	console.log("启动 YouTube 直播聊天翻译脚本");

	// 启动定时器.
	window.ytLiveChatInterval = setInterval(CheckAndObserveChatContainer, 1000);
}

function ShowReCaptchaPrompt(onResolved) {
	if (window.ytIsSolvingCaptcha) {
		return;
	}

	window.ytIsSolvingCaptcha = true;

	// 创建提示框 HTML.
	var promptBox = document.createElement("div");
	promptBox.style = `
		position: fixed; top: 20%; left: 50%; transform: translateX(-50%);
		padding: 5px; width: 70%; background: white; border: 1px solid gray; box-shadow: 0 4px 8px rgba(0,0,0,0.2);
		z-index: 10000; text-align: center; font-size: 16px;
	`;
	promptBox.innerHTML = `
		<h3 style="margin-bottom: 15px;">YouTube 直播聊天实时翻译</h3>
		<p style="margin-bottom: 15px;">HTTP 429: 检测到疑似频繁请求触发 reCaptcha 验证码验证.</p>
		<p style="margin-bottom: 15px;">手动访问 <a href="https://translate.googleapis.com/translate_a/single" target="_blank" style="color: blue;">Google Translate API</a> 并完成验证码验证.</p>
		<p style="margin-bottom: 15px;">完成验证后, 点按"我已完成", 即可继续开始翻译.</p>
		<button id="ytRecaptchaResolvedButton">我已完成</button> <button id="ytDisableTranslation">停用翻译</button>
	`;

	document.body.appendChild(promptBox);

	document.getElementById("ytRecaptchaResolvedButton").addEventListener("click", () => {
		promptBox.remove();
		window.ytIsSolvingCaptcha = false;
	});
	document.getElementById("ytDisableTranslation").addEventListener("click", () => {
		promptBox.remove();
		StopYouTubeLiveChatTranslator();
	});
}

// Google Translate API 调用.
async function TranslateText(text, targetLang = 'zh-CN') {
	console.log("翻译消息: " + text);

	if (window.ytIsSolvingCaptcha) {
		return "[翻译失败: 等待解决验证码]";
	}

	try {
		var response = await YTGMFetch('https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=' + targetLang + '&dt=t&q=' + encodeURIComponent(text));
		if (!response.ok) {
			console.error("翻译 API 请求失败", response.status, response.statusText);
			if (response.status === 429) {
				ShowReCaptchaPrompt();
				return "[翻译失败: 疑似频繁请求触发 reCaptcha 验证码]";
			}
			return `[翻译失败: HTTP ${response.status} ${response.statusText}]`;
		}

		var result = await response.json();
		if (!result || !result[0] || !Array.isArray(result[0])) {
			return "[翻译失败: 无法解析翻译 API 返回的内容]";
		}

		return result[0].map(segment => segment[0]).join('');
	} catch (error) {
		console.error("翻译出错", error);
		return "[翻译出错]";
	}
}

// 解析消息内容, 过滤表情和图片, 并将它们替换为占位符.
function ExtractTextContent(element) {
	var text = '';
	var elements = element.childNodes;
	var placeholders = []; // 存储占位符与原始内容的对应关系.
	var hasText = false;

	elements.forEach(function (node, index) {
		if (node.nodeType === Node.TEXT_NODE) {
			if (!hasText && !node.nodeValue.trim().split(/\r?\n/).every(line => window.ytSymbolRegex.test(line))) {
				hasText = true;
			}
			text += node.nodeValue.trim();
		} else if (node.nodeType === Node.ELEMENT_NODE) {
			if (node.tagName.toLowerCase() === 'img' || (node.tagName.toLowerCase() === 'span' && node.classList.contains('emoji'))) {
				var placeholder = `{{ytPH${index}}}`; // 使用占位符.
				placeholders.push({ placeholder: placeholder, html: node.outerHTML });
				text += placeholder; // 将表情或图片替换为占位符.
			}
		}
	});

	if (!hasText || (text.trim().length <= 2 && window.ytTextEncoder.encode(text.trim()).length <= 4)) {
		return { text: "", placeholders: [] };
	}
	return { text: text.trim(), placeholders: placeholders };
}

// 重新将占位符替换为表情和图片.
function InsertPlaceholdersIntoTranslation(translatedMessage, placeholders) {
	translatedMessage = translatedMessage.replace('{ {', '{{').replace('} }', '}}');
	placeholders.forEach(function (placeholder) {
		translatedMessage = translatedMessage.replace(new RegExp(placeholder.placeholder, 'gi'), placeholder.html);
	});
	return translatedMessage;
}

// 插入翻译后的消息.
function InsertTranslatedMessage(messageElement, translatedMessage) {
	var translationElement = document.createElement('div');
	translationElement.style.color = 'gray';
	translationElement.style.fontSize = 'small';
	translationElement.className = 'translated-message';
	translationElement.innerHTML = "[翻译]: " + translatedMessage;

	// 在原消息下方插入翻译内容.
	messageElement.appendChild(translationElement);
}

// 检查并观察聊天容器.
function CheckAndObserveChatContainer() {
	var chatContainer = document.querySelector('yt-live-chat-app');
	if (chatContainer) {
		if (!window.ytObserver) {
			console.log("聊天容器找到: ", chatContainer);
			ObserveChatUpdates(chatContainer);
			TranslateInitialMessages(chatContainer);
		}
	} else if (window.ytObserver) {
		if (window.chatContainerNotFoundCount++ >= 3) {
			window.chatContainerNotFoundCount = 0;
			window.ytObserver.disconnect();
			window.ytObserver = null;
			console.log("聊天容器已丢失, 停止以前监听的聊天更新");
		}
	}
}

// 翻译已有的最新 20 条消息.
async function TranslateInitialMessages(chatContainer) {
	console.log("开始翻译已有消息...");

	// 获取所有聊天消息节点.
	var messages = chatContainer.querySelectorAll('yt-live-chat-text-message-renderer, yt-live-chat-paid-message-renderer');
	var totalMessages = messages.length;

	// 只处理最后的 10 条消息.
	for (var i = Math.max(0, totalMessages - 10); i < totalMessages; i++) {
		var messageNode = messages[i];
		var messageElement = messageNode.querySelector('#message');
		if (messageElement) {
			// 跳过已翻译消息.
			if (messageElement.querySelector('.translated-message')) {
				console.log("消息已翻译,跳过: ", messageElement.textContent);
				continue;
			}
			var { text, placeholders } = ExtractTextContent(messageElement);
			if (text.length === 0) {
				console.log("已有消息内容为空,跳过翻译");
				continue;
			}
			console.log("已有消息: " + text);
			var translatedMessage = await TranslateText(text);
			if (translatedMessage.length === 0) {
				console.log("翻译消息内容为空,跳过翻译");
				continue;
			}
			console.log("翻译消息: " + text);
			var finalMessage = InsertPlaceholdersIntoTranslation(translatedMessage, placeholders);
			InsertTranslatedMessage(messageElement, finalMessage);
		}
	}
}

// 监听聊天消息更新.
function ObserveChatUpdates(chatContainer) {
	window.ytObserver = new MutationObserver(async function (mutations) {
		for (var i = 0; i < mutations.length; i++) {
			var mutation = mutations[i];
			if (mutation.type !== 'childList') {
				continue;
			}
			mutation.addedNodes.forEach(async function (node) {
				// 检查是否为聊天消息.
				if (node.nodeType === 1 && (node.tagName.toLowerCase() === 'yt-live-chat-text-message-renderer' || node.tagName.toLowerCase() === 'yt-live-chat-paid-message-renderer')) {
					var messageElement = node.querySelector('#message');
					if (messageElement) {
						// 跳过已翻译消息.
						if (messageElement.querySelector('.translated-message')) {
							console.log("消息已翻译,跳过: ", messageElement.textContent);
							return;
						}
						// 提取文本并替换表情或图片为占位符.
						var { text, placeholders } = ExtractTextContent(messageElement);
						if (text.length === 0) {
							console.log("消息内容为空, 跳过翻译");
							return;
						}
						console.log("检测到新消息: " + text);
						var translatedMessage = await TranslateText(text);

						// 将翻译后的文本和表情或图片组合.
						var finalMessage = InsertPlaceholdersIntoTranslation(translatedMessage, placeholders);
						InsertTranslatedMessage(messageElement, finalMessage);
					} else {
						console.warn("未找到消息内容元素");
					}
				}
			});
		}
	});

	console.log("开始监听聊天更新...");
	window.ytObserver.observe(chatContainer, { childList: true, subtree: true });
}

StopYouTubeLiveChatTranslator();
StartYouTubeLiveChatTranslator();