deepwiki输出的质量比较好,但是英文的读起来没那么顺畅,GPT帮忙写了个翻译的脚本
我个人用的是gemini2.5 flash,可以自行配置。把代码粘贴到油猴中就可以了
// ==UserScript==
// @name DeepWiki Instant Translator (EN↔ZH)
// @namespace https://chat.openai.com/
// @version 0.1.0
// @description Translate DeepWiki pages on the fly with OpenAI or DeepL; preserves code blocks; works with dynamically loaded content.
// @author You
// @match https://deepwiki.com/*
// @match https://www.deepwiki.com/*
// @run-at document-end
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_xmlhttpRequest
// @connect api.openai.com
// @connect api.deepl.com
// @connect api-free.deepl.com
// @connect openrouter.ai
// @connect *.openrouter.ai // (可选)如果你用的是区域/镜像域名
// @connect api.openrouter.ai
// ==/UserScript==
(function () {
'use strict';
// -------------------- Config (editable in UI) --------------------
const DEFAULTS = {
provider: 'openai', // 'openai' | 'deepl'
openaiModel: 'google/gemini-2.5-flash',
openaiBase: 'https://api.openrouter.ai/v1/',
deeplBase: 'https://api-free.deepl.com/v2/translate', // change to https://api.deepl.com/v2/translate if you have Pro
apiKey: '',
direction: 'auto', // 'auto' | 'en2zh' | 'zh2en'
translateScope: 'main', // 'main' | 'body'
maxCharsPerBatch: 1800,
concurrency: 1,
cacheTTLHours: 72
};
// Keys for GM storage
const KV = {
CFG: 'dw_i18n_cfg_v1',
CACHE: 'dw_i18n_cache_v1'
};
// -------------------- Utilities --------------------
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function loadCfg() {
const raw = GM_getValue(KV.CFG);
if (!raw) return { ...DEFAULTS };
try { return { ...DEFAULTS, ...JSON.parse(raw) }; } catch (e) { return { ...DEFAULTS }; }
}
function saveCfg(cfg) {
GM_setValue(KV.CFG, JSON.stringify(cfg));
}
function loadCache() {
const raw = GM_getValue(KV.CACHE);
if (!raw) return {};
try { return JSON.parse(raw); } catch (e) { return {}; }
}
function saveCache(cache) {
GM_setValue(KV.CACHE, JSON.stringify(cache));
}
function clearCache() {
CACHE = {};
saveCache(CACHE);
}
function hashKey(s) {
// simple djb2 hash -> string
let h = 5381;
for (let i = 0; i < s.length; i++) h = ((h << 5) + h) + s.charCodeAt(i);
return 'h' + (h >>> 0);
}
function isCJK(str) {
return /[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff]/.test(str);
}
function detectDirection(textSample, cfg) {
if (cfg.direction !== 'auto') return cfg.direction;
// Heuristic: if mostly CJK, translate to English; else to Chinese
return isCJK(textSample) ? 'zh2en' : 'en2zh';
}
function isVisible(el) {
const style = el.nodeType === Node.ELEMENT_NODE ? getComputedStyle(el) : getComputedStyle(el.parentElement);
return style && style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
}
function isInside(node, tagsUpper) {
let p = node.parentNode;
while (p) {
if (p.nodeType === Node.ELEMENT_NODE) {
const tag = p.tagName.toUpperCase();
if (tagsUpper.includes(tag)) return true;
if (p.getAttribute && p.getAttribute('data-i18n-skip') === '1') return true;
}
p = p.parentNode;
}
return false;
}
function selectRoot(cfg) {
const main = document.querySelector('main');
if (cfg.translateScope === 'main' && main) return main;
return document.body;
}
function makeWalker(root) {
return document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
if (!node || !node.nodeValue) return NodeFilter.FILTER_REJECT;
const text = node.nodeValue.replace(/[\s\u00A0]+/g, ' ').trim();
if (!text) return NodeFilter.FILTER_REJECT;
if (text.length < 2) return NodeFilter.FILTER_REJECT;
if (isInside(node, ['CODE', 'PRE', 'KBD', 'SAMP', 'SCRIPT', 'STYLE', 'NOSCRIPT', 'MATH', 'SVG', 'TEXTAREA', 'INPUT'])) return NodeFilter.FILTER_REJECT;
if (!isVisible(node)) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
});
}
function splitForBatches(items, maxChars) {
const batches = [];
let cur = [];
let total = 0;
for (const it of items) {
const len = it.text.length;
if (len > maxChars) {
// split long text by sentences or punctuation
const chunks = it.text.split(/(?<=[.!?。!?;;])\s+/);
for (const ch of chunks) {
if (!ch.trim()) continue;
batches.push([{ ...it, text: ch }]);
}
continue;
}
if (total + len > maxChars && cur.length) {
batches.push(cur);
cur = [];
total = 0;
}
cur.push(it);
total += len + 1;
}
if (cur.length) batches.push(cur);
return batches;
}
function wrapNode(node) {
// Store original text and put a span wrapper to allow toggling
const span = document.createElement('span');
span.className = 'dw-i18n-span';
span.dataset.original = node.nodeValue;
node.parentNode.replaceChild(span, node);
span.textContent = node.nodeValue;
return span;
}
function collectTextNodes(root) {
const walker = makeWalker(root);
const items = [];
let node;
while ((node = walker.nextNode())) {
if (!node.nodeValue || !node.parentNode) continue;
items.push({ node, text: node.nodeValue });
}
return items;
}
function nowTs() { return Date.now(); }
function hours(ms) { return ms / 3600000; }
function getCached(cache, text) {
const key = hashKey(text);
const rec = cache[key];
if (!rec) return null;
if (hours(nowTs() - rec.ts) > CFG.cacheTTLHours) return null;
return rec.val;
}
function setCached(cache, text, val) {
const key = hashKey(text);
cache[key] = { val, ts: nowTs() };
}
// Normalize OpenAI base to ensure /chat/completions is present
function normalizeOpenAIBase(url) {
if (!url) return '';
try {
const u = new URL(url);
// If base ends with /v1 or /v1/, append chat/completions
if (/\/v1\/?/.test(u.pathname)) {
u.pathname = u.pathname.replace(/\/v1\/?/, '/v1/chat/completions');
}
// If path is /api/v1 or /api/v1/, also append
if (/\/api\/v1\/?/.test(u.pathname)) {
u.pathname = u.pathname.replace(/\/api\/v1\/?/, '/api/v1/chat/completions');
}
return u.toString();
} catch (_) {
return url;
}
}
// -------------------- Providers --------------------
async function translateOpenAI(batchText, direction, cfg) {
const sys = direction === 'en2zh'
? 'You are a professional English→Chinese translator. Translate the user text to natural, concise Simplified Chinese. Preserve code, math, and inline code exactly as-is. Keep line breaks. Return ONLY the translated text.'
: 'You are a professional Chinese→English translator. Translate the user text to clear, idiomatic English for technical documentation. Preserve code, math, and inline code exactly as-is. Keep line breaks. Return ONLY the translated text.';
const base = normalizeOpenAIBase(cfg.openaiBase);
let res = await gmFetch(base, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer {cfg.apiKey}`,
'X-Title': 'DeepWiki Instant Translator'
},
data: JSON.stringify({
model: cfg.openaiModel,
temperature: 0.2,
messages: [
{ role: 'system', content: sys },
{ role: 'user', content: batchText }
]
}),
noFetchFallback: true
});
if (!res.ok) {
// 若是 0(网络/DNS),尝试在 api.openrouter.ai 与 openrouter.ai/api 间切换
if (res.status === 0 && typeof base === 'string' && /openrouter\.ai/.test(base)) {
const alt = base.includes('api.openrouter.ai')
? base.replace('api.openrouter.ai', 'openrouter.ai/api')
: base.replace('openrouter.ai/api', 'api.openrouter.ai');
const retry0 = await gmFetch(alt, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer{cfg.apiKey}`,
'X-Title': 'DeepWiki Instant Translator'
},
data: JSON.stringify({
model: cfg.openaiModel,
temperature: 0.2,
messages: [
{ role: 'system', content: sys },
{ role: 'user', content: batchText }
]
}),
noFetchFallback: true
});
if (retry0.ok) {
res = retry0;
} else {
throw new Error(`OpenAI HTTP {retry0.status}; finalUrl:{retry0.finalUrl || ''}`);
}
} else {
throw new Error(`OpenAI HTTP {res.status}; finalUrl:{res.finalUrl || ''}`);
}
}
let ct = (res.headers || '').toLowerCase();
if (ct && !ct.includes('application/json')) {
// 如果命中 openrouter 网页而非 API,尝试回退到 api.openrouter.ai
if (typeof cfg.openaiBase === 'string' && cfg.openaiBase.includes('openrouter.ai/api/')) {
const alt = cfg.openaiBase.replace('openrouter.ai/api/', 'api.openrouter.ai/');
const retry = await gmFetch(alt, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer {cfg.apiKey}`,
'X-Title': 'DeepWiki Instant Translator'
},
data: JSON.stringify({
model: cfg.openaiModel,
temperature: 0.2,
messages: [
{ role: 'system', content: sys },
{ role: 'user', content: batchText }
]
})
});
if (retry.ok) {
res = retry;
ct = (res.headers || '').toLowerCase();
}
}
}
if (ct && !ct.includes('application/json')) {
const preview = (res.responseText || '').slice(0, 200);
throw new Error(`OpenAI: unexpected content-type:{ct}; finalUrl: {res.finalUrl || ''}; preview:{preview}`);
}
let json;
try {
json = JSON.parse(res.responseText);
} catch (err) {
const preview = (res.responseText || '').slice(0, 200);
throw new Error(`OpenAI: invalid JSON: {err && err.message ? err.message : String(err)}; finalUrl:{res.finalUrl || ''}; preview: {preview}`);
}
const text = json.choices?.[0]?.message?.content?.trim();
if (!text) throw new Error('OpenAI: empty response');
return text;
}
async function translateDeepL(batchText, direction, cfg) {
const target_lang = direction === 'en2zh' ? 'ZH' : 'EN-US';
const source_lang = direction === 'en2zh' ? 'EN' : 'ZH';
const res = await gmFetch(cfg.deeplBase, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json', 'Authorization': `DeepL-Auth-Key{cfg.apiKey}` },
data: new URLSearchParams({ text: batchText, target_lang, source_lang }).toString()
});
if (!res.ok) throw new Error(`DeepL HTTP {res.status}`);
const ct = (res.headers || '').toLowerCase();
if (ct && !ct.includes('application/json')) {
const preview = (res.responseText || '').slice(0, 200);
throw new Error(`DeepL: unexpected content-type:{ct}; preview: {preview}`);
}
let json;
try {
json = JSON.parse(res.responseText);
} catch (err) {
const preview = (res.responseText || '').slice(0, 200);
throw new Error(`DeepL: invalid JSON:{err && err.message ? err.message : String(err)}; preview: {preview}`);
}
const text = json.translations?.[0]?.text?.trim();
if (!text) throw new Error('DeepL: empty response');
return text;
}
async function gmFetch(url, opts) {
const noFetchFallback = !!opts.noFetchFallback;
const gmResult = await new Promise((resolve) => {
GM_xmlhttpRequest({
url,
method: opts.method || 'GET',
headers: opts.headers || {},
data: opts.data,
responseType: 'text',
timeout: 30000,
onload: (r) => resolve({ ok: r.status >= 200 && r.status<300, status: r.status, responseText: r.responseText, headers: r.responseHeaders || '', finalUrl: r.finalUrl || '' }),
onerror: (e) => resolve({ ok: false, status: 0, responseText: String(e && e.error ? e.error : e), headers: '', finalUrl: '' }),
ontimeout: () => resolve({ ok: false, status: 0, responseText: 'timeout', headers: '', finalUrl: '' }),
onabort: () => resolve({ ok: false, status: 0, responseText: 'aborted', headers: '', finalUrl: '' })
});
});
if (gmResult.ok || gmResult.status !== 0 || noFetchFallback) return gmResult;
// 回退到原生 fetch(若目标支持 CORS)
try {
const resp = await fetch(url, {
method: opts.method || 'GET',
headers: opts.headers || {},
body: opts.data,
mode: 'cors',
credentials: 'omit',
cache: 'no-cache'
});
const text = await resp.text();
let headerStr = '';
try {
resp.headers.forEach((v, k) => { headerStr += `{k}:${v}\n`; });
} catch (_) { /* ignore */ }
return { ok: resp.ok, status: resp.status, responseText: text, headers: headerStr, finalUrl: resp.url || '' };
} catch (err) {
return { ok: false, status: 0, responseText: String(err && err.message ? err.message : err), headers: '', finalUrl: '' };
}
}
// -------------------- Core translate pipeline --------------------
let CFG = loadCfg();
// 迁移:修正旧配置中的 openrouter 网页端点为 API 端点
if (CFG.openaiBase && CFG.openaiBase.includes('openrouter.ai/api/')) {
CFG.openaiBase = CFG.openaiBase.replace('openrouter.ai/api/', 'api.openrouter.ai/');
saveCfg(CFG);
}
let CACHE = loadCache();
const SEP = '\n<<<SEG>>>\n';
async function translateNodes(nodes, dir) {
// 1) Wrap nodes with spans if not already
const spans = nodes.map(({ node, text }) => {
if (node.parentNode && node.parentNode.classList && node.parentNode.classList.contains('dw-i18n-span')) {
return { span: node.parentNode, text };
}
const span = wrapNode(node);
return { span, text };
});
// 2) Prepare work items (skip cached)
const work = [];
for (const it of spans) {
const cached = getCached(CACHE, it.text);
if (cached) {
it.span.dataset.translated = cached;
continue;
}
work.push({ span: it.span, text: it.text });
}
if (!work.length) {
// just apply translated text from cache
for (const it of spans) if (it.span.dataset.translated) it.span.textContent = it.span.dataset.translated;
return;
}
const batches = splitForBatches(work, CFG.maxCharsPerBatch);
for (const batch of batches) {
const joined = batch.map((b) => b.text).join(SEP);
const provider = CFG.provider;
let translatedJoined = '';
try {
if (provider === 'openai') translatedJoined = await translateOpenAI(joined, dir, CFG);
else translatedJoined = await translateDeepL(joined, dir, CFG);
} catch (e) {
console.error('[DW-I18N] translate error', e);
continue;
}
const parts = translatedJoined.split(SEP);
if (parts.length !== batch.length) {
// fallback: naive split by line count
console.warn('[DW-I18N] Segment mismatch; applying naive mapping');
for (let i = 0; i < batch.length; i++) {
const piece = parts[i] || translatedJoined;
batch[i].span.dataset.translated = piece;
batch[i].span.textContent = piece;
setCached(CACHE, batch[i].text, piece);
}
} else {
for (let i = 0; i < batch.length; i++) {
const piece = parts[i];
batch[i].span.dataset.translated = piece;
batch[i].span.textContent = piece;
setCached(CACHE, batch[i].text, piece);
}
}
saveCache(CACHE);
await sleep(50);
}
}
// Debounce util
function debounce(fn, wait) {
let t; return function (...args) { clearTimeout(t); t = setTimeout(() => fn.apply(this, args), wait); };
}
// -------------------- UI --------------------
function createPanel() {
const host = document.createElement('div');
host.id = 'dw-i18n-panel-host';
document.documentElement.appendChild(host);
const root = host.attachShadow({ mode: 'open' });
const html = `
<style>
.card{ position:fixed; right:16px; bottom:16px; z-index:2147483647; background:#111827; color:#e5e7eb; padding:12px; border-radius:14px; box-shadow:0 10px 30px rgba(0,0,0,.4); font: 13px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial; width: 320px }
.row{ display:flex; gap:8px; align-items:center; margin:6px 0 }
.row label{ flex:0 0 92px; color:#9ca3af }
.row input[type="text"], .row input[type="password"], .row select { flex:1; background:#0b1220; border:1px solid #374151; border-radius:8px; padding:6px 8px; color:#e5e7eb }
.row input::placeholder { color:#6b7280 }
.actions{ display:flex; gap:8px; margin-top:10px }
.btn{ flex:1; background:#2563eb; color:white; border:none; border-radius:10px; padding:8px 10px; cursor:pointer }
.btn.secondary{ background:#374151 }
.small{ font-size:12px; color:#9ca3af }
.pill{ display:inline-flex; gap:6px; align-items:center; background:#0b1220; border:1px solid #374151; border-radius:999px; padding:4px 8px }
.toggle{ cursor:pointer; }
</style>
<div class="card">
<div class="row"><strong>DeepWiki 即时翻译</strong><span class="pill" id="status">待机</span></div>
<div class="row"><label>服务商</label>
<select id="provider">
<option value="openai">OpenAI</option>
<option value="deepl">DeepL</option>
</select>
</div>
<div class="row" id="modelRow"><label>模型</label><input id="model" type="text" placeholder="gpt-4o-mini"/></div>
<div class="row"><label>API Key</label><input id="apikey" type="password" placeholder="粘贴你的密钥"/></div>
<div class="row" id="baseRow"><label>接口地址</label><input id="base" type="text" placeholder="https://api.openrouter.ai/v1/chat/completions"/></div>
<div class="row"><label>方向</label>
<select id="direction">
<option value="auto">自动检测</option>
<option value="en2zh">英 → 中</option>
<option value="zh2en">中 → 英</option>
</select>
</div>
<div class="row"><label>范围</label>
<select id="scope">
<option value="main">仅 main</option>
<option value="body">整个页面</option>
</select>
</div>
<div class="actions">
<button class="btn" id="doTrans">翻译</button>
<button class="btn secondary" id="toggle">原文/译文</button>
<button class="btn secondary" id="clearCache">清理缓存</button>
</div>
<div class="small">提示:代码块、内联代码、MathJax 将被自动跳过;内容变化时自动监听。</div>
</div>
`;
root.innerHTML = html;
const qs = (sel) => root.querySelector(sel);
const ui = {
host, root,
status: qs('#status'),
provider: qs('#provider'),
model: qs('#model'),
apikey: qs('#apikey'),
base: qs('#base'),
direction: qs('#direction'),
scope: qs('#scope'),
// auto removed
doTrans: qs('#doTrans'),
toggle: qs('#toggle')
,clearCache: qs('#clearCache')
};
// init from CFG
ui.provider.value = CFG.provider;
ui.model.value = CFG.openaiModel;
ui.apikey.value = CFG.apiKey;
ui.base.value = CFG.openaiBase;
ui.direction.value = CFG.direction;
ui.scope.value = CFG.translateScope;
ui.model.parentElement.style.display = CFG.provider === 'openai' ? '' : 'none';
ui.base.parentElement.style.display = CFG.provider === 'openai' ? '' : 'none';
// events
ui.provider.onchange = () => {
CFG.provider = ui.provider.value; saveCfg(CFG);
ui.model.parentElement.style.display = CFG.provider === 'openai' ? '' : 'none';
ui.base.parentElement.style.display = CFG.provider === 'openai' ? '' : 'none';
};
ui.model.oninput = () => { CFG.openaiModel = ui.model.value.trim(); saveCfg(CFG); };
ui.apikey.oninput = () => { CFG.apiKey = ui.apikey.value.trim(); saveCfg(CFG); };
ui.base.oninput = () => { CFG.openaiBase = normalizeOpenAIBase(ui.base.value.trim()); ui.base.value = CFG.openaiBase; saveCfg(CFG); };
ui.direction.onchange = () => { CFG.direction = ui.direction.value; saveCfg(CFG); };
ui.scope.onchange = () => { CFG.translateScope = ui.scope.value; saveCfg(CFG); };
ui.doTrans.onclick = runTranslate;
ui.toggle.onclick = toggleText;
ui.clearCache.onclick = clearCacheAndReset;
return ui;
}
function setStatus(ui, text) { ui.status.textContent = text; }
let showingTranslation = true;
function toggleText() {
const spans = document.querySelectorAll('.dw-i18n-span');
for (const s of spans) {
const orig = s.dataset.original || '';
const tr = s.dataset.translated || '';
if (showingTranslation) s.textContent = orig; else if (tr) s.textContent = tr;
}
showingTranslation = !showingTranslation;
}
function clearCacheAndReset() {
clearCache();
// 将页面切回原文
const spans = document.querySelectorAll('.dw-i18n-span');
for (const s of spans) {
const orig = s.dataset.original || '';
s.textContent = orig;
delete s.dataset.translated;
}
setStatus(UI, '缓存已清空');
}
let isTranslating = false;
const runTranslate = debounce(async function () {
if (isTranslating) return;
isTranslating = true;
if (!CFG.apiKey) { alert('请先在面板中填写 API Key'); return; }
const root = selectRoot(CFG);
const nodes = collectTextNodes(root);
if (!nodes.length) return;
const sample = nodes.slice(0, 20).map(n => n.text).join(' ');
const dir = detectDirection(sample, CFG);
setStatus(UI, dir === 'en2zh' ? '英→中翻译中…' : '中→英翻译中…');
try {
await translateNodes(nodes, dir);
} finally {
isTranslating = false;
}
setStatus(UI, '完成');
}, 250);
// -------------------- Observe DOM changes --------------------
let UI = null;
function startObserver() {
const root = selectRoot(CFG);
const obs = new MutationObserver(debounce((muts) => {
// 自动翻译已移除,这里不再触发自动翻译
}, 400));
obs.observe(root, { childList: true, subtree: true, characterData: true });
}
// -------------------- Boot --------------------
function boot() {
UI = createPanel();
startObserver();
// 自动翻译已移除,启动时不自动翻译
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
else boot();
})();
文章评论