Linux Do Translate

对回复进行翻译,或者将回复注音

当前为 2024-10-31 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Linux Do Translate
// @namespace    linux-do-translate
// @version      0.1.0
// @author       delph1s, Unique, Hua
// @license      MIT
// @description  对回复进行翻译,或者将回复注音
// @match        https://linux.do/t/topic/*
// @connect      api.deeplx.org
// @icon         https://cdn.linux.do/uploads/default/original/3X/9/d/9dd49731091ce8656e94433a26a3ef36062b3994.png
// @grant        unsafeWindow
// @grant        window.close
// @grant        window.focus
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_xmlhttpRequest
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/index.min.js
// @run-at       document-end
// ==/UserScript==

(function () {
  'use strict';
  const REQUIRED_CHARS = 6;
  const SPACE_PRESS_COUNT = 3; // 连按次数
  const SPACE_PRESS_TIMEOUT = 1500; // 连续按键的最大时间间隔(毫秒)
  const DEFAULT_CONFIG = {
    maxRetryTimes: 5,
    deeplxUrl: '',
    deeplxAuthKey: '',
    enableTranslate: false,
    translateSize: 150,
    enablePinYin: false,
    closeConfigAfterSave: true,
  };

  const uiIDs = {
    replyControl: 'reply-control',
    configButton: 'multi-lang-say-config-button',
    configPanel: 'multi-lang-say-config-panel',
    deeplxUrlInput: 'deeplx-url-input',
    deeplxAuthKeyInput: 'deeplx-authkey-input',
    translateSizeInput: 'translate-size-input',
  };

  let config = {
    maxRetryTimes: GM_getValue('maxRetryTimes', DEFAULT_CONFIG.maxRetryTimes),
    deeplxUrl: GM_getValue('deeplxUrl', DEFAULT_CONFIG.deeplxUrl),
    deeplxAuthKey: GM_getValue('deeplxAuthKey', DEFAULT_CONFIG.deeplxAuthKey),
    enableTranslate: GM_getValue('enableTranslate', DEFAULT_CONFIG.enableTranslate),
    translateSize: GM_getValue('translateSize', DEFAULT_CONFIG.translateSize),
    enablePinYin: GM_getValue('enablePinYin', DEFAULT_CONFIG.enablePinYin),
    closeConfigAfterSave: GM_getValue('closeConfigAfterSave', DEFAULT_CONFIG.closeConfigAfterSave),
  };

  const checkPinYinRequire = () => {
    // 检查是否加载了 pinyin 库
    if (typeof pinyinPro === 'undefined') {
      console.error('pinyin 库未正确加载!');
      config.enablePinYin = false;
      GM_setValue('enablePinYin', false);
      return false;
    }
    return true;
  };

  const genFormatDateTime = d => {
    return d.toLocaleString('zh-CN', {
      year: 'numeric',
      month: '2-digit',
      day: '2-digit',
      hour: '2-digit',
      minute: '2-digit',
      second: '2-digit',
      hour12: false,
    });
  };

  const genFormatNow = () => {
    return genFormatDateTime(new Date());
  };

  /**
   * 获取随机整数
   *
   * @param {number} start 范围开始
   * @param {number} end 范围结束
   * @returns
   */
  const randInt = (start, end) => {
    return Math.floor(Math.random() * (end - start + 1)) + start;
  };

  /**
   * 随机睡眠(毫秒)
   *
   * @param {number} start 范围开始
   * @param {number} end 范围结束
   */
  const randSleep = async (start = 2000, end = 3000) => {
    // 生成随机整数 randSleepTime,范围在 start 到 end 之间
    const randSleepTime = getRandomInt(start, end);
    // 睡眠时间
    return await new Promise(resolve => setTimeout(resolve, randSleepTime));
  };

  /**
   * 是否相同
   *
   * @param a
   * @param b
   * @returns
   */
  const isEqual = (a, b) => {
    if (a === null || a === undefined || b === null || b === undefined) {
      return a === b;
    }

    if (typeof a !== typeof b) {
      return false;
    }

    if (typeof a === 'string' || typeof a === 'number' || typeof a === 'boolean') {
      return a === b;
    }

    if (Array.isArray(a) && Array.isArray(b)) {
      if (a.length !== b.length) {
        return false;
      }

      return a.every((item, index) => isEqual(item, b[index]));
    }

    if (typeof a === 'object' && typeof b === 'object') {
      const keysA = Object.keys(a || {});
      const keysB = Object.keys(b || {});

      if (keysA.length !== keysB.length) {
        return false;
      }

      return keysA.every(key => isEqual(a[key], b[key]));
    }

    return false;
  };

  /**
   * 判断字符串中是否包含中文字符
   * @param {string} text
   * @returns {boolean}
   */
  const containsChinese = text => {
    return /[\u4e00-\u9fa5]/.test(text);
  };

  /**
   * 将中文字符串转换为带拼音的ruby标签字符串
   * @param {string} text
   * @returns {string}
   */
  const convertToRuby = text => {
    // 使用tiny-pinyin将中文转换为拼音,拼音之间用空格分隔
    const pinyin = pinyinPro.pinyin(text);
    // 构建ruby标签
    return `<ruby><rb>${text}</rb><rt>${pinyin}</rt></ruby>`;
  };

  const getInvertColor = hex => {
    // 去掉前面的“#”字符
    hex = hex.replace('#', '');

    // 如果输入的是3位的hex值,转换为6位的
    if (hex.length === 3) {
      hex = hex
        .split('')
        .map(c => c + c)
        .join('');
    }

    // 计算相反的颜色
    const r = (255 - parseInt(hex.slice(0, 2), 16)).toString(16).padStart(2, '0');
    const g = (255 - parseInt(hex.slice(2, 4), 16)).toString(16).padStart(2, '0');
    const b = (255 - parseInt(hex.slice(4, 6), 16)).toString(16).padStart(2, '0');

    return `#${r}${g}${b}`;
  };

  const translateText = text => {
    let url;
    if (!config.deeplxAuthKey) {
      if (!config.deeplxUrl) return '';
      url = config.deeplxUrl;
    } else {
      url = `https://api.deeplx.org/${config.deeplxAuthKey}/translate`;
    }
    const data = {
      text: text,
      target_lang: 'EN',
      source_lang: 'auto',
    };
    // return GM_xmlhttpRequest({
    //   method: 'POST',
    //   url,
    //   data: JSON.stringify(data),
    //   responseType: 'json',
    //   onload: res => {
    //     console.log(res);
    //     return res.response;
    //   },
    //   onerror: err => {
    //     console.log(err);
    //     return '';
    //   },
    // });
    return GM.xmlHttpRequest({
      method: 'POST',
      url,
      data: JSON.stringify(data),
      responseType: 'json',
    })
      .then(res => {
        console.log(res);
        console.log(`[翻译结果] ${res.response.data}`)
        return res.response.data;
      })
      .catch(err => {
        console.log(err);
        return '';
      });
  };

  const processTranslateText = async text => {
    // 检查是否已存在拼音
    const rubyRegex = /(<ruby>[\s\S]*?<\/ruby>)/g;
    // 使用正则表达式将文本分割为已被 <ruby> 包裹的部分和未被包裹的部分
    const parts = text.split(rubyRegex);

    // 处理每一部分
    let newText = [];
    for (let i = 0; i < parts.length; i += 1) {
      if (rubyRegex.test(parts[i])) {
        // 已被 <ruby> 包裹,保持原样
        return parts[i];
      } else {
        if (parts[i].indexOf('\n') > -1) {
          let newLine = parts[i].split('\n');
          for (let j = 0; j < newLine.length; j += 1) {
            if (!newLine[j].trim()) {
              newLine[j] = '\n';
            } else {
              const segmentTranslate = await translateText(newLine[j]);
              newLine[j] = `[size=${config.translateSize}]<ruby><rb>${newLine[j]}</rb><rt>${segmentTranslate}</rt></ruby>[/size]`;
            }
          }
          newText.push(newLine.join('\n'));
        } else {
          const segmentTranslate = await translateText(parts[i]);
          // 构建ruby标签
          newText.push(`[size=${config.translateSize}]<ruby><rb>${parts[i]}</rb><rt>${segmentTranslate}</rt></ruby>[/size]`);
        }
      }
    }
    return newText.join('');
  };

  const processTextArea = () => {
    let textarea = document.querySelector(`#${uiIDs.replyControl} textarea`);
    let text = textarea.value.trim();
    let originalLength = text.length;

    if (text.length !== 0 && originalLength >= REQUIRED_CHARS) {
      // 检查是否已存在拼音
      const rubyRegex = /(<ruby>[\s\S]*?<\/ruby>)/g;

      // 为中文加入翻译
      if (config.enableTranslate) {
        textarea.value = '开始翻译...';
        // // 创建并触发 input 事件
        // const inputEvent = new Event('input', {
        //   bubbles: true,
        //   cancelable: true,
        // });
        // // 触发事件
        // textarea.dispatchEvent(inputEvent);

        processTranslateText(text).then(res => {
          textarea.value = res;

          // 创建并触发 input 事件
          const inputEvent = new Event('input', {
            bubbles: true,
            cancelable: true,
          });
          // 触发事件
          textarea.dispatchEvent(inputEvent);
        });
        return;
      }

      // 为中文加入拼音
      if (!config.enableTranslate && config.enablePinYin) {
        if (containsChinese(text)) {
          // 使用正则表达式将文本分割为已被 <ruby> 包裹的部分和未被包裹的部分
          const parts = text.split(rubyRegex);

          // 处理每一部分
          text = parts
            .map(part => {
              if (rubyRegex.test(part)) {
                // 已被 <ruby> 包裹,保持原样
                return part;
              } else {
                // 未被包裹,进一步分割为中文和非中文
                if (containsChinese(part)) {
                  const segments = part.split(/([\u4e00-\u9fa5]+)/g);
                  return segments
                    .map(segment => {
                      if (containsChinese(segment)) {
                        return convertToRuby(segment);
                      } else {
                        return segment;
                      }
                    })
                    .join('');
                } else {
                  return part;
                }
              }
            })
            .join('');
        }
      }

      textarea.value = text;

      // 创建并触发 input 事件
      const inputEvent = new Event('input', {
        bubbles: true,
        cancelable: true,
      });
      // 触发事件
      textarea.dispatchEvent(inputEvent);
    }
  };

  const handleClick = event => {
    if (event.target && event.target.closest('button.create')) {
      processTextArea();
    }
  };

  let spacePresses = 0;
  let lastKeyTime = 0;
  let timeoutHandle = null;

  const handleKeydown = event => {
    if (event.ctrlKey && event.key === 'Enter') {
      processTextArea();
    }
    const currentTime = Date.now();
    if (event.code === 'Space') {
      // 如果时间间隔太长,重置计数
      if (currentTime - lastKeyTime > SPACE_PRESS_TIMEOUT) {
        spacePresses = 1;
      } else {
        spacePresses += 1;
      }

      lastKeyTime = currentTime;

      // 清除之前的定时器
      if (timeoutHandle) {
        clearTimeout(timeoutHandle);
      }

      // 设置新的定时器,如果在 SPACE_PRESS_TIMEOUT 毫秒内没有新的按键,则重置计数
      timeoutHandle = setTimeout(() => {
        spacePresses = 0;
      }, SPACE_PRESS_TIMEOUT);

      // 检查是否达到了按键次数
      if (spacePresses === SPACE_PRESS_COUNT) {
        spacePresses = 0; // 重置计数

        // 执行翻译操作
        processTextArea();
      }
    } else {
      // 如果按下了其他键,重置计数
      spacePresses = 0;
      if (timeoutHandle) {
        clearTimeout(timeoutHandle);
        timeoutHandle = null;
      }
    }
  };

  const saveConfig = () => {
    const deeplxUrlInput = document.getElementById(uiIDs.deeplxUrlInput);
    config.deeplxUrl = deeplxUrlInput.value.trim();
    const deeplxAuthKeyInput = document.getElementById(uiIDs.deeplxAuthKeyInput);
    config.deeplxAuthKey = deeplxAuthKeyInput.value.trim();
    const transalteSizeInput = document.getElementById(uiIDs.translateSizeInput);
    config.translateSize = transalteSizeInput.value;
    console.log(config);

    GM_setValue('deeplxUrl', config.deeplxUrl);
    GM_setValue('deeplxAuthKey', config.deeplxAuthKey);
    GM_setValue('enableTranslate', config.enableTranslate);
    GM_setValue('translateSize', config.translateSize);
    GM_setValue('enablePinYin', config.enablePinYin);
    GM_setValue('closeConfigAfterSave', config.closeConfigAfterSave);

    if (config.closeConfigAfterSave) {
      document.getElementById(uiIDs.configPanel).style.display = 'none';
    }
  };

  const restoreDefaults = () => {
    if (confirm('确定要将所有设置恢复为默认值吗?')) {
      config = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
      GM_setValue('deeplxUrl', config.deeplxUrl);
      GM_setValue('deeplxAuthKey', config.deeplxAuthKey);
      GM_setValue('enableTranslate', config.enableTranslate);
      GM_setValue('translateSize', config.translateSize);
      GM_setValue('enablePinYin', config.enablePinYin);
      GM_setValue('closeConfigAfterSave', config.closeConfigAfterSave);

      const panel = document.getElementById(uiIDs.configPanel);
      if (panel) {
        updateConfigPanelContent(panel);
      }
    }
  };

  const createCheckbox = (id, text, checked, onChange = undefined) => {
    const label = document.createElement('label');
    label.style.display = 'flex';
    label.style.alignItems = 'center';
    label.style.marginBottom = '10px';
    label.style.cursor = 'pointer';

    const checkbox = document.createElement('input');
    checkbox.type = 'checkbox';
    checkbox.id = id;
    checkbox.checked = checked;
    checkbox.style.marginRight = '10px';
    if (onChange !== undefined) {
      checkbox.addEventListener('change', e => onChange(e));
    } else {
      checkbox.addEventListener('change', e => {
        config[id] = e.target.checked;
        GM_setValue(id, config[id]);
      });
    }

    label.appendChild(checkbox);
    label.appendChild(document.createTextNode(text));

    return label;
  };

  const createSelect = (id, text, options, defaultValue) => {
    const container = document.createElement('div');
    container.style.display = 'flex';
    container.style.alignItems = 'center';
    container.style.marginBottom = '10px';

    const label = document.createElement('label');
    label.htmlFor = id;
    label.textContent = text;
    label.style.marginRight = '10px';

    const select = document.createElement('select');
    select.id = id;
    select.style.flex = '1';

    options.forEach(option => {
      const optionElement = document.createElement('option');
      optionElement.value = option.value;
      optionElement.textContent = option.text;
      select.appendChild(optionElement);
    });

    select.value = defaultValue;

    select.addEventListener('change', e => {
      config[id] = e.target.value;
      GM_setValue(id, config[id]);
    });

    container.appendChild(select);
    container.appendChild(label);

    return container;
  };

  const createTextInput = (id, value, labelText, placeholder, type='text') => {
    const container = document.createElement('div');
    container.style.marginBottom = '15px';

    const label = document.createElement('label');
    label.textContent = labelText;
    label.style.display = 'block';
    label.style.marginBottom = '5px';
    container.appendChild(label);

    const inputPlace = document.createElement('input');
    inputPlace.id = id;
    inputPlace.type = type;
    inputPlace.value = value;
    inputPlace.placeholder = placeholder;
    inputPlace.style.width = '100%';
    inputPlace.style.padding = '5px';
    inputPlace.style.border = '1px solid var(--panel-border)';
    inputPlace.style.borderRadius = '4px';
    inputPlace.style.backgroundColor = 'var(--panel-bg)';
    inputPlace.style.color = 'var(--panel-text)';
    container.appendChild(inputPlace);

    return [container, inputPlace];
  };

  const createTextArea = (id, value, labelText, placeholder) => {
    const container = document.createElement('div');
    container.style.marginBottom = '15px';

    const label = document.createElement('label');
    label.textContent = labelText;
    label.style.display = 'block';
    label.style.marginBottom = '5px';
    container.appendChild(label);

    const textarea = document.createElement('textarea');
    textarea.id = id;
    if (typeof value === 'string') {
      textarea.value = value;
    } else {
      textarea.value = JSON.stringify(value, null, 2);
    }
    textarea.placeholder = placeholder;
    textarea.rows = 5;
    textarea.style.width = '100%';
    textarea.style.padding = '5px';
    textarea.style.border = '1px solid var(--panel-border)';
    textarea.style.borderRadius = '4px';
    textarea.style.backgroundColor = 'var(--panel-bg)';
    textarea.style.color = 'var(--panel-text)';
    container.appendChild(textarea);

    return [container, textarea];
  };

  const createButton = (text, onClick, primary = false) => {
    const button = document.createElement('button');
    button.textContent = text;
    button.style.padding = '8px 16px';
    button.style.border = 'none';
    button.style.borderRadius = '4px';
    button.style.cursor = 'pointer';
    button.style.backgroundColor = primary ? '#0078d7' : '#f0f0f0';
    button.style.color = primary ? '#ffffff' : '#333333';
    button.addEventListener('click', onClick);
    return button;
  };

  const updateConfigPanelContent = panel => {
    panel.innerHTML = '';

    const title = document.createElement('h3');
    title.textContent = '配置';
    title.style.marginTop = '0';
    title.style.marginBottom = '15px';
    panel.appendChild(title);

    panel.appendChild(
      createTextInput(uiIDs.deeplxUrlInput, config.deeplxUrl, 'Deeplx URL', '填写 Deeplx 的请求地址')[0]
    );
    panel.appendChild(
      createTextInput(uiIDs.deeplxAuthKeyInput, config.deeplxAuthKey, 'Deeplx Auth Key', '填写 connect 的 key')[0]
    );
    panel.appendChild(
      createTextInput(uiIDs.translateSizeInput, config.translateSize, '翻译字体大小(百分比)', '默认值为150(字体大小为原始的150%)', 'number')[0]
    );
    panel.appendChild(createCheckbox('enableTranslate', '启用翻译', config.enableTranslate));
    if (checkPinYinRequire()) {
      panel.appendChild(createCheckbox('enablePinYin', '启用拼音注音', config.enablePinYin));
    }
    panel.appendChild(createCheckbox('closeConfigAfterSave', '保存后自动关闭配置页面', config.closeConfigAfterSave));

    const buttonContainer = document.createElement('div');
    buttonContainer.style.display = 'flex';
    buttonContainer.style.justifyContent = 'space-between';
    buttonContainer.style.marginTop = '20px';

    const restoreDefaultsButton = createButton('恢复默认设置', restoreDefaults);
    restoreDefaultsButton.style.marginRight = '10px';

    const saveButton = createButton('保存设置', saveConfig, true);
    const closeButton = createButton('关闭', () => {
      panel.style.display = 'none';
    });

    buttonContainer.appendChild(restoreDefaultsButton);
    buttonContainer.appendChild(saveButton);
    buttonContainer.appendChild(closeButton);
    panel.appendChild(buttonContainer);
  };

  const createConfigPanel = () => {
    // 获取页面的 <meta name="theme-color"> 标签
    const themeColorMeta = document.querySelector('meta[name="theme-color"]');
    let themeColor = '#ffffff'; // 默认白色背景
    let invertedColor = '#000000'; // 默认黑色字体

    if (themeColorMeta) {
      themeColor = themeColorMeta.getAttribute('content');
      invertedColor = getInvertColor(themeColor); // 计算相反颜色
    }

    // 设置样式变量
    const style = document.createElement('style');
    style.textContent = `
      :root {
          --panel-bg: ${themeColor};
          --panel-text: ${invertedColor};
          --panel-border: ${invertedColor};
          --button-bg: ${invertedColor};
          --button-text: ${themeColor};
          --button-hover-bg: ${getInvertColor(invertedColor)};
      }`;
    document.head.appendChild(style);

    const panel = document.createElement('div');
    panel.id = uiIDs.configPanel;
    panel.style.position = 'fixed';
    panel.style.top = '80px';
    panel.style.right = '360px';
    panel.style.padding = '20px';
    panel.style.border = `1px solid var(--panel-border)`;
    panel.style.borderRadius = '8px';
    panel.style.zIndex = '10000';
    panel.style.width = '300px';
    panel.style.maxHeight = '80%';
    panel.style.overflowY = 'auto';
    panel.style.display = 'none'; // 默认隐藏面板
    panel.style.backgroundColor = 'var(--panel-bg)';
    panel.style.color = 'var(--panel-text)';
    panel.style.boxShadow = '0 4px 6px rgba(0, 0, 0, 0.1)';

    updateConfigPanelContent(panel);

    document.body.appendChild(panel);
    return panel;
  };

  const toggleConfigPanel = () => {
    let panel = document.getElementById(uiIDs.configPanel);
    panel = panel || createConfigPanel();
    panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
  };

  const createConfigButton = () => {
    const toolbar = document.querySelector('.d-editor-button-bar');
    if (!toolbar || document.getElementById(uiIDs.configButton)) return;

    const configButton = document.createElement('button');
    configButton.id = uiIDs.configButton;
    configButton.className = 'btn btn-flat btn-icon no-text user-menu-tab active';
    configButton.title = '配置';
    configButton.innerHTML =
      '<svg class="fa d-icon d-icon-discourse-other-tab svg-icon svg-string" xmlns="http://www.w3.org/2000/svg"><use href="#discourse-other-tab"></use></svg>';
    configButton.onclick = toggleConfigPanel;

    toolbar.appendChild(configButton);
  };

  const watchReplyControl = () => {
    const replyControl = document.getElementById(uiIDs.replyControl);
    if (!replyControl) return;

    const observer = new MutationObserver(mutations => {
      mutations.forEach(mutation => {
        if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
          if (replyControl.classList.contains('closed')) {
            const panel = document.getElementById(uiIDs.configPanel);
            if (panel) {
              panel.style.display = 'none';
            }
          } else {
            // 当 reply-control 重新打开时,尝试添加配置按钮
            setTimeout(createConfigButton, 500); // 给予一些时间让编辑器完全加载
          }
        }
      });
    });

    observer.observe(replyControl, { attributes: true });
  };

  const watchForEditor = () => {
    const observer = new MutationObserver(mutations => {
      mutations.forEach(mutation => {
        if (mutation.type === 'childList') {
          const addedNodes = mutation.addedNodes;
          for (let node of addedNodes) {
            if (node.nodeType === Node.ELEMENT_NODE && node.classList.contains('d-editor')) {
              createConfigButton();
              return;
            }
          }
        }
      });
    });

    observer.observe(document.body, { childList: true, subtree: true });
  };

  const init = () => {
    const container = document.getElementById(uiIDs.replyControl);
    container.addEventListener('click', handleClick, true);
    document.addEventListener('keydown', handleKeydown, true);
    if (!document.getElementById(uiIDs.configButton)) {
      createConfigButton();
    }
    watchReplyControl();
    watchForEditor();
  };

  // 初始化
  setTimeout(() => {
    init();
  }, 1000);
})();