YouTube 直播聊天实时翻译

Same as the name

  1. // ==UserScript==
  2. // @name YouTube 直播聊天实时翻译
  3. // @version 1.5
  4. // @author lslqtz
  5. // @license GPL
  6. // @grant GM.xmlHttpRequest
  7. // @inject-into content
  8. // @run-at document-end
  9. // @match *://*.youtube.com/live_chat*
  10. // @namespace https://gf.qytechs.cn/users/155581
  11. // @description Same as the name
  12. // ==/UserScript==
  13.  
  14. window.YTGMFetch = function (url, options = {}) {
  15. return new Promise((resolve, reject) => {
  16. const method = options.method || 'GET';
  17. const headers = options.headers || {};
  18. const body = options.body || null;
  19.  
  20. GM.xmlHttpRequest({
  21. method: method,
  22. url: url,
  23. headers: headers,
  24. data: body,
  25. responseType: options.responseType || 'text',
  26. onload: function (response) {
  27. const fetchResponse = {
  28. ok: response.status >= 200 && response.status < 300,
  29. status: response.status,
  30. statusText: response.statusText,
  31. url: response.finalUrl,
  32. text: () => Promise.resolve(response.responseText),
  33. json: () => Promise.resolve(JSON.parse(response.responseText)),
  34. blob: () => Promise.resolve(new Blob([response.response])),
  35. arrayBuffer: () => Promise.resolve(response.response),
  36. headers: {
  37. get: (header) => {
  38. const headersArray = response.responseHeaders.split('\r\n');
  39. const headerMap = headersArray.reduce((acc, curr) => {
  40. const [key, value] = curr.split(': ');
  41. if (key && value) acc[key.toLowerCase()] = value;
  42. return acc;
  43. }, {});
  44. return headerMap[header.toLowerCase()] || null;
  45. },
  46. },
  47. };
  48.  
  49. resolve(fetchResponse);
  50. },
  51. onerror: function () {
  52. reject(new TypeError('Network request failed.'));
  53. },
  54. ontimeout: function () {
  55. reject(new TypeError('Network request timed out.'));
  56. },
  57. onabort: function () {
  58. reject(new DOMException('The operation was aborted.', 'AbortError'));
  59. },
  60. });
  61. });
  62. };
  63. window.ytTextEncoder = new TextEncoder();
  64. window.ytIsSolvingCaptcha = false;
  65. 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;
  66.  
  67. // 停止翻译脚本.
  68. function StopYouTubeLiveChatTranslator() {
  69. console.log("停止 YouTube 直播聊天翻译脚本");
  70.  
  71. if (window.ytLiveChatInterval) {
  72. clearInterval(window.ytLiveChatInterval);
  73. console.log("已清除计时器");
  74. }
  75.  
  76. if (window.ytObserver) {
  77. window.ytObserver.disconnect();
  78. window.ytObserver = null;
  79. console.log("已清除观察器");
  80. }
  81. window.chatContainerNotFoundCount = 0;
  82. window.ytIsSolvingCaptcha = false;
  83. }
  84.  
  85. // 启动翻译脚本.
  86. function StartYouTubeLiveChatTranslator() {
  87. console.log("启动 YouTube 直播聊天翻译脚本");
  88.  
  89. // 启动定时器.
  90. window.ytLiveChatInterval = setInterval(CheckAndObserveChatContainer, 1000);
  91. }
  92.  
  93. function ShowReCaptchaPrompt(onResolved) {
  94. if (window.ytIsSolvingCaptcha) {
  95. return;
  96. }
  97.  
  98. window.ytIsSolvingCaptcha = true;
  99.  
  100. // 创建提示框 HTML.
  101. var promptBox = document.createElement("div");
  102. promptBox.style = `
  103. position: fixed; top: 20%; left: 50%; transform: translateX(-50%);
  104. padding: 5px; width: 70%; background: white; border: 1px solid gray; box-shadow: 0 4px 8px rgba(0,0,0,0.2);
  105. z-index: 10000; text-align: center; font-size: 16px;
  106. `;
  107. promptBox.innerHTML = `
  108. <h3 style="margin-bottom: 15px;">YouTube 直播聊天实时翻译</h3>
  109. <p style="margin-bottom: 15px;">HTTP 429: 检测到疑似频繁请求触发 reCaptcha 验证码验证.</p>
  110. <p style="margin-bottom: 15px;">手动访问 <a href="https://translate.googleapis.com/translate_a/single" target="_blank" style="color: blue;">Google Translate API</a> 并完成验证码验证.</p>
  111. <p style="margin-bottom: 15px;">完成验证后, 点按"我已完成", 即可继续开始翻译.</p>
  112. <button id="ytRecaptchaResolvedButton">我已完成</button> <button id="ytDisableTranslation">停用翻译</button>
  113. `;
  114.  
  115. document.body.appendChild(promptBox);
  116.  
  117. document.getElementById("ytRecaptchaResolvedButton").addEventListener("click", () => {
  118. promptBox.remove();
  119. window.ytIsSolvingCaptcha = false;
  120. });
  121. document.getElementById("ytDisableTranslation").addEventListener("click", () => {
  122. promptBox.remove();
  123. StopYouTubeLiveChatTranslator();
  124. });
  125. }
  126.  
  127. // Google Translate API 调用.
  128. async function TranslateText(text, targetLang = 'zh-CN') {
  129. console.log("翻译消息: " + text);
  130.  
  131. if (window.ytIsSolvingCaptcha) {
  132. return "[翻译失败: 等待解决验证码]";
  133. }
  134.  
  135. try {
  136. var response = await YTGMFetch('https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=' + targetLang + '&dt=t&q=' + encodeURIComponent(text));
  137. if (!response.ok) {
  138. console.error("翻译 API 请求失败", response.status, response.statusText);
  139. if (response.status === 429) {
  140. ShowReCaptchaPrompt();
  141. return "[翻译失败: 疑似频繁请求触发 reCaptcha 验证码]";
  142. }
  143. return `[翻译失败: HTTP ${response.status} ${response.statusText}]`;
  144. }
  145.  
  146. var result = await response.json();
  147. if (!result || !result[0] || !Array.isArray(result[0])) {
  148. return "[翻译失败: 无法解析翻译 API 返回的内容]";
  149. }
  150.  
  151. return result[0].map(segment => segment[0]).join('');
  152. } catch (error) {
  153. console.error("翻译出错", error);
  154. return "[翻译出错]";
  155. }
  156. }
  157.  
  158. // 解析消息内容, 过滤表情和图片, 并将它们替换为占位符.
  159. function ExtractTextContent(element) {
  160. var text = '';
  161. var elements = element.childNodes;
  162. var placeholders = []; // 存储占位符与原始内容的对应关系.
  163. var hasText = false;
  164.  
  165. elements.forEach(function (node, index) {
  166. if (node.nodeType === Node.TEXT_NODE) {
  167. if (!hasText && !node.nodeValue.trim().split(/\r?\n/).every(line => window.ytSymbolRegex.test(line))) {
  168. hasText = true;
  169. }
  170. text += node.nodeValue.trim();
  171. } else if (node.nodeType === Node.ELEMENT_NODE) {
  172. if (node.tagName.toLowerCase() === 'img' || (node.tagName.toLowerCase() === 'span' && node.classList.contains('emoji'))) {
  173. var placeholder = `{{ytPH${index}}}`; // 使用占位符.
  174. placeholders.push({ placeholder: placeholder, html: node.outerHTML });
  175. text += placeholder; // 将表情或图片替换为占位符.
  176. }
  177. }
  178. });
  179.  
  180. if (!hasText || (text.trim().length <= 2 && window.ytTextEncoder.encode(text.trim()).length <= 4)) {
  181. return { text: "", placeholders: [] };
  182. }
  183. return { text: text.trim(), placeholders: placeholders };
  184. }
  185.  
  186. // 重新将占位符替换为表情和图片.
  187. function InsertPlaceholdersIntoTranslation(translatedMessage, placeholders) {
  188. translatedMessage = translatedMessage.replace('{ {', '{{').replace('} }', '}}');
  189. placeholders.forEach(function (placeholder) {
  190. translatedMessage = translatedMessage.replace(new RegExp(placeholder.placeholder, 'gi'), placeholder.html);
  191. });
  192. return translatedMessage;
  193. }
  194.  
  195. // 插入翻译后的消息.
  196. function InsertTranslatedMessage(messageElement, translatedMessage) {
  197. var translationElement = document.createElement('div');
  198. translationElement.style.color = 'gray';
  199. translationElement.style.fontSize = 'small';
  200. translationElement.className = 'translated-message';
  201. translationElement.innerHTML = "[翻译]: " + translatedMessage;
  202.  
  203. // 在原消息下方插入翻译内容.
  204. messageElement.appendChild(translationElement);
  205. }
  206.  
  207. // 检查并观察聊天容器.
  208. function CheckAndObserveChatContainer() {
  209. var chatContainer = document.querySelector('yt-live-chat-app');
  210. if (chatContainer) {
  211. if (!window.ytObserver) {
  212. console.log("聊天容器找到: ", chatContainer);
  213. ObserveChatUpdates(chatContainer);
  214. TranslateInitialMessages(chatContainer);
  215. }
  216. } else if (window.ytObserver) {
  217. if (window.chatContainerNotFoundCount++ >= 3) {
  218. window.chatContainerNotFoundCount = 0;
  219. window.ytObserver.disconnect();
  220. window.ytObserver = null;
  221. console.log("聊天容器已丢失, 停止以前监听的聊天更新");
  222. }
  223. }
  224. }
  225.  
  226. // 翻译已有的最新 20 条消息.
  227. async function TranslateInitialMessages(chatContainer) {
  228. console.log("开始翻译已有消息...");
  229.  
  230. // 获取所有聊天消息节点.
  231. var messages = chatContainer.querySelectorAll('yt-live-chat-text-message-renderer, yt-live-chat-paid-message-renderer');
  232. var totalMessages = messages.length;
  233.  
  234. // 只处理最后的 10 条消息.
  235. for (var i = Math.max(0, totalMessages - 10); i < totalMessages; i++) {
  236. var messageNode = messages[i];
  237. var messageElement = messageNode.querySelector('#message');
  238. if (messageElement) {
  239. // 跳过已翻译消息.
  240. if (messageElement.querySelector('.translated-message')) {
  241. console.log("消息已翻译,跳过: ", messageElement.textContent);
  242. continue;
  243. }
  244. var { text, placeholders } = ExtractTextContent(messageElement);
  245. if (text.length === 0) {
  246. console.log("已有消息内容为空,跳过翻译");
  247. continue;
  248. }
  249. console.log("已有消息: " + text);
  250. var translatedMessage = await TranslateText(text);
  251. if (translatedMessage.length === 0) {
  252. console.log("翻译消息内容为空,跳过翻译");
  253. continue;
  254. }
  255. console.log("翻译消息: " + text);
  256. var finalMessage = InsertPlaceholdersIntoTranslation(translatedMessage, placeholders);
  257. InsertTranslatedMessage(messageElement, finalMessage);
  258. }
  259. }
  260. }
  261.  
  262. // 监听聊天消息更新.
  263. function ObserveChatUpdates(chatContainer) {
  264. window.ytObserver = new MutationObserver(async function (mutations) {
  265. for (var i = 0; i < mutations.length; i++) {
  266. var mutation = mutations[i];
  267. if (mutation.type !== 'childList') {
  268. continue;
  269. }
  270. mutation.addedNodes.forEach(async function (node) {
  271. // 检查是否为聊天消息.
  272. if (node.nodeType === 1 && (node.tagName.toLowerCase() === 'yt-live-chat-text-message-renderer' || node.tagName.toLowerCase() === 'yt-live-chat-paid-message-renderer')) {
  273. var messageElement = node.querySelector('#message');
  274. if (messageElement) {
  275. // 跳过已翻译消息.
  276. if (messageElement.querySelector('.translated-message')) {
  277. console.log("消息已翻译,跳过: ", messageElement.textContent);
  278. return;
  279. }
  280. // 提取文本并替换表情或图片为占位符.
  281. var { text, placeholders } = ExtractTextContent(messageElement);
  282. if (text.length === 0) {
  283. console.log("消息内容为空, 跳过翻译");
  284. return;
  285. }
  286. console.log("检测到新消息: " + text);
  287. var translatedMessage = await TranslateText(text);
  288.  
  289. // 将翻译后的文本和表情或图片组合.
  290. var finalMessage = InsertPlaceholdersIntoTranslation(translatedMessage, placeholders);
  291. InsertTranslatedMessage(messageElement, finalMessage);
  292. } else {
  293. console.warn("未找到消息内容元素");
  294. }
  295. }
  296. });
  297. }
  298. });
  299.  
  300. console.log("开始监听聊天更新...");
  301. window.ytObserver.observe(chatContainer, { childList: true, subtree: true });
  302. }
  303.  
  304. StopYouTubeLiveChatTranslator();
  305. StartYouTubeLiveChatTranslator();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址