// ==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 = $(`
${msg}
`);
$('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 `
`;
}
function buildCookieGroupTwoFieldRowHtml(type, index, group = {}) {
const desc = group.desc || '';
const cookieText = Array.isArray(group.cookies) ? group.cookies.join(',') : '';
return `
`;
}
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) => `
`;
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 = `
${fold('sp_blockedKeywords','屏蔽关键词','关键词请用逗号隔开,词中包含逗号请加\\\转义')}
导入/导出配置
导入将覆盖当前全部配置,建议先导出备份
`;
$('#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;
}
`;
$('