您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
只翻译你的生词表【Version Comment】增加详细的控制台日志,便于追踪脚本执行状态;并修复潜在的兼容性问题,运行更稳定。
// ==UserScript== // @name 半沉浸式网页翻译助手 (Semi-Immersive Helper) // @namespace http://tampermonkey.net/ // @version 0.4.1 // @description 只翻译你的生词表【Version Comment】增加详细的控制台日志,便于追踪脚本执行状态;并修复潜在的兼容性问题,运行更稳定。 // @author Gemini & You // @license CC BY-NC-SA 4.0 // @match *://*/* // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @grant GM_xmlhttpRequest // @connect api.chatanywhere.tech // @connect * // ==/UserScript== (function () { 'use strict'; // --- 默认配置 (无变化) --- const DEFAULTS = { words: 'word\nexample\ncontext\nlanguage\nlearning', apiProvider: 'openai', openai: { apiKey: '', baseUrl: 'https://api.chatanywhere.tech/v1', model: 'gpt-3.5-turbo', }, custom: { apiKey: '', endpointUrl: '', bodyTemplate: JSON.stringify({ "model": "gpt-3.5-turbo", "messages": [{ "role": "user", "content": "__PROMPT__" }], "response_format": { "type": "json_object" } }, null, 2), }, }; // --- 全局状态 --- let config = {}; let wordRegex = null; let totalMatchesFound = 0; const sessionCache = new Map(); const processedNodes = new WeakSet(); // --- 工具函数 (无变化) --- function escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } // --- 配置管理 (无变化) --- async function getConfig() { const storedConfig = {}; storedConfig.words = await GM_getValue('words', DEFAULTS.words); storedConfig.apiProvider = await GM_getValue('apiProvider', DEFAULTS.apiProvider); storedConfig.openai = await GM_getValue('openai', DEFAULTS.openai); storedConfig.custom = await GM_getValue('custom', DEFAULTS.custom); return storedConfig; } async function saveConfig(newConfig) { await GM_setValue('words', newConfig.words); await GM_setValue('apiProvider', newConfig.apiProvider); await GM_setValue('openai', newConfig.openai); await GM_setValue('custom', newConfig.custom); alert('设置已保存!页面将刷新以应用更改。'); window.location.reload(); } // --- UI --- function createSettingsPanel() { GM_addStyle(` #sih-settings-panel { position: fixed; top: 50px; right: -400px; width: 380px; height: calc(100vh - 100px); background-color: #f9f9f9; border: 1px solid #ccc; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 99999; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; display: flex; flex-direction: column; transition: right 0.3s ease-in-out; } #sih-settings-panel.sih-show { right: 20px; } .sih-header { padding: 15px 20px; background-color: #fff; border-bottom: 1px solid #ddd; border-top-left-radius: 8px; border-top-right-radius: 8px; display: flex; justify-content: space-between; align-items: center; } .sih-header h2 { margin: 0; font-size: 18px; color: #333; } .sih-close-btn { cursor: pointer; font-size: 24px; color: #888; border: none; background: none; } .sih-close-btn:hover { color: #333; } .sih-tabs { display: flex; background-color: #fff; border-bottom: 1px solid #ddd; } .sih-tab-button { flex: 1; padding: 12px; cursor: pointer; border: none; background-color: transparent; font-size: 16px; color: #666; border-bottom: 3px solid transparent; } .sih-tab-button.sih-active { color: #007bff; border-bottom-color: #007bff; } .sih-content { padding: 20px; overflow-y: auto; flex-grow: 1; background-color: #fff; } .sih-tab-content { display: none; } .sih-tab-content.sih-active { display: block; } .sih-form-group { margin-bottom: 20px; } .sih-form-group label { display: block; margin-bottom: 8px; font-weight: 600; color: #555; } .sih-form-group input[type="text"], .sih-form-group input[type="password"], .sih-form-group textarea { width: 100%; padding: 10px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; box-sizing: border-box; } .sih-form-group textarea { min-height: 150px; resize: vertical; } .sih-radio-group label { margin-right: 15px; font-weight: normal; } .sih-footer { padding: 15px 20px; border-top: 1px solid #ddd; background-color: #f9f9f9; text-align: right; border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; } .sih-save-btn { padding: 10px 20px; background-color: #007bff; color: white; border: none; border-radius: 5px; cursor: pointer; font-size: 16px; } .sih-save-btn:hover { background-color: #0056b3; } .sih-highlight { background-color: #FFF3A3 !important; font-weight: bold !important; } .sih-explanation { color: #007bff; font-weight: normal; margin-left: 5px; cursor: help; } `); const panel = document.createElement('div'); panel.id = 'sih-settings-panel'; panel.innerHTML = ` <div class="sih-header"><h2>半沉浸式翻译设置</h2><button class="sih-close-btn">×</button></div> <div class="sih-tabs"><button class="sih-tab-button sih-active" data-tab="general">通用设置</button><button class="sih-tab-button" data-tab="api">API 配置</button></div> <div class="sih-content"> <div id="sih-tab-general" class="sih-tab-content sih-active"><div class="sih-form-group"><label for="sih-words-list">单词列表 (每行一个)</label><textarea id="sih-words-list"></textarea></div></div> <div id="sih-tab-api" class="sih-tab-content"> <div class="sih-form-group sih-radio-group"><label><input type="radio" name="apiProvider" value="openai" checked> OpenAI 兼容 (代理/中转)</label><label><input type="radio" name="apiProvider" value="custom"> 自定义 API</label></div> <div id="sih-openai-config"><div class="sih-form-group"><label for="sih-openai-key">API Key</label><input type="password" id="sih-openai-key"></div><div class="sih-form-group"><label for="sih-openai-baseurl">API Base URL</label><input type="text" id="sih-openai-baseurl"></div><div class="sih-form-group"><label for="sih-openai-model">模型名称 (Model)</label><input type="text" id="sih-openai-model"></div></div> <div id="sih-custom-config" style="display: none;"><div class="sih-form-group"><label for="sih-custom-key">API Key</label><input type="password" id="sih-custom-key"></div><div class="sih-form-group"><label for="sih-custom-endpoint">API Endpoint URL</label><input type="text" id="sih-custom-endpoint"></div><div class="sih-form-group"><label for="sih-custom-body">请求体模板 (JSON) - 使用 __PROMPT__ 作为占位符</label><textarea id="sih-custom-body"></textarea></div></div> </div> </div> <div class="sih-footer"><button id="sih-save-btn" class="sih-save-btn">保存并刷新</button></div> `; document.body.appendChild(panel); panel.querySelector('.sih-close-btn').addEventListener('click', () => panel.classList.remove('sih-show')); panel.querySelectorAll('.sih-tab-button').forEach(button => { button.addEventListener('click', () => { panel.querySelectorAll('.sih-tab-button').forEach(btn => btn.classList.remove('sih-active')); button.classList.add('sih-active'); panel.querySelectorAll('.sih-tab-content').forEach(content => content.classList.remove('sih-active')); panel.querySelector(`#sih-tab-${button.dataset.tab}`).classList.add('sih-active'); }); }); panel.querySelectorAll('input[name="apiProvider"]').forEach(radio => { radio.addEventListener('change', (e) => { if (e.target.value === 'openai') { panel.querySelector('#sih-openai-config').style.display = 'block'; panel.querySelector('#sih-custom-config').style.display = 'none'; } else { panel.querySelector('#sih-openai-config').style.display = 'none'; panel.querySelector('#sih-custom-config').style.display = 'block'; } }); }); panel.querySelector('#sih-save-btn').addEventListener('click', async () => { const newConfig = { words: document.getElementById('sih-words-list').value, apiProvider: document.querySelector('input[name="apiProvider"]:checked').value, openai: { apiKey: document.getElementById('sih-openai-key').value, baseUrl: document.getElementById('sih-openai-baseurl').value, model: document.getElementById('sih-openai-model').value, }, custom: { apiKey: document.getElementById('sih-custom-key').value, endpointUrl: document.getElementById('sih-custom-endpoint').value, bodyTemplate: document.getElementById('sih-custom-body').value, } }; await saveConfig(newConfig); }); loadConfigIntoPanel(); } async function loadConfigIntoPanel() { const config = await getConfig(); document.getElementById('sih-words-list').value = config.words; const providerRadio = document.querySelector(`input[name="apiProvider"][value="${config.apiProvider}"]`); if (providerRadio) { providerRadio.checked = true; providerRadio.dispatchEvent(new Event('change')); } document.getElementById('sih-openai-key').value = config.openai.apiKey; document.getElementById('sih-openai-baseurl').value = config.openai.baseUrl; document.getElementById('sih-openai-model').value = config.openai.model; document.getElementById('sih-custom-key').value = config.custom.apiKey; document.getElementById('sih-custom-endpoint').value = config.custom.endpointUrl; document.getElementById('sih-custom-body').value = config.custom.bodyTemplate; } function toggleSettingsPanel() { const panel = document.getElementById('sih-settings-panel'); if (panel) { panel.classList.toggle('sih-show'); } } // --- 核心逻辑 --- function detectLanguage() { const lang = document.documentElement.lang || document.body.lang || ''; if (lang.toLowerCase().startsWith('ja')) return 'ja'; if (lang.toLowerCase().startsWith('zh')) return 'zh'; if (lang.toLowerCase().startsWith('en')) return 'en'; const textSample = (document.body.textContent || '').substring(0, 500); if (/[\u3040-\u30ff\u4e00-\u9faf]/.test(textSample)) return 'ja'; if (/[\u4e00-\u9fa5]/.test(textSample)) return 'zh'; return 'en'; } async function processNode(node) { if (!node || node.nodeType !== Node.ELEMENT_NODE || processedNodes.has(node) || node.closest('script, style, textarea, a, button, [contenteditable], .sih-highlight')) { return; } processedNodes.add(node); const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, { acceptNode: (textNode) => { if (!textNode.nodeValue || textNode.nodeValue.trim().length < 1 || textNode.parentElement.closest('script, style, textarea, a, button, [contenteditable], .sih-highlight')) { return NodeFilter.FILTER_REJECT; } return NodeFilter.FILTER_ACCEPT; } }); const nodesToModify = []; let currentNode; while (currentNode = walker.nextNode()) { wordRegex.lastIndex = 0; // 重置正则索引 if (wordRegex.test(currentNode.nodeValue)) { nodesToModify.push(currentNode); } } if (nodesToModify.length === 0) return; console.log(`SIH: Found ${nodesToModify.length} text node(s) with potential matches in`, node); for (const textNode of nodesToModify) { const textContent = textNode.nodeValue; // -- 使用更兼容的 exec 循环代替 matchAll -- const matches = []; let match; wordRegex.lastIndex = 0; // 每次执行前都重置 while ((match = wordRegex.exec(textContent)) !== null) { matches.push(match); } if (matches.length === 0) continue; totalMatchesFound += matches.length; const wordsToDefine = [...new Set(matches.map(m => m[1].toLowerCase()))]; const definitions = await fetchExplanationsForBatch(wordsToDefine, textContent); const parent = textNode.parentNode; if (!parent || processedNodes.has(parent)) continue; const fragment = document.createDocumentFragment(); let lastIndex = 0; matches.forEach(m => { const [fullMatch, foundWord] = m; const offset = m.index; if (offset > lastIndex) { fragment.appendChild(document.createTextNode(textContent.substring(lastIndex, offset))); } const highlightSpan = document.createElement('span'); highlightSpan.className = 'sih-highlight'; highlightSpan.textContent = foundWord; fragment.appendChild(highlightSpan); const explanationSpan = document.createElement('span'); explanationSpan.className = 'sih-explanation'; explanationSpan.textContent = ` ${definitions[foundWord.toLowerCase()] || '(解析失败)'}`; fragment.appendChild(explanationSpan); lastIndex = offset + foundWord.length; }); if (lastIndex < textContent.length) { fragment.appendChild(document.createTextNode(textContent.substring(lastIndex))); } parent.replaceChild(fragment, textNode); } } async function fetchExplanationsForBatch(words, context) { if (words.length === 0) return {}; const cacheKey = `${context}|${words.sort().join(',')}`; if (sessionCache.has(cacheKey)) return sessionCache.get(cacheKey); const prompt = `You are a dictionary API. Analyze the context. For each word in the list, give its precise Chinese definition based on the context. Respond with a single, valid JSON object where keys are lowercase words and values are strings in "(part-of-speech. definition)" format. Example: {"word": "(n. 单词)"} Context: "${context}" Words: ${JSON.stringify(words)}`; try { const apiProvider = config.apiProvider; const apiConfig = config[apiProvider]; const responseJson = apiProvider === 'openai' ? await callOpenAI(prompt, apiConfig) : await callCustomAPI(prompt, apiConfig); sessionCache.set(cacheKey, responseJson); return responseJson; } catch (error) { console.error('SIH API Batch Error:', error); return words.reduce((acc, word) => ({ ...acc, [word.toLowerCase()]: '(请求错误)' }), {}); } } // --- API 调用 (无重大变化,折叠) --- function callOpenAI(prompt, openaiConfig) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${openaiConfig.baseUrl}/chat/completions`, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${openaiConfig.apiKey}` }, data: JSON.stringify({ model: openaiConfig.model, messages: [{ role: 'user', content: prompt }], temperature: 0.1, response_format: { "type": "json_object" } }), timeout: 20000, onload: function (response) { try { if (response.status >= 200 && response.status < 300) { const data = JSON.parse(response.responseText); const content = data.choices[0].message.content; resolve(JSON.parse(content)); } else { reject(new Error(`HTTP error! status: ${response.status}, response: ${response.responseText}`)); } } catch (e) { reject(new Error(`Failed to parse JSON response: ${e.message}`)); } }, onerror: (e) => reject(new Error('Network error during API call.')), ontimeout: () => reject(new Error('Request timed out.')) }); }); } function findAndReplacePlaceholder(obj, placeholder, value) { for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { if (typeof obj[key] === 'string') { obj[key] = obj[key].replace(new RegExp(placeholder, 'g'), value); } else if (typeof obj[key] === 'object' && obj[key] !== null) { findAndReplacePlaceholder(obj[key], placeholder, value); } } } } function callCustomAPI(prompt, customConfig) { return new Promise((resolve, reject) => { let bodyObject; try { bodyObject = JSON.parse(customConfig.bodyTemplate); } catch (e) { return reject(new Error("自定义API请求体模板不是有效的JSON格式。")); } findAndReplacePlaceholder(bodyObject, '__PROMPT__', prompt); const body = JSON.stringify(bodyObject); GM_xmlhttpRequest({ method: 'POST', url: customConfig.endpointUrl, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${customConfig.apiKey}` }, data: body, timeout: 20000, onload: function (response) { try { if (response.status >= 200 && response.status < 300) { const data = JSON.parse(response.responseText); const result = data.answer || data.result || (data.choices && data.choices[0].message.content) || data.content || data.text; if (result) { resolve(JSON.parse(result)); } else { reject(new Error('Could not find a standard result key in custom API response.')); } } else { reject(new Error(`HTTP error! status: ${response.status}, response: ${response.responseText}`)); } } catch (e) { reject(new Error(`Failed to parse JSON response: ${e.message}`)); } }, onerror: () => reject(new Error('Network error during API call.')), ontimeout: () => reject(new Error('Request timed out.')) }); }); } function processVisibleAndObserve() { console.log("SIH: Starting observers and initial viewport scan..."); const contentSelectors = 'p, li, th, td, h1, h2, h3, h4, h5, h6, article, .post, .content, .main, body'; const intersectionObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { processNode(entry.target); intersectionObserver.unobserve(entry.target); } }); }, { rootMargin: '200px 0px' }); const mutationObserver = new MutationObserver((mutations) => { mutations.forEach(mutation => { mutation.addedNodes.forEach(newNode => { if (newNode.nodeType === Node.ELEMENT_NODE) { if (newNode.matches(contentSelectors)) { intersectionObserver.observe(newNode); } newNode.querySelectorAll(contentSelectors).forEach(child => intersectionObserver.observe(child)); } }); }); }); document.querySelectorAll(contentSelectors).forEach(el => { const rect = el.getBoundingClientRect(); if (rect.top < window.innerHeight && rect.bottom >= 0) { processNode(el); } intersectionObserver.observe(el); }); mutationObserver.observe(document.body, { childList: true, subtree: true }); // 在首次扫描后(给予一个短暂的延迟),检查是否找到了任何匹配 setTimeout(() => { if (totalMatchesFound === 0) { console.log("SIH: Initial scan complete. No words from your list were found on this page. The script will remain idle until the page content changes."); } else { console.log(`SIH: Initial scan complete. Found a total of ${totalMatchesFound} matches.`); } }, 3000); } async function init() { createSettingsPanel(); GM_registerMenuCommand('设置单词和API', toggleSettingsPanel); config = await getConfig(); const words = config.words.split('\n').filter(w => w.trim() !== ''); if (words.length === 0) { console.log('SIH: Your word list is empty. Exiting.'); return; } const lang = detectLanguage(); console.log(`SIH: Detected language: ${lang}`); if (lang === 'ja' || lang === 'zh') { const cjkChars = '\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Han}'; const patterns = words.map(word => `(?<![${cjkChars}])(${escapeRegex(word)})(?![${cjkChars}])`); wordRegex = new RegExp(patterns.join('|'), 'gu'); } else { const pattern = `\\b(${words.map(escapeRegex).join('|')})\\b`; wordRegex = new RegExp(pattern, 'gi'); } if (document.readyState === 'complete' || document.readyState === 'interactive') { processVisibleAndObserve(); } else { document.addEventListener('DOMContentLoaded', processVisibleAndObserve); } } init(); })();