GitHub汉化指南
浏览器安装油猴脚本
新建脚本,粘贴以下内容
// ==UserScript==
// @name GitHub 中文化插件
// @namespace https://github.com/maboloshi/github-chinese
// @description 中文化 GitHub 界面的部分菜单及内容。原作者为楼教主(http://www.52cik.com/)。
// @copyright 2021, 沙漠之子 (https://maboloshi.github.io/Blog)
// @icon https://github.githubassets.com/pinned-octocat.svg
// @version 1.9.4.4-2026-06-21
// @author 沙漠之子
// @license GPL-3.0
// @match https://github.com/*
// @match https://skills.github.com/*
// @match https://gist.github.com/*
// @match https://education.github.com/*
// @match https://www.githubstatus.com/*
// @require https://raw.githubusercontent.com/maboloshi/github-chinese/gh-pages/locals.js?v1.9.4.4-2026-06-21
// @run-at document-start
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant GM_notification
// @connect fanyi.iflyrec.com
// @supportURL https://github.com/maboloshi/github-chinese/issues
// ==/UserScript==
(function (window, document, undefined) {
'use strict';
/* =========================== 全局配置常量 =========================== */
const CONFIG = {
LANG: 'zh-CN', // 默认语言
DEV: false, // 默认不开启开发者模式
PAGE_MAP: { // 站点域名 -> 类型映射
'gist.github.com': 'gist',
'www.githubstatus.com': 'status',
'skills.github.com': 'skills',
'education.github.com': 'education'
},
SPECIAL_SITES: ['gist', 'status', 'skills', 'education'], // 特殊站点类型
DESC_SELECTORS: { // 简介元素的CSS选择器
repository: ".f4.tmp-my-3",
gist: ".gist-content [itemprop='about']"
},
OBSERVER_CONFIG: { // MutationObserver配置
childList: true,
subtree: true,
characterData: true,
attributeFilter: ['value', 'placeholder', 'aria-label', 'data-confirm']
},
TRANS_ENGINES: { // 翻译引擎配置
iflyrec: {
name: '讯飞听见',
url: 'https://fanyi.iflyrec.com/text-translate',
url_api: 'https://fanyi.iflyrec.com/TJHZTranslationService/v2/textAutoTranslation',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Origin': 'https://fanyi.iflyrec.com'
},
getRequestData: (text) => ({
from: 2, // 英语
to: 1, // 简体中文
type: 1,
contents: [{ text: text }]
}),
responseIdentifier: 'biz[0]?.sectionResult[0]?.dst', // 翻译结果在响应中的路径
},
},
STYLES: `
/* 基础样式变量 */
:root {
--ghc-primary-color: #1b95e0;
--ghc-bg-color: #f8f9fa;
--ghc-border-color: #e1e4e8;
--ghc-button-bg: #f6f8fa;
}
/* 浅色主题样式(默认) */
.translate-button {
color: var(--ghc-primary-color);
font-size: small;
cursor: pointer;
margin-top: 5px;
display: inline-block;
}
.translation-result {
margin-top: 10px;
padding: 8px;
border: 1px solid var(--ghc-border-color);
background-color: var(--ghc-button-bg);
border-radius: 6px;
}
.translation-credit {
font-size: small;
color: var(--ghc-primary-color);
}
.translation-content {
margin-top: 5px;
white-space: pre-wrap;
}
/* 暗色主题适配 - 使用 prefers-color-scheme */
@media (prefers-color-scheme: dark) {
:root {
--ghc-primary-color: #58a6ff;
--ghc-bg-color: #0d1117;
--ghc-border-color: #30363d;
--ghc-button-bg: #21262d;
}
}
`
};
/* =========================== 状态管理器 =========================== */
const State = {
// 功能开关
featureSet: {
enable_RegExp: GM_getValue("enable_RegExp", true),
enable_transDesc: GM_getValue("enable_transDesc", true),
enable_missedTerms: GM_getValue("enable_missedTerms", false),
enable_onurlchange: false,
},
// 当前运行时状态
pageConfig: null, // 当前页面配置(null 表示无有效页面)
currentURL: window.location.href, // 当前页面URL
transEngine: 'iflyrec', // 当前翻译引擎
mutationObserver: null, // DOM变化观察器
urlChangeHandler: null, // 存储URL变化处理器
dynamicMenus: {}, // 动态菜单ID记录
initDone: false,
};
/* =========================== 安全检查 =========================== */
/**
* 检查词库文件是否加载 — 未加载则抛出错误阻止继续执行
*/
function checkI18NLoaded() {
if (typeof I18N === 'undefined') {
alert('GitHub 汉化插件:词库文件 locals.js 未加载,脚本无法运行!');
throw new Error('[GitHub 中文化插件] 词库文件 locals.js 未加载');
}
}
/**
* 错误边界 — 包装函数,捕获异常避免阻断页面正常使用
* @param {Function} fn - 要执行的函数
* @param {string} label - 错误标签
* @returns {Function} 包装后的函数
*/
function safe(fn, label) {
return function (...args) {
try {
return fn.apply(this, args);
} catch (e) {
console.error(`[GitHub 中文化插件] ${label} 出错:`, e);
}
};
}
/* =========================== 初始化入口 =========================== */
function init() {
checkI18NLoaded();
setupReactGlobalNavTranslation();
initLangEnv();
injectStyles();
setupMenuCommands();
setupInitTrans();
setupUrlChangeListener();
setupTurboEvents();
State.initDone = true;
}
/**
* 初始化并保护中文语言环境
*/
function initLangEnv() {
// 设置初始语言
document.documentElement.lang = CONFIG.LANG;
// 监视语言属性变化,防止被改回英文
const langObserver = new MutationObserver(() => {
// 如果检测到语言被改回英文,重新设置
if (document.documentElement.lang === "en") {
document.documentElement.lang = CONFIG.LANG;
}
});
langObserver.observe(document.documentElement, { attributeFilter: ['lang'] });
}
/**
* 注入自定义样式到页面
*/
function injectStyles() {
GM_addStyle(CONFIG.STYLES);
}
/**
* 设置初始翻译
*
* 即使 @run-at document-start,Tampermonkey 注入脚本也可能晚于 DOMContentLoaded
*(扩展冷启动、bfcache 恢复等场景)。因此不能假设注册监听器时事件尚未触发:
* readyState 已是 interactive/complete 则直接执行,否则才注册一次性监听器。
*/
function setupInitTrans() {
function doInitTrans() {
updatePageConfig('首次载入');
if (State.pageConfig) {
safe(traverseNode, '首次遍历')(document.body);
}
setupMutationObserver(); // 设置DOM变化观察器
}
if (document.readyState === 'interactive' || document.readyState === 'complete') {
// 文档已就绪,直接执行
doInitTrans();
} else {
// 等待 DOMContentLoaded
window.addEventListener('DOMContentLoaded', doInitTrans, { once: true });
}
}
/* =========================== URL 变化监听 =========================== */
/**
* 设置URL变化监听器
* Tampermonkey 环境使用 onurlchange 事件,其他环境回退到 MutationObserver URL 检测
*/
function setupUrlChangeListener() {
// Tampermonkey 环境下 window.onurlchange 为 null(支持),其他环境为 undefined
if (State.featureSet.enable_onurlchange && window.onurlchange === null) {
// 创建URL变化处理函数
State.urlChangeHandler = function (event) {
console.log("URL变化检测 (Tampermonkey onurlchange)", event);
handleUrlChange();
};
window.addEventListener('urlchange', State.urlChangeHandler);
console.log("🛠️ 开发者模式:已启用 onurlchange 事件监听");
} else {
console.log("当前环境不支持 onurlchange 事件,使用传统URL检测方式");
}
}
/**
* 处理URL变化
*/
function handleUrlChange() {
const currentURL = window.location.href;
// 如果URL没有实际变化,则跳过处理
if (currentURL === State.currentURL) return;
State.currentURL = currentURL;
updatePageConfig("URL变化 (onurlchange)");
// 重新设置观察器
if (State.mutationObserver) {
State.mutationObserver.disconnect();
}
// 如果页面类型有效,重新遍历DOM
if (State.pageConfig) {
safe(traverseNode, 'URL变化遍历')(document.body);
}
setupMutationObserver();
}
/* =========================== Turbo 事件 =========================== */
/**
* 设置Turbo框架事件监听
* 处理GitHub的Turbolinks页面切换
*/
function setupTurboEvents() {
document.addEventListener('turbo:load', handleTurboLoad);
}
/**
* 处理Turbo页面加载事件
* 在新页面加载后执行必要的翻译
*/
function handleTurboLoad() {
if (!State.pageConfig) return;
transTitle(); // 翻译页面标题
transBySelector(); // 通过选择器翻译特定元素
// 如果描述翻译功能启用,翻译页面描述
if (State.featureSet.enable_transDesc &&
CONFIG.DESC_SELECTORS[State.pageConfig.currentPageType]) {
transDesc(CONFIG.DESC_SELECTORS[State.pageConfig.currentPageType]);
}
}
/* =========================== 页面配置管理 =========================== */
/**
* 更新页面配置 — 页面类型变化时重建 State.pageConfig
* @param {string} trigger - 触发更新的原因(用于调试)
*/
function updatePageConfig(trigger) {
const newType = detectPageType();
if (!newType) {
State.pageConfig = null;
} else if (newType !== State.pageConfig?.currentPageType) {
State.pageConfig = buildPageConfig(newType);
}
console.log(`【Debug】${trigger}触发, 页面类型为 ${State.pageConfig?.currentPageType}`);
}
/**
* 构建页面配置对象
* @param {string} pageType - 页面类型
* @returns {Object} 页面配置对象
*/
function buildPageConfig(pageType) {
return {
currentPageType: pageType, // 当前页面类型
currentPath: window.location.pathname, // 当前路径
titleStaticDict: I18N[CONFIG.LANG][pageType]?.title?.static || {},
titleRegexpRules: I18N[CONFIG.LANG][pageType]?.title?.regexp || [],
staticDict: { // 合并公共和页面特定的静态词典
...I18N[CONFIG.LANG].public.static,
...(I18N[CONFIG.LANG][pageType]?.static || {})
},
regexpRules: [ // 合并公共和页面特定的正则规则
...(I18N[CONFIG.LANG][pageType]?.regexp || []),
...(I18N[CONFIG.LANG].public.regexp || [])
],
ignoreMutationSelectors: [ // 忽略的突变选择器
...(I18N.conf.ignoreMutationSelectorPage['*'] || []),
...(I18N.conf.ignoreMutationSelectorPage[pageType] || [])
].join(', '),
ignoreSelectors: [ // 忽略的选择器
...(I18N.conf.ignoreSelectorPage['*'] || []),
...(I18N.conf.ignoreSelectorPage[pageType] || [])
].join(', '),
characterData: (I18N.conf.characterDataPage || []).includes(pageType), // 是否监视文本节点变化
transSelectors: [ // 翻译选择器规则
...(I18N[CONFIG.LANG].public.selector || []),
...(I18N[CONFIG.LANG][pageType]?.selector || [])
],
};
}
/* =========================== 页面类型检测 =========================== */
/**
* 检测当前页面类型
* @returns {string|boolean} 页面类型或false(如果未识别)
*/
function detectPageType() {
const url = new URL(window.location.href);
const { PAGE_MAP, SPECIAL_SITES } = CONFIG;
const { hostname, pathname } = url;
// 基础配置
const site = PAGE_MAP[hostname] || 'github'; // 通过站点映射获取基础类型
const isLogin = document.body.classList.contains("logged-in");
const metaLocation = document.head.querySelector('meta[name="analytics-location"]')?.content || '';
// 页面特征检测
const isSession = document.body.classList.contains("session-authentication");
const isHomepage = pathname === '/' && site === 'github';
const isProfile = document.body.classList.contains("page-profile") || metaLocation === '/<user-name>';
const isRepository = /\/<user-name>\/<repo-name>/.test(metaLocation);
const isOrganization = /\/<org-login>/.test(metaLocation) || /^\/(?:orgs|organizations)/.test(pathname);
let pageType;
// 根据页面特征确定页面类型
switch (true) { // 使用 switch(true) 模式处理多条件分支
case isSession: // 登录/认证页面
pageType = 'session-authentication';
break;
case SPECIAL_SITES.includes(site): // 特殊站点
pageType = site;
break;
case isProfile: { // 用户资料页面
const tabParam = new URLSearchParams(url.search).get('tab');
pageType = pathname.includes('/stars') ? 'page-profile/stars'
: tabParam ? `page-profile/${tabParam}`
: 'page-profile';
break;
}
case isHomepage: // 首页/仪表盘
pageType = isLogin ? 'dashboard' : 'homepage';
break;
case isRepository: { // 代码仓库页面
const repoMatch = pathname.match(I18N.conf.rePagePathRepo);
pageType = repoMatch ? `repository/${repoMatch[1]}` : 'repository';
break;
}
case isOrganization: { // 组织页面
const orgMatch = pathname.match(I18N.conf.rePagePathOrg);
pageType = orgMatch ? `orgs/${orgMatch[1] || orgMatch.slice(-1)[0]}` : 'orgs';
break;
}
default: { // 默认页面类型
const pathMatch = pathname.match(I18N.conf.rePagePath);
pageType = pathMatch ? (pathMatch[1] || pathMatch.slice(-1)[0]) : false;
}
}
// 验证页面类型是否有效
if (pageType === false || !I18N[CONFIG.LANG]?.[pageType]) {
const reason = pageType === false
? '路径未匹配任何页面规则'
: `词库中缺少 "${pageType}" 的翻译`;
console.warn('[i18n] %s', reason, {
url: window.location.href,
hostname,
pathname,
site,
pageType,
isLogin,
metaLocation
});
return false;
}
return pageType;
}
/* =========================== React 新版头部翻译补丁 =================== */
/**
* 模块:React 新版头部翻译补丁
* 说明:针对 GitHub 新版 React 头部(GlobalNav)及其弹层,
* 通过 DOM 操作 + 精细的时机控制来翻译文本,
* 避免与 React 渲染发生冲突。
* 作者:MasterBao66
* 日期:2026-06-17
*/
function isReactGlobalNavPortalNode(node) {
const element = node?.nodeType === 1 ? node : node?.parentElement;
const portalRoot = element?.closest?.('#__primerPortalRoot__');
if (!portalRoot) return false;
const portal = element.closest?.('[data-component="Portal"]')
|| element.querySelector?.('[data-component="Portal"]')
|| portalRoot;
if (portal.matches?.('#search-suggestions-dialog')
|| portal.querySelector?.('#search-suggestions-dialog')) return true;
const referenceAttributes = ['aria-labelledby', 'aria-describedby', 'aria-controls', 'aria-owns'];
const referenceElements = [
portal,
...portal.querySelectorAll?.(
referenceAttributes.map(attribute => `[${attribute}]`).join(', ')
) || [],
];
for (const referenceElement of referenceElements) {
for (const attribute of referenceAttributes) {
const ids = referenceElement.getAttribute?.(attribute)?.split(/\s+/) || [];
if (ids.some(id => document.getElementById(id)?.closest?.('header.GlobalNav'))) {
return true;
}
}
}
const portalIds = new Set([
portal.id,
...Array.from(portal.querySelectorAll?.('[id]') || [], item => item.id),
].filter(Boolean));
if (portalIds.size) {
const headerReferences = document.querySelectorAll(
'header.GlobalNav [aria-describedby], header.GlobalNav [aria-controls], header.GlobalNav [aria-owns]'
);
for (const headerReference of headerReferences) {
for (const attribute of ['aria-describedby', 'aria-controls', 'aria-owns']) {
const ids = headerReference.getAttribute(attribute)?.split(/\s+/) || [];
if (ids.some(id => portalIds.has(id))) return true;
}
}
}
const hasControlledSurface = portal.matches?.('[role="menu"], [role="dialog"], [role="tooltip"]')
|| portal.querySelector?.('[role="menu"], [role="dialog"], [role="tooltip"]');
return !!hasControlledSurface
&& !!document.activeElement?.closest?.('header.GlobalNav, qbsearch-input');
}
function setupReactGlobalNavTranslation() {
// ----- 环境检查 -----
if (typeof document === 'undefined' || typeof window === 'undefined') return;
// ----- 词库(从 I18N 读取)-----
const labels = I18N.conf.reactGlobalNavLabels || {};
// ----- 选择器定义 -----
const dataContentLabelSelector = 'header.GlobalNav [data-component="text"][data-content]';
// 需要监听的 React 渲染区域:头部和弹层
const controlledSurfaceSelector = [
'header.GlobalNav',
'#__primerPortalRoot__ [role="menu"]',
'#__primerPortalRoot__ [role="dialog"]',
'#__primerPortalRoot__ [role="tooltip"]',
].join(', ');
// 仅弹层(用于单独判断更新状态)
const portalSurfaceSelector = '#__primerPortalRoot__ [role="menu"], #__primerPortalRoot__ [role="dialog"], #__primerPortalRoot__ [role="tooltip"]';
// 旧版搜索框(兼容)
const searchSurfaceSelector = 'qbsearch-input';
// 新版搜索模块(类名包含 Search-module__)
const searchModuleSelector = 'header.GlobalNav [class*="Search-module__"]';
// 不翻译的标签(避免破坏代码、图片等)
const unsafeTextSelector = [
'textarea',
'[contenteditable="true"]',
'code',
'pre',
'kbd',
'svg',
'img',
'canvas',
'video',
].join(', ');
// 搜索相关区域(用于判断焦点状态)
const searchSelector = `${searchModuleSelector}, ${searchSurfaceSelector}, #__primerPortalRoot__ [role="dialog"]`;
// 需要翻译的属性列表
const translatableAttributeNames = ['title', 'aria-label', 'data-visible-text', 'placeholder'];
// ----- 时间控制参数 -----
const reactGlobalNavIdleMs = 700; // 判断渲染空闲的等待时间(毫秒)
const reactGlobalNavRetryMs = 400; // 重试间隔
// ----- 状态变量 -----
let timer = null; // 延时执行句柄
let headerObserver = null; // MutationObserver 实例
let lastReactGlobalNavMutationAt = Date.now(); // 头部最后变化时间
let lastReactGlobalNavPortalMutationAt = Date.now(); // 弹层最后变化时间
const observedSurfaces = new WeakSet(); // 已监听的 DOM 元素(避免重复绑定)
// ----- 状态查询函数 -----
/**
* 判断当前是否处于搜索激活状态(输入框有焦点或弹层打开)
*/
function isReactGlobalNavSearchActive() {
const active = document.activeElement;
return !!active?.closest?.(searchSelector)
|| !!document.querySelector('#__primerPortalRoot__ [role="dialog"]');
}
/**
* 判断指定区域是否已经处于空闲状态(无变化超过 IDLE_MS)
* @param {string} surfaceType - 'header' 或 'portal'
*/
function isReactGlobalNavSurfaceIdle(surfaceType = 'header') {
const lastMutationAt = surfaceType === 'portal'
? lastReactGlobalNavPortalMutationAt
: lastReactGlobalNavMutationAt;
return Date.now() - lastMutationAt >= reactGlobalNavIdleMs;
}
/**
* 判断是否可以进行头部翻译
* 条件:页面完全加载、头部空闲、搜索未激活
*/
function canTranslateReactGlobalNavHeader() {
return document.readyState === 'complete'
&& isReactGlobalNavSurfaceIdle('header')
&& !isReactGlobalNavSearchActive();
}
// ----- 词库查找函数(与现有 I18N 集成) -----
/**
* 从 I18N 的静态词典中查找翻译
* @param {string} source - 原文
* @returns {string|null}
*/
function findStaticGlobalNavLabel(source) {
const locale = I18N["zh-CN"] || I18N.zh;
if (!locale) return null;
for (const section of Object.values(locale)) {
const label = section?.static?.[source];
if (typeof label === 'string' && label && label !== source) {
return label;
}
}
return null;
}
/**
* 从 I18N 的正则规则中查找翻译
*/
function findRegexpGlobalNavLabel(source) {
const locale = I18N["zh-CN"] || I18N.zh;
if (!locale) return null;
for (const section of Object.values(locale)) {
for (const [pattern, replacement] of section?.regexp || []) {
const match = source.match(pattern);
if (!match || match.index !== 0 || match[0] !== source) continue;
const label = source.replace(pattern, replacement);
if (label !== source) return label;
}
}
return null;
}
/**
* 解析翻译:优先硬编码标签 -> 静态词库 -> 正则词库
*/
function resolveReactGlobalNavLabel(source) {
return labels[source] || findStaticGlobalNavLabel(source) || findRegexpGlobalNavLabel(source);
}
// ----- 文本处理辅助函数 -----
function normalizeReactGlobalNavText(text) {
return text?.replace(/\s+/g, ' ').trim();
}
function translateReactGlobalNavText(text) {
const source = normalizeReactGlobalNavText(text);
return source ? resolveReactGlobalNavLabel(source) : null;
}
// ----- 翻译执行函数 -----
/**
* 翻译单个元素的文本内容(直接修改 textContent)
*/
function translateReactGlobalNavElement(element, source) {
const label = translateReactGlobalNavText(source ?? element.textContent);
if (label && element.textContent !== label) {
element.textContent = label;
}
}
/**
* 判断节点是否应该被跳过(不翻译)
*/
function shouldSkipReactGlobalNavNode(node) {
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
if (!element) return true;
if (element.closest?.(unsafeTextSelector)) return true;
if (element.closest?.(searchModuleSelector)) return true;
if (element.closest?.(searchSurfaceSelector)) return true;
return false;
}
/**
* 翻译元素的可翻译属性(title、aria-label 等)
*/
function translateReactGlobalNavAttributes(element) {
translatableAttributeNames.forEach(attributeName => {
const value = element.getAttribute?.(attributeName);
const label = translateReactGlobalNavText(value);
if (label && value !== label) {
element.setAttribute(attributeName, label);
}
});
}
/**
* 翻译文本节点
*/
function translateReactGlobalNavTextNode(node) {
const label = translateReactGlobalNavText(node.data);
if (label) {
// 替换原有文本(保留前后空白)
node.data = node.data.replace(node.data.trim(), label);
}
}
/**
* 遍历并翻译整个 Surface(区域)
* 使用 TreeWalker 遍历所有元素和文本节点
*/
function translateReactGlobalNavSurface(surface) {
if (!surface || shouldSkipReactGlobalNavNode(surface)) return;
if (surface.nodeType === Node.ELEMENT_NODE) {
translateReactGlobalNavAttributes(surface);
}
const walker = document.createTreeWalker(
surface,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
{
acceptNode(node) {
return shouldSkipReactGlobalNavNode(node)
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT;
}
}
);
let node;
while ((node = walker.nextNode())) {
if (node.nodeType === Node.ELEMENT_NODE) {
translateReactGlobalNavAttributes(node);
} else if (node.nodeType === Node.TEXT_NODE) {
translateReactGlobalNavTextNode(node);
}
}
}
// ----- 主翻译入口 -----
/**
* 翻译头部(header.GlobalNav)
* @returns {boolean} 是否成功翻译(头部存在且空闲)
*/
function translateReactGlobalNavHeader() {
const header = document.querySelector('header.GlobalNav');
if (!header) return true; // 不存在时认为已处理
if (!canTranslateReactGlobalNavHeader()) return false;
// 优先翻译包含 data-content 的元素(React 组件通过此属性存储原始文案)
document.querySelectorAll(dataContentLabelSelector).forEach(element => {
if (!shouldSkipReactGlobalNavNode(element)) {
translateReactGlobalNavElement(element, element.getAttribute('data-content'));
}
});
// 翻译整个头部区域
translateReactGlobalNavSurface(header);
return true;
}
function isReactGlobalNavSearchPortal(surface) {
return surface.matches?.('[role="dialog"]')
|| !!surface.querySelector?.('#search-suggestions-dialog, qbsearch-input, [role="dialog"]');
}
/**
* 翻译弹层(portal)
* @returns {boolean} 是否成功翻译(弹层存在且空闲)
*/
function translateReactGlobalNavPortals() {
const surfaces = Array.from(document.querySelectorAll(portalSurfaceSelector))
.filter(isReactGlobalNavPortalNode);
if (!surfaces.length) return true;
let searchPortalPending = false;
surfaces.forEach(surface => {
if (isReactGlobalNavSearchPortal(surface) && !isReactGlobalNavSurfaceIdle('portal')) {
searchPortalPending = true;
return;
}
translateReactGlobalNavSurface(surface);
});
return !searchPortalPending;
}
/**
* 总翻译入口,被调度函数调用
* @param {object} options - { requireSettledHeader: true/false }
*/
function translateReactGlobalNavLabels(options = { requireSettledHeader: true }) {
observeReactGlobalNav(); // 确保观察器已启动
const headerTranslated = translateReactGlobalNavHeader();
const portalsTranslated = translateReactGlobalNavPortals();
// 如果未完成(头部未就绪或弹层未空闲),则安排重试
if ((options.requireSettledHeader && !headerTranslated) || !portalsTranslated) {
scheduleReactGlobalNavTranslation(reactGlobalNavRetryMs, options);
}
}
// ----- 调度函数 -----
/**
* 延迟调度翻译
*/
function scheduleReactGlobalNavTranslation(delay = 800, options = {}) {
window.clearTimeout(timer);
timer = window.setTimeout(() => translateReactGlobalNavLabels(options), delay);
}
/**
* 初始启动序列:在多个时间点尝试翻译,覆盖 React 异步渲染
*/
function scheduleReactGlobalNavSeries() {
[800, 1600, 3000].forEach(delay => {
window.setTimeout(translateReactGlobalNavLabels, delay);
});
}
// ----- MutationObserver 与状态记录 -----
/**
* 记录 DOM 变化的时间戳(区分头部和弹层)
*/
function recordReactGlobalNavMutation(surface) {
if (surface?.id === '__primerPortalRoot__' || surface?.closest?.('#__primerPortalRoot__')) {
lastReactGlobalNavPortalMutationAt = Date.now();
return;
}
lastReactGlobalNavMutationAt = Date.now();
}
/**
* 设置 MutationObserver,监听头部和弹层的变化
*/
function observeReactGlobalNav() {
if (!headerObserver) {
headerObserver = new MutationObserver(mutations => {
mutations.forEach(mutation => recordReactGlobalNavMutation(mutation.target));
translateReactGlobalNavPortals();
// 变化后延迟重试翻译
scheduleReactGlobalNavTranslation(reactGlobalNavRetryMs, { requireSettledHeader: true });
});
}
[
document.querySelector('header.GlobalNav'),
document.querySelector('#__primerPortalRoot__'),
].forEach(surface => {
if (!surface || observedSurfaces.has(surface)) return;
observedSurfaces.add(surface);
recordReactGlobalNavMutation(surface);
headerObserver.observe(surface, {
childList: true,
subtree: true,
characterData: true,
});
});
}
function startReactGlobalNavTranslation() {
observeReactGlobalNav();
scheduleReactGlobalNavSeries();
}
// ----- 初始化入口 -----
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startReactGlobalNavTranslation, { once: true });
} else {
startReactGlobalNavTranslation();
}
// 监听 Turbo 导航和 URL 变化
window.addEventListener('turbo:load', scheduleReactGlobalNavSeries);
window.addEventListener('urlchange', scheduleReactGlobalNavSeries);
// 监听用户交互事件,交互后可能触发 React 更新,延迟重试翻译
['click', 'focusin', 'focusout', 'pointerover'].forEach(evt => {
document.addEventListener(evt, () => scheduleReactGlobalNavTranslation(reactGlobalNavRetryMs, { requireSettledHeader: true }), true);
});
}
/* =========================== MutationObserver =========================== */
/**
* 设置DOM变化观察器
* 监听页面变化并触发翻译
*/
function setupMutationObserver() {
// 缓存当前页面的 URL
let previousURL = window.location.href;
if (State.mutationObserver) {
State.mutationObserver.disconnect();
}
State.mutationObserver = new MutationObserver(
safe((mutations) => {
const currentURL = window.location.href;
// 当没有 onurlchange 支持时,通过 Observer 检测 URL 变化
if (!State.urlChangeHandler && currentURL !== previousURL) {
previousURL = currentURL;
State.currentURL = currentURL;
updatePageConfig("URL变化 (MutationObserver)");
}
// 处理DOM变化
if (State.pageConfig) {
processMutations(mutations);
}
}, 'MutationObserver')
);
// 开始观察页面主体
State.mutationObserver.observe(document.body, CONFIG.OBSERVER_CONFIG);
}
/**
* 处理MutationObserver检测到的变化
* 收集突变节点、过滤忽略选择器、对祖先-后代关系去重,仅遍历顶层节点
* @param {Array} mutations - 变化记录数组
*/
function shouldIgnoreMutationNode(node) {
const element = node?.nodeType === Node.ELEMENT_NODE ? node : node?.parentElement;
if (!element) return true;
const ignoredSelectors = State.pageConfig?.ignoreMutationSelectors;
if (ignoredSelectors && element.closest?.(ignoredSelectors)) return true;
return isReactGlobalNavPortalNode(element);
}
function processMutations(mutations) {
const nodesToProcess = new Set();
// 收集需要处理的节点
mutations.forEach(({ target, addedNodes, type }) => {
if (type === 'childList' && addedNodes.length > 0) {
// 处理新增节点
addedNodes.forEach(node => {
if (!shouldIgnoreMutationNode(node)) {
nodesToProcess.add(node);
}
});
} else if (type === 'attributes') {
// 处理属性变化,target 就是元素
if (!shouldIgnoreMutationNode(target)) {
nodesToProcess.add(target);
}
} else if (type === 'characterData' && State.pageConfig.characterData) {
// 处理文本变化,target 是文本节点,取其父元素
if (!shouldIgnoreMutationNode(target)) {
nodesToProcess.add(target);
}
}
});
// 过滤掉祖先已在集合中的后代节点,避免重复遍历
const topNodes = new Set();
nodesToProcess.forEach(node => {
let ancestor = node.parentElement;
let hasAncestor = false;
while (ancestor) {
if (nodesToProcess.has(ancestor)) {
hasAncestor = true;
return;
}
ancestor = ancestor.parentElement;
}
if (!hasAncestor) {
topNodes.add(node);
}
});
if (CONFIG.DEV) console.log("DOM变化(已过滤)", topNodes);
// 仅遍历顶层节点
topNodes.forEach(node => {
traverseNode(node);
});
}
/* =========================== DOM 遍历与节点处理 =========================== */
/**
* 遍历节点树并进行翻译
* @param {Node} rootNode - 要遍历的根节点
*/
function traverseNode(rootNode) {
const start = performance.now();
// 文本节点直接处理
if (rootNode.nodeType === Node.TEXT_NODE) {
handleTextNode(rootNode);
return;
}
// 创建TreeWalker遍历节点树
const treeWalker = document.createTreeWalker(
rootNode,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
node => {
if (node.nodeType === Node.ELEMENT_NODE
&& State.pageConfig.ignoreSelectors
&& node.matches(State.pageConfig.ignoreSelectors)) {
return NodeFilter.FILTER_REJECT; // 跳过忽略的选择器
}
return NodeFilter.FILTER_ACCEPT; // 接受其他节点
}
);
let currentNode;
// 遍历所有节点
while ((currentNode = treeWalker.nextNode())) {
if (currentNode.nodeType === Node.ELEMENT_NODE) {
handleElementNode(currentNode);
} else if (currentNode.nodeType === Node.TEXT_NODE) {
handleTextNode(currentNode);
}
}
// 性能监控
const duration = performance.now() - start;
if (duration > 10) {
console.log(`节点遍历耗时: ${duration.toFixed(2)}ms`);
}
}
/**
* 处理文本节点
* @param {Node} node - 文本节点
*/
function handleTextNode(node) {
if (node.length > 500) return; // 跳过长文本节点
transElementAttrs(node, 'data'); // 翻译文本内容
}
/**
* 处理元素节点
* @param {Element} node - 元素节点
*/
function handleElementNode(node) {
// 根据标签类型进行不同的翻译处理
const tag = node.tagName;
if (tag === "RELATIVE-TIME") { // 相对时间元素
if (node.shadowRoot) {
transTimeElement(node.shadowRoot);
}
return;
}
if (tag === "INPUT" || tag === "TEXTAREA") { // 输入框和文本域
if (['button', 'submit', 'reset'].includes(node.type)) {
transElementAttrs(node.dataset, 'confirm'); // 确认对话框文本
transElementAttrs(node, 'value'); // 值属性
} else {
transElementAttrs(node, 'placeholder'); // 占位符
}
return;
}
if (tag === "OPTGROUP") { // 选项组
transElementAttrs(node, 'label'); // 标签文本
return;
}
if (tag === "BUTTON") { // 按钮
transElementAttrs(node, [
'title',
'cancelConfirmText'
]);
transElementAttrs(node.dataset, [
'confirm', // 确认文本
'confirmText', // 确认按钮文本
'confirmCancelText', // 取消按钮文本
'disableWith', // 禁用提示
'visibleText'
]);
}
if (tag === "A" || tag === "SPAN") {
transElementAttrs(node, 'title'); // 标题提示
transElementAttrs(node.dataset, 'visibleText'); // 可见文本
}
// 带有 tooltipped 样式的元素
if (/tooltipped/.test(node.className)) {
transElementAttrs(node, 'ariaLabel');
}
}
/* =========================== 翻译功能 =========================== */
/**
* 翻译页面标题
*/
function transTitle() {
const text = document.title;
let result = State.pageConfig.titleStaticDict[text] || '';
// 尝试静态翻译
if (!result) {
// 尝试正则表达式翻译
for (const [pattern, replacement] of State.pageConfig.titleRegexpRules) {
result = text.replace(pattern, replacement);
if (result !== text) break;
}
}
// 应用翻译结果
if (result) {
document.title = result;
}
}
/**
* 翻译时间元素
* @param {Element} element - 时间元素
*/
function transTimeElement(element) {
// 获取时间文本
const text = element.textContent;
if (!text) return;
// 移除开头的"on"
const result = text.replace(/^on/, "");
if (result !== text) {
element.textContent = result; // 应用翻译
}
}
/**
* 翻译元素的单个属性
* @param {Object} target - 元素对象或元素数据集
* @param {string} attrName - 要翻译的属性名
*/
function transElementAttr(target, attrName) {
const text = target[attrName];
if (!text) return;
const result = transText(text);
if (result) {
target[attrName] = result;
}
}
/**
* 批量翻译元素的多个属性
* @param {Object} target - 元素对象或元素数据集
* @param {string|string[]} attrs - 要翻译的属性名或属性名数组
*/
function transElementAttrs(target, attrs) {
const attrList = Array.isArray(attrs) ? attrs : [attrs];
attrList.forEach(attrName => transElementAttr(target, attrName));
}
/**
* 通过选择器翻译特定元素
*/
function transBySelector() {
State.pageConfig.transSelectors?.forEach(([selector, result]) => {
const element = document.querySelector(selector);
if (element) {
element.textContent = result; // 应用翻译
}
});
}
/**
* 翻译文本内容
* @param {string} text - 要翻译的文本
* @returns {string|boolean} 翻译后的文本或 false
*/
function transText(text) {
// 跳过不需要翻译的文本:
// 1. 空文本(包空白字符)或纯数字
// 2. 纯中文字符
// 3. 不包含英文字母和,.符号的文本
if (/^[\s0-9]*$/.test(text) ||
/^[\u4e00-\u9fa5]+$/.test(text) ||
!/[a-zA-Z,.]/.test(text)) {
return false;
}
// 清理文本:去除首尾空格和多余空白
const trimmedText = text.trim();
const cleanedText = trimmedText.replace(/\xa0|[\s]+/g, ' ');
// 获取翻译
const result = fetchTransResult(cleanedText);
if (result && result !== cleanedText) {
return text.replace(trimmedText, result);
}
return false;
}
/**
* 从词库获取翻译 — 直接读取 State.pageConfig
* @param {string} text - 要翻译的文本
* @returns {string|boolean} 翻译结果或 false
*/
function fetchTransResult(text) {
if (!State.pageConfig) return false;
// 静态词典查找
const staticResult = State.pageConfig.staticDict[text];
if (typeof staticResult === 'string') {
MissedTermsManager.cleanup(text, State.pageConfig.currentPath);
return staticResult;
}
// 正则规则查找
if (State.featureSet.enable_RegExp) {
for (const [pattern, replacement] of State.pageConfig.regexpRules) {
const result = text.replace(pattern, replacement);
if (result !== text) {
MissedTermsManager.cleanup(text, State.pageConfig.currentPath);
return result;
}
}
}
// 记录未命中词条
if (State.featureSet.enable_missedTerms) {
MissedTermsManager.record(text, State.pageConfig.currentPath);
refreshMenuStates();
}
return false;
}
/* =========================== 远程翻译 =========================== */
/**
* 为描述元素添加翻译按钮
* @param {string} selector - 描述元素的选择器
*/
function transDesc(selector) {
const element = document.querySelector(selector);
if (!element) return;
// 修复:安全检查 classList.contains,避免 null sibling 时崩溃
const nextSibling = element.nextElementSibling;
if (nextSibling?.classList?.contains('translate-button')) return;
// 创建翻译按钮
const button = document.createElement('div');
button.classList.add('translate-button');
button.textContent = '翻译';
element.after(button);
// 绑定点击事件
button.addEventListener('click', () => handleTransClick(button, element));
}
/**
* 处理翻译按钮点击事件
* @param {Element} button - 翻译按钮元素
* @param {Element} element - 要翻译的元素
*/
function handleTransClick(button, element) {
if (button.disabled) return;
button.disabled = true; // 防止重复点击
const descText = element.textContent.trim();
if (!descText) {
button.disabled = false;
return;
}
// 发起远程翻译请求
requestRemoteTrans(descText)
.then(result => {
showTransResult(element, button, result);
})
.catch(error => {
console.error('翻译失败:', error);
button.disabled = false; // 启用按钮以允许重试
});
}
/**
* 显示翻译结果
* @param {Element} element - 原始元素
* @param {Element} button - 翻译按钮
* @param {string} result - 翻译结果
*/
function showTransResult(element, button, result) {
const { name, url } = CONFIG.TRANS_ENGINES[State.transEngine];
// 创建结果容器 — 结构与不可信文本分离,防止 XSS
const resultContainer = document.createElement('div');
resultContainer.className = 'translation-result';
resultContainer.innerHTML = `
<span class="translation-credit">
由 <a target='_blank' href='${url}'>${name}</a> 翻译👇
</span>
<br/>
<div class="translation-content"></div>
`;
// API 响应文本使用 textContent,禁止 HTML 解析
resultContainer.querySelector('.translation-content').textContent = result;
// 移除按钮并显示结果
button.remove();
element.after(resultContainer);
}
/**
* 请求远程翻译API
* @param {string} text - 要翻译的文本
* @returns {Promise} 返回翻译结果的Promise
*/
function requestRemoteTrans(text) {
return new Promise((resolve, reject) => {
const engine = CONFIG.TRANS_ENGINES[State.transEngine];
const { url_api, method, headers, getRequestData, responseIdentifier } = engine;
// 准备请求数据
const requestData = getRequestData(text);
// 使用GM_xmlhttpRequest发起跨域请求
GM_xmlhttpRequest({
method: method,
url: url_api,
headers: headers,
data: method === 'POST' ? JSON.stringify(requestData) : null,
params: method === 'GET' ? requestData : null, // For GET requests
timeout: 10000, // 10秒超时
onload: (res) => {
try {
const response = JSON.parse(res.responseText);
// 从响应中提取翻译结果
const result = getNestedProperty(response, responseIdentifier);
if (result) {
resolve(result);
} else {
reject(new Error('翻译结果无效'));
}
} catch (err) {
reject(err);
}
},
onerror: (err) => {
reject(err);
}
});
});
}
/**
* 安全获取嵌套对象属性
* 支持路径格式如 'biz[0]?.sectionResult[0]?.dst'
* - '?.' 在路径中作为可选链标记被忽略,实际按强制访问处理
* @param {Object} obj - 目标对象
* @param {string} path - 属性路径
* @returns {*} 属性值或 undefined
*/
function getNestedProperty(obj, path) {
// 移除路径中的 ?. 标记(讯飞API返回的路径表示可选,但此处按强制处理)
const cleanPath = path.replace(/\?\./g, '.');
return cleanPath.split('.').reduce((acc, part) => {
if (!acc) return undefined;
const match = part.match(/^(\w+)(?:\[(\d+)\])?$/);
if (!match) return undefined;
const key = match[1];
const index = match[2];
// 处理数组索引或对象属性
return index !== undefined ? acc[key]?.[index] : acc[key];
}, obj);
}
/* =========================== 未命中词条管理器 =========================== */
const MissedTermsManager = {
/**
* 未命中词条数据结构(简洁模式)
* {
* [pathname]: {
* "原始文本1": "",
* "原始文本2": "",
* ...
* }
* }
*/
data: GM_getValue("missedTerms", {}),
/**
* 记录未命中词条
* @param {string} text - 未翻译的文本
* @param {string} path - 当前页面路径
*/
record(text, path) {
if (!path) return false;
if (!this.data[path]) {
this.data[path] = {};
}
// 使用对象存储,保持简洁
if (!(text in this.data[path])) {
this.data[path][text] = "";
this.save();
return true; // 新增词条
}
return false; // 词条已存在
},
/**
* 清理已命中的词条
* @param {string} text - 已翻译的文本
* @param {string} path - 当前页面路径
*/
cleanup(text, path) {
if (!path) return false;
if (this.data[path] && text in this.data[path]) {
delete this.data[path][text];
// 如果该路径下没有词条了,删除路径条目
if (Object.keys(this.data[path]).length === 0) {
delete this.data[path];
}
this.save();
return true;
}
return false;
},
/**
* 获取所有未命中词条
* @returns {Object} 未命中词条数据
*/
getAll() {
return this.data;
},
/**
* 按路径获取词条
* @param {string} path - 页面路径
* @returns {Object} 该路径下的词条对象
*/
getByPath(path) {
return this.data[path] || {};
},
/**
* 获取所有词条的文本数组(按路径分组)
* @returns {Array} 格式为 [{path, terms: []}, ...]
*/
getAllTermsArray() {
return Object.entries(this.data).map(([path, terms]) => ({
path,
terms: Object.keys(terms)
}));
},
/**
* 清空所有词条
*/
clearAll() {
this.data = {};
this.save();
},
/**
* 清空指定路径的词条
* @param {string} path - 页面路径
*/
clearPath(path) {
if (this.data[path]) {
delete this.data[path];
this.save();
}
},
/**
* 获取统计信息
* @returns {Object} 统计信息
*/
getStats() {
const paths = Object.keys(this.data);
const totalTerms = paths.reduce((sum, path) =>
sum + Object.keys(this.data[path]).length, 0
);
return { totalPaths: paths.length, totalTerms: totalTerms };
},
/**
* 导出数据
* @returns {Object} 导出数据
*/
exportData() {
const data = this.data;
const stats = this.getStats();
return {
metadata: {
exportedAt: new Date().toISOString(),
version: "1.0",
...stats
},
data
};
},
/**
* 保存数据到存储
*/
save() {
GM_setValue("missedTerms", this.data);
}
};
/* =========================== 用户菜单 =========================== */
/**
* 1. 动态菜单管理
*/
function refreshMenuStates() {
// 注销所有动态菜单
Object.values(State.dynamicMenus).forEach(id => GM_unregisterMenuCommand(id));
State.dynamicMenus = {};
// 仅开发者模式下显示未命中词条相关菜单
if (!CONFIG.DEV) return;
// 切换菜单
const toggleLabel = `${State.featureSet.enable_missedTerms ? "禁用" : "启用"} 未命中词条记录`;
State.dynamicMenus.toggle = GM_registerMenuCommand(toggleLabel, () => {
const newState = !State.featureSet.enable_missedTerms;
State.featureSet.enable_missedTerms = newState;
GM_setValue("enable_missedTerms", newState);
if (!newState) {
MissedTermsManager.clearAll();
GM_notification("未命中词条记录已禁用,所有记录已清空");
} else {
GM_notification("未命中词条记录已启用");
}
refreshMenuStates();
});
// 启用 + 有词条时显示导出和清空菜单
if (State.featureSet.enable_missedTerms) {
const stats = MissedTermsManager.getStats();
const hasData = stats.totalTerms > 0;
if (hasData) {
// 导出菜单
State.dynamicMenus.export = GM_registerMenuCommand(
`📥 导出未命中词条 (${stats.totalTerms}条)`,
() => {
const exportData = MissedTermsManager.exportData();
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
type: "application/json"
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `GitHub_未命中词条_${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
}
);
// 清空菜单
State.dynamicMenus.clear = GM_registerMenuCommand(
"🗑️ 清空未命中词条",
() => {
if (confirm(`确定要清空所有未命中词条吗?\n共 ${stats.totalPaths} 个页面,${stats.totalTerms} 个词条`)) {
MissedTermsManager.clearAll();
GM_notification("未命中词条记录已清空");
refreshMenuStates();
}
}
);
// 查看统计菜单
State.dynamicMenus.stats = GM_registerMenuCommand(
"📊 查看统计",
() => {
const s = MissedTermsManager.getStats();
GM_notification({
title: "未命中词条统计",
text: `页面数: ${s.totalPaths}\n词条数: ${s.totalTerms}`,
timeout: 5000
});
}
);
}
}
}
/**
* 2. 静态菜单创建
* @param {Object} config - 菜单配置
*/
function createMenuCommand(config) {
const { label, key, callback } = config;
let menuId;
// 生成菜单标签(根据当前状态)
const getMenuLabel = () =>
`${State.featureSet[key] ? "禁用" : "启用"} ${label}`;
// 切换功能状态
const toggle = () => {
const newState = !State.featureSet[key];
// 保存到存储
GM_setValue(key, newState);
State.featureSet[key] = newState;
// 显示通知
GM_notification(`${label}已${newState ? '启用' : '禁用'}`);
// 执行回调
callback?.(newState);
// 重新注册菜单(更新标签)
GM_unregisterMenuCommand(menuId);
menuId = GM_registerMenuCommand(getMenuLabel(), toggle);
};
// 初始注册菜单
menuId = GM_registerMenuCommand(getMenuLabel(), toggle);
}
/**
* 3. 主菜单设置
*/
function setupMenuCommands() {
const menuConfigs = [
{
label: "正则功能",
key: "enable_RegExp",
callback: (enabled) => {
if (enabled && State.pageConfig) safe(traverseNode, '菜单触发遍历')(document.body);
}
},
{
label: "描述翻译",
key: "enable_transDesc",
callback: (enabled) => {
const pageType = State.pageConfig?.currentPageType;
if (enabled && pageType) {
// 启用描述翻译
transDesc(CONFIG.DESC_SELECTORS[pageType]);
} else if (!enabled) {
// 禁用描述翻译,移除按钮
document.querySelector('.translate-button')?.remove();
}
}
}
];
// 为每个配置创建静态菜单
menuConfigs.forEach(config => createMenuCommand(config));
// 初始化动态菜单
refreshMenuStates();
}
/* =========================== 启动 =========================== */
init();
})(window, document);