NexusMods 中文化插件

仅翻译 Nexus Mods 界面元素为简体中文,不修改 Mod 标题和描述。

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         NexusMods 中文化插件
// @namespace    https://github.com/SychO3/nexusmods-chinese
// @description  仅翻译 Nexus Mods 界面元素为简体中文,不修改 Mod 标题和描述。
// @version      0.1.4
// @author       SychO
// @match        https://*.nexusmods.com/*
// @match        https://nexusmods.com/*
// @run-at       document-start
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @require https://update.greasyfork.org/scripts/556780/1701542/NexusMods%20%E4%B8%AD%E6%96%87%E5%8C%96-%E8%AF%8D%E5%BA%93.js
// @supportURL   https://github.com/SychO3/nexusmods-chinese/issues
// @license      MIT
// ==/UserScript==

(function (window, document, undefined) {
  'use strict';

  const CONFIG = {
    LANG: 'zh-CN',
    // 忽略翻译的区域:
    // - Mod 详情页的长描述容器
    // - 使用 Lexical 编辑器渲染的富文本内容(合集说明、变更日志等,属于用户写的内容)
  IGNORE_SELECTORS: (function () {
      try {
        const conf = window.NEXUS_I18N && window.NEXUS_I18N.conf;
        if (conf && Array.isArray(conf.ignoreSelectors) && conf.ignoreSelectors.length > 0) {
          return conf.ignoreSelectors.join(',');
        }
      } catch (e) {
        // ignore
      }
      // 本地兜底(如果词典未提供 ignoreSelectors)
      return [
        '.mod_description_container', // <div class="container mod_description_container condensed">
        '.prose-lexical.prose'        // Lexical 富文本区域(例如合集日志正文)
      ].join(',');
    })(),
    // 在 <div class="container tab-description"> 里,允许翻译的子区域
    // 其它子区域一律不翻译
  DESC_TAB_ALLOW_SELECTORS: (function () {
      try {
        const conf = window.NEXUS_I18N && window.NEXUS_I18N.conf;
        if (conf && Array.isArray(conf.descTabAllowSelectors) && conf.descTabAllowSelectors.length > 0) {
          return conf.descTabAllowSelectors.slice();
        }
      } catch (e) {
        // ignore
      }
      // 本地兜底列表
      return [
        '#description_tab_h2',
        '.modhistory.inline-flex',
        '.actions.clearfix',
        '.accordionitems'
      ];
    })(),
    // 超过该长度的文本节点不尝试翻译,以降低误伤长段文字的概率
    MAX_TEXT_LENGTH: 200,
    // 是否屏蔽站内广告(仅隐藏常见广告容器,不影响功能)
    BLOCK_ADS: false,
    // 常见广告容器选择器(优先从词典配置中读取,方便远程更新)
    AD_SELECTORS: (function () {
      try {
        const conf = window.NEXUS_I18N && window.NEXUS_I18N.conf;
        if (conf && Array.isArray(conf.adSelectors) && conf.adSelectors.length > 0) {
          return conf.adSelectors.join(',');
        }
      } catch (e) {
        // ignore
      }
      // 本地兜底列表(如果词典未提供 adSelectors)
      return [
        '[data-testid^="ad-"]',
        '.ad-container',
        '.ad-slot',
        '.advertisement',
        '.premium-upsell-banner',
        '.new-new-premium-banner',
        '#freeTrialBanner'
      ].join(',');
    })()
  };

  let currentPageType = null;
  let currentDict = {};
  let lastUrl = window.location.href;

  // 规范化后的公共词典与各页面词典缓存,避免重复 normalize
  let normalizedPublicDict = null;
  const normalizedPageDictMap = new Map();

  // 文本翻译缓存:同一英文短语在当前页面类型下只翻译一次
  const translationCache = new Map();
  const NO_TRANSLATION = Symbol('NO_TRANSLATION');

  // 文本节点内容缓存:避免在 DOM mutation 高频时重复翻译未变更的 TextNode
  let textNodeCache = new WeakMap();

  // 预编译后的正则规则缓存
  let compiledRegexpRules = null;

  // 文本标准化:压缩所有空白为单个空格,并去掉首尾空白
  function normalizeText(str) {
    if (typeof str !== 'string') return '';
    return str.replace(/\s+/g, ' ').trim();
  }

  // 将英文短月份(Jan/Feb/...)转换为两位数字月份
  function mapShortMonth(monStr) {
    if (!monStr) return '01';
    const key = monStr.slice(0, 3);
    const monthMapShort = {
      Jan: '01', Feb: '02', Mar: '03', Apr: '04',
      May: '05', Jun: '06', Jul: '07', Aug: '08',
      Sep: '09', Oct: '10', Nov: '11', Dec: '12'
    };
    return monthMapShort[key] || '01';
  }

  // 将英文完整月份(January/...)转换为两位数字月份
  function mapFullMonth(monStr) {
    if (!monStr) return '01';
    const monthMapFull = {
      January: '01',   February: '02', March: '03',    April: '04',
      May: '05',       June: '06',     July: '07',     August: '08',
      September: '09', October: '10',  November: '11', December: '12'
    };
    return monthMapFull[monStr] || mapShortMonth(monStr);
  }

  // 12 小时制转 24 小时制,返回两位数字字符串
  function convert12hTo24(hourStr, ampm) {
    let hour = parseInt(hourStr, 10);
    if (Number.isNaN(hour)) hour = 0;
    const up = (ampm || '').toUpperCase();
    if (up === 'PM' && hour < 12) hour += 12;
    if (up === 'AM' && hour === 12) hour = 0;
    return String(hour).padStart(2, '0');
  }

  // 将 {Y}/{M}/{D}/{h}/{m} 模板占位符替换为具体数值
  // 只替换存在于 replacement 字符串中的占位符,避免多余 replace
  function applyDateTemplate(replacement, parts) {
    let result = replacement;
    const { year, month, day, hour, minute } = parts || {};

    if (year != null && result.includes('{Y}')) {
      result = result.replace('{Y}', year);
    }
    if (month != null && result.includes('{M}')) {
      result = result.replace('{M}', month);
    }
    if (day != null && result.includes('{D}')) {
      result = result.replace('{D}', day);
    }
    if (hour != null && result.includes('{h}')) {
      result = result.replace('{h}', hour);
    }
    if (minute != null && result.includes('{m}')) {
      result = result.replace('{m}', minute);
    }

    return result;
  }

  // MutationObserver 统一配置
  const observerConfig = {
    childList: true,
    subtree: true,
    characterData: true,
    // 监听常见可翻译属性 + style(用于重新隐藏被脚本改样式显示出来的广告)
    attributeFilter: ['value', 'placeholder', 'aria-label', 'title', 'data-original-title', 'style']
  };

  // 已经挂过观察器的根节点(document.body 或各个 shadowRoot)
  const observedRoots = new WeakSet();

  const STORAGE_KEYS = {
    BLOCK_ADS: 'nexusmods_chinese_block_ads'
  };

  // 最近一次因为 URL 变化而触发整页翻译的时间戳,用于简单节流
  let lastUrlTranslateAt = 0;

  function loadConfigFromStorage() {
    try {
      if (typeof GM_getValue === 'function') {
        const storedBlockAds = GM_getValue(STORAGE_KEYS.BLOCK_ADS, null);
        if (typeof storedBlockAds === 'boolean') {
          CONFIG.BLOCK_ADS = storedBlockAds;
        }
      }
    } catch (e) {
      console.warn('NexusMods 中文化插件:读取配置失败', e);
    }
  }

  /**
   * 处理任意根节点(document.body 或 shadowRoot)上的 DOM 变化
   * - 对 URL 变化做简单节流,避免短时间内多次整页遍历
   * - 将同一批次 mutation 按类型聚合,减少重复遍历与广告查询
   * - 检测大规模 DOM 变化,触发完整页面翻译(修复表单提交后翻译失效问题)
   */
  function handleMutations(mutations) {
    const currentUrl = window.location.href;
    if (currentUrl !== lastUrl) {
      lastUrl = currentUrl;

      const now = Date.now();
      const shouldFullTranslate = now - lastUrlTranslateAt > 500;
      if (shouldFullTranslate) {
        lastUrlTranslateAt = now;
        updatePageConfig('URL 变化');
        if (document.body) {
          traverseNode(document.body);
          hideAds(document.body);
        }
        translateTitle();
      }
    }

    if (!mutations || mutations.length === 0) return;

    const addedNodes = new Set();
    const textNodes = new Set();
    const attrTargets = new Set();
    const adRoots = new Set();
    let totalAddedNodeCount = 0;
    let totalRemovedNodeCount = 0;

    for (const mutation of mutations) {
      if (!mutation) continue;

      if (mutation.type === 'childList') {
        if (mutation.addedNodes && mutation.addedNodes.length) {
          totalAddedNodeCount += mutation.addedNodes.length;
          mutation.addedNodes.forEach((node) => {
            if (!node) return;
            addedNodes.add(node);
            adRoots.add(node);
          });
        }
        if (mutation.removedNodes && mutation.removedNodes.length) {
          totalRemovedNodeCount += mutation.removedNodes.length;
        }
      } else if (mutation.type === 'characterData') {
        if (mutation.target) {
          textNodes.add(mutation.target);
        }
      } else if (mutation.type === 'attributes') {
        const target = mutation.target;
        if (target && target.nodeType === Node.ELEMENT_NODE) {
          attrTargets.add(target);
          // 仅当样式 / 类名 / id 改变时,才认为可能影响广告可见性
          if (
            mutation.attributeName === 'style' ||
            mutation.attributeName === 'class' ||
            mutation.attributeName === 'id'
          ) {
            adRoots.add(target);
          }
        }
      }
    }

    // 检测大规模 DOM 变化:如果添加/删除的节点数超过阈值,触发完整页面翻译
    // 这能解决表单提交后内容大量更新但 URL 不变的情况
    const LARGE_CHANGE_THRESHOLD = 20; // 节点变化超过20个认为是大规模更新
    const isLargeChange = (totalAddedNodeCount + totalRemovedNodeCount) >= LARGE_CHANGE_THRESHOLD;
    
    if (isLargeChange) {
      // 大规模变化,对整个页面重新翻译
      const now = Date.now();
      if (now - lastUrlTranslateAt > 200) { // 简单节流,避免短时间内多次全页翻译
        lastUrlTranslateAt = now;
        if (document.body) {
          traverseNode(document.body);
          hideAds(document.body);
        }
        translateTitle();
        return; // 已经完整翻译过了,不需要再处理增量变化
      }
    }

    // 正常的增量处理流程
    // 先处理新增整棵子树
    addedNodes.forEach((node) => {
      traverseNode(node);
    });

    // 再处理纯文本变更
    textNodes.forEach((node) => {
      traverseNode(node);
    });

    // 属性变更只翻译相关属性,不再重跑整棵子树
    attrTargets.forEach((el) => {
      translateElementAttributes(el);
    });

    // 最后针对可能包含广告的节点做局部广告隐藏
    if (CONFIG.BLOCK_ADS) {
      adRoots.forEach((root) => {
        hideAds(root);
      });
    }
  }

  /**
   * 对某个根节点(document.body 或 shadowRoot)挂载 MutationObserver
   */
  function observeRoot(root) {
    if (!root || observedRoots.has(root)) return;
    observedRoots.add(root);

    try {
      const observer = new MutationObserver(handleMutations);
      observer.observe(root, observerConfig);
    } catch (e) {
      console.warn('NexusMods 中文化插件:无法观察根节点', root, e);
    }
  }

  /**
   * 屏蔽广告容器:根据 CONFIG.AD_SELECTORS 隐藏常见广告区域
   */
  function hideAds(root) {
    if (!CONFIG.BLOCK_ADS) return;
    if (!root || (root.nodeType !== Node.ELEMENT_NODE && root !== document && root !== document.body)) return;

    try {
      const base = root === document ? document.documentElement : root;
      const adSelectors = CONFIG.AD_SELECTORS;
      if (!adSelectors) return;

      // 1) 如果当前根元素本身就是广告容器,直接隐藏
      if (base.nodeType === Node.ELEMENT_NODE && base.matches && base.matches(adSelectors)) {
        base.style.setProperty('display', 'none', 'important');
        base.style.setProperty('visibility', 'hidden', 'important');
      }

      // 2) 再隐藏其内部所有广告容器
      const nodes = base.querySelectorAll(adSelectors);
      nodes.forEach((el) => {
        el.style.setProperty('display', 'none', 'important');
        el.style.setProperty('visibility', 'hidden', 'important');
      });

      // 额外布局调整:当页脚「支持 Nexus Mods」块被隐藏时,
      // 将紧随其后的「数据统计」块往上贴一点,去掉多余的顶部间距。
      try {
        const footerStats = base.querySelector('.rj-supporter-wrapper + .rj-network-stats');
        if (footerStats) {
          footerStats.style.setProperty('margin-top', '0', 'important');
        }
      } catch (e) {
        // 忽略布局调整中的错误
      }

      // 3) Premium 试用卡片等:通过 premium 按钮推断广告卡片
      const premiumLinks = base.querySelectorAll('a.nxm-button-premium[href*="/premium"]');
      premiumLinks.forEach((link) => {
        const card =
          link.closest('div.relative') ||
          link.closest('div');
        if (card) {
          card.style.setProperty('display', 'none', 'important');
          card.style.setProperty('visibility', 'hidden', 'important');
        }
      });
    } catch (e) {
      console.warn('NexusMods 中文化插件:隐藏广告时出错', e);
    }
  }

  /**
   * 检查词典是否已加载
   */
  function ensureDictionary() {
    if (typeof window.NEXUS_I18N === 'undefined') {
      console.warn('NexusMods 中文化插件:词典 window.NEXUS_I18N 未加载。');
      return false;
    }
    if (!window.NEXUS_I18N[CONFIG.LANG]) {
      console.warn('NexusMods 中文化插件:未找到语言配置 ' + CONFIG.LANG);
      return false;
    }
    return true;
  }

  /**
   * 根据当前 URL 检测页面类型
   * 使用词典文件中配置的正则 routes 来匹配
   */
  function detectPageType() {
    const path = window.location.pathname || '/';
    const conf = window.NEXUS_I18N && window.NEXUS_I18N.conf;
    const routes = conf && conf.routes;

    if (!routes || !Array.isArray(routes)) {
      return null;
    }

    for (const route of routes) {
      const [pattern, type] = route;
      if (!pattern || !type) continue;
      try {
        const re = new RegExp(pattern);
        if (re.test(path)) {
          return type;
        }
      } catch (e) {
        console.warn('NexusMods 中文化插件:无效的 URL 正则', pattern, e);
      }
    }

    return null;
  }

  /**
   * 根据当前页面类型构建翻译词典
   */
  function buildPageDict(pageType) {
    if (!ensureDictionary()) {
      currentDict = {};
      return;
    }

    const langDict = window.NEXUS_I18N[CONFIG.LANG] || {};

    // 将词典的 key 也做一次空白标准化,避免因为换行/多空格导致匹配不上
    function normalizeDict(dictObj) {
      const result = {};
      if (!dictObj) return result;
      for (const key in dictObj) {
        if (!Object.prototype.hasOwnProperty.call(dictObj, key)) continue;
        const normKey = normalizeText(key);
        if (!normKey) continue;
        result[normKey] = dictObj[key];
      }
      return result;
    }

    // 公共词典只需规范化一次
    if (!normalizedPublicDict) {
      normalizedPublicDict = normalizeDict(langDict.public || {});
    }

    let pageDict = {};
    if (pageType) {
      const cacheKey = pageType;
      if (normalizedPageDictMap.has(cacheKey)) {
        pageDict = normalizedPageDictMap.get(cacheKey) || {};
      } else {
        pageDict = normalizeDict(langDict[pageType] || {});
        normalizedPageDictMap.set(cacheKey, pageDict);
      }
    }

    currentDict = Object.assign({}, normalizedPublicDict, pageDict);
  }

  /**
   * 重新识别页面并构建词典
   */
  function updatePageConfig(trigger) {
    const newType = detectPageType();
    const pageTypeChanged = newType !== currentPageType;

    // 无论页面类型是否为 null,都更新当前类型并重建词典(至少保证 public 生效)
    currentPageType = newType;
    buildPageDict(currentPageType);

    // 页面配置变化时清空翻译相关缓存,避免旧页面结果干扰新页面
    translationCache.clear();
    textNodeCache = new WeakMap();

    // 只有在页面类型发生变化时,才对整页重新翻译,避免过于频繁
    if (pageTypeChanged) {
      if (document.body) {
        traverseNode(document.body);
      }
      translateTitle();
      console.log(`NexusMods 中文化插件:${trigger} 触发,页面类型 = ${currentPageType || 'unknown'}`);
    }
  }

  /**
   * 文本翻译:先完整匹配词典,再用字典里配置的正则做动态替换
   * 支持简单日期格式转换(例如 15 Nov 2025 -> 2025-11-15)
   */
  function translateText(raw) {
    if (!raw) return raw;
    const text = normalizeText(raw);
    if (!text) return raw;

    // 先尝试完整匹配词典(即使是长文本,只要你在字典里显式配置,就允许翻译)
    const translated = currentDict[text];
    if (translated) {
      return translated;
    }

    // 再对“非词典命中的文本”做长度限制,避免误伤长段用户内容
    if (text.length > CONFIG.MAX_TEXT_LENGTH) {
      return raw;
    }

    // 对较短的 UI 文本启用翻译结果缓存(按标准化后的 text 为 key)
    const useCache = text.length <= CONFIG.MAX_TEXT_LENGTH;
    if (useCache) {
      const cached = translationCache.get(text);
      if (cached !== undefined) {
        if (cached === NO_TRANSLATION) {
          return raw;
        }
        return cached;
      }
    }

    // 再尝试 I18N.conf.regexpRules 中配置的正则规则(使用预编译版本)
    const rules = getCompiledRegexpRules();

    if (rules && rules.length > 0) {
      for (const rule of rules) {
        const { re, replacement, type, patternDesc } = rule;
        if (!re || !replacement) continue;
        try {
          const match = text.match(re);
          if (!match) continue;

          // 特殊类型 1:英文日期(缩写月份)"15 Nov 2025" -> "2025-11-15"
          if (type === 'date_en_dMY') {
            const day = parseInt(match[1], 10);
            const monStr = match[2];
            const year = match[3];

            const mm = mapShortMonth(monStr);
            const dd = String(day).padStart(2, '0');
            return applyDateTemplate(replacement, {
              year,
              month: mm,
              day: dd
            });
          }

          // 特殊类型 1c:"16 Nov 2025, 1:55PM | Action by:" -> "2025-11-16 13:55 | 操作:"
          if (type === 'date_en_dMYhm_action') {
            const day = parseInt(match[1], 10);
            const monStr = match[2];
            const year = match[3];
            const hourRaw = match[4];
            const minute = match[5];
            const ampm = match[6];

            const mm = mapShortMonth(monStr);
            const dd = String(day).padStart(2, '0');
            const HH = convert12hTo24(hourRaw, ampm);

            return applyDateTemplate(replacement, {
              year,
              month: mm,
              day: dd,
              hour: HH,
              minute
            });
          }

          // 特殊类型 1b:英文日期(完整月份)"15 November 2025" -> "2025-11-15"
          if (type === 'date_en_dFY') {
            const day = parseInt(match[1], 10);
            const monStr = match[2];
            const year = match[3];

            const mm = mapFullMonth(monStr);
            const dd = String(day).padStart(2, '0');
            return applyDateTemplate(replacement, {
              year,
              month: mm,
              day: dd
            });
          }

          // 特殊类型 1d:仅月份和年份 "November 2025" / "Nov 2025" -> "2025-11"
          if (type === 'date_en_FY') {
            const monStr = match[1];
            const year = match[2];

            // 只接受合法月份,避免把 "Game 2025" 之类误判为日期
            const key = monStr.slice(0, 3);
            const monthMapShortStrict = {
              Jan: '01', Feb: '02', Mar: '03', Apr: '04',
              May: '05', Jun: '06', Jul: '07', Aug: '08',
              Sep: '09', Oct: '10', Nov: '11', Dec: '12'
            };
            const mm = monthMapShortStrict[key];
            if (!mm) {
              return raw;
            }

            return applyDateTemplate(replacement, {
              year,
              month: mm
            });
          }

          // 特殊类型 2:完整月份 + 12 小时制时间
          // 示例:"15 November 2025, 9:16AM" -> "2025-11-15 09:16"
          if (type === 'date_en_dFYGis') {
            const day = parseInt(match[1], 10);
            const monStr = match[2];
            const year = match[3];
            const hourRaw = match[4];
            const minute = match[5];
            const ampm = match[6];

            const mm = mapFullMonth(monStr);
            const dd = String(day).padStart(2, '0');
            const HH = convert12hTo24(hourRaw, ampm);

            return applyDateTemplate(replacement, {
              year,
              month: mm,
              day: dd,
              hour: HH,
              minute
            });
          }

          // 特殊类型 2b:仅时间 + AM/PM,例如 "10:02PM" -> "22:02"
          if (type === 'time_en_hmAP') {
            const hourRaw = match[1];
            const minute = match[2];
            const ampm = match[3];

            const HH = convert12hTo24(hourRaw, ampm);

            return applyDateTemplate(replacement, {
              hour: HH,
              minute
            });
          }

          // 特殊类型 3:"Uploaded at 21:21 03 Nov 2025" -> "上传于 2025-11-03 21:21"
          if (type === 'date_en_time_dMY') {
            const hourRaw = match[1];
            const minute = match[2];
            const day = parseInt(match[3], 10);
            const monStr = match[4];
            const year = match[5];

            const mm = mapShortMonth(monStr);
            const dd = String(day).padStart(2, '0');
            const HH = String(parseInt(hourRaw, 10)).padStart(2, '0');

            return applyDateTemplate(replacement, {
              year,
              month: mm,
              day: dd,
              hour: HH,
              minute
            });
          }

          // 特殊类型 4:"06:31, 16 Nov 2025" -> "2025-11-16 06:31"
          if (type === 'date_en_GijMY') {
            const hourRaw = match[1];
            const minute = match[2];
            const day = parseInt(match[3], 10);
            const monStr = match[4];
            const year = match[5];

            const mm = mapShortMonth(monStr);
            const dd = String(day).padStart(2, '0');
            const HH = String(parseInt(hourRaw, 10)).padStart(2, '0');

            return applyDateTemplate(replacement, {
              year,
              month: mm,
              day: dd,
              hour: HH,
              minute
            });
          }

          // 特殊类型 4b:"Uploaded 02 Apr 2016, 10:23" -> "上传于 2016-04-02 10:23"
          if (type === 'date_en_dMYhm') {
            const day = parseInt(match[1], 10);
            const monStr = match[2];
            const year = match[3];
            const hourRaw = match[4];
            const minute = match[5];

            const mm = mapShortMonth(monStr);
            const dd = String(day).padStart(2, '0');
            const HH = String(parseInt(hourRaw, 10)).padStart(2, '0');

            return applyDateTemplate(replacement, {
              year,
              month: mm,
              day: dd,
              hour: HH,
              minute
            });
          }

          // 特殊类型 5:相对时间:"4 weeks ago" / "1 day ago" / "2 years ago"
          if (type === 'rel_time_en') {
            const n = parseInt(match[1], 10);
            const unitRaw = match[2]; // 原始英文单位
            const unit = unitRaw.toLowerCase();

            let suffix = '';
            if (unit.startsWith('second') || unit === 'sec' || unit === 'secs') {
              suffix = '秒前';
            } else if (unit.startsWith('minute') || unit === 'min' || unit === 'mins') {
              suffix = '分钟前';
            } else if (unit.startsWith('hour') || unit === 'hr' || unit === 'hrs') {
              suffix = '小时前';
            } else if (unit.startsWith('day')) {
              suffix = '天前';
            } else if (unit.startsWith('week') || unit === 'wk' || unit === 'wks') {
              suffix = '周前';
            } else if (unit.startsWith('month') || unit === 'mo' || unit === 'mos') {
              suffix = '个月前';
            } else if (unit.startsWith('year') || unit === 'yr' || unit === 'yrs') {
              suffix = '年前';
            } else {
              // 未知单位,直接返回原文
              return raw;
            }

            return `${n} ${suffix}`;
          }

          // 特殊类型 5b:相对时间拆分形式:"2 years"(另一个节点是 "ago")
          if (type === 'rel_time_en_partial') {
            const n = parseInt(match[1], 10);
            const unitRaw = match[2];
            const unit = unitRaw.toLowerCase();

            let suffix = '';
            if (unit.startsWith('second') || unit === 'sec' || unit === 'secs') {
              suffix = '秒';
            } else if (unit.startsWith('minute') || unit === 'min' || unit === 'mins') {
              suffix = '分钟';
            } else if (unit.startsWith('hour') || unit === 'hr' || unit === 'hrs') {
              suffix = '小时';
            } else if (unit.startsWith('day')) {
              suffix = '天';
            } else if (unit.startsWith('week') || unit === 'wk' || unit === 'wks') {
              suffix = '周';
            } else if (unit.startsWith('month') || unit === 'mo' || unit === 'mos') {
              suffix = '个月';
            } else if (unit.startsWith('year') || unit === 'yr' || unit === 'yrs') {
              suffix = '年';
            } else {
              return raw;
            }

            return `${n} ${suffix}`;
          }

          // 特殊类型 6:时间范围 "Time range: 7 Days" / "Time range: 24 Hours"
          if (type === 'time_range_en') {
            const n = match[1];
            const unitRaw = match[2].toLowerCase();

            let unitZh = '';
            if (unitRaw.startsWith('hour')) {
              unitZh = '小时';
            } else if (unitRaw.startsWith('day')) {
              unitZh = '天';
            } else if (unitRaw.startsWith('year')) {
              unitZh = '年';
            } else {
              return raw;
            }

            return `时间范围:${n} ${unitZh}`;
          }

          // 默认:直接使用字符串替换
          if (re.test(text)) {
            return text.replace(re, replacement);
          }
        } catch (e) {
          console.warn('NexusMods 中文化插件:无效的正则规则', patternDesc || re, e);
        }
      }
    }

    // 没有命中任何规则,缓存“无翻译”结果
    if (useCache) {
      translationCache.set(text, NO_TRANSLATION);
    }

    return raw;
  }

  /**
   * 预编译 I18N.conf.regexpRules,避免在每次翻译时重复构造 RegExp
   */
  function getCompiledRegexpRules() {
    if (compiledRegexpRules) {
      return compiledRegexpRules;
    }

    const conf = window.NEXUS_I18N && window.NEXUS_I18N.conf;
    const regexpRules = conf && conf.regexpRules;

    const compiled = [];
    if (regexpRules && Array.isArray(regexpRules)) {
      for (const rule of regexpRules) {
        if (!rule || rule.length < 2) continue;
        const [pattern, replacement, type] = rule;
        if (!pattern || !replacement) continue;
        try {
          const re = pattern instanceof RegExp ? pattern : new RegExp(pattern);
          compiled.push({
            re,
            replacement,
            type,
            patternDesc: pattern
          });
        } catch (e) {
          console.warn('NexusMods 中文化插件:编译正则规则失败', rule, e);
        }
      }
    }

    compiledRegexpRules = compiled;
    return compiledRegexpRules;
  }

  /**
   * 判断某个元素是否应该被忽略翻译
   */
  function shouldIgnoreElement(el) {
    if (!el || el.nodeType !== Node.ELEMENT_NODE) return false;

    // 全局忽略选择器(如整块描述容器)
    if (CONFIG.IGNORE_SELECTORS && el.matches && el.matches(CONFIG.IGNORE_SELECTORS)) {
      return true;
    }

    // 描述 Tab 内的细粒度控制:
    // 在 .container.tab-description 里,只有特定子块允许翻译,其余一律忽略
    const descContainer = el.closest && el.closest('.container.tab-description');
    // 只对“容器内部的元素”启用白名单;容器本身仍然需要遍历
    if (descContainer && !el.matches('.container.tab-description')) {
      const allowSelectors = CONFIG.DESC_TAB_ALLOW_SELECTORS || [];
      for (const sel of allowSelectors) {
        if (!sel) continue;
        const allowedBlock = el.closest(sel);
        if (allowedBlock && descContainer.contains(allowedBlock)) {
          // 位于允许翻译的区域
          return false;
        }
      }
      // 在描述 Tab 里,但不在允许翻译的几个块中 → 忽略
      return true;
    }

    return false;
  }

  /**
   * 翻译元素的常见文本属性
   */
  function translateElementAttributes(el) {
    if (!el || el.nodeType !== Node.ELEMENT_NODE) return;

    const attrs = ['value', 'placeholder', 'aria-label', 'title', 'data-original-title'];

    for (const name of attrs) {
      const original = el.getAttribute(name);
      if (original) {
        const translated = translateText(original);
        if (translated !== original) {
          el.setAttribute(name, translated);
        }
      }
    }
  }

  /**
   * 判断元素是否是图标容器(如 Material Icons、Font Awesome 等)
   */
  function isIconElement(el) {
    if (!el || el.nodeType !== Node.ELEMENT_NODE) return false;
    
    const classList = el.classList;
    if (!classList) return false;
    
    // 检查常见的图标类名
    const iconClasses = [
      'material-icons',
      'material-icons-outlined',
      'material-icons-round',
      'material-icons-sharp',
      'material-icons-two-tone',
      'fa',      // Font Awesome
      'fas',     // Font Awesome Solid
      'far',     // Font Awesome Regular
      'fal',     // Font Awesome Light
      'fab',     // Font Awesome Brands
      'icon',    // 通用图标类
      'glyphicon' // Bootstrap Glyphicons
    ];
    
    for (const iconClass of iconClasses) {
      if (classList.contains(iconClass)) {
        return true;
      }
    }
    
    return false;
  }

  /**
   * 遍历并翻译节点
   */
  function traverseNode(rootNode) {
    if (!rootNode) return;

    // 根元素本身需要忽略时,整棵子树都不翻译
    if (rootNode.nodeType === Node.ELEMENT_NODE && shouldIgnoreElement(rootNode)) {
      return;
    }

    // 文本节点
    if (rootNode.nodeType === Node.TEXT_NODE) {
      // 如果文本节点位于忽略区域内(例如 .prose-lexical.prose),则不翻译
      const parentEl = rootNode.parentElement;
      if (
        parentEl &&
        CONFIG.IGNORE_SELECTORS &&
        parentEl.closest &&
        parentEl.closest(CONFIG.IGNORE_SELECTORS)
      ) {
        return;
      }

      // 如果父元素是图标容器,则不翻译(图标字体的文本标识符不应被翻译)
      if (parentEl && isIconElement(parentEl)) {
        return;
      }

      const originalText = rootNode.data;
      // 如果文本内容未变化,则跳过翻译,避免在高频 mutation 时重复处理
      const lastProcessed = textNodeCache.get(rootNode);
      if (lastProcessed === originalText) {
        return;
      }

      const translatedText = translateText(originalText);
      if (translatedText !== originalText) {
        rootNode.data = translatedText;
      }
      // 记录当前(可能已翻译后的)文本,后续变更时再处理
      textNodeCache.set(rootNode, rootNode.data);
      return;
    }

    // 元素节点:先翻译属性,再遍历子节点 & shadowRoot
    if (rootNode.nodeType === Node.ELEMENT_NODE) {
      translateElementAttributes(rootNode);

      // 如果这是一个 Web Component,且有开放的 shadowRoot,则也需要翻译其中内容
      if (rootNode.shadowRoot) {
        // 确保对 shadowRoot 也挂上 MutationObserver,处理后续动态变化
        observeRoot(rootNode.shadowRoot);
        traverseNode(rootNode.shadowRoot);
      }

      const childNodes = rootNode.childNodes;
      if (!childNodes || childNodes.length === 0) return;

      for (let i = 0; i < childNodes.length; i++) {
        const child = childNodes[i];

        // 对于子元素,再次检查忽略区域(包括描述 Tab 的细粒度规则)
        if (child.nodeType === Node.ELEMENT_NODE && shouldIgnoreElement(child)) {
          continue;
        }
        traverseNode(child);
      }
    }
  }

  /**
   * 翻译页面标题
   * 仅做完整匹配,避免破坏带游戏名/模组名的标题
   */
  function translateTitle() {
    if (!document || !document.title) return;
    const original = document.title;
    const translated = translateText(original);
    if (translated !== original) {
      document.title = translated;
    }
  }

  /**
   * 补丁:强制修正顶部导航里的 Shadow DOM Upload 按钮
   * 目标:<upload-modal-button> 组件内部的 <span>Upload</span> → "上传"
   * 说明:
   * - 有时该组件使用 Declarative Shadow DOM / 异步挂载,常规遍历可能错过;
   * - 这里直接遍历所有 upload-modal-button 的 shadowRoot,按字典翻译一次文本。
   */
  const patchedUploadHosts = new WeakSet();

  function patchUploadButtons() {
    try {
      const hosts = document.querySelectorAll('upload-modal-button');
      if (!hosts || hosts.length === 0) return;

      hosts.forEach((host) => {
        if (patchedUploadHosts.has(host)) return;
        const sr = host.shadowRoot;
        if (!sr) return;

        const spans = sr.querySelectorAll('span');
        spans.forEach((span) => {
          const original = span.textContent;
          const norm = normalizeText(original || '');
          if (!norm || norm.length > CONFIG.MAX_TEXT_LENGTH) return;

          // 只处理简单的按钮文案,避免误伤其它长文本
          if (norm === 'Upload') {
            const translated = translateText(norm);
            if (translated && translated !== norm) {
              span.textContent = translated;
              patchedUploadHosts.add(host);
            }
          }
        });
      });
    } catch (e) {
      console.warn('NexusMods 中文化插件:Upload 按钮补丁失败', e);
    }
  }

  /**
   * 监听 DOM 变化 & URL 变化
   */
  function watchUpdate() {
    if (document.body) {
      observeRoot(document.body);
      hideAds(document.body);
      // 补丁:初次挂载时尝试修正 Upload 按钮
      patchUploadButtons();
    } else {
      // 兜底:等待 body 出现后再开始监听
      const intervalId = setInterval(() => {
        if (document.body) {
          clearInterval(intervalId);
          observeRoot(document.body);
          // 初次翻译
          updatePageConfig('body 就绪');
          // 补丁:body 就绪后再修正一次 Upload 按钮
          patchUploadButtons();
        }
      }, 50);
    }
  }

  /**
   * 兼容旧账号页面顶部标签栏(如 Security / Billing)
   * 这些标签使用 <ul class="nav nav-old"> 结构,点击后页面内容更新但有时翻译不及时。
   * 这里在捕获到点击这些标签时,稍作延迟后对整页再跑一遍翻译。
   */
  function watchOldNavTabs() {
    document.addEventListener(
      'click',
      (event) => {
        const target = event.target;
        if (!target || !(target instanceof Element)) return;

        const navLink = target.closest('.nav.nav-old .nav-link');
        if (!navLink) return;

        // 给页面一点时间完成内容切换,再进行翻译
        setTimeout(() => {
          if (document.body) {
            traverseNode(document.body);
          }
          translateTitle();
        }, 80);

        // 再在短时间内多次重跑翻译,防止站点脚本后续覆盖文本导致“闪一下又变回英文”
        let rerunCount = 0;
        const maxRerun = 5; // 最多额外执行 5 次
        const intervalId = setInterval(() => {
          if (document.body) {
            traverseNode(document.body);
          }
          translateTitle();
          rerunCount += 1;
          if (rerunCount >= maxRerun) {
            clearInterval(intervalId);
          }
        }, 250);
      },
      true // 捕获阶段,以免被站点自己的事件提前阻止
    );
  }

  /**
   * 监听表单提交,确保提交后的内容更新能被正确翻译
   * 修复验证码错误等表单提交后翻译失效的问题
   */
  function watchFormSubmissions() {
    document.addEventListener(
      'submit',
      (event) => {
        // 给页面时间处理表单提交和更新内容
        setTimeout(() => {
          if (document.body) {
            traverseNode(document.body);
            hideAds(document.body);
          }
          translateTitle();
        }, 100);

        // 多次重试翻译,确保动态加载的内容也能被翻译
        let rerunCount = 0;
        const maxRerun = 3;
        const intervalId = setInterval(() => {
          if (document.body) {
            traverseNode(document.body);
          }
          rerunCount += 1;
          if (rerunCount >= maxRerun) {
            clearInterval(intervalId);
          }
        }, 300);
      },
      true // 捕获阶段
    );
  }

  /**
   * 监听站内链接点击,确保页面跳转后能正确翻译
   * 适用于所有通过链接触发的页面跳转场景
   */
  function watchInternalLinks() {
    document.addEventListener(
      'click',
      (event) => {
        const target = event.target;
        if (!target) return;

        // 检查是否点击了链接
        const link = target.closest('a');
        if (!link || !link.href) return;

        // 只处理站内链接(nexusmods.com 或 users.nexusmods.com)
        try {
          const linkUrl = new URL(link.href);
          const currentHost = window.location.hostname;
          
          // 检查是否是 Nexus Mods 站内链接
          const isNexusModsLink = linkUrl.hostname.includes('nexusmods.com');
          const isSameHost = linkUrl.hostname === currentHost;
          
          if (!isNexusModsLink && !isSameHost) return;
          
          // 检查是否是页面内锚点跳转(不需要翻译)
          if (linkUrl.pathname === window.location.pathname && linkUrl.hash) return;

        } catch (e) {
          // URL 解析失败,可能是相对路径,继续处理
        }

        // 检测到站内链接点击,延迟后强制翻译
        // 使用多个不同的延迟时间,确保能捕获到页面内容
        const delays = [100, 300, 500, 800, 1200];
        
        delays.forEach((delay) => {
          setTimeout(() => {
            // 强制重置节流时间戳,确保翻译能够执行
            lastUrlTranslateAt = 0;
            updatePageConfig('站内链接跳转');
            if (document.body) {
              traverseNode(document.body);
              hideAds(document.body);
            }
            translateTitle();
          }, delay);
        });
      },
      true // 捕获阶段
    );
  }

  /**
   * Hook History API,监听 SPA 应用的路由变化
   * 确保使用 pushState/replaceState 的页面跳转也能正确翻译
   */
  function hookHistoryAPI() {
    const originalPushState = history.pushState;
    const originalReplaceState = history.replaceState;

    function afterHistoryChange() {
      // 使用多个延迟时间进行翻译重试
      const delays = [50, 150, 300, 600];
      
      delays.forEach((delay) => {
        setTimeout(() => {
          lastUrlTranslateAt = 0;
          updatePageConfig('History API 变化');
          if (document.body) {
            traverseNode(document.body);
            hideAds(document.body);
          }
          translateTitle();
        }, delay);
      });
    }

    history.pushState = function(...args) {
      const result = originalPushState.apply(this, args);
      afterHistoryChange();
      return result;
    };

    history.replaceState = function(...args) {
      const result = originalReplaceState.apply(this, args);
      afterHistoryChange();
      return result;
    };

    // 监听 popstate 事件(浏览器前进/后退按钮)
    window.addEventListener('popstate', () => {
      afterHistoryChange();
    });
  }

  /**
   * 在 Tampermonkey 菜单中提供简单的配置入口(目前只暴露“广告屏蔽开关”)
   */
  function setupMenuCommands() {
    try {
      if (typeof GM_registerMenuCommand !== 'function') return;

      const label = CONFIG.BLOCK_ADS
        ? '广告屏蔽:已开启(点击关闭并刷新页面)'
        : '广告屏蔽:已关闭(点击开启并刷新页面)';

      GM_registerMenuCommand(label, () => {
        const next = !CONFIG.BLOCK_ADS;
        CONFIG.BLOCK_ADS = next;
        if (typeof GM_setValue === 'function') {
          GM_setValue(STORAGE_KEYS.BLOCK_ADS, next);
        }
        // 简单粗暴:切换配置后刷新页面,避免状态不一致
        window.location.reload();
      });
    } catch (e) {
      console.warn('NexusMods 中文化插件:注册菜单命令失败', e);
    }
  }

  /**
   * Hook attachShadow:对之后动态创建的 shadowRoot 也进行翻译和监听
   * (脚本 run-at=document-start,能在站点脚本之前生效)
   */
  if (Element.prototype.attachShadow) {
    const rawAttachShadow = Element.prototype.attachShadow;
    Element.prototype.attachShadow = function(init) {
      const shadowRoot = rawAttachShadow.call(this, init);
      // 仅观察新创建的 shadowRoot,等其内容挂载后再由 MutationObserver 统一处理
      observeRoot(shadowRoot);
      return shadowRoot;
    };
  }

  /**
   * 初始化
   */
  function init() {
    // 从存储中加载用户配置(例如是否屏蔽广告)
    loadConfigFromStorage();

    // 设置文档语言为中文
    document.documentElement.lang = CONFIG.LANG;

    // 初次配置与翻译
    updatePageConfig('首次载入');
    // 对初次加载的页面做一次遍历翻译,保证即使 pageType 为 null 也能应用 public 词条
    if (document.body) {
      traverseNode(document.body);
      hideAds(document.body);
      // 补丁:强制修正一次 Upload 按钮(Shadow DOM)
      patchUploadButtons();
    }
    translateTitle();

    // Hook History API,监听 SPA 路由变化
    hookHistoryAPI();
    // 监视 DOM 更新
    watchUpdate();
    // 兼容旧账号页面标签栏(Security / Billing 等)
    watchOldNavTabs();
    // 监听表单提交事件
    watchFormSubmissions();
    // 监听站内链接点击
    watchInternalLinks();
    // 注册脚本菜单
    setupMenuCommands();
  }

  // DOMContentLoaded 之后启动
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init, { once: true });
  } else {
    init();
  }
})(window, document);