搜尋框:新版 React SearchBar 用 <span data-type="filter-expression"> 包住 is:issue state:open,外層 form 只有 role="form" 不是 role="search", 既有 [role="searchbox"] / form[role="search"] 全部抓不到,導致 state:open 被翻成 state:開啟中,點擊會破壞搜尋功能。 新增 skip: - [data-type="filter-expression"] / [data-type="filter"] / [data-type="filter-value"] - .styled-input-container 貢獻 banner:整句詞庫條目永遠不匹配(contributing guidelines 在 <a> 裡, 跨不過節點邊界),實際只靠三個片段拼。原本缺前段「If you have a bug or an idea, read the」對應,所以顯示半翻。補上該片段條目,移除無效的整句條目與 read the contributing guidelines 條目。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
495 lines
17 KiB
JavaScript
495 lines
17 KiB
JavaScript
// ==UserScript==
|
||
// @name GitHub 中文化 (繁體)
|
||
// @namespace https://github.com/scriptscat/scriptcat
|
||
// @version 0.18
|
||
// @description 幫 GitHub 導覽列、按鈕、屬性換成繁體中文,支援 SPA 動態更新,不亂翻、不卡頓。
|
||
// @match https://github.com/*
|
||
// @run-at document-idle
|
||
// @grant none
|
||
// ==/UserScript==
|
||
|
||
(() => {
|
||
'use strict';
|
||
|
||
const TRANSLATION_MAP = new Map([
|
||
// 倉庫導覽
|
||
['Code', '原始碼'],
|
||
['Issues', '問題'],
|
||
['Pull requests', '拉取請求'],
|
||
['Pull request', '拉取請求'],
|
||
['Pulls', '拉取請求'],
|
||
['Discussions', '討論'],
|
||
['Actions', '動作'],
|
||
['Projects', '專案'],
|
||
['Security', '安全'],
|
||
['Security and quality', '安全與品質'],
|
||
['Code scanning', '程式碼掃描'],
|
||
['Secret scanning', '密鑰掃描'],
|
||
['Dependabot', 'Dependabot'],
|
||
['Insights', '洞察'],
|
||
['Pulse', '脈動'],
|
||
['Settings', '設定'],
|
||
['Wiki', '維基'],
|
||
['Overview', '總覽'],
|
||
['Repositories', '倉庫'],
|
||
['Repository', '倉庫'],
|
||
['Followers', '追蹤者'],
|
||
|
||
// 關注 / 分支 / 星標
|
||
['Watch', '關注'],
|
||
['Watching', '關注中'],
|
||
['Unwatch', '取消關注'],
|
||
['Fork', '分支'],
|
||
['Forks', '分支數'],
|
||
['Forked', '已分支'],
|
||
['Star', '星標'],
|
||
['Starred', '已標星'],
|
||
['Stars', '星標數'],
|
||
['Unstar', '取消星標'],
|
||
['stars', '星標'],
|
||
['watching', '關注中'],
|
||
['forks', '分支'],
|
||
['Sponsor', '贊助'],
|
||
['Sponsors', '贊助者'],
|
||
['Sponsor this project', '贊助此專案'],
|
||
['Learn more about GitHub Sponsors', '進一步了解 GitHub Sponsors'],
|
||
|
||
// 倉庫頁面側欄
|
||
['About', '關於'],
|
||
['Readme', '自述檔'],
|
||
['License', '授權'],
|
||
['MIT license', 'MIT 授權'],
|
||
['Apache-2.0 license', 'Apache-2.0 授權'],
|
||
['GPL-3.0 license', 'GPL-3.0 授權'],
|
||
['BSD-3-Clause license', 'BSD-3-Clause 授權'],
|
||
['Activity', '活動'],
|
||
['Custom properties', '自訂屬性'],
|
||
['Releases', '版本'],
|
||
['No releases published', '尚未發佈版本'],
|
||
['Contributors', '貢獻者'],
|
||
['Languages', '語言'],
|
||
['Packages', '套件'],
|
||
['No packages published', '尚未發佈套件'],
|
||
['Deployments', '部署'],
|
||
['Environments', '環境'],
|
||
['Report repository', '檢舉此倉庫'],
|
||
['Resources', '資源'],
|
||
['Citation', '引用'],
|
||
['Cite this repository', '引用此倉庫'],
|
||
['Contributing', '貢獻'],
|
||
['Code of conduct', '行為準則'],
|
||
|
||
// 檔案列表操作
|
||
['Go to file', '前往檔案'],
|
||
['Add file', '新增檔案'],
|
||
['Create new file', '新增檔案'],
|
||
['Upload files', '上傳檔案'],
|
||
['Find file', '搜尋檔案'],
|
||
['Clone', '複製'],
|
||
['Download ZIP', '下載 ZIP'],
|
||
['Open with GitHub Desktop', '以 GitHub Desktop 開啟'],
|
||
['Latest commit', '最新提交'],
|
||
['Commits', '提交'],
|
||
['Commit', '提交'],
|
||
['Branches', '分支'],
|
||
['Branch', '分支'],
|
||
['Default', '預設'],
|
||
['Active', '活躍'],
|
||
['Stale', '過時'],
|
||
['All', '全部'],
|
||
['Yours', '我的'],
|
||
['Active branches', '活躍分支'],
|
||
['Stale branches', '過時分支'],
|
||
['All branches', '所有分支'],
|
||
['Your branches', '我的分支'],
|
||
['Search branches', '搜尋分支'],
|
||
['Search branches...', '搜尋分支…'],
|
||
['New branch', '新增分支'],
|
||
['Delete branch', '刪除分支'],
|
||
['Rename branch', '重新命名分支'],
|
||
['Restore branch', '還原分支'],
|
||
['View all branches', '檢視所有分支'],
|
||
['No branches match the search', '沒有分支符合搜尋條件'],
|
||
['No branches', '沒有分支'],
|
||
['No results matched your search', '沒有結果符合你的搜尋'],
|
||
['No results found', '找不到結果'],
|
||
['Nothing to show', '沒有可顯示的內容'],
|
||
['No commits', '尚無提交'],
|
||
['No issues', '沒有問題'],
|
||
['No pull requests', '沒有拉取請求'],
|
||
['No open issues', '沒有開啟中的問題'],
|
||
['No open pull requests', '沒有開啟中的拉取請求'],
|
||
['No tags', '沒有標籤'],
|
||
['No matches found', '找不到符合項目'],
|
||
['Previous', '上一頁'],
|
||
['Next', '下一頁'],
|
||
['Loading', '載入中'],
|
||
['Updated', '更新時間'],
|
||
['Check status', '檢查狀態'],
|
||
['Behind', '落後'],
|
||
['Ahead', '領先'],
|
||
['Pull request', '拉取請求'],
|
||
['Tags', '標籤'],
|
||
['Tag', '標籤'],
|
||
|
||
// PR / Issue 常見按鈕
|
||
['New issue', '開新問題'],
|
||
['New pull request', '開新拉取請求'],
|
||
['New repository', '新增倉庫'],
|
||
['Import repository', '匯入倉庫'],
|
||
['New codespace', '新增 codespace'],
|
||
['New gist', '新增 gist'],
|
||
['New organization', '新增組織'],
|
||
['Compare', '比較'],
|
||
['Merge', '合併'],
|
||
['Merge pull request', '合併拉取請求'],
|
||
['Squash and merge', '壓縮後合併'],
|
||
['Rebase and merge', '重定基底後合併'],
|
||
['Create a merge commit', '建立合併提交'],
|
||
['Close issue', '關閉問題'],
|
||
['Close pull request', '關閉拉取請求'],
|
||
['Reopen issue', '重新開啟問題'],
|
||
['Comment', '留言'],
|
||
['Review changes', '審查變更'],
|
||
['Files changed', '變更檔案'],
|
||
['Conversation', '對話'],
|
||
['Checks', '檢查'],
|
||
|
||
// 列表篩選
|
||
['Open', '開啟中'],
|
||
['Closed', '已關閉'],
|
||
['Author', '作者'],
|
||
['Labels', '標籤'],
|
||
['Label', '標籤'],
|
||
['Milestones', '里程碑'],
|
||
['Milestone', '里程碑'],
|
||
['Assignee', '指派對象'],
|
||
['Assignees', '指派對象'],
|
||
['Reviewer', '審查者'],
|
||
['Reviewers', '審查者'],
|
||
['Sort', '排序'],
|
||
['Sort by', '排序依據'],
|
||
['Order', '順序'],
|
||
['Newest', '最新'],
|
||
['Oldest', '最舊'],
|
||
['Created on', '建立時間'],
|
||
['Last updated', '最後更新時間'],
|
||
['Most commented', '留言最多'],
|
||
['Total comments', '留言總數'],
|
||
['Best match', '最佳匹配'],
|
||
['Reactions', '反應'],
|
||
['Most reactions', '反應最多'],
|
||
['Recently updated', '最近更新'],
|
||
['Dismiss', '關閉提示'],
|
||
['Want to contribute to', '想貢獻給'],
|
||
// 此橫幅實際是三段文字節點:前段文字 + <a>contributing guidelines</a> + 後段文字。
|
||
// 整句條目永遠不會匹配(跨不過 <a> 邊界),只靠三個片段湊。
|
||
['If you have a bug or an idea, read the', '若您發現錯誤或有新想法,請先閱讀'],
|
||
['contributing guidelines', '貢獻指引'],
|
||
['before opening an issue', '再開啟問題'],
|
||
['Never', '從未'],
|
||
['Everyone', '所有人'],
|
||
['Anyone', '任何人'],
|
||
|
||
// Copilot / 搜尋框
|
||
['Ask Copilot', '詢問 Copilot'],
|
||
['About this page', '關於此頁'],
|
||
['In a space', '在 Space 中'],
|
||
['Download for', '下載'],
|
||
['Type', '輸入'],
|
||
['to search', '搜尋'],
|
||
|
||
// 全站導覽 / 使用者選單
|
||
['Pull', '拉取'],
|
||
['Marketplace', '市集'],
|
||
['Explore', '探索'],
|
||
['Your profile', '個人頁面'],
|
||
['Your repositories', '我的倉庫'],
|
||
['Your organizations', '我的組織'],
|
||
['Your projects', '我的專案'],
|
||
['Your stars', '我的星標'],
|
||
['Your gists', '我的程式片段'],
|
||
['Your sponsors', '我的贊助者'],
|
||
['Upgrade', '升級'],
|
||
['Feature preview', '功能預覽'],
|
||
['Help', '說明'],
|
||
['Sign out', '登出'],
|
||
['Sign in', '登入'],
|
||
|
||
// 通用動作
|
||
['Save', '儲存'],
|
||
['Cancel', '取消'],
|
||
['Delete', '刪除'],
|
||
['Edit', '編輯'],
|
||
['Update', '更新'],
|
||
['Create', '建立'],
|
||
['Submit', '送出'],
|
||
['Search', '搜尋'],
|
||
['Filter', '篩選'],
|
||
['Filters', '篩選'],
|
||
['Follow', '追蹤'],
|
||
['Following', '追蹤中'],
|
||
['Unfollow', '取消追蹤'],
|
||
['Block or Report', '封鎖或檢舉'],
|
||
|
||
// 頁尾 / 無障礙
|
||
['Footer', '頁尾'],
|
||
['Footer navigation', '頁尾導覽'],
|
||
['GitHub Homepage', 'GitHub 首頁'],
|
||
['Terms', '服務條款'],
|
||
['Privacy', '隱私'],
|
||
['Status', '狀態'],
|
||
['Community', '社群'],
|
||
['Docs', '文件'],
|
||
['Contact', '聯絡我們'],
|
||
['Manage cookies', '管理 Cookie'],
|
||
['Do not share my personal information', '不要分享我的個人資訊'],
|
||
['Skip to content', '跳至內容'],
|
||
|
||
// 相對時間
|
||
['yesterday', '昨天'],
|
||
['today', '今天'],
|
||
['tomorrow', '明天'],
|
||
['now', '剛剛'],
|
||
['just now', '剛才'],
|
||
['last week', '上週'],
|
||
['last month', '上個月'],
|
||
['last year', '去年'],
|
||
['this week', '本週'],
|
||
['this month', '本月'],
|
||
['this year', '今年'],
|
||
]);
|
||
|
||
const TIME_UNITS = {
|
||
second: '秒', minute: '分鐘', hour: '小時',
|
||
day: '天', week: '週', month: '個月', year: '年',
|
||
};
|
||
const TIME_AGO_REGEX = /\b(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago\b/gi;
|
||
const TIME_AGO_ARTICLE_REGEX = /\b(a|an)\s+(second|minute|hour|day|week|month|year)\s+ago\b/gi;
|
||
|
||
// 這些標籤的文字節點不翻(避免動到原始碼、編輯器);但它們的屬性(如 placeholder)仍會翻。
|
||
const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'CODE', 'PRE', 'TEXTAREA', 'INPUT', 'NOSCRIPT']);
|
||
const ATTRIBUTES_TO_TRANSLATE = ['data-content', 'aria-label', 'title', 'placeholder'];
|
||
const ATTR_SELECTOR = '[data-content], [aria-label], [title], [placeholder]';
|
||
|
||
// 這些區塊內的文字節點(屬於使用者內容:檔名、commit 訊息、倉庫描述、README、程式碼…)一律跳過。
|
||
const SKIP_CONTEXT_SELECTOR = [
|
||
// 檔案列表(新版 React UI)
|
||
'.react-directory-filename-column',
|
||
'.react-directory-commit-message',
|
||
'[aria-labelledby="folders-and-files"] [role="rowheader"]',
|
||
// 最新 commit 列
|
||
'[data-testid="latest-commit-details"]',
|
||
'[data-testid="latest-commit"]',
|
||
// 任何指向檔案/資料夾/commit/raw 的連結(含裡頭包的 <strong> 等)
|
||
'a[href*="/blob/"]',
|
||
'a[href*="/tree/"]',
|
||
'a[href*="/commit/"]',
|
||
'a[href*="/raw/"]',
|
||
'a[href*="/releases/tag/"]',
|
||
// 倉庫描述(About 區塊的敘述段落)
|
||
'[data-testid="repository-description"]',
|
||
'[itemprop="about"]',
|
||
'.BorderGrid-cell > p',
|
||
'.f4.my-3',
|
||
// 2026 新版 React UI:描述 <p class="f4 tmp-my-3"> 包在 .hide-sm.hide-md 裡,原 `> p` 直接子代選擇器失效。
|
||
// 用 p.f4 靠語意(f4 = 大型段落文字 = prose 描述),對未來 tmp-* 類別變動有抵抗力。
|
||
'p.f4',
|
||
// Topics 標籤
|
||
'a.topic-tag',
|
||
'.topic-tag',
|
||
'.topic-tag-link',
|
||
// 使用者/組織連結(含 hovercard)— 避免翻到使用者名稱
|
||
'a[data-hovercard-type="user"]',
|
||
'a[data-hovercard-type="organization"]',
|
||
'a[data-hovercard-url*="/users/"]',
|
||
'a[data-hovercard-url*="/orgs/"]',
|
||
'a[data-hovercard-url*="/hovercards"]',
|
||
// 搜尋框 / 過濾器(含語法高亮 token)
|
||
'[role="searchbox"]',
|
||
'[role="combobox"]',
|
||
'form[role="search"]',
|
||
'.subnav-search-input',
|
||
'.subnav-search-context',
|
||
'input[name="q"] ~ *',
|
||
'.pl-c1', '.pl-s', '.pl-en', '.pl-k', '.pl-v', '.pl-smi', '.pl-s1',
|
||
// 2026 新版 React SearchBar:filter 語法 token 用 data-type 標記(is / state / open / issue 等),
|
||
// 外層 form 只有 role="form" 不是 role="search",原規則抓不到。
|
||
'[data-type="filter-expression"]',
|
||
'[data-type="filter"]',
|
||
'[data-type="filter-value"]',
|
||
'.styled-input-container',
|
||
// Labels(使用者自定義 label 顏色塊)
|
||
'a.IssueLabel',
|
||
'.IssueLabel',
|
||
'.Label',
|
||
// 倉庫完整名 org/repo 連結
|
||
'a[data-octo-click="hovercard-link-click"]',
|
||
// README / 渲染後的 markdown
|
||
'.markdown-body',
|
||
'article.markdown-body',
|
||
// 檔案內容、程式碼檢視
|
||
'.blob-code-inner',
|
||
'.highlight',
|
||
'.react-code-lines',
|
||
'.react-blob-view-header-sticky',
|
||
// Issue / PR 標題與內文
|
||
'.markdown-title',
|
||
'.js-issue-title',
|
||
'.markdown-body .comment-body',
|
||
].join(',');
|
||
|
||
// 預編譯一次:長詞優先、忽略大小寫
|
||
const WORD_BOUNDARY_REGEX = (() => {
|
||
const sorted = [...TRANSLATION_MAP.keys()]
|
||
.sort((a, b) => b.length - a.length)
|
||
.map(s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||
return new RegExp(`\\b(${sorted.join('|')})\\b`, 'gi');
|
||
})();
|
||
|
||
// 給 regex 匹配結果做大小寫不敏感查表
|
||
const LOWER_MAP = new Map(
|
||
[...TRANSLATION_MAP.entries()].map(([k, v]) => [k.toLowerCase(), v])
|
||
);
|
||
|
||
// 把 62.8k → 6.3 萬、1.2M → 120 萬、999 以下千分號化
|
||
function formatAbbrev(_m, num, unit) {
|
||
const mul = unit.toLowerCase() === 'm' ? 1_000_000 : 1_000;
|
||
const val = Math.round(parseFloat(num) * mul);
|
||
if (val >= 10000) {
|
||
const wan = val / 10000;
|
||
const s = wan >= 100 ? Math.round(wan).toString() : wan.toFixed(1).replace(/\.0$/, '');
|
||
return `${s} 萬`;
|
||
}
|
||
return val.toLocaleString('en-US');
|
||
}
|
||
|
||
const ABBREV_REGEX = /\b(\d+(?:\.\d+)?)([kKmM])\b/g;
|
||
|
||
function replaceText(text) {
|
||
if (!text) return text;
|
||
let out = text;
|
||
if (/[A-Za-z]/.test(out)) {
|
||
// 先處理 "X units ago" 避免被詞庫部分吃掉
|
||
out = out.replace(TIME_AGO_REGEX, (_m, n, u) => `${n} ${TIME_UNITS[u.toLowerCase()]}前`);
|
||
out = out.replace(TIME_AGO_ARTICLE_REGEX, (_m, _a, u) => `1 ${TIME_UNITS[u.toLowerCase()]}前`);
|
||
out = out.replace(WORD_BOUNDARY_REGEX, (m) => LOWER_MAP.get(m.toLowerCase()) ?? m);
|
||
}
|
||
if (ABBREV_REGEX.test(out)) {
|
||
ABBREV_REGEX.lastIndex = 0;
|
||
out = out.replace(ABBREV_REGEX, formatAbbrev);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function shouldSkipTextAncestor(el) {
|
||
if (!el) return false;
|
||
if (el.closest?.(SKIP_CONTEXT_SELECTOR)) return true;
|
||
while (el && el.nodeType === Node.ELEMENT_NODE) {
|
||
if (SKIP_TAGS.has(el.nodeName)) return true;
|
||
if (el.isContentEditable) return true;
|
||
if (el.getAttribute?.('role') === 'textbox') return true;
|
||
el = el.parentElement;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function translateAttributes(el) {
|
||
if (!(el instanceof Element)) return;
|
||
for (const attr of ATTRIBUTES_TO_TRANSLATE) {
|
||
const val = el.getAttribute?.(attr);
|
||
if (!val) continue;
|
||
const replaced = replaceText(val);
|
||
if (replaced !== val) el.setAttribute(attr, replaced);
|
||
}
|
||
}
|
||
|
||
function translateTextNode(node) {
|
||
const original = node.nodeValue;
|
||
if (!original || !/\S/.test(original)) return;
|
||
if (shouldSkipTextAncestor(node.parentElement)) return;
|
||
const replaced = replaceText(original);
|
||
if (replaced !== original) node.nodeValue = replaced;
|
||
}
|
||
|
||
function translateTree(root) {
|
||
if (!root) return;
|
||
// 屬性:root 自己 + 所有子孫
|
||
if (root instanceof Element && root.matches?.(ATTR_SELECTOR)) translateAttributes(root);
|
||
root.querySelectorAll?.(ATTR_SELECTOR).forEach(translateAttributes);
|
||
|
||
// 文字節點:用 TreeWalker 走全部
|
||
const scanRoot = root.nodeType === Node.DOCUMENT_NODE ? root.body : root;
|
||
if (!scanRoot) return;
|
||
const walker = document.createTreeWalker(scanRoot, NodeFilter.SHOW_TEXT, {
|
||
acceptNode: (n) => {
|
||
if (!n.nodeValue || !/\S/.test(n.nodeValue)) return NodeFilter.FILTER_REJECT;
|
||
if (shouldSkipTextAncestor(n.parentElement)) return NodeFilter.FILTER_REJECT;
|
||
return NodeFilter.FILTER_ACCEPT;
|
||
},
|
||
});
|
||
const nodes = [];
|
||
let n;
|
||
while ((n = walker.nextNode())) nodes.push(n);
|
||
for (const node of nodes) translateTextNode(node);
|
||
}
|
||
|
||
// debounce:短時間內多次變更只翻一次
|
||
function debounce(fn, wait) {
|
||
let timer = 0;
|
||
return () => {
|
||
clearTimeout(timer);
|
||
timer = setTimeout(fn, wait);
|
||
};
|
||
}
|
||
|
||
function init() {
|
||
translateTree(document.body);
|
||
|
||
const scheduleTranslate = debounce(() => translateTree(document.body), 150);
|
||
|
||
const observer = new MutationObserver((mutations) => {
|
||
for (const m of mutations) {
|
||
if (m.type === 'attributes' && m.target instanceof Element) {
|
||
translateAttributes(m.target);
|
||
} else if (m.type === 'childList') {
|
||
m.addedNodes.forEach((n) => {
|
||
if (n.nodeType === Node.ELEMENT_NODE) translateTree(n);
|
||
else if (n.nodeType === Node.TEXT_NODE) translateTextNode(n);
|
||
});
|
||
}
|
||
}
|
||
// 保險起見再安排一次全域掃描(SPA 會在微任務後才補資料)
|
||
scheduleTranslate();
|
||
});
|
||
|
||
observer.observe(document.body, {
|
||
subtree: true,
|
||
childList: true,
|
||
attributes: true,
|
||
attributeFilter: ATTRIBUTES_TO_TRANSLATE,
|
||
});
|
||
|
||
// SPA 路由切換後重新掃描
|
||
const hookHistory = (type) => {
|
||
const orig = history[type];
|
||
history[type] = function () {
|
||
const ret = orig.apply(this, arguments);
|
||
scheduleTranslate();
|
||
return ret;
|
||
};
|
||
};
|
||
hookHistory('pushState');
|
||
hookHistory('replaceState');
|
||
window.addEventListener('popstate', scheduleTranslate);
|
||
document.addEventListener('turbo:load', scheduleTranslate);
|
||
document.addEventListener('turbo:render', scheduleTranslate);
|
||
}
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init, { once: true });
|
||
} else {
|
||
init();
|
||
}
|
||
})();
|