// ==UserScript== // @name 粉笔评论查看器(Fenbi Comment Viewer) // @namespace https://github.com/local/fenbi-comment-viewer // @version 1.6.0 // @description 在粉笔网站题目页注入「查看评论」按钮,直接调用粉笔评论接口展示精选评论 // @author local // @match https://www.fenbi.com/spa/tiku/* // @match https://spa.fenbi.com/ti/* // @run-at document-start // @grant none // @noframes // ==/UserScript== (function () { 'use strict'; // ============================ 常量与状态 ============================ const DEFAULT_AVATAR = 'https://static.fenbi.com/res/avatar/default.png'; const COMMENT_BTN_CLASS = 'view-comments-btn'; const COMMENT_FEATURE_VERSION = '1.6.0'; // v1.6.0:按钮外部定位常量。2cm ≈ 75.6px(CSS 里也可直接写 left:-2cm), // 这里用 px 便于 JS 精确对齐题号行、并避免不同 DPR 下的换算误差。 const EXTERNAL_BTN_OFFSET_PX = 75.6; const QUESTION_MOUNT_CLASS = 'fcv-question-mount'; // episode 映射接口的 tiku_type 候选值(原扩展常量 Oe=5;实测 tiku_prefix 必须与 tiku_type 配对, // 如 sydw<->5、gwy<->1,用错组合会返回 {"code":-1,"msg":"tiku_prefix 错误"}) const EPISODE_TIKU_TYPES = [5, 1, 3, 2, 4]; const state = { solutionIds: [], // 当前页面题目 ID 列表(有序) episodeIds: [], // 与 solutionIds 一一对应的 episodeId // 评论接口路径前缀:初始种子值仅在「未拦截到任何带 tiku_prefix 的请求」时兜底, // 实际值一律以拦截到的请求 URL 参数 tiku_prefix 为准(背题页 /ti/memorize/ 实测为 xingce) currentTikuPrefix: 'xingce', episodeDataBySolutionId: {}, // solutionId -> 原始 episode 数据桶 }; // ============================ 运行时诊断(v1.2.0 新增) ============================ // 设计目标:把「拦截到了什么 / 抓到几个题号 / 映射到几个 episodeId / 当前 prefix / // 注入了几个按钮」全部变成可观测数据,既输出到 console,也渲染到右下角自检浮标。 const LOG_PREFIX = '[FenbiComment]'; const MAX_XHR_KEEP = 40; // 请求列表最多保留条数 const MAX_LOG_KEEP = 60; // 日志最多保留条数 const STATIC_ASSET_RE = /\.(js|css|png|jpe?g|gif|svg|webp|ico|woff2?|ttf|otf|mp4|m3u8)(\?|$)/i; const diag = { startedAt: new Date(), xhrSeen: [], // 页面发出的非静态请求: {at, method, url, matched[]} xhrMatchedCount: 0, // 命中拦截 pattern 的请求条数 logs: [], // 关键阶段日志: {at, level, text} lastError: null, // {at, text, stack} }; function diagTs() { const d = new Date(); const p = (n, w) => String(n).padStart(w, '0'); return p(d.getHours(), 2) + ':' + p(d.getMinutes(), 2) + ':' + p(d.getSeconds(), 2) + '.' + p(d.getMilliseconds(), 3); } function diagFormat(v) { if (v == null) return String(v); if (v instanceof Error) return v.message; // 兼容跨 realm / 非 Error 实例的错误对象(例如 iframe、扩展注入环境抛出的异常) if (isObj(v) && typeof v.message === 'string' && v.message) return v.message; if (typeof v === 'string') return v; try { return JSON.stringify(v); } catch (e) { return String(v); } } function isErrorLike(v) { return v instanceof Error || (isObj(v) && typeof v.message === 'string' && typeof v.name === 'string'); } function pushLog(level, args) { const text = args.map(diagFormat).join(' '); diag.logs.push({ at: diagTs(), level: level, text: text }); if (diag.logs.length > MAX_LOG_KEEP) diag.logs.shift(); return text; } function logInfo() { console.log(LOG_PREFIX, pushLog('info', Array.prototype.slice.call(arguments))); } function logWarn() { console.warn(LOG_PREFIX, pushLog('warn', Array.prototype.slice.call(arguments))); } function logError() { const args = Array.prototype.slice.call(arguments); const err = args.find((x) => isErrorLike(x)); diag.lastError = { at: diagTs(), text: pushLog('error', args), stack: err && err.stack ? err.stack : '' }; console.error.apply(console, [LOG_PREFIX].concat(args)); } function shortenUrl(u) { return String(u == null ? '' : u).replace(/^https?:\/\//, '').slice(0, 150); } // 记录页面发出的请求(只观测,不改变行为),用于回答「为什么没拦住」 function recordRequest(method, url) { const u = String(url == null ? '' : url); if (!u || u.startsWith('data:') || u.startsWith('blob:') || STATIC_ASSET_RE.test(u)) return; let matched = []; try { matched = interceptors.filter((ic) => u.indexOf(ic.pattern) !== -1).map((ic) => ic.pattern); } catch (e) { matched = []; } if (matched.length) diag.xhrMatchedCount++; diag.xhrSeen.push({ at: diagTs(), method: method || 'GET', url: u.slice(0, 220), matched: matched }); if (diag.xhrSeen.length > MAX_XHR_KEEP) diag.xhrSeen.shift(); if (matched.length) logInfo('拦截命中', matched.join(' + '), shortenUrl(u)); return matched; } function countQuestionAnchors() { const sels = [ '.tis-container .ti', '.ti .title-right', '.solution-detail .question', 'fb-local-question, fb-recitatation-question', '#app-report-ques-solution .solution-item', ]; const out = {}; sels.forEach((s) => { out[s] = document.querySelectorAll(s).length; }); return out; } function episodeMappedCount() { if (!Array.isArray(state.episodeIds)) return 0; return state.episodeIds.filter((v) => v != null).length; } // 汇总当前全部可观测中间态(浮标 / 诊断弹窗 / console 共用) function collectDiagnostics() { let profileRoute = null; try { profileRoute = isProfileRoute(); } catch (e) { profileRoute = null; } return { version: COMMENT_FEATURE_VERSION, page: location.href, isProfileRoute: profileRoute, currentTikuPrefix: state.currentTikuPrefix, solutionIdsCount: state.solutionIds.length, solutionIdSample: state.solutionIds.slice(0, 5), episodeMappedCount: episodeMappedCount(), episodeBuckets: Object.keys(state.episodeDataBySolutionId).length, buttonsInjected: document.querySelectorAll('.' + COMMENT_BTN_CLASS).length, xhrMatchedCount: diag.xhrMatchedCount, xhrSeenCount: diag.xhrSeen.length, questionAnchors: countQuestionAnchors(), startedAt: diagTs(), }; } // 生成纯文本诊断报告(供复制粘贴反馈) function buildDiagnosticText() { const d = collectDiagnostics(); const lines = []; lines.push('==== 粉笔评论查看器 自检报告 v' + d.version + ' ===='); lines.push('时间: ' + new Date().toLocaleString()); Object.keys(d).forEach((k) => lines.push(' ' + k + ': ' + diagFormat(d[k]))); lines.push('-- 已捕获请求(保留 ' + diag.xhrSeen.length + ' 条,命中 ' + diag.xhrMatchedCount + ' 条) --'); diag.xhrSeen.forEach((x) => { lines.push(' [' + x.at + '] ' + x.method + ' ' + x.url + (x.matched.length ? ' <= 命中: ' + x.matched.join(',') : '')); }); lines.push('-- 关键日志(最近 ' + diag.logs.length + ' 条) --'); diag.logs.forEach((l) => lines.push(' [' + l.at + '] ' + l.level + ' ' + l.text)); if (diag.lastError) { lines.push('-- 最近错误 --'); lines.push(' [' + diag.lastError.at + '] ' + diag.lastError.text); if (diag.lastError.stack) lines.push(diag.lastError.stack); } return lines.join('\n'); } function copyDiagnostics() { const text = buildDiagnosticText(); const fallback = () => { try { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.left = '-9999px'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); logInfo('诊断信息已复制(fallback)'); } catch (e) { logWarn('复制失败,请在控制台执行 __fenbiCV.dump()', e && e.message ? e.message : String(e)); } }; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(() => logInfo('诊断信息已复制到剪贴板'), fallback); } else fallback(); } // ============================ 基础工具 ============================ function isObj(v) { return typeof v === 'object' && v !== null; } function deepGet(obj, path) { let cur = obj; for (const k of path) { if (!isObj(cur)) return undefined; cur = cur[k]; } return cur; } function firstString() { for (let i = 0; i < arguments.length; i++) { const v = arguments[i]; if (typeof v === 'string' && v.trim()) return v.trim(); } return ''; } function escapeHtml(v) { return String(v == null ? '' : v) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function safeUrl(v) { if (typeof v !== 'string' || !v.trim()) return DEFAULT_AVATAR; const u = v.startsWith('//') ? 'https:' + v : v; try { const url = new URL(u, location.origin); return (url.protocol === 'https:' || url.protocol === 'http:') ? url.href : DEFAULT_AVATAR; } catch (e) { return DEFAULT_AVATAR; } } function formatTime(v) { const n = Number(v); if (!Number.isFinite(n) || n <= 0) return ''; const d = new Date(n < 0xe8d4a51000 ? n * 1000 : n); if (Number.isNaN(d.getTime())) return ''; const p = (x) => String(x).padStart(2, '0'); return d.getFullYear() + '.' + p(d.getMonth() + 1) + '.' + p(d.getDate()); } // ============================ XHR 拦截 ============================ const interceptors = []; let interceptorInitialized = false; function addInterceptor(pattern, cb) { interceptors.push({ pattern: pattern, cb: cb }); logInfo('注册拦截器', pattern, '(当前共 ' + interceptors.length + ' 个)'); } function initInterceptor() { if (interceptorInitialized) return; interceptorInitialized = true; const origOpen = XMLHttpRequest.prototype.open; const origSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function (method, url) { this.__fenbiHelperUrl = String(url); this.__fenbiHelperMethod = String(method || 'GET').toUpperCase(); return origOpen.apply(this, arguments); }; XMLHttpRequest.prototype.send = function () { const xhr = this; const url = xhr.__fenbiHelperUrl; if (url) { // 无论是否命中 pattern,都记录到诊断面板(便于排查"为什么没拦截到") const matched = recordRequest(xhr.__fenbiHelperMethod || 'GET', url); const registeredCbs = []; for (const ic of interceptors) { if (matched.indexOf(ic.pattern) !== -1 || (url + '').indexOf(ic.pattern) !== -1) { if (matched.indexOf(ic.pattern) === -1) diag.xhrMatchedCount++; // v1.3.0:同一回调可能被多个 pattern 命中(如 exercise / exercises / getExercise),只注册一次 if (registeredCbs.indexOf(ic.cb) !== -1) continue; registeredCbs.push(ic.cb); xhr.addEventListener('readystatechange', function () { if (xhr.readyState === XMLHttpRequest.DONE) { logInfo('拦截响应返回', ic.pattern, 'status=' + xhr.status, shortenUrl(url)); try { ic.cb(url, xhr.responseText); } catch (e) { logWarn('拦截回调失败', e); } } }); } } } return origSend.apply(this, arguments); }; // fetch 仅观测记录,不改变行为:用于判断页面是否改用 fetch 拉取题库数据 if (typeof window.fetch === 'function' && !window.__fenbiCVFetchPatched) { window.__fenbiCVFetchPatched = true; const origFetch = window.fetch; window.fetch = function (input, init) { try { const u = typeof input === 'string' ? input : (input && input.url ? input.url : ''); const method = (init && init.method) || (input && input.method) || 'GET'; if (u) recordRequest(method, u); } catch (e) { /* 观测失败不影响业务 */ } return origFetch.apply(this, arguments); }; } } // ============================ 数据提取 ============================ const ID_KEYS = ['id', 'questionId', 'question_id', 'solutionId', 'solution_id']; const NESTED_KEYS = ['solutions', 'solution', 'questions', 'question', 'datas', 'data', 'items', 'list', 'results']; // 从响应中提取题目 ID 列表 function extractQuestionIds(root) { const out = []; const seen = new Set(); const push = (v) => { const num = Number(v); const id = Number.isFinite(num) ? num : (typeof v === 'string' ? v : null); if (id == null || id === '') return; if (seen.has(String(id))) return; seen.add(String(id)); out.push(id); }; const walk = (node, depth) => { if (node == null || depth > 4) return; if (Array.isArray(node)) { if (node.length && node.every((x) => typeof x === 'number' || (typeof x === 'string' && /^\d+$/.test(x)))) { node.forEach(push); return; } node.forEach((x) => walk(x, depth + 1)); return; } if (!isObj(node)) return; ['questionIds', 'ids', 'results', 'data'].forEach((k) => walk(node[k], depth + 1)); const v = ID_KEYS.map((k) => node[k]).find((x) => x != null && x !== ''); if (v !== undefined) push(v); }; walk(root, 0); return out; } // 从 episode 数据桶中递归提取 episodeId function extractEpisodeIdFromBucket(bucket, depth) { depth = depth || 0; if (!bucket || depth > 3) return null; if (Array.isArray(bucket)) { for (const item of bucket) { const r = extractEpisodeIdFromBucket(item, depth + 1); if (r) return r; } return null; } if (!isObj(bucket)) return null; const stat = isObj(bucket.episodeStat) ? bucket.episodeStat : null; const direct = (stat && (stat.episodeId || stat.episode_id)) || bucket.episodeId || bucket.episode_id || bucket.id; if (typeof direct === 'string' || typeof direct === 'number') return direct; const keys = Object.keys(bucket).filter((k) => /^\d+$/.test(k)); ['episodes', 'episode', 'episodeStat', 'datas', 'data', 'items', 'list'].forEach((k) => { if (bucket[k] !== undefined && keys.indexOf(k) === -1) keys.push(k); }); for (const k of keys) { const r = extractEpisodeIdFromBucket(bucket[k], depth + 1); if (r) return r; } return null; } function mergeEpisodeData(data) { if (!isObj(data)) return; const before = Object.keys(state.episodeDataBySolutionId).length; state.episodeDataBySolutionId = Object.assign({}, state.episodeDataBySolutionId, data); const keys = Object.keys(data); logInfo('合并 episode 数据桶', '新增 ' + keys.length + ' 个', '样例题号 ' + JSON.stringify(keys.slice(0, 3)), '累计 ' + before + ' → ' + Object.keys(state.episodeDataBySolutionId).length); } function getEpisodeIdForSolution(solutionId) { const bucket = state.episodeDataBySolutionId[solutionId] || state.episodeDataBySolutionId[String(solutionId)]; return extractEpisodeIdFromBucket(bucket); } // 明显不是题库前缀的路径片段(避免把 /spa/tiku/profile/ 之类误当成 prefix) const PREFIX_STOP = new Set([ 'v1', 'v2', 'v3', 'v4', 'v5', 'api', 'web', 'android', 'ipad', 'iphone', 'profile', 'report', 'wrong', 'collect', 'note', 'exam', 'practice', 'error', 'recitation', 'questions', 'question', 'solution', 'exercises', 'tiku', 'ti', 'spa', ]); function pickPrefix(candidates) { for (const c of candidates) { if (typeof c !== 'string') continue; const v = c.trim(); if (!v || PREFIX_STOP.has(v.toLowerCase())) continue; if (!/^[a-z][a-z0-9_-]{1,32}$/i.test(v)) continue; return v; } return null; } // 个人中心 / 练习报告页路由里的 tiku_prefix,如 // /exam/error/practice/{primaryPrefix}/{tikuPrefix}/{categoryId}/{type} // /report/questions/solution/{primaryPrefix}/{tikuPrefix}/{categoryId}/{type} function parseProfileRoute() { const m = location.pathname.match(/\/(?:exam\/(?:error|recitation|practice)|report\/questions\/solution)\/[^/]+\/([^/?#]+)\//); return m ? pickPrefix([m[1]]) : null; } function extractTikuPrefix(url, data) { const fromData = []; if (isObj(data)) { fromData.push(data.tiku_prefix, data.tikuPrefix); ['tiku_prefix', 'tikuPrefix', 'prefix'].forEach((k) => fromData.push(deepGet(data, ['data', k]))); } const fromUrl = []; try { const u = new URL(String(url || ''), location.href); ['tiku_prefix', 'tikuPrefix', 'prefix', 'tiku', 'k'].forEach((k) => fromUrl.push(u.searchParams.get(k))); const pm = u.pathname.match(/\/(?:android|ipad|iphone|web|tikuApi)\/([^/]+)\//) || u.pathname.match(/\/(?:tiku|ti)\/([^/?#]+)\//); if (pm) fromUrl.push(pm[1]); } catch (e) { /* ignore */ } return pickPrefix(fromData) || pickPrefix(fromUrl) || parseProfileRoute(); } // v1.3.0:题号顺序优先取映射请求 URL 上的 tiku_ids 参数(逗号分隔,保持原始顺序)。 // 背题页 /ti/memorize/ 的映射请求形如: // .../tiku_episodes_with_multi_type?tiku_ids=2577965,19283923,...&tiku_prefix=xingce&tiku_type=5 // 直接以该参数为建序依据,避免依赖题目接口命名差异。 function extractTikuIdsFromUrl(url) { let raw = ''; try { const u = new URL(String(url || ''), location.href); raw = u.searchParams.get('tiku_ids') || ''; } catch (e) { return []; } if (!raw) return []; const out = []; const seen = {}; raw.split(',').forEach((seg) => { const v = String(seg).trim(); if (!v || seen[v]) return; seen[v] = 1; out.push(/^\d+$/.test(v) ? Number(v) : v); }); return out; } // ============================ 拦截器注册 ============================ function setupInterceptors() { // 1) episode 映射接口 addInterceptor('tiku_episodes_with_multi_type', function (url, text) { // v1.3.0:题号顺序优先取本请求 URL 的 tiku_ids 参数(背题页的题号顺序完全依赖它) const urlIds = extractTikuIdsFromUrl(url); if (urlIds.length) setQuestionIds(urlIds, 'url'); let json; try { json = JSON.parse(text); } catch (e) { logWarn('映射接口响应不是合法 JSON', shortenUrl(url)); return; } const data = isObj(json) && isObj(json.data) ? json.data : json; logInfo('映射接口响应', 'code=' + (isObj(json) ? json.code : '?'), 'buckets=' + (isObj(data) ? Object.keys(data).length : 0), shortenUrl(url)); mergeEpisodeData(data); const prefix = extractTikuPrefix(url, json); if (prefix) state.currentTikuPrefix = prefix; updateEpisodeIdsAndButtons(); }); // 2) 题目/解析列表接口 addInterceptor('solution', function (url, text) { let json; try { json = JSON.parse(text); } catch (e) { logWarn('题目接口响应不是合法 JSON', shortenUrl(url)); return; } const ids = extractQuestionIds(json); logInfo('题目接口响应', '解析出题号 ' + ids.length + ' 个', shortenUrl(url)); if (ids.length) setQuestionIds(ids); const prefix = extractTikuPrefix(url, json); if (prefix) state.currentTikuPrefix = prefix; updateEpisodeIdsAndButtons(); }); const questionHandler = function (url, text) { let json; try { json = JSON.parse(text); } catch (e) { logWarn('题目列表接口响应不是合法 JSON', shortenUrl(url)); return; } const ids = extractQuestionIds(json); logInfo('题目列表接口响应', '解析出题号 ' + ids.length + ' 个', shortenUrl(url)); if (ids.length) setQuestionIds(ids); const prefix = extractTikuPrefix(url, json); if (prefix) state.currentTikuPrefix = prefix; updateEpisodeIdsAndButtons(); }; addInterceptor('questionIds', questionHandler); addInterceptor('exercises', questionHandler); // v1.3.0:背题页(/ti/memorize/)的题目接口实际为 // tiku.fenbi.com/combine/exercise/getExercise(单数)与 combine/question/getMeta, // 旧子串 exercises 匹配不到,这里补齐常见命名;题号顺序仍以 tiku_ids 参数为优先。 addInterceptor('exercise', questionHandler); addInterceptor('getExercise', questionHandler); addInterceptor('getMeta', questionHandler); logInfo('拦截器注册完成', 'pattern 列表:' + interceptors.map((ic) => ic.pattern).join(', ')); } // v1.3.0:题号来源优先级标志。'url' 表示已由映射请求 URL 的 tiku_ids 参数确定顺序, // 此时后续从响应体提取的题号只作兜底、不再覆盖,保证背题页题号与页面顺序一致。 let questionIdsSource = ''; function setQuestionIds(ids, source) { if (!Array.isArray(ids) || !ids.length) return; const src = source || 'response'; if (questionIdsSource === 'url' && src !== 'url') { logInfo('忽略响应体提取的题号(题号顺序已由 tiku_ids 参数确定)', '本次解析出 ' + ids.length + ' 个'); return; } const changed = ids.length !== state.solutionIds.length || ids.some((v, i) => String(v) !== String(state.solutionIds[i])); state.solutionIds = ids; questionIdsSource = src; if (changed) logInfo('捕获题号列表', '共 ' + ids.length + ' 个', '来源 ' + src, '样例 ' + JSON.stringify(ids.slice(0, 5))); } let lastSyncSignature = ''; function updateEpisodeIdsAndButtons() { if (Array.isArray(state.solutionIds) && state.solutionIds.length) { state.episodeIds = state.solutionIds.map((id) => getEpisodeIdForSolution(id)); } try { syncCommentButtons(); } catch (e) { logWarn('按钮同步失败', e); } const mapped = episodeMappedCount(); const btnCount = document.querySelectorAll('.' + COMMENT_BTN_CLASS).length; const sig = state.solutionIds.length + '/' + mapped + '/' + btnCount + '/' + state.currentTikuPrefix; if (sig !== lastSyncSignature) { lastSyncSignature = sig; logInfo('状态同步', '题号=' + state.solutionIds.length, 'episodeId 映射=' + mapped, '已注入按钮=' + btnCount, 'prefix=' + state.currentTikuPrefix); } } // ============================ 评论接口 ============================ function buildCommentApiUrls(episodeId, start) { // v1.3.0:候选链首位固定为拦截到的请求 URL 参数 tiku_prefix 实际值(背题页实测 xingce), // 不再默认用 gwy / sydw 猜测;gwy 仅作为后缀降级候选保留。 const prefix = encodeURIComponent(state.currentTikuPrefix || 'gwy'); const urls = []; urls.push('https://ke.fenbi.com/android/' + prefix + '/v3/comments/episodes/' + episodeId + '?start=' + start); urls.push('https://ke.fenbi.com/android/gwy/v3/comments/episodes/' + episodeId + '?start=' + start); const params = new URLSearchParams({ system: '12.4.7', inhouse: '0', app: 'gwy', ua: 'iPad', av: '44', version: '6.11.3', kav: '22', len: '600', start: String(start), }); urls.push('https://ke.fenbi.com/ipad/gwy/v3/comments/episodes/' + episodeId + '?' + params.toString()); return urls.filter((u, i) => urls.indexOf(u) === i); } // 与原扩展 readFenbiJson 对齐:非 2xx、HTML 响应、空 body 分别抛出,避免把错误响应当成"没有评论" async function readJson(res, label) { if (!res.ok) throw new Error(label + ' HTTP ' + res.status); const text = await res.text(); const trimmed = text.trim(); if (!trimmed) return null; if (trimmed.charAt(0) === '<') throw new Error(label + ' 返回了 HTML,可能是接口域名或登录态异常'); try { return JSON.parse(text); } catch (e) { throw new Error(label + ' JSON 解析失败: ' + (e && e.message ? e.message : e)); } } async function fetchCommentsJson(episodeId, start) { const urls = buildCommentApiUrls(episodeId, start); let lastErr = null; for (const url of urls) { try { const res = await fetch(url, { method: 'GET', credentials: 'include', cache: 'no-store', headers: { Accept: 'application/json, text/plain, */*' }, }); const json = await readJson(res, '评论接口'); if (json) return json; } catch (e) { lastErr = e; logWarn('评论接口候选失败,尝试下一个', url, e); } } throw lastErr || new Error('没有可用的评论接口'); } function parseCommentResponse(json) { if (!isObj(json) || !Array.isArray(json.datas)) return null; return { datas: json.datas.filter(isObj), totalCount: typeof json.totalCount === 'number' ? json.totalCount : undefined, code: typeof json.code === 'number' ? json.code : undefined, }; } function hasCommentsInResponse(json) { const parsed = parseCommentResponse(json); return !!parsed && parsed.datas.length > 0; } // ============================ 评论字段解析 ============================ function normalizeComment(e) { const likeCount = Number(e.likeCount); return { id: (typeof e.id === 'string' || typeof e.id === 'number') ? e.id : null, nickname: firstString( e.nickName, e.nickname, deepGet(e, ['user', 'nickname']), deepGet(e, ['user', 'nickName']), deepGet(e, ['user', 'name']), deepGet(e, ['user', 'userName']), e.userName, e.username, e.authorName, e.creatorName, deepGet(e, ['userInfo', 'nickname']), deepGet(e, ['userInfo', 'name']), deepGet(e, ['author', 'nickname']), deepGet(e, ['author', 'name']) ) || '匿名用户', avatar: safeUrl( e.avatar || e.avatarUrl || deepGet(e, ['user', 'avatar']) || deepGet(e, ['user', 'avatarUrl']) || e.userAvatar || e.headImg || e.headUrl || deepGet(e, ['userInfo', 'avatar']) || deepGet(e, ['author', 'avatar']) ), province: firstString( e.ipLocation, e.ipLocationName, e.province, e.location, deepGet(e, ['user', 'province']), deepGet(e, ['user', 'location']), deepGet(e, ['userInfo', 'province']) ), content: typeof e.comment === 'string' ? e.comment : '', createdTime: e.createdTime, likeCount: Number.isFinite(likeCount) ? Math.max(0, Math.trunc(likeCount)) : 0, liked: e.liked === true, rating: extractRating(e), }; } function extractRating(e) { const candidates = [ e.fiveGradeScore, e.rate, e.rating, e.star, e.stars, e.score, e.videoRate, e.videoRating, e.evaluateScore, deepGet(e, ['evaluate', 'rate']), deepGet(e, ['evaluate', 'score']), deepGet(e, ['evaluate', 'star']), ]; for (const c of candidates) { const n = Number(c); if (!Number.isFinite(n) || n <= 0) continue; if (n <= 5) return Math.round(n * 10) / 10; if (n <= 10) return Math.round((n / 2) * 10) / 10; } return 0; } function renderStars(rating) { if (!rating) return ''; const n = Math.max(0, Math.min(5, Math.round(rating))); return '' + '★'.repeat(n) + '☆'.repeat(5 - n) + ''; } function renderLikeIcon(liked) { const path = ''; if (liked) { return ''; } return ''; } async function toggleLike(commentId, el) { if (el.dataset.loading === 'true') return; el.dataset.loading = 'true'; const liked = el.classList.contains('liked'); const action = liked ? 'like/cancel' : 'like'; const url = 'https://ke.fenbi.com/android/' + state.currentTikuPrefix + '/v3/comments/' + encodeURIComponent(commentId) + '/' + action; try { const res = await fetch(url, { method: 'POST', credentials: 'include' }); const json = await res.json(); if (!json || json.code !== 1) throw new Error((json && json.msg) || '点赞接口返回异常'); const countEl = el.querySelector('.like-count'); const iconEl = el.querySelector('.like-icon-container'); if (!countEl || !iconEl) throw new Error('点赞控件结构不完整'); const cur = parseInt(countEl.textContent, 10) || 0; el.classList.toggle('liked', !liked); iconEl.innerHTML = renderLikeIcon(!liked); countEl.textContent = String(liked ? Math.max(0, cur - 1) : cur + 1); } catch (e) { logError('点赞失败', e); el.classList.add('shake-error'); setTimeout(() => el.classList.remove('shake-error'), 500); } finally { el.dataset.loading = 'false'; } } // ============================ 评论弹窗 ============================ function detectDarkMode() { const classes = ['dark', 'dark-mode', 'is-dark', 'theme-night', 'theme-dark', 'night', 'night-mode']; const attrs = ['data-theme', 'data-mode', 'data-color-mode', 'data-skin']; const roots = [document.documentElement, document.body].filter(Boolean); for (const el of roots) { if (classes.some((c) => el.classList && el.classList.contains(c))) return true; if (attrs.some((a) => (el.getAttribute(a) || '').toLowerCase().indexOf('dark') >= 0 || (el.getAttribute(a) || '').toLowerCase() === 'night')) return true; } return false; } function createModal(title) { const dark = detectDarkMode(); const overlay = document.createElement('div'); overlay.className = 'comment-modal-overlay' + (dark ? ' dark' : ''); overlay.setAttribute('data-fenbi-theme', dark ? 'dark' : 'light'); const modal = document.createElement('div'); modal.className = 'comment-modal'; modal.setAttribute('role', 'dialog'); modal.setAttribute('aria-modal', 'true'); const header = document.createElement('div'); header.className = 'comment-modal-header'; const h3 = document.createElement('h3'); h3.textContent = title; const closeBtn = document.createElement('button'); closeBtn.className = 'comment-modal-close'; closeBtn.type = 'button'; closeBtn.setAttribute('aria-label', '关闭评论'); closeBtn.innerHTML = '×'; header.appendChild(h3); header.appendChild(closeBtn); const content = document.createElement('div'); content.className = 'comment-modal-content'; modal.appendChild(header); modal.appendChild(content); overlay.appendChild(modal); document.body.appendChild(overlay); const prevOverflow = document.body.style.overflow || ''; const cleanups = []; let closed = false; function close() { if (closed) return; closed = true; cleanups.forEach((fn) => { try { fn(); } catch (e) { /* ignore */ } }); modal.classList.remove('open'); overlay.classList.remove('active'); document.body.style.overflow = prevOverflow; document.removeEventListener('keydown', onKeydown, true); setTimeout(() => overlay.remove(), 300); } function onKeydown(e) { if (e.key === 'Escape' && !e.defaultPrevented) { e.preventDefault(); e.stopPropagation(); close(); } } closeBtn.addEventListener('click', close); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); document.addEventListener('keydown', onKeydown, true); // 进入动画 modal.classList.add('center'); void modal.offsetHeight; requestAnimationFrame(() => { modal.classList.add('open'); overlay.classList.add('active'); document.body.style.overflow = 'hidden'; }); return { overlay: overlay, modal: modal, content: content, closed: false, isClose: () => closed, close: close, registerCleanup: (fn) => { if (closed) { fn(); return; } cleanups.push(fn); }, currentStart: 0, currentId: undefined, isLoading: false, hasMore: true, lastDataCount: undefined, }; } function showModalMessage(m, html) { if (m.isClose()) return; m.content.innerHTML = '
' + html + '
'; } function displayComments(json, m) { if (m.isClose()) return; const parsed = parseCommentResponse(json); if (!parsed || parsed.datas.length === 0) { if (m.currentStart === 0) { showModalMessage(m, '
📭
还没有人发表评论哦
'); } else { m.hasMore = false; } return; } let container = m.content.querySelector('.comments-container'); if (!container) { container = document.createElement('div'); container.className = 'comments-container'; m.content.innerHTML = ''; m.content.appendChild(container); } // 移除"更多评论正在路上"提示 m.content.querySelectorAll('.loading-more').forEach((el) => el.remove()); const box = document.createElement('div'); parsed.datas.forEach((raw, idx) => { const c = normalizeComment(raw); const item = document.createElement('div'); item.className = 'comment-item'; const likeHtml = renderLikeIcon(c.liked); item.innerHTML = '
' + '' + '
' + '
' + escapeHtml(c.nickname) + '
' + '
' + renderStars(c.rating) + '' + escapeHtml(formatTime(c.createdTime)) + '' + (c.province ? '· ' + escapeHtml(c.province) + '' : '') + '
' + '
' + '
' + '
' + escapeHtml(c.content) + '
' + ''; const likeWrapper = item.querySelector('.like-count-wrapper'); if (likeWrapper && c.id !== null) { likeWrapper.addEventListener('click', () => toggleLike(c.id, likeWrapper)); } box.appendChild(item); }); container.appendChild(box); m.hasMore = parsed.datas.length > 0; m.lastDataCount = parsed.datas.length; } function fetchComments(episodeId, start, m) { start = Math.max(0, Number(start) || 0); if (m.isClose()) return; if (m.isLoading || m.pendingCommentStart === start) return; let container = m.content.querySelector('.comments-container'); if (start === 0) { showModalMessage(m, '
正在寻找这道题的评论...
'); m.hasMore = false; m.lastDataCount = undefined; } else if (container) { if (!container.querySelector('.loading-more')) { const tip = document.createElement('div'); tip.className = 'loading-more'; tip.textContent = '更多评论正在路上...'; container.appendChild(tip); } } m.currentId = episodeId; m.currentStart = start; m.isLoading = true; m.pendingCommentStart = start; fetchCommentsJson(episodeId, start).then((json) => { if (m.isClose()) return; m.isLoading = false; m.pendingCommentStart = null; displayComments(json, m); if (!hasCommentsInResponse(json) && start > 0) m.hasMore = false; }).catch((e) => { if (m.isClose()) return; m.isLoading = false; m.pendingCommentStart = null; m.content.querySelectorAll('.loading-more').forEach((el) => el.remove()); if (start === 0) { showModalMessage(m, '
😕
加载评论失败了,请稍后再试
'); } else { m.hasMore = false; } logError('请求或解析评论数据失败', e); }); } function bindScrollLoad(m, episodeId) { m.content.addEventListener('scroll', () => { const c = m.content; if (c.scrollTop + c.clientHeight < c.scrollHeight - 50) return; if (!m.hasMore || m.isLoading || m.currentId === undefined) return; const nextStart = m.currentStart + (m.lastDataCount || 10); fetchComments(m.currentId, nextStart, m); }, true); } // 兜底:用题号主动请求 episode 映射接口。 // 原扩展对应实现(ensureProfileQuestionEpisode): // https://ke.fenbi.com/api/gwy/v3/episodes/tiku_episodes_with_multi_type // ?tiku_ids={solutionId}&tiku_prefix={route.tikuPrefix}&tiku_type=5 // 注意:必须保留 /api 段;tiku_type 需与 tiku_prefix 配对(测试:sydw<->5 可用,gwy+5 报 "tiku_prefix 错误")。 function buildEpisodeMappingUrls(solutionId) { const prefixes = []; [parseProfileRoute(), state.currentTikuPrefix, 'sydw', 'gwy'].forEach((p) => { if (p && prefixes.indexOf(p) === -1) prefixes.push(p); }); const urls = []; const types = EPISODE_TIKU_TYPES.slice(0, 3); for (const type of types) { for (const p of prefixes) { const params = new URLSearchParams({ tiku_ids: String(solutionId), tiku_prefix: p, tiku_type: String(type), }); const u = 'https://ke.fenbi.com/api/gwy/v3/episodes/tiku_episodes_with_multi_type?' + params.toString(); if (urls.indexOf(u) === -1) urls.push(u); } } return urls; } function resolveEpisodeIdForQuestion(index) { if (state.episodeIds && state.episodeIds[index] != null) { logInfo('命中已有映射', 'index=' + index, 'episodeId=' + state.episodeIds[index]); return Promise.resolve(state.episodeIds[index]); } const solutionId = state.solutionIds[index]; if (solutionId == null) { logWarn('题号缺失,无法兜底解析', 'index=' + index, '当前 solutionIds 共 ' + state.solutionIds.length + ' 个'); return Promise.resolve(null); } const urls = buildEpisodeMappingUrls(solutionId); logInfo('启动兜底映射解析', '题号=' + solutionId, '候选接口 ' + urls.length + ' 个'); let i = 0; const tryNext = () => { if (i >= urls.length) { logWarn('所有映射候选接口均未取到 episodeId', '题号=' + solutionId, '已尝试 ' + urls.length + ' 个'); return Promise.resolve(null); } const url = urls[i++]; logInfo('发起映射请求', '[' + i + '/' + urls.length + ']', shortenUrl(url)); return fetch(url, { method: 'GET', credentials: 'include', cache: 'no-store', headers: { Accept: 'application/json, text/plain, */*' }, }) .then((r) => r.json()) .then((json) => { if (!isObj(json) || Number(json.code) !== 1) { logWarn('映射请求返回不可用', 'code=' + (isObj(json) ? json.code : '?'), 'msg=' + (isObj(json) ? json.msg : ''), shortenUrl(url)); return tryNext(); } const data = isObj(json.data) ? json.data : json; mergeEpisodeData(data); const bucket = data[solutionId] || data[String(solutionId)]; if (!isObj(bucket) || Object.keys(bucket).length === 0) { logWarn('映射响应中没有该题号的桶', '题号=' + solutionId, shortenUrl(url)); return tryNext(); } updateEpisodeIdsAndButtons(); return getEpisodeIdForSolution(solutionId) || tryNext(); }) .catch((e) => { logWarn('episode 映射候选接口失败', url, e); return tryNext(); }); }; return tryNext(); } async function openCommentsForQuestion(index) { logInfo('点击查看评论', 'index=' + index, 'solutionId=' + (state.solutionIds[index] == null ? 'null' : state.solutionIds[index]), 'episodeId=' + (state.episodeIds && state.episodeIds[index] != null ? state.episodeIds[index] : 'null')); const m = createModal('精选评论'); m.modal.dataset.fenbiQuestionIndex = String(index); showModalMessage(m, '
正在寻找这道题的评论...
'); let episodeId = null; let failure = null; try { episodeId = await resolveEpisodeIdForQuestion(index); } catch (e) { failure = e; } if (m.isClose()) return; if (failure || episodeId == null) { // v1.2.0:映射缺失不再只给一句"没找到",而是弹出完整诊断(中间态 + 请求列表 + 日志) const reason = failure ? ('解析过程抛出异常:' + (failure.message || failure)) : (state.solutionIds[index] == null ? ('按钮序号 ' + index + ' 在已捕获的题号列表中不存在(solutionIds 共 ' + state.solutionIds.length + ' 个,可能未拦截到题目列表或序号错位)') : ('题号 ' + state.solutionIds[index] + ' 未取到评论集 episodeId(映射接口未返回可用数据)')); logWarn('映射缺失,弹出诊断面板', reason); showDiagnosticPanel(m, reason, index); return; } logInfo('映射成功', 'index=' + index, 'episodeId=' + episodeId); bindScrollLoad(m, episodeId); fetchComments(episodeId, 0, m); } // 在评论弹窗内渲染自检面板:中间态表格 + 请求列表 + 关键日志 + 复制/重试 function showDiagnosticPanel(m, reason, index) { if (m.isClose()) return; const d = collectDiagnostics(); const rows = Object.keys(d).map((k) => '' + escapeHtml(k) + '' + escapeHtml(diagFormat(d[k])) + '').join(''); const xhrList = diag.xhrSeen.length ? diag.xhrSeen.slice(-12).reverse().map((x) => '
  • ' + (x.matched.length ? '[命中 ' + escapeHtml(x.matched.join(',')) + '] ' : '') + escapeHtml(x.method + ' ' + x.url) + '
  • ').join('') : '
  • 未捕获到任何非静态请求(脚本可能在页面请求之后才注入)
  • '; const logList = diag.logs.slice(-18).map((l) => '
  • [' + escapeHtml(l.at) + '] ' + escapeHtml(l.text) + '
  • ').join('') || '
  • 暂无日志
  • '; m.content.innerHTML = '
    ' + '
    评论集映射未建立:' + escapeHtml(reason) + '
    ' + '
    当前中间态
    ' + '' + rows + '
    ' + '
    页面已发出的请求(最近 12 条)
    ' + '' + '
    关键日志(最近 18 条)
    ' + '' + '
    ' + '' + '' + '
    ' + '
    把「复制」得到的文本发给助手即可定位;也可在控制台执行 __fenbiCV.dump()。
    ' + '
    '; const copyBtn = m.content.querySelector('.fcv-report-copy'); if (copyBtn) copyBtn.addEventListener('click', copyDiagnostics); const retryBtn = m.content.querySelector('.fcv-report-retry'); if (retryBtn) retryBtn.addEventListener('click', () => retryMapping(index, m)); } // 用户在诊断面板点「重试解析」 async function retryMapping(index, m) { if (m.isClose()) return; showModalMessage(m, '
    正在重新解析评论集映射...
    '); logInfo('用户触发重试解析', 'index=' + index); let episodeId = null; try { episodeId = await resolveEpisodeIdForQuestion(index); } catch (e) { logWarn('重试解析异常', e && e.message ? e.message : String(e)); } if (m.isClose()) return; if (episodeId == null) { showDiagnosticPanel(m, '重试后仍未取到 episodeId', index); return; } bindScrollLoad(m, episodeId); fetchComments(episodeId, 0, m); } // ============================ 按钮注入 ============================ function isProfileRoute() { return /\/spa\/tiku\/(?:profile|report|wrong|collect|note)/.test(location.pathname) || !!document.querySelector('fb-local-question, fb-recitatation-question'); } // v1.5.0:为题卡分配稳定 uid,用于在外层容器(可能被多个题卡共享)中唯一定位按钮 const cardUids = new WeakMap(); let cardUidSeq = 0; function getCardUid(card) { let uid = cardUids.get(card); if (!uid) { uid = 'fcv-card-' + (++cardUidSeq); cardUids.set(card, uid); } return uid; } function createQuestionCommentButton(card, index) { // v1.3.0 策略(恢复注入门槛,覆盖 v1.2.0 的「识别到锚点即注入」): // 仅当该题已取到 episodeId(即该题存在解析视频)时才注入按钮;未取到映射的题不注入。 // 背题页(/ti/memorize/)首屏在映射响应到达前可能暂无按钮,映射合并落地后由 // updateEpisodeIdsAndButtons → syncCommentButtons 统一补注;自检浮标与诊断日志保留。 const episodeId = state.episodeIds && state.episodeIds[index] != null ? state.episodeIds[index] : null; if (episodeId == null) return null; // v1.5.0:按钮挂到题卡的外层容器,按题卡 uid 查找,避免共享容器内串号 const outer = card.parentElement || card; const uid = getCardUid(card); let btn = outer.querySelector('.' + COMMENT_BTN_CLASS + '[data-fenbi-card-uid="' + uid + '"]'); if (!btn) { btn = document.createElement('button'); btn.type = 'button'; btn.className = COMMENT_BTN_CLASS; btn.dataset.fenbiHelperComments = COMMENT_FEATURE_VERSION; btn.dataset.fenbiCardUid = uid; btn.addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); openCommentsForQuestion(index); }); } const solutionId = state.solutionIds[index]; btn.dataset.fenbiIndex = String(index); btn.dataset.fenbiSolutionId = solutionId == null ? '' : String(solutionId); btn.dataset.fenbiEpisodeId = episodeId == null ? '' : String(episodeId); btn.classList.toggle('view-comments-btn--pending', episodeId == null); btn.title = episodeId != null ? ('题号 ' + solutionId + ' → 评论集 ' + episodeId) : ('尚未取到该题的评论集映射(题号 ' + (solutionId == null ? '未知' : solutionId) + '),点击查看诊断'); return btn; } // v1.5.0:把按钮绝对定位到「题卡整体盒子」左外侧、纵向对齐题号行。 // v1.6.0:水平偏移由 1cm 增加到 2cm(EXTERNAL_BTN_OFFSET_PX=75.6),仅改位置不改逻辑。 // 外层容器已设 position:relative(.fcv-question-mount),left/top 相对该容器计算: // 若外层容器与题卡左边缘对齐(每题一个容器),left 即题卡左边缘外 2cm; // 若多题共享同一容器,则按各自题卡的实时位置换算,互不重叠。 function alignExternalButton(btn, card, outer) { try { const cardRect = card.getBoundingClientRect(); const outerRect = outer.getBoundingClientRect(); if (!cardRect.width && !cardRect.height) return; // 元素不可见时不定位,留待下次同步 // 水平:题卡左边缘外 2cm(相对外层容器,含滚动偏移补偿) const left = Math.round(cardRect.left - outerRect.left + outer.scrollLeft - EXTERNAL_BTN_OFFSET_PX); // 纵向:优先对齐题号行(.title-right / span.index / .options-title),找不到则以题卡顶部为基准 const titleEl = card.querySelector('.title-right, span.index, .options-title'); let top; if (titleEl) { const tRect = titleEl.getBoundingClientRect(); top = Math.round(tRect.top - outerRect.top + outer.scrollTop + Math.max(0, (tRect.height - btn.offsetHeight) / 2)); } else { top = Math.round(cardRect.top - outerRect.top + outer.scrollTop); } // v1.6.0:定位同样走 style.setProperty 局部写入,避免整体赋值误伤其它内联样式 btn.style.setProperty('left', left + 'px'); btn.style.setProperty('top', top + 'px'); } catch (e) { logWarn('按钮定位失败', e); } } // v1.5.0:窗口尺寸变化后重算全部外部按钮的定位(题卡位置 / 宽度可能改变) let externalResizeTimer = null; let externalResizeBound = false; function realignAllExternalButtons() { document.querySelectorAll('.' + COMMENT_BTN_CLASS + '--external').forEach((btn) => { const card = btn._fcvCard; const outer = btn.parentElement; if (card && card.isConnected && outer) alignExternalButton(btn, card, outer); }); } function bindExternalResizeHandler() { if (externalResizeBound) return; externalResizeBound = true; window.addEventListener('resize', () => { clearTimeout(externalResizeTimer); externalResizeTimer = setTimeout(realignAllExternalButtons, 120); }); } function injectButton(card, index) { if (!card) return; const btn = createQuestionCommentButton(card, index); if (!btn) return; btn.classList.remove('fenbi-helper-inline-comment'); btn.removeAttribute('aria-label'); // v1.6.0:仅保留「查看评论」四个字,移除原先的气泡 / 评论图标(原为 '💬 查看评论') btn.textContent = '查看评论'; btn.classList.add('view-comments-btn--external'); // v1.6.0:橙色填充(实心橙底 + 白字、去掉原蓝色半透明描边 / 透明底)。 // 一律用 style.setProperty / removeProperty 局部覆盖:先清掉可能残留的颜色类内联样式, // 再以 CSS 变量注入新配色(由 .view-comments-btn--external 及其 :hover 规则消费), // 全程不触碰 left / top 等定位内联样式。严禁 style.cssText 整体赋值——那会清空全部内联样式。 ['background', 'background-color', 'color', 'border', 'border-color', 'border-style', 'border-width'] .forEach((prop) => btn.style.removeProperty(prop)); btn.style.setProperty('--fcv-btn-bg', '#ff8a00'); btn.style.setProperty('--fcv-btn-bg-hover', '#f07900'); btn.style.setProperty('--fcv-btn-fg', '#fff'); // 外层容器 = 题卡的父节点(题卡整体盒子的外层容器),保证按钮落在题卡外部、 // 不被题卡自身的 overflow 裁切;再统一设 position:relative + overflow:visible。 const outer = card.parentElement || card; if (!outer.classList.contains(QUESTION_MOUNT_CLASS)) outer.classList.add(QUESTION_MOUNT_CLASS); // 已挂载到同一外层容器则只需同步状态(title / 待映射样式 / data-*),避免重复插入 if (btn.parentElement !== outer) { if (btn.parentElement) btn.parentElement.removeChild(btn); outer.appendChild(btn); } // 记录按钮所属题卡(供窗口尺寸变化时重算定位;生命周期与按钮一致) btn._fcvCard = card; alignExternalButton(btn, card, outer); logInfo('注入查看评论按钮', 'index=' + index, '题号=' + (state.solutionIds[index] == null ? 'null' : state.solutionIds[index]), 'episodeId=' + (state.episodeIds && state.episodeIds[index] != null ? state.episodeIds[index] : 'null'), '待映射=' + (state.episodeIds && state.episodeIds[index] != null ? '否' : '是')); } function syncButtonsIn(rootSelector, itemSelector, callback) { const roots = document.querySelectorAll(rootSelector); if (!roots.length) return; roots.forEach((root) => { const items = root.querySelectorAll(itemSelector); items.forEach((el, i) => { const target = callback ? callback(el, i) : el; if (target) injectButton(target, i); }); }); } function syncNormalQuestionCommentButtons() { if (isProfileRoute()) return; // v1.5.0:以「题卡整体盒子」为锚点(原为卡片内部的 .title-right / 题号区域), // 用合并选择器统一编号,避免分组 forEach 各从 0 开始导致序号错位 const cards = document.querySelectorAll('.tis-container .ti, .solution-detail .question, .ti'); if (cards.length) { cards.forEach((el, i) => injectButton(el, i)); } else { // 兜底:页面结构不含上述题卡时,退回以题号 span.index 的父级为锚点 document.querySelectorAll('.ti span.index').forEach((el, i) => { const parent = el.parentElement; if (parent) injectButton(parent, i); }); } } function syncCommentButtons() { if (isProfileRoute()) { // 个人中心/练习报告页:以题目卡片为锚点注入(下标改用卡片上显示的题号,等价于原扩展 getProfileQuestionIndex) // v1.5.0:按钮挂到卡片外层容器,故不再用 el.querySelector 判重,统一交给 injectButton 内部按题卡 uid 去重 document.querySelectorAll('fb-local-question, fb-recitatation-question, #app-report-ques-solution .solution-item').forEach((el, i) => { injectButton(el, getProfileQuestionIndex(el, i)); }); return; } syncNormalQuestionCommentButtons(); } // 原扩展 getProfileQuestionIndex:题卡上 .options-title 的文本即题号,减一为下标; // 直接用 forEach 的 i 会在多容器混排时与 solutionIds 错位,导致点 A 题去查 B 题的 episodeId。 function getProfileQuestionIndex(el, fallback) { const titleEl = el && el.querySelector ? el.querySelector('.options-title') : null; const n = parseInt(titleEl ? titleEl.textContent : '', 10); const total = Math.max(state.solutionIds.length, document.querySelectorAll('fb-local-question, fb-recitatation-question').length); if (Number.isFinite(n) && n > 0 && n <= total) return n - 1; return fallback; } // ============================ 观察器 ============================ function observeElements(rootSelector, itemSelector, callback, debounceTime) { const root = document.querySelector(rootSelector); if (!root) return null; const seen = new WeakSet(); let timer = null; const process = () => { Array.from(root.querySelectorAll(itemSelector)).forEach((el, i) => { if (seen.has(el)) return; seen.add(el); try { callback(el, i); } catch (e) { logError('处理元素失败', e); } }); }; process(); const mo = new MutationObserver((records) => { const changed = records.some((r) => (r.type === 'childList' && (r.addedNodes.length || r.removedNodes.length))); if (!changed) return; if (timer !== null) clearTimeout(timer); timer = setTimeout(process, debounceTime || 300); }); mo.observe(root, { childList: true, subtree: true }); return { stop: () => { if (timer !== null) clearTimeout(timer); mo.disconnect(); } }; } function onDomReady(fn) { const run = () => { if (!document.body) { setTimeout(run, 50); return; } fn(); }; if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run, { once: true }); else run(); } function initializeCommentButtons() { document.documentElement.setAttribute('data-fenbi-comment-viewer', COMMENT_FEATURE_VERSION); observeElements('body', '.tis-container, .solution-detail, .ti', () => setTimeout(syncCommentButtons, 0), 200); // v1.5.0:观察器锚点由卡片内部(.title-right / span.index)改为「题卡整体盒子」, // 按钮统一由 injectButton 挂到题卡外层容器并绝对定位到盒子左外侧 2cm(v1.6.0)。 observeElements('.tis-container', '.ti', (el, i) => injectButton(el, i), 200); observeElements('.solution-detail', '.question', (el, i) => injectButton(el, i), 200); observeElements('.ti', '.title-right', (el, i) => injectButton(el.closest('.ti') || el, i), 200); observeElements('.ti', 'span.index', (el, i) => { const card = el.closest('.ti') || el.parentElement; if (card) injectButton(card, i); }, 200); // profile / 报告页 observeElements('body', 'fb-local-question, fb-recitatation-question, #app-report-ques-solution .solution-item', (el, i) => injectButton(el, getProfileQuestionIndex(el, i)), 150); // v1.5.0:窗口尺寸变化后重算外部按钮定位 bindExternalResizeHandler(); syncCommentButtons(); } // 供控制台调用:__fenbiCV.showDiagnostics() 打开同一套诊断弹窗 function openDiagnosticModal(reason) { const m = createModal('评论功能自检'); showDiagnosticPanel(m, reason || '手动查看诊断信息', -1); return m; } // ============================ 样式 ============================ function injectStyles() { if (document.getElementById('fenbi-comment-viewer-style')) return; const style = document.createElement('style'); style.id = 'fenbi-comment-viewer-style'; style.textContent = ` .view-comments-btn{display:inline-flex;align-items:center;gap:4px;margin:0 6px;padding:3px 10px; font-size:12px;line-height:1.4;color:#3b7cff;background:rgba(59,124,255,.08); border:1px solid rgba(59,124,255,.35);border-radius:14px;cursor:pointer; transition:background .2s,color .2s;font-family:inherit;vertical-align:middle} .view-comments-btn:hover{background:rgba(59,124,255,.18)} .comment-modal-overlay{position:fixed;inset:0;z-index:2147483000;background:rgba(0,0,0,0); display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity .25s,background .25s} .comment-modal-overlay.active{opacity:1;background:rgba(0,0,0,.45)} .comment-modal{width:min(560px,92vw);max-height:80vh;background:#fff;border-radius:12px; box-shadow:0 12px 40px rgba(0,0,0,.25);display:flex;flex-direction:column;overflow:hidden; transform:translateY(12px);transition:transform .25s} .comment-modal.open{transform:translateY(0)} .comment-modal-header{display:flex;align-items:center;justify-content:space-between; padding:14px 18px;border-bottom:1px solid #eee} .comment-modal-header h3{margin:0;font-size:16px;font-weight:600;color:#222} .comment-modal-close{border:none;background:transparent;font-size:22px;line-height:1;cursor:pointer;color:#999} .comment-modal-close:hover{color:#333} .comment-modal-content{flex:1;overflow-y:auto;padding:8px 0} .loading-wrapper{padding:48px 20px;text-align:center;color:#888;font-size:14px} .loading-spinner{width:28px;height:28px;margin:0 auto 12px;border:3px solid #e5e5e5; border-top-color:#3b7cff;border-radius:50%;animation:fenbi-spin .8s linear infinite} @keyframes fenbi-spin{to{transform:rotate(360deg)}} .comments-container{padding:0 16px} .comment-item{padding:12px 0;border-bottom:1px solid #f2f2f2} .comment-item:last-child{border-bottom:none} .comment-user-info{display:flex;gap:10px;align-items:center} .comment-avatar{width:36px;height:36px;border-radius:50%;object-fit:cover;background:#f0f0f0} .comment-meta{flex:1;min-width:0} .comment-nickname{font-size:13px;font-weight:600;color:#333} .comment-sub-meta{display:flex;align-items:center;gap:6px;font-size:11px;color:#999;margin-top:2px} .rating-stars{color:#ffb400;letter-spacing:-1px} .comment-text{margin:8px 0 6px;font-size:13px;line-height:1.6;color:#333;word-break:break-word;white-space:pre-wrap} .comment-footer{display:flex;justify-content:flex-end} .like-count-wrapper{display:inline-flex;align-items:center;gap:4px;color:#999;cursor:pointer; font-size:12px;padding:2px 6px;border-radius:10px;transition:color .2s,background .2s} .like-count-wrapper:hover{background:#f5f5f5} .like-count-wrapper.liked{color:#e64545} .like-count-wrapper.shake-error{animation:fenbi-shake .4s} @keyframes fenbi-shake{0%,100%{transform:translateX(0)}25%{transform:translateX(-3px)}75%{transform:translateX(3px)}} .no-more-comments{padding:14px;text-align:center;color:#bbb;font-size:12px} .loading-more{padding:14px;text-align:center;color:#bbb;font-size:12px} .comment-modal-overlay.dark .comment-modal{background:#1f1f1f} .comment-modal-overlay.dark .comment-modal-header{border-bottom-color:#333} .comment-modal-overlay.dark .comment-modal-header h3{color:#eee} .comment-modal-overlay.dark .comment-text,.comment-modal-overlay.dark .comment-nickname{color:#ddd} .comment-modal-overlay.dark .comment-item{border-bottom-color:#2a2a2a} .comment-modal-overlay.dark .like-count-wrapper:hover{background:#2a2a2a} /* 待映射态按钮(灰虚线,点击弹诊断) */ .view-comments-btn--pending{opacity:.72;border-style:dashed;border-color:#c8ccd4;background:transparent;color:#8a9099} /* v1.5.0:题卡外层容器——相对定位 + 溢出可见,保证按钮落在题卡盒子外部且不被裁切 */ .fcv-question-mount{position:relative!important;overflow:visible!important} /* v1.5.0:外部定位按钮——绝对定位到题卡左边缘外侧,纵向位置由脚本对齐题号行 */ /* v1.6.0:偏移由 1cm 改为 2cm(2cm≈75.6px),兜底值与 EXTERNAL_BTN_OFFSET_PX 保持一致; 颜色走 CSS 变量(--fcv-btn-*),由 JS 用 style.setProperty 局部注入,便于统一换肤且不影响 left/top */ .view-comments-btn--external{position:absolute;left:-2cm;top:0;margin:0;z-index:20;white-space:nowrap; color:var(--fcv-btn-fg,#fff);background:var(--fcv-btn-bg,#ff8a00); border:1px solid var(--fcv-btn-bg,#ff8a00);border-radius:14px} /* v1.6.0:橙色填充(实心橙底 + 白字),去掉原蓝色半透明描边 / 透明底风格; 悬浮态保留既有交互与过渡,仅同步为橙色系加深,未做任何行为变更 */ .view-comments-btn--external:hover{color:var(--fcv-btn-fg,#fff); background:var(--fcv-btn-bg-hover,#f07900);border-color:var(--fcv-btn-bg-hover,#f07900)} /* v1.2.0:弹窗内的诊断报告 */ .fcv-report{text-align:left;font-size:12px;color:#333} .fcv-report-reason{padding:8px 10px;border-radius:6px;background:#fff6f6;border:1px solid #ffd9d9; color:#b03030;line-height:1.6;word-break:break-word} .fcv-report-sec{margin:10px 0 4px;font-weight:600;color:#666} .fcv-report-table{width:100%;border-collapse:collapse} .fcv-report-table th{width:118px;text-align:left;font-weight:400;color:#888;vertical-align:top; padding:2px 6px 2px 0} .fcv-report-table td{padding:2px 0;word-break:break-all;color:#333} .fcv-report-list{margin:0;padding-left:18px;max-height:150px;overflow:auto;color:#555} .fcv-report-list li{margin:2px 0;word-break:break-all} .fcv-report-list b{color:#c47f00} .fcv-report-actions{display:flex;gap:8px;margin:12px 0 6px} .fcv-report-btn{flex:1;border:1px solid #d5d9e0;background:#f7f8fa;color:#333;border-radius:6px; padding:6px 8px;cursor:pointer;font-size:12px} .fcv-report-btn:hover{background:#eef1f6} .fcv-report-tip{color:#aaa;font-size:11px;line-height:1.6} `; (document.head || document.documentElement).appendChild(style); } // ============================ 启动 ============================ function init() { logInfo('脚本启动 v' + COMMENT_FEATURE_VERSION, '页面=' + shortenUrl(location.href)); initInterceptor(); setupInterceptors(); window.addEventListener('hashchange', () => { logInfo('路由变化', shortenUrl(location.href)); setTimeout(syncCommentButtons, 100); }); onDomReady(() => { injectStyles(); updateEpisodeIdsAndButtons(); initializeCommentButtons(); const d = collectDiagnostics(); logInfo('初始化完成', '题号=' + d.solutionIdsCount, 'episodeId 映射=' + d.episodeMappedCount, '按钮=' + d.buttonsInjected, 'prefix=' + d.currentTikuPrefix); }); } // 控制台入口(便于用户自行取证 / 反馈) function exposeConsoleApi() { try { window.__fenbiCV = { version: COMMENT_FEATURE_VERSION, state: state, diag: diag, collect: collectDiagnostics, dump: () => { const t = buildDiagnosticText(); console.log(t); return t; }, copy: copyDiagnostics, showDiagnostics: openDiagnosticModal, sync: () => { updateEpisodeIdsAndButtons(); }, }; logInfo('已暴露控制台接口 window.__fenbiCV', '可用方法:dump / copy / showDiagnostics / sync'); } catch (e) { logWarn('暴露控制台接口失败', e); } } injectStyles.safe = true; exposeConsoleApi(); try { init(); } catch (e) { logError('初始化失败', e); } })();