// ==UserScript==
// @name 日语学习助手 - 语法分析与Anki制卡
// @namespace http://tampermonkey.net/
// @version 2025-01-05
// @description 选中日语文本进行语法和单词分析,支持一键保存到Anki,提供更好的UI交互和错误处理
// @author 乃木流架
// @match *://*/*
// @icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAqElEQVR4nO2V3QmAMBCD6yZ1Op+EbtOZ3MPeGhHBvgin3vWw9SeQF6nkSwrq3K+L8lPEaqtzYvnPA/RTxGoA3ZHzOfMroJQgsVkwtmZSgPxeMQAJg82WgLK52RJUGKxeAkzzNM8iq5cgpkEYB5HVSxDTXArALcF+Yv3m0uZnS+zz3OMASPn8PQCh9hUEa4DmvwN0N0D1f0EzAFnVgpsByKoW3AzAr9dqAc7mUbe0kIcIAAAAAElFTkSuQmCC
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM.xmlHttpRequest
// @connect api.openai.com
// @connect localhost
// @connect 127.0.0.1
// @connect *
// @license MIT
// @downloadURL https://update.greasyfork.icu/scripts/522859/%E6%97%A5%E8%AF%AD%E5%AD%A6%E4%B9%A0%E5%8A%A9%E6%89%8B%20-%20%E8%AF%AD%E6%B3%95%E5%88%86%E6%9E%90%E4%B8%8EAnki%E5%88%B6%E5%8D%A1.user.js
// @updateURL https://update.greasyfork.icu/scripts/522859/%E6%97%A5%E8%AF%AD%E5%AD%A6%E4%B9%A0%E5%8A%A9%E6%89%8B%20-%20%E8%AF%AD%E6%B3%95%E5%88%86%E6%9E%90%E4%B8%8EAnki%E5%88%B6%E5%8D%A1.meta.js
// ==/UserScript==
(async function() {
'use strict';
// 工具函数
const logger = {
debug: (...args) => console.log('[日语助手-DEBUG]', new Date().toISOString(), ...args),
info: (...args) => console.info('[日语助手-INFO]', new Date().toISOString(), ...args),
error: (...args) => console.error('[日语助手-ERROR]', new Date().toISOString(), ...args),
state: (state, data) => console.log('[日语助手-STATE]', new Date().toISOString(), state, data),
data: (label, data) => console.log('[日语助手-DATA]', new Date().toISOString(), label, JSON.stringify(data, null, 2))
};
// 防抖函数
const debounce = (fn, delay) => {
let timer = null;
return (...args) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
};
// 缓存相关
const CACHE_KEY = 'jp_helper_cache';
const MAX_CACHE_SIZE = 50;
async function getCachedResult(text) {
try {
const cache = await GM_getValue(CACHE_KEY, {});
const cached = cache[text];
if (cached && Date.now() - cached.timestamp < 24 * 60 * 60 * 1000) { // 24小时有效期
return cached.result;
}
return null;
} catch (error) {
logger.error('获取缓存失败:', error);
return null;
}
}
async function cacheResult(text, result) {
try {
const cache = await GM_getValue(CACHE_KEY, {});
const entries = Object.entries(cache);
if (entries.length >= MAX_CACHE_SIZE) {
// 删除最旧的缓存
const oldestKey = entries.sort((a, b) => a[1].timestamp - b[1].timestamp)[0][0];
delete cache[oldestKey];
}
cache[text] = {
result,
timestamp: Date.now()
};
await GM_setValue(CACHE_KEY, cache);
} catch (error) {
logger.error('保存缓存失败:', error);
}
}
// 在线状态检查
async function checkOnlineStatus() {
if (!navigator.onLine) {
throw new Error('当前处于离线状态,无法访问API');
}
}
// API密钥加密存储
async function encryptApiKey(apiKey) {
return btoa(apiKey);
}
async function decryptApiKey(encryptedKey) {
return atob(encryptedKey);
}
// 配置和常量
const DEFAULT_CONFIG = {
apiKey: '',
models: [
'gpt-4',
'gpt-4o',
'gpt-4o-mini',
'gpt-3.5-turbo-0125',
'gpt-3.5-turbo-1106'
],
model: 'gpt-4o',
maxTextLength: 2000,
requestTimeout: 60000,
systemPrompt: `你是一名知识渊博的日语老师,帮我分析日语段落,要求整洁清晰的排版,以句子为单位分析生词(用平假名标读音)以及语法结构,也可适当拓展知识,结尾总结并给出译文:`,
ankiConnect: {
endpoint: 'http://localhost:8765',
deckName: '日语牌组',
modelName: '日语模版',
timeout: 5000,
duplicateScope: 'deck'
}
};
// 从存储加载配置
const CONFIG = await loadConfig();
console.log(CONFIG);
// 配置管理函数
async function loadConfig() {
try {
const savedConfig = await GM_getValue('config', {});
logger.debug('加载配置');
logger.data('默认配置', DEFAULT_CONFIG);
logger.data('保存的配置', savedConfig);
// 解密API密钥
if (savedConfig.apiKey) {
try {
savedConfig.apiKey = await decryptApiKey(savedConfig.apiKey);
} catch (error) {
logger.error('API密钥解密失败:', error);
// 如果解密失败,使用默认配置的API密钥
savedConfig.apiKey = DEFAULT_CONFIG.apiKey;
}
}
// 深度合并配置
const mergedConfig = {
...DEFAULT_CONFIG,
...savedConfig,
models: DEFAULT_CONFIG.models, // 始终使用默认的models
ankiConnect: {
...DEFAULT_CONFIG.ankiConnect,
...savedConfig.ankiConnect,
duplicateScope: DEFAULT_CONFIG.ankiConnect.duplicateScope // 确保新增字段存在
}
};
logger.data('合并后的配置', mergedConfig);
return mergedConfig;
} catch (error) {
logger.error('加载配置失败:', error);
return DEFAULT_CONFIG;
}
}
async function saveConfig(newConfig) {
try {
logger.debug('保存配置');
logger.data('当前配置', CONFIG);
logger.data('新配置', newConfig);
// 加密API密钥
if (newConfig.apiKey) {
try {
const encryptedKey = await encryptApiKey(newConfig.apiKey);
newConfig = {
...newConfig,
apiKey: encryptedKey
};
} catch (error) {
logger.error('API密钥加密失败:', error);
throw new Error('API密钥加密失败');
}
}
await GM_setValue('config', newConfig);
// 更新当前配置时使用解密后的API密钥
Object.assign(CONFIG, {
...newConfig,
apiKey: await decryptApiKey(newConfig.apiKey)
});
logger.data('更新后的配置', CONFIG);
logger.debug('配置已保存');
} catch (error) {
logger.error('保存配置失败:', error);
logger.data('错误配置', newConfig);
throw error;
}
}
// 加载必要的依赖
async function loadDependencies() {
logger.info('开始加载依赖项');
try {
await loadScript('https://cdn.jsdelivr.net/npm/markdown-it@14.1.0/dist/markdown-it.min.js');
logger.debug('markdown-it.js 加载完成');
logger.info('所有依赖项加载成功');
} catch (error) {
logger.error('依赖项加载失败', error);
throw error;
}
}
function loadScript(url) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = url;
script.onload = resolve;
script.onerror = () => reject(new Error(`Failed to load ${url}`));
document.head.appendChild(script);
});
}
// 添加样式
function addStyles() {
const style = document.createElement('style');
style.textContent = `
.loading-spinner {
display: inline-block;
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #4CAF50;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.jp-helper-menu {
position: absolute;
background: transparent;
padding: 2px;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
z-index: 10000;
font-family: system-ui, -apple-system, sans-serif;
}
.jp-helper-button {
padding: 6px 10px;
background: white;
color: #4CAF50;
border: 1px solid #4CAF50;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 6px;
min-width: 100px;
white-space: nowrap;
}
.jp-helper-button:hover {
background: #4CAF50;
color: white;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.jp-helper-button img {
width: 18px;
height: 18px;
object-fit: contain;
}
.jp-helper-panel {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
max-width: 800px;
width: 90%;
max-height: 80vh;
z-index: 10001;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid rgba(229, 231, 235, 1);
}
.jp-helper-panel .drag-handle {
padding: 12px 16px;
background: #f0f7ff;
border-bottom: 1px solid rgba(209, 213, 219, 0.5);
cursor: move;
user-select: none;
border-radius: 12px 12px 0 0;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 2;
height: 56px;
box-sizing: border-box;
}
.jp-helper-panel .drag-handle span {
font-size: 15px;
font-weight: 600;
color: #111827;
letter-spacing: -0.01em;
display: flex;
align-items: center;
gap: 8px;
}
.jp-helper-panel .content-container {
padding: 0 30px;
overflow-y: auto;
flex: 1;
min-height: 0;
max-height: calc(80vh - 56px);
color: #111827;
line-height: 1.6;
}
.jp-helper-panel .content-container .analysis-result {
padding-bottom: 30px;
}
.jp-helper-toggle,
.jp-helper-copy,
.jp-helper-refresh,
.jp-helper-close,
.jp-helper-anki {
padding: 6px 12px;
font-size: 13px;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 64px;
height: 32px;
border: none;
color: white;
}
.jp-helper-toggle {
background: #6b7280;
}
.jp-helper-copy {
background: #3b82f6;
}
.jp-helper-refresh {
background: #10b981;
}
.jp-helper-close {
background: #ef4444;
}
.jp-helper-anki {
background: #6366f1;
}
.jp-helper-toggle:hover,
.jp-helper-copy:hover,
.jp-helper-refresh:hover,
.jp-helper-close:hover,
.jp-helper-anki:hover {
filter: brightness(0.95);
}
.jp-helper-toggle:active,
.jp-helper-copy:active,
.jp-helper-refresh:active,
.jp-helper-close:active,
.jp-helper-anki:active {
filter: brightness(0.9);
}
.update-btn,
.cancel-btn {
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
border: none;
color: white;
}
.update-btn {
background: #10b981;
}
.cancel-btn {
background: #ef4444;
}
.update-btn:hover,
.cancel-btn:hover {
filter: brightness(0.95);
}
.update-btn:active,
.cancel-btn:active {
filter: brightness(0.9);
}
.jp-helper-anki:disabled {
background: #9ca3af;
cursor: not-allowed;
}
.jp-helper-anki:disabled:hover {
filter: none;
}
.jp-helper-panel .content-container::-webkit-scrollbar,
.compare-dialog-content::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.jp-helper-panel .content-container::-webkit-scrollbar-track,
.compare-dialog-content::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.jp-helper-panel .content-container::-webkit-scrollbar-thumb,
.compare-dialog-content::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
.jp-helper-panel .content-container::-webkit-scrollbar-thumb:hover,
.compare-dialog-content::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
.jp-helper-settings-panel {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #ffffff;
border-radius: 16px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
max-width: 600px;
width: 90%;
max-height: 85vh;
z-index: 10003;
display: none;
flex-direction: column;
border: 1px solid #e5e7eb;
}
.jp-helper-settings-panel .settings-header {
padding: 20px 24px;
border-bottom: 1px solid #e5e7eb;
display: flex;
justify-content: space-between;
align-items: center;
background: #f8fafc;
border-radius: 16px 16px 0 0;
}
.jp-helper-settings-panel .settings-content {
padding: 24px;
overflow-y: auto;
max-height: calc(85vh - 140px);
}
.jp-helper-settings-panel .settings-footer {
padding: 16px 24px;
border-top: 1px solid #e5e7eb;
display: flex;
justify-content: flex-end;
gap: 12px;
background: #f8fafc;
border-radius: 0 0 16px 16px;
}
.jp-helper-settings-panel .settings-group {
margin-bottom: 32px;
}
.jp-helper-settings-panel .settings-group:last-child {
margin-bottom: 0;
}
.jp-helper-settings-panel .settings-group-title {
font-weight: 600;
color: #111827;
font-size: 16px;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid #e5e7eb;
}
.jp-helper-settings-panel .setting-item {
margin-bottom: 20px;
}
.jp-helper-settings-panel .setting-item:last-child {
margin-bottom: 0;
}
.jp-helper-settings-panel .help-text {
margin-top: 6px;
font-size: 12px;
color: #6b7280;
line-height: 1.5;
}
.jp-helper-settings-panel input,
.jp-helper-settings-panel select,
.jp-helper-settings-panel textarea {
width: 100%;
padding: 10px 14px;
border: 1px solid #e5e7eb;
border-radius: 8px;
font-size: 14px;
background: #f9fafb;
transition: all 0.2s ease;
box-sizing: border-box;
max-width: 100%;
color: #1f2937;
}
.jp-helper-settings-panel input[type="password"] {
font-family: monospace;
letter-spacing: 1px;
}
.jp-helper-settings-panel input:focus,
.jp-helper-settings-panel select:focus,
.jp-helper-settings-panel textarea:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
background: #ffffff;
}
.jp-helper-settings-panel label {
font-weight: 600;
color: #374151;
display: block;
margin-bottom: 8px;
font-size: 14px;
}
.jp-helper-settings-panel .section-title {
font-weight: 600;
color: #111827;
margin: 32px 0 24px;
padding-top: 24px;
border-top: 1px solid #e5e7eb;
font-size: 16px;
}
.jp-helper-settings-panel textarea {
min-height: 120px;
resize: vertical;
font-family: inherit;
line-height: 1.6;
will-change: height;
transition: none;
}
.jp-helper-settings-panel .help-text {
margin-top: 6px;
font-size: 12px;
color: #6b7280;
line-height: 1.5;
}
.jp-helper-settings-panel .settings-close,
.jp-helper-settings-panel .settings-cancel,
.jp-helper-settings-panel .settings-save {
padding: 8px 16px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
border: none;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 80px;
justify-content: center;
}
.jp-helper-settings-panel .settings-close {
background: #ef4444;
color: white;
}
.jp-helper-settings-panel .settings-cancel {
background: #6b7280;
color: white;
}
.jp-helper-settings-panel .settings-save {
background: #3b82f6;
color: white;
}
.jp-helper-settings-panel .settings-close:hover,
.jp-helper-settings-panel .settings-cancel:hover,
.jp-helper-settings-panel .settings-save:hover {
filter: brightness(1.1);
transform: translateY(-1px);
}
.jp-helper-settings-panel .settings-close:active,
.jp-helper-settings-panel .settings-cancel:active,
.jp-helper-settings-panel .settings-save:active {
transform: translateY(0);
}
.jp-helper-settings-panel .api-key-container {
position: relative;
}
.jp-helper-settings-panel .toggle-password {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
padding: 4px;
cursor: pointer;
color: #6b7280;
}
.jp-helper-settings-panel .toggle-password:hover {
color: #374151;
}
.jp-helper-settings-panel .settings-content::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.jp-helper-settings-panel .settings-content::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.jp-helper-settings-panel .settings-content::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
.jp-helper-settings-panel .settings-content::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
.jp-helper-settings-panel input[type="password"],
.jp-helper-settings-panel input[type="text"] {
padding-right: 36px;
}
.jp-helper-settings-panel .api-key-container {
position: relative;
display: flex;
align-items: center;
}
.jp-helper-settings-panel .toggle-password {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
padding: 4px;
cursor: pointer;
color: #6b7280;
z-index: 1;
}
`;
document.head.appendChild(style);
}
// 添加重试工具函数
async function retry(fn, retries = 3, delay = 1000) {
let lastError;
for (let i = 0; i < retries; i++) {
try {
return await fn();
} catch (error) {
lastError = error;
logger.error(`重试第 ${i + 1} 次失败:`, error);
if (i < retries - 1) {
await new Promise(resolve => setTimeout(resolve, delay * (i + 1)));
}
}
}
throw lastError;
}
// API 请求函数
async function analyzeText(text, skipCache = false) {
try {
// 基本验证
if (!text || typeof text !== 'string') {
throw new Error('无效的输入文本');
}
logger.debug('开始分析文本', { length: text.length, skipCache });
logger.data('输入文本', text);
// 检查在线状态
await checkOnlineStatus();
// 检查配置
if (!CONFIG.apiKey) {
throw new Error('请先设置 OpenAI API Key');
}
if (!CONFIG.apiEndpoint) {
throw new Error('API端点未设置');
}
if (text.length > CONFIG.maxTextLength) {
throw new Error(`文本过长,请限制在${CONFIG.maxTextLength} 字符以内`);
}
// 检查缓存(如果不跳过缓存)
if (!skipCache) {
const cachedResult = await getCachedResult(text);
if (cachedResult) {
logger.debug('使用缓存结果');
return cachedResult;
}
}
// API请求
const result = await retry(async () => {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
logger.error('API请求超时');
reject(new Error('请求超时'));
}, CONFIG.requestTimeout);
const requestData = {
model: CONFIG.model,
messages: [
{ role: "system", content: CONFIG.systemPrompt },
{ role: "user", content: text }
],
temperature: 0.7
};
logger.debug('发送API请求');
logger.data('请求数据', requestData);
GM_xmlhttpRequest({
method: 'POST',
url: CONFIG.apiEndpoint,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${CONFIG.apiKey}`
},
data: JSON.stringify(requestData),
onload: function(response) {
clearTimeout(timeoutId);
logger.debug('收到API响应:', {
status: response.status,
statusText: response.statusText,
headers: response.headers
});
logger.data('响应原始数据', response.responseText);
try {
if (response.status !== 200) {
const errorData = JSON.parse(response.responseText);
logger.error('API错误响应:', errorData);
throw new Error(`API错误: ${errorData.error?.message || response.statusText}`);
}
const data = JSON.parse(response.responseText);
logger.debug('API响应解析成功');
logger.data('解析后的响应数据', data);
const result = data.choices[0].message.content;
// 缓存结果
cacheResult(text, result);
resolve(result);
} catch (error) {
logger.error('API响应处理失败:', error);
reject(error);
}
},
onerror: function(error) {
clearTimeout(timeoutId);
reject(new Error('网络请求失败'));
}
});
});
});
return result;
} catch (error) {
logger.error('文本分析失败:', {
error: error.message,
text_length: text?.length,
api_endpoint: CONFIG.apiEndpoint,
skipCache
});
throw error;
}
}
// UI 组件
function createUI() {
const menu = document.createElement('div');
menu.className = 'jp-helper-menu';
menu.style.display = 'none';
const button = document.createElement('button');
button.className = 'jp-helper-button';
button.innerHTML = `
日语分析
`;
button.style.display = 'flex';
button.style.alignItems = 'center';
button.style.padding = '8px 12px';
menu.appendChild(button);
const panel = document.createElement('div');
panel.className = 'jp-helper-panel';
panel.style.display = 'none';
requestAnimationFrame(() => {
panel.style.transform = 'translate(-50%, -50%)';
});
document.body.appendChild(menu);
document.body.appendChild(panel);
return { menu, button, panel };
}
// UI 状态更新
function updateUI(panel, state, content = '', selectedText = '') {
logger.debug('更新UI状态', { state });
logger.data('UI更新内容---content', content);
// 保存当前transform
const currentTransform = panel.style.transform;
// 清除面板内容
panel.innerHTML = '';
const header = `
分析中...
${content}
原文:${selectedText}
`; body = `