// ==UserScript== // @name 粉笔题库·悬浮查词(成语/词语) // @namespace local.doubao.fenbi-hover-word // @version 3.13.0 // @description v3.13.0 修复「我的收藏」面板顶端混入两条非成语卡片(`...` 与 ``):远端 Markdown 文件头里的两处示例占位标记 `` 与说明文字里的字面示例 `` 被整块解析器当成真实词块起始渲染成卡片。现让 bookParseBlocks 与 favMarkerRe 共用同一套排除规则(起始标记捕获段禁用尖括号、`...` 模板占位前置否定断言),并在词名解析后加 favIsRealWord 兜底校验(空词、`...`、含尖括号、含 encodeURIComponent 一律丢弃),仅真实词块会进入面板与计数。v3.12.0 收紧「我的收藏」面板排版:面板头部与卡片外边距/内边距整体缩小(卡片 margin 12/20→8/14、头部 padding 10/14→7/12、正文行高 1.7→1.55、字段/元信息间距同步收紧),并把「取消收藏」按钮从卡片底部独立操作行移入卡片头部操作区、排在「展开 ▾」提示的左侧(点按钮不触发展开),每个词块更紧凑;面板头部另加「配置」按钮(直通 WebDAV 设置)。v3.11.0 修复「点收藏慢、我的收藏面板加载不出内容」:① 收藏改为在本机快照上直接合并写回(省掉每次一次 GET 往返),保存目录的探测结果缓存复用(省掉重复 MKCOL),单次请求超时 30s→12s,卡住就明确报超时;②「我的收藏」面板改为先用本机快照秒开,云端刷新失败时保留缓存内容并显示失败原因与「最近一次请求」状态,另加「诊断信息」一键自检(请求出口 / 超时 / 最近请求明细 / 快照时间),面板不再出现空白或永久加载。 粉笔网页版做逻辑填空/言语理解题时,鼠标悬浮到成语或词语上,即时显示释义、用法语境、出处、例句、近反义。成语走本地词库(秒开离线),词语走汉典在线查询并缓存。v3.10.0 把「我的收藏」面板改成「点击展开」:默认每张卡片只显示成语与释义,点某张卡片即展开该词全部字段(拼音/用法/出处/例句/近反义/来源/收藏时间),再次点击收起;并修复面板可能卡在「正在加载收藏数据…」的问题(本地 15 秒兜底超时 + 失败/超时态给「重新加载」按钮 + 已开面板时再点「我的收藏」会重新拉取)。v3.8.0 新增「我的收藏」面板:侧栏/中键栏「我的收藏」按钮弹出面板,从坚果云远端文件解析所有已收藏词块,每条渲染为独立卡片展示词名/拼音/释义/用法/出处/例句/近反义/来源/收藏时间/页面,每条卡片下方带「取消收藏」;v3.7.0 新增「一键收藏」:在悬浮窗左侧栏或中键固定栏点「收藏」,就能把当前词的释义卡片以 Markdown 词块写入坚果云(WebDAV:GM_xmlhttpRequest + HTTP Basic + ETag 乐观锁,同一个词自动去重,已收藏的词再点一次即取消收藏);并新增 WebDAV 配置面板(油猴菜单「★ WebDAV 收藏设置」/ 控制台 window.__fwFav.open())——地址固定 https://dav.jianguoyun.com/dav/、账号填注册邮箱、密码必须用「应用密码」。v3.6 变更:中键不再直接跳转百度汉语,改为在鼠标旁弹出 3 个按钮(百度汉语/人民网/收藏)并固定住,鼠标移过去点完即自动消失。v3.5 变更:① 悬浮窗整体贴到鼠标右侧约 3cm 处,绝不遮挡选项;② 查词按钮统一挪到悬浮窗左侧竖排——百度汉语、人民网(搜该成语的用法)、收藏。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 // @grant GM_deleteValue // @grant GM_registerMenuCommand // @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 // @connect dav.jianguoyun.com // @connect * // @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=', // 中键跳转查词源(百度汉语,收词更全) rmwBase: 'http://search.people.cn/s/?keyword=', // 人民网搜索:查该成语在新闻/评论里的实际用法 favEnabled: true, // 一键收藏:v3.7.0 实装 —— 把当前词的释义卡片经 WebDAV 写入坚果云 favWebdavPath: 'fenbi-word-favorites.md', // 一键收藏默认保存路径(相对 WebDAV 根目录;只填裸文件名时自动补前缀「我的坚果云/」) 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 currentData = null; // v3.7.0:当前展示的释义数据(收藏时直接用它,避免再查一次导致字段缺失) 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, .fw-pin-bar'; 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:470px;min-width:250px;', '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;}', /* v3.5:左侧竖排按钮栏(百度汉语 / 人民网 / 收藏),右侧为词条正文 */ '.fw-tip-flex{display:flex;align-items:stretch;}', '.fw-tip-main{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;}', '.fw-tip-side{flex:0 0 96px;background:#fafbfc;border-right:1px solid #f0f2f5;', 'padding:10px 8px;display:flex;flex-direction:column;gap:6px;}', '.fw-side-btn{display:block;width:100%;box-sizing:border-box;margin:0;padding:6px 4px;', 'font-family:inherit;font-size:12px;line-height:1.3;text-align:center;text-decoration:none;', 'background:#fff;color:#165dff;border:1px solid #e5e6eb;border-radius:7px;cursor:pointer;}', '.fw-side-btn:hover{background:#eef3ff;border-color:#bedaff;}', '.fw-side-sub{display:block;font-size:10px;line-height:1.2;color:#86909c;margin-top:1px;}', '.fw-side-fav.is-disabled{color:#a9aeb8;border-style:dashed;}', '.fw-side-fav.is-disabled:hover{background:#f7f8fa;border-color:#e5e6eb;}', /* v3.8.0:「我的收藏」按钮样式 */ '.fw-side-book{background:#fff7e6;color:#92400e;border-color:#f5d89a;}', '.fw-side-book:hover{background:#fff0d4;color:#78350f;border-color:#f0c97a;}', /* v3.8.0:「我的收藏」面板 */ '.fw-book-overlay{position:fixed;left:0;top:0;right:0;bottom:0;z-index:2147483647;background:rgba(0,0,0,.42);display:flex;align-items:center;justify-content:center;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;}', '.fw-book-panel{width:min(720px,calc(100vw - 60px));max-height:calc(100vh - 80px);overflow:auto;background:#fff;border-radius:14px;box-shadow:0 20px 60px rgba(0,0,0,.28);}', '.fw-book-head{display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px;padding:12px 16px 10px;border-bottom:1px solid #f0f2f5;position:sticky;top:0;background:#fff;z-index:1;border-radius:14px 14px 0 0;}', '.fw-book-head h3{margin:0;margin-right:auto;font-size:16px;color:#1f2329;}', '.fw-book-count{font-size:12px;color:#86909c;margin-left:8px;}', '.fw-book-close{background:none;border:none;font-size:22px;line-height:1;color:#86909c;cursor:pointer;padding:0 8px;}', '.fw-book-close:hover{color:#333;}', '.fw-book-config{background:none;border:none;font-size:13px;line-height:1;color:#4e5969;cursor:pointer;padding:4px 10px;border-radius:6px;border:1px solid #e5e6eb;margin-left:0;}', '.fw-book-config:hover{background:#f2f6ff;border-color:#bedaff;color:#165dff;}', /* v3.10.0:「我的收藏」卡片——默认只显示成语 + 释义,点击卡片展开其余字段 */ '.fw-book-card{cursor:pointer;transition:border-color .15s,box-shadow .15s;}', '.fw-book-card.is-open{border-color:#c9d8ff;box-shadow:0 2px 10px rgba(22,93,255,.08);}', '.fw-book-card-py{display:none;} .fw-book-card.is-open .fw-book-card-py{display:inline;}', '.fw-book-extra{display:none;} .fw-book-card.is-open .fw-book-extra{display:block;}', '.fw-book-card-hint{font-size:11px;color:#86909c;user-select:none;margin-left:6px;}', '.fw-book-card.is-open .fw-book-card-hint{color:#165dff;}', '.fw-book-empty .fw-book-tip{font-size:12px;color:#a9aeb8;}', '.fw-book-retry{margin-top:10px;font-size:13px;padding:6px 16px;border-radius:8px;border:1px solid #c9d8ff;background:#f2f6ff;color:#165dff;cursor:pointer;}', '.fw-book-retry:hover{background:#e6efff;}', '.fw-book-diag{margin-top:10px;margin-left:8px;font-size:13px;padding:6px 16px;border-radius:8px;border:1px solid #e5e6eb;background:#fafbfc;color:#4e5969;cursor:pointer;}', '.fw-book-diag:hover{background:#f2f3f5;}', /* v3.11.0:面板顶部提示(本机缓存 / 刷新失败原因)+ 底部状态行 + 诊断输出 */ '.fw-book-note{font-size:12px;line-height:1.6;color:#86909c;background:#f7f8fa;padding:8px 20px;border-bottom:1px solid #f0f2f5;}', '.fw-book-note.is-warn{color:#d4380d;background:#fff7f0;}', '.fw-book-note .fw-book-retry{margin-top:6px;margin-left:6px;font-size:12px;padding:4px 12px;}', '.fw-book-status{font-size:11px;color:#a9aeb8;padding:8px 20px;border-top:1px solid #f0f2f5;text-align:right;}', '.fw-book-diag-pre{margin:0;padding:12px 20px;background:#fafbfc;border-top:1px solid #f0f2f5;font-size:11px;line-height:1.6;color:#4e5969;white-space:pre-wrap;word-break:break-all;}', '.fw-book-empty{text-align:center;padding:32px 20px;color:#86909c;font-size:14px;} .fw-book-empty p{margin:6px 0;}', '.fw-book-card{margin:8px 14px;border:1px solid #e5e6eb;border-radius:10px;overflow:hidden;background:#fff;}', '.fw-book-card-head{display:flex;align-items:center;padding:7px 12px;border-bottom:1px solid #f0f2f5;background:#fafbfc;gap:6px;}', '.fw-book-card-word{font-size:15px;font-weight:700;color:#1f2329;}', '.fw-book-card-py{font-size:12px;color:#86909c;margin-left:2px;}', '.fw-book-card-body{padding:8px 12px;font-size:13px;line-height:1.55;color:#333;}', '.fw-book-card-sec{margin:2px 0;} .fw-book-card-sec b{color:#165dff;}', '.fw-book-card-sec .fw-book-card-val{color:#333;} .fw-book-card-sec .fw-book-card-val em{font-style:normal;color:#4e5969;}', '.fw-book-card-example{color:#4e5969;background:#f7f8fa;border-radius:6px;padding:4px 8px;margin-top:2px;}', '.fw-book-card-meta{margin-top:5px;padding-top:5px;border-top:1px solid #f0f2f5;font-size:11px;color:#86909c;display:flex;gap:14px;}', '.fw-book-card-actions{display:flex;align-items:center;gap:6px;margin-left:auto;}', '.fw-book-btn-unfav{font-size:12px;padding:3px 10px;border-radius:6px;border:1px solid #f5d89a;background:#fff7e6;color:#92400e;cursor:pointer;}', '.fw-book-btn-unfav:hover{background:#fff0d4;border-color:#f0c97a;}', '.fw-book-btn-unfav:disabled{opacity:.5;cursor:default;background:#f7f8fa;border-color:#e5e6eb;color:#a9aeb8;}', '.fw-book-btn-unfav.is-loading{pointer-events:none;} .fw-book-btn-unfav.is-loading span{opacity:.5;}', /* v3.8.0 结束 */ /* v3.6:中键固定按钮栏——钉在鼠标旁,鼠标移过去点完即消失 */ '.fw-pin-bar{position:fixed;z-index:2147483647;display:flex;flex-direction:column;gap:6px;padding:8px;', 'background:#fff;border:1px solid #e2e6ea;border-radius:10px;box-shadow:0 6px 24px rgba(0,0,0,.18);', 'font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;min-width:92px;}', '.fw-pin-head{font-size:11px;color:#86909c;text-align:center;padding-bottom:4px;', 'border-bottom:1px solid #f0f2f5;max-width:112px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}', '.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;}', /* v3.7.0:一键收藏(WebDAV → 坚果云)—— 已收藏态 + 配置面板 */ '.fw-side-fav.is-on{background:#e8f5ec;color:#1a7f4b;border-color:#a8dfc0;}', '.fw-side-fav.is-on:hover{background:#daf0e2;color:#15683d;border-color:#8ad3ae;}', '.fw-side-fav.is-on .fw-side-sub{color:#3f9a6c;}', '.fw-wd-overlay{position:fixed;left:0;top:0;right:0;bottom:0;z-index:2147483647;background:rgba(0,0,0,.42);display:flex;align-items:center;justify-content:center;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;}', '.fw-wd-panel{width:min(560px,calc(100vw - 40px));max-height:calc(100vh - 60px);overflow:auto;background:#fff;color:#333;border-radius:12px;box-shadow:0 16px 48px rgba(0,0,0,.24);font-size:13px;line-height:1.6;}', '.fw-wd-head{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid #f0f2f5;}', '.fw-wd-head h3{margin:0;font-size:15px;color:#1f2329;}', '.fw-wd-close{background:none;border:none;font-size:20px;line-height:1;color:#86909c;cursor:pointer;padding:0 4px;}', '.fw-wd-close:hover{color:#333;}', '.fw-wd-body{padding:12px 16px 16px;}', '.fw-wd-field{margin-bottom:10px;}', '.fw-wd-field label{display:block;font-size:12px;color:#4e5969;margin-bottom:4px;}', '.fw-wd-input{width:100%;box-sizing:border-box;padding:7px 9px;border:1px solid #e5e6eb;border-radius:7px;font-family:inherit;font-size:13px;color:#333;background:#fff;}', '.fw-wd-input:focus{outline:none;border-color:#165dff;box-shadow:0 0 0 2px rgba(22,93,255,.12);}', '.fw-wd-tip{font-size:11px;color:#86909c;margin-top:3px;line-height:1.5;}', '.fw-wd-actions{display:flex;gap:8px;flex-wrap:wrap;margin:12px 0 10px;}', '.fw-wd-btn{font-family:inherit;font-size:12px;padding:6px 14px;border-radius:7px;cursor:pointer;background:#fff;color:#165dff;border:1px solid #e5e6eb;}', '.fw-wd-btn:hover{background:#f2f6ff;border-color:#bedaff;}', '.fw-wd-primary{background:#165dff;color:#fff;border-color:#165dff;}', '.fw-wd-primary:hover{background:#0e4fd8;}', '.fw-wd-result{display:none;margin-top:6px;padding:8px 10px;border-radius:7px;font-size:12px;word-break:break-all;}', '.fw-wd-result.is-show{display:block;}', '.fw-wd-result.is-ok{background:#e8f5ec;color:#1a7f4b;border:1px solid #b7e2c8;}', '.fw-wd-result.is-err{background:#fdeceb;color:#c0392b;border:1px solid #f6c6c1;}', '.fw-wd-result.is-warn{background:#fff7e6;color:#95610a;border:1px solid #f5dfa8;}', '.fw-wd-help{margin-top:10px;font-size:11px;color:#86909c;line-height:1.7;}', '.fw-wd-help b{color:#4e5969;}' ].join(''); function ensureTip() { if (tipEl) return tipEl; tipEl = document.createElement('div'); tipEl.className = 'fw-tip-root'; tipEl.addEventListener('mouseenter', function () { /* 鼠标进入提示框:保持显示,方便点按钮 */ }); // 左侧"收藏"按钮:v3.7.0 实装 —— 点一次收藏该词(写入坚果云),再点一次取消收藏 tipEl.addEventListener('click', function (e) { var t = e.target; var fav = (t && t.closest) ? t.closest('.fw-side-fav') : null; if (!fav) return; e.preventDefault(); e.stopPropagation(); var word = (fav.getAttribute && fav.getAttribute('data-fw-word')) || currentWord; favToggle(word, favDataFor(word), fav); }, true); // v3.8.0:「我的收藏」按钮 —— 打开收藏面板 tipEl.addEventListener('click', function (e) { var t = e.target; var book = (t && t.closest) ? t.closest('.fw-side-book') : null; if (!book) return; e.preventDefault(); e.stopPropagation(); openBookPanel(); }, true); document.documentElement.appendChild(tipEl); return tipEl; } /* 悬浮窗左侧竖排按钮栏:百度汉语 / 人民网(搜这个词的用法)/ 收藏(v3.7.0:写入坚果云 WebDAV) */ function sideBarHtml(word) { var dictUrl = CONFIG.jumpBase + encodeURIComponent(word); var rmwUrl = CONFIG.rmwBase + encodeURIComponent(word); var html = '
'; html += '百度汉语'; html += '人民网搜用法'; html += favButtonHtml(word); // v3.7.0:收藏到坚果云(未收藏 → 收藏;已收藏 → 已收藏 / 点此取消) html += ''; html += '
'; return html; } /* v3.6:中键固定按钮栏——中键点在选项词上后,把 3 个按钮钉在鼠标旁, 鼠标可以慢慢移过去点(不用中键即刻跳转);点任一按钮后固定栏消失。 */ var pinBar = null; var pinBarWord = null; // v3.7.0:中键固定栏当前对应的词(收藏按钮据此取词 / 显示「已收藏」) function removePinBar() { if (pinBar && pinBar.parentNode && pinBar.parentNode.removeChild) pinBar.parentNode.removeChild(pinBar); pinBar = null; pinBarWord = null; } /* v3.6:中键固定按钮栏——中键点在选项词上后,把 3 个按钮钉在鼠标旁, 鼠标可以慢慢移过去点(不用中键即刻跳转)。 v3.7.0:点「百度汉语 / 人民网」后固定栏消失;点「收藏」后固定栏保留(按钮自身要显示收藏/取消的实时状态)。 */ function onPinBarClick(e) { if (e && e.preventDefault) e.preventDefault(); if (e && e.stopPropagation) e.stopPropagation(); var t = e && e.target; var btn = (t && t.closest) ? t.closest('.fw-side-btn') : null; if (!btn) return; if (btn.classList && btn.classList.contains('fw-side-fav')) { // v3.7.0:收藏 / 取消收藏。注意此处不能先 removePinBar —— 按钮还要用来显示写入结果状态 var word = (btn.getAttribute && btn.getAttribute('data-fw-word')) || pinBarWord || currentWord; favToggle(word, favDataFor(word), btn); return; } if (btn.classList && btn.classList.contains('fw-side-book')) { // v3.8.0:打开「我的收藏」面板,固定栏保留(用户可继续操作其它按钮) openBookPanel(); return; } var href = btn.getAttribute('data-href') || btn.getAttribute('href'); if (href) openDict(href); removePinBar(); } function showPinBar(x, y, word) { removePinBar(); pinBarWord = String(word == null ? '' : word).trim(); var bar = document.createElement('div'); bar.className = 'fw-pin-bar'; var dictUrl = CONFIG.jumpBase + encodeURIComponent(word); var rmwUrl = CONFIG.rmwBase + encodeURIComponent(word); var html = '
「' + esc(word) + '」
'; html += '百度汉语'; html += '人民网搜用法'; html += favButtonHtml(word); // v3.7.0:收藏到坚果云 html += ''; // v3.8.0:打开「我的收藏」面板 bar.innerHTML = html; document.documentElement.appendChild(bar); // 钉在鼠标旁(不压住鼠标),越界就回收进视口 var bw = bar.offsetWidth, bh = bar.offsetHeight; var vw = window.innerWidth, vh = window.innerHeight; var px = x + 16, py = y - Math.round(bh / 2); if (px + bw > vw - 8) px = x - bw - 16; if (px < 8) px = 8; if (py + bh > vh - 8) py = vh - bh - 8; if (py < 8) py = 8; bar.style.left = px + 'px'; bar.style.top = py + 'px'; // 点按钮:「我的收藏」打开收藏面板;其余(跳转 / 收藏)走 onPinBarClick bar.addEventListener('click', function (e) { var t = e.target; var book = (t && t.closest) ? t.closest('.fw-side-book') : null; if (book) { e.preventDefault(); e.stopPropagation(); openBookPanel(); return; } onPinBarClick(e); }, true); pinBar = bar; } /* 左侧按钮栏 + 右侧词条正文 */ function wrapTip(word, mainHtml) { return '
' + sideBarHtml(word) + '
' + mainHtml + '
'; } function renderTip(word, data) { var tip = ensureTip(); currentData = data; // v3.7.0:记下当前展示的释义数据,收藏时直接用它 var main = ''; if (data.source === 'loading') { main = '
' + esc(word) + '
' + '
正在查询释义…
'; } else if (data.source === 'none') { main = '
' + esc(word) + '
' + '
未收录「' + esc(word) + '」,可中键点击该词调出按钮,再到百度汉语、人民网查看。
'; } else { main = '
' + esc(word) + '' + (data.pinyin ? '' + esc(data.pinyin) + '' : '') + '
'; main += '
'; // 用法若与释义重复(本地词库常把语境写在释义里)就不重复占一行 var usage = data.usage; if (usage && data.explanation && data.explanation.indexOf(usage) >= 0) usage = ''; if (data.explanation) main += '
释义' + esc(data.explanation) + '
'; if (usage) main += '
用法' + esc(usage) + '
'; if (data.derivation) main += '
出处' + esc(data.derivation) + '
'; if (data.example) main += '
例句
' + 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); main += '
辨析' + line + '
'; } main += '
'; main += '
来源:' + esc(data.source || '') + ' 中键点击词语可调出查词按钮
'; } tip.innerHTML = wrapTip(word, main); favRefreshButtons(word); // v3.7.0:按钮按本机收藏记录显示「收藏 / 已收藏」 } /* 只取"词"本身的矩形: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):v3.6 起不再直接跳转,而是在鼠标旁钉出一列按钮 (百度汉语 / 人民网 / 收藏),鼠标可以慢慢移过去点,点完固定栏消失。 监听挂在 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,.fw-rmw-link'); if (a) { var href = a.getAttribute('data-href') || a.getAttribute('href'); if (href && href.indexOf('javascript:') !== 0) { e.preventDefault(); lastAuxTs = now; openDict(href); removePinBar(); } return; } // 情形 2:鼠标悬浮在选项区的词上 → 把 3 个按钮钉在鼠标旁,等鼠标移过去点 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; hideTip(); showPinBar(e.clientX, e.clientY, 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') || el.closest('.fw-pin-bar'))) 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(); removePinBar(); } } 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) { var t = e.target; if (t && t.closest && t.closest('.fw-tip-root')) return; // 点悬浮窗:保持显示,方便点按钮 if (t && t.closest && t.closest('.fw-pin-bar')) return; // 点中键固定栏:交给它自己的处理器 hideTip(); removePinBar(); // 点空白处:顺带收掉中键固定栏 }, true); document.addEventListener('keydown', onKeyDown, true); window.addEventListener('resize', hideTip); } /* ================= v3.7.0:一键收藏(WebDAV → 坚果云) ================= 目标:点悬浮窗左侧栏 / 中键固定栏的「收藏」按钮,就把当前词的释义卡片以 Markdown 区块 追加写入远端文件(坚果云 WebDAV)。设计要点(与旧项目 fenbi-comment-viewer.user.js 同一套思路): a) 追加而非覆盖:写入前先 GET 远端已有内容 → 合并 / 移除词块 → 整体 PUT 回写, 并用 WebDAV 标准 ETag 做乐观锁(If-Match),412 / 428 冲突自动重读重试。 b) 跨域:统一走 GM_xmlhttpRequest(免 CORS,HTTP Basic 认证);没有该授权时退回 fetch。 c) 未配置 WebDAV → 点「收藏」明确提示 + 给「去配置」按钮;未取到释义 → 明确提示暂不可收藏, 绝不静默失败。 d) 配置面板入口三处:油猴菜单 / 控制台 window.__fwFav.open() / 未配置时的引导按钮。 e) 远端标记:词块起始 ,词块结束 。 */ var FAV_WEBDAV_KEY = 'fw_webdav_config_v1'; // 配置持久化键(服务器地址 / 账号 / 密码 / 保存路径) var FAV_WORDS_KEY = 'fw_fav_words_v1'; // 本机「已收藏的词」标记(按钮状态 + 去重) var FAV_FILE_DEFAULT = 'fenbi-word-favorites.md'; // 保存路径默认值(相对 WebDAV 根目录) var FAV_JGY_ROOT = '我的坚果云'; // 坚果云 WebDAV 虚拟服务根下唯一的真实目录 var FAV_TIMEOUT_MS = 12000; // 单次 WebDAV 请求超时(毫秒);v3.11.0:30s → 12s(仍小于面板 15s 兜底),卡住就明确报超时 var FAV_SNAP_KEY = 'fw_fav_snapshot_v1'; // v3.11.0:远端文件快照(内容 + ETag + 时间)——收藏时直接在快照上合并,省掉一次 GET 往返 var FAV_SNAP_MAX = 256 * 1024; // 快照体积上限:超过则只留内存、不落本地存储 var FAV_REQ_LOG_MAX = 12; // v3.11.0:诊断用——保留最近多少次请求的状态与耗时 var FAV_MAX_RETRY = 2; // 乐观锁冲突时的最大重试次数 var FAV_WORDS_KEEP_MAX = 5000; // 本机记录的已收藏词上限(超出后丢最早的) var FAV_MARK_HEAD = ''; var FAV_BLOCK_END = ''; // 词块结束标记 var FAV_JGY_RE = /(^|\.)jianguoyun\.com(:\d+)?(\/|$)/i; // 远端文件不存在时写入的文件头(仅首次创建时使用) var FAV_FILE_HEADER = [ '# 粉笔悬浮查词 · 我的收藏(成语 / 词语释义卡片)', '', '> 本文件由「粉笔题库·悬浮查词」油猴脚本(v3.7.0 起)自动维护:每收藏一个词,就在文件末尾追加一个词块。', '> 每个词块长这样(起始标记里的词是 `encodeURIComponent(词)` 的结果):', '>', '> ', '> ### 掩耳盗铃', '> - **拼音**:yǎn ěr dào líng', '> - **释义**:……', '> - **收藏时间**:2026-10-07 21:50:00', '> - **页面**:https://fenbi.com/...', '> ', '', '**请勿删除隐藏标记**:词块起始标记(形如 ``)是判重与取消收藏的唯一依据,', '词块结束标记 `` 标出词块的结尾。手动删改这两个标记会导致脚本无法判重、也无法取消收藏。', '同一个词重复收藏不会写入第二遍;已收藏的词再点一次「收藏」即取消收藏(从本文件移除该词块)。', '写入方式为「先读取本文件已有内容 → 合并或移除词块 → 整体回写」,并用 WebDAV ETag 乐观锁避免覆盖并发改动。', '', ].join('\n'); var favState = { config: null, words: null, snap: undefined, dirsOk: {}, log: [] }; // 运行时缓存(配置 / 已收藏词 / 远端快照 / 目录探测 / 请求耗时诊断) var favPanelEl = null; // 当前打开的 WebDAV 配置面板 // (中键固定栏对应的词 pinBarWord 声明在固定栏一节,收藏按钮据此取词) function favLog(msg, extra) { try { if (typeof console !== 'undefined' && console && console.log) console.log('[FW 收藏]', msg, extra === undefined ? '' : extra); } catch (e) {} } function favWarn(msg, extra) { try { if (typeof console !== 'undefined' && console && console.warn) console.warn('[FW 收藏]', msg, extra === undefined ? '' : extra); } catch (e) {} } function favErrText(e) { return String(e && e.message ? e.message : (e == null ? '未知错误' : e)); } function favErrCode(e) { var m = /HTTP\s*(\d+)/.exec(favErrText(e)); return m ? m[1] : ''; } /* ---------------- 存储(GM 存储优先,退回 localStorage) ---------------- */ function favStoreGet(key, fallback) { try { if (typeof GM_getValue === 'function') { var v = GM_getValue(key, null); if (v !== null && v !== undefined) return String(v); } } catch (e) { /* 未授权 / 被禁用时退回 localStorage */ } try { var lv = localStorage.getItem(key); if (lv !== null && lv !== undefined) return String(lv); } catch (e) { /* 隐私模式等场景读失败 */ } return fallback; } function favStoreSet(key, value) { var s = String(value), ok = false; try { if (typeof GM_setValue === 'function') { GM_setValue(key, s); ok = true; } } catch (e) { /* ignore */ } try { localStorage.setItem(key, s); ok = true; } catch (e) { /* ignore */ } return ok; } function favStoreRemove(key) { try { if (typeof GM_deleteValue === 'function') GM_deleteValue(key); } catch (e) { /* ignore */ } try { localStorage.removeItem(key); } catch (e) { /* ignore */ } } /* ---------------- 配置读写 ---------------- */ // 保存路径规整:坚果云 dav/ 是「虚拟服务根」,根目录不可写(往根下 PUT 裸文件名会 404), // 只填了裸文件名(无任何目录层级)且地址指向坚果云时,自动补上最外层真实目录「我的坚果云」。 function favNormalizeFilePath(filePath, url) { var p = String(filePath == null ? '' : filePath).trim().replace(/^\/+/, '') || FAV_FILE_DEFAULT; if (p.indexOf('/') === -1 && p !== FAV_JGY_ROOT && FAV_JGY_RE.test(String(url == null ? '' : url).trim())) { return FAV_JGY_ROOT + '/' + p; } return p; } function favNormalizeConfig(raw) { var c = (raw && typeof raw === 'object') ? raw : {}; var url = String(c.url == null ? '' : c.url).trim(); return { url: url, username: String(c.username == null ? '' : c.username).trim(), password: String(c.password == null ? '' : c.password), filePath: favNormalizeFilePath(c.filePath, url) }; } function favGetConfig() { if (favState.config) return favState.config; var parsed = null; try { parsed = JSON.parse(favStoreGet(FAV_WEBDAV_KEY, '') || 'null'); } catch (e) { parsed = null; } favState.config = favNormalizeConfig(parsed); return favState.config; } function favSaveConfig(cfg) { var next = favNormalizeConfig(cfg); favState.config = next; var ok = favStoreSet(FAV_WEBDAV_KEY, JSON.stringify(next)); favSnapDrop(); // v3.11.0:配置一改,旧的远端快照与目录探测结果全部作废 favLog('已保存 WebDAV 配置', '地址=' + (next.url || '(空)') + ' 账号=' + (next.username || '(空)') + ' 保存路径=' + next.filePath + ' 持久化=' + (ok ? '成功' : '失败')); return ok; } function favClearConfig() { favState.config = favNormalizeConfig(null); favStoreRemove(FAV_WEBDAV_KEY); favSnapDrop(); // v3.11.0:配置清空后快照一并作废 favLog('已清空 WebDAV 配置(远端已收藏的文件不受影响)'); } // 「已配置」判定:地址 + 账号必填;密码允许留空(留空且服务端要求认证时会以 401 明确提示检查密码) function favIsConfigured() { var c = favGetConfig(); return !!(c.url && c.username); } /* ---------------- URL / 认证(WebDAV 一律走 HTTP Basic) ---------------- */ function favJoinUrl(base, seg) { var b = String(base == null ? '' : base).trim().replace(/\/+$/, ''); var s = String(seg == null ? '' : seg).replace(/^\/+/, ''); return s ? b + '/' + s : b + '/'; } function favEncodePath(p) { return String(p == null ? '' : p).split('/').map(function (seg) { return encodeURIComponent(seg); }).join('/'); } function favFileUrl(cfg) { return favJoinUrl(cfg.url, favEncodePath(cfg.filePath)); } // 目标文件所在目录(连接测试用,统一以「/」结尾表示集合) function favDirUrl(cfg) { var segs = String(cfg.filePath || '').split('/').filter(function (s) { return !!s; }); segs.pop(); var dirUrl = favJoinUrl(cfg.url, favEncodePath(segs.join('/'))); return /\/$/.test(dirUrl) ? dirUrl : dirUrl + '/'; } // 账号密码可能含中文 / 特殊字符,先转 UTF-8 字节再 base64,避免 btoa 抛 InvalidCharacterError function favUtf8Base64(str) { var s = String(str == null ? '' : str); try { if (typeof TextEncoder === 'function' && typeof btoa === 'function') { var bytes = new TextEncoder().encode(s), bin = ''; for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); return btoa(bin); } } catch (e) { /* 退回下面的老办法 */ } try { return btoa(unescape(encodeURIComponent(s))); } catch (e) { return btoa(s); } } function favAuthHeader(cfg) { return 'Basic ' + favUtf8Base64(cfg.username + ':' + cfg.password); } function favExtractEtag(rawHeaders) { var m = /^etag:\s*(.+)$/im.exec(String(rawHeaders == null ? '' : rawHeaders)); return m ? m[1].trim() : null; } /* ---------------- 请求出口:GM_xmlhttpRequest 优先(免 CORS),退回 fetch ---------------- */ function favRequest(method, url, headers, body) { var h = Object.assign({}, headers || {}); var hasBody = !(body === undefined || body === null); if (typeof GM_xmlhttpRequest === 'function') { return new Promise(function (resolve, reject) { var settled = false, reqT0 = Date.now(); var ok = function (fn, arg) { if (!settled) { settled = true; fn(arg); } }; try { GM_xmlhttpRequest({ method: method, url: url, headers: h, data: hasBody ? body : undefined, timeout: FAV_TIMEOUT_MS, responseType: 'text', onload: function (res) { var st = (res && typeof res.status === 'number') ? res.status : 0; var ms = Date.now() - reqT0; favNoteReq(method, url, st, ms, 'GM_xmlhttpRequest'); ok(resolve, { status: st, statusText: (res && res.statusText) || '', text: (res && res.responseText != null) ? String(res.responseText) : '', headers: (res && res.responseHeaders) || '', via: 'GM_xmlhttpRequest', ms: ms }); }, onerror: function (res) { var eNet = new Error('网络错误(GM_xmlhttpRequest)' + (res && res.error ? ':' + res.error : '')); eNet.ms = Date.now() - reqT0; favNoteReq(method, url, 0, eNet.ms, 'GM_xmlhttpRequest(网络错误)'); ok(reject, eNet); }, ontimeout: function () { var eTo = new Error('请求超时(超过 ' + FAV_TIMEOUT_MS + 'ms)'); eTo.ms = Date.now() - reqT0; favNoteReq(method, url, 0, eTo.ms, 'GM_xmlhttpRequest(超时)'); ok(reject, eTo); }, onabort: function () { ok(reject, new Error('请求被中止')); } }); } catch (e) { ok(reject, e); } }); } var fetchT0 = Date.now(); return fetch(url, { method: method, headers: h, body: hasBody ? body : undefined, credentials: 'omit', // 不要把粉笔的 Cookie 带到坚果云上去 cache: 'no-store' }).then(function (res) { return res.text().then(function (t) { var ms = Date.now() - fetchT0; favNoteReq(method, url, res.status, ms, 'fetch'); return { status: res.status, statusText: res.statusText || '', text: String(t == null ? '' : t), headers: '', via: 'fetch', ms: ms }; }); }).catch(function (e) { throw new Error('请求失败(' + favErrText(e) + ');当前环境没有 GM_xmlhttpRequest,浏览器会以 CORS 拦截跨域 WebDAV 请求'); }); } /* ---------------- v3.11.0 诊断:请求出口 / 状态码 / 耗时(只记路径,不记账号密码) ---------------- */ function favUrlPath(url) { var m = /^[a-z]+:\/\/[^/]+(\/[^?#]*)?/i.exec(String(url == null ? '' : url)); return m ? (m[1] || '/') : String(url == null ? '' : url); } function favNoteReq(method, url, status, ms, via) { try { var d = new Date(), p2 = function (n) { return (n < 10 ? '0' : '') + n; }; favState.log.push({ at: p2(d.getHours()) + ':' + p2(d.getMinutes()) + ':' + p2(d.getSeconds()), method: String(method || '').toUpperCase(), path: favUrlPath(url), status: status, ms: ms, via: via || '' }); if (favState.log.length > FAV_REQ_LOG_MAX) favState.log = favState.log.slice(favState.log.length - FAV_REQ_LOG_MAX); } catch (e) { /* 诊断日志失败绝不影响主流程 */ } } function favLastReq() { var l = favState.log; return (l && l.length) ? l[l.length - 1] : null; } function favReqBrief() { var r = favLastReq(); if (!r) return ''; return r.method + ' ' + (r.status || '未响应') + ' · ' + (r.ms >= 1000 ? (r.ms / 1000).toFixed(1) + 's' : r.ms + 'ms'); } // 一键诊断文本:面板「诊断信息」按钮 / 控制台 __fwFav.diag() 共用(账号密码只回显长度,不泄露内容) function favDiagText() { var cfg = favGetConfig(); var snap = favSnapGet(cfg); var lines = []; lines.push('【粉笔悬浮查词 · 收藏诊断】脚本 v3.13.0'); lines.push('页面:' + favPageUrl()); lines.push('配置状态:' + (favIsConfigured() ? '已配置' : '未配置(点「收藏」会先弹配置引导)')); lines.push(' 服务器:' + (cfg.url || '(空)')); lines.push(' 账号:' + (cfg.username ? '已填(' + cfg.username.length + ' 位)' : '(空)')); lines.push(' 密码:' + (cfg.password ? '已填(' + cfg.password.length + ' 位)' : '(空)')); lines.push(' 保存路径:' + cfg.filePath); lines.push(' 目标 URL:' + favFileUrl(cfg)); lines.push('请求出口:' + (typeof GM_xmlhttpRequest === 'function' ? 'GM_xmlhttpRequest(免 CORS,推荐)' : 'fetch(浏览器同源策略可能拦截跨域 WebDAV)')); // 收藏模块可被离线回归单独抽出(不含面板常量),这里对面板兜底超时做宽容读取 var bookMs = (typeof BOOK_LOAD_TIMEOUT_MS === 'number') ? BOOK_LOAD_TIMEOUT_MS : 15000; lines.push('单次请求超时:' + FAV_TIMEOUT_MS + 'ms;面板兜底超时:' + bookMs + 'ms'); lines.push('本机收藏标记:' + favGetWordSet().size + ' 个词'); lines.push('远端快照:' + (snap ? (snap.at + ' · ' + favCountBlocks(snap.content) + ' 个词块 · ETag ' + (snap.etag ? '有' : '无')) : '无(下次打开面板会重新建立)')); lines.push('最近 ' + favState.log.length + ' 次请求(新 → 旧):'); for (var i = favState.log.length - 1; i >= 0; i--) { var r = favState.log[i]; lines.push(' ' + r.at + ' ' + r.method + ' ' + r.path + ' → ' + (r.status || '未响应') + ' · ' + (r.ms >= 1000 ? (r.ms / 1000).toFixed(2) + 's' : r.ms + 'ms') + (r.via ? ' · ' + r.via : '')); } if (!favState.log.length) lines.push(' (还没有发出过 WebDAV 请求)'); lines.push('提示:401/403 = 账号或应用密码不对;404/405 = 保存路径不可写;409 = 上级目录不存在;超时 = 网络或代理拦截 dav.jianguoyun.com。'); return lines.join('\n'); } /* ---------------- v3.11.0 远端文件快照:省掉「每次收藏都先 GET 一遍」的往返 ---------------- */ function favCfgKey(cfg) { return String((cfg && cfg.url) || '') + '|' + String((cfg && cfg.filePath) || ''); } function favSnapGet(cfg) { if (favState.snap === undefined) { var raw = '', parsed = null; try { raw = favStoreGet(FAV_SNAP_KEY, '') || ''; } catch (e) { raw = ''; } try { parsed = raw ? JSON.parse(raw) : null; } catch (e) { parsed = null; } favState.snap = (parsed && typeof parsed === 'object') ? parsed : null; } var sn = favState.snap; if (!sn || sn.key !== favCfgKey(cfg) || typeof sn.content !== 'string') return null; return sn; } function favSnapSave(cfg, content, etag) { var text = String(content == null ? '' : content); var sn = { key: favCfgKey(cfg), content: text, etag: etag || null, at: favFormatTime(new Date()) }; favState.snap = sn; try { if (text.length <= FAV_SNAP_MAX) favStoreSet(FAV_SNAP_KEY, JSON.stringify(sn)); } catch (e) { /* 体积过大只留内存 */ } return sn; } function favSnapDrop(cfg) { favState.snap = null; try { favStoreRemove(FAV_SNAP_KEY); } catch (e) { /* ignore */ } if (cfg) favState.dirsOk[favCfgKey(cfg)] = false; // 配置 / 目录状态可能已变,下次写入重新探测 } /* ---------------- v3.11.0 快路径:有快照就直接 PUT(少一次 GET 往返) ---------------- */ function favAppendFast(cfg, card) { var sn = favSnapGet(cfg); if (!sn) return favAppendToWebdav(cfg, card); var merged = favMergeIntoMarkdown(sn.content, card); if (merged.duplicated) { return Promise.resolve({ duplicated: true, created: false, count: favCountBlocks(sn.content), via: '本机快照', fast: true }); } if (!sn.etag) return favAppendToWebdav(cfg, card); // 无 ETag 无法做乐观锁 → 回退安全路径 favLog('收藏走快路径', '本机快照直接合并后 PUT(省掉一次 GET)'); return favPutFile(cfg, merged.text, { etag: sn.etag }).then(function (res) { if (res.status >= 200 && res.status < 300) { favSnapSave(cfg, merged.text, favExtractEtag(res.headers) || sn.etag); if (!favExtractEtag(res.headers)) favWarn('远端未返回 ETag,本机快照后续直写可能退化为「最后写入者胜」'); return { duplicated: false, created: false, count: favCountBlocks(merged.text), via: (res.via || '') + '(本机快照直写)', fast: true, ms: res.ms }; } if (res.status === 401 || res.status === 403) { throw new Error('WebDAV 认证失败(HTTP ' + res.status + '),请检查账号和密码(坚果云须用「应用密码」)'); } // 快照过期 / 目录被改动 → 丢掉快照,退回「读 → 改 → 回写」安全路径 favWarn('快路径写入未成功,回退安全路径重试', 'HTTP ' + res.status + ' · ' + (res.ms || 0) + 'ms'); favSnapDrop(cfg); return favAppendToWebdav(cfg, card); }); } function favRemoveFast(cfg, word) { var sn = favSnapGet(cfg); if (!sn || !sn.etag) return favRemoveFromWebdav(cfg, word); var next = favRemoveFromMarkdown(sn.content, word); if (!next.removed) { return Promise.resolve({ removed: false, noFile: false, count: next.count, via: '本机快照', fast: true }); } favLog('取消收藏走快路径', '本机快照直接删块后 PUT(省掉一次 GET)'); return favPutFile(cfg, next.text, { etag: sn.etag }).then(function (res) { if (res.status >= 200 && res.status < 300) { favSnapSave(cfg, next.text, favExtractEtag(res.headers) || sn.etag); return { removed: true, noFile: false, noEnd: !!next.noEnd, count: next.count, via: (res.via || '') + '(本机快照直写)', fast: true, ms: res.ms }; } if (res.status === 401 || res.status === 403) { throw new Error('WebDAV 认证失败(HTTP ' + res.status + '),请检查账号和密码(坚果云须用「应用密码」)'); } favWarn('快路径取消未成功,回退安全路径重试', 'HTTP ' + res.status + ' · ' + (res.ms || 0) + 'ms'); favSnapDrop(cfg); return favRemoveFromWebdav(cfg, word); }); } /* ---------------- 远端文件读写 ---------------- */ // 读取远端已有文件(不存在不算错误,返回 exists:false) function favGetFile(cfg) { return favRequest('GET', favFileUrl(cfg), { 'Authorization': favAuthHeader(cfg), 'Accept': 'text/markdown, text/plain, */*', 'Cache-Control': 'no-cache' }).then(function (res) { // 404:目标文件不存在;409(坚果云等返回 AncestorsNotFound / Conflict):上级目录不存在 // → 与 404 一并按「不存在」处理,交给上游建目录流程,避免被误判成「读取失败」直接中断。 if (res.status === 404 || res.status === 409) { favSnapDrop(cfg); // 远端确实没有这个文件:清掉旧快照,避免下次拿过期内容直写 return { exists: false, content: '', etag: null, status: res.status, ms: res.ms }; } if (res.status === 401 || res.status === 403) throw new Error('WebDAV 认证失败(HTTP ' + res.status + '),请检查账号和密码(坚果云须用「应用密码」)'); if (res.status < 200 || res.status >= 300) throw new Error('读取远端文件失败(HTTP ' + res.status + (res.statusText ? ' ' + res.statusText : '') + ')'); var etag = favExtractEtag(res.headers); favSnapSave(cfg, res.text, etag); // v3.11.0:读一次就留快照——之后再收藏不必先 GET 一遍 return { exists: true, content: res.text, etag: etag, status: res.status, ms: res.ms }; }); } // 整体回写:cond.etag → If-Match(已有文件,乐观锁);cond.create → If-None-Match:*(确认不存在时新建) function favPutFile(cfg, content, cond) { cond = cond || {}; var headers = { 'Authorization': favAuthHeader(cfg), 'Content-Type': 'text/markdown; charset=utf-8' }; if (cond.etag) headers['If-Match'] = cond.etag; else if (cond.create) headers['If-None-Match'] = '*'; return favRequest('PUT', favFileUrl(cfg), headers, content); } // 尽力创建保存路径上的各级目录(已存在 / 服务端不支持时静默跳过,真正的问题交给 PUT 报错) function favEnsureDirs(cfg, force) { var dirsKey = favCfgKey(cfg); // v3.11.0:同一配置下目录只需探测一次(目录已存在时服务端多半回 405),省掉每次收藏的 MKCOL 往返; // 写入真遇到 404/405/409 时会带 force 重探一次,避免「用户手动删了目录后一直 409」。 if (!force && favState.dirsOk[dirsKey]) return Promise.resolve(false); var segs = String(cfg.filePath || '').split('/').filter(function (s) { return !!s; }); // 只有文件名(无目录层级)时确实没有可建目录:这类路径在 favNormalizeFilePath 里已自动补 // 「我的坚果云/」;仍然出现单段路径说明是自建 WebDAV,原样返回、由上层 404 提示兜底。 if (segs.length <= 1) return Promise.resolve(false); var created = false, chain = Promise.resolve(); var makeStep = function (i) { return function () { var dirUrl = favJoinUrl(cfg.url, favEncodePath(segs.slice(0, i + 1).join('/'))); return favRequest('MKCOL', dirUrl, { 'Authorization': favAuthHeader(cfg) }).then(function (res) { if (res.status >= 200 && res.status < 300) created = true; }).catch(function () { /* 目录已存在或服务端不支持 MKCOL,继续 */ }); }; }; for (var i = 0; i < segs.length - 1; i++) chain = chain.then(makeStep(i)); return chain.then(function () { favState.dirsOk[dirsKey] = true; // 探测过就记下;真不可写时 PUT 会报错,届时再 force 重探 return created; }); } /* ---------------- 词块标记 / Markdown 纯函数(无 DOM 依赖,可离线单测) ---------------- */ // 起始标记:排除「文件说明头」里的两处示例占位 // ① `` → 词为空模板((?!\.\.\.) 挡掉) // ② `` → 说明文字里的字面示例(真实词经 encodeURIComponent 后绝不含 < >,用 [^<>] 挡掉) function favMarkerRe() { return //g; } // 词名合法性校验:占位示例、含尖括号的伪词一律不算真实词块 function favIsRealWord(word) { var w = String(word == null ? '' : word).trim(); if (!w) return false; if (w === '...' || w === '…') return false; if (/[<>]/.test(w)) return false; if (/encodeURIComponent/i.test(w)) return false; return true; } function favWordMarker(word) { return FAV_MARK_HEAD + encodeURIComponent(String(word == null ? '' : word).trim()) + FAV_MARK_TAIL; } function favDecodeMarker(raw) { var s = String(raw == null ? '' : raw).trim(); try { return decodeURIComponent(s); } catch (e) { return s; } } // 已收藏词块数:只认真实标记,排除文件说明头里形如 的示例占位 function favCountBlocks(text) { var m = String(text == null ? '' : text).match(favMarkerRe()); return m ? m.length : 0; } // 定位某个词的词块区间(起始标记 → 结束标记,含两端) function favFindBlockRange(text, word) { var t = String(text == null ? '' : text); var target = String(word == null ? '' : word).trim(); if (!target) return null; var re = favMarkerRe(), m; while ((m = re.exec(t)) !== null) { if (favDecodeMarker(m[1]) !== target) continue; var after = m.index + m[0].length; var endIdx = t.indexOf(FAV_BLOCK_END, after); if (endIdx >= 0) return { start: m.index, end: endIdx + FAV_BLOCK_END.length, noEnd: false }; // 结束标记被人为删掉时,退化为「删到下一个词块起始处 / 文件末尾」,避免误删后续词块 var nextStart = t.indexOf(FAV_MARK_HEAD, after); return { start: m.index, end: nextStart === -1 ? t.length : nextStart, noEnd: true }; } return null; } function favFlatten(s) { // 单行化 + 拆掉可能被误认成隐藏标记的 "