// ==UserScript==
// @name X岛-EX
// @namespace https://github.com/SayaGoodBye/nmbxd-EX
// @version 3.0.0
// @description X岛-EX 网页端增强,移动端般的浏览体验:快捷切换饼干/ 添加页首页码 / 关闭图片水印 / 预览真实饼干 / 隐藏无标题-无名氏-版规 / 显示外部图床 / 自动刷新饼干 toast提示 / 无缝翻页-自动翻页 / 默认原图+控件 / 新标签打开串 / 优化引用弹窗 / 拓展引用格式 / 当页回复编号 / 扩展坞增强 / 拦截回复中间页 / 颜文字拓展 / 高亮PO主 / 发串UI调整 / 『分组标记饼干』 / 『屏蔽饼干』 / 『只看饼干』 / 『屏蔽关键词』- 隐藏-折叠 / 增强X岛匿名版 / 板块页快速回复 / 展开板块页长串 / 野生搜索酱 / unvcode-零宽空格模式 / 侧边栏收起 / 图片隐藏模式 / 图片自动压缩-非法图像格式(无GCT)GIF重编码 / 链接自动识别 / 设置项导入导出-剪贴板文件 / 常用串 / 浏览历史 。
// @author XY
// @match https://*.nmbxd1.com/*
// @match https://*.nmbxd.com/*
// @match https://nmb-search.166666666.xyz/*
// @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
// @icon https://image.nmb.best/image/2026-06-03/6a1fcea41fad3.png
// @icon64 https://image.nmb.best/image/2026-06-03/6a1fced8e0e64.png
// @license WTFPL
// @changelog 新增\n0.浏览器拓展版发布,完整迁移目前脚本版全部功能。\n注意事项:\n1)本浏览器拓展适配Chrome、Edge等Chromium内核浏览器,**Firefox浏览器无法使用**;\n2)拓展版与脚本版共同开启时默认作用前者,由于二者数据不互通,请先导出脚本版设置后再开启拓展版;\n3)提示:拓展版稳定性略优于脚本版,“图片菜单”功能二者作用方式不一致,建议优先选择拓展版。\n安装方法:\n1) 打开浏览器拓展页面\n2) 开启“开发者模式”\n3) 点击“加载解压缩的拓展”\n4) 选择通过[GitHub release](https://github.com/SayaGoodBye/nmbxd-EX/releases/latest)或者[百度网盘](https://pan.baidu.com/s/1-ELWglsTXG8jK5S6WwqtsQ?pwd=k8zf)下载的拓展压缩包(nmbxd-EX-Extension-version.zip)解压后的文件夹\n5) 安装成功\( ゚∀。)/\n\n1.侧边栏/设置面板新增浏览历史,当前上限500条,支持搜索,支持多种排序方式。\n2.侧边栏新增常用串,支持串内一键添加,并支持直接跳转“浏览历史”中记录的最近浏览位置。\n\n优化\n1.更新设置面板UI,当前在“设置面板/浏览历史”中切换,设置项修改为即时保存,刷新后作用。\n2.支持鼠标右键直接复制颜文字;颜文字面板中可使用空格/Enter选择颜文字。\n\n致谢:\n本项目Icon由9HrD12x设计并绘制。\n你们怎么知道这是9Hr给我画的无偿 Y(`ε´ )
// @note 特别感谢:icon由9HrD12x设计并绘制 >>No.68765505
// @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)
// @run-at document-start
// ==/UserScript==
/* global $, jQuery */
// 更新渠道
// @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;
const XDEX_SINGLETON_OWNER_DATASET_KEY = 'xdexSingletonOwner';
const XDEX_SINGLETON_WAIT_MS = 100;
function getXDexRuntimeInfo(){
const declared = typeof globalThis !== 'undefined' ? globalThis.__xdexRuntime : null;
if (declared && declared.kind === 'extension') {
return Object.assign({ kind: 'extension' }, declared);
}
const scriptHandler = GM_info && (GM_info.scriptHandler || (GM_info.script && GM_info.script.handler)) || '';
return {
kind: 'userscript',
scriptHandler: scriptHandler || 'unknown'
};
}
function shouldExitForXDexSingleton(runtimeInfo){
const root = document.documentElement;
const owner = root && root.dataset ? root.dataset[XDEX_SINGLETON_OWNER_DATASET_KEY] : '';
return owner === 'extension' && (!runtimeInfo || runtimeInfo.kind !== 'extension');
}
function cat_version(){
console.log('[version]:', VERSION);
}
const XDEX_RUNTIME = getXDexRuntimeInfo();
function getXDexGmStorageReady(){
const ready = typeof globalThis !== 'undefined' ? globalThis.__xdexGmStorageReady : null;
return ready && typeof ready.then === 'function' ? ready : Promise.resolve();
}
const XDEX_GM_STORAGE_READY = getXDexGmStorageReady();
function scheduleXDexStartup(){
if (shouldExitForXDexSingleton(XDEX_RUNTIME)) return;
const startAfterStorageReady = () => {
if (shouldExitForXDexSingleton(XDEX_RUNTIME)) return;
startXDexRuntime();
};
if (XDEX_RUNTIME.kind !== 'extension') {
setTimeout(() => {
if (shouldExitForXDexSingleton(XDEX_RUNTIME)) return;
XDEX_GM_STORAGE_READY.then(() => {
startAfterStorageReady();
}, () => {
startAfterStorageReady();
});
}, XDEX_SINGLETON_WAIT_MS);
return;
}
XDEX_GM_STORAGE_READY.then(() => {
startAfterStorageReady();
}, () => {
startAfterStorageReady();
});
}
function startXDexRuntime(){
cat_version();
console.log('[runtime]:', XDEX_RUNTIME.kind, XDEX_RUNTIME);
const UPDATE_CHECK_KEY = 'xdex_update_check_state';
const UPDATE_EXTENSION_CHECK_KEY = 'xdex_extension_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_EXTENSION_GITHUB_JSON_URL = 'https://raw.githubusercontent.com/SayaGoodBye/nmbxd-EX/main/nmbxd-EX-Extension/update.json';
const UPDATE_EXTENSION_JSDELIVR_JSON_URL = 'https://fastly.jsdelivr.net/gh/SayaGoodBye/nmbxd-EX@main/nmbxd-EX-Extension/update.json';
const UPDATE_CHECK_HOUR = 11;
const THREAD_HISTORY_STORAGE_KEY = 'xdex_thread_history';
const THREAD_HISTORY_STORE_VERSION = 1;
const THREAD_HISTORY_LIMIT = 500;
const THREAD_HISTORY_EXCERPT_LIMIT = 250;
const THREAD_HISTORY_RECORD_RETRY_LIMIT = 10;
const THREAD_HISTORY_RECORD_RETRY_DELAY = 500;
const THREAD_HISTORY_SYNC_EVENT = 'xdex:thread-history-changed';
const THREAD_HISTORY_LIVE_RENDER_DEBOUNCE_DELAY = 300;
const THREAD_HISTORY_LIVE_RENDER_MAX_WAIT = 1500;
const THREAD_HISTORY_REVISIT_DWELL_MS = 5000;
const ZERO_WIDTH_RE = /[\u200B\u200C\u200D\uFEFF]/;
const threadHistoryDebugState = {
loadedAt: new Date().toISOString(),
href: location.href,
runtime: XDEX_RUNTIME && XDEX_RUNTIME.kind,
storageKey: THREAD_HISTORY_STORAGE_KEY,
lastRecord: null,
lastRender: null,
lastPanelModule: ''
};
window.__xdexThreadHistoryDebug = threadHistoryDebugState;
let threadHistoryLiveSyncBound = false;
let threadHistoryLiveRenderTimer = 0;
let threadHistoryLiveRenderFirstAt = 0;
let threadHistoryLiveRenderPendingCount = 0;
let threadHistoryReactivationTrackingInstalled = false;
let threadHistoryDwellTimer = 0;
let threadHistoryVisibleSince = 0;
let threadHistoryVisibleSessionCounted = false;
function updateThreadHistoryDebugState(patch) {
Object.assign(threadHistoryDebugState, patch || {});
window.__xdexThreadHistoryDebug = threadHistoryDebugState;
return threadHistoryDebugState;
}
function logThreadHistory(message, details, level = 'info') {
const payload = Object.assign({ href: location.href }, details || {});
updateThreadHistoryDebugState({ lastLog: { message, details: payload, at: new Date().toISOString() } });
const logger = console[level] || console.info || console.log;
logger.call(console, `[thread-history] ${message}`, payload);
}
function logThreadHistoryFlat(message, details, level = 'info') {
const payload = Object.assign({ href: location.href }, details || {});
const text = Object.keys(payload)
.map(key => `${key}=${JSON.stringify(payload[key])}`)
.join(' ');
updateThreadHistoryDebugState({ lastFlatLog: { message, details: payload, at: new Date().toISOString() } });
const logger = console[level] || console.info || console.log;
logger.call(console, `[thread-history] ${message} ${text}`);
}
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 getUpdateCheckStorageKey() {
return XDEX_RUNTIME && XDEX_RUNTIME.kind === 'extension' ? UPDATE_EXTENSION_CHECK_KEY : UPDATE_CHECK_KEY;
}
function getDefaultUpdateCheckState() {
return {
lastCheckDate: '',
nextCheckAt: 0,
pendingUpdateVersion: '',
pendingUpdateChangelog: '',
pendingUpdateSource: '',
pendingUpdateDetectedAt: 0,
latestRemoteVersion: '',
ignoredVersion: '',
lastDismissDate: '',
dismissedUntil: 0
};
}
function getUpdateCheckState() {
try {
const saved = GM_getValue(getUpdateCheckStorageKey(), 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(getUpdateCheckStorageKey(), merged);
console.log('[update-check] set state:', merged);
return merged;
}
function createDefaultThreadHistoryStore() {
return {
version: 1,
limit: 500,
items: {},
index: {},
order: []
};
}
function getThreadHistoryKey(mode, threadId) {
return `${mode}:${String(threadId || '').slice(0, 8)}`;
}
function normalizeThreadHistoryStore(rawStore) {
const store = Object.assign(createDefaultThreadHistoryStore(), rawStore || {});
store.version = THREAD_HISTORY_STORE_VERSION;
store.limit = Number(store.limit) > 0 ? Number(store.limit) : THREAD_HISTORY_LIMIT;
store.items = store.items && typeof store.items === 'object' ? store.items : {};
store.index = store.index && typeof store.index === 'object' ? store.index : {};
const seen = new Set();
store.order = (Array.isArray(store.order) ? store.order : [])
.filter(key => {
if (!store.items[key] || seen.has(key)) return false;
seen.add(key);
return true;
});
Object.keys(store.items).forEach(key => {
if (!store.index[key]) store.index[key] = buildThreadHistoryIndexEntry(store.items[key]);
if (!seen.has(key)) {
seen.add(key);
store.order.push(key);
}
});
Object.keys(store.index).forEach(key => {
if (!store.items[key]) delete store.index[key];
});
store.order.sort((a, b) => {
const av = Number(store.items[a] && store.items[a].lastVisitedAt) || 0;
const bv = Number(store.items[b] && store.items[b].lastVisitedAt) || 0;
return bv - av;
});
while (store.order.length > store.limit) {
const key = store.order.pop();
delete store.items[key];
delete store.index[key];
}
return store;
}
function getThreadHistoryStore() {
try {
return normalizeThreadHistoryStore(GM_getValue(THREAD_HISTORY_STORAGE_KEY, null));
} catch (e) {
return createDefaultThreadHistoryStore();
}
}
function setThreadHistoryStore(store) {
const normalized = normalizeThreadHistoryStore(store);
GM_setValue(THREAD_HISTORY_STORAGE_KEY, normalized);
notifyThreadHistoryStoreChanged('local-write', false);
return normalized;
}
function isThreadHistoryPanelOpen() {
const cover = document.getElementById('sp_cover');
const module = document.getElementById('sp_module_history');
return !!module && module.classList.contains('active') && (!cover || getComputedStyle(cover).display !== 'none');
}
function scheduleThreadHistoryLiveRender(source, remote) {
const active = isThreadHistoryPanelOpen();
const now = Date.now();
if (!threadHistoryLiveRenderFirstAt) threadHistoryLiveRenderFirstAt = now;
threadHistoryLiveRenderPendingCount += 1;
updateThreadHistoryDebugState({
lastLiveSync: {
source,
remote: !!remote,
active,
pendingCount: threadHistoryLiveRenderPendingCount,
firstAt: threadHistoryLiveRenderFirstAt,
at: new Date().toISOString()
}
});
if (!active) {
if (threadHistoryLiveRenderTimer) clearTimeout(threadHistoryLiveRenderTimer);
threadHistoryLiveRenderTimer = 0;
threadHistoryLiveRenderFirstAt = 0;
threadHistoryLiveRenderPendingCount = 0;
return;
}
const run = () => {
threadHistoryLiveRenderTimer = 0;
threadHistoryLiveRenderFirstAt = 0;
threadHistoryLiveRenderPendingCount = 0;
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => renderThreadHistoryModule());
else renderThreadHistoryModule();
};
if (threadHistoryLiveRenderTimer) clearTimeout(threadHistoryLiveRenderTimer);
const elapsed = now - threadHistoryLiveRenderFirstAt;
const delay = elapsed >= THREAD_HISTORY_LIVE_RENDER_MAX_WAIT
? 0
: Math.min(THREAD_HISTORY_LIVE_RENDER_DEBOUNCE_DELAY, THREAD_HISTORY_LIVE_RENDER_MAX_WAIT - elapsed);
threadHistoryLiveRenderTimer = setTimeout(run, delay);
}
function notifyThreadHistoryStoreChanged(source, remote) {
try {
window.dispatchEvent(new CustomEvent(THREAD_HISTORY_SYNC_EVENT, { detail: { source, remote: !!remote, at: Date.now() } }));
} catch (e) {}
scheduleThreadHistoryLiveRender(source, remote);
}
function bindThreadHistoryLiveSync() {
if (threadHistoryLiveSyncBound) return;
threadHistoryLiveSyncBound = true;
if (typeof GM_addValueChangeListener === 'function') {
try {
GM_addValueChangeListener(THREAD_HISTORY_STORAGE_KEY, (_key, _oldValue, _newValue, remote) => {
scheduleThreadHistoryLiveRender('gm-value-change', remote);
});
} catch (e) {
logThreadHistory('live sync listener failed', { error: e && e.message ? e.message : String(e) }, 'warn');
}
}
window.addEventListener(THREAD_HISTORY_SYNC_EVENT, (event) => {
const detail = event && event.detail || {};
scheduleThreadHistoryLiveRender(detail.source || 'window-event', !!detail.remote);
});
}
function parseThreadHistoryUrl(inputUrl) {
let url;
try {
url = new URL(inputUrl || location.href, location.origin);
} catch (e) {
return null;
}
const path = url.pathname || '';
const normalMatch = path.match(/\/t\/(\d{8,})(?:\/(\d+))?/);
const poMatch = path.match(/\/Forum\/po\/id\/(\d{8,})(?:\/page\/(\d+)\.html)?/);
const match = normalMatch || poMatch;
if (!match) return null;
const pathPage = parseInt(match[2] || '', 10);
const queryPage = parseInt(url.searchParams.get('page') || '', 10);
return {
mode: poMatch ? 'po' : 'normal',
threadId: String(match[1]).slice(0, 8),
page: pathPage > 0 ? pathPage : (queryPage > 0 ? queryPage : 1),
url: url.toString()
};
}
function buildThreadHistoryPageUrl(mode, threadId, page) {
const tid = String(threadId || '').trim();
const pageNum = Math.max(1, Number(page) || 1);
if (!tid) return location.href;
if (mode === 'po') return `${location.origin}/Forum/po/id/${tid}/page/${pageNum}.html`;
return `${location.origin}/t/${tid}?page=${pageNum}`;
}
function parseThreadHistoryPageNumberFromElement(el) {
if (!el) return 0;
const text = String(el.textContent || '').trim();
const lastTextMatch = text.match(/末页\s*\((\d+)\)/);
if (lastTextMatch) return Number(lastTextMatch[1]) || 0;
const href = el.getAttribute && el.getAttribute('href');
const parsed = href ? parseThreadHistoryUrl(href) : null;
if (parsed && parsed.page) return Number(parsed.page) || 0;
const numericText = text.match(/^\d+$/);
return numericText ? Number(numericText[0]) || 0 : 0;
}
function getThreadHistoryPaginationBounds(root = document) {
const paginations = Array.from((root || document).querySelectorAll('ul.uk-pagination.uk-pagination-left.h-pagination'));
const pagination = paginations.length ? paginations[paginations.length - 1] : null;
if (!pagination) return null;
const items = Array.from(pagination.querySelectorAll('li'));
const elements = Array.from(pagination.querySelectorAll('a, span'));
const parsedLinks = elements
.map(el => parseThreadHistoryUrl(el.getAttribute && el.getAttribute('href')))
.filter(Boolean);
const parsedIdentity = parsedLinks.find(parsed => parsed.threadId);
const lastPageLink = elements.find(el => /^末页/.test(String(el.textContent || '').trim()));
const activeEl = pagination.querySelector('li.uk-active a, li.uk-active span');
const activePage = parseThreadHistoryPageNumberFromElement(activeEl);
const nextItem = items.find(li => /下一页|下页|Next|›|»|→/i.test(String(li.textContent || '').trim()));
const nextHasLink = !!(nextItem && nextItem.querySelector('a[href]'));
const numericPages = elements
.map(parseThreadHistoryPageNumberFromElement)
.filter(num => num > 0);
let lastPage = parseThreadHistoryPageNumberFromElement(lastPageLink);
if (!lastPage && nextItem && !nextHasLink) {
lastPage = activePage || Math.max(0, ...numericPages);
}
if (!lastPage) return null;
return {
lastPage,
activePage,
threadId: parsedIdentity && parsedIdentity.threadId || '',
mode: parsedIdentity && parsedIdentity.mode || '',
source: lastPageLink ? 'last-link' : 'disabled-next'
};
}
function applyThreadHistoryPageBounds(record, root = document) {
if (!record || !record.threadId) return record;
const bounds = getThreadHistoryPaginationBounds(root);
if (!bounds || !bounds.lastPage) return record;
if (bounds.threadId && bounds.threadId !== String(record.threadId)) return record;
if (bounds.mode && record.mode && bounds.mode !== record.mode) return record;
const parsedUrl = record.url ? parseThreadHistoryUrl(record.url) : null;
const page = Math.max(1, Number(record.page || (parsedUrl && parsedUrl.page)) || 1);
const boundedPage = Math.min(page, bounds.lastPage);
const existingUrlPage = parsedUrl && parsedUrl.page || 0;
const next = Object.assign({}, record, {
page: boundedPage,
maxVisitedPage: Math.min(Math.max(Number(record.maxVisitedPage) || boundedPage, boundedPage), bounds.lastPage),
lastKnownPage: bounds.lastPage
});
if (page > bounds.lastPage || existingUrlPage > bounds.lastPage) {
next.url = buildThreadHistoryPageUrl(next.mode, next.threadId, boundedPage);
}
return next;
}
function getElementTextPreserveZeroWidth(el) {
return el ? String(el.textContent || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n') : '';
}
function getVisibleTextForHistory(text) {
return String(text || '').replace(ZERO_WIDTH_RE, '').replace(/[\s\u00a0]+/g, '');
}
function trimThreadHistoryContentText(text) {
return String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim();
}
function sanitizeThreadHistoryInlineStyle(styleValue) {
const safeRules = [];
String(styleValue || '').split(';').forEach(rule => {
const separator = rule.indexOf(':');
if (separator === -1) return;
const name = rule.slice(0, separator).trim().toLowerCase();
const value = rule.slice(separator + 1).trim();
if (!/^(color|background-color|text-decoration|font-weight)$/.test(name) && !/^--darkreader-inline-(?:color|bgcolor)$/.test(name)) return;
if (/url\s*\(|expression\s*\(|javascript:/i.test(value)) return;
safeRules.push(`${name}: ${value}`);
});
return safeRules.join('; ');
}
function sanitizeThreadHistoryContentUrl(urlValue) {
try {
const url = new URL(urlValue, location.origin);
if (!/^https?:$/.test(url.protocol)) return '';
return url.href;
} catch (e) {
return '';
}
}
function escapeThreadHistoryHtml(text) {
return String(text || '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function cleanThreadHistoryContentWhitespace(root) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
if (!nodes.length) return;
nodes[0].nodeValue = String(nodes[0].nodeValue || '').replace(/^\s+/, '');
const last = nodes[nodes.length - 1];
last.nodeValue = String(last.nodeValue || '').replace(/\s+$/, '');
}
function normalizeThreadHistoryContentWhitespace(root) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
nodes.forEach(node => {
const prev = node.previousSibling;
const next = node.nextSibling;
let value = String(node.nodeValue || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
value = value.replace(/\n[ \t]*\n(?:[ \t]*\n)+/g, '\n\n');
if (prev && prev.nodeType === Node.ELEMENT_NODE && prev.tagName === 'BR') value = value.replace(/^\s+/, '');
if (next && next.nodeType === Node.ELEMENT_NODE && next.tagName === 'BR') value = value.replace(/[ \t]+$/, '');
node.nodeValue = value;
});
cleanThreadHistoryContentWhitespace(root);
}
function removeThreadHistoryTrailingBreaks(root) {
while (root && root.lastChild) {
const node = root.lastChild;
if (node.nodeType === Node.ELEMENT_NODE) {
removeThreadHistoryTrailingBreaks(node);
}
if (node.nodeType === Node.TEXT_NODE && !String(node.nodeValue || '').trim()) {
node.remove();
continue;
}
if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'BR') {
node.remove();
continue;
}
if (isEmptyThreadHistoryInlineElement(node)) {
node.remove();
continue;
}
break;
}
}
function isEmptyThreadHistoryInlineElement(node) {
if (!node || node.nodeType !== Node.ELEMENT_NODE) return false;
if (!/^(A|SPAN|FONT|B|STRONG|I|EM|U|S|DEL|CODE|SUB|SUP)$/.test(node.tagName)) return false;
return !String(node.textContent || '').trim() && !node.querySelector('img, video, audio, canvas, svg');
}
function limitThreadHistoryContentText(root, limit) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
let remaining = Math.max(0, Number(limit) || 0);
let truncated = false;
for (const node of nodes) {
if (truncated || remaining <= 0) {
node.nodeValue = '';
continue;
}
const value = String(node.nodeValue || '');
if (value.length > remaining) {
node.nodeValue = value.slice(0, remaining).replace(/\s+$/, '');
pruneAfterThreadHistoryTextNode(root, node);
truncated = true;
remaining = 0;
} else {
remaining -= value.length;
}
}
cleanThreadHistoryContentWhitespace(root);
removeThreadHistoryTrailingBreaks(root);
}
function pruneAfterThreadHistoryTextNode(root, textNode) {
let current = textNode;
while (current && current.parentNode && current !== root) {
while (current.nextSibling) current.nextSibling.remove();
current = current.parentNode;
}
if (current === root) removeThreadHistoryTrailingBreaks(root);
}
function isThreadHistoryContentTruncated(text) {
return String(text || '').length > THREAD_HISTORY_EXCERPT_LIMIT;
}
function appendThreadHistoryTruncationMarker(contentEl) {
if (!contentEl) return;
removeThreadHistoryTrailingBreaks(contentEl);
contentEl.appendChild(document.createTextNode('……'));
}
function sanitizeThreadHistoryInlineHtml(sourceEl) {
if (!sourceEl) return '';
const clone = sourceEl.cloneNode(true);
clone.querySelectorAll('script, style, template, iframe, object, embed, svg, math').forEach(el => el.remove());
const allowedTags = new Set(['SPAN', 'FONT', 'B', 'STRONG', 'I', 'EM', 'U', 'S', 'DEL', 'SUB', 'SUP']);
Array.from(clone.querySelectorAll('*')).forEach(el => {
if (!allowedTags.has(el.tagName)) {
el.replaceWith(...Array.from(el.childNodes));
return;
}
Array.from(el.attributes).forEach(attr => {
const name = attr.name.toLowerCase();
const value = attr.value || '';
if (name === 'style') {
const safeStyle = sanitizeThreadHistoryInlineStyle(value);
if (safeStyle) el.setAttribute('style', safeStyle);
else el.removeAttribute(attr.name);
return;
}
if (el.tagName === 'FONT' && name === 'color') return;
if (name === 'class') return;
if (name === 'data-darkreader-inline-color' || name === 'data-darkreader-inline-bgcolor') return;
el.removeAttribute(attr.name);
});
});
cleanThreadHistoryContentWhitespace(clone);
return clone.innerHTML.trim();
}
function extractThreadHistoryCookieId(cookieEl, fallbackText) {
if (cookieEl) {
const font = cookieEl.querySelector('font');
if (font) {
for (const node of font.childNodes) {
if (node.nodeType === Node.TEXT_NODE) {
const value = String(node.nodeValue || '').trim();
if (value) return value;
}
}
}
}
const value = (String(fallbackText || '').split(':')[1] || fallbackText || '').trim();
const match = String(value).match(/[A-Za-z0-9]{3,7}/);
return match ? match[0] : value;
}
function buildThreadHistoryLegacyCookieHtml(cookieId) {
const value = String(cookieId || '').trim();
const match = value.match(/^([A-Za-z0-9]{3,7})(.+)$/);
if (!match || !match[2].trim()) return '';
const id = escapeThreadHistoryHtml(match[1]);
const badge = escapeThreadHistoryHtml(match[2].trim());
return `ID:${id}${badge}`;
}
function getThreadHistoryCookieMarkId(item) {
const value = String(item && item.cookieId || '').trim();
const match = value.match(/^([A-Za-z0-9]{3,7})/);
return match ? match[1] : value;
}
function sanitizeThreadHistoryContentHtml(contentEl) {
if (!contentEl) return '';
const clone = contentEl.cloneNode(true);
clone.querySelectorAll('script, style, template, iframe, object, embed, svg, math').forEach(el => el.remove());
const allowedTags = new Set(['A', 'BR', 'SPAN', 'FONT', 'B', 'STRONG', 'I', 'EM', 'U', 'S', 'DEL', 'CODE', 'PRE', 'SUB', 'SUP']);
Array.from(clone.querySelectorAll('*')).forEach(el => {
if (!allowedTags.has(el.tagName)) {
el.replaceWith(...Array.from(el.childNodes));
return;
}
Array.from(el.attributes).forEach(attr => {
const name = attr.name.toLowerCase();
const value = attr.value || '';
if (name.startsWith('on') || name === 'id' || (name.startsWith('data-') && name !== 'data-darkreader-inline-color' && name !== 'data-darkreader-inline-bgcolor')) {
el.removeAttribute(attr.name);
return;
}
if (name === 'style') {
const safeStyle = sanitizeThreadHistoryInlineStyle(value);
if (safeStyle) el.setAttribute('style', safeStyle);
else el.removeAttribute(attr.name);
return;
}
if (el.tagName === 'A' && name === 'href') {
const safeHref = sanitizeThreadHistoryContentUrl(value);
if (safeHref) {
el.setAttribute('href', safeHref);
el.setAttribute('target', '_blank');
el.setAttribute('rel', 'noopener');
} else {
el.removeAttribute(attr.name);
}
return;
}
if (el.tagName === 'FONT' && name === 'color') return;
if (name !== 'class' && name !== 'title' && name !== 'target' && name !== 'rel') el.removeAttribute(attr.name);
});
});
normalizeThreadHistoryContentWhitespace(clone);
return clone.innerHTML.trim();
}
function normalizeThreadHistoryImageFile(urlValue) {
if (!urlValue) return '';
try {
const url = new URL(urlValue, location.origin);
const match = url.pathname.match(/\/(?:image|thumb)\/([^?#]+)/);
return match ? decodeURIComponent(match[1]) : '';
} catch (e) {
const match = String(urlValue).match(/\/(?:image|thumb)\/([^?#]+)/);
return match ? decodeURIComponent(match[1]) : '';
}
}
const THREAD_HISTORY_IMAGE_FILE_CONTRACT_EXAMPLE = '2024-12-10/6757ea866e1aa.png';
function extractThreadHistoryImageFile(mainEl) {
if (!mainEl) return '';
const anchor = mainEl.querySelector('.h-threads-img-a[href]');
const img = mainEl.querySelector('.h-threads-img-a img, img.h-threads-img');
const sources = [
anchor && anchor.getAttribute('href'),
img && img.dataset && img.dataset.xdexHdSrc,
img && img.dataset && img.dataset.xdexThumbSrc,
img && img.dataset && img.dataset.src,
img && img.getAttribute && img.getAttribute('src')
];
for (const source of sources) {
const imageFile = normalizeThreadHistoryImageFile(source);
if (imageFile) return imageFile;
}
return '';
}
function isThreadHistoryMainCandidate(el) {
return !!el && !el.closest('.h-preview-box') && !!el.querySelector('.h-threads-content');
}
function findThreadHistoryMainElement(root, parsed) {
const scope = root || document;
const primary = scope.querySelector('.h-threads-list .h-threads-item-main');
if (isThreadHistoryMainCandidate(primary)) return primary;
const mains = Array.from(scope.querySelectorAll('.h-threads-item-main'));
return mains.find(isThreadHistoryMainCandidate) || null;
}
function extractThreadHistoryRecord(root) {
const parsed = parseThreadHistoryUrl(location.href);
if (!parsed) {
logThreadHistory('skip record: unsupported url', { url: location.href });
return null;
}
const mainEl = findThreadHistoryMainElement(root, parsed);
if (!mainEl) {
logThreadHistory('skip record: missing h-threads-item-main', { url: location.href, parsed });
return null;
}
const contentEl = mainEl.querySelector('.h-threads-content');
const rawContent = getElementTextPreserveZeroWidth(contentEl);
const contentText = trimThreadHistoryContentText(rawContent);
const contentTruncated = isThreadHistoryContentTruncated(contentText);
const contentHtml = sanitizeThreadHistoryContentHtml(contentEl);
const hasZeroWidth = ZERO_WIDTH_RE.test(rawContent);
const hasVisibleText = !!getVisibleTextForHistory(rawContent);
const hasWhitespaceOnly = !hasVisibleText && /^[\s\u00a0]*$/.test(rawContent.replace(ZERO_WIDTH_RE, '')) && rawContent.length > 0;
const imageFile = extractThreadHistoryImageFile(mainEl);
const title = getElementTextPreserveZeroWidth(mainEl.querySelector('.h-threads-info-title')).trim();
const author = getElementTextPreserveZeroWidth(mainEl.querySelector('.h-threads-info-email')).trim();
const cookieEl = mainEl.querySelector('.h-threads-info-uid');
const cookieText = getElementTextPreserveZeroWidth(cookieEl).trim();
const cookieId = extractThreadHistoryCookieId(cookieEl, cookieText);
const cookieHtml = sanitizeThreadHistoryInlineHtml(cookieEl);
const createdAtEl = mainEl.querySelector('.h-threads-info-createdat, .h-threads-info time');
const createdAt = String(createdAtEl && (createdAtEl.getAttribute('title') || createdAtEl.getAttribute('datetime')) || getElementTextPreserveZeroWidth(createdAtEl)).trim();
return {
key: getThreadHistoryKey(parsed.mode, parsed.threadId),
mode: parsed.mode,
threadId: parsed.threadId,
page: parsed.page,
url: parsed.url,
title,
author,
cookieId,
cookieHtml,
createdAt,
contentText,
contentHtml,
contentTruncated,
excerpt: contentText.slice(0, THREAD_HISTORY_EXCERPT_LIMIT),
imageFile,
contentFlags: { hasVisibleText, hasWhitespaceOnly, hasZeroWidth },
lastScrollY: Math.max(0, Math.floor(window.scrollY || 0))
};
}
function buildThreadHistoryIndexEntry(item) {
const contentFlags = item && item.contentFlags ? item.contentFlags : {};
const imageFile = String(item && item.imageFile || '');
const titleText = String(item && item.title || '').toLowerCase();
const authorText = String(item && item.author || '').toLowerCase();
const cookieIdText = String(item && item.cookieId || '').toLowerCase();
const excerptText = String(item && (item.contentText || item.excerpt) || '').toLowerCase();
const threadIdText = String(item && item.threadId || '');
return {
searchText: [threadIdText, titleText, authorText, cookieIdText, excerptText].join(' ').toLowerCase(),
threadIdText,
titleText,
authorText,
cookieIdText,
excerptText,
mode: item && item.mode === 'po' ? 'po' : 'normal',
hasImage: !!imageFile,
isGif: /\.gif(?:$|[?#])/i.test(imageFile),
hasZeroWidth: !!contentFlags.hasZeroWidth,
hasVisibleText: !!contentFlags.hasVisibleText,
hasWhitespaceOnly: !!contentFlags.hasWhitespaceOnly,
lastVisitedAt: Number(item && item.lastVisitedAt) || 0
};
}
function upsertThreadHistoryRecord(nextRecord, options = {}) {
if (!nextRecord || !nextRecord.threadId || !nextRecord.mode) return getThreadHistoryStore();
const now = Date.now();
const countVisit = options.countVisit !== false;
const touchVisitedAt = countVisit || options.touchVisitedAt === true;
const store = getThreadHistoryStore();
nextRecord = applyThreadHistoryPageBounds(nextRecord);
const key = nextRecord.key || getThreadHistoryKey(nextRecord.mode, nextRecord.threadId);
const old = store.items[key] || {};
const maxVisitedPage = Math.max(Number(old.maxVisitedPage) || 1, Number(nextRecord.page) || 1);
const boundedMaxVisitedPage = nextRecord.lastKnownPage ? Math.min(maxVisitedPage, Number(nextRecord.lastKnownPage) || maxVisitedPage) : maxVisitedPage;
const mergedBase = Object.assign({}, old, nextRecord);
const merged = Object.assign({}, applyThreadHistoryPageBounds(mergedBase), {
key,
firstVisitedAt: old.firstVisitedAt || now,
lastVisitedAt: touchVisitedAt ? now : (Number(old.lastVisitedAt) || now),
visitCount: (Number(old.visitCount) || 0) + (countVisit ? 1 : 0),
maxVisitedPage: boundedMaxVisitedPage,
cookieHtml: nextRecord.cookieHtml || old.cookieHtml || ''
});
store.items[key] = merged;
store.index[key] = buildThreadHistoryIndexEntry(merged);
store.order = [key].concat((store.order || []).filter(itemKey => itemKey !== key));
const saved = setThreadHistoryStore(store);
logThreadHistory('record saved', { key, total: saved.order.length, countVisit, reason: options.reason || '', record: merged });
return saved;
}
function touchThreadHistoryCurrentLocation(options = {}) {
const parsed = parseThreadHistoryUrl(options.url || location.href);
if (!parsed) return getThreadHistoryStore();
const key = getThreadHistoryKey(parsed.mode, parsed.threadId);
const store = getThreadHistoryStore();
const item = store.items[key];
if (!item) return store;
const now = Date.now();
const bounded = applyThreadHistoryPageBounds(Object.assign({}, item, {
page: Math.max(Number(item.page) || 1, Number(options.page || parsed.page) || 1),
url: options.url || parsed.url
}));
item.page = bounded.page;
item.url = bounded.url;
item.maxVisitedPage = bounded.lastKnownPage
? Math.min(Math.max(Number(item.maxVisitedPage) || 1, Number(item.page) || 1), Number(bounded.lastKnownPage) || Number(item.page) || 1)
: Math.max(Number(item.maxVisitedPage) || 1, Number(item.page) || 1);
if (bounded.lastKnownPage) item.lastKnownPage = bounded.lastKnownPage;
item.lastScrollY = Math.max(0, Math.floor(window.scrollY || 0));
if (options.touchVisitedAt) item.lastVisitedAt = now;
store.index[key] = buildThreadHistoryIndexEntry(item);
store.order = [key].concat((store.order || []).filter(itemKey => itemKey !== key));
const saved = setThreadHistoryStore(store);
logThreadHistory('location touched', { key, reason: options.reason || '', page: item.page, url: item.url, maxVisitedPage: item.maxVisitedPage, touchVisitedAt: !!options.touchVisitedAt });
return saved;
}
function recordThreadHistoryProgress(options = {}) {
const parsed = parseThreadHistoryUrl(options.url || location.href);
if (!parsed) return getThreadHistoryStore();
const record = extractThreadHistoryRecord(document) || {
key: getThreadHistoryKey(parsed.mode, parsed.threadId),
mode: parsed.mode,
threadId: parsed.threadId
};
record.key = getThreadHistoryKey(parsed.mode, parsed.threadId);
record.mode = parsed.mode;
record.threadId = parsed.threadId;
record.page = Math.max(Number(record.page) || 1, Number(options.page || parsed.page) || 1);
record.url = options.url || parsed.url;
record.lastScrollY = Math.max(0, Math.floor(window.scrollY || 0));
return upsertThreadHistoryRecord(record, { countVisit: false, touchVisitedAt: options.touchVisitedAt === true, reason: options.reason || 'progress' });
}
function updateThreadHistoryScrollPosition() {
touchThreadHistoryCurrentLocation({ reason: 'scroll-position' });
}
function deleteThreadHistoryItem(key) {
const store = getThreadHistoryStore();
delete store.items[key];
delete store.index[key];
store.order = (store.order || []).filter(itemKey => itemKey !== key);
return setThreadHistoryStore(store);
}
function clearThreadHistory() {
return setThreadHistoryStore(createDefaultThreadHistoryStore());
}
function parseThreadHistorySearchQuery(query) {
const filters = { mode: '', hasImage: false, isGif: false, hasZeroWidth: false };
const tokens = [];
String(query || '').toLowerCase().split(/\s+/).filter(Boolean).forEach(token => {
if (token === 'mode:po') filters.mode = 'po';
else if (token === 'mode:normal') filters.mode = 'normal';
else if (token === 'has:image') filters.hasImage = true;
else if (token === 'has:gif') filters.isGif = true;
else if (token === 'has:zwsp' || token === 'has:zerowidth') filters.hasZeroWidth = true;
else tokens.push(token);
});
return { filters, tokens };
}
function scoreThreadHistoryIndexEntry(entry, tokens) {
let score = Number(entry.lastVisitedAt) || 0;
tokens.forEach(token => {
if (/^\d{1,8}$/.test(token)) {
if (entry.threadIdText === token) score += 1000000000000000;
else if (entry.threadIdText.includes(token)) score += 500000000000000;
}
});
return score;
}
function getThreadHistorySortValue(item, field) {
if (!item) return 0;
if (field === 'visitCount') return Number(item.visitCount) || 0;
if (field === 'maxVisitedPage') return Number(item.maxVisitedPage || item.page) || 0;
return Number(item.lastVisitedAt) || 0;
}
function compareThreadHistoryResults(a, b, sortMode, tokens) {
const itemA = a.item || {};
const itemB = b.item || {};
if (sortMode === 'last-asc') return getThreadHistorySortValue(itemA, 'lastVisitedAt') - getThreadHistorySortValue(itemB, 'lastVisitedAt');
if (sortMode === 'visits-desc') return getThreadHistorySortValue(itemB, 'visitCount') - getThreadHistorySortValue(itemA, 'visitCount') || getThreadHistorySortValue(itemB, 'lastVisitedAt') - getThreadHistorySortValue(itemA, 'lastVisitedAt');
if (sortMode === 'visits-asc') return getThreadHistorySortValue(itemA, 'visitCount') - getThreadHistorySortValue(itemB, 'visitCount') || getThreadHistorySortValue(itemB, 'lastVisitedAt') - getThreadHistorySortValue(itemA, 'lastVisitedAt');
if (sortMode === 'page-desc') return getThreadHistorySortValue(itemB, 'maxVisitedPage') - getThreadHistorySortValue(itemA, 'maxVisitedPage') || getThreadHistorySortValue(itemB, 'lastVisitedAt') - getThreadHistorySortValue(itemA, 'lastVisitedAt');
return scoreThreadHistoryIndexEntry(b.index, tokens) - scoreThreadHistoryIndexEntry(a.index, tokens);
}
function searchThreadHistory(query, storeInput, sortMode) {
const store = normalizeThreadHistoryStore(storeInput || getThreadHistoryStore());
const { filters, tokens } = parseThreadHistorySearchQuery(query);
return (store.order || [])
.filter(key => {
const entry = store.index[key];
if (!entry || !store.items[key]) return false;
if (filters.mode && entry.mode !== filters.mode) return false;
if (filters.hasImage && !entry.hasImage) return false;
if (filters.isGif && !entry.isGif) return false;
if (filters.hasZeroWidth && !entry.hasZeroWidth) return false;
return tokens.every(token => entry.searchText.includes(token));
})
.map(key => ({ key, item: store.items[key], index: store.index[key] }))
.sort((a, b) => compareThreadHistoryResults(a, b, sortMode || 'last-desc', tokens));
}
let threadHistoryScrollTrackingInstalled = false;
function installThreadHistoryScrollTracking() {
if (threadHistoryScrollTrackingInstalled) return;
threadHistoryScrollTrackingInstalled = true;
let scrollTimer = 0;
window.addEventListener('scroll', () => {
if (scrollTimer) return;
scrollTimer = setTimeout(() => {
scrollTimer = 0;
updateThreadHistoryScrollPosition();
}, 1200);
}, { passive: true });
window.addEventListener('pagehide', updateThreadHistoryScrollPosition, { passive: true });
}
function isThreadHistoryPageActive() {
return document.visibilityState === 'visible' && (typeof document.hasFocus !== 'function' || document.hasFocus());
}
function cancelThreadHistoryDwellTimer(resetSession) {
if (threadHistoryDwellTimer) clearTimeout(threadHistoryDwellTimer);
threadHistoryDwellTimer = 0;
threadHistoryVisibleSince = 0;
if (resetSession) threadHistoryVisibleSessionCounted = false;
}
function scheduleThreadHistoryReactivationVisit(source) {
if (!parseThreadHistoryUrl(location.href)) return;
if (!isThreadHistoryPageActive()) {
cancelThreadHistoryDwellTimer(document.visibilityState !== 'visible');
return;
}
if (threadHistoryVisibleSessionCounted || threadHistoryDwellTimer) return;
threadHistoryVisibleSince = Date.now();
updateThreadHistoryDebugState({ lastDwell: { source, status: 'scheduled', threshold: THREAD_HISTORY_REVISIT_DWELL_MS, at: new Date().toISOString() } });
threadHistoryDwellTimer = setTimeout(() => {
threadHistoryDwellTimer = 0;
if (!threadHistoryVisibleSince || !isThreadHistoryPageActive()) return;
if (Date.now() - threadHistoryVisibleSince < THREAD_HISTORY_REVISIT_DWELL_MS) return;
threadHistoryVisibleSessionCounted = true;
updateThreadHistoryDebugState({ lastDwell: { source, status: 'counted', threshold: THREAD_HISTORY_REVISIT_DWELL_MS, at: new Date().toISOString() } });
recordCurrentThreadHistory(0, { reason: 'reactivation-dwell', countVisit: true });
}, THREAD_HISTORY_REVISIT_DWELL_MS);
}
function installThreadHistoryReactivationTracking(initialCounted) {
if (threadHistoryReactivationTrackingInstalled) return;
threadHistoryReactivationTrackingInstalled = true;
threadHistoryVisibleSessionCounted = !!initialCounted && document.visibilityState === 'visible';
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') scheduleThreadHistoryReactivationVisit('visibilitychange');
else cancelThreadHistoryDwellTimer(true);
}, { passive: true });
window.addEventListener('focus', () => scheduleThreadHistoryReactivationVisit('focus'), { passive: true });
window.addEventListener('blur', () => cancelThreadHistoryDwellTimer(false), { passive: true });
window.addEventListener('pagehide', () => cancelThreadHistoryDwellTimer(true), { passive: true });
}
function recordCurrentThreadHistory(attempt = 0, options = {}) {
const record = extractThreadHistoryRecord(document);
if (!record) {
const parsed = parseThreadHistoryUrl(location.href);
const missingMain = !!parsed && !findThreadHistoryMainElement(document, parsed);
updateThreadHistoryDebugState({
lastRecord: {
status: parsed ? (missingMain ? 'missing-main' : 'extract-failed') : 'unsupported-url',
attempt,
parsed,
at: new Date().toISOString(),
readyState: document.readyState,
mainCount: document.querySelectorAll('.h-threads-item-main').length,
listCount: document.querySelectorAll('.h-threads-list').length
}
});
if (missingMain && attempt < THREAD_HISTORY_RECORD_RETRY_LIMIT) {
logThreadHistory('retry record: waiting for h-threads-item-main', {
attempt: attempt + 1,
limit: THREAD_HISTORY_RECORD_RETRY_LIMIT,
delay: THREAD_HISTORY_RECORD_RETRY_DELAY,
readyState: document.readyState
});
setTimeout(() => recordCurrentThreadHistory(attempt + 1), THREAD_HISTORY_RECORD_RETRY_DELAY);
}
return;
}
upsertThreadHistoryRecord(record, Object.assign({ reason: 'initial-load', countVisit: true }, options));
updateThreadHistoryDebugState({ lastRecord: { status: 'saved', attempt, reason: options.reason || 'initial-load', record, at: new Date().toISOString() } });
installThreadHistoryScrollTracking();
}
function formatThreadHistoryTime(ts) {
if (!ts) return '';
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');
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
return `${y}-${m}-${day} ${hh}:${mm}`;
}
function buildThreadHistoryImageUrl(imageFile, full) {
if (!imageFile) return '';
const path = /\.gif$/i.test(imageFile) || full ? 'image' : 'thumb';
const encodedFile = String(imageFile).split('/').map(encodeURIComponent).join('/');
return `https://image.nmb.best/${path}/${encodedFile}`;
}
function buildThreadHistoryItemUrl(item) {
if (item && item.url) return item.url;
const threadId = item && item.threadId ? item.threadId : '';
const page = item && item.page ? item.page : 1;
return buildThreadHistoryPageUrl(item && item.mode, threadId, page);
}
function getLatestThreadHistoryUrl(threadId) {
const tid = String(threadId || '').trim();
if (!isValidThreadId(tid)) return '';
const store = getThreadHistoryStore();
const candidates = ['normal', 'po']
.map((mode) => store.items[getThreadHistoryKey(mode, tid)])
.filter(Boolean)
.sort((a, b) => (Number(b.lastVisitedAt) || 0) - (Number(a.lastVisitedAt) || 0));
return candidates.length ? buildThreadHistoryItemUrl(candidates[0]) : '';
}
function appendThreadHistoryText(parent, tagName, className, text) {
const el = document.createElement(tagName);
if (className) el.className = className;
el.textContent = text || '';
parent.appendChild(el);
return el;
}
function appendThreadHistoryInfoText(parent, className, text) {
const value = String(text || '').trim();
if (!value) return null;
return appendThreadHistoryText(parent, 'span', className, value);
}
function shouldRenderThreadHistoryTitle(title) {
const value = String(title || '').trim();
return !!value && value !== '无标题';
}
function shouldRenderThreadHistoryAuthor(author) {
const value = String(author || '').trim();
return !!value && value !== '无名氏';
}
function buildThreadHistoryItemElement(result) {
const item = result.item || {};
const wrapper = document.createElement('div');
wrapper.className = 'xdex-history-item';
wrapper.dataset.historyKey = result.key;
const main = document.createElement('div');
main.className = 'h-threads-item-main';
wrapper.appendChild(main);
const info = document.createElement('div');
info.className = 'h-threads-info xdex-history-info';
main.appendChild(info);
const infoMain = document.createElement('span');
infoMain.className = 'xdex-history-info-main';
info.appendChild(infoMain);
if (shouldRenderThreadHistoryTitle(item.title)) appendThreadHistoryInfoText(infoMain, 'h-threads-info-title', item.title);
if (shouldRenderThreadHistoryAuthor(item.author)) appendThreadHistoryInfoText(infoMain, 'h-threads-info-email', item.author);
const createdAtNode = appendThreadHistoryInfoText(infoMain, 'h-threads-info-createdat', item.createdAt);
if (createdAtNode) {
createdAtNode.dataset.xdexOriginalTime = item.createdAt;
createdAtNode.title = item.createdAt;
}
const cookieHtml = item.cookieHtml || buildThreadHistoryLegacyCookieHtml(item.cookieId);
const cookieMarkId = getThreadHistoryCookieMarkId(item);
if (cookieHtml) {
const cookieSpan = appendThreadHistoryText(infoMain, 'span', 'h-threads-info-uid', '');
if (cookieMarkId) cookieSpan.setAttribute('data-xdex-cookie-id', cookieMarkId);
cookieSpan.innerHTML = cookieHtml;
} else if (item.cookieId) {
const cookieSpan = appendThreadHistoryInfoText(infoMain, 'h-threads-info-uid', `ID:${item.cookieId}`);
if (cookieSpan && cookieMarkId) cookieSpan.setAttribute('data-xdex-cookie-id', cookieMarkId);
}
const historyUrl = buildThreadHistoryItemUrl(item);
const replyLink = document.createElement('a');
replyLink.className = 'h-threads-info-id xdex-history-thread-id';
replyLink.href = historyUrl;
replyLink.textContent = `No.${item.threadId || ''}`;
infoMain.appendChild(replyLink);
const replyAction = document.createElement('span');
replyAction.className = 'h-threads-info-reply-btn xdex-history-reply-label';
const replyActionLink = document.createElement('a');
replyActionLink.className = 'xdex-history-reply-action';
replyActionLink.href = historyUrl;
replyActionLink.target = '_blank';
replyActionLink.rel = 'noopener';
replyActionLink.textContent = '回应';
replyAction.appendChild(document.createTextNode('['));
replyAction.appendChild(replyActionLink);
replyAction.appendChild(document.createTextNode(']'));
infoMain.appendChild(replyAction);
const deleteButton = document.createElement('button');
deleteButton.type = 'button';
deleteButton.className = 'xdex-history-delete';
deleteButton.dataset.historyKey = result.key;
deleteButton.title = '删除';
deleteButton.textContent = '×';
main.appendChild(deleteButton);
if (item.imageFile) {
const imageLink = document.createElement('a');
imageLink.className = 'h-threads-img-a xdex-history-image';
imageLink.href = buildThreadHistoryImageUrl(item.imageFile, true);
imageLink.target = '_blank';
imageLink.rel = 'noopener';
const img = document.createElement('img');
img.className = 'h-threads-img';
img.src = buildThreadHistoryImageUrl(item.imageFile, false);
img.alt = item.imageFile;
imageLink.appendChild(img);
main.appendChild(imageLink);
}
const content = document.createElement('div');
content.className = 'h-threads-content';
if (item.contentHtml) content.innerHTML = item.contentHtml;
else content.textContent = item.contentText || item.excerpt || '';
if (item.contentTruncated) {
limitThreadHistoryContentText(content, THREAD_HISTORY_EXCERPT_LIMIT);
appendThreadHistoryTruncationMarker(content);
}
main.appendChild(content);
const footer = document.createElement('div');
footer.className = 'xdex-history-footer';
appendThreadHistoryText(footer, 'span', 'xdex-history-time', formatThreadHistoryTime(item.lastVisitedAt));
appendThreadHistoryText(footer, 'span', 'xdex-history-visit-count', `共访问 ${item.visitCount || 1} 次`);
appendThreadHistoryText(footer, 'span', 'xdex-history-page', `串内最远:P${item.maxVisitedPage || item.page || 1}`);
appendThreadHistoryText(footer, 'span', 'xdex-history-current-page', `最近查看:P${item.page || 1}`);
if (item.mode === 'po') appendThreadHistoryText(footer, 'span', 'xdex-history-po-label', 'Po');
main.appendChild(footer);
markAllCookies(getFilterConfig().markedGroups || [], wrapper);
return wrapper;
}
function renderThreadHistoryModule(query) {
const root = document.getElementById('sp_history_results');
if (!root) {
logThreadHistory('render skipped: missing #sp_history_results');
return;
}
const input = document.getElementById('sp_history_search');
const sortSelect = document.getElementById('sp_history_sort');
const effectiveQuery = query == null && input ? input.value : query;
const sortMode = sortSelect ? sortSelect.value : 'last-desc';
const results = searchThreadHistory(effectiveQuery || '', null, sortMode);
updateThreadHistoryDebugState({ lastRender: { query: effectiveQuery || '', sortMode, count: results.length, at: new Date().toISOString() } });
logThreadHistory('render module', { query: effectiveQuery || '', sortMode, count: results.length });
const count = document.getElementById('sp_history_count');
if (count) count.textContent = `${results.length} 条`;
root.textContent = '';
if (!results.length) {
const empty = document.createElement('div');
empty.className = 'xdex-history-empty';
empty.textContent = effectiveQuery ? '没有匹配的浏览历史' : '暂无浏览历史';
root.appendChild(empty);
return;
}
results.forEach(result => root.appendChild(buildThreadHistoryItemElement(result)));
const reportThreadHistoryRenderDom = () => {
const cover = document.getElementById('sp_cover');
const panel = document.getElementById('sp_panel');
const views = document.getElementById('sp_panel_views');
const module = document.getElementById('sp_module_history');
const panelContent = document.querySelector('#sp_module_history .sp_panel_content');
const historyContent = document.getElementById('sp_history_content');
const firstItem = root.querySelector('.xdex-history-item');
updateThreadHistoryDebugState({
lastRenderDom: {
activeModule: module?.classList.contains('active') || false,
contentDisplay: getComputedStyle(historyContent || root).display,
resultsDisplay: getComputedStyle(root).display,
coverDisplay: getComputedStyle(cover || document.body).display,
panelDisplay: getComputedStyle(panel || document.body).display,
viewsDisplay: getComputedStyle(views || document.body).display,
moduleDisplay: getComputedStyle(module || document.body).display,
panelContentDisplay: getComputedStyle(panelContent || document.body).display,
childCount: root.children.length,
itemCount: root.querySelectorAll('.xdex-history-item').length,
coverHeight: cover?.offsetHeight || 0,
panelHeight: panel?.offsetHeight || 0,
viewsHeight: views?.offsetHeight || 0,
moduleHeight: module?.offsetHeight || 0,
panelContentHeight: panelContent?.offsetHeight || 0,
historyContentHeight: historyContent?.offsetHeight || 0,
offsetHeight: root.offsetHeight,
scrollHeight: root.scrollHeight,
firstItemHeight: firstItem?.offsetHeight || 0,
firstItemText: firstItem?.textContent?.slice(0, 80) || '',
at: new Date().toISOString()
}
});
logThreadHistory('render dom', threadHistoryDebugState.lastRenderDom);
logThreadHistoryFlat('render dom flat', threadHistoryDebugState.lastRenderDom);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(reportThreadHistoryRenderDom);
else setTimeout(reportThreadHistoryRenderDom, 0);
}
function renderThreadHistoryModuleSoon(query) {
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(() => renderThreadHistoryModule(query));
return;
}
setTimeout(() => renderThreadHistoryModule(query), 0);
}
function bindThreadHistoryModuleEvents() {
$('#sp_history_search').off('input.xdex-history').on('input.xdex-history', function () {
renderThreadHistoryModule(this.value || '');
});
$('#sp_history_sort').off('change.xdex-history').on('change.xdex-history', function () {
renderThreadHistoryModule();
});
$('#sp_history_results').off('click.xdex-history-reply', '.xdex-history-reply-action').on('click.xdex-history-reply', '.xdex-history-reply-action', function (e) {
if (e.button !== 0) return;
const url = this.href || '';
if (!url) return;
e.preventDefault();
if (e.ctrlKey || e.metaKey) {
window.location.href = url;
return;
}
window.open(url, '_blank', 'noopener');
});
$('#sp_history_results').off('click.xdex-history-delete', '.xdex-history-delete').on('click.xdex-history-delete', '.xdex-history-delete', function (e) {
e.preventDefault();
const key = this.dataset.historyKey || '';
if (!key) return;
deleteThreadHistoryItem(key);
renderThreadHistoryModule();
toast('已删除浏览历史');
});
$('#sp_history_clear').off('click.xdex-history').on('click.xdex-history', function (e) {
e.preventDefault();
clearThreadHistory();
renderThreadHistoryModule();
toast('已清空浏览历史');
});
}
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()
};
}
async function fetchExtensionUpdateJson(url, source) {
const resp = await gmRequest(url, 'text');
const json = JSON.parse(resp.responseText || '{}');
const extension = json && json.extension ? json.extension : json;
return {
source,
url,
version: String(extension.version || '').trim(),
changelog: String(extension.changelog || '').trim(),
downloads: extension.downloads || {}
};
}
function getUpdateCheckRequestsForRuntime() {
if (XDEX_RUNTIME && XDEX_RUNTIME.kind === 'extension') {
return [
() => fetchExtensionUpdateJson(UPDATE_EXTENSION_GITHUB_JSON_URL, 'github'),
() => fetchExtensionUpdateJson(UPDATE_EXTENSION_JSDELIVR_JSON_URL, 'jsdelivr')
];
}
return [
() => fetchMetaVersionAndChangelog(UPDATE_GREASYFORK_META_URL, 'greasyfork'),
() => fetchScriptCatVersionAndChangelog(UPDATE_SCRIPTCAT_API_URL, 'scriptcat')
];
}
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 preferredSource = XDEX_RUNTIME && XDEX_RUNTIME.kind === 'extension' ? 'github' : 'greasyfork';
const preferred = topCandidates.find(item => item.source === preferredSource) || 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;
if (typeof isUpdateCheckEnabled === 'function' && !isUpdateCheckEnabled()) {
$btn.removeClass('xdex-has-update');
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 xdex-update-source-github');
$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']
},
github: {
containerClass: 'xdex-update-source-github',
primary: 'github',
secondary: ['baidupan']
},
jsdelivr: {
containerClass: 'xdex-update-source-github',
primary: 'baidupan',
secondary: ['github']
}
};
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() {
if (!isUpdateCheckEnabled()) {
const state = getDefaultUpdateCheckState();
updateSettingsButtonBadge(state);
clearFooterUpdateHighlight();
return;
}
const state = getUpdateCheckState();
updateSettingsButtonBadge(state);
if (shouldShowPendingUpdateReminder(state)) {
openUpdateLogDialog('remote');
}
}
async function checkForDailyScriptUpdate(force = false) {
if (!isUpdateCheckEnabled()) {
const state = getUpdateCheckState();
updateSettingsButtonBadge(state);
clearFooterUpdateHighlight();
return state;
}
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 requests = getUpdateCheckRequestsForRuntime();
const settled = await Promise.allSettled(requests.map((request) => request()));
const sourceResults = settled.map((item, index) => {
const source = XDEX_RUNTIME && XDEX_RUNTIME.kind === 'extension'
? (index === 0 ? 'github' : 'jsdelivr')
: (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, options = {}) {
if (options.queue === false) {
showImmediateToast(msg, duration, options.key);
return;
}
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(); // ✅ 显示下一个
});
}
function showImmediateToast(msg, duration = 900, key = 'default') {
const safeKey = String(key || 'default').replace(/[^a-z0-9_-]/gi, '-');
let $t = $(`#xdex-immediate-toast-${safeKey}`);
if ($t.length) {
$t.stop(true, true).text(msg).show().delay(duration).fadeOut(160, () => $t.remove());
return $t;
}
$t = $(``);
$t.text(msg);
$('body').append($t);
$t.fadeIn(120).delay(duration).fadeOut(160, () => $t.remove());
return $t;
}
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 isValidHexColor(color) {
return /^#[0-9A-Fa-f]{6}$/.test(color);
}
function normalizeHexColor(color) {
if (typeof color !== 'string') return '';
const trimmed = color.trim();
return isValidHexColor(trimmed) ? trimmed.toUpperCase() : '';
}
function clampColorChannel(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function rgbToHex(rgb) {
if (!rgb) return '';
const r = clampColorChannel(Math.round(rgb.r || 0), 0, 255);
const g = clampColorChannel(Math.round(rgb.g || 0), 0, 255);
const b = clampColorChannel(Math.round(rgb.b || 0), 0, 255);
return `#${[r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('').toUpperCase()}`;
}
function hexToRgb(color) {
const normalized = normalizeHexColor(color);
if (!normalized) return null;
return {
r: parseInt(normalized.slice(1, 3), 16),
g: parseInt(normalized.slice(3, 5), 16),
b: parseInt(normalized.slice(5, 7), 16),
};
}
function hsvToRgb(h, s, v) {
const hue = ((Number(h) % 360) + 360) % 360;
const sat = clampColorChannel(Number(s), 0, 1);
const val = clampColorChannel(Number(v), 0, 1);
const c = val * sat;
const x = c * (1 - Math.abs((hue / 60) % 2 - 1));
const m = val - c;
let r1 = 0;
let g1 = 0;
let b1 = 0;
if (hue < 60) {
r1 = c; g1 = x;
} else if (hue < 120) {
r1 = x; g1 = c;
} else if (hue < 180) {
g1 = c; b1 = x;
} else if (hue < 240) {
g1 = x; b1 = c;
} else if (hue < 300) {
r1 = x; b1 = c;
} else {
r1 = c; b1 = x;
}
return {
r: Math.round((r1 + m) * 255),
g: Math.round((g1 + m) * 255),
b: Math.round((b1 + m) * 255),
};
}
function rgbToHsv(r, g, b) {
const red = clampColorChannel(Number(r), 0, 255) / 255;
const green = clampColorChannel(Number(g), 0, 255) / 255;
const blue = clampColorChannel(Number(b), 0, 255) / 255;
const max = Math.max(red, green, blue);
const min = Math.min(red, green, blue);
const delta = max - min;
let h = 0;
if (delta) {
if (max === red) h = 60 * (((green - blue) / delta) % 6);
else if (max === green) h = 60 * (((blue - red) / delta) + 2);
else h = 60 * (((red - green) / delta) + 4);
}
if (h < 0) h += 360;
return {
h,
s: max === 0 ? 0 : delta / max,
v: max,
};
}
function hexToHsv(color) {
const rgb = hexToRgb(color);
return rgb ? rgbToHsv(rgb.r, rgb.g, rgb.b) : { h: 0, s: 0, v: 1 };
}
function hsvToHex(h, s, v) {
return rgbToHex(hsvToRgb(h, s, v));
}
function parseRgbColorString(value) {
if (typeof value !== 'string') return null;
const match = value.trim().match(/^rgb\s*\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/i);
if (!match) return null;
const r = Number(match[1]);
const g = Number(match[2]);
const b = Number(match[3]);
if ([r, g, b].some((n) => Number.isNaN(n) || n < 0 || n > 255)) return null;
return { r, g, b };
}
function formatRgbColor(rgb) {
if (!rgb) return '';
return `rgb(${clampColorChannel(Math.round(rgb.r || 0), 0, 255)}, ${clampColorChannel(Math.round(rgb.g || 0), 0, 255)}, ${clampColorChannel(Math.round(rgb.b || 0), 0, 255)})`;
}
function getMarkedGroupEffectiveColor(group, index) {
return normalizeHexColor(group && group.color) || markColors[index % markColors.length];
}
function isValidThreadId(threadId) {
return /^\d{8}$/.test(threadId);
}
function normalizeFavoriteThreadInput(raw) {
const value = String(raw || '').trim();
if (isValidThreadId(value)) return value;
let url;
try {
url = new URL(value, location.origin);
} catch (e) {
return '';
}
if (url.hostname && !['www.nmbxd1.com', 'nmbxd1.com', 'www.nmbxd.com', 'nmbxd.com'].includes(url.hostname)) return '';
const path = url.pathname || '';
const threadMatch = path.match(/^\/t\/(\d{8})(?:\/\d+)?\/?$/);
if (threadMatch) return threadMatch[1];
const poMatch = path.match(/^\/Forum\/po\/id\/(\d{8})(?:\/page\/\d+)?(?:\.html)?$/);
if (poMatch) return poMatch[1];
return '';
}
function makeFavoriteThreadUrl(threadId) {
return `https://www.nmbxd1.com/t/${threadId}`;
}
function trimFavoriteThreadDesc(desc) {
return String(desc || '').trim().slice(0, 20);
}
function formatFavoriteThreadMenuText(item) {
const text = item && item.desc ? item.desc : item && item.threadId ? item.threadId : '';
return text.length > 7 ? `${text.slice(0, 7)}……` : text;
}
function normalizeFavoriteThreads(val) {
if (!Array.isArray(val)) return [];
const seen = new Set();
return val.map((item) => {
const desc = item && typeof item.desc === 'string' && isValidDesc(item.desc.trim()) ? item.desc.trim() : '';
const threadId = normalizeFavoriteThreadInput(item && typeof item.threadId === 'string' ? item.threadId : '');
return { desc, threadId };
}).filter((item) => {
if (!isValidThreadId(item.threadId) || seen.has(item.threadId)) return false;
seen.add(item.threadId);
return true;
});
}
function collectFavoriteThreadsFromPanel() {
const parsed = [];
const seen = new Map();
let valid = true;
$('#favorite-thread-inputs-container .favorite-thread-row').each((idx, el) => {
const $row = $(el);
const desc = ($row.find('.favorite-thread-desc-input').val() || '').trim();
const rawThread = ($row.find('.favorite-thread-id-input').val() || '').trim();
const threadId = normalizeFavoriteThreadInput(rawThread);
if (!desc && !rawThread) return;
if (!isValidDesc(desc)) { toast(`第${idx + 1}组备注过长`); valid = false; return false; }
if (!rawThread) { toast(`第${idx + 1}组未指定串号或链接`); valid = false; return false; }
if (!threadId) { toast(`第${idx + 1}组存在不合法串号或链接`); valid = false; return false; }
if (seen.has(threadId)) {
const first = seen.get(threadId);
const suffix = first.desc ? `(${first.desc})` : '';
toast(`第${idx + 1}组与第${first.index}组${suffix}重复`);
valid = false;
return false;
}
seen.set(threadId, { index: idx + 1, desc });
parsed.push({ desc, threadId });
});
return valid ? parsed : null;
}
function buildFavoriteThreadRowHtml(index, item = {}) {
const desc = item.desc || '';
const threadId = item.threadId || '';
return `
`;
}
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 buildBlockedKeywordGroupRowHtml(index, group = {}) {
const keywordText = typeof group.value === 'string'
? group.value
: (Array.isArray(group.keywords) ? group.keywords.join(',') : '');
return buildCookieGroupRowHtml('blocked-keyword', index, keywordText, '关键词1,关键词2;8位数字同时也作为串号/回复号匹配');
}
function buildCookieGroupTwoFieldRowHtml(type, index, group = {}) {
const desc = group.desc || '';
const cookieText = Array.isArray(group.cookies) ? group.cookies.join(',') : '';
const color = normalizeHexColor(group.color);
const effectiveColor = type === 'marked' ? getMarkedGroupEffectiveColor(group, index - 1) : '';
const gridTemplateColumns = type === 'marked'
? 'minmax(0,2fr) minmax(0,3fr) 34px'
: 'minmax(0,2fr) minmax(0,3fr)';
const markedSwatchHtml = type === 'marked' ? `
#RRGGBB,自定义为空时按默认序列
默认 ${effectiveColor}
` : '';
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 escapeBlockedKeywordInputToken(keyword) {
return String(keyword || '').trim().replace(/([\\,,])/g, '\\$1');
}
function joinBlockedKeywordInputTokens(keywords) {
return keywords.map(escapeBlockedKeywordInputToken).filter(Boolean).join(',');
}
function normalizeBlockedKeywordGroupValue(group) {
if (typeof group === 'string') return group.trim();
if (Array.isArray(group)) return joinBlockedKeywordInputTokens(group);
if (!group || typeof group !== 'object') return '';
if (typeof group.value === 'string') return group.value.trim();
if (typeof group.text === 'string') return group.text.trim();
if (typeof group.keywords === 'string') return group.keywords.trim();
if (Array.isArray(group.keywords)) return joinBlockedKeywordInputTokens(group.keywords);
return '';
}
function normalizeBlockedKeywordGroups(val) {
if (!val) return [];
if (typeof val === 'string') {
const value = normalizeBlockedKeywordGroupValue(val);
return Utils.strToList(value).length ? [{ value }] : [];
}
if (!Array.isArray(val)) return [];
return val.map((group) => {
const value = normalizeBlockedKeywordGroupValue(group);
return { value };
}).filter((group) => Utils.strToList(group.value).length);
}
function flattenBlockedKeywords(groups) {
return [...new Set(normalizeBlockedKeywordGroups(groups).flatMap((group) => Utils.strToList(group.value)))];
}
function isEightDigitKeyword(keyword) {
return /^\d{8}$/.test(String(keyword || '').trim());
}
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, color: '', 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() : '',
color: normalizeHexColor(g.color),
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, color: '', 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 SETTINGS_BUTTON_ICON_32 = 'data:image/png;base64,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';
const SETTINGS_BUTTON_ICON_64 = 'data:image/png;base64,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';
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, // 优化引用弹窗
enableUpdateCheck: true,
enableImageContextMenu: true,
enableImageHideMode: true, // 图片隐藏/无图模式
applyImageHideMode: 'default', // default | blur | noimage | tips
enableDraft: true,
timeDisplayMode: 'relative', // relative | exact
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: [],
favoriteThreads: [],
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 (key === 'blockedKeywords') {
if (typeof val !== 'string' && !Array.isArray(val)) { skipped++; continue; }
validated[key] = normalizeBlockedKeywordGroups(val);
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';
}
const timeDisplaySelect = document.getElementById('sp_timeDisplayMode');
if (timeDisplaySelect) {
timeDisplaySelect.value = (this.state && this.state.timeDisplayMode === 'exact') ? 'exact' : 'relative';
}
},
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);
if (this.state.timeDisplayMode !== 'exact') this.state.timeDisplayMode = 'relative';
// 该功能为固定启用项:避免历史配置把它保存为 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.blockedKeywords = normalizeBlockedKeywordGroups(this.state.blockedKeywords);
this.state.favoriteThreads = normalizeFavoriteThreads(this.state.favoriteThreads);
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.blockedKeywords = normalizeBlockedKeywordGroups(this.state.blockedKeywords);
this.state.favoriteThreads = normalizeFavoriteThreads(this.state.favoriteThreads);
this.state.threadCookieWhitelistGroups = normalizeThreadCookieWhitelistGroups(this.state.threadCookieWhitelistGroups);
if (this.state.timeDisplayMode !== 'exact') this.state.timeDisplayMode = 'relative';
this.syncInputs();
this.syncAuxiliaryControls();
try { renderFavoriteThreadsMenu(); } catch (e) {}
try { refreshFilterDisplay(this.state); } catch (e) {}
try { if (typeof window.__xdexApplyTimeDisplayMode === 'function') window.__xdexApplyTimeDisplayMode(document); } 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 = `
屏蔽关键词
每组仍使用逗号分隔;8位纯数字会同时匹配正文、串号和回复号
导入/导出配置
导入将覆盖当前全部配置,建议先导出备份
浏览历史
0 条
`;
$('#sp_cover').remove();
$('body').append(html);
function setSettingsPanelModule(moduleName) {
const $nextView = $(`#sp_panel_views [data-sp-module-view="${moduleName}"]`);
const nextModule = $nextView.length ? moduleName : 'settings';
$('#sp_panel_tab_slot .sp_panel_tab').removeClass('active')
.filter(`[data-sp-module="${nextModule}"]`).addClass('active');
$('#sp_panel_views .sp_panel_module').removeClass('active').css('display', 'none');
const $activeModule = $('#sp_panel_views .sp_panel_module')
.filter(`[data-sp-module-view="${nextModule}"]`)
.addClass('active')
.css({ display: 'flex', flexDirection: 'column', flex: '1 1 auto', minHeight: nextModule === 'history' ? '300px' : '0' });
if (nextModule === 'history') {
$activeModule.find('.sp_panel_content').css({ display: 'block', flex: '1 1 auto', minHeight: '300px', overflowY: 'auto', boxSizing: 'border-box' });
$activeModule.find('#sp_history_content').css({ display: 'block' });
$activeModule.find('#sp_history_results').css({ display: 'block', minHeight: '40px' });
}
$('#sp_panel_footer .sp_panel_links').show();
$('#sp_panel_footer .sp_panel_links a').toggle(nextModule === 'settings');
$('#sp_apply').toggle(nextModule === 'settings');
updateThreadHistoryDebugState({
lastPanelModule: nextModule,
lastPanelState: {
requested: moduleName,
resolved: nextModule,
footerLinksVisible: $('#sp_panel_footer .sp_panel_links').is(':visible'),
applyVisible: $('#sp_apply').is(':visible'),
historyResults: !!document.getElementById('sp_history_results'),
historyResultsHeight: document.getElementById('sp_history_results')?.offsetHeight || 0,
historyModuleDisplay: getComputedStyle(document.getElementById('sp_module_history') || document.body).display,
historyContentDisplay: getComputedStyle(document.getElementById('sp_history_content') || document.body).display,
historyPanelContentHeight: document.querySelector('#sp_module_history .sp_panel_content')?.offsetHeight || 0,
at: new Date().toISOString()
}
});
logThreadHistory('panel module switched', threadHistoryDebugState.lastPanelState);
logThreadHistoryFlat('panel module switched flat', threadHistoryDebugState.lastPanelState);
}
$('#sp_panel_tab_slot').off('click', '[data-sp-module]').on('click', '[data-sp-module]', (e) => {
e.preventDefault();
logThreadHistory('panel tab clicked', { module: $(e.currentTarget).data('spModule') });
setSettingsPanelModule($(e.currentTarget).data('spModule'));
if ($(e.currentTarget).data('spModule') === 'history') renderThreadHistoryModuleSoon();
});
$('#sp_panel_tab_slot').off('mouseenter mouseleave', '.sp_panel_tab')
.on('mouseenter', '.sp_panel_tab', (e) => { $(e.currentTarget).addClass('is-hover'); })
.on('mouseleave', '.sp_panel_tab', (e) => { $(e.currentTarget).removeClass('is-hover'); });
setSettingsPanelModule('settings');
bindThreadHistoryModuleEvents();
bindThreadHistoryLiveSync();
renderThreadHistoryModule();
// 折叠头:统一控制
$('.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 reloadRequiredSettingKeys = [
'enableCookieSwitch',
'disableWatermark',
'enablePaginationDuplication',
'updatePreviewCookie',
'hideEmptyTitleEmail',
'enableExternalImagePreview',
'enableUpdateCheck',
'enableAutoCookieRefresh',
'enableAutoCookieRefreshToast',
'interceptReplyFormUnvcode',
'interceptReplyFormU200B',
'interceptReplyFormAutoCompress',
'enableSeamlessPaging',
'enableAutoSeamlessPaging',
'enableHDImageAndLayoutFix',
'enableImageContextMenu',
'enableLinkBlank',
'enableAutoUrlLinkify',
'enableQuotePreview',
'extendQuote',
'toggleSidebar'
];
const collectReloadRequiredSettingsFromPanel = () => {
reloadRequiredSettingKeys.forEach(k => { this.state[k] = $('#sp_' + k).is(':checked'); });
// 固定启用:不受面板勾选状态影响
this.state.enableImageHideMode = true;
};
const saveReloadRequiredSettingsImmediately = () => {
collectReloadRequiredSettingsFromPanel();
try {
GM_setValue(this.key, this.state);
toast('设置已保存,刷新后生效', 900, { queue: false, key: 'settings-saved' });
} catch (e) {}
};
const reloadRequiredSettingSelector = reloadRequiredSettingKeys.map(k => '#sp_' + k).join(',');
$(reloadRequiredSettingSelector)
.off('change.xdexReloadSettingSave')
.on('change.xdexReloadSettingSave', saveReloadRequiredSettingsImmediately);
// 图片隐藏模式:即时切换并即时应用(无需点“应用更改”)
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('已开启草稿缓存');
}
});
})();
(function initTimeDisplayModeSelect() {
const sel = document.getElementById('sp_timeDisplayMode');
if (!sel) return;
sel.value = (SettingPanel.state && SettingPanel.state.timeDisplayMode === 'exact') ? 'exact' : 'relative';
sel.addEventListener('change', (e) => {
e.stopPropagation();
const mode = (sel.value === 'exact') ? 'exact' : 'relative';
SettingPanel.state.timeDisplayMode = mode;
try { GM_setValue(SettingPanel.key, SettingPanel.state); } catch (err) {}
try { if (typeof window.__xdexApplyTimeDisplayMode === 'function') window.__xdexApplyTimeDisplayMode(document); } catch (err) {}
toast(mode === 'exact' ? '已切换为精确时间' : '已切换为相对时间');
});
})();
// 标记:新增组输入
$('#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();
});
$('#btn_group_blockedKeywords').off('click').on('click', e=>{
e.stopPropagation();
const nextIndex = $('#blocked-keyword-inputs-container .blocked-keyword-row').length + 1;
$('#blocked-keyword-inputs-container').append(buildBlockedKeywordGroupRowHtml(nextIndex));
$('#blocked-keyword-inputs-container .blocked-keyword-row').last().find('.blocked-keyword-input').focus();
});
$('#btn_group_favoriteThreads').off('click').on('click', e=>{
e.stopPropagation();
const nextIndex = $('#favorite-thread-inputs-container .favorite-thread-row').length + 1;
$('#favorite-thread-inputs-container').append(buildFavoriteThreadRowHtml(nextIndex));
$('#favorite-thread-inputs-container .favorite-thread-row').last().find('.favorite-thread-desc-input').focus();
});
const saveMarkedGroups = ({ fromDelete = false } = {}) => {
const parsed = collectMarkedGroupsFromPanel();
if (!parsed) 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;
};
const collectBlockedKeywordGroupsFromPanel = () => {
const parsed = [];
$('#blocked-keyword-inputs-container .blocked-keyword-row').each((idx, el)=>{
const rawValue = ($(el).find('.blocked-keyword-input').val() || '').trim();
if (Utils.strToList(rawValue).length) parsed.push({ value: rawValue });
});
return parsed;
};
const saveBlockedKeywordGroups = ({ fromDelete = false } = {}) => {
this.state.blockedKeywords = collectBlockedKeywordGroupsFromPanel();
GM_setValue(this.key, this.state);
this.syncInputs();
toast(fromDelete ? '已删除关键词分组' : '屏蔽关键词已保存');
refreshFilterDisplay(this.state);
return true;
};
const saveFavoriteThreads = ({ fromDelete = false } = {}) => {
const parsed = collectFavoriteThreadsFromPanel();
if (!parsed) return false;
this.state.favoriteThreads = parsed;
GM_setValue(this.key, this.state);
this.syncInputs();
renderFavoriteThreadsMenu();
toast(fromDelete ? '已删除常用串' : '常用串已保存');
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;
});
$('#blocked-keyword-inputs-container').off('click', '.blocked-keyword-delete').on('click', '.blocked-keyword-delete', (e) => {
e.preventDefault();
e.stopPropagation();
$(e.currentTarget).closest('.blocked-keyword-row').remove();
saveBlockedKeywordGroups({ fromDelete: true });
return false;
});
$('#favorite-thread-inputs-container').off('click', '.favorite-thread-delete').on('click', '.favorite-thread-delete', (e) => {
e.preventDefault();
e.stopPropagation();
$(e.currentTarget).closest('.favorite-thread-row').remove();
saveFavoriteThreads({ 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();
});
$('#btn_sp_blockedKeywords').off('click').on('click', e=>{
e.stopPropagation();
saveBlockedKeywordGroups();
});
$('#btn_sp_favoriteThreads').off('click').on('click', e=>{
e.stopPropagation();
saveFavoriteThreads();
});
$('#btn_sp_threadCookieWhitelist').off('click').on('click', e=>{
e.stopPropagation();
saveThreadCookieWhitelistGroups();
});
const closeMarkedColorPopovers = () => {
$('#marked-inputs-container .marked-color-popover').hide();
};
const getMarkedRowDefaultColor = ($row) => {
const rowIndex = Math.max($row.index(), 0);
return markColors[rowIndex % markColors.length];
};
const updateMarkedRowSwatch = ($row, draftColor) => {
const color = normalizeHexColor(draftColor) || getMarkedRowDefaultColor($row);
$row.find('.marked-color-swatch').css('background', color).attr('data-default-color', color);
$row.find('.marked-color-preview').css('background', color);
};
const readMarkedColorInputValue = ($popover) => ($popover.find('.marked-color-input').val() || '').trim();
const readMarkedColorInputAsHex = ($popover) => {
const state = $popover.data('pickerState') || {};
const rawValue = readMarkedColorInputValue($popover);
if (!rawValue) return '';
if ((state.format || 'hex') === 'rgb') {
const rgb = parseRgbColorString(rawValue);
return rgb ? rgbToHex(rgb) : null;
}
const normalized = normalizeHexColor(rawValue);
return normalized || null;
};
const setMarkedColorInputFromState = ($popover) => {
const state = $popover.data('pickerState');
if (!state) return;
const inputValue = state.inputEmpty
? ''
: (state.format === 'rgb' ? formatRgbColor(hexToRgb(state.hex)) : state.hex);
$popover.find('.marked-color-input').val(inputValue);
};
const updateMarkedColorFormatButtons = ($popover) => {
const state = $popover.data('pickerState') || {};
$popover.find('.marked-color-format').each((_, el) => {
const $btn = $(el);
const active = $btn.data('format') === (state.format || 'hex');
$btn.css({
border: active ? '1px solid #7da6bf' : '1px solid #a98f7a',
background: active ? '#66CCFF' : '#F0E0D6',
color: active ? '#fff' : '#6f5d50',
});
});
};
const renderMarkedColorPicker = ($row) => {
const $popover = $row.find('.marked-color-popover');
const state = $popover.data('pickerState');
if (!state) return;
const displayHex = normalizeHexColor(state.hex) || state.defaultHex;
const displayHsv = hexToHsv(displayHex);
const hueHex = hsvToHex(state.hsv.h, 1, 1);
const $sv = $popover.find('.marked-color-sv');
const $svThumb = $popover.find('.marked-color-sv-thumb');
const $hueThumb = $popover.find('.marked-color-hue-thumb');
$sv.css('background', `linear-gradient(to top, #000 0%, transparent 100%), linear-gradient(to right, #fff 0%, ${hueHex} 100%)`);
$svThumb.css({ left: `${state.hsv.s * 100}%`, top: `${(1 - state.hsv.v) * 100}%` });
$hueThumb.css('left', `${(state.hsv.h / 360) * 100}%`);
$popover.find('.marked-color-preview').css('background', displayHex);
$popover.find('.marked-color-status').text(state.inputEmpty ? `默认色 ${state.defaultHex}` : displayHex);
$popover.find('.marked-color-default-hint').text(`默认 ${state.defaultHex}`);
setMarkedColorInputFromState($popover);
updateMarkedColorFormatButtons($popover);
updateMarkedRowSwatch($row, state.inputEmpty ? '' : displayHex);
};
const setMarkedColorPickerHex = ($row, nextHex, options = {}) => {
const $popover = $row.find('.marked-color-popover');
const state = $popover.data('pickerState');
if (!state) return;
const normalized = normalizeHexColor(nextHex);
const allowEmpty = !!options.allowEmpty;
if (!normalized && !allowEmpty) return;
state.inputEmpty = !normalized;
state.hex = normalized || state.defaultHex;
state.hsv = hexToHsv(state.hex);
$popover.data('pickerState', state);
renderMarkedColorPicker($row);
};
const setMarkedColorPickerFormat = ($row, format) => {
const $popover = $row.find('.marked-color-popover');
const state = $popover.data('pickerState');
if (!state) return;
state.format = format === 'rgb' ? 'rgb' : 'hex';
$popover.data('pickerState', state);
renderMarkedColorPicker($row);
};
const updateMarkedColorPickerFromPointer = ($row, areaType, clientX, clientY) => {
const $popover = $row.find('.marked-color-popover');
const state = $popover.data('pickerState');
if (!state) return;
if (areaType === 'sv') {
const rect = $popover.find('.marked-color-sv')[0].getBoundingClientRect();
const s = clampColorChannel((clientX - rect.left) / rect.width, 0, 1);
const v = clampColorChannel(1 - ((clientY - rect.top) / rect.height), 0, 1);
state.hsv.s = s;
state.hsv.v = v;
} else {
const rect = $popover.find('.marked-color-hue')[0].getBoundingClientRect();
state.hsv.h = clampColorChannel(((clientX - rect.left) / rect.width) * 360, 0, 360);
if (state.hsv.h === 360) state.hsv.h = 359.999;
}
state.inputEmpty = false;
state.hex = hsvToHex(state.hsv.h, state.hsv.s, state.hsv.v);
$popover.data('pickerState', state);
renderMarkedColorPicker($row);
};
const positionMarkedColorPopover = ($row) => {
const $cell = $row.find('.marked-color-cell');
const $popover = $row.find('.marked-color-popover');
if (!$cell.length || !$popover.length) return;
$popover.css({ top: '', bottom: '' });
const cellRect = $cell[0].getBoundingClientRect();
const popRect = $popover[0].getBoundingClientRect();
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
const gap = 6;
const spaceBelow = viewportHeight - cellRect.bottom - gap;
const spaceAbove = cellRect.top - gap;
const openAbove = spaceBelow < popRect.height && spaceAbove > spaceBelow;
const popoverHeight = popRect.height;
if (openAbove) {
let offsetBottom = cellRect.height + gap;
const topIfOpened = cellRect.bottom - offsetBottom - popoverHeight;
if (topIfOpened < 4) {
offsetBottom = Math.max(cellRect.bottom - popoverHeight - 4, gap);
}
$popover.css({ top: 'auto', bottom: `${offsetBottom}px` });
} else {
let offsetTop = cellRect.height + gap;
const overflow = cellRect.top + offsetTop + popoverHeight - viewportHeight + 4;
if (overflow > 0) {
offsetTop = Math.max(offsetTop - overflow, -cellRect.top + 4);
}
$popover.css({ top: `${offsetTop}px`, bottom: 'auto' });
}
};
const openMarkedColorPopover = ($row) => {
const $popover = $row.find('.marked-color-popover');
const storedColor = ($row.find('.marked-color-value').val() || '').trim();
const defaultHex = getMarkedRowDefaultColor($row);
const displayHex = normalizeHexColor(storedColor) || defaultHex;
$popover.data('pickerState', {
format: 'hex',
defaultHex,
inputEmpty: !normalizeHexColor(storedColor),
hex: displayHex,
hsv: hexToHsv(displayHex),
});
renderMarkedColorPicker($row);
$popover.show();
positionMarkedColorPopover($row);
$popover.find('.marked-color-input').trigger('focus').trigger('select');
};
const beginMarkedColorDrag = ($row, areaType, startEvent) => {
const moveEvent = startEvent.type.indexOf('touch') === 0 ? 'touchmove.markedColorDrag' : 'mousemove.markedColorDrag';
const endEvent = startEvent.type.indexOf('touch') === 0 ? 'touchend.markedColorDrag touchcancel.markedColorDrag' : 'mouseup.markedColorDrag';
const getPoint = (evt) => {
const touch = evt.originalEvent && evt.originalEvent.touches && evt.originalEvent.touches[0];
const changedTouch = evt.originalEvent && evt.originalEvent.changedTouches && evt.originalEvent.changedTouches[0];
return touch || changedTouch || evt;
};
const applyPointer = (evt) => {
const point = getPoint(evt);
updateMarkedColorPickerFromPointer($row, areaType, point.clientX, point.clientY);
};
applyPointer(startEvent);
$(document).off('.markedColorDrag').on(moveEvent, (evt) => {
evt.preventDefault();
applyPointer(evt);
}).on(endEvent, () => {
$(document).off('.markedColorDrag');
});
};
const collectMarkedGroupsFromPanel = () => {
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());
const rawColor = ($row.find('.marked-color-value').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; }
if (rawColor && !isValidHexColor(rawColor)) { toast(`第${idx + 1}条颜色格式无效,应为 #RRGGBB`); valid=false; return false; }
parsed.push({ desc, color: normalizeHexColor(rawColor), cookies });
});
return valid ? parsed : null;
};
$(document).off('click.markedColorPopover').on('click.markedColorPopover', (e) => {
if ($(e.target).closest('#marked-inputs-container .marked-color-cell').length) return;
closeMarkedColorPopovers();
});
$('#marked-inputs-container').off('click', '.marked-color-swatch').on('click', '.marked-color-swatch', (e) => {
e.preventDefault();
e.stopPropagation();
const $row = $(e.currentTarget).closest('.marked-row');
const $popover = $row.find('.marked-color-popover');
const shouldOpen = !$popover.is(':visible');
closeMarkedColorPopovers();
if (!shouldOpen) return false;
openMarkedColorPopover($row);
return false;
});
$('#marked-inputs-container').off('input', '.marked-color-input').on('input', '.marked-color-input', (e) => {
const $row = $(e.currentTarget).closest('.marked-row');
const $popover = $row.find('.marked-color-popover');
const normalized = readMarkedColorInputAsHex($popover);
if (normalized === '') {
setMarkedColorPickerHex($row, '', { allowEmpty: true });
return;
}
if (normalized) setMarkedColorPickerHex($row, normalized);
});
$('#marked-inputs-container').off('blur', '.marked-color-input').on('blur', '.marked-color-input', (e) => {
const $row = $(e.currentTarget).closest('.marked-row');
const $popover = $row.find('.marked-color-popover');
const parsedHex = readMarkedColorInputAsHex($popover);
if (parsedHex === '') {
setMarkedColorPickerHex($row, '', { allowEmpty: true });
return;
}
if (parsedHex) {
setMarkedColorPickerHex($row, parsedHex);
return;
}
renderMarkedColorPicker($row);
});
$('#marked-inputs-container').off('click', '.marked-color-format').on('click', '.marked-color-format', (e) => {
e.preventDefault();
e.stopPropagation();
const $row = $(e.currentTarget).closest('.marked-row');
setMarkedColorPickerFormat($row, $(e.currentTarget).data('format'));
return false;
});
$('#marked-inputs-container').off('mousedown touchstart', '.marked-color-sv').on('mousedown touchstart', '.marked-color-sv', (e) => {
e.preventDefault();
e.stopPropagation();
beginMarkedColorDrag($(e.currentTarget).closest('.marked-row'), 'sv', e);
return false;
});
$('#marked-inputs-container').off('mousedown touchstart', '.marked-color-hue').on('mousedown touchstart', '.marked-color-hue', (e) => {
e.preventDefault();
e.stopPropagation();
beginMarkedColorDrag($(e.currentTarget).closest('.marked-row'), 'hue', e);
return false;
});
$('#marked-inputs-container').off('click', '.marked-color-clear').on('click', '.marked-color-clear', (e) => {
e.preventDefault();
e.stopPropagation();
const $row = $(e.currentTarget).closest('.marked-row');
const $popover = $row.find('.marked-color-popover');
$row.find('.marked-color-value').val('');
if ($popover.is(':visible')) {
setMarkedColorPickerHex($row, '', { allowEmpty: true });
const state = $popover.data('pickerState');
if (state) {
state.format = 'hex';
$popover.data('pickerState', state);
renderMarkedColorPicker($row);
}
} else {
updateMarkedRowSwatch($row, '');
}
$popover.hide();
return false;
});
$('#marked-inputs-container').off('click', '.marked-color-save').on('click', '.marked-color-save', (e) => {
e.preventDefault();
e.stopPropagation();
const $row = $(e.currentTarget).closest('.marked-row');
const $popover = $row.find('.marked-color-popover');
const parsedHex = readMarkedColorInputAsHex($popover);
if (parsedHex === null) {
toast('颜色格式无效,应为 #RRGGBB 或 rgb(r, g, b)');
$row.find('.marked-color-input').trigger('focus');
return false;
}
const normalized = normalizeHexColor(parsedHex);
$row.find('.marked-color-value').val(normalized);
if (normalized) {
setMarkedColorPickerHex($row, normalized);
setMarkedColorPickerFormat($row, 'hex');
} else {
setMarkedColorPickerHex($row, '', { allowEmpty: true });
}
updateMarkedRowSwatch($row, normalized);
$popover.hide();
return false;
});
// ========== 导入/导出配置 ==========
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', ()=>{
collectReloadRequiredSettingsFromPanel();
let valid = true;
this.state.blockedKeywords = collectBlockedKeywordGroupsFromPanel();
const favoriteThreads = collectFavoriteThreadsFromPanel();
if (!favoriteThreads) return;
this.state.favoriteThreads = favoriteThreads;
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';
this.state.timeDisplayMode = ($('#sp_timeDisplayMode').val() === 'exact') ? 'exact' : 'relative';
// 标记分组(双字段结构)
const mk = collectMarkedGroupsFromPanel();
if (!mk) 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_enableUpdateCheck: '控制是否自动检查脚本更新;关闭后不会发起远程更新请求,也不会继续安排后续检查。',
sp_enableAutoCookieRefresh: '回到X岛页面后自动刷新饼干,以防错饼',
sp_enableAutoCookieRefreshToast: '自动刷新时显示toast提示,触发频率较高,建议关闭',
sp_enableSeamlessPaging: '阅读到页面底部时无缝加载下一页并为新页首添加页码提示',
sp_enableAutoSeamlessPaging: '滚动到页面底部后自动触发无缝翻页,关闭则可使用按钮手动无缝翻页',
sp_enableHDImageAndLayoutFix: 'X岛-揭示板的增强型体验:默认加载原图而非缩略图,并为所有图片添加X岛自带图片控件;调整布局,防止文字与图片溢出',
sp_enableImageContextMenu: 'userscript模式:为图片/动图启用自定义右键菜单,关闭后保留浏览器原生图片右键菜单,复制图片过程中需要浏览器窗口在前台。\nextension模式:在浏览器右键菜单中添加“X岛-EX:复制GIF/APNG”按钮,仅用于复制GIF/APNG,在复制GIF/APNG过程中可不在前台。',
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_timeDisplayMode: '切换串内时间显示方式。相对时间会在当前可见页面定时刷新;精确时间显示原始发帖时间。',
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娘,点击后可恢复原图显示',
sp_enableFavoriteThreads: '在侧边栏添加常用串,支持串内一键添加,并优先跳转浏览历史中的最近阅读页',
sp_enableThreadHistory: '保存浏览历史,支持搜索,可切换多种排序方式',
};
// 更新日志弹窗(放在 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;
}
`;
$('