// ==UserScript== // @name 粉笔题库·悬浮查词(成语/词语) // @namespace local.doubao.fenbi-hover-word // @version 3.18.3 // @description v3.15.0 收藏数据格式从 Markdown 词块改为整份 JSON 文档仅题目区域触发,不影响答题。 // @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/index.json', // v3.16.0:一键收藏默认保存路径 favNoteEnabled: true, // v3.17.0:是否启用收藏笔记功能 favNotePlaceholder: '请输入笔记(支持多行,收藏时自动保存)', // v3.17.0:笔记输入框占位提示(相对 WebDAV 根目录),旧 .md 路径启动时自动迁移 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-sec-note{margin:2px 0;}', '.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-card-note-icon{font-size:13px;margin-right:4px;cursor:pointer}', '.fw-book-card-note-icon:hover{transform:scale(1.15)}', '.fw-book-btn-unfav.is-loading{pointer-events:none;} .fw-book-btn-unfav.is-loading span{opacity:.5;}', /* v3.18.0:笔记编辑区 */ '.fw-book-card-note{margin:4px 0;display:flex;align-items:flex-start;gap:6px;}', '.fw-book-note-edit-btn{background:none;border:1px solid #e5e6eb;border-radius:4px;padding:3px 5px;cursor:pointer;color:#86909c;transition:all .15s;display:flex;align-items:center;flex-shrink:0;margin-top:1px;}', '.fw-book-note-edit-btn:hover{border-color:#c9d8ff;background:#f2f6ff;color:#165dff;}', '.fw-book-note-edit-btn.active{border-color:#165dff;background:#e6efff;color:#165dff;}', '.fw-book-note-display{flex:1;min-height:0;color:#333;font-size:13px;line-height:1.5;}', '.fw-book-note-display:empty::after{content:"未添加笔记";color:#c0c4cc;font-style:italic;}', '.fw-book-note-editor{display:flex;gap:6px;margin-top:4px;flex:1;flex-wrap:wrap;}', '.fw-book-note-ta{flex:1;min-width:0;min-height:48px;max-height:120px;border:1px solid #e5e6eb;border-radius:6px;padding:6px 8px;font-size:13px;font-family:inherit;line-height:1.5;resize:vertical;color:#333;background:#fff;} .fw-book-note-ta:focus{outline:none;border-color:#c9d8ff;box-shadow:0 0 0 2px rgba(22,93,255,.08);}', '.fw-book-note-save-btn{background:#165dff;color:#fff;border:none;border-radius:6px;padding:5px 12px;font-size:12px;cursor:pointer;flex-shrink:0;transition:all .15s;} .fw-book-note-save-btn:hover{background:#0f40c9;}', /* 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-note-editor{background:#fff;border-radius:14px;padding:16px;margin:auto;min-width:480px;max-width:600px;max-height:calc(100vh - 80px);overflow:auto;box-shadow:0 20px 60px rgba(0,0,0,.28);font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;}', '.fw-wd-note-editor-head{display:flex;align-items:center;justify-content:space-between;padding:8px 0 12px;border-bottom:1px solid #f0f2f5}', '.fw-wd-note-editor-head h3{margin:0;font-size:16px;color:#1f2329}', '.fw-wd-note-editor-body{padding:12px 0}', '.fw-note-editor-row{margin-bottom:12px;padding:8px 0;border-bottom:1px solid #f0f2f5}', '.fw-note-editor-row b{display:block;margin-bottom:4px}', '.fw-note-editor-textarea{width:100%;min-height:60px;border:1px solid #e5e6eb;border-radius:7px;padding:8px;font-family:inherit;font-size:13px;resize:vertical;box-sizing:border-box}', '.fw-note-editor-textarea:focus{outline:none;border-color:#165dff}', '.fw-note-editor-save-btn{margin-top:4px;padding:4px 12px;border-radius:6px;border:1px solid #e5e6eb;background:#fff;color:#165dff;cursor:pointer}', '.fw-note-editor-save-btn:hover{background:#f2f6ff}', '.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;}', /* v3.16.0:面板工具按钮区 + WebDAV 配置状态行 + 导入确认弹窗 */ '.fw-book-tools{display:flex;flex-wrap:wrap;gap:6px;align-items:center;justify-content:flex-end;}', '.fw-book-tool{border:1px solid #d8dce2;background:#fff;color:#1f2329;border-radius:6px;padding:5px 10px;font-size:12px;line-height:1.4;cursor:pointer;}', '.fw-book-tool:hover{border-color:#3478f6;color:#3478f6;}', '.fw-book-tool.fw-book-close{font-size:16px;padding:2px 8px;}', '.fw-book-cfgline{font-size:12px;color:#6b7280;padding:7px 16px;border-bottom:1px solid #f0f2f5;background:#fafbfc;flex-wrap:wrap;}', '.fw-import-overlay{z-index:2147483646;}', '.fw-import-panel{width:520px;max-width:calc(100vw - 40px);max-height:calc(100vh - 60px);overflow:auto;}', '.fw-import-panel h3{margin:0 0 10px;font-size:15px;color:#1f2329;}', '.fw-import-line{font-size:12px;color:#4b5563;margin:4px 0;padding-left:8px;}', '.fw-import-line b{color:#1f2329;}', '.fw-import-acts{display:flex;gap:8px;justify-content:flex-end;margin-top:14px;}', '.fw-import-btn{border:1px solid #d8dce2;background:#fff;color:#1f2329;border-radius:6px;padding:6px 14px;font-size:13px;cursor:pointer;}', '.fw-import-btn-main{border-color:#3478f6;background:#3478f6;color:#fff;}' ].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 = '
点击词条可编辑笔记,编辑后自动保存到收藏并同步远端。
'; if (doc.words.length === 0) { html += '当前没有收藏词条
'; } doc.words.forEach(function(w, idx) { html += '[\s\S]*?<\/pre>/g, '') +
'' + esc(text) + '
';
bindPanelButtons();
showToast('诊断信息已显示在面板底部(同时输出到控制台)', 3000);
};
var drawBody = function (cardsHtml) {
bodyEl.innerHTML = (noteHtml ? '' + noteHtml + '' : '') +
(cardsHtml || '');
bindPanelButtons();
};
var renderHint = function (title, lines) {
noteHtml = ''; noteWarn = false;
var html = '' + esc(title) + '
';
for (var i = 0; i < lines.length; i++) html += '' + esc(lines[i]) + '
';
html += '' +
'
';
bodyEl.innerHTML = html;
bindPanelButtons();
};
/* 开始加载远端文件(v3.11.0:先用本机快照秒开,再向坚果云刷新) */
var loadingGuard = null;
var loadSeq = 0; // 每次加载一个序号,丢弃迟到 / 过期回调
var startLoading = function () {
var mySeq = ++loadSeq;
if (loadingGuard) { clearTimeout(loadingGuard); loadingGuard = null; }
var cfgNow = favGetConfig(); // v3.11.0:每次重新读配置——改过设置后无需关面板重开
var cached = favSnapGet(cfgNow);
var cacheHtml = '', cacheCount = 0;
if (cached && cached.content) {
try {
var cb = bookParseBlocks(cached.content);
for (var ci = 0; ci < cb.length; ci++) cacheHtml += bookRenderCard(cb[ci]);
cacheCount = cb.length;
} catch (e) { cacheHtml = ''; cacheCount = 0; }
}
noteHtml = ''; noteWarn = false;
if (cacheHtml) {
noteHtml = '显示的是本机缓存(上次同步 ' + esc(String(cached.at || '未知时间')) + '),正在向坚果云刷新…';
var countEl0 = overlay.querySelector('.fw-book-count');
if (countEl0) countEl0.textContent = '(' + cacheCount + ' 个词 · 点击卡片可展开全部字段)';
drawBody(cacheHtml);
} else {
bodyEl.innerHTML = '正在加载收藏数据…
';
}
loadingGuard = setTimeout(function () {
loadingGuard = null;
if (mySeq !== loadSeq) return;
if (cacheHtml) {
noteHtml = '坚果云在 ' + Math.round(BOOK_LOAD_TIMEOUT_MS / 1000) + ' 秒内没有响应(下面是本机缓存,可点「重新加载」再试)。' +
'' +
'';
noteWarn = true;
drawBody(cacheHtml);
return;
}
renderHint('收藏数据加载超时', ['坚果云在 ' + Math.round(BOOK_LOAD_TIMEOUT_MS / 1000) + ' 秒内没有响应,可能是网络不通或被代理拦截。']);
}, BOOK_LOAD_TIMEOUT_MS);
var done = function (draw) {
if (mySeq !== loadSeq) return; // 过期回调:直接丢弃
if (loadingGuard) { clearTimeout(loadingGuard); loadingGuard = null; }
try { draw(); }
catch (err) {
if (cacheHtml) {
noteHtml = '渲染出错:' + esc(favErrText(err)) + '(下面是本机缓存)';
noteWarn = true;
drawBody(cacheHtml);
} else {
renderHint('收藏数据渲染出错', [String(favErrText(err))]);
}
}
};
favGetFile(cfgNow).then(function (file) {
done(function () {
var countEl = overlay.querySelector('.fw-book-count');
if (!file.exists || !file.content) {
bookLastContent = '';
if (countEl) countEl.textContent = '';
renderHint('收藏数据暂不可用', [
'坚果云上没有找到收藏文件(HTTP ' + (file.status || 404) + '):可能还没有成功收藏过,或保存路径不对。',
'当前保存路径:' + cfgNow.filePath,
'最近一次请求:' + (favReqBrief() || '无')
]);
return;
}
var blocks = bookParseBlocks(file.content);
bookLastContent = file.content;
noteHtml = ''; noteWarn = false;
if (!blocks.length) {
if (countEl) countEl.textContent = '';
renderHint('还没有收藏过任何词', [
'把鼠标移到成语 / 词语上,点「收藏」即可添加到这里。',
'最近一次请求:' + (favReqBrief() || '无')
]);
return;
}
if (countEl) countEl.textContent = '(' + blocks.length + ' 个词 · 点击卡片可展开全部字段)';
var html = '';
for (var i = 0; i < blocks.length; i++) {
html += bookRenderCard(blocks[i]);
}
drawBody(html);
bookBindUnfav(bodyEl); // 绑定取消收藏按钮
bookBindCards(bodyEl); // 绑定「点击卡片展开 / 收起」
favLog('收藏面板已加载', blocks.length + ' 个词条 · 最近一次请求 ' + (favReqBrief() || '无'));
});
}, function (e) {
done(function () {
var brief = favReqBrief();
if (cacheHtml) {
noteHtml = '云端刷新失败:' + esc(favErrText(e)) + (brief ? '(最近一次请求 ' + esc(brief) + ')' : '') +
' —— 下面是本机缓存。' +
'';
noteWarn = true;
drawBody(cacheHtml);
bookBindUnfav(bodyEl);
bookBindCards(bodyEl);
return;
}
renderHint('收藏数据暂不可用', [
'网络错误:' + favErrText(e),
'最近一次请求:' + (brief || '无'),
'排查顺序:① 点「诊断信息」看状态码与耗时;② 401/403 = 账号或应用密码不对;③ 404/405 = 保存路径不可写;④ 超时 = 网络或代理拦截了 dav.jianguoyun.com。'
]);
});
});
};
overlay.__fwBookReload = startLoading;
startLoading();
return overlay;
}
/* Esc 关闭面板:用同一个函数引用注册 / 注销,避免反复开关面板累积监听器 */
function onBookPanelKey(e) {
if (e && e.key === 'Escape') closeBookPanel();
}
function closeBookPanel() {
if (!bookPanelEl) return;
var el = bookPanelEl;
bookPanelEl = null;
try { if (el.parentNode && el.parentNode.removeChild) el.parentNode.removeChild(el); } catch (e) { /* ignore */ }
document.removeEventListener('keydown', onBookPanelKey, true);
}
/* ——— 收藏 / 取消收藏的统一入口(悬浮窗左侧栏按钮、中键固定栏按钮都走这里) ——— */
function favToggle(word, data, btn) {
var w = String(word == null ? '' : word).trim();
if (!w) { showToast('没取到要收藏的词:把鼠标移到成语 / 词语上再点「收藏」', 3200); return; }
if (!CONFIG.favEnabled) { showToast('一键收藏未启用(CONFIG.favEnabled = false)', 3000); return; }
if (!favIsConfigured()) { favPromptConfig(); return; }
var d = data || favDataFor(w);
if (!d || d.source === 'loading') { showToast('「' + w + '」的释义还在查询中,稍等一下再点「收藏」', 3200); return; }
if (d.source === 'none' || (!d.explanation && !d.usage && !d.derivation && !d.example)) {
showToast('暂不可收藏:还没取到「' + w + '」的释义(词库未收录 / 在线查询未返回内容)', 3600); return;
}
if (favIsFavorited(w)) {
favRunRemove(w, btn);
} else {
favShowNoteEditor(w, d, 'append');
}
}
/* v3.17.0: 笔记编辑面板 */
function favShowNoteEditor(word, data, mode, existingCard) {
var host = document.body || document.documentElement;
if (!host || !host.appendChild) { favWarn('笔记面板打不开'); return; }
var overlay = document.createElement('div');
overlay.className = 'fw-wd-overlay';
overlay.innerHTML =
'' +
'' + esc(word) + '
' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'';
host.appendChild(overlay);
var ta = overlay.querySelector('.fw-wd-note-textarea');
overlay.querySelector('.fw-wd-close').addEventListener('click', function(){if(overlay.parentNode)overlay.parentNode.removeChild(overlay);});
overlay.querySelector('#fw-note-cancel').addEventListener('click', function(){if(overlay.parentNode)overlay.parentNode.removeChild(overlay);});
overlay.querySelector('#fw-note-save').addEventListener('click', function(){
var note = ta.value;
if(overlay.parentNode) overlay.parentNode.removeChild(overlay);
if(mode==='edit'){ favWriteNoteToCard(word,note); }
else { var card=favBuildCard(word,data); card.entry.note=note; favRunAppendWithNote(word,card,note); }
});
}
function favWriteNoteToCard(word, note) {
var cfg = favGetConfig();
favUpdateNoteFast(cfg, word, note).then(function(r){
showToast('笔记已保存至「' + word + '」,云端共 ' + r.count + ' 个词',3200);
// 同步刷新收藏面板数据源,让「我的收藏-笔记」展示区保持一致
if (r && r.text) bookLastContent = r.text;
var body = bookPanelEl ? bookPanelEl.querySelector('.fw-book-body') : null;
if (body) bookRefreshCards(body);
}, function(e){ showToast('笔记保存失败:'+favErrText(e),5000); });
}
function favRunAppendWithNote(word, card, note) {
var cfg = favGetConfig();
card.entry.note = note;
favMarkLocal(word); favRefreshButtons(word);
showToast('已收藏「' + word + '」,正在同步到坚果云…', 2500);
favLog('开始收藏(含笔记)', word + ' -> ' + cfg.filePath);
favAppendFast(cfg, card).then(function(r){
if(r.duplicated) showToast('「' + word + '」已在收藏中,未重复写入',3200);
else showToast('已收藏「' + word + '」,云端共 ' + r.count + ' 个词',3200);
try{ favLocalAppendEntry(card.entry); } catch(e){ favWarn('本地暂存写入失败', favErrText(e)); }
}, function(e){
favUnmarkLocal(word); favRefreshButtons(word);
showToast('收藏失败(已撤销本机标记):'+favErrText(e),7000,'去配置',openFavWebdavPanel);
});
}
function favRunAppend(word, data, btn) {
var cfg = favGetConfig();
var card;
try { card = favBuildCard(word, data); } catch (e) { showToast('收藏失败:' + favErrText(e), 4000); return; }
// v3.11.0:先本地落地 + 按钮即时反馈,再后台同步——点下去立刻有响应,不再干等网络往返
favMarkLocal(word);
favRefreshButtons(word);
if (btn) favApplyBtnState(btn, word, true);
showToast('已收藏「' + word + '」,正在同步到坚果云…', 2500);
favLog('开始收藏', word + ' → ' + cfg.filePath + '(本机快照=' + (favSnapGet(cfg) ? '有,走直写' : '无,走完整读写') + ')');
favAppendFast(cfg, card).then(function (r) {
if (r.duplicated) showToast('「' + word + '」已在收藏中(远端文件里已存在,未重复写入)', 3200);
else showToast('已收藏「' + word + '」' + (r.created ? '(已创建收藏文件)' : '') + ',云端共 ' + r.count + ' 个词' +
(r.ms ? ',本次写入 ' + (r.ms / 1000).toFixed(1) + 's' : ''), 3200);
try { favLocalAppendEntry(card.entry); } catch (e2) { favWarn('本地暂存写入失败', favErrText(e2)); } // v3.16.0:收藏成功后并入本地暂存并落盘
favLog('收藏完成', r);
}, function (e) {
// 同步失败:回滚本机标记与按钮,避免出现「显示已收藏、云端却没有」的假象
favUnmarkLocal(word);
favRefreshButtons(word);
if (btn) favApplyBtnState(btn, word, false);
favWarn('收藏失败(已回滚本机标记)', favErrText(e));
showToast('收藏失败(已撤销本机标记):' + favErrText(e), 7000, '去配置', openFavWebdavPanel);
});
}
function favRunRemove(word, btn) {
var cfg = favGetConfig();
// v3.11.0:同样先本地即时反馈,再后台同步
favUnmarkLocal(word);
favRefreshButtons(word);
if (btn) favApplyBtnState(btn, word, false);
showToast('已取消收藏「' + word + '」,正在同步到坚果云…', 2500);
favLog('开始取消收藏', word + ' → ' + cfg.filePath + '(本机快照=' + (favSnapGet(cfg) ? '有,走直写' : '无,走完整读写') + ')');
favRemoveFast(cfg, word).then(function (r) {
if (r.noFile) showToast('远端收藏文件还不存在,已取消「' + word + '」的本机收藏标记', 3200);
else if (!r.removed) showToast('远端收藏文件里没有「' + word + '」这个词条,本机标记已清掉', 3200);
else showToast('已取消收藏「' + word + '」,云端剩 ' + r.count + ' 个词', 3200);
try { favLocalTombstone(word); } catch (e2) { favWarn('本地暂存墓碑写入失败', favErrText(e2)); } // v3.16.0:取消收藏后记删除墓碑并落盘
favLog('取消收藏完成', r);
}, function (e) {
favMarkLocal(word);
favRefreshButtons(word);
if (btn) favApplyBtnState(btn, word, true);
favWarn('取消收藏失败(已恢复本机标记)', favErrText(e));
showToast('取消收藏失败(本机标记已恢复):' + favErrText(e), 7000, '去配置', openFavWebdavPanel);
});
}
/* ---------------- WebDAV 配置面板(油猴菜单 / 控制台 / 未配置引导 三处入口) ---------------- */
function closeFavWebdavPanel() {
if (!favPanelEl) return;
var el = favPanelEl;
favPanelEl = null;
try { if (el.__fwKeydown && document.removeEventListener) document.removeEventListener('keydown', el.__fwKeydown, true); } catch (e) { /* ignore */ }
try { if (el.parentNode && el.parentNode.removeChild) el.parentNode.removeChild(el); } catch (e) { /* ignore */ }
}
function openFavWebdavPanel() {
if (favPanelEl) return favPanelEl; // 已经开着:聚焦即可
var host = document.body || document.documentElement;
if (!host || !host.appendChild) { favWarn('配置面板打不开:页面尚未就绪'); return null; }
var cfg = favGetConfig();
var overlay = document.createElement('div');
overlay.className = 'fw-wd-overlay';
overlay.innerHTML =
'' +
'WebDAV 收藏设置(坚果云)
' +
'' +
'' +
'' +
'' +
'坚果云固定填 https://dav.jianguoyun.com/dav/(到 WebDAV 根路径这一级即可,末尾带不带「/」都行)。' +
'' +
'' +
'坚果云的账号是「注册邮箱」(不是昵称 / 手机号)。' +
'' +
'' +
'必须用「应用密码」:坚果云网页端 → 账户信息 → 安全选项 → 添加应用,生成后粘贴到此处;直接填登录密码会报 HTTP 401。' +
'' +
'' +
'可留空(默认 ' + FAV_FILE_DEFAULT + ')。只填裸文件名时会自动补上前缀「' + FAV_JGY_ROOT + '/」——坚果云 dav/ 是虚拟服务根、根目录不可写。' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'收藏时笔记输入框的占位文本,可留空使用默认值。' +
'' +
'' +
'坚果云要点① 地址固定 https://dav.jianguoyun.com/dav/;② 账号填注册邮箱;③ 密码必须用「应用密码」(填登录密码会 401)。' +
'连接失败怎么查401 / 403 → 账号或应用密码不对;404 / 405 → 地址或保存路径不对(根目录不可写);409 → 上级目录不存在(脚本会自动尝试创建);412 / 428 → 远端文件正被其它端改写,稍后重试。' +
'隐私提示地址 / 账号 / 密码只保存在本机(油猴存储或浏览器 localStorage,密码为明文),仅发送给你填写的 WebDAV 服务器;公用电脑请谨慎保存。' +
'' +
'' +
'';
host.appendChild(overlay);
var urlEl = overlay.querySelector('.fw-wd-url');
var userEl = overlay.querySelector('.fw-wd-user');
var passEl = overlay.querySelector('.fw-wd-pass');
var pathEl = overlay.querySelector('.fw-wd-path');
var resultEl = overlay.querySelector('.fw-wd-result');
var saveBtn = overlay.querySelector('.fw-wd-save');
var testBtn = overlay.querySelector('.fw-wd-test');
var pullBtn = overlay.querySelector('.fw-wd-pull');
var diagBtn = overlay.querySelector('.fw-wd-diag');
var jgyBtn = overlay.querySelector('.fw-wd-jgy');
var clearBtn = overlay.querySelector('.fw-wd-clear');
var closeBtn = overlay.querySelector('.fw-wd-close');
var noteEnabledCb = overlay.querySelector('.fw-wd-note-enabled');
var notePlaceholderInput = overlay.querySelector('.fw-wd-note-placeholder');
if (noteEnabledCb) {
noteEnabledCb.checked = CONFIG.favNoteEnabled;
noteEnabledCb.addEventListener('change', function(){
CONFIG.favNoteEnabled = this.checked;
showToast('笔记功能已' + (this.checked?'启用':'关闭'),1500);
});
}
if (notePlaceholderInput) {
notePlaceholderInput.value = CONFIG.favNotePlaceholder || '';
notePlaceholderInput.addEventListener('change', function(){
CONFIG.favNotePlaceholder = this.value || '';
showToast('笔记占位提示已更新',1500);
});
}
if (!urlEl || !userEl || !passEl || !pathEl || !resultEl || !saveBtn || !testBtn || !pullBtn || !diagBtn || !jgyBtn || !clearBtn || !closeBtn) {
favWarn('配置面板结构异常,已放弃打开');
try { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); } catch (e) { /* ignore */ }
return null;
}
urlEl.value = cfg.url || '';
userEl.value = cfg.username || '';
passEl.value = cfg.password || '';
pathEl.value = cfg.filePath || FAV_FILE_DEFAULT;
var readForm = function () {
return { url: urlEl.value, username: userEl.value, password: passEl.value, filePath: pathEl.value };
};
var showResult = function (html, kind) {
resultEl.className = 'fw-wd-result is-show is-' + (kind || 'warn');
resultEl.innerHTML = html;
};
closeBtn.addEventListener('click', closeFavWebdavPanel);
saveBtn.addEventListener('click', function () {
var ok = favSaveConfig(readForm());
var saved = favGetConfig();
if (!saved.url || !saved.username) { showResult('请至少填写「服务器地址」与「账号」,再保存。', 'err'); return; }
showResult(ok ? '配置已保存,正在同步(读远端→并集合并→回写)…' : '配置已在本次会话生效,但写入本机存储失败(隐私模式 / 存储被禁用)。', ok ? 'ok' : 'warn');
favSyncPush('manual').then(function (r) {
if (r && r.skipped) return;
showResult('同步完成:云端 ' + r.count + ' 个词' + (r.created ? '(远端收藏文件已新建)' : '') + '。
地址 ' + esc(saved.url) + ' · 路径 ' + esc(saved.filePath), 'ok');
}, function (e) {
showResult('同步失败:' + esc(favErrText(e)), 'err');
});
});
testBtn.addEventListener('click', function () {
var testCfg = favNormalizeConfig(readForm());
if (!testCfg.url || !testCfg.username) { showResult('请先填写「服务器地址」与「账号」,再测试连接。', 'err'); return; }
favSaveConfig(testCfg); // 顺手保存,避免测完还要再点一次「保存」
var oldText = testBtn.textContent;
testBtn.disabled = true;
testBtn.textContent = '测试中…';
showResult('正在连接 ' + esc(testCfg.url) + ' …', 'warn');
favTestConnection(testCfg).then(function (r) {
showResult('连接成功:HTTP ' + r.status + ',通道 ' + esc(r.via) + (r.dav ? ',DAV: ' + esc(r.dav) : '') + '。
' +
'目标文件 ' + esc(r.filePath) + ':' + (r.fileExists
? ('已存在,其中词条 ' + r.blockCount + ' 个')
: '不存在(首次收藏时会自动创建)'), 'ok');
}, function (e) {
favWarn('WebDAV 连接测试失败', favErrText(e));
showResult('连接失败:' + esc(favErrText(e)) +
(favErrCode(e) ? '
排查建议:' + esc(favStatusHint(favErrCode(e), testCfg)) : ''), 'err');
}).then(function () {
testBtn.disabled = false;
testBtn.textContent = oldText;
});
});
jgyBtn.addEventListener('click', function () {
urlEl.value = 'https://dav.jianguoyun.com/dav/';
showResult('已填入坚果云 WebDAV 地址(https://dav.jianguoyun.com/dav/)。
下一步:账号填「注册邮箱」,密码填「应用密码」——坚果云网页端 → 账户信息 → 安全选项 → 添加应用,生成后粘贴到密码框(填登录密码会 401)。', 'ok');
});
pullBtn.addEventListener('click', function () {
var pullCfg = favNormalizeConfig(readForm());
if (!pullCfg.url || !pullCfg.username) { showResult('请先填写「服务器地址」与「账号」,再拉取。', 'err'); return; }
favSaveConfig(pullCfg);
var oldText = pullBtn.textContent;
pullBtn.disabled = true;
pullBtn.textContent = '拉取中…';
showResult('正在从远端拉取并合并到本地暂存…', 'warn');
favSyncPull(true).then(function (r) {
showResult('拉取完成:本地暂存现共 ' + r.count + ' 个词(含本次新增 ' + r.added + ' 个)。', 'ok');
}, function (e) {
showResult('拉取失败:' + esc(favErrText(e)), 'err');
}).then(function () {
pullBtn.disabled = false;
pullBtn.textContent = oldText;
});
});
diagBtn.addEventListener('click', function () {
var text = favDiagText();
try { favLog('收藏诊断信息', '\n' + text); } catch (e) { /* ignore */ }
showResult('' + esc(text) + '
', 'warn');
});
clearBtn.addEventListener('click', function () {
var yes = true;
try {
if (typeof window.confirm === 'function') yes = window.confirm('确认清空本机保存的 WebDAV 配置?\n(远端文件里已收藏的词不受影响)');
} catch (e) { yes = true; }
if (!yes) return;
favClearConfig();
urlEl.value = '';
userEl.value = '';
passEl.value = '';
pathEl.value = FAV_FILE_DEFAULT;
showResult('已清空本机保存的 WebDAV 配置(远端已收藏的文件不受影响)。', 'warn');
});
var onKey = function (e) {
if (e.key !== 'Escape') return;
try { e.stopPropagation(); } catch (err) { /* ignore */ }
closeFavWebdavPanel();
};
overlay.__fwKeydown = onKey;
try { if (document.addEventListener) document.addEventListener('keydown', onKey, true); } catch (e) { /* ignore */ }
overlay.addEventListener('click', function (e) { if (e.target === overlay) closeFavWebdavPanel(); });
try { urlEl.focus(); } catch (e) { /* 聚焦失败无碍 */ }
favPanelEl = overlay;
favLog('已打开 WebDAV 收藏设置面板', favIsConfigured() ? '当前状态:已配置' : '当前状态:未配置');
return overlay;
}
/* =====================================================
* v3.16.0 本地浏览器暂存 + 整体同步 + 导入导出(对齐错题本脚本)
* 1) 本地暂存:新增存储键 FAV_LOCAL_KEY,持久化整份 JSON 文档(words + deleted 墓碑);
* 启动加载、每次收藏 / 取消收藏 / 导入后落盘,离线也能保留收藏。
* 2) 整体同步:favSyncPush(读远端→双向并集合并→整体回写,ETag 乐观锁 + 412 重试)、
* favSyncPull(远端并入本地暂存并落盘)、favScheduleUpload(合并式串行调度)。
* 3) 导入导出:exportJson / validateDoc / buildImportPreview / confirmImportDoc /
* doImportDoc / importJson;导出附带统计信息、不含 WebDAV 密码。
* ===================================================== */
/* ---------- 本地暂存 ---------- */
function favEmptyLocal() {
return favBuildDoc(null);
}
function favLoadLocal() {
var d = null;
try { d = JSON.parse(favStoreGet(FAV_LOCAL_KEY, 'null')); } catch (e) { d = null; }
if (!d || typeof d !== 'object' || Array.isArray(d)) d = null;
if (d && !Array.isArray(d.words)) d = null; // 旧版若把 .md 文本塞进暂存键:丢弃回空文档
favLocalDoc = d || favEmptyLocal();
if (!favLocalDoc.deleted || typeof favLocalDoc.deleted !== 'object' || Array.isArray(favLocalDoc.deleted)) favLocalDoc.deleted = {};
favPersistLocal();
favLog('本地暂存已加载', favCountWords(favLocalDoc) + ' 个词 / ' + Object.keys(favLocalDoc.deleted).length + ' 条墓碑');
return favLocalDoc;
}
function favPersistLocal() {
var ok = false;
try { ok = favStoreSet(FAV_LOCAL_KEY, JSON.stringify(favBuildDoc(favLocalDoc))); } catch (e) { ok = false; }
return ok;
}
// 把一份文档并入本地暂存(并集合并 + 落盘);返回 favMergeDocs 的 { doc, added }
function favLocalMergeDoc(doc) {
if (!doc || typeof doc !== 'object' || !Array.isArray(doc.words)) doc = favEmptyDoc();
var res = favMergeDocs(doc, favLocalDoc);
favLocalDoc = res.doc;
favPersistLocal();
return res;
}
// 收藏成功后并入本地暂存(快路径 / 安全路径通用)
function favLocalAppendEntry(entry) {
if (!entry || typeof entry !== 'object' || !favNormalizeWord(entry.word)) return { added: 0 };
var patch = favEmptyDoc();
patch.words = [entry];
var res = favMergeDocs(patch, favLocalDoc);
favLocalDoc = res.doc;
favPersistLocal();
return res;
}
// 取消收藏成功后写入删除墓碑(时间只增不减,防远端拉回后复活)
function favLocalTombstone(word) {
var res = favTombstoneEntry(favSerializeDoc(favLocalDoc), word);
favLocalDoc = res.doc;
favPersistLocal();
return res;
}
/* ---------- 整体同步(对齐错题本 davPush / davPull / scheduleUpload) ---------- */
function favPutError(status, base) {
if (status === 401 || status === 403) return new Error('WebDAV 认证失败(HTTP ' + status + '),请检查账号和密码(坚果云须用「应用密码」)');
if (status === 404 || status === 405) return new Error('保存路径不可写(HTTP ' + status + '):' + base + ',请检查「保存路径」是否包含可写目录');
return new Error(base + '(HTTP ' + status + ')');
}
// 读远端 → 双向并集合并(含本地暂存与墓碑)→ 整体回写。ETag 乐观锁,412/428 冲突重读重试。
function favSyncPush(trigger) {
if (!favIsConfigured()) { favPromptConfig(); return Promise.reject(new Error('未配置 WebDAV')); }
if (favSyncBusy) { favSyncPending = true; return Promise.resolve({ skipped: true, reason: 'busy' }); }
favSyncBusy = true;
var cfg = favGetConfig();
favLog('开始同步', (trigger || 'auto') + ' → ' + cfg.filePath + '(本地暂存 ' + favCountWords(favLocalDoc) + ' 词 / ' + Object.keys(favLocalDoc.deleted).length + ' 墓碑)');
var attempt = function (n) {
return favGetFile(cfg).then(function (file) {
var remoteDoc = file.exists ? (favParseDoc(file.content) || favEmptyDoc()) : favEmptyDoc();
var merged = favMergeDocs(remoteDoc, favLocalDoc); // 远端 ⊕ 本地(双向并集,墓碑防复活)
var count = favCountWords(merged.doc);
if (!file.exists) {
// 远端尚不存在:确保目录后新建(If-None-Match:*)
return favEnsureDirs(cfg).then(function () {
return favPutFile(cfg, favSerializeDoc(merged.doc), { create: true });
}).then(function (res) {
if (res.status >= 200 && res.status < 300) { finishPush(cfg, merged, true, res); return { count: count, created: true, via: (res.via || '') }; }
if (res.status === 412) throw new Error('创建冲突(HTTP 412):远端文件刚刚被其它端创建,请稍后重试');
throw favPutError(res.status, '写入远端文件失败');
});
}
var put = (file.etag
? favPutFile(cfg, favSerializeDoc(merged.doc), { etag: file.etag })
: favPutFile(cfg, favSerializeDoc(merged.doc), {})); // 无 ETag:退化为最后写入者胜
return put.then(function (res) {
if (res.status >= 200 && res.status < 300) { finishPush(cfg, merged, false, res); return { count: count, created: false, via: (res.via || '') + (file.etag ? '' : '(无ETag)') }; }
if (res.status === 412 || res.status === 428) {
if (n < FAV_MAX_RETRY) { favWarn('同步冲突(HTTP ' + res.status + '),重读重试', (n + 1) + '/' + FAV_MAX_RETRY); return attempt(n + 1); }
throw new Error('同步冲突(HTTP ' + res.status + '):远端文件被其它端改写,重试 ' + FAV_MAX_RETRY + ' 次仍冲突,请稍后再试');
}
throw favPutError(res.status, '写入远端文件失败');
});
});
};
var finishPush = function (cfg, merged, created, res) {
favLocalDoc = merged.doc;
favPersistLocal();
favSnapSave(cfg, favSerializeDoc(merged.doc), favExtractEtag(res.headers) || null);
favLog('同步完成', (created ? '新建远端文件,' : '') + '云端 ' + favCountWords(merged.doc) + ' 个词 · 墓碑 ' + Object.keys(merged.doc.deleted).length + ' 条 · ' + (res.ms || 0) + 'ms');
};
return attempt(0).then(function (r) {
favSyncBusy = false;
if (favSyncPending) { favSyncPending = false; favScheduleUpload(); }
return r;
}, function (e) {
favSyncBusy = false;
throw e;
});
}
// 从远端拉取:读取远端文档并入本地暂存(不动远端);之后普通收藏会走快路径并集合并。
function favSyncPull(silent) {
if (!favIsConfigured()) { if (!silent) favPromptConfig(); return Promise.reject(new Error('未配置 WebDAV')); }
var cfg = favGetConfig();
favLog('开始拉取', cfg.filePath);
return favGetFile(cfg).then(function (file) {
if (!file.exists) {
if (!silent) showToast('远端还没有收藏文件(HTTP ' + (file.status || 404) + '),没有可拉取的内容', 4000);
return { count: favCountWords(favLocalDoc), added: 0 };
}
var remoteDoc = favParseDoc(file.content);
if (!remoteDoc) throw new Error('远端文件不是有效的 JSON 收藏文档(可能是旧版 Markdown,请先备份后手动转换或删除重建)');
var merged = favMergeDocs(remoteDoc, favLocalDoc);
favLocalDoc = merged.doc;
favPersistLocal();
favLog('拉取完成', '本地暂存现为 ' + favCountWords(favLocalDoc) + ' 词 / ' + Object.keys(favLocalDoc.deleted).length + ' 墓碑');
if (!silent) showToast('已从远端拉取并合并:本地暂存现共 ' + favCountWords(favLocalDoc) + ' 个词', 4000);
return { count: favCountWords(favLocalDoc), added: merged.added };
});
}
// 合并式串行调度:已在上传中则标记待续,等当前完成后自动再跑一轮(本地暂存已在每次操作后落盘,不会丢)
function favScheduleUpload() {
if (!favIsConfigured()) return Promise.resolve({ skipped: true });
if (favSyncBusy) { favSyncPending = true; return Promise.resolve({ skipped: true, reason: 'busy' }); }
return favSyncPush('auto');
}
/* ---------- 导入导出(对齐错题本 exportJson / validateDoc / buildImportPreview / confirmImportDoc / doImportDoc / importJson) ---------- */
function favStamp() {
var d = new Date();
function p(n) { return (n < 10 ? '0' : '') + n; }
return d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '-' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds());
}
function exportJson() {
var content;
try {
var doc = favBuildDoc(favLocalDoc);
var stat = {
app: 'fenbi-word-hover',
words: doc.words.length,
deleted: Object.keys(doc.deleted).length,
exportedAt: new Date().toISOString(),
note: '本文件不含 WebDAV 密码等敏感配置'
};
content = JSON.stringify(Object.assign({}, doc, { stat: stat }), null, 2);
} catch (e) { showToast('导出失败:' + favErrText(e), 4000); return; }
var filename = 'fenbi-word-favorites-' + favStamp() + '.json';
var usedGm = false;
try {
if (typeof GM_download === 'function') { // 油猴环境优先:保存对话框
var blob = new Blob([content], { type: 'application/json' });
var url = URL.createObjectURL(blob);
GM_download({ url: url, name: filename, saveAs: true });
usedGm = true;
setTimeout(function () { try { URL.revokeObjectURL(url); } catch (e) {} }, 30000);
}
} catch (e) { usedGm = false; }
if (usedGm) return;
try { // 普通浏览器: 触发下载
var blob2 = new Blob([content], { type: 'application/json' });
var url2 = URL.createObjectURL(blob2);
var a = document.createElement('a');
a.href = url2;
a.download = filename;
a.click();
setTimeout(function () { try { URL.revokeObjectURL(url2); } catch (e) {} }, 30000);
} catch (e) {
favWarn('导出失败', favErrText(e));
showToast('导出失败:' + favErrText(e), 4000);
}
}
// 结构校验:必须含 words 数组(deleted 缺失可自动补空);词条须有合法 word,非法条目丢弃计数
function validateDoc(doc) {
var errs = [];
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return { ok: false, errs: ['不是有效的 JSON 对象'] };
if (!Array.isArray(doc.words)) errs.push('缺少 words 数组');
if (!doc.deleted || typeof doc.deleted !== 'object' || Array.isArray(doc.deleted)) errs.push('缺少 deleted 对象(将自动补空)');
var bad = 0, wc = 0;
if (Array.isArray(doc.words)) {
for (var i = 0; i < doc.words.length; i++) {
var it = doc.words[i];
if (!it || typeof it !== 'object' || !favNormalizeWord(it.word)) bad++;
else wc++;
}
}
if (bad) errs.push(bad + ' 个词条缺少 word 字段,将被忽略');
return { ok: errs.length === 0, errs: errs, wordCount: wc };
}
// 合并影响预览:以本地暂存为基线,统计 新增 / 更新 / 保留 / 新增墓碑 / 合并后总数
function buildImportPreview(doc) {
var src = (doc && typeof doc === 'object') ? doc : favEmptyDoc();
var ldel = (favLocalDoc.deleted && typeof favLocalDoc.deleted === 'object') ? favLocalDoc.deleted : {};
var rdel = (src.deleted && typeof src.deleted === 'object') ? src.deleted : {};
var del = {};
Object.keys(ldel).forEach(function (w) { del[w] = ldel[w]; });
Object.keys(rdel).forEach(function (w) {
var t = Number(rdel[w]) || 0;
if (!(del[w] >= t)) del[w] = t;
});
var map = new Map();
(Array.isArray(favLocalDoc.words) ? favLocalDoc.words : []).forEach(function (it) {
if (it && typeof it === 'object' && favNormalizeWord(it.word)) map.set(favNormalizeWord(it.word), it);
});
var addN = 0, updN = 0, keepN = 0;
(Array.isArray(src.words) ? src.words : []).forEach(function (ri) {
if (!ri || typeof ri !== 'object') return;
var w = favNormalizeWord(ri.word);
if (!w) return;
var li = map.get(w);
if (!li) { map.set(w, ri); addN++; }
else if ((ri.updatedTime || 0) > (li.updatedTime || 0)) { map.set(w, ri); updN++; }
else keepN++;
});
var mergedWords = [];
map.forEach(function (it) {
var w = favNormalizeWord(it.word);
if (del[w] !== undefined && del[w] >= (it.updatedTime || 0)) return;
mergedWords.push(it);
});
var tombN = 0;
Object.keys(rdel).forEach(function (w) { if (!(ldel[w] >= rdel[w])) tombN++; });
return {
add: addN, update: updN, keep: keepN, tomb: tombN,
mergedWordCount: mergedWords.length,
deletedCount: Object.keys(del).length,
fileWordCount: (Array.isArray(src.words) ? src.words : []).length
};
}
// 确认弹窗(复用 .fw-wd-* 基础样式 + .fw-import-*)
function confirmImportDoc(doc, preview, onOk, onCancel) {
var host = document.body || document.documentElement;
if (!host || !host.appendChild) { favWarn('导入确认弹窗打不开:页面尚未就绪'); return; }
var overlay = document.createElement('div');
overlay.className = 'fw-wd-overlay fw-import-overlay';
overlay.innerHTML =
'' +
'导入收藏 JSON —— 合并预览
' +
'文件内词条:' + preview.fileWordCount + ' 个' +
'新增(本机没有):' + preview.add + ' 个' +
'更新(较新覆盖):' + preview.update + ' 个' +
'保留(与本地相同或较旧):' + preview.keep + ' 个' +
'新增删除墓碑:' + preview.tomb + ' 条(已删除的词不会复活)' +
'合并后收藏总数:' + preview.mergedWordCount + ' 个' +
'' +
'' +
'' +
'' +
'';
host.appendChild(overlay);
var close = function () {
try { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); } catch (e) { /* ignore */ }
try { if (overlay.__k && document.removeEventListener) document.removeEventListener('keydown', overlay.__k, true); } catch (e) { /* ignore */ }
};
overlay.addEventListener('click', function (e) {
if (e.target === overlay) { close(); if (onCancel) onCancel(); return; }
var act = (e.target && e.target.getAttribute) ? e.target.getAttribute('data-act') : null;
if (act === 'cancel') { close(); if (onCancel) onCancel(); }
else if (act === 'ok') { close(); if (onOk) onOk(); }
});
overlay.__k = function (e) { if (e.key === 'Escape') { close(); if (onCancel) onCancel(); } };
try { document.addEventListener('keydown', overlay.__k, true); } catch (e) { /* ignore */ }
}
// 真正执行导入:并入本地暂存 → 落盘 → 同步按钮标记 → 后台同步 → 刷新面板
function doImportDoc(doc) {
var res = favLocalMergeDoc(doc);
try {
var lw = (favLocalDoc.words || []).map(function (it) { return favNormalizeWord(it && it.word); }).filter(Boolean);
for (var i = 0; i < lw.length; i++) favMarkLocal(lw[i]);
var dl = favLocalDoc.deleted || {};
Object.keys(dl).forEach(function (w) { favUnmarkLocal(w); });
favRefreshButtons(currentWord || '');
} catch (e) { /* ignore */ }
favLog('导入完成', '本地暂存现为 ' + favCountWords(favLocalDoc) + ' 词 / ' + Object.keys(favLocalDoc.deleted).length + ' 墓碑(新增 ' + res.added + ')');
showToast('导入完成:本地暂存共 ' + favCountWords(favLocalDoc) + ' 个词,正在同步到坚果云…', 3200);
favScheduleUpload();
if (bookPanelEl && typeof bookPanelEl.__fwBookReload === 'function') bookPanelEl.__fwBookReload();
}
// 文件选择入口:读取 → 校验 → 预览 → 确认 → 合并
function importJson() {
var input = document.createElement('input');
input.type = 'file';
input.accept = '.json,application/json';
input.addEventListener('change', function () {
var f = input.files && input.files[0];
if (!f) return;
var reader = new FileReader();
reader.onload = function () {
try {
var doc = JSON.parse(String(reader.result || ''));
var v = validateDoc(doc);
if (!v.ok) {
showToast('导入校验失败:' + v.errs.join(';'), 6000);
try { console.warn('[FW 收藏] 导入校验失败', v.errs); } catch (e) { /* ignore */ }
return;
}
var preview = buildImportPreview(doc);
confirmImportDoc(doc, preview, function () { doImportDoc(doc); });
} catch (e) {
showToast('导入失败:文件不是合法的 JSON(' + favErrText(e) + ')', 6000);
}
};
try { reader.readAsText(f); } catch (e) { showToast('读取文件失败:' + favErrText(e), 4000); }
});
try { input.click(); } catch (e) { favWarn('导入入口触发失败', favErrText(e)); }
}
/* ---------------- 油猴菜单 / 控制台 API ---------------- */
function registerFavMenus() {
if (typeof GM_registerMenuCommand !== 'function') { favLog('当前环境没有 GM_registerMenuCommand,菜单入口不可用(可用 window.__fwFav.open())'); return; }
try {
GM_registerMenuCommand('★ WebDAV 收藏设置(坚果云)', function () { openFavWebdavPanel(); });
GM_registerMenuCommand('★ 立即同步(上传)', function () {
if (!favIsConfigured()) { favPromptConfig(); return; }
showToast('正在同步…', 15000);
favSyncPush('manual').then(function (r) {
if (r && r.skipped) return;
showToast('已同步到 WebDAV(' + r.count + ' 个词)', 3200);
if (bookPanelEl && typeof bookPanelEl.__fwBookReload === 'function') bookPanelEl.__fwBookReload();
}, function (e) { showToast('同步失败:' + favErrText(e), 6000); });
});
GM_registerMenuCommand('★ 从远端拉取', function () { favSyncPull(false).catch(function () { /* 失败已 toast */ }); });
GM_registerMenuCommand('★ 测试 WebDAV 连接', function () { favConsoleTest(); });
GM_registerMenuCommand('★ 导出收藏 JSON', function () { exportJson(); });
GM_registerMenuCommand('★ 导入收藏 JSON', function () { importJson(); });
} catch (e) { favWarn('注册油猴菜单失败', favErrText(e)); }
}
function favConsoleTest() {
if (!favIsConfigured()) { favPromptConfig(); return Promise.reject(new Error('未配置 WebDAV')); }
var cfg = favGetConfig();
showToast('正在测试 WebDAV 连接…', 15000);
return favTestConnection(cfg).then(function (r) {
favLog('连接成功', r);
showToast('WebDAV 连接成功(HTTP ' + r.status + ',通道 ' + r.via + ');目标文件' +
(r.fileExists ? '已存在,其中词条 ' + r.blockCount + ' 个' : '尚不存在,首次收藏时自动创建'), 4000);
return r;
}, function (e) {
favWarn('连接测试失败', favErrText(e));
showToast('连接失败:' + favErrText(e), 6000, '去配置', openFavWebdavPanel);
throw e;
});
}
function exposeFavApi() {
var api = {
version: '3.16.0',
open: openFavWebdavPanel,
settings: openFavWebdavPanel,
close: closeFavWebdavPanel,
enabled: function () { return !!CONFIG.favEnabled; },
getConfig: favGetConfig,
saveConfig: function (cfg) { return favSaveConfig(cfg); },
clearConfig: favClearConfig,
test: favConsoleTest,
isFavorited: favIsFavorited,
list: function () { return Array.from(favGetWordSet()); },
favorite: function (word) { favToggle(word, favDataFor(word)); },
unfavorite: function (word) { if (favIsConfigured()) favRunRemove(word, null); else favPromptConfig(); },
card: function (word, data) { return favBuildCard(word, data || favDataFor(word)); },
entryJson: function (word, data) { return JSON.stringify(favBuildCard(word, data || favDataFor(word)).entry); },
mergeEntry: favMergeEntry,
tombstoneEntry: favTombstoneEntry,
countWords: favCountWords,
diag: favDiagText, // v3.11.0:一键诊断文本(请求状态码 / 耗时 / 出口 / 配置脱敏回显)
requestLog: function () { return favState.log.slice(); },
snapshot: function () { var sn = favSnapGet(favGetConfig()); return sn ? { at: sn.at, words: favCountWords(sn.content), etag: !!sn.etag, bytes: sn.content.length } : null; },
clearSnapshot: function () { favSnapDrop(); return true; }, // 清掉本机快照,下次收藏强制走完整读写
syncNow: function () { return favSyncPush('manual'); }, // v3.16.0:立即同步(读远端→并集合并→回写,ETag 乐观锁)
pullNow: function () { return favSyncPull(false); }, // v3.16.0:从远端拉取并合并到本机暂存
exportJson: exportJson, // v3.16.0:导出收藏 JSON(含统计,不含 WebDAV 密码)
importJson: importJson, // v3.16.0:导入收藏 JSON(校验+预览+确认合并)
localDoc: function () { return favLocalDoc ? JSON.parse(JSON.stringify(favLocalDoc)) : null; }, // v3.16.0:查看本地暂存文档
loadLocal: favLoadLocal, // v3.16.0:重载本地暂存
openFavorites: openBookPanel,
closeFavorites: closeBookPanel,
expandCard: function (word) { return bookSetCardOpen(word, true); }, // 展开某个成语卡片(word 省略=全部)
collapseCard: function (word) { return bookSetCardOpen(word, false); },// 收起某个成语卡片
toggleCard: function (word) { return bookSetCardOpen(word); }, // 切换某个成语卡片展开态
expandAllCards: bookExpandAll, // 展开全部卡片
collapseAllCards: bookCollapseAll, // 收起全部卡片
help: function () {
var lines = [
'window.__fwFav 用法:',
' .open() / .settings() 打开 WebDAV 配置面板(同油猴菜单「★ WebDAV 收藏设置」)',
' .getConfig() / .saveConfig({url,username,password,filePath}) / .clearConfig()',
' .test() 连接测试(返回 Promise)',
' .favorite("掩耳盗铃") / .unfavorite("掩耳盗铃")',
' .isFavorited("掩耳盗铃") / .list()',
' .card("掩耳盗铃") 生成该词的 JSON 收藏词条({word,updatedTime,at,pinyin,...})',
' .expandCard("掩耳盗铃") / .collapseCard("掩耳盗铃") / .toggleCard("掩耳盗铃")',
' 「我的收藏」面板:展开 / 收起该成语卡片的全部字段(默认只显示成语与释义)',
' .expandAllCards() / .collapseAllCards() 收藏面板:一键展开 / 收起全部卡片',
' .mergeEntry(text, entry) / .tombstoneEntry(text, "掩耳盗铃") / .countWords(text) 纯函数,便于离线自查 JSON 文档格式',
' .diag() 收藏诊断:请求出口 / 最近若干次请求的状态码与耗时 / 配置(脱敏)/ 快照',
' .snapshot() / .clearSnapshot() 查看 / 清除本机远端文件快照(本快照让收藏省掉一次 GET)',
' .syncNow() / .pullNow() 立即同步(读远端→并集合并→回写)/ 从远端拉取并合并',
' .exportJson() / .importJson() 导出 / 导入收藏 JSON(导出含统计、不含 WebDAV 密码)',
' .localDoc() / .loadLocal() 查看 / 重载本地暂存文档(words+deleted,离线收藏靠它)'
];
favLog('帮助', '\n' + lines.join('\n'));
return lines.join('\n');
}
};
try { window.__fwFav = api; } catch (e) { favWarn('挂载 window.__fwFav 失败', favErrText(e)); }
return api;
}
/* ================= 启动 ================= */
function init() {
var style = document.createElement('style');
style.textContent = CSS_TEXT;
(document.head || document.documentElement).appendChild(style);
ensureTip();
loadDict();
loadWordSet(); // 词边界词表(约 1.4MB,首次后台下载后缓存)
bindEvents();
registerFavMenus(); // v3.7.0 一键收藏:油猴菜单入口(★ WebDAV 收藏设置 / 测试连接)
favLoadLocal(); // v3.16.0:启动加载本地暂存 JSON(收藏词条 + 删除墓碑)
exposeFavApi(); // v3.7.0 一键收藏:控制台入口 window.__fwFav(open / test / favorite / list ...)
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();