// ==UserScript== // @name 粉笔题库·悬浮查词(成语/词语) // @namespace local.doubao.fenbi-hover-word // @version 3.4.0 // @description 粉笔网页版做逻辑填空/言语理解题时,鼠标悬浮到成语或词语上,即时显示释义、用法语境、出处、例句、近反义。成语走本地词库(秒开离线),词语走汉典在线查询并缓存。v3.4 变更:① 悬浮窗改为紧贴鼠标/所作词显示(原先挂在整个选项行中央,离鼠标很远);② 选项里一段连续的汉字(成语尺度)一律整体取词,词库不收录就显示"未收录",绝不从中截出"两个单独的词"(如未收录成语被拆成两个双字词)。v3.3 变更:鼠标中键查词源由汉典改为百度汉语(汉典成语收录不全),释义仍由汉典提供。v3.2 改进:① 只在 ABCD 选项区域内生效,题干不触发;② 鼠标悬浮在选项词上时,直接按鼠标中键即可打开百度汉语词条,鼠标无需移动到悬浮窗(中键监听挂在 document 层)。v3 还修复"把两个相邻的词并成一个词"的问题:取词一律以分词词表(jieba 词典)+ 成语词库判定真实词边界,绝不跨标点/跨词拼接(如"医生:患者"不会再被并成"医生患者")。仅题目区域触发,不影响答题。 // @author doubao // @match https://fenbi.com/* // @match https://*.fenbi.com/* // @match http://fenbi.com/* // @match http://*.fenbi.com/* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @connect zdic.net // @connect www.zdic.net // @connect hanyu.baidu.com // @connect raw.githubusercontent.com // @connect cdn.jsdelivr.net // @connect gh-proxy.com // @connect ghfast.top // @run-at document-end // @license MIT // ==/UserScript== (function () { 'use strict'; /* ================= 配置 ================= */ var CONFIG = { dictStorageKey: 'fw_idiom_dict_v1', wordDictStorageKey: 'fw_word_boundary_v1', // 分词词表缓存(只存词,不存释义) wordCachePrefix: 'fw_word_cache_', wordCacheTtl: 30 * 24 * 60 * 60 * 1000, // 在线查询结果缓存 30 天 queryBase: 'https://www.zdic.net/hans/', // 在线释义查询源(汉典,页面结构固定、已有解析器) jumpBase: 'https://hanyu.baidu.com/s?wd=', // 中键跳转查词源(百度汉语,收词更全) queryTimeout: 8000, // 单次在线查询超时 dictTimeout: 60000, // 单个词库源超时 wordDictTimeout: 60000, // 分词词表源超时 moveThrottle: 50, // mousemove 节流(ms) stemDwell: 400, // 题干里停留多久才联网查词(ms) enrichLocal: true // 本地词库命中后,再联网补充"用法语境/近反义" }; // 成语词库下载源(按实测速度排序;请求会自动携带 gzip,实际传输约 3.7MB) var DICT_SOURCES = [ { url: 'https://cdn.jsdelivr.net/gh/pwxcoo/chinese-xinhua@master/data/idiom.json', tag: 'jsDelivr' }, { url: 'https://gh-proxy.com/https://raw.githubusercontent.com/pwxcoo/chinese-xinhua/master/data/idiom.json', tag: '国内加速1' }, { url: 'https://ghfast.top/https://raw.githubusercontent.com/pwxcoo/chinese-xinhua/master/data/idiom.json', tag: '国内加速2' }, { url: 'https://raw.githubusercontent.com/pwxcoo/chinese-xinhua/master/data/idiom.json', tag: 'GitHub 源站' } ]; // 分词词表(jieba 词典,约 1.4MB / 34.9 万词,只保留 2~6 字词): // 作用不是给释义,而是判定"词边界"——只有表里真实存在的词才允许被选中, // 从根本上杜绝"医生:患者 → 医生患者""遏制蔓延 → 遏制蔓延"这类把两个词并成一个词的错误。 var WORD_SOURCES = [ { url: 'https://cdn.jsdelivr.net/gh/fxsjy/jieba@master/jieba/dict.txt', tag: 'jsDelivr' }, { url: 'https://gh-proxy.com/https://raw.githubusercontent.com/fxsjy/jieba/master/jieba/dict.txt', tag: '国内加速1' }, { url: 'https://ghfast.top/https://raw.githubusercontent.com/fxsjy/jieba/master/jieba/dict.txt', tag: '国内加速2' }, { url: 'https://raw.githubusercontent.com/fxsjy/jieba/master/jieba/dict.txt', tag: 'GitHub 源站' } ]; /* ================= 状态 ================= */ var dict = null; // { 成语: {p,e,d,x} } var dictLoaded = false; var dictSkip = false; // 用户点击"跳过"后为 true var dictDownloading = false; var lastDictPct = -1; var wordSet = null; // Set<词>:分词词表,仅用于判定词边界 var wordSetLoading = false; var inflight = new Map(); // word -> Promise(在途在线查询) var memoryCache = new Map(); // word -> 在线结果(内存缓存) var tipEl = null; var tipVisible = false; var currentWord = null; // 当前 tooltip 展示的词 var currentKind = null; // 'option' | 'stem' var querySeq = 0; // 查询序号,防止异步乱序覆盖 var pendingTimer = null; // 题干"停留后才联网"的定时器 var pendingWord = null; var lastMoveTs = 0; var hoverEl = null; var lastMX = 0, lastMY = 0; // 最近一次鼠标位置:拿不到"词级"位置时,悬浮窗贴着鼠标显示 var toastEl = null; var toastTimer = null; /* ================= 小工具 ================= */ function gmGet(key) { try { return GM_getValue(key); } catch (e) { return undefined; } } function gmSet(key, val) { try { GM_setValue(key, val); } catch (e) { /* 存储不可用时忽略 */ } } function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); } function escReg(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function isHanzi(ch) { return /[\u4e00-\u9fa5]/.test(ch); } var HANZI_RE = /^[\u4e00-\u9fa5]+$/; // 纯汉字(无标点/数字/字母) var HANZI_WORD_RE = /^[\u4e00-\u9fa5·]+$/; // 纯汉字(允许姓名间隔号) function cleanText(s) { return String(s == null ? '' : s).replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim(); } function compactText(s) { return String(s == null ? '' : s).replace(/\u00a0/g, ' ').replace(/\s+/g, '').trim(); } /* 词库里的"出处"常带整句书证(与例句重复),只保留到书名号结束 */ function shortenSource(s) { var t = cleanText(s); if (t.length <= 32) return t; var m = t.match(/^[\s\S]{0,18}?《[^》]{1,22}》/); return m ? m[0] : t.slice(0, 32) + '…'; } /* ================= 用法/语境抽取(纯逻辑,可单测) ================= 汉典没有独立的"用法"分区,语境信息藏在"百科"描述句与释义里, 如"多形容新生事物正在蓬勃发展""常用来比喻……",用提示词抓句子。 注意:不要放裸的"指",否则"是指……"这种句子会被误抓。 */ var USAGE_RE = /(多用于|多形容|多指|常用于|借指|泛指|形容|比喻|含褒义|含贬义|褒义|贬义|用于)[^。;;!?]{2,50}/g; function usageFromText(text, max) { if (!text) return ''; // 百科句常写成"…比喻眼光远大,出自《论衡》",把"出自/语出"之后的书证切掉 var src = String(text).split(/[,,]?\s*(?:出自|语出|源自|源出)/)[0]; var hits = [], m, limit = max || 2; USAGE_RE.lastIndex = 0; while ((m = USAGE_RE.exec(src)) !== null && hits.length < limit) { var s = m[0].replace(/^[,,、\s]+/, '').trim().replace(/[,,、;;]+$/, ''); if (s.length >= 4 && hits.indexOf(s) < 0) hits.push(s); } return hits.join(';').slice(0, 140); } /* ================= 成语词库加载 ================= */ function loadDict() { if (dictLoaded) return; var cached = gmGet(CONFIG.dictStorageKey); if (cached) { try { var obj = JSON.parse(cached); if (obj && typeof obj === 'object' && Object.keys(obj).length > 500) { dict = obj; dictLoaded = true; showToast('成语词库已就绪(' + Object.keys(dict).length + ' 条,离线秒开)', 1800); return; } } catch (e) { /* 缓存损坏则重新下载 */ } } showToast('首次使用:正在下载成语词库(约10MB,仅一次)…下载期间悬停词语走在线查询', 0, '跳过下载', function () { dictSkip = true; hideToast(); }, 0); startDictDownload(0); } function startDictDownload(idx) { if (idx >= DICT_SOURCES.length) { failDict(); return; } var src = DICT_SOURCES[idx]; dictDownloading = true; lastDictPct = -1; GM_xmlhttpRequest({ method: 'GET', url: src.url, timeout: CONFIG.dictTimeout, onload: function (res) { try { var arr = JSON.parse(res.responseText); if (!Array.isArray(arr) || arr.length < 500) throw new Error('bad data'); var map = {}; arr.forEach(function (it) { if (it && it.word) map[it.word] = { p: it.pinyin, e: it.explanation, d: it.derivation, x: it.example }; }); dict = map; dictLoaded = true; dictDownloading = false; try { gmSet(CONFIG.dictStorageKey, JSON.stringify(map)); } catch (e) {} hideToast(); showToast(dictSkip ? '成语词库已在后台加载完成(' + Object.keys(map).length + ' 条),此后悬停成语秒开' : '成语词库加载完成(' + Object.keys(map).length + ' 条,来自' + src.tag + ')', 3000); } catch (e) { nextDictSource(idx); } }, onerror: function () { nextDictSource(idx); }, ontimeout: function () { nextDictSource(idx); }, onprogress: function (e) { if (dictSkip) return; if (e && e.lengthComputable && e.total > 0) { var pct = Math.floor(e.loaded / e.total * 100); if (pct !== lastDictPct) { lastDictPct = pct; showToast('正在下载成语词库 ' + pct + '%(' + fmtMB(e.loaded) + ' / ' + fmtMB(e.total) + ' MB)…下载期间悬停词语也可查词', 0, '跳过', function () { dictSkip = true; hideToast(); }, pct); } } } }); } function nextDictSource(idx) { startDictDownload(idx + 1); } function fmtMB(bytes) { return (bytes / 1048576).toFixed(1); } function failDict() { dictDownloading = false; dictSkip = false; showToast('成语词库下载失败,当前使用在线查询(每个词联网取义,稍慢)', 0, '重试', function () { hideToast(); startDictDownload(0); }); } function lookupDict(word) { return (dictLoaded && dict) ? dict[word] : null; } /* ================= 分词词表:词边界(防"两个词并成一个词") ================= */ function loadWordSet() { if (wordSet || wordSetLoading) return; wordSetLoading = true; var cached = gmGet(CONFIG.wordDictStorageKey); if (cached && cached.length > 50000) { buildWordSet(cached); wordSetLoading = false; return; } startWordDownload(0); } function buildWordSet(joined) { var set = new Set(); var arr = String(joined).split('\n'); for (var i = 0; i < arr.length; i++) { if (arr[i]) set.add(arr[i]); } wordSet = set; } function startWordDownload(idx) { if (idx >= WORD_SOURCES.length) { wordSetLoading = false; return; } // 静默失败:退化为只认成语词库 var src = WORD_SOURCES[idx]; GM_xmlhttpRequest({ method: 'GET', url: src.url, timeout: CONFIG.wordDictTimeout, onload: function (res) { try { var raw = res && res.responseText ? res.responseText : ''; if (raw.length < 100000) throw new Error('bad data'); var lines = raw.split('\n'), keep = [], seen = new Set(); for (var i = 0; i < lines.length; i++) { // jieba 词典每行形如 "循序渐进 123 x",取词列;只留 2~6 字纯汉字 var w = lines[i].split(' ')[0]; if (w.length >= 2 && w.length <= 6 && HANZI_RE.test(w) && !seen.has(w)) { seen.add(w); keep.push(w); } } if (keep.length < 50000) throw new Error('bad data'); buildWordSet(keep.join('\n')); wordSetLoading = false; try { gmSet(CONFIG.wordDictStorageKey, keep.join('\n')); } catch (e) {} showToast('分词词表已就绪(' + keep.length + ' 词,来自' + src.tag + '):此后按词取词,不再把两个词并成一个', 3000); } catch (e) { startWordDownload(idx + 1); } }, onerror: function () { startWordDownload(idx + 1); }, ontimeout: function () { startWordDownload(idx + 1); } }); } /* 只认"真实存在的词":成语词库优先,其次分词词表。 返回 null 表示这不是一个词(调用方必须放弃,不得拼接)。 */ function lookupW(word) { if (!word) return null; var loc = lookupDict(word); if (loc) return { word: word, local: loc }; if (wordSet && wordSet.has(word)) return { word: word, local: null }; return null; } /* ================= 在线查询(汉典) ================= */ function pullOnline(word) { if (inflight.has(word)) return inflight.get(word); var p = new Promise(function (resolve) { GM_xmlhttpRequest({ method: 'GET', url: CONFIG.queryBase + encodeURIComponent(word), timeout: CONFIG.queryTimeout, headers: { 'Accept': 'text/html,application/xhtml+xml' }, onload: function (res) { if (!res || !res.responseText || (res.status && res.status >= 400)) { resolve(null); return; } resolve(parseZdic(res.responseText, word)); }, onerror: function () { resolve(null); }, ontimeout: function () { resolve(null); } }); }); inflight.set(word, p); p.then(function () { inflight.delete(word); }, function () { inflight.delete(word); }); return p; } /* ---------- 汉典解析:优先按 data-section 结构化取,失败再走文本兜底 ---------- */ function parseZdic(html, word) { var doc; try { doc = new DOMParser().parseFromString(html, 'text/html'); } catch (e) { return null; } if (!doc || !doc.body) return null; var out = parseZdicStructured(doc, word); if (out && (out.explanation || out.usage || out.derivation)) return out; var legacy = parseZdicLegacy(doc, word); if (legacy && (legacy.explanation || legacy.example)) return legacy; return out; } function textOf(root, sel) { if (!root) return ''; var n = root.querySelector(sel); return n ? cleanText(n.textContent) : ''; } function allText(root, sel) { if (!root) return []; return Array.prototype.slice.call(root.querySelectorAll(sel)) .map(function (n) { return cleanText(n.textContent); }) .filter(function (s) { return !!s; }); } function joinSenses(list, max) { var out = [], total = 0; for (var i = 0; i < list.length && i < max; i++) { var s = String(list[i]).replace(/^\d+\s*[..、]\s*/, '').trim(); s = s.replace(/[。;;,,、\s]+$/, ''); // 避免出现"…自如。;形容…"这种叠标点 if (!s) continue; if (total + s.length > 260) break; out.push(s); total += s.length; } return out.join(';'); } function parseZdicStructured(doc, word) { var out = { word: word, source: '汉典', pinyin: '', explanation: '', usage: '', derivation: '', example: '', similar: '', opposite: '' }; // 1) 按 data-section 归集分区(实测:词语解释 / 国语辞典 / 百科 / 近反义词 / 翻译) var secs = {}; Array.prototype.forEach.call(doc.querySelectorAll('.dict-section[data-section]'), function (s) { var k = cleanText(s.getAttribute('data-section')); if (k && !secs[k]) secs[k] = s; }); // 兼容旧结构(只有标题、没有 data-section) if (!Object.keys(secs).length) { Array.prototype.forEach.call(doc.querySelectorAll('.dict-section'), function (s) { var t = cleanText(textOf(s, '.dict-section__title')); if (!t) return; var k = t.replace(word, '').trim(); if (k && !secs[k]) secs[k] = s; }); } // 2) 拼音 out.pinyin = textOf(doc, '.word-pronun-row .meta-pinyin') || textOf(doc, '.xxjs-reading__py') || textOf(doc, '.gy-reading__py'); // 3) 释义:词语解释的多条义项全取(最多 4 条),再退国语辞典 var defs = allText(secs['词语解释'], '.xxjs-item__def'); if (!defs.length) defs = allText(secs['词语解释'], '.gy-sense__def'); if (!defs.length) defs = allText(secs['国语辞典'], '.gy-sense__def'); if (defs.length) out.explanation = joinSenses(defs, 4); // 3b) 部分词条没有"词语解释",只有"成语"分区(纯文本"解释/出处"结构) if (secs['成语']) { var cyTxt = cleanText(secs['成语'].textContent).replace(/反馈[\s\S]*$/, '').trim(); if (!out.explanation) { var cm1 = cyTxt.match(/解释\s*[::]?\s*([\s\S]*?)(?=出处|示例|用法|近义|反义|拼音|$)/); if (cm1 && cm1[1].trim().length > 3) out.explanation = cm1[1].trim().slice(0, 220); } if (!out.derivation) { var cm2 = cyTxt.match(/出处\s*[::]?\s*([\s\S]*?)(?=示例|用法|近义|反义|拼音|$)/); if (cm2 && cm2[1].trim().length > 3) out.derivation = cm2[1].trim().slice(0, 90); } } // 4) 百科描述(语境信息的主要来源) var wiki = ''; var wikiBody = doc.querySelector('.wiki-block__body'); if (wikiBody) { var li = wikiBody.querySelector('li, p'); wiki = cleanText(li ? li.textContent : wikiBody.textContent); if (!li) wiki = wiki.replace(new RegExp('^' + escReg(word) + '\\s*'), ''); wiki = wiki.replace(/内容来自\s*\S*百科[\s\S]*$/, '').trim(); } // 5) 用法 / 语境 out.usage = explicitUsage(doc) || usageFromText(wiki) || usageFromText(out.explanation); // 6) 出处 / 例句(书证) var cite = doc.querySelector('.xxjs-citation, .gy-sense__cit'); if (cite) { var src = textOf(cite, '.xxjs-citation__source') || textOf(cite, 'cite'); var quote = textOf(cite, '.xxjs-citation__text'); if (!quote) { var item = cite.querySelector('li'); if (item) { var clone = item.cloneNode(true); var c = clone.querySelector('cite, .xxjs-citation__source, .xxjs-citation__dash'); if (c && c.parentNode) c.parentNode.removeChild(c); quote = cleanText(clone.textContent).replace(/[—--]+\s*$/, '').trim(); } } out.derivation = src; if (quote) out.example = quote + (src ? '(' + src + ')' : ''); } if (!out.derivation && wiki) { var dm = wiki.match(/出自\s*([^。;;]{2,30})/); if (dm) out.derivation = dm[1].trim(); } // 7) 近义 / 反义 var synRoot = secs['近反义词']; if (synRoot) { Array.prototype.forEach.call(synRoot.querySelectorAll('.synonym-row'), function (r) { var lab = cleanText(textOf(r, '.synonym-label')); var words = Array.prototype.slice.call(r.querySelectorAll('.synonym-tag')) .map(function (a) { return cleanText(a.textContent); }) .filter(function (s) { return !!s; }); if (!words.length) return; if (/近义/.test(lab) && !out.similar) out.similar = words.join('、'); if (/反义/.test(lab) && !out.opposite) out.opposite = words.join('、'); }); } if ((!out.similar || !out.opposite) && secs['国语辞典']) { Array.prototype.forEach.call(secs['国语辞典'].querySelectorAll('.xxjs-also'), function (b) { var lab = cleanText(textOf(b, '.xxjs-block-label')); var words = Array.prototype.slice.call(b.querySelectorAll('a, .syn-tag')) .map(function (a) { return cleanText(a.textContent); }) .filter(function (s) { return !!s; }); if (!words.length) return; if (/近义/.test(lab) && !out.similar) out.similar = words.join('、'); if (/反义/.test(lab) && !out.opposite) out.opposite = words.join('、'); }); } return out; } /* 显式的"用法:xxx"标签(汉典部分词条有) */ function explicitUsage(doc) { var labels = Array.prototype.slice.call(doc.querySelectorAll('.xxjs-block-label, dt, .wiki-label')); for (var i = 0; i < labels.length; i++) { if (!/^用法/.test(cleanText(labels[i].textContent))) continue; var sib = labels[i].nextElementSibling || (labels[i].parentElement && labels[i].parentElement.querySelector('.xxjs-also__text, dd')); var t = cleanText(sib ? sib.textContent : ''); if (t) return t.slice(0, 90); } return ''; } /* 兜底:页面结构变化时,从各分区纯文本里做语义分段(旧逻辑,保留作为保险) */ function parseZdicLegacy(doc, word) { try { var sections = Array.prototype.slice.call(doc.querySelectorAll('.dict-section__body')); var texts = sections.map(function (b) { return cleanText(b.textContent); }) .filter(function (s) { return !!s; }); if (!texts.length) { var body = doc.body ? cleanText(doc.body.textContent) : ''; var i = body.indexOf(word); if (i >= 0) { var seg = body.slice(i + word.length, i + word.length + 240) .replace(/^[\s,。;、::]+/, '').replace(/纠错反馈[\s\S]*$/, ''); if (seg.length > 4) { return { word: word, source: '汉典', pinyin: '', explanation: seg, usage: '', derivation: '', example: '', similar: '', opposite: '' }; } } return null; } var out = { word: word, source: '汉典', pinyin: '', explanation: '', usage: '', derivation: '', example: '', similar: '', opposite: '' }; var joined = texts.join(' '); out.explanation = stripHead(joined, word).split(/书证|近义词|反义词|英文|英语/)[0].slice(0, 400).trim(); out.usage = usageFromText(joined); var sm = joined.match(/书证\s*(.+?)(?=英文|英语|近义词|反义词|用法|反馈|$)/); if (sm && sm[1].trim().length > 4) out.example = sm[1].trim(); var jm = joined.match(/近义词\s*[::]?\s*([\u4e00-\u9fa5、,\s]{2,60}?)(?=反义词|反馈|$)/); if (jm) out.similar = jm[1].trim().replace(/\s+/g, '、'); var fm = joined.match(/反义词\s*[::]?\s*([\u4e00-\u9fa5、,\s]{2,60}?)(?=近义词|反馈|$)/); if (fm) out.opposite = fm[1].trim().replace(/\s+/g, '、'); return out; } catch (e) { return null; } } /* 剥掉"词头 + 拼音 + 注音"前缀 */ var PY_CLASS = 'a-zA-Z\\u00c0-\\u02af'; function stripHead(txt, word) { var t = String(txt == null ? '' : txt); t = t.replace(new RegExp('^' + escReg(word) + '(?:\\s*' + escReg(word) + ')?\\s*'), ''); t = t.replace(/^[((]\s*/, ''); t = t.replace(new RegExp('^(?:[' + PY_CLASS + ']+\\s*)+'), ''); t = t.replace(/^(?:[\u3105-\u312f\u02c7\u02c9\u02ca\u02cb\u02d9]+\s*)+/, ''); return t.replace(/^[\s,。;、::))]+/, '').trim(); } /* ================= 词缓存 ================= */ function loadWordCache(word) { try { var raw = gmGet(CONFIG.wordCachePrefix + word); if (!raw) return null; var obj = JSON.parse(raw); if (!obj || Date.now() - obj.ts > CONFIG.wordCacheTtl) return null; memoryCache.set(word, obj.data); return obj.data; } catch (e) { return null; } } function saveWordCache(word, data) { try { gmSet(CONFIG.wordCachePrefix + word, JSON.stringify({ ts: Date.now(), data: data })); } catch (e) {} } /* ================= 区域与候选词识别 ================= */ // 题目区:优先粉笔真实结构(app-ti / .question-multiple / .solution-choice-container ...) var ZONE_SEL = [ 'app-ti', '.question-multiple', '.questions-single-container', '.ti-content', '.solution-choice-container', '.question-choice-container', '.app-format-html', '[class*="question"]', '[class*="option"]', '[class*="choice"]', '[class*="stem"]', '[class*="material"]', '[class*="passage"]', '[class*="ti-container"]', '[class*="tiku"]', '[class*="exercise"]', '[class*="practice"]' ].join(','); var SKIP_SEL = 'header, nav, footer, aside, .site-header, .top-bar, .sidebar, .fw-tip-root'; var OPT_LABEL_RE = /^[((]?([A-Ha-h])[))..、::]?\s*([\u4e00-\u9fa5·]{2,8})$/; var OPT_CLS_RE = /(^|\s)(choice-radio|choice-item|option-item|optionItem|option-radio-label)(\s|$)/; function inQuestionZone(el) { if (!el || el.nodeType !== 1) return false; if (el.closest('.fw-tip-root')) return false; var tag = el.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'SCRIPT' || tag === 'STYLE' || tag === 'BUTTON' || tag === 'A') return false; if (el.closest(SKIP_SEL)) return false; return !!el.closest(ZONE_SEL); } /* 选项行:① 粉笔真实类名 li.choice-radio;② 文本形态兜底("A 潜移默化") */ function findOptionRow(el) { var node = el, d = 0; while (node && node.nodeType === 1 && d++ < 5) { if (OPT_CLS_RE.test(String(node.className || ''))) return node; node = node.parentElement; } return null; } function findOptionRowByText(el) { var node = el, d = 0; while (node && node.nodeType === 1 && d++ < 4) { var t = compactText(node.textContent); if (t && t.length <= 12 && OPT_LABEL_RE.test(t)) return node; node = node.parentElement; } return null; } /* 鼠标所在的那"一段连续汉字"(被标点/字母数字切开的整段,不截断) */ function hanziRunAt(text, offset) { if (!text) return null; var n = text.length, i = offset; if (i >= n || !isHanzi(text.charAt(i))) i = offset - 1; if (i < 0 || i >= n || !isHanzi(text.charAt(i))) return null; var s = i, e = i + 1; while (s > 0 && isHanzi(text.charAt(s - 1))) s--; while (e < n && isHanzi(text.charAt(e))) e++; return { text: text.slice(s, e), start: s, end: e }; } /* "一段连续汉字"整体取词——成语不再被拆成两个词的关键: 命中词库 → 就用它;词库没收录 → 整段原样返回(界面显示"未收录"), 绝不在这段汉字里再截出更短的子词("未收录成语被识别成两个单独词语"就是这么来的)。 整段是长句(>8 字)时不属于"一个词",返回 null 交给词边界逻辑处理。 */ function wholeRunWord(run) { if (!run || !HANZI_WORD_RE.test(run)) return null; if (run.length < 2 || run.length > 8) return null; var hit = lookupW(run); if (hit) return { word: run, local: hit.local }; // 词库没收录:只有"词/成语尺度"(2~4 字)才原样交给在线查询, // 更长的一整段(多半是句子片段)不猜、不拆; // 2~3 字的碎段若是虚词组合("的了"这类)也不打扰。 if (run.length > 4) return null; if (STEM_STOP[run]) return null; if (run.length <= 3 && FUZZY_CH_RE.test(run)) return null; return { word: run, local: null }; } /* 从选项行里取词:只认词库里真实存在的词 + 标点边界,既不做拼接,也不做拆分。 例:"A. 医生:患者" 取到 "医生" 或 "患者",绝不会是 "医生患者"; 未收录的 4 字成语整段返回并显示"未收录",绝不会被切成两个字。 */ function rowWord(row) { if (!row) return null; var raw = cleanText(row.textContent); if (!raw) return null; var m = raw.match(OPT_LABEL_RE); var core = cleanText(m ? m[2] : raw); if (!core) return null; var segs = core.split(/[^\u4e00-\u9fa5·]+/).filter(function (s) { return !!s; }); if (!segs.length) return null; // ① 整行就是一段连续汉字:整体取词,绝不从中间截子词 if (segs.length === 1) { var whole = wholeRunWord(segs[0]); if (whole) return { word: whole.word, start: 0, end: whole.word.length, node: null, local: whole.local }; // 只有"整句"(>8 字)才允许在段内取真实词;词尺度的一律不拆 return segs[0].length > 8 ? longestWordIn(segs[0]) : null; } // ② 有标点/空格分隔:逐段整体取词,段内同样不拆 for (var i = 0; i < segs.length; i++) { var s = segs[i]; var w = wholeRunWord(s); if (w) return { word: w.word, start: 0, end: w.word.length, node: null, local: w.local }; if (s.length > 8) { var inner = longestWordIn(s); if (inner) return inner; } } return null; } /* 选项取词:优先按鼠标所在的那段连续汉字整体取词(长句选项再按词边界取词), 取不到才退回"整行取词"。所有路径都只认真实词或整段,不做任何拼接/拆分。 */ function optionWordAt(row, x, y) { var pos = getCaretPos(x, y); if (pos && pos.node && (!row.contains || row.contains(pos.node))) { var text = pos.node.textContent || ''; var run = hanziRunAt(text, pos.offset); if (run) { var whole = wholeRunWord(run.text); if (whole) { // 顺带带出"词级"位置(node + 起止偏移),供悬浮窗贴着这个词显示 return { word: whole.word, start: run.start, end: run.end, node: pos.node, local: whole.local }; } if (run.text.length > 8) { // 整段是长句:按词边界取真实词 var hit = matchInText(text, pos.offset); if (hit) { hit.node = pos.node; return hit; } } return null; // 词尺度但词库没收录:不猜、不拆,也不打扰 } var loose = matchInText(text, pos.offset); if (loose) { loose.node = pos.node; return loose; } return null; } return rowWord(row); } /* ================= 题干:光标定位 ================= */ function getCaretPos(x, y) { var node = null, offset = 0; try { if (document.caretRangeFromPoint) { var r = document.caretRangeFromPoint(x, y); if (r && r.startContainer && r.startContainer.nodeType === 3) { node = r.startContainer; offset = r.startOffset; } } else if (document.caretPositionFromPoint) { var cp = document.caretPositionFromPoint(x, y); if (cp && cp.offsetNode && cp.offsetNode.nodeType === 3) { node = cp.offsetNode; offset = cp.offset; } } } catch (e) { return null; } return node ? { node: node, offset: offset } : null; } /* 在文本中取"包含光标的、最长的真实词"(成语词库 + 分词词表)。 三条硬约束,专治"两个词并成一个词": ① 候选必须是词库里真实存在的词,拼不出来就返回 null,不猜; ② 候选不得跨标点,"医生:患者" 永远不会返回 "医生患者"; ③ 光标紧贴词尾时只算弱候选,右边以光标为起始边界的词优先, 即"循序渐进|持之以恒"上悬停"持"取"持之以恒",不会取到左边的"循序渐进"。 */ function matchInText(text, offset) { if (!text) return null; var n = text.length, best = null; var lo = Math.max(0, offset - 8), hi = Math.min(n, offset + 9); for (var i = lo; i < hi; i++) { if (!isHanzi(text.charAt(i))) continue; var maxLen = Math.min(n - i, 8); for (var len = maxLen; len >= 2; len--) { var w = text.slice(i, i + len); if (!HANZI_RE.test(w)) continue; // ② 跨标点的不算词 var hit = lookupW(w); if (!hit) continue; var tier; if (i < offset && offset < i + len) tier = 3; // 光标在词内 else if (offset === i) tier = 3; // 光标压在词首 else if (offset === i + len) tier = 2; // 光标紧贴词尾:弱候选 else tier = 1; var score = tier * 100 + len; if (!best || score > best.score) { best = { word: w, start: i, end: i + len, node: null, score: score, local: hit.local, tier: tier }; } break; // 以 i 为起点的最长匹配已找到 } } // ④ 光标不在任何词内、只是"旁边恰好有个词"(tier 1)时不打扰用户,交给兜底流程 if (best && best.tier === 1) return null; return best; } /* 在整段文本里找"最长的真实词"(选项行整体取词用),找不到就 null */ function longestWordIn(text) { if (!text) return null; var n = text.length, best = null; for (var i = 0; i < n; i++) { if (!isHanzi(text.charAt(i))) continue; for (var len = Math.min(n - i, 8); len >= 2; len--) { var w = text.slice(i, i + len); if (!HANZI_RE.test(w)) continue; var hit = lookupW(w); if (!hit) continue; if (!best || len > best.word.length) best = { word: w, start: i, end: i + len, node: null, local: hit.local }; break; } } return best; } /* 题干兜底:只有当光标处"标点围起来"的连续汉字段恰好是 2~3 字、 且不含虚词字时才敢直接用;更长的段落一律放弃——宁可不显示, 也不把"遏制蔓延""医生患者"这种两个词拼成一个词去查。 */ var STEM_STOP = { '这个': 1, '那个': 1, '我们': 1, '他们': 1, '你们': 1, '什么': 1, '这样': 1, '那样': 1, '因为': 1, '所以': 1, '但是': 1, '如果': 1, '可以': 1, '没有': 1, '就是': 1, '已经': 1, '一个': 1, '不是': 1, '这些': 1, '那些': 1, '以及': 1, '并且': 1, '而且': 1, '于是': 1 }; var FUZZY_CH_RE = /[的了着过在是和与及或而就都也又很更最太把被对从向给让使能会要可不等中上下里时后前]/; function fallbackRun(text, offset) { if (!text) return null; var n = text.length; var hit = offset; if (hit >= n || !isHanzi(text.charAt(hit))) hit = offset - 1; if (hit < 0 || hit >= n || !isHanzi(text.charAt(hit))) return null; var s = hit, e = hit + 1; while (s > 0 && isHanzi(text.charAt(s - 1))) s--; while (e < n && isHanzi(text.charAt(e))) e++; var w = text.slice(s, e); if (w.length < 2 || w.length > 3) return null; if (FUZZY_CH_RE.test(w)) return null; return { word: w, start: s, end: e, node: null, local: null }; } /* ================= 展示流程 ================= */ function clearPending() { if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; } pendingWord = null; } function hideVisual() { if (tipEl) tipEl.style.display = 'none'; tipVisible = false; } function hideTip() { hideVisual(); currentWord = null; currentKind = null; clearPending(); ++querySeq; // 使在途查询失效 } function setTarget(anchor, word, kind, range) { if (word === currentWord && kind === currentKind) { if (tipVisible) showTip(anchor, range); return; } clearPending(); ++querySeq; var seq = querySeq; currentWord = word; currentKind = kind; var local = lookupDict(word); var fallback = local ? { word: word, source: '成语词库', pinyin: local.p, explanation: local.e, derivation: shortenSource(local.d), example: local.x, usage: usageFromText(local.e), similar: '', opposite: '' } : null; if (fallback) { renderTip(word, fallback); showTip(anchor, range); if (CONFIG.enrichLocal) requestOnline(word, seq, anchor, range, 0, false, fallback); return; } var cached = memoryCache.get(word) || loadWordCache(word); if (cached) { renderTip(word, cached); showTip(anchor, range); return; } if (kind === 'stem') { // 题干:先不打扰用户,停留够了再联网;查不到就静默隐藏 hideVisual(); requestOnline(word, seq, anchor, range, CONFIG.stemDwell, true, null); return; } renderTip(word, { source: 'loading', word: word }); showTip(anchor, range); requestOnline(word, seq, anchor, range, 0, false, null); } function requestOnline(word, seq, anchor, range, delay, silent, fallback) { if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; } pendingWord = word; var run = function () { pendingTimer = null; if (seq !== querySeq || currentWord !== word) return; // 已过期 if (delay > 0) { renderTip(word, { source: 'loading', word: word }); showTip(anchor, range); } pullOnline(word).then(function (data) { if (seq !== querySeq || currentWord !== word) return; // 空结果 / 页面改版拿不到内容,统一按"未收录"处理 if (!data || (!data.explanation && !data.usage && !data.derivation && !data.example)) { if (silent) { hideVisual(); } else { renderTip(word, { source: 'none', word: word }); showTip(anchor, range); } return; } if (fallback) { if (!data.explanation) data.explanation = fallback.explanation; if (!data.derivation) data.derivation = fallback.derivation; if (!data.example) data.example = fallback.example; if (!data.pinyin) data.pinyin = fallback.pinyin; if (!data.usage) data.usage = fallback.usage; if (!data.similar) data.similar = fallback.similar; if (!data.opposite) data.opposite = fallback.opposite; data.source = data.source + ' + ' + fallback.source; } memoryCache.set(word, data); saveWordCache(word, data); renderTip(word, data); showTip(anchor, range); }); }; if (delay > 0) pendingTimer = setTimeout(run, delay); else run(); } /* ================= Tooltip UI ================= */ var CSS_TEXT = [ '.fw-tip-root{position:fixed;z-index:2147483647;display:none;max-width:380px;min-width:200px;', 'background:#fff;color:#333;border:1px solid #e2e6ea;border-radius:10px;', 'box-shadow:0 6px 24px rgba(0,0,0,.14);font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;', 'font-size:13px;line-height:1.65;overflow:hidden;}', '.fw-tip-head{padding:9px 14px 7px;border-bottom:1px solid #f0f2f5;background:#fafbfc;}', '.fw-tip-word{font-size:17px;font-weight:700;color:#1f2329;margin-right:8px;}', '.fw-tip-py{font-size:12px;color:#86909c;}', '.fw-tip-body{padding:8px 14px 12px;max-height:300px;overflow-y:auto;}', '.fw-tip-sec{margin:6px 0;display:flex;}', '.fw-tip-k{flex:0 0 34px;font-weight:600;color:#165dff;}', '.fw-tip-v{flex:1 1 auto;color:#333;word-break:break-word;}', '.fw-tip-example{color:#4e5969;background:#f7f8fa;border-radius:6px;padding:4px 8px;margin-top:4px;}', '.fw-tip-none{color:#c0392b;}', '.fw-tip-foot{padding:6px 14px 8px;border-top:1px solid #f0f2f5;font-size:11px;color:#86909c;', 'display:flex;justify-content:space-between;align-items:center;}', '.fw-tip-foot a{color:#165dff;text-decoration:none;}', '.fw-toast-msg{vertical-align:middle;}', '.fw-toast-btn{margin-left:10px;background:#fff;color:#165dff;border:none;border-radius:6px;padding:3px 10px;font-size:12px;cursor:pointer;vertical-align:middle;}', '.fw-toast-btn:hover{background:#eef3ff;}', '.fw-toast-bar{height:4px;background:rgba(255,255,255,.25);border-radius:2px;margin-top:6px;overflow:hidden;}', '.fw-toast-bar-i{height:100%;background:#4c8dff;border-radius:2px;transition:width .3s;}' ].join(''); function ensureTip() { if (tipEl) return tipEl; tipEl = document.createElement('div'); tipEl.className = 'fw-tip-root'; tipEl.addEventListener('mouseenter', function () { /* 鼠标进入提示框:保持显示 */ }); document.documentElement.appendChild(tipEl); return tipEl; } function renderTip(word, data) { var tip = ensureTip(); var html = ''; if (data.source === 'loading') { html = '
' + esc(word) + '
' + '
正在查询释义…
'; } else if (data.source === 'none') { var noneLink = CONFIG.jumpBase + encodeURIComponent(word); html = '
' + esc(word) + '
' + '
未收录「' + esc(word) + '」,中键点击词语或下方链接可到百度汉语查看。' + '百度汉语 ↗
'; } else { html = '
' + esc(word) + '' + (data.pinyin ? '' + esc(data.pinyin) + '' : '') + '
'; html += '
'; // 用法若与释义重复(本地词库常把语境写在释义里)就不重复占一行 var usage = data.usage; if (usage && data.explanation && data.explanation.indexOf(usage) >= 0) usage = ''; if (data.explanation) html += '
释义' + esc(data.explanation) + '
'; if (usage) html += '
用法' + esc(usage) + '
'; if (data.derivation) html += '
出处' + esc(data.derivation) + '
'; if (data.example) html += '
例句
' + esc(data.example) + '
'; if (data.similar || data.opposite) { var line = ''; if (data.similar) line += '近义:' + esc(data.similar); if (data.opposite) line += (line ? ' ' : '') + '反义:' + esc(data.opposite); html += '
辨析' + line + '
'; } html += '
'; var link = CONFIG.jumpBase + encodeURIComponent(word); html += '
来源:' + esc(data.source || '') + ' 中键点击词语可查百度汉语' + '百度汉语 ↗
'; } tip.innerHTML = html; } /* 只取"词"本身的矩形:range 定位失败就返回 null, 绝不退化成"整个选项行"的矩形(否则又会把悬浮窗挂到行中央、离鼠标很远)。 */ function rectFromRange(range) { if (!range || !range.node || typeof range.start !== 'number') return null; try { var r = document.createRange(); r.setStart(range.node, range.start); r.setEnd(range.node, Math.min(range.end, range.node.textContent.length)); var rc = r.getBoundingClientRect(); if (rc && (rc.width || rc.height)) return rc; } catch (e) { /* ignore */ } return null; } function rectOf(anchor, range) { var rc = rectFromRange(range); if (rc) return rc; if (anchor && anchor.nodeType === 1) { var b = anchor.getBoundingClientRect(); if (b && (b.width || b.height)) return b; } return null; } /* v3.5 定位:悬浮窗贴在鼠标所在词的右侧,绝不挡选项。 */ function showTip(anchor, range) { if (!tipEl) return; tipEl.style.visibility = 'hidden'; tipEl.style.display = 'block'; var tw = tipEl.offsetWidth, th = tipEl.offsetHeight; var GAP = 120; // 约 3cm 间距 var wordRect = rectFromRange(range); var rect = wordRect || rectOf(anchor, range); var tight = !!wordRect; var x, y; var vw = window.innerWidth, vh = window.innerHeight; if (tight) { x = wordRect.right + GAP; y = wordRect.top - 4; if (x + tw > vw - 8) { x = wordRect.left - tw - GAP; if (x < 8) x = wordRect.right + GAP; } if (y + th > vh - 8) y = vh - th - 8; if (y < 8) y = 8; } else if (lastMX || lastMY) { x = lastMX + 14; y = lastMY - 4; if (x + tw > vw - 8) x = lastMX - tw - 14; if (x < 8) x = lastMX + 14; if (y + th > vh - 8) y = vh - th - 8; if (y < 8) y = 8; } else if (rect) { x = rect.right + GAP; y = rect.top - 4; if (x + tw > vw - 8) x = rect.left - tw - GAP; if (y + th > vh - 8) y = rect.top - th - 4; if (x < 8) x = 8; if (y < 8) y = 8; } else { tipEl.style.display = 'none'; tipVisible = false; tipEl.style.visibility = ''; return; } if (x < 8) x = 8; if (y < 8) y = 8; tipEl.style.left = x + 'px'; tipEl.style.top = y + 'px'; tipEl.style.visibility = ''; tipVisible = true; } /* ================= Toast ================= */ function showToast(msg, ms, actionText, actionFn, progress) { if (!toastEl) { toastEl = document.createElement('div'); toastEl.style.cssText = 'position:fixed;right:16px;bottom:16px;z-index:2147483646;' + 'background:rgba(0,0,0,.78);color:#fff;padding:8px 14px;border-radius:8px;' + 'font:13px/1.5 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif;max-width:340px;' + 'box-shadow:0 4px 16px rgba(0,0,0,.2);display:none;'; document.documentElement.appendChild(toastEl); } var html = '' + esc(msg) + ''; if (typeof progress === 'number') { html += '
'; } if (actionText) html += ''; toastEl.innerHTML = html; var btn = toastEl.querySelector('.fw-toast-btn'); if (btn) { btn.onclick = function (e) { e.preventDefault(); toastEl.style.display = 'none'; if (toastTimer) { clearTimeout(toastTimer); toastTimer = null; } if (actionFn) actionFn(); }; } toastEl.style.display = 'block'; if (toastTimer) clearTimeout(toastTimer); if (ms) toastTimer = setTimeout(function () { toastEl.style.display = 'none'; }, ms); } function hideToast() { if (toastEl) toastEl.style.display = 'none'; if (toastTimer) { clearTimeout(toastTimer); toastTimer = null; } } /* ================= 事件 ================= */ function openDict(url) { if (!url) return; try { window.open(url, '_blank', 'noopener'); } catch (err) { /* ignore */ } } /* 鼠标中键(button===1):鼠标停在选项词上时直接跳百度汉语查词,全程不用把鼠标移到悬浮窗。 监听挂在 document 捕获阶段,因此在词上按下中键就能命中; 同时在 mousedown 阶段 preventDefault,避免触发浏览器中键自动滚动。 */ var lastAuxTs = 0; // 同一次中键点击可能同时触发 mousedown 与 auxclick,去重防开两个标签 function onAuxDown(e) { if (e.button !== 1) return; var now = Date.now(); if (now - lastAuxTs < 400) return; var el = e.target; if (!el || el.nodeType !== 1) { el = document.elementFromPoint ? document.elementFromPoint(e.clientX, e.clientY) : null; } if (!el || !el.closest) return; // 情形 1:中键点在悬浮窗内的「百度汉语」链接上 → 打开该词条 var a = el.closest('.fw-zdic-link'); if (a) { var href = a.getAttribute('data-href') || a.getAttribute('href'); if (href && href.indexOf('javascript:') !== 0) { e.preventDefault(); lastAuxTs = now; openDict(href); } return; } // 情形 2:鼠标悬浮在选项区的词上 → 直接中键跳百度汉语查当前词 if (el.closest('.fw-tip-root')) return; var row = findOptionRow(el) || findOptionRowByText(el); if (!row) return; var word = null; var hit = optionWordAt(row, e.clientX, e.clientY); if (hit && hit.word) word = hit.word; else if (currentWord && currentKind === 'option') word = currentWord; if (!word) return; e.preventDefault(); // 阻止中键自动滚动 lastAuxTs = now; openDict(CONFIG.jumpBase + encodeURIComponent(word)); } function onPointer(e) { var el = e.target; if (typeof e.clientX === 'number') { lastMX = e.clientX; lastMY = e.clientY; } if (!el || el.nodeType !== 1) return; if (el.closest && el.closest('.fw-tip-root')) return; // 鼠标在提示框上:保持显示,方便点链接 hoverEl = el; // 只在选项区域(ABCD 盒子)里触发,题干不生效 var row = findOptionRow(el) || findOptionRowByText(el); if (!row) { hideTip(); return; } var w = optionWordAt(row, e.clientX, e.clientY); if (w && w.word) { // 有"词级"位置就带上,悬浮窗才能贴着这个词显示(而不是挂到整行中央) var range = (w.node && typeof w.start === 'number') ? { node: w.node, start: w.start, end: w.end } : null; setTarget(row, w.word, 'option', range); } else { hideTip(); } } function onMouseOver(e) { onPointer(e); } function onMouseMove(e) { var now = Date.now(); if (typeof e.clientX === 'number') { lastMX = e.clientX; lastMY = e.clientY; } if (now - lastMoveTs < CONFIG.moveThrottle) return; lastMoveTs = now; if (!hoverEl) return; onPointer(e); } function onMouseOut(e) { var to = e.relatedTarget; if (to && to.nodeType === 1 && to.closest && (to.closest('.fw-tip-root') || inQuestionZone(to))) return; hideTip(); } function onKeyDown(e) { if (e.key === 'Escape') hideTip(); } function bindEvents() { document.addEventListener('mouseover', onMouseOver, true); document.addEventListener('mousemove', onMouseMove, true); document.addEventListener('mouseout', onMouseOut, true); // 鼠标中键:悬浮在选项词上直接跳百度汉语查词(捕获阶段,早于页面自身逻辑) document.addEventListener('mousedown', onAuxDown, true); document.addEventListener('auxclick', onAuxDown, true); document.addEventListener('scroll', hideTip, true); document.addEventListener('click', function (e) { if (e.target && e.target.closest && e.target.closest('.fw-tip-root')) return; hideTip(); }, true); document.addEventListener('keydown', onKeyDown, true); window.addEventListener('resize', hideTip); } /* ================= 启动 ================= */ function init() { var style = document.createElement('style'); style.textContent = CSS_TEXT; (document.head || document.documentElement).appendChild(style); ensureTip(); loadDict(); loadWordSet(); // 词边界词表(约 1.4MB,首次后台下载后缓存) bindEvents(); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init(); })();