您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
将 GitHub 页面翻译为中文。采用字典驱动,按页面细分,不改变页面功能;自动处理 PJAX/动态内容。
// ==UserScript== // @name GitHub 中文翻译增强 // @namespace https://github.com/SychO3/github-i18n-plugin // @version 1.0.13 // @description 将 GitHub 页面翻译为中文。采用字典驱动,按页面细分,不改变页面功能;自动处理 PJAX/动态内容。 // @author SychO // @match https://github.com/* // @match https://gist.github.com/* // @run-at document-idle // @license MIT // @grant GM.getResourceText // @grant GM_getResourceText // @resource zhCN https://raw.githubusercontent.com/SychO3/github-i18n-plugin/master/locales/zh-CN.json // ==/UserScript== (function () { 'use strict'; // --------------------------- // 外部字典加载(JSON 文件) // --------------------------- /** * 词典 JSON 结构: * { * "global": { "sign in": "登录", ... }, * "repo": { ... }, * "issues_list": { ... }, * ... 其他页面键 ... * } */ const RESOURCE_NAME = 'zhCN'; let loadedDictionaries = {}; function parseDictionaryJson(text) { try { const json = JSON.parse(text); if (json && typeof json === 'object') return json; } catch (_) {} return {}; } async function loadDictionaries() { // 仅使用 @resource(GreasyFork/Tampermonkey 推荐方式) try { // 兼容 GM.getResourceText / GM_getResourceText // eslint-disable-next-line no-undef const gmGet = (typeof GM !== 'undefined' && typeof GM.getResourceText === 'function') ? GM.getResourceText : (typeof GM_getResourceText === 'function' ? GM_getResourceText : null); if (gmGet) { const maybe = gmGet(RESOURCE_NAME); const text = (maybe && typeof maybe.then === 'function') ? await maybe : maybe; if (typeof text === 'string' && text) { return parseDictionaryJson(text); } } } catch (_) {} // 未配置资源时兜底为空对象(不翻译) return {}; } // --------------------------- // 工具函数 // --------------------------- function normalizeKey(text) { if (typeof text !== 'string') return ''; return text.replace(/\s+/g, ' ').trim().toLowerCase(); } function escapeRegExp(literal) { return literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function detectPageKeyFallback() { const p = location.pathname; // 仓库首页,如 /owner/repo if (/^\/[\w.-]+\/[\w.-]+$/.test(p)) return 'repo'; // Issues 列表或详情 if (/^\/[\w.-]+\/[\w.-]+\/issues(\/.*)?$/.test(p)) { return /\/issues\/\d+/.test(p) ? 'issue_detail' : 'issues_list'; } // PR 列表或详情 if (/^\/[\w.-]+\/[\w.-]+\/(pull|pulls)(\/.*)?$/.test(p)) { return /\/pull\/(\d+)/.test(p) ? 'pr_detail' : 'pulls_list'; } // 文件浏览 if (/^\/[\w.-]+\/[\w.-]+\/(tree|blob)\//.test(p)) return 'file_view'; // Commits 列表/详情 if (/^\/[\w.-]+\/[\w.-]+\/commits/.test(p)) return 'commits_list'; if (/^\/[\w.-]+\/[\w.-]+\/commit\//.test(p)) return 'commit_detail'; // 搜索/通知 if (/^\/search/.test(p)) return 'search'; if (/^\/notifications/.test(p)) return 'notifications'; return 'global'; } function matchRouteByRegex(pattern, url) { try { const re = new RegExp(pattern); return re.test(url); } catch (_) { return false; } } function matchRouteByPrefix(prefix, url) { if (typeof prefix !== 'string') return false; return url.startsWith(prefix); } function resolvePageKeyByRoutes(url) { const routes = Array.isArray(loadedDictionaries.routes) ? loadedDictionaries.routes : []; for (const route of routes) { if (!route || typeof route !== 'object') continue; const type = route.type || 'regex'; const pattern = route.pattern || route.match || ''; const key = route.key || route.pageKey || ''; if (!pattern || !key) continue; let ok = false; if (type === 'prefix') ok = matchRouteByPrefix(pattern, url); else ok = matchRouteByRegex(pattern, url); if (ok) return key; } return null; } function detectActivePageKey() { const url = location.href; const mapped = resolvePageKeyByRoutes(url); if (mapped) return mapped; return detectPageKeyFallback(); } function buildDictionaryForPage(pageKey) { const merged = Object.create(null); const globalDict = loadedDictionaries.global || {}; for (const [k, v] of Object.entries(globalDict)) { merged[normalizeKey(k)] = v; } const pageDict = loadedDictionaries[pageKey] || {}; for (const [k, v] of Object.entries(pageDict)) { merged[normalizeKey(k)] = v; } return merged; } function buildPatternRulesForPage(pageKey) { const rules = []; const add = (arr) => { if (Array.isArray(arr)) { for (const item of arr) { if (!item || typeof item !== 'object') continue; const pattern = item.regex || item.pattern; const replace = item.replace || item.replacement; if (!pattern || typeof replace !== 'string') continue; let flags = typeof item.flags === 'string' ? item.flags : 'gi'; if (!flags.includes('g')) flags += 'g'; try { const re = new RegExp(pattern, flags); rules.push({ re, replace }); } catch (_) { // ignore invalid regex } } } }; add(loadedDictionaries.patterns); const pageDict = loadedDictionaries[pageKey] || {}; add(pageDict.patterns); return rules; } function buildTemplatesForPage(pageKey) { // 模板对象:{ normalizedKey: { html: string, anchors?: string[] } } const templates = Object.create(null); const add = (obj) => { if (obj && typeof obj === 'object' && !Array.isArray(obj)) { for (const [k, v] of Object.entries(obj)) { if (typeof k !== 'string') continue; const nk = normalizeKey(k); if (typeof v === 'string') { templates[nk] = { html: v }; } else if (v && typeof v === 'object') { const html = typeof v.html === 'string' ? v.html : null; const anchors = Array.isArray(v.anchors) ? v.anchors : (Array.isArray(v.anchorTexts) ? v.anchorTexts : null); if (html) templates[nk] = anchors ? { html, anchors } : { html }; } } } }; add(loadedDictionaries.templates); const pageDict = loadedDictionaries[pageKey] || {}; add(pageDict.templates); return templates; } function buildAnchorTemplateKey(parent) { const tokens = []; const anchors = []; parent.childNodes.forEach((node) => { if (node.nodeType === Node.TEXT_NODE) { tokens.push(node.nodeValue || ''); } else if (node.nodeType === Node.ELEMENT_NODE) { const el = node; if (el.tagName === 'A') { const idx = anchors.length; anchors.push(el); tokens.push(`[A${idx}]`); } else if (el.tagName === 'KBD') { // 将 <kbd>x</kbd> 看作其文本 tokens.push(el.textContent || ''); } else { // 其他标签中止模板尝试 anchors.length = 0; tokens.length = 0; } } }); if (tokens.length === 0 && anchors.length === 0) return null; // 直接拼接,避免标点前的多余空格 let raw = tokens.join(''); // 清理标点前空格:"word ." -> "word." raw = raw.replace(/\s+([.,!?;:])/g, '$1'); return { key: normalizeKey(raw), anchors }; } function translateTextByDictAndPatterns(text, dict, patternRules) { if (!text) return null; const key = normalizeKey(text); if (key) { const byDict = dict[key]; if (byDict) return byDict; } if (patternRules && patternRules.length) { const byPat = translateByPatterns(text, patternRules); if (typeof byPat === 'string' && byPat !== text) return byPat; } return null; } function applyAnchorTemplate(parent, templates, dict, patternRules) { if (!parent || !templates) return 0; const built = buildAnchorTemplateKey(parent); if (!built) return 0; const tpl = templates[built.key]; if (!tpl || !tpl.html) return 0; let html = tpl.html; built.anchors.forEach((a, idx) => { const marker = new RegExp(escapeRegExp(`[A${idx}]`), 'g'); let aHtml = a.outerHTML; const override = tpl.anchors && typeof tpl.anchors[idx] === 'string' ? tpl.anchors[idx] : null; if (override) { const clone = a.cloneNode(true); clone.textContent = override; aHtml = clone.outerHTML; } else { // 尝试依据词典/模式翻译链接文本 const translated = translateTextByDictAndPatterns(a.textContent || '', dict, patternRules); if (translated && translated !== (a.textContent || '')) { const clone = a.cloneNode(true); clone.textContent = translated; aHtml = clone.outerHTML; } } html = html.replace(marker, aHtml); }); if (parent.innerHTML !== html) { parent.innerHTML = html; processedElementSet.add(parent); return 1; } return 0; } function translateByPatterns(text, patternRules) { if (!Array.isArray(patternRules) || !patternRules.length) return null; let out = text; let changed = false; for (const { re, replace } of patternRules) { if (!re) continue; try { const next = out.replace(re, replace); if (next !== out) { out = next; changed = true; } } catch (_) { // ignore invalid regex } } return changed ? out : null; } // 需要跳过翻译的容器选择器 const SKIP_CONTAINER_SELECTOR = [ 'pre', 'code', 'kbd', 'samp', 'var', 'script', 'style', 'noscript', 'svg', 'math', // Markdown/代码内容区域 '.markdown-body', '.blob-code', '.diff-code', '.js-blob-code-container', '.react-code-lines' ].join(','); function isSkippable(node) { if (!node) return true; if (node.nodeType !== Node.TEXT_NODE) return true; const parent = node.parentElement; if (!parent) return true; if (parent.closest(SKIP_CONTAINER_SELECTOR)) return true; // 忽略很长的文本(段落类),避免误伤内容文本 const text = node.nodeValue || ''; const norm = normalizeKey(text); if (!norm) return true; if (norm.length > 80) return true; return false; } function translateTextNode(node, dict, patternRules) { if (!node || node.nodeType !== Node.TEXT_NODE) return 0; const original = node.nodeValue || ''; const leading = (original.match(/^\s*/)?.[0]) || ''; const trailing = (original.match(/\s*$/)?.[0]) || ''; const core = original.slice(leading.length, original.length - trailing.length); const key = normalizeKey(core); if (!key) return 0; const replacement = dict[key]; if (replacement) { const next = leading + replacement + trailing; if (next !== original) { node.nodeValue = next; return 1; } return 0; } // 尝试模式规则(如 "2 results" 等) if (patternRules && patternRules.length) { const byPattern = translateByPatterns(core, patternRules); if (typeof byPattern === 'string' && byPattern !== core) { const next = leading + byPattern + trailing; if (next !== original) { node.nodeValue = next; return 1; } } } return 0; } let processedElementSet = new WeakSet(); function tryTranslateByParentElement(textNode, dict, patternRules, templates) { const parent = textNode && textNode.parentElement; if (!parent || processedElementSet.has(parent)) return 0; if (parent.closest(SKIP_CONTAINER_SELECTOR)) return 0; // 情况 A:仅含内联标签(允许 KBD、SPAN、EM、STRONG、CODE);含 <a> 走模板 const allowedInlineTags = new Set(['KBD', 'SPAN', 'EM', 'STRONG', 'CODE']); const children = parent.children; let containsOnlyAllowedInline = true; let containsAnchor = false; if (children && children.length > 0) { for (let i = 0; i < children.length; i++) { const tag = children[i].tagName; if (tag === 'A') containsAnchor = true; if (!(allowedInlineTags.has(tag) || tag === 'A')) containsOnlyAllowedInline = false; } } if (containsAnchor && containsOnlyAllowedInline) { // 情况 B:仅含 <a>/<kbd>,尝试模板替换以保留链接 const changed = applyAnchorTemplate(parent, templates, dict, patternRules); if (changed) return changed; // 未命中模板则不整体替换 return 0; } else if (!containsOnlyAllowedInline && children && children.length > 0) { // 复杂结构:放弃整体替换 return 0; } const fullText = parent.textContent || ''; const norm = normalizeKey(fullText); if (!norm || norm.length > 120) return 0; let replacement = dict[norm]; // 如果没有直接命中,尝试按未规范化文本应用模式规则 if (!replacement && patternRules && patternRules.length) { const byPattern = translateByPatterns(fullText, patternRules); if (typeof byPattern === 'string' && byPattern !== fullText) { replacement = byPattern; } } if (!replacement) return 0; // 如果 replacement 含 HTML 标签,则作为 innerHTML 应用;否则尝试保留 <kbd> 结构 if (typeof replacement === 'string' && /<[^>]+>/.test(replacement)) { if (parent.innerHTML !== replacement) { parent.innerHTML = replacement; processedElementSet.add(parent); return 1; } return 0; } else { const next = String(replacement); // 若父元素包含一个或多个 <kbd>,尝试将 next 中对应字符替换为原 <kbd> 的 outerHTML const kbdNodes = parent.querySelectorAll('kbd'); if (kbdNodes.length > 0) { let html = next; kbdNodes.forEach((k) => { const token = (k.textContent || '').trim(); if (!token) return; const re = new RegExp(escapeRegExp(token)); html = html.replace(re, k.outerHTML); }); if (parent.innerHTML !== html) { parent.innerHTML = html; processedElementSet.add(parent); return 1; } return 0; } else { // 保留内联子元素(例如 caret 的 <span>),仅替换父级中的文本节点 let replaced = false; let changed = false; parent.childNodes.forEach((n) => { if (n.nodeType === Node.TEXT_NODE) { const cur = n.nodeValue || ''; const curNorm = normalizeKey(cur); if (curNorm) { if (!replaced) { if (cur !== next) { n.nodeValue = next; changed = true; } replaced = true; } else { if (cur !== '') { n.nodeValue = ''; changed = true; } } } } }); if (changed) { processedElementSet.add(parent); return 1; } return 0; } } } function translateInTree(root, dict) { if (!root || !dict) return 0; let replacedCount = 0; // 仅遍历文本节点 const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null); const toProcess = []; while (walker.nextNode()) { const t = walker.currentNode; toProcess.push(t); } for (const textNode of toProcess) { // 先尝试以父元素整体文本进行替换(支持跨内联标签的整体翻译) replacedCount += tryTranslateByParentElement(textNode, dict, currentPatternRules, currentTemplates); if (isSkippable(textNode)) continue; replacedCount += translateTextNode(textNode, dict, currentPatternRules); } return replacedCount; } function translateAttributesInRoot(root, dict) { if (!root || !dict) return 0; let count = 0; try { const attrTargets = [ ['placeholder'], ['title'], ['aria-label'] ]; const walker = (container) => { const all = container.querySelectorAll('*'); all.forEach((el) => { for (const attrs of attrTargets) { for (const attr of attrs) { if (!el.hasAttribute(attr)) continue; const value = el.getAttribute(attr) || ''; const key = normalizeKey(value); if (!key) continue; const replacement = dict[key]; if (!replacement) continue; if (replacement !== value) { el.setAttribute(attr, replacement); count++; } } } }); }; if (root instanceof ShadowRoot || root instanceof Document || root instanceof HTMLElement) { walker(root); } } catch (_) {} return count; } function translateInAllShadowRoots(dict) { let count = 0; try { const all = document.querySelectorAll('*'); all.forEach((el) => { const sr = el.shadowRoot; if (sr) { count += translateInTree(sr, dict); count += translateAttributesInRoot(sr, dict); } }); } catch (_) {} return count; } // --------------------------- // 事件与观察 // --------------------------- let currentPageKey = null; let currentDict = null; let scheduled = false; let currentPatternRules = []; let currentTemplates = Object.create(null); function applyTranslation(reason) { try { // 每次翻译重新计算已处理元素集合,避免动态更新后无法再次翻译 processedElementSet = new WeakSet(); const pageKey = detectActivePageKey(); if (pageKey !== currentPageKey || !currentDict) { currentPageKey = pageKey; currentDict = buildDictionaryForPage(pageKey); currentPatternRules = buildPatternRulesForPage(pageKey); currentTemplates = buildTemplatesForPage(pageKey); } translateInTree(document.body, currentDict); translateAttributesInRoot(document, currentDict); translateInAllShadowRoots(currentDict); } catch (e) { // eslint-disable-next-line no-console console.debug('[GH i18n] translate error:', e, 'reason =', reason); } } function scheduleTranslate(reason) { if (scheduled) return; scheduled = true; requestAnimationFrame(() => { scheduled = false; applyTranslation(reason); }); } // 观察 DOM 变化(处理懒加载和交互新增的节点) const observer = new MutationObserver((mutations) => { for (const m of mutations) { if (m.type === 'childList') { if (m.addedNodes && m.addedNodes.length) { scheduleTranslate('mutation:childList'); break; } if (m.removedNodes && m.removedNodes.length) { scheduleTranslate('mutation:childList:removed'); break; } } else if (m.type === 'characterData') { scheduleTranslate('mutation:char'); break; } else if (m.type === 'attributes') { // GitHub 会通过切换类名/属性重绘部分节点 if (m.attributeName === 'class' || m.attributeName === 'aria-label' || m.attributeName === 'data-view-component') { scheduleTranslate('mutation:attr'); break; } } } }); function startObserver() { observer.observe(document.documentElement, { subtree: true, childList: true, characterData: true, attributes: true }); } // 监听 PJAX/前端路由跳转 function hookHistory() { const origPush = history.pushState; const origReplace = history.replaceState; history.pushState = function () { const ret = origPush.apply(this, arguments); scheduleTranslate('history.pushState'); return ret; }; history.replaceState = function () { const ret = origReplace.apply(this, arguments); scheduleTranslate('history.replaceState'); return ret; }; window.addEventListener('popstate', () => scheduleTranslate('history.popstate')); // GitHub pjax 事件 document.addEventListener('pjax:end', () => scheduleTranslate('pjax:end')); } // 初始化 async function init() { hookHistory(); startObserver(); loadedDictionaries = await loadDictionaries(); applyTranslation('init'); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init, { once: true }); } else { init(); } })();