// ==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();
}
})();