// ==UserScript== // @name X岛-EX // @namespace http://tampermonkey.net/ // @version 2.2.5 // @description X岛-EX 网页端增强,移动端般的浏览体验:快捷切换饼干/ 添加页首页码 / 关闭图片水印 / 预览真实饼干 / 隐藏无标题-无名氏-版规 / 显示外部图床 / 自动刷新饼干 toast提示 / 无缝翻页-自动翻页 / 默认原图+控件 / 新标签打开串 / 优化引用弹窗 / 拓展引用格式 / 当页回复编号 / 扩展坞增强 / 拦截回复中间页 / 颜文字拓展 / 高亮PO主 / 发串UI调整 / 『分组标记饼干』 / 『屏蔽饼干』 / 『只看饼干』 / 『屏蔽关键词』- 隐藏-折叠 / 增强X岛匿名版 / 板块页快速回复 / 展开板块页长串 / 野生搜索酱 / unvcode-零宽空格模式 / 侧边栏收起 / 图片隐藏模式 / 图片自动压缩-非法图像格式(无GCT)GIF重编码 / 链接自动识别 / 设置项导入导出-剪贴板文件 。 // @author XY // @match https://*.nmbxd1.com/* // @grant GM_getValue // @grant GM_setValue // @grant GM_addValueChangeListener // @grant GM_xmlhttpRequest // @grant GM_deleteValue // @grant GM_listValues // @grant GM_addStyle // @grant unsafeWindow // @connect image.nmb.best // @require https://code.jquery.com/jquery-3.6.0.min.js // @require https://cdn.jsdelivr.net/npm/apng-js@1.1.5/lib/index.js // @require https://unpkg.com/upng-js@2.1.0/UPNG.js // @license WTFPL // @changelog 新增:\n1.新增ScriptCat更新渠道:新增 ScriptCat(脚本猫)作为备选更新源(https://scriptcat.org/api/v2/scripts/6289),无法访问GreasyFork的肥哥可通过此渠道下载更新\n2.更新提示:启动时自动检查 GreasyFork 与 ScriptCat 的最新版本,展示更新日志,支持「立即更新 / 忽略此版本 / 今日关闭」三种操作\n3.只看饼干·批注模式:开启后,若Po主与只看饼干设置中填写的其他饼干回复 ≥ 2 条,则重点回复置于主栏,其余回复展示在侧栏;不足 2 条时按网页默认顺序排列。侧栏支持「展开 / 收起」切换,收起时侧栏高度与对应主栏回复平齐\n4.屏蔽饼干/关键词·隐藏模式:开启后完全隐藏被关键词/饼干匹配的回复\n5.图片右键菜单:右键图片弹出自定义菜单,支持复制图片、下载图片、复制图片链接、复制串链接。GIF / APNG 通过 Chromium Web Custom Format 写入剪贴板,粘贴时保留动态效果(注意:不会显示在 Windows 剪贴板历史中,复制后请及时粘贴)\n\n优化:\n1.饼干切换快捷键:使用 `Ctrl + \` 切换饼干时,若选中已激活的饼干,焦点将回到输入框而非停留在选择栏中,避免误操作\n2.设置项交互统一:「增强 X 岛匿名版」(关闭草稿 / 开启草稿)与「展开板块页长串」(全部展开 / 全部收起)由按钮改为选择框,交互风格与其他设置项保持一致 // @note 致谢:切饼代码移植自[XD-Enhance](https://greasyfork.org/zh-CN/scripts/438164-xd-enhance) // @note 致谢:外部图床代码二改自[显示x岛图片链接指向的图片](https://greasyfork.org/zh-CN/scripts/546024-%E6%98%BE%E7%A4%BAx%E5%B2%9B%E5%9B%BE%E7%89%87%E9%93%BE%E6%8E%A5%E6%8C%87%E5%90%91%E7%9A%84%E5%9B%BE%E7%89%87) // @note 致谢:完整移植[增强x岛匿名版](https://greasyfork.org/zh-CN/scripts/513156-%E5%A2%9E%E5%BC%BAx%E5%B2%9B%E5%8C%BF%E5%90%8D%E7%89%88) // @note 致谢:部分功能移植自[X岛-揭示板的增强型体验](https://greasyfork.org/zh-CN/scripts/497875-x%E5%B2%9B-%E6%8F%AD%E7%A4%BA%E6%9D%BF%E7%9A%84%E5%A2%9E%E5%BC%BA%E5%9E%8B%E4%BD%93%E9%AA%8C#%E8%BF%9E%E6%8E%A5%E7%9B%B4%E6%8E%A5%E8%B7%B3%E8%BD%AC) // @note 致谢:来自4sYbzEX的搜索服务[野生搜索酱](https://www.nmbxd.com/t/64792841) // @note 致谢:来自acVMxuv的[侧边栏优化](https://greasyfork.org/zh-CN/scripts/553143-x%E5%B2%9B%E4%BC%98%E5%8C%96%E5%B2%9B-%E4%BE%A7%E8%BE%B9%E6%A0%8F%E4%BC%98%E5%8C%96%E7%89%88) // ==/UserScript== /* global $, jQuery */ // @run-at document-end // @downloadURL https://update.greasyfork.org/scripts/531005/X%E5%B2%9B-EX.user.js // @updateURL https://update.greasyfork.org/scripts/531005/X%E5%B2%9B-EX.meta.js // @downloadURL https://scriptcat.org/scripts/code/6289/X%E5%B2%9B-EX.user.js // @updateURL https://scriptcat.org/scripts/code/6289/X%E5%B2%9B-EX.meta.js (function($){ 'use strict'; /* -------------------------------------------------- * tag 0. 通用与工具函数 * -------------------------------------------------- */ const VERSION = GM_info.script.version; function cat_version(){ console.log('[version]:', VERSION); } cat_version(); const UPDATE_CHECK_KEY = 'xdex_update_check_state'; const UPDATE_GREASYFORK_META_URL = 'https://update.greasyfork.org/scripts/531005/X%E5%B2%9B-EX.meta.js'; const UPDATE_SCRIPTCAT_API_URL = 'https://scriptcat.org/api/v2/scripts/6289'; const UPDATE_CHECK_HOUR = 10; function normalizeMetaChangelog(text) { return String(text || '') .replace(/\\r\\n/g, '\n') .replace(/\\n/g, '\n') .replace(/\\r/g, '\n') .trim(); } function parseVersionAndChangelogFromMeta(metaText) { const text = String(metaText || ''); const versionMatch = text.match(/^\/\/\s*@version\s+(.+)$/m); const changelogMatches = [...text.matchAll(/^\/\/\s*@changelog\s+(.+)$/gm)]; const changelog = normalizeMetaChangelog(changelogMatches .map(m => String(m[1] || '').trim()) .filter(Boolean) .join('\n') .trim()); return { version: versionMatch ? String(versionMatch[1] || '').trim() : '', changelog }; } const CHANGELOG = parseVersionAndChangelogFromMeta(GM_info.scriptMetaStr || '').changelog || ''; function getDefaultUpdateCheckState() { return { lastCheckDate: '', nextCheckAt: 0, pendingUpdateVersion: '', pendingUpdateChangelog: '', pendingUpdateSource: '', pendingUpdateDetectedAt: 0, latestRemoteVersion: '', ignoredVersion: '', lastDismissDate: '', dismissedUntil: 0 }; } function getUpdateCheckState() { try { const saved = GM_getValue(UPDATE_CHECK_KEY, null); const merged = Object.assign(getDefaultUpdateCheckState(), saved || {}); console.log('[update-check] get state:', merged); return merged; } catch (e) { console.warn('[update-check] get state failed, fallback to default:', e); return getDefaultUpdateCheckState(); } } function setUpdateCheckState(nextState) { const merged = Object.assign(getDefaultUpdateCheckState(), nextState || {}); GM_setValue(UPDATE_CHECK_KEY, merged); console.log('[update-check] set state:', merged); return merged; } function formatLocalDateKey(ts = Date.now()) { const d = new Date(ts); const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${day}`; } function getNextNaturalCheckAt(nowTs = Date.now(), hour = UPDATE_CHECK_HOUR) { const d = new Date(nowTs); d.setDate(d.getDate() + 1); d.setHours(hour, 0, 0, 0); return d.getTime(); } function compareVersionStrings(a, b) { const pa = String(a || '').split('.').map(v => parseInt(v, 10) || 0); const pb = String(b || '').split('.').map(v => parseInt(v, 10) || 0); const len = Math.max(pa.length, pb.length); for (let i = 0; i < len; i++) { const av = pa[i] || 0; const bv = pb[i] || 0; if (av > bv) return 1; if (av < bv) return -1; } return 0; } function gmRequest(url, responseType = 'text') { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, responseType, onload: (resp) => { if (resp.status >= 200 && resp.status < 300) { resolve(resp); } else { reject(new Error(`HTTP ${resp.status} ${url}`)); } }, onerror: () => reject(new Error(`Request failed: ${url}`)), ontimeout: () => reject(new Error(`Request timeout: ${url}`)) }); }); } async function fetchMetaVersionAndChangelog(url, source) { const resp = await gmRequest(url, 'text'); const parsed = parseVersionAndChangelogFromMeta(resp.responseText || ''); return { source, url, version: parsed.version, changelog: parsed.changelog }; } async function fetchScriptCatVersionAndChangelog(url, source = 'scriptcat') { const resp = await gmRequest(url, 'text'); const json = JSON.parse(resp.responseText || '{}'); const script = (json && json.data && json.data.script) || {}; return { source, url, version: String(script.version || '').trim(), changelog: String(script.changelog || '').trim() }; } function choosePreferredRemoteMeta(results) { const valid = (results || []).filter(item => item && item.version); if (!valid.length) return null; valid.sort((a, b) => compareVersionStrings(b.version, a.version)); const topVersion = valid[0].version; const topCandidates = valid.filter(item => compareVersionStrings(item.version, topVersion) === 0); const preferred = topCandidates.find(item => item.source === 'greasyfork') || topCandidates[0]; return preferred; } function shouldShowPendingUpdateReminder(state, currentVersion = VERSION) { if (!state || !state.pendingUpdateVersion) return false; if (compareVersionStrings(state.pendingUpdateVersion, currentVersion) <= 0) return false; if (state.ignoredVersion && state.ignoredVersion === state.pendingUpdateVersion) return false; if (state.lastDismissDate && state.lastDismissDate === formatLocalDateKey()) return false; if (state.dismissedUntil && Date.now() < state.dismissedUntil) return false; return true; } function updateSettingsButtonBadge(state = getUpdateCheckState()) { const $btn = $('#sp_btn'); if (!$btn.length) return; $btn.toggleClass('xdex-has-update', shouldShowPendingUpdateReminder(state)); } function clearFooterUpdateHighlight() { const $links = $('#sp_panel_footer .sp_panel_links'); $links.removeClass('xdex-update-highlight xdex-update-source-greasyfork xdex-update-source-scriptcat'); $links.find('[data-update-channel]').removeClass('xdex-update-link-primary xdex-update-link-secondary'); } function flashFooterUpdateHighlight(source = '') { const $links = $('#sp_panel_footer .sp_panel_links'); if (!$links.length) return; clearFooterUpdateHighlight(); $links.addClass('xdex-update-highlight'); const sourceKey = String(source || '').trim().toLowerCase(); const channelMap = { greasyfork: { containerClass: 'xdex-update-source-greasyfork', primary: 'greasyfork', secondary: ['github'] }, scriptcat: { containerClass: 'xdex-update-source-scriptcat', primary: 'scriptcat', secondary: ['baidupan'] } }; const config = channelMap[sourceKey]; if (config) { $links.addClass(config.containerClass); $links.find(`[data-update-channel="${config.primary}"]`).addClass('xdex-update-link-primary'); (config.secondary || []).forEach((channel) => { $links.find(`[data-update-channel="${channel}"]`).addClass('xdex-update-link-secondary'); }); } setTimeout(() => { clearFooterUpdateHighlight(); }, 5000); } function renderUpdateLogDialog(mode = 'local', state = getUpdateCheckState()) { const $dlg = $('#sp_update_log'); if (!$dlg.length) return; const isRemote = mode === 'remote' && state && state.pendingUpdateVersion && compareVersionStrings(state.pendingUpdateVersion, VERSION) > 0; const title = isRemote ? `发现新版本 v${state.pendingUpdateVersion}` : '更新日志'; const bodyText = isRemote ? (state.pendingUpdateChangelog || `发现新版本 v${state.pendingUpdateVersion},但未提取到更新说明。`) : (CHANGELOG || '暂无更新说明'); $dlg.attr('data-update-mode', isRemote ? 'remote' : 'local'); $dlg.find('.xdex-update-log-title').text(title); $dlg.find('.xdex-update-log-body').text(bodyText); $dlg.find('.xdex-update-log-actions').css('display', isRemote ? 'flex' : 'none'); } function openUpdateLogDialog(mode = 'local') { const state = getUpdateCheckState(); renderUpdateLogDialog(mode, state); $('#sp_update_log').fadeIn(120); } function closeUpdateLogDialog(options = {}) { const { treatAsDismiss = false, reason = 'unknown' } = options || {}; const mode = $('#sp_update_log').attr('data-update-mode') || ''; if (treatAsDismiss) { const state = getUpdateCheckState(); state.lastDismissDate = formatLocalDateKey(); state.dismissedUntil = state.nextCheckAt || getNextNaturalCheckAt(); setUpdateCheckState(state); updateSettingsButtonBadge(state); console.log('[update-check] dismiss dialog:', { reason, mode, treatAsDismiss: true, lastDismissDate: state.lastDismissDate, dismissedUntil: state.dismissedUntil, dismissedUntilISO: state.dismissedUntil ? new Date(state.dismissedUntil).toISOString() : '' }); } else { console.log('[update-check] close dialog:', { reason, mode, treatAsDismiss: false }); } $('#sp_update_log').fadeOut(120); } function maybeShowPendingUpdateDialogOnPanelOpen() { const state = getUpdateCheckState(); updateSettingsButtonBadge(state); if (shouldShowPendingUpdateReminder(state)) { openUpdateLogDialog('remote'); } } async function checkForDailyScriptUpdate(force = false) { const now = Date.now(); const today = formatLocalDateKey(now); const state = getUpdateCheckState(); const alreadyChecked = !force && state.nextCheckAt && now < state.nextCheckAt; console.log('[update-check] start:', { force, now, today, currentVersion: VERSION, state: Object.assign({}, state), alreadyChecked, nextCheckAtISO: state.nextCheckAt ? new Date(state.nextCheckAt).toISOString() : '' }); if (alreadyChecked) { console.log('[update-check] skip: already checked for current window', { force, now, nextCheckAt: state.nextCheckAt, nextCheckAtISO: new Date(state.nextCheckAt).toISOString() }); updateSettingsButtonBadge(state); return state; } state.lastCheckDate = today; state.nextCheckAt = getNextNaturalCheckAt(now); console.log('[update-check] scheduled next check:', { lastCheckDate: state.lastCheckDate, nextCheckAt: state.nextCheckAt, nextCheckAtISO: new Date(state.nextCheckAt).toISOString() }); try { const settled = await Promise.allSettled([ fetchMetaVersionAndChangelog(UPDATE_GREASYFORK_META_URL, 'greasyfork'), fetchScriptCatVersionAndChangelog(UPDATE_SCRIPTCAT_API_URL, 'scriptcat') ]); const sourceResults = settled.map((item, index) => { const source = index === 0 ? 'greasyfork' : 'scriptcat'; if (item.status === 'fulfilled') { console.log('[update-check] remote meta success:', item.value); return item.value; } console.warn(`[update-check] remote meta failed: ${source}`, item.reason); return null; }); const preferredRemote = choosePreferredRemoteMeta(sourceResults); console.log('[update-check] remote meta choice:', { localVersion: VERSION, candidates: sourceResults, preferred: preferredRemote }); const remoteVersion = preferredRemote ? String(preferredRemote.version || '').trim() : ''; const remoteChangelog = preferredRemote ? String(preferredRemote.changelog || '').trim() : ''; state.latestRemoteVersion = remoteVersion; if (remoteVersion && compareVersionStrings(remoteVersion, VERSION) > 0) { state.pendingUpdateVersion = remoteVersion; state.pendingUpdateDetectedAt = now; state.pendingUpdateChangelog = remoteChangelog || `发现新版本 v${remoteVersion},但未提取到更新说明。`; state.pendingUpdateSource = preferredRemote ? String(preferredRemote.source || '').trim() : ''; if (state.dismissedUntil && now >= state.dismissedUntil) { state.dismissedUntil = 0; state.lastDismissDate = ''; } if (state.ignoredVersion && compareVersionStrings(state.ignoredVersion, remoteVersion) < 0) { state.ignoredVersion = ''; } } else { state.pendingUpdateVersion = ''; state.pendingUpdateChangelog = ''; state.pendingUpdateSource = ''; state.pendingUpdateDetectedAt = 0; } } catch (e) { console.warn('[update-check] daily update check failed:', e); } console.log('[update-check] final state before save:', state); setUpdateCheckState(state); updateSettingsButtonBadge(state); return state; } const toastQueue = []; let isShowing = false; function toast(msg, duration = 1800) { toastQueue.push({ msg, duration }); if (!isShowing) showNextToast(); } function showNextToast() { if (toastQueue.length === 0) { isShowing = false; return; } isShowing = true; const { msg, duration } = toastQueue.shift(); console.log('[toast]', msg); // ✅ 每次创建一个新的 toast 节点 const $t = $(``); $('body').append($t); $t.fadeIn(240).delay(duration).fadeOut(240, () => { $t.remove(); // ✅ 动画结束后删除节点 showNextToast(); // ✅ 显示下一个 }); } const Utils = { // 逗号(中英)分隔,支持转义 \, \, \\ strToList(s) { if (!s) return []; const list = [], esc = ',,\\'; let cur = ''; for (let i = 0; i < s.length; i++) { const ch = s[i]; if (ch === '\\' && i + 1 < s.length && esc.includes(s[i+1])) { cur += s[++i]; } else if (ch === ',' || ch === ',') { const t = cur.trim(); if (t) list.push(t); cur = ''; } else cur += ch; } const t = cur.trim(); if (t) list.push(t); return [...new Set(list)]; }, cookieLegal: s => /^[A-Za-z0-9]{3,7}$/.test(s), cookieMatch: (cid,p) => cid.toLowerCase().includes(p.toLowerCase()), firstHit(txt,list) { return list.find(k=>txt.toLowerCase().includes(k.toLowerCase()))||null; }, collapse($elem, hint) { if (!$elem.length || $elem.data('xdex-collapsed')) return; const $icons = $elem.find('.h-threads-item-reply-icon'); let nums = ''; if ($icons.length) { const f = $icons.first().text(); const l = $icons.last().text(); nums = $icons.length>1 ? `${f}-${l} ` : `${f} `; } const cap = `${nums}${hint}`; const $ph = $(`
${cap}(点击展开)
`); $elem.before($ph).hide().data('xdex-collapsed',true); $elem.addClass('xdex-generic-collapsed'); // ★ 标记为公用折叠,以免触发板块页长串折叠/收起 $ph.on('click',()=>{ if($elem.is(':visible')){ $elem.hide(); $ph.html(`${cap}(点击展开)`); } else { $elem.show(); $ph.text('点击折叠'); } }); return $ph; }, // ===== 引用串优化缓存相关 ===== quoteCache: {}, getQuoteFromCache(id) { return this.quoteCache[id] || GM_getValue('quote_' + id, null); }, saveQuoteToCache(id, html) { this.quoteCache[id] = html; GM_setValue('quote_' + id, html); } }; // 多分组标记时依次使用的背景色(可扩充) const markColors = [ '#66CCFF','#00FFCC','#EE0000','#006666','#0080FF','#FFFF00', '#39C5BB','#9999FF','#FF4004','#3399FF','#D80000','#F6BE71', '#EE82EE','#FFA500','#FFE211','#FAAFBE','#0000FF' ]; // 解析"最后一个冒号分隔"的分组:返回 {desc, list} function parseDescAndListByLastColon(raw) { const idx = Math.max(raw.lastIndexOf(':'), raw.lastIndexOf(':')); let desc = ''; let cookiePart = ''; if (idx > 0) { // 有冒号:冒号前是备注/说明,冒号后是饼干 desc = raw.slice(0, idx).trim(); cookiePart = raw.slice(idx + 1).trim(); } else { // 没有冒号:整个字符串都是饼干 cookiePart = raw.trim(); } const list = Utils.strToList(cookiePart); return { desc, list }; } // 校验分组说明长度(<=20 字符;满足“10个汉字/20个英文字符”的近似约束) function isValidDesc(desc) { return !desc || desc.length <= 20; } function isValidThreadId(threadId) { return /^\d{8}$/.test(threadId); } function parseThreadCookieWhitelistRule(raw) { const idx = Math.max(raw.lastIndexOf(':'), raw.lastIndexOf(':')); let threadPart = ''; let cookiePart = ''; if (idx > 0) { threadPart = raw.slice(0, idx).trim(); cookiePart = raw.slice(idx + 1).trim(); } else { threadPart = raw.trim(); } return { threads: Utils.strToList(threadPart), cookies: Utils.strToList(cookiePart), }; } function buildThreadCookieWhitelistRowHtml(index, group = {}) { const desc = group.desc || ''; const threadText = Array.isArray(group.threads) ? group.threads.join(',') : ''; const cookieText = Array.isArray(group.cookies) ? group.cookies.join(',') : ''; return ` `; } function buildCookieGroupRowHtml(type, index, value, placeholder) { return `
#${index}
`; } function buildCookieGroupTwoFieldRowHtml(type, index, group = {}) { const desc = group.desc || ''; const cookieText = Array.isArray(group.cookies) ? group.cookies.join(',') : ''; return `
#${index}
`; } function normalizeThreadCookieWhitelistGroups(val) { if (!Array.isArray(val)) return []; return val.map((g) => ({ desc: typeof g.desc === 'string' && isValidDesc(g.desc.trim()) ? g.desc.trim() : '', threads: Array.isArray(g.threads) ? [...new Set(g.threads.filter(isValidThreadId))] : [], cookies: Array.isArray(g.cookies) ? [...new Set(g.cookies.filter(Utils.cookieLegal))] : [], })).filter((g) => g.threads.length && g.cookies.length); } function mergeThreadCookieWhitelistGroups(groups) { const threadOrder = []; const threadToState = new Map(); const mergeEvents = []; groups.forEach((group, idx) => { const desc = typeof group.desc === 'string' && isValidDesc(group.desc.trim()) ? group.desc.trim() : ''; const cookies = [...new Set((group.cookies || []).filter(Utils.cookieLegal))]; const threads = [...new Set((group.threads || []).filter(isValidThreadId))]; threads.forEach((threadId) => { if (!threadToState.has(threadId)) { threadToState.set(threadId, { desc, cookies: new Set(), firstIndex: typeof group.rowIndex === 'number' ? group.rowIndex : idx }); threadOrder.push(threadId); } else { mergeEvents.push({ threadId, rowIndex: typeof group.rowIndex === 'number' ? group.rowIndex : idx, desc, cookies, }); } cookies.forEach((cookie) => threadToState.get(threadId).cookies.add(cookie)); }); }); const grouped = new Map(); const mergedGroups = []; threadOrder.forEach((threadId) => { const state = threadToState.get(threadId); const cookies = Array.from(state.cookies); const desc = state.desc || ''; const key = `${desc}\u0002${cookies.slice().sort().join('\u0001')}`; if (!grouped.has(key)) { const group = { desc, threads: [], cookies }; grouped.set(key, group); mergedGroups.push(group); } grouped.get(key).threads.push(threadId); }); return { groups: mergedGroups, mergeEvents, }; } // 兼容旧版本 blockedCookies 值到“组结构” function normalizeBlockedGroups(val) { if (!val) return []; if (typeof val === 'string') { const tokens = Utils.strToList(val); return tokens.map(t=>{ const {desc, list} = parseDescAndListByLastColon(t); const id = list[0] || ''; return id && Utils.cookieLegal(id) ? { desc, cookies:[id] } : null; }).filter(Boolean); } if (Array.isArray(val)) { if (val.length && 'cookies' in val[0]) { return val.map(g=>({ desc: g.desc || '', cookies: Array.isArray(g.cookies) ? g.cookies.filter(Utils.cookieLegal) : [] })).filter(g=>g.cookies.length); } if (val.length && 'cookie' in val[0]) { const map = new Map(); val.forEach(({cookie, desc})=>{ if (!Utils.cookieLegal(cookie)) return; const key = desc || ''; if (!map.has(key)) map.set(key, []); map.get(key).push(cookie); }); return [...map.entries()].map(([desc, cookies])=>({desc, cookies})); } } return []; } function normalizeMarkedGroups(val) { if (!val) return []; if (typeof val === 'string') { const tokens = Utils.strToList(val); return tokens.map(t => { const { desc, list } = parseDescAndListByLastColon(t); const cookies = list.filter(Utils.cookieLegal); return cookies.length ? { desc, cookies } : null; }).filter(Boolean); } if (Array.isArray(val)) { if (val.length && 'cookies' in val[0]) { return val.map(g => ({ desc: typeof g.desc === 'string' && isValidDesc(g.desc.trim()) ? g.desc.trim() : '', cookies: Array.isArray(g.cookies) ? [...new Set(g.cookies.filter(Utils.cookieLegal))] : [] })).filter(g => g.cookies.length); } if (val.length && 'cookie' in val[0]) { const map = new Map(); val.forEach(({ cookie, desc }) => { if (!Utils.cookieLegal(cookie)) return; const key = typeof desc === 'string' && isValidDesc(desc.trim()) ? desc.trim() : ''; if (!map.has(key)) map.set(key, []); map.get(key).push(cookie); }); return [...map.entries()].map(([desc, cookies]) => ({ desc, cookies: [...new Set(cookies)] })); } } return []; } // 双刷新支持:如果上一次保存设置时要求执行第二次重载(localStorage 标记), // 则在页面加载时触发第二次重载并清理标记。 try { const _flag = localStorage.getItem('myScriptSettings_doSecondReload'); if (_flag === '1') { localStorage.removeItem('myScriptSettings_doSecondReload'); console.log('[Settings] detected second-reload flag, performing second reload'); // 延迟短暂时间以让页面先完成初始化任务,再执行第二次重载 setTimeout(() => { try { location.reload(); } catch (e) { console.warn(e); } }, 200); } } catch (e) { /* ignore */ } /* -------------------------------------------------- * tag 1. 设置面板 * -------------------------------------------------- */ const SettingPanel = { key: 'myScriptSettings', defaults: { enableCookieSwitch: true, disableWatermark: true, // note: `duplicatePagination` 已弃用,使用 `enablePaginationDuplication` enablePaginationDuplication: true, updatePreviewCookie: true, hideEmptyTitleEmail: true, enableExternalImagePreview: true, // 外部图床显示 enableAutoCookieRefresh: true, enableAutoCookieRefreshToast: false, interceptReplyFormUnvcode: true, // 拦截回复中间页--unvcode interceptReplyFormU200B: true, interceptReplyFormAutoCompress: true, enableSeamlessPaging: true, enableAutoSeamlessPaging: true, enableHDImageAndLayoutFix: true, // 启用高清图片链接 enableLinkBlank: true, // 串页新标签打开 enableAutoUrlLinkify: true, enableQuotePreview: true, // 优化引用弹窗 enableImageHideMode: true, // 图片隐藏/无图模式 applyImageHideMode: 'default', // default | blur | noimage | tips enableDraft: true, extendQuote: true, // 拓展引用格式 enablePostExpandAll: true, // 默认展开板块页长串 kaomojiSort: 'default', // 颜文字排序:default | freq | recent toggleSidebar: false, // 侧边栏收起功能 threadCookieWhitelistGroups: [], threadCookieWhitelistDisplayMode: 'fold', // 只看饼干:fold | hide | column poAnnotationSideDisplayMode: 'collapse', // 分栏侧栏:collapse | expand replyModeDefault: '回复', // 板块页默认模式:发串/回复 replyExtraDefault: '临时', // 板块/时间线默认额外模式:临时/连续 markedGroups: [], blockedCookies: [], blockedKeywords: '', blockDisplayMode: 'hide' // fold = 折叠 | hide = 隐藏 }, state: {}, // JSONC 解析(支持 // 注释和尾随逗号) parseJSONC(str) { const cleaned = str .replace(/\/\/.*$/gm, '') .replace(/,(\s*[}\]])/g, '$1'); return JSON.parse(cleaned); }, // 导出为 JSONC 字符串 buildJSONC(state) { const lines = [ '// X岛-EX 配置文件', '// 支持 // 注释和尾随逗号', '{', ' "_meta": {', ` "version": "${typeof VERSION !== 'undefined' ? VERSION : 'unknown'}",`, ` "exportedAt": "${new Date().toISOString()}",`, ' "source": "nmbxd-EX"', ' },', ' "settings": {', ]; const entries = Object.entries(state); entries.forEach(([key, val], i) => { const json = JSON.stringify(val, null, 2); if (json.includes('\n')) { const indented = json.split('\n').map((line, li) => li === 0 ? line : ' ' + line).join('\n'); lines.push(` ${JSON.stringify(key)}: ${indented}${i < entries.length - 1 ? ',' : ''}`); } else { lines.push(` ${JSON.stringify(key)}: ${json}${i < entries.length - 1 ? ',' : ''}`); } }); lines.push(' },'); lines.push('}'); return lines.join('\n'); }, // 校验导入的配置 validateImport(incoming) { if (typeof incoming !== 'object' || incoming === null) return { valid: false, error: '配置内容无效' }; const defaults = this.defaults; const validated = {}; let skipped = 0; for (const [key, val] of Object.entries(incoming)) { if (!(key in defaults)) continue; if (typeof val !== typeof defaults[key]) { skipped++; continue; } if (Array.isArray(defaults[key]) && !Array.isArray(val)) { skipped++; continue; } validated[key] = val; } return { valid: true, merged: Object.assign({}, defaults, validated), skipped }; }, // 从文本导入(校验 + 暂存) importFromText(text) { let parsed; try { parsed = this.parseJSONC(text); } catch (e) { toast('配置文件格式错误'); return; } const incoming = parsed.settings || parsed; const result = this.validateImport(incoming); if (!result.valid) { toast(result.error); return; } this.__pendingImport = result.merged; const msg = result.skipped > 0 ? `格式正确(${result.skipped} 个字段已跳过),请点击[应用]` : '格式正确,请点击[应用]'; toast(msg); const btn = document.getElementById('btn_xdex_import_export'); if (btn) btn.classList.remove('xdex-inv'); }, // 导出到剪贴板 async exportToClipboard() { try { const jsonc = this.buildJSONC(this.state); await navigator.clipboard.writeText(jsonc); toast('配置已复制到剪贴板'); } catch (e) { toast('复制失败,请重试'); } }, // 导出为文件 exportToFile() { const jsonc = this.buildJSONC(this.state); const blob = new Blob([jsonc], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `x岛-ex-settings-v${typeof VERSION !== 'undefined' ? VERSION : 'unknown'}.jsonc`; a.click(); URL.revokeObjectURL(a.href); toast('配置已导出'); }, // 从剪贴板导入 async importFromClipboard() { try { const text = await navigator.clipboard.readText(); if (!text) { toast('剪贴板为空'); return; } this.importFromText(text); } catch (e) { toast('无法读取剪贴板,请先点击页面后再试'); } }, // 从文件导入 importFromFile() { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json,.jsonc,.txt'; input.onchange = () => { if (!input.files || !input.files[0]) return; const reader = new FileReader(); reader.onload = () => this.importFromText(reader.result); reader.readAsText(input.files[0]); }; input.click(); }, syncAuxiliaryControls() { const whitelistModeSelect = document.getElementById('sp_threadCookieWhitelistDisplayMode'); if (whitelistModeSelect) { whitelistModeSelect.value = (this.state && this.state.threadCookieWhitelistDisplayMode) || 'fold'; } const poSideModeSelect = document.getElementById('sp_poAnnotationSideDisplayMode'); if (poSideModeSelect) { poSideModeSelect.value = (this.state && this.state.poAnnotationSideDisplayMode) || 'collapse'; } const draftSelect = document.getElementById('sp_enableDraftMode'); if (draftSelect) { const enabled = !!(this.state && this.state.enableDraft); draftSelect.value = enabled ? 'off' : 'on'; } const expandSelect = document.getElementById('sp_postExpandAllMode'); if (expandSelect) { const enabled = !!(this.state && this.state.enablePostExpandAll); expandSelect.value = enabled ? 'expand' : 'collapse'; } }, init() { const saved = GM_getValue(this.key, {}); const isFirstInit = Object.keys(saved).length === 0; // 判断是否首次初始化 console.log('init读取的原始数据:', JSON.stringify(saved)); this.state = Object.assign({}, this.defaults, saved); // 该功能为固定启用项:避免历史配置把它保存为 false 导致下拉无法生效 this.state.enableImageHideMode = true; console.log('init合并后的state:', JSON.stringify(this.state)); // 兼容迁移:屏蔽饼干到组结构 this.state.markedGroups = normalizeMarkedGroups(this.state.markedGroups); this.state.blockedCookies = normalizeBlockedGroups(this.state.blockedCookies); this.state.threadCookieWhitelistGroups = normalizeThreadCookieWhitelistGroups(this.state.threadCookieWhitelistGroups); // 清理废弃字段 const validKeys = Object.keys(this.defaults); let needCleanup = false; Object.keys(this.state).forEach(key => { if (!validKeys.includes(key)) { delete this.state[key]; needCleanup = true; } }); console.log('init清理后的state:', JSON.stringify(this.state)); // 只在首次初始化或需要清理废弃字段时才保存 if (isFirstInit || needCleanup) { console.log('首次初始化或需要清理,执行保存'); GM_setValue(this.key, this.state); } this.render(); GM_addValueChangeListener(this.key,(k,ov,nv,remote)=>{ if(remote){ this.state = Object.assign({}, this.defaults, nv); this.state.markedGroups = normalizeMarkedGroups(this.state.markedGroups); this.state.blockedCookies = normalizeBlockedGroups(this.state.blockedCookies); this.state.threadCookieWhitelistGroups = normalizeThreadCookieWhitelistGroups(this.state.threadCookieWhitelistGroups); this.syncInputs(); this.syncAuxiliaryControls(); try { refreshFilterDisplay(this.state); } catch (e) {} } }); }, render() { if (!$('#xdex-setting-style').length) { $('head').append(` `); } if (!$('#sp_btn').length) { $('body').append( $('') .on('click',()=>{ this.syncInputs(); this.syncAuxiliaryControls(); if (typeof window.__xdexSyncDarkReaderTheme === 'function') window.__xdexSyncDarkReaderTheme(); $('#sp_cover').fadeIn(); maybeShowPendingUpdateDialogOnPanelOpen(); }) ); updateSettingsButtonBadge(getUpdateCheckState()); } const fold = (id,title,ph) => `
${title}
`; const checkboxItemStyle = 'width:50%; display:flex; align-items:center; gap:8px;'; const checkboxRowStyle = 'width:50%; display:flex; align-items:center; gap:8px;'; const quickReplyRowStyle = 'display:flex; align-items:center; gap:12px; margin:4px 0;'; const quickReplyColumnStyle = 'flex:1; display:flex; flex-direction:column; align-items:flex-start; gap:4px;'; const html = ` `; $('#sp_cover').remove(); $('body').append(html); // 折叠头:统一控制 $('.sp_fold_head').off('click').on('click', function(){ const $head = $(this); $head.next('.sp_fold_body').slideToggle(150); const btns = ($head.data('btn') || '').split(','); btns.forEach(sel => $(sel).toggleClass('xdex-inv')); }); // 同步已有配置 & 默认折叠 this.syncInputs(); // 图片隐藏模式:即时切换并即时应用(无需点“应用更改”) const applyImageHideModeImmediately = () => { const mode = $('#sp_applyImageHideMode').val() || 'default'; // 固定启用,仅切换具体模式 this.state.enableImageHideMode = true; this.state.applyImageHideMode = mode; try { GM_setValue(this.key, this.state); } catch (e) {} if (typeof applyImageHideMode === 'function') { applyImageHideMode(mode, document); } }; $('#sp_enableImageHideMode').off('change').on('change', applyImageHideModeImmediately); $('#sp_applyImageHideMode').off('change').on('change', applyImageHideModeImmediately); // 屏蔽显示模式:即时切换并即时生效(折叠/隐藏) const applyBlockDisplayModeImmediately = () => { const mode = $('#sp_blockDisplayMode').val() || 'fold'; this.state.blockDisplayMode = mode; try { GM_setValue(this.key, this.state); } catch (e) {} refreshFilterDisplay(this.state); }; $('#sp_blockDisplayMode').off('change').on('change', applyBlockDisplayModeImmediately); const applyThreadCookieWhitelistDisplayModeImmediately = () => { this.state.threadCookieWhitelistDisplayMode = $('#sp_threadCookieWhitelistDisplayMode').val() || 'fold'; this.state.poAnnotationSideDisplayMode = $('#sp_poAnnotationSideDisplayMode').val() || 'collapse'; try { GM_setValue(this.key, this.state); } catch (e) {} refreshFilterDisplay(this.state); }; $('#sp_threadCookieWhitelistDisplayMode').off('change').on('change', applyThreadCookieWhitelistDisplayModeImmediately); $('#sp_poAnnotationSideDisplayMode').off('change').on('change', applyThreadCookieWhitelistDisplayModeImmediately); // 颜文字排序:即时切换并即时生效(无需点“应用更改”) const applyKaomojiSortImmediately = () => { const mode = $('#sp_kaomojiSort').val() || 'default'; this.state.kaomojiSort = mode; try { GM_setValue(this.key, this.state); } catch (e) {} // 与颜文字按钮右侧的快捷下拉实时同步 $('.sp_kaomojiSort_copy').val(mode); document.querySelectorAll('#h-emot-select').forEach(sel => { try { sel.dispatchEvent(new Event('kaomoji:sort-changed')); } catch (e) {} }); // 广播排序模式变化,供其它复制下拉同步 try { window.dispatchEvent(new CustomEvent('kaomoji:sort-mode-changed', { detail: { mode, source: 'settings' } })); } catch (e) {} }; $('#sp_kaomojiSort').off('change').on('change', applyKaomojiSortImmediately); (function initPostExpandModeSelect() { const sel = document.getElementById('sp_postExpandAllMode'); if (!sel) return; sel.value = (SettingPanel.state && SettingPanel.state.enablePostExpandAll) ? 'expand' : 'collapse'; sel.addEventListener('change', (e) => { e.stopPropagation(); const nextState = (sel.value || 'expand') === 'expand'; SettingPanel.state.enablePostExpandAll = nextState; const items = document.querySelectorAll('.h-threads-item-index'); let anchor = null; for (const item of items) { const rect = item.getBoundingClientRect(); if (rect.top >= 0) { anchor = item; break; } } if (!anchor && items.length) anchor = items[items.length - 1]; const anchorTopBefore = anchor ? anchor.getBoundingClientRect().top : null; items.forEach(item => { const toggleBtn = item.querySelector('.h-threads-info .js-toggle-mode'); if (!toggleBtn) return; const expanded = item.classList.contains('expanded'); if (nextState && !expanded) { toggleBtn.click(); } else if (!nextState && expanded) { toggleBtn.click(); } }); if (!nextState && anchor && anchorTopBefore !== null) { requestAnimationFrame(() => { const anchorTopAfter = anchor.getBoundingClientRect().top; const delta = anchorTopAfter - anchorTopBefore; window.scrollBy({ top: delta, behavior: 'instant' }); }); } try { GM_setValue(SettingPanel.key, SettingPanel.state); } catch (err) {} toast(nextState ? '已展开长串' : '已折叠长串'); }); })(); (function initDraftModeSelect() { const sel = document.getElementById('sp_enableDraftMode'); if (!sel) return; sel.value = (SettingPanel.state && SettingPanel.state.enableDraft) ? 'off' : 'on'; sel.addEventListener('change', (e) => { e.stopPropagation(); const enabled = (sel.value || 'off') === 'off'; SettingPanel.state.enableDraft = enabled; try { GM_setValue(SettingPanel.key, SettingPanel.state); } catch (err) {} if (!enabled) { deleteAllDraftsSafe(); toast('已清除缓存草稿并关闭草稿功能'); } else { try { migrateLegacyDraftIfNeeded(); } catch (err) {} try { if (typeof 载入编辑 === 'function') 载入编辑(); } catch (err) {} try { if (typeof 注册自动保存编辑 === 'function') 注册自动保存编辑(); } catch (err) {} toast('已开启草稿缓存'); } }); })(); // 标记:新增组输入 $('#btn_group_marked').off('click').on('click', e=>{ e.stopPropagation(); const nextIndex = $('#marked-inputs-container .marked-row').length + 1; $('#marked-inputs-container').append( buildCookieGroupTwoFieldRowHtml('marked', nextIndex) ).find('.marked-desc-input').last().focus(); }); // 屏蔽:新增组输入 $('#btn_group_blocked').off('click').on('click', e=>{ e.stopPropagation(); const nextIndex = $('#blocked-inputs-container .blocked-row').length + 1; $('#blocked-inputs-container').append( buildCookieGroupTwoFieldRowHtml('blocked', nextIndex) ).find('.blocked-desc-input').last().focus(); }); $('#btn_group_threadCookieWhitelist').off('click').on('click', e=>{ e.stopPropagation(); const nextIndex = $('#thread-cookie-whitelist-inputs-container .thread-cookie-whitelist-row').length + 1; $('#thread-cookie-whitelist-inputs-container').append(buildThreadCookieWhitelistRowHtml(nextIndex)); $('#thread-cookie-whitelist-inputs-container .thread-cookie-whitelist-row').last().find('.thread-cookie-whitelist-desc-input').focus(); }); const saveMarkedGroups = ({ fromDelete = false } = {}) => { const parsed = []; let valid = true; $('#marked-inputs-container .marked-row').each((idx, el)=>{ const $row = $(el); const desc = ($row.find('.marked-desc-input').val() || '').trim(); const cookies = Utils.strToList(($row.find('.marked-cookies-input').val() || '').trim()); if (!desc && !cookies.length) return; if (!isValidDesc(desc)) { toast(`第${idx + 1}条备注过长`); valid=false; return false; } if (!cookies.length) { toast(`第${idx + 1}条未指定饼干`); valid=false; return false; } if (cookies.some(id=>!Utils.cookieLegal(id))) { toast(`第${idx + 1}条存在不合法饼干`); valid=false; return false; } parsed.push({ desc, cookies }); }); if (!valid) return false; this.state.markedGroups = parsed; GM_setValue(this.key, this.state); this.syncInputs(); toast(fromDelete ? '已删除标记分组' : '标记分组已保存'); refreshFilterDisplay(this.state); return true; }; const saveBlockedGroups = ({ fromDelete = false } = {}) => { const parsed = []; let valid = true; $('#blocked-inputs-container .blocked-row').each((idx, el)=>{ const $row = $(el); const desc = ($row.find('.blocked-desc-input').val() || '').trim(); const cookies = Utils.strToList(($row.find('.blocked-cookies-input').val() || '').trim()); if (!desc && !cookies.length) return; if (!isValidDesc(desc)) { toast(`第${idx + 1}条备注过长`); valid=false; return false; } if (!cookies.length) { toast(`第${idx + 1}条未指定饼干`); valid=false; return false; } if (cookies.some(id=>!Utils.cookieLegal(id))) { toast(`第${idx + 1}条存在不合法饼干`); valid=false; return false; } parsed.push({ desc, cookies }); }); if (!valid) return false; this.state.blockedCookies = parsed; GM_setValue(this.key, this.state); this.syncInputs(); toast(fromDelete ? '已删除屏蔽分组' : '屏蔽分组已保存'); refreshFilterDisplay(this.state); return true; }; const saveThreadCookieWhitelistGroups = ({ fromDelete = false } = {}) => { const parsed = []; let valid = true; $('#thread-cookie-whitelist-inputs-container .thread-cookie-whitelist-row').each((idx, el) => { const $row = $(el); const desc = ($row.find('.thread-cookie-whitelist-desc-input').val() || '').trim(); const threads = Utils.strToList(($row.find('.thread-cookie-whitelist-threads-input').val() || '').trim()); const cookies = Utils.strToList(($row.find('.thread-cookie-whitelist-cookies-input').val() || '').trim()); if (!desc && !threads.length && !cookies.length) return; if (!isValidDesc(desc)) { toast(`第${idx + 1}条备注过长`); valid = false; return false; } if (!threads.length) { toast(`第${idx + 1}条未指定串号`); valid = false; return false; } if (threads.some(id => !isValidThreadId(id))) { toast(`第${idx + 1}条存在不合法串号`); valid = false; return false; } if (!cookies.length) { toast(`第${idx + 1}条未指定饼干`); valid = false; return false; } if (cookies.some(id => !Utils.cookieLegal(id))) { toast(`第${idx + 1}条存在不合法饼干`); valid = false; return false; } parsed.push({ desc, threads, cookies, rowIndex: idx + 1 }); }); if (!valid) return false; const { groups, mergeEvents } = mergeThreadCookieWhitelistGroups(parsed); this.state.threadCookieWhitelistGroups = groups; GM_setValue(this.key, this.state); mergeEvents.forEach((event) => { if (event.desc) { toast(`第${event.rowIndex}条(${event.desc})中的串号 ${event.threadId} 已与现有只看规则合并`); } else { toast(`第${event.rowIndex}条的串号 ${event.threadId}(${event.cookies.join(',')})已与现有只看规则合并`); } }); this.syncInputs(); toast(fromDelete ? '已删除只看饼干分组' : '只看饼干分组已保存'); refreshFilterDisplay(this.state); return true; }; $('#marked-inputs-container').off('click', '.marked-delete').on('click', '.marked-delete', (e) => { e.preventDefault(); e.stopPropagation(); $(e.currentTarget).closest('.marked-row').remove(); saveMarkedGroups({ fromDelete: true }); return false; }); $('#blocked-inputs-container').off('click', '.blocked-delete').on('click', '.blocked-delete', (e) => { e.preventDefault(); e.stopPropagation(); $(e.currentTarget).closest('.blocked-row').remove(); saveBlockedGroups({ fromDelete: true }); return false; }); $('#thread-cookie-whitelist-inputs-container').off('click', '.thread-cookie-whitelist-delete').on('click', '.thread-cookie-whitelist-delete', (e) => { e.preventDefault(); e.stopPropagation(); $(e.currentTarget).closest('.thread-cookie-whitelist-row').remove(); saveThreadCookieWhitelistGroups({ fromDelete: true }); return false; }); // 标记:保存 $('#btn_sp_marked').off('click').on('click', e=>{ e.stopPropagation(); saveMarkedGroups(); }); // 屏蔽:保存 $('#btn_sp_blocked').off('click').on('click', e=>{ e.stopPropagation(); saveBlockedGroups(); }); // 屏蔽关键词:单项保存 $('.sp_save').filter('[data-id="sp_blockedKeywords"]').off('click').on('click', e=>{ e.stopPropagation(); const v = $('#sp_blockedKeywords').val().trim(); if (v && !Utils.strToList(v).length) return toast('屏蔽关键词 规则有误'); this.state.blockedKeywords = v; GM_setValue(this.key, this.state); toast('屏蔽关键词已保存'); refreshFilterDisplay(this.state); }); $('#btn_sp_threadCookieWhitelist').off('click').on('click', e=>{ e.stopPropagation(); saveThreadCookieWhitelistGroups(); }); // ========== 导入/导出配置 ========== function parseJSONC(str) { // 逐字符解析:只在字符串外部去掉 // 注释和尾随逗号(不清理零宽字符,保留用户数据完整性) let out = '', inStr = false, esc = false; for (let i = 0; i < str.length; i++) { const ch = str[i]; if (inStr) { out += ch; if (esc) { esc = false; } else if (ch === '\\') { esc = true; } else if (ch === '"') { inStr = false; } } else { if (ch === '"') { inStr = true; out += ch; } else if (ch === '/' && str[i + 1] === '/') { // 跳过行注释 while (i < str.length && str[i] !== '\n') i++; out += '\n'; } else if (ch === ',' && /^\s*[}\]]/.test(str.slice(i + 1))) { // 尾随逗号:后面紧跟 } 或 ](允许空白),跳过逗号 continue; } else { out += ch; } } } return JSON.parse(out); } // 固定开启项:UI 中为 disabled + checked,无需导入/导出 const FIXED_KEYS = new Set([ 'enableImageHideMode', // 固定启用(applyImageHideMode 仍可导出) 'interceptReplyForm', // 拦截回复中间页 'updateReplyNumbers', // 当页回复编号 'replaceRightSidebar', // 扩展坞增强 'kaomojiEnhancer', // 颜文字拓展(kaomojiSort 仍可导出) 'highlightPO', // 标记Po主 'enhancePostFormLayout', // 发串UI调整 'applyFilters', // 标记/屏蔽-饼干/关键词 'enhanceIsland', // 增强X岛匿名版 'enablePostExpand', // 展开板块页长串 'searchServiceBy4sY', // 野生搜索酱 ]); // 清理字符串值中的零宽/不可见控制字符 function sanitizeValue(val) { if (typeof val === 'string') { return val.replace(/[\u200B\u200C\u200D\uFEFF\u200E\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069]/g, ''); } if (Array.isArray(val)) return val.map(sanitizeValue); if (val && typeof val === 'object') { const o = {}; for (const [k2, v2] of Object.entries(val)) o[k2] = sanitizeValue(v2); return o; } return val; } function buildJSONC(state) { const filtered = {}; for (const [k, v] of Object.entries(state)) { if (!FIXED_KEYS.has(k)) filtered[k] = v; // 不清理零宽字符,保留用户原始数据 } const meta = { _meta: { version: (typeof VERSION !== 'undefined' ? VERSION : GM_info.script.version), exportedAt: new Date().toISOString(), source: 'nmbxd-EX' }, settings: filtered }; const lines = JSON.stringify(meta, null, 2).split('\n'); // 在 _meta 前加注释,在 settings 闭合括号前加尾随逗号 let result = lines.map((line, i) => { if (i === 0) return line; // 开头 { if (/"_meta"/.test(line)) return ' // X岛-EX 配置文件\n' + line; if (/^}$/.test(line.trim())) return line; // 最外层 } return line; }).join('\n'); // 给 settings 对象的最后一个字段后加尾随逗号 result = result.replace(/("settings":\s*\{[\s\S]*?)(\n\s*\}\s*\n\s*\})/, (m, inner, close) => { const trimmed = inner.replace(/,\s*$/, ''); return trimmed + ',\n' + close.replace(/^\n/, ''); }); return result; } function validateImport(incoming) { const defaults = SettingPanel.defaults; if (typeof incoming !== 'object' || incoming === null) { toast('配置内容无效'); return null; } const validated = {}; let skipped = 0; for (const [key, val] of Object.entries(incoming)) { if (FIXED_KEYS.has(key)) continue; // 固定项直接跳过 if (!(key in defaults)) continue; // 未知字段跳过 if (Array.isArray(defaults[key]) && !Array.isArray(val)) { skipped++; continue; } if (typeof val !== typeof defaults[key]) { skipped++; continue; } validated[key] = val; } return Object.assign({}, defaults, validated); } function handleImportText(text) { if (!text || !text.trim()) { toast('内容为空'); return; } // 只清理文本开头的 BOM,不清理内容中的零宽字符(用户数据可能包含零宽空格) text = text.replace(/^\uFEFF/, ''); let parsed; try { parsed = parseJSONC(text); } catch (e) { toast('配置文件格式错误'); return; } const incoming = parsed.settings || parsed; const merged = validateImport(incoming); if (!merged) return; SettingPanel.__pendingImport = merged; // 显示保存按钮 $('#btn_sp_importExport').removeClass('xdex-inv'); toast('格式正确,请点击[应用]'); } // 从剪贴板导入 $('#sp_importClipboard').off('click').on('click', async () => { try { const text = await navigator.clipboard.readText(); handleImportText(text); } catch (e) { toast('无法读取剪贴板,请先点击页面后再试'); } }); // 导出到剪贴板 $('#sp_exportClipboard').off('click').on('click', async () => { try { const jsonc = buildJSONC(SettingPanel.state); await navigator.clipboard.writeText(jsonc); toast('已复制到剪贴板'); } catch (e) { toast('复制失败'); } }); // 从文件导入 $('#sp_importFile').off('click').on('click', () => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json,.jsonc,.txt'; input.onchange = () => { const file = input.files && input.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => handleImportText(reader.result); reader.onerror = () => toast('文件读取失败'); reader.readAsText(file); }; input.click(); }); // 导出为文件 $('#sp_exportFile').off('click').on('click', () => { try { const jsonc = buildJSONC(SettingPanel.state); const blob = new Blob([jsonc], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'x岛-ex-settings-v' + (typeof VERSION !== 'undefined' ? VERSION : GM_info.script.version) + '.jsonc'; a.click(); URL.revokeObjectURL(a.href); toast('配置已导出'); } catch (e) { toast('导出失败'); } }); // 导入/导出折叠块的"保存"按钮 $('#btn_sp_importExport').off('click').on('click', e => { e.stopPropagation(); if (SettingPanel.__pendingImport) { GM_setValue(SettingPanel.key, SettingPanel.__pendingImport); SettingPanel.state = SettingPanel.__pendingImport; delete SettingPanel.__pendingImport; $('#btn_sp_importExport').addClass('xdex-inv'); toast('配置已导入,即将刷新'); setTimeout(() => location.reload(), 800); } else { toast('没有待导入的配置'); } }); // 关闭面板时清除暂存 const _origClose = $('#sp_close,#sp_cover').off.bind($('#sp_close,#sp_cover'), 'click'); delete SettingPanel.__pendingImport; // 应用更改:保存开关、屏蔽(组)、标记(组) $('#sp_apply').off('click').on('click', ()=>{ [ 'enableCookieSwitch', 'disableWatermark', 'enablePaginationDuplication', 'updatePreviewCookie', 'hideEmptyTitleEmail', 'enableExternalImagePreview', 'enableAutoCookieRefresh', 'enableAutoCookieRefreshToast', 'interceptReplyFormUnvcode', 'interceptReplyFormU200B', 'interceptReplyFormAutoCompress', 'enableSeamlessPaging', 'enableAutoSeamlessPaging', 'enableHDImageAndLayoutFix', 'enableLinkBlank', 'enableAutoUrlLinkify', 'enableQuotePreview', 'extendQuote', 'toggleSidebar' ].forEach(k=> this.state[k] = $('#sp_'+k).is(':checked')); // 固定启用:不受面板勾选状态影响 this.state.enableImageHideMode = true; // 屏蔽关键词 this.state.blockedKeywords = $('#sp_blockedKeywords').val().trim(); this.state.replyModeDefault = $('#sp_replyModeDefault').val(); this.state.replyExtraDefault = $('#sp_replyExtraDefault').val(); this.state.kaomojiSort = $('#sp_kaomojiSort').val() || 'default'; this.state.applyImageHideMode = $('#sp_applyImageHideMode').val() || 'default'; this.state.threadCookieWhitelistDisplayMode = $('#sp_threadCookieWhitelistDisplayMode').val() || 'fold'; this.state.poAnnotationSideDisplayMode = $('#sp_poAnnotationSideDisplayMode').val() || 'collapse'; // 标记分组(双字段结构) const mk = []; let valid = true; $('#marked-inputs-container .marked-row').each((idx, el)=>{ const $row = $(el); const desc = ($row.find('.marked-desc-input').val() || '').trim(); const cookies = Utils.strToList(($row.find('.marked-cookies-input').val() || '').trim()); if (!desc && !cookies.length) return; if (!isValidDesc(desc)) { toast(`第${idx + 1}条备注过长`); valid=false; return false; } if (!cookies.length) { toast(`第${idx + 1}条未指定饼干`); valid=false; return false; } if (cookies.some(id=>!Utils.cookieLegal(id))) { toast(`第${idx + 1}条存在不合法饼干`); valid=false; return false; } mk.push({ desc, cookies }); }); if (!valid) return; this.state.markedGroups = mk; // 屏蔽分组(双字段结构) const bk = []; $('#blocked-inputs-container .blocked-row').each((idx, el)=>{ const $row = $(el); const desc = ($row.find('.blocked-desc-input').val() || '').trim(); const cookies = Utils.strToList(($row.find('.blocked-cookies-input').val() || '').trim()); if (!desc && !cookies.length) return; if (!isValidDesc(desc)) { toast(`第${idx + 1}条备注过长`); valid=false; return false; } if (!cookies.length) { toast(`第${idx + 1}条未指定饼干`); valid=false; return false; } if (cookies.some(id=>!Utils.cookieLegal(id))) { toast(`第${idx + 1}条存在不合法饼干`); valid=false; return false; } bk.push({ desc, cookies }); }); if (!valid) return; this.state.blockedCookies = bk; const wlg = []; $('#thread-cookie-whitelist-inputs-container .thread-cookie-whitelist-row').each((idx, el) => { const $row = $(el); const desc = ($row.find('.thread-cookie-whitelist-desc-input').val() || '').trim(); const threads = Utils.strToList(($row.find('.thread-cookie-whitelist-threads-input').val() || '').trim()); const cookies = Utils.strToList(($row.find('.thread-cookie-whitelist-cookies-input').val() || '').trim()); if (!desc && !threads.length && !cookies.length) return; if (!isValidDesc(desc)) { toast(`第${idx + 1}条备注过长`); valid = false; return false; } if (!threads.length) { toast(`第${idx + 1}条未指定串号`); valid = false; return false; } if (threads.some(id => !isValidThreadId(id))) { toast(`第${idx + 1}条存在不合法串号`); valid = false; return false; } if (!cookies.length) { toast(`第${idx + 1}条未指定饼干`); valid = false; return false; } if (cookies.some(id => !Utils.cookieLegal(id))) { toast(`第${idx + 1}条存在不合法饼干`); valid = false; return false; } wlg.push({ desc, threads, cookies, rowIndex: idx + 1 }); }); if (!valid) return; const { groups: mergedWhitelistGroups, mergeEvents } = mergeThreadCookieWhitelistGroups(wlg); this.state.threadCookieWhitelistGroups = mergedWhitelistGroups; mergeEvents.forEach((event) => { if (event.desc) { toast(`第${event.rowIndex}条(${event.desc})中的串号 ${event.threadId} 已与现有只看规则合并`); } else { toast(`第${event.rowIndex}条的串号 ${event.threadId}(${event.cookies.join(',')})已与现有只看规则合并`); } }); // 原版 // GM_setValue(this.key, this.state); // toast('保存成功,即将刷新页面'); // setTimeout(()=>location.reload(),500); // Edge双重刷新版 // GM_setValue(this.key, this.state); // console.log('GM_setValue执行成功'); // // 立即读取验证 // const saved = GM_getValue(this.key); // console.log('读取验证:', JSON.stringify(saved)); // toast('保存成功,即将刷新页面'); // // Edge 浏览器需要更长的延迟确保存储完成 // setTimeout(() => { // // 再次验证保存是否成功 // const finalCheck = GM_getValue(this.key); // console.log('刷新前最终验证:', JSON.stringify(finalCheck)); // // 两次刷新 // try { // // 设置标记,下一次页面加载时脚本会检测到并执行第二次重载 // localStorage.setItem(this.key + '_doSecondReload', '1'); // } catch (e) { // console.warn('[Settings] set second-reload flag failed', e); // } // location.reload(); // }, 800); // 将延迟从 500ms 增加到 800ms // 当前版本 GM_setValue(this.key, this.state); console.log('GM_setValue执行成功'); // 立即读取验证 const saved = GM_getValue(this.key); console.log('读取验证:', JSON.stringify(saved)); toast('保存成功,即将刷新页面'); // Edge 浏览器需要更长的延迟确保存储完成 setTimeout(() => { // 再次验证保存是否成功 const finalCheck = GM_getValue(this.key); console.log('刷新前最终验证:', JSON.stringify(finalCheck)); // 只刷新一次 location.reload(); }, 800); // 将延迟从 500ms 增加到 800ms }); // 关闭面板 $('#sp_close,#sp_cover').off('click').on('click', e=>{ if (e.target.id==='sp_close' || e.target.id==='sp_cover') $('#sp_cover').fadeOut(); }); //鼠标悬浮在具体功能上显示提示 // ====== 1. 定义功能描述映射表 ====== const spDescriptions = { sp_enableCookieSwitch: '发帖框上方添加饼干切换器,单击即可快速切换饼干。使用前可单击“刷新”以获取当前登陆账户最新饼干列表。', sp_enablePaginationDuplication: '在串首页添加页码导航栏', sp_disableWatermark: '取消发图默认勾选的水印选项', sp_updatePreviewCookie: '为“增强X岛匿名版”添加的预览框显示真实饼干', sp_hideEmptyTitleEmail: '隐藏帖内无标题、无名氏和版规提示,优化显示效果,减少版面占用', sp_enableExternalImagePreview: '直接显示外部图床的图片', sp_enableAutoCookieRefresh: '回到X岛页面后自动刷新饼干,以防错饼', sp_enableAutoCookieRefreshToast: '自动刷新时显示toast提示,触发频率较高,建议关闭', sp_enableSeamlessPaging: '阅读到页面底部时无缝加载下一页并为新页首添加页码提示', sp_enableAutoSeamlessPaging: '滚动到页面底部后自动触发无缝翻页,关闭则可使用按钮手动无缝翻页', sp_enableHDImageAndLayoutFix: 'X岛-揭示板的增强型体验:默认加载原图而非缩略图,并为所有图片添加X岛自带图片控件;调整布局,防止文字与图片溢出', sp_enableLinkBlank: 'X岛-揭示板的增强型体验:串页链接在新标签页打开', sp_enableAutoUrlLinkify: '自动将正文中的网址转换为可点击的新标签页蓝色链接,可与“拓展引用格式”共存', sp_enableQuotePreview: '优化引用弹窗显示,将鼠标悬停出现引用弹窗改为点击显示引用弹窗,引用弹窗可持久存在,支持嵌套、拖拽,点击非引用弹窗区域或ESC键可关闭当前引用弹窗,点击右下角×以关闭全部引用弹窗', sp_extendQuote: '拓展引用格式,支持除“>>No.66994128”标准引用格式外的引用,例如“>>66994128”、“66994128”、“No.66994128”,同样支持“优化引用弹窗”', sp_threadCookieWhitelistModeEnabled: '只看饼干模式。折叠:保持原版只看饼干折叠逻辑;隐藏:未命中的回复直接隐藏;分栏:重点回复保留在主阅读流,观众回复进入侧栏批注。', sp_poAnnotationSideDisplayMode: '分栏模式下观众回复栏的显示状态。展开:完整展开;收起:默认高度不超过对应主回复高度,超出部分滚动。', sp_toggleSidebar: '来自acVMxuv的自动收起右侧扩展坞侧边栏,鼠标悬停时展开显示', sp_updateReplyNumbers: '添加当页内回复编号显示', sp_replaceRightSidebar: '增强右侧扩展坞功能,点击REPLY按钮打开回复弹窗,点击非回复弹窗区域或ESC键可关闭回复弹窗,另外支持使用CTRL+ENTER发送消息', sp_interceptReplyForm: '拦截回复跳转中间页,使用toast提示发送成功/失败信息', sp_interceptReplyFormUnvcode: '不可明说的功能,请参照https://words-away.typeboom.com/说明', sp_interceptReplyFormU200B: '优先使用插入零宽空格模式而非unvcode替换模式', sp_interceptReplyFormAutoCompress: '自动压缩>2048KB的图片。', sp_kaomojiEnhancer: '拓展颜文字功能,添加更多颜文字(部分来自蓝岛),优化选择颜文字弹窗,选择颜文字后可插入光标所在处。支持排序:默认(原顺序)/常用(使用次数高优先)/最近(最近使用优先,未使用保持默认顺序)。', sp_highlightPO: '为回复添加Po主标志,PO主回复编号使用角标显示', sp_enhancePostFormLayout: '优化发串/回复表单布局,将“送出”按钮移至颜文字栏目,折叠“标题”“E-mail”“名称”等不常用项目,节省版面', sp_applyFilters: '标记/屏蔽-饼干/关键词过滤规则\n折叠:匹配到的串/回复显示为可展开的按钮\n隐藏:匹配到的串/回复完全隐藏', sp_enhanceIsland: '增强X岛匿名版:\n1.发串前显示预览:麻麻再也不用担心我的ASCII ART排版失误了,另外支持预览插入图片和外部图床图片;\n2.自动保存编辑:记忆文本框内容(防止屏蔽词导致被吞),可以在翻页等各种页面切换后保存,仅在“回复成功”后删除,按主串号 "/t/xxxx" 分开存储;\n3.追记引用串号:点击串号回复时附加到光标所在处(或替换文本选区),可追记多条引用;\n4.人类友好的时间显示:如“5秒前”、“1小时前”、“昨天”等;\n5.粘贴插入图片:直接粘贴,将自动作为图片插入\n自动添加标题:将po主设置的标题或者第一行文字 + 页码设置为标签页标题', sp_replyQuicklyOnBoardPage: '为板块页添加快速回复模式,在板块页即可回串,页面实时更新,无需跳转串内;并额外支持时间线内回串。\n“板块页默认模式”可选“发串/回复”两种模式,“回复默认模式”可选“临时/连续”两种回复模式,临时模式下回复成功即清除回串信息,连续模式可连续回复直到手动清理回串信息,搭配回复浮窗使用效果更佳', sp_enablePostExpand: '为板块页内串添加“展开/收起”按钮,点击即可切换长串的完整显示与折叠显示', sp_searchServiceBy4sY: '官方搜索当前不可用,公告详见:https://www.nmbxd1.com/t/56546294\n替换搜索按钮为来自4sYbzEX的“野生搜索酱”,具体使用方法请查阅原串:https://www.nmbxd.com/t/64792841', sp_enableImageHideMode: '“默认/模糊/无图/Tips”四种模式可选。默认模式不做修改;选择模糊模式时可使用鼠标悬浮暂时预览图片;无图模式隐藏图片;Tips模式随机显示Tips娘,点击后可恢复原图显示', }; // 更新日志弹窗(放在 spDescriptions 之后,避免引用未定义) if (!document.getElementById('sp_update_log')) { const $log = $( '
' + '
' + '
' + '更新日志' + '' + '
' + '
' + (CHANGELOG || '暂无更新说明') + '
' + '
' + '' + '' + '' + '
' + '
' + '
' ); $('body').append($log); $('#sp_update_log_close,#sp_update_log').on('click', (e) => { if (e.target.id === 'sp_update_log') { closeUpdateLogDialog({ treatAsDismiss: false, reason: 'overlay' }); return; } if (e.target.id === 'sp_update_log_close') { const isRemoteMode = $('#sp_update_log').attr('data-update-mode') === 'remote'; closeUpdateLogDialog({ treatAsDismiss: isRemoteMode, reason: 'close-button' }); } }); $('#sp_update_log_update_now').on('click', () => { const state = getUpdateCheckState(); closeUpdateLogDialog({ reason: 'update-now' }); flashFooterUpdateHighlight(state.pendingUpdateSource || ''); }); $('#sp_update_log_ignore_version').on('click', () => { const state = getUpdateCheckState(); if (state.pendingUpdateVersion) { state.ignoredVersion = state.pendingUpdateVersion; setUpdateCheckState(state); updateSettingsButtonBadge(state); } closeUpdateLogDialog({ reason: 'ignore-version' }); }); $('#sp_update_log_dismiss_today').on('click', () => { closeUpdateLogDialog({ treatAsDismiss: true, reason: 'dismiss-today' }); }); } // 版本号同步到当前脚本版本 try { const ver = (typeof GM_info !== 'undefined' && GM_info && GM_info.script && GM_info.script.version) ? GM_info.script.version : ''; if (ver) $('#sp_version_link').text('v' + ver); } catch (e) {} $('#sp_version_link').off('click').on('click', (e) => { e.preventDefault(); openUpdateLogDialog('local'); }); // ====== 2. 创建 tooltip 元素并添加样式 ====== if (!$('#sp_tooltip').length) { $('body').append('
'); const tooltipStyle = ` #sp_tooltip { position: fixed; max-width: 260px; background: rgba(0,0,0,0.85); color: #fff; padding: 6px 10px; border-radius: 4px; font-size: 12px; line-height: 1.4; pointer-events: none; display: none; z-index: 100000; box-shadow: 0 2px 6px rgba(0,0,0,0.3); transition: opacity 0.15s ease; opacity: 0; white-space: pre-line; } #sp_tooltip.show { display: block; opacity: 1; } `; $('