// ==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 =
'' +
'' +
'';
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) + '
' +
'
当前中间态
' +
'
' +
'
页面已发出的请求(最近 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); }
})();