// ==UserScript== // @name 划词本地题库搜索 // @namespace http://tampermonkey.net/ // @version 2.2 // @description 网页划词后在本地题库搜索匹配的题目与答案,界面简洁,支持匹配词高亮、一键复制答案、异步搜索、GM菜单设置、精确搜索。 // @author LLs // @match *://*/* // @grant GM_getValue // @grant GM_setValue // @grant GM_addStyle // @grant GM_registerMenuCommand // @grant GM_notification // @downloadURL https://update.greasyfork.icu/scripts/556105/%E5%88%92%E8%AF%8D%E6%9C%AC%E5%9C%B0%E9%A2%98%E5%BA%93%E6%90%9C%E7%B4%A2.user.js // @updateURL https://update.greasyfork.icu/scripts/556105/%E5%88%92%E8%AF%8D%E6%9C%AC%E5%9C%B0%E9%A2%98%E5%BA%93%E6%90%9C%E7%B4%A2.meta.js // ==/UserScript== (function () { 'use strict'; /* ------------------------- 配置与默认题库 -------------------------*/ const STORAGE_KEY = 'local_question_bank_v1'; const STORAGE_KEY_SETTINGS = 'local_question_bank_settings_v1'; const DEFAULT_BANK = [ { id: 'q1', type: '选择', question: 'HTML 的全称是什么?', options: ['HyperText Markup Language', 'HighText Machine Language', 'Hyper Transfer Markup Language'], answer: 'HyperText Markup Language', tags: ['前端', '基础'] }, { id: 'q2', type: '判断', question: 'CSS 用来控制网页的样式与布局(对或错)?', answer: '对', tags: ['前端', '样式'] }, { id: 'q3', type: '简答', question: '简述 HTTP 和 HTTPS 区别。', answer: 'HTTPS 在 HTTP 基础上使用 TLS/SSL 加密,保证传输加密性与完整性。', tags: ['网络'] } ]; const DEFAULT_SETTINGS = { scoreThreshold: 0.3, searchImmediately: false }; /* ------------------------- 全局变量 (UI 和状态) -------------------------*/ let panel, badge, settingsPanel; let searchIcon; let lastQuery = ''; let currentSelection = { text: '', range: null }; /* ------------------------- 样式 -------------------------*/ GM_addStyle(` #tm-qsearch-panel { position: fixed; z-index: 2147483647; right: 15px; top: 80px; width: 380px; max-width: 90vw; max-height: 80vh; overflow: hidden; background: #fff; border: 1px solid #ddd; box-shadow: 0 8px 24px rgba(0,0,0,0.15); font-family: Arial, sans-serif; color: #222; border-radius: 8px; -webkit-overflow-scrolling: touch; display: none; /* 默认隐藏 */ flex-direction: column; /* 垂直布局 */ } #tm-qsearch-panel .header { display:flex; justify-content:space-between; align-items:center; padding:8px 10px; border-bottom:1px solid #eee; background:#f9f9f9; border-top-left-radius:8px; border-top-right-radius:8px; flex-shrink: 0; /* 不收缩 */ } #tm-qsearch-panel .header .title { font-weight:600; } #tm-qsearch-panel .list { padding:10px; overflow-y: auto; flex-grow: 1; /* 占据剩余空间 */ } #tm-qsearch-panel .item { padding:8px; border-bottom:1px solid #f1f1f1; } #tm-qsearch-panel .question { font-weight:600; margin-bottom:6px; } #tm-qsearch-panel .meta { color:#666; font-size:12px; margin-bottom:6px; } #tm-qsearch-panel .answer { background:#fff8e6; padding:8px; border-radius:6px; font-size:14px; border: 1px dashed #ffdca8; /* 突出答案区域 */ margin-top: 5px; } #tm-qsearch-panel .controls { padding:8px; display:flex; gap:8px; justify-content:space-between; align-items:center; border-bottom: 1px solid #eee; flex-shrink: 0; /* 不收缩 */ } /* 融合的搜索框样式 */ #tm-qsearch-panel .search-container { display: flex; flex-grow: 1; } #tm-qsearch-panel #tm-current-query-input { flex-grow: 1; padding: 6px 8px; border: 1px solid #ccc; border-right: none; /* 移除右边框 */ border-radius: 6px 0 0 6px; /* 圆角调整 */ font-size: 14px; height: 32px; /* 固定高度 */ box-sizing: border-box; /* 保证高度一致 */ } #tm-qsearch-panel #tm-manual-search-btn { padding: 6px 10px; border: 1px solid #ccc; border-left: none; /* 移除左边框 */ border-radius: 0 6px 6px 0; /* 圆角调整 */ margin-left: 0; /* 移除左边距 */ height: 32px; /* 固定高度 */ box-sizing: border-box; /* 保证高度一致 */ background: #f0f0f0; } #tm-qsearch-panel #tm-manual-search-btn:hover { background: #e0e0e0; } /* 精确搜索开关样式 */ #tm-exact-search-toggle-container { display: flex; align-items: center; gap: 5px; flex-shrink: 0; } #tm-exact-search-toggle-container .tm-toggle-label { font-size: 12px; color: #333; cursor: pointer; } #tm-exact-search-toggle-container .tm-toggle-switch { position: relative; display: inline-block; width: 34px; height: 20px; } #tm-exact-search-toggle-container .tm-toggle-switch input { opacity: 0; width: 0; height: 0; } #tm-exact-search-toggle-container .tm-toggle-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; border-radius: 20px; transition: .4s; } #tm-exact-search-toggle-container .tm-toggle-slider:before { position: absolute; content: ""; height: 16px; width: 16px; left: 2px; bottom: 2px; background-color: white; border-radius: 50%; transition: .4s; } #tm-exact-search-toggle-container input:checked + .tm-toggle-slider { background-color: #ff6b6b; } #tm-exact-search-toggle-container input:checked + .tm-toggle-slider:before { transform: translateX(14px); } #tm-qsearch-panel button { cursor:pointer; border:1px solid #ddd; background:#fff; padding:6px 8px; border-radius:6px; } #tm-qsearch-panel button:hover { background: #f0f0f0; } #tm-qsearch-badge { position: fixed; z-index: 2147483646; right: 20px; bottom: 20px; top: auto; background:#ff6b6b; color:#fff; padding:6px 10px; border-radius:20px; cursor:pointer; box-shadow: 0 6px 18px rgba(0,0,0,0.12); font-weight:600; } #tm-qsearch-empty { padding:10px; color:#666; } /* --- (设置面板样式保持不变) --- */ #tm-qsearch-settings-overlay { position: fixed; z-index: 2147483648; left: 0; top: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.4); display: none; align-items: center; justify-content: center; } #tm-qsearch-settings-panel { background: #fff; padding: 20px; border-radius: 8px; width: 80vw; max-width: 350px; box-shadow: 0 4px 15px rgba(0,0,0,0.2); } #tm-qsearch-settings-panel .setting-item { margin-bottom: 15px; } #tm-qsearch-settings-panel .setting-item label { display: block; font-weight: 600; margin-bottom: 5px; } #tm-qsearch-settings-panel .setting-item input[type="number"] { width: 100%; padding: 8px; box-sizing: border-box; border: 1px solid #ccc; border-radius: 4px; } #tm-qsearch-settings-panel .setting-item-check { display: flex; align-items: center; gap: 8px; } #tm-qsearch-settings-panel .setting-item-check label { margin-bottom: 0; } #tm-qsearch-settings-panel .settings-footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; } .tm-qsearch-data-zone { border-top: 1px solid #eee; margin-top: 20px; padding-top: 15px; } .tm-qsearch-data-zone label { display: block; font-weight: 600; margin-bottom: 5px; } .tm-settings-button { background-color: #f0f0f0; color: #333; border: 1px solid #ccc; padding: 8px 12px; border-radius: 6px; cursor: pointer; width: 100%; font-weight: 600; box-sizing: border-box; } .tm-settings-button:hover { background-color: #e0e0e0; } .tm-qsearch-danger-zone { border-top: 1px solid #eee; margin-top: 20px; padding-top: 15px; } .tm-qsearch-danger-zone label { color: #d9534f; font-weight: 600; margin-bottom: 5px; display: block; } .tm-settings-button-danger { background-color: #d9534f; color: white; border: 1px solid #d43f3a; padding: 8px 12px; border-radius: 6px; cursor: pointer; width: 100%; font-weight: 600; box-sizing: border-box; } .tm-settings-button-danger:hover { background-color: #c9302c; } #tm-qsearch-icon { position: absolute; z-index: 2147483646; background: #fff; border: 1px solid #ccc; border-radius: 50%; width: 30px; height: 30px; display: none; align-items: center; justify-content: center; font-size: 16px; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.15); transition: transform 0.1s ease; } #tm-qsearch-icon:hover { transform: scale(1.1); } /* --- 新增样式 --- */ /* 高亮样式 */ .tm-highlight { background-color: #fffdc4; color: #000; font-weight: bold; border-radius: 3px; padding: 0 2px; } `); /* ------------------------- 存储函数 (题库 & 配置) -------------------------*/ async function loadBank() { let raw = await GM_getValue(STORAGE_KEY); if (!raw) { await GM_setValue(STORAGE_KEY, JSON.stringify(DEFAULT_BANK)); raw = JSON.stringify(DEFAULT_BANK); } try { return JSON.parse(raw); } catch (e) { console.error('解析本地题库失败,将重置为默认题库', e); await GM_setValue(STORAGE_KEY, JSON.stringify(DEFAULT_BANK)); return DEFAULT_BANK; } } async function saveBank(bank) { await GM_setValue(STORAGE_KEY, JSON.stringify(bank)); } async function loadSettings() { let raw = await GM_getValue(STORAGE_KEY_SETTINGS); if (!raw) { return DEFAULT_SETTINGS; } try { const parsed = JSON.parse(raw); return { ...DEFAULT_SETTINGS, ...parsed }; } catch (e) { console.error('解析配置失败,将重置为默认配置', e); return DEFAULT_SETTINGS; } } async function saveSettings(settings) { await GM_setValue(STORAGE_KEY_SETTINGS, JSON.stringify(settings)); } /* ------------------------- 异步分块处理 -------------------------*/ function updateProgress(current, total) { const el = document.querySelector('#tm-qsearch-empty.progress'); if (el) { const percent = Math.round((current / total) * 100); el.textContent = `搜索中 (${percent}%)...`; } } function processArrayAsync(array, fn, chunkSize = 50, progressCallback) { return new Promise((resolve) => { let index = 0; const results = []; function processChunk() { const end = Math.min(index + chunkSize, array.length); for (; index < end; index++) { const result = fn(array[index]); if (result) results.push(result); } if (index < array.length) { if (progressCallback) progressCallback(index, array.length); setTimeout(processChunk, 0); } else { if (progressCallback) progressCallback(index, array.length); resolve(results); } } processChunk(); }); } /* ------------------------- 文本预处理与相似度算法 -------------------------*/ function normalizeText(s) { if (!s) return ''; s = s.toString().toLowerCase(); s = s.replace(/[^\p{Script=Han}\p{L}\p{N}\s]/gu, ' '); s = s.replace(/\s+/g, ' ').trim(); return s; } function substringMatch(a, b) { return a.indexOf(b) !== -1 || b.indexOf(a) !== -1; } function tokens(s) { return s.split(/\s+/).filter(Boolean); } function jaccardSimilarity(a, b) { const A = new Set(tokens(a)); const B = new Set(tokens(b)); const inter = [...A].filter(x => B.has(x)).length; const union = new Set([...A, ...B]).size; if (union === 0) return 0; return inter / union; } function levenshteinDistance(a, b) { const n = a.length, m = b.length; if (n === 0) return m; if (m === 0) return n; const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); for (let i = 0; i <= n; i++) dp[i][0] = i; for (let j = 0; j <= m; j++) dp[0][j] = j; for (let i = 1; i <= n; i++) { for (let j = 1; j <= m; j++) { const cost = a[i - 1] === b[j - 1] ? 0 : 1; dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost); } } return dp[n][m]; } function levenshteinRatio(a, b) { if (a.length === 0 && b.length === 0) return 1; const dist = levenshteinDistance(a, b); return 1 - dist / Math.max(a.length, b.length); } function scoreMatch(q_normalized, c_normalized) { if (!q_normalized || !c_normalized) return 0; // 精确匹配 (子字符串) 给予满分 if (substringMatch(c_normalized, q_normalized)) return 1.0; const jacc = jaccardSimilarity(q_normalized, c_normalized); let lev = 0; const LEV_COMPLEXITY_LIMIT = 250000; if (q_normalized.length * c_normalized.length < LEV_COMPLEXITY_LIMIT) { lev = levenshteinRatio(q_normalized, c_normalized); } // 模糊匹配 const score = 0.55 * jacc + 0.45 * lev; return score; } /* ------------------------- 主搜索函数 -------------------------*/ async function searchBank(query, limit = 10) { const [bank, settings] = await Promise.all([loadBank(), loadSettings()]); const qn = normalizeText(query); if (!qn) return []; // 获取精确搜索开关的状态 const exactSearchToggle = document.getElementById('tm-exact-search-toggle'); const isExactSearch = exactSearchToggle ? exactSearchToggle.checked : false; let scoreThreshold = settings.scoreThreshold || 0; const MAX_QUERY_LEN_FOR_SCORE = 500; const truncatedQuery = qn.length > MAX_QUERY_LEN_FOR_SCORE ? qn.substring(0, MAX_QUERY_LEN_FOR_SCORE) : qn; const scoringFn = (item) => { let combined = item.question || ''; if (item.options && Array.isArray(item.options)) combined += ' ' + item.options.join(' '); if (item.tags && Array.isArray(item.tags)) combined += ' ' + item.tags.join(' '); if (item.answer) combined += ' ' + (typeof item.answer === 'string' ? item.answer : JSON.stringify(item.answer)); const cn = normalizeText(combined); const sc = scoreMatch(truncatedQuery, cn); if (isExactSearch) { // 精确搜索模式:只接受 1.0 (完美匹配) if (sc === 1.0) { return { item, score: sc }; } } else { // 模糊搜索模式:使用设置中的阈值 if (sc >= scoreThreshold) { return { item, score: sc }; } } return null; }; const results = await processArrayAsync(bank, scoringFn, 50, updateProgress); results.sort((a,b) => b.score - a.score); return results.slice(0, limit); } /* ------------------------- UI:主面板 -------------------------*/ async function createPanel() { if (panel) return panel; panel = document.createElement('div'); panel.id = 'tm-qsearch-panel'; panel.style.display = "none"; // 确保初始隐藏 panel.innerHTML = `