// ==UserScript== // @name X岛-EX // @namespace https://github.com/SayaGoodBye/nmbxd-EX // @version 3.2.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 nmbxd1.com // @connect www.nmbxd1.com // @connect nmbxd.com // @connect www.nmbxd.com // @connect nmb-search.166666666.xyz // @connect image.nmb.best // @connect api.nmb.best // @connect raw.githubusercontent.com // @connect cdn.jsdelivr.net // @connect fastly.jsdelivr.net // @connect update.greasyfork.org // @connect scriptcat.org // @connect code.jquery.com // @connect unpkg.com // @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 新增\n1.新增"我的订阅",可使用订阅号与移动端订阅互通,提供更完整的信息(内容、图片、版块),并支持侧边栏一键跳转订阅面板,支持添加多个订阅号。\n\n优化:\n1.浏览历史支持显示被SAGE的串,高级检索支持“has:sage”。\n\n修复:\n1.修复图片隐藏模式对设置图标进行作用;修复图片懒加载下图片隐藏模式中Tips模式无法正常替换图片的问题。\n // @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 POST_HISTORY_STORAGE_KEY = 'xdex_post_history'; const POST_HISTORY_STORE_VERSION = 1; // const POST_HISTORY_LIMIT = 500; const POST_HISTORY_THREAD_LIMIT = Infinity; const POST_HISTORY_REPLY_LIMIT = Infinity; const POST_HISTORY_SYNC_EVENT = 'xdex:post-history-changed'; const POST_HISTORY_API_BASE = `${location.origin}/Api`; const POST_HISTORY_REF_API_FALLBACK_BASE = 'https://api.nmb.best/api'; const POST_HISTORY_THREAD_API_BASE = 'https://api.nmb.best/api'; const POST_HISTORY_GET_LAST_POST_RETRY_DELAYS = [300, 800, 1500, 2500]; const POST_HISTORY_CONFIRM_TIMEOUT_MS = 10000; const POST_HISTORY_REPLIES_PER_PAGE = 19; const POST_HISTORY_MATCH_TIME_WINDOW_MS = 45000; const POST_HISTORY_FORUM_FID_MAP = Object.freeze({ '-1': '时间线', '4': '综合版1', '98': 'DANGER/U/', '20': '欢乐恶搞', '121': '速报2', '17': '绘画(二创)', '110': '社畜(校园)', '19': '故事(小说)', '81': '都市怪谈(灵异)', '37': '军武', '30': '技术宅(代码)', '75': '数码(装机)', '118': '宠物', '97': '女装(时尚)', '106': '买买买(物品推荐)', '14': '动画综合', '12': '漫画', '53': '婆罗门一', '31': '电影/电视', '116': '主播管人(圈内)', '45': '卡牌桌游', '9': '特摄(布袋戏)', '102': '战锤', '39': '胶佬(手办)', '94': '铁道厨(车辆)', '6': 'VOCALOID', '90': '小马(美漫)', '5': '东方Project', '93': '舰娘', '111': '跑团', '57': '创作茶水间', '91': '规则怪谈', '11': '海龟汤(推理)', '15': '科学(干货)', '103': '文学(推书)', '35': '音乐(推歌)', '27': 'AI(Chatgpt)', '115': '摄影(cos)', '112': 'ROLL点', '2': '游戏综合', '3': '手游专楼', '25': '任天堂NS', '22': '腾讯游戏(LOL)', '23': '暴雪游戏', '124': 'SE(FF14)', '70': 'V社(DOTA)', '28': '怪物猎人', '68': '鹰角游戏', '47': '米哈游', '34': '音游打卡', '10': '联机(服务器发布)', '62': '露营', '113': '育儿', '120': '自救互助', '32': '料理(美食)', '33': '体育(健身)', '56': '学业打卡', '89': '日记(树洞)', '18': '值班室', '117': '技术支持', '96': '版务', '60': '三百人委员会' }); const POST_HISTORY_FORUM_GROUP_MAP = Object.freeze({ '-1': '综合', '4': '综合', '98': '综合', '20': '综合', '121': '综合', '17': '综合', '110': '综合', '19': '综合', '81': '综合', '37': '综合', '30': '综合', '75': '综合', '118': '综合', '97': '综合', '106': '综合', '14': '亚文化', '12': '亚文化', '53': '亚文化', '31': '亚文化', '116': '亚文化', '45': '亚文化', '9': '亚文化', '102': '亚文化', '39': '亚文化', '94': '亚文化', '6': '亚文化', '90': '亚文化', '5': '亚文化', '93': '亚文化', '111': '创作', '57': '创作', '91': '创作', '11': '创作', '15': '创作', '103': '创作', '35': '创作', '27': '创作', '115': '创作', '112': '创作', '2': '游戏', '3': '游戏', '25': '游戏', '22': '游戏', '23': '游戏', '124': '游戏', '70': '游戏', '28': '游戏', '68': '游戏', '47': '游戏', '34': '游戏', '10': '游戏', '62': '生活', '113': '生活', '120': '生活', '32': '生活', '33': '生活', '56': '生活', '89': '生活', '18': '管理', '117': '管理', '96': '管理', '60': '管理' }); const POST_HISTORY_FORUM_SEARCH_META = Object.freeze({ '98': { rawName: 'DANGER_U', showName: 'DANGER/U/', groupName: '综合' }, '17': { rawName: '绘画', showName: '绘画(二创)', groupName: '综合' }, '110': { rawName: '社畜', showName: '社畜(校园)', groupName: '综合' }, '19': { rawName: '故事', showName: '故事(小说)', groupName: '综合' }, '81': { rawName: '都市怪谈', showName: '都市怪谈(灵异)', groupName: '综合' }, '30': { rawName: '技术宅', showName: '技术宅(代码)', groupName: '综合' }, '75': { rawName: '数码', showName: '数码(装机)', groupName: '综合' }, '97': { rawName: '女装2', showName: '女装(时尚)', groupName: '综合' }, '106': { rawName: '买买买', showName: '买买买(物品推荐)', groupName: '综合' }, '31': { rawName: '影视', showName: '电影/电视', groupName: '亚文化' }, '116': { rawName: '主播管人', showName: '主播管人(圈内)', groupName: '亚文化' }, '9': { rawName: '特摄', showName: '特摄(布袋戏)', groupName: '亚文化' }, '39': { rawName: '胶佬', showName: '胶佬(手办)', groupName: '亚文化' }, '94': { rawName: '铁道厨', showName: '铁道厨(车辆)', groupName: '亚文化' }, '90': { rawName: '小马', showName: '小马(美漫)', groupName: '亚文化' }, '11': { rawName: '海龟汤', showName: '海龟汤(推理)', groupName: '创作' }, '15': { rawName: '科学', showName: '科学(干货)', groupName: '创作' }, '103': { rawName: '文学', showName: '文学(推书)', groupName: '创作' }, '35': { rawName: '音乐', showName: '音乐(推歌)', groupName: '创作' }, '27': { rawName: 'AI', showName: 'AI(Chatgpt)', groupName: '创作' }, '115': { rawName: '摄影', showName: '摄影(cos)', groupName: '创作' }, '25': { rawName: '任天堂', showName: '任天堂NS', groupName: '游戏' }, '22': { rawName: '腾讯游戏', showName: '腾讯游戏(LOL)', groupName: '游戏' }, '124': { rawName: 'SE', showName: 'SE(FF14)', groupName: '游戏' }, '70': { rawName: 'V社', showName: 'V社(DOTA)', groupName: '游戏' }, '10': { rawName: '联机', showName: '联机(服务器发布)', groupName: '游戏' }, '32': { rawName: '料理', showName: '料理(美食)', groupName: '生活' }, '33': { rawName: '体育', showName: '体育(健身)', groupName: '生活' }, '89': { rawName: '日记', showName: '日记(树洞)', groupName: '生活' }, '60': { rawName: '百脑汇', showName: '三百人委员会', groupName: '管理' } }); const POST_HISTORY_TIMELINE_ID_MAP = Object.freeze({ '1': '综合线', '2': '创作线', '3': '非创作线', '4': '亚文化线', '5': '综合2线', '6': '游戏线', '7': '生活线' }); const THREAD_HISTORY_SEARCH_HELP_TEXT = '普通关键词:串号、标题、名称、饼干、正文\n高级检索:\nmode:po 只看 Po 串\nmode:normal 普通串\nhas:image 带图\nhas:gif GIF\nhas:zwsp 或 has:zerowidth 含零宽字符\nhas:sage 被 SAGE 的串\n可组合:mode:po has:image has:sage 关键词'; const postHistoryConfirmationMap = new Map(); // 等待发串确认后跳转的 Promise 存储器 { localId -> resolver } const POST_HISTORY_SEARCH_HELP_TEXT = '普通关键词:发言 No、串号、板块、标题、名称、Email、正文、饼干、状态\n高级检索:\nstatus:confirmed 已确认\nstatus:pending 确认中\nstatus:failed 失败\nstatus:unconfirmed 未确认\nfid:98 指定板块 ID\nforum:综合 模糊匹配板块显示名/本名/分组名\nthread:64180270 指定串号\nid:68821620 指定发言 No\npage:203 指定页码\ncookie:abc123 指定饼干\nname:无名氏 指定名称\nemail:sage 指定 Email\nhas:image 带图\nhas:gif GIF\nhas:zwsp 或 has:zerowidth 含零宽字符\n可组合:forum:综合 has:image 关键词'; 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; let postHistoryLiveSyncBound = false; let postHistoryLiveRenderTimer = 0; let postHistoryLiveRenderFirstAt = 0; let postHistoryLiveRenderPendingCount = 0; let postHistoryLiveRenderDirty = false; let postHistoryActiveType = 'reply'; 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 createDefaultPostHistoryStore() { return { version: POST_HISTORY_STORE_VERSION, // limit: POST_HISTORY_LIMIT, items: {}, order: [] }; } function normalizePostHistoryType(type) { return type === 'reply' ? 'reply' : 'thread'; } function normalizePostHistoryStatus(status) { return ['pending', 'confirmed', 'unconfirmed', 'failed'].includes(status) ? status : 'pending'; } function normalizePostHistoryFid(fid) { const value = String(fid == null ? '' : fid).trim(); return /^-?\d+$/.test(value) ? value : ''; } function getPostHistoryForumNameByFid(fid) { return POST_HISTORY_FORUM_FID_MAP[normalizePostHistoryFid(fid)] || ''; } function normalizeHistorySearchValue(value) { return String(value == null ? '' : value).trim().toLowerCase(); } function getPostHistoryForumSearchText(item) { const fid = normalizePostHistoryFid(item && item.fid); const meta = POST_HISTORY_FORUM_SEARCH_META[fid] || {}; return [ fid, item && item.forumName, getPostHistoryForumNameByFid(fid), meta.rawName, meta.showName, meta.groupName || POST_HISTORY_FORUM_GROUP_MAP[fid] ].join(' ').toLowerCase(); } function getPostHistoryPostFid(post) { if (!post || typeof post !== 'object') return ''; return normalizePostHistoryFid(post.fid || post.Fid || post.forum_id || post.forumId || post.forum); } function getCurrentPostHistoryFid() { const path = String(location && location.pathname || ''); const forumMatch = path.match(/^\/f\/([^/?#]+)/); if (!forumMatch) return ''; let forumName = ''; try { forumName = decodeURIComponent(forumMatch[1] || ''); } catch (e) { forumName = forumMatch[1] || ''; } const normalizedName = forumName.replace(/\s+/g, '').toLowerCase(); return Object.keys(POST_HISTORY_FORUM_FID_MAP).find(fid => { const name = String(POST_HISTORY_FORUM_FID_MAP[fid] || '').replace(/\s+/g, '').toLowerCase(); return name === normalizedName; }) || ''; } function normalizePostHistoryText(text) { return String(text || '') .replace(//gi, ' ') .replace(/<[^>]+>/g, '') .replace(/ /gi, ' ') .replace(/>/gi, '>') .replace(/</gi, '<') .replace(/&/gi, '&') .replace(/\r\n/g, '\n') .replace(/\r/g, '\n') .replace(/[\s\u00a0]+/g, ' ') .trim(); } function sanitizePostHistoryServerContentHtml(content) { const wrapper = document.createElement('div'); wrapper.innerHTML = String(content || ''); return sanitizeThreadHistoryContentHtml(wrapper); } function hashPostHistoryText(text) { const normalized = normalizePostHistoryText(text); let hash = 0; for (let i = 0; i < normalized.length; i++) { hash = ((hash << 5) - hash + normalized.charCodeAt(i)) | 0; } return String(hash >>> 0); } const postHistoryDebugState = window.__xdexPostHistoryDebug = window.__xdexPostHistoryDebug || { events: [] }; function summarizePostHistoryText(text) { const normalized = normalizePostHistoryText(text); return { length: normalized.length, hash: hashPostHistoryText(normalized), preview: normalized.slice(0, 40) }; } function summarizePostHistoryCandidate(post) { if (!post) return null; const resto = String(post.resto || '0').trim(); return { id: String(post.id || '').trim(), resto, type: Number(resto) === 0 ? 'thread' : 'reply', now: post.now || '', img: post.img || '', ext: post.ext || '', content: summarizePostHistoryText(post.content || '') }; } function summarizePostHistorySnapshot(snapshot) { if (!snapshot) return null; return { localId: snapshot.localId || '', type: snapshot.type || '', resto: snapshot.resto || '', contentHash: snapshot.contentHash || '', submittedAt: snapshot.submittedAt || 0, sourceUrl: snapshot.sourceUrl || '' }; } function logPostHistory(stage, data, level = 'log') { const detail = Object.assign({ stage, at: Date.now() }, data || {}); try { postHistoryDebugState.events.push(detail); if (postHistoryDebugState.events.length > 80) postHistoryDebugState.events.shift(); postHistoryDebugState.last = detail; } catch (e) {} const method = console[level] ? level : 'log'; console[method]('[post-history] ' + stage, detail); } window.__xdexGetPostHistoryDebug = function getPostHistoryDebug() { return postHistoryDebugState; }; window.__xdexClearPostHistoryDebug = function clearPostHistoryDebug() { postHistoryDebugState.events = []; postHistoryDebugState.last = null; return postHistoryDebugState; }; function normalizePostHistoryStore(rawStore) { const store = Object.assign(createDefaultPostHistoryStore(), rawStore || {}); store.version = POST_HISTORY_STORE_VERSION; // store.limit = Number(store.limit) > 0 ? Number(store.limit) : POST_HISTORY_LIMIT; store.items = store.items && typeof store.items === 'object' ? store.items : {}; 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 => { const item = store.items[key] || {}; item.localId = item.localId || key; item.status = normalizePostHistoryStatus(item.status); item.type = normalizePostHistoryType(item.type); item.fid = normalizePostHistoryFid(item.fid); item.forumName = item.forumName || getPostHistoryForumNameByFid(item.fid); item.contentText = normalizePostHistoryText(item.contentText || item.contentRaw || ''); item.contentHash = item.contentHash || hashPostHistoryText(item.contentText); item.page = Math.max(0, Number(item.page) || 0); store.items[key] = item; if (!seen.has(key)) { seen.add(key); store.order.push(key); } }); store.order.sort((a, b) => { const av = Number(store.items[a] && store.items[a].submittedAt) || 0; const bv = Number(store.items[b] && store.items[b].submittedAt) || 0; return bv - av; }); // 旧:全局共享 limit 清理 // while (store.order.length > store.limit) { // const key = store.order.pop(); // delete store.items[key]; // } // 新增:按类型分别清理(仅在 limit !== Infinity 时生效) const shouldCleanThread = POST_HISTORY_THREAD_LIMIT !== Infinity; const shouldCleanReply = POST_HISTORY_REPLY_LIMIT !== Infinity; if (shouldCleanThread || shouldCleanReply) { const typeOrders = { thread: [], reply: [] }; store.order.forEach(key => { const type = normalizePostHistoryType(store.items[key]?.type); typeOrders[type].push(key); }); while (shouldCleanThread && typeOrders.thread.length > POST_HISTORY_THREAD_LIMIT) { const key = typeOrders.thread.pop(); delete store.items[key]; } while (shouldCleanReply && typeOrders.reply.length > POST_HISTORY_REPLY_LIMIT) { const key = typeOrders.reply.pop(); delete store.items[key]; } store.order = store.order.filter(key => store.items[key]); } return store; } function getPostHistoryStore() { try { return normalizePostHistoryStore(GM_getValue(POST_HISTORY_STORAGE_KEY, null)); } catch (e) { return createDefaultPostHistoryStore(); } } function isPostHistoryPanelOpen() { const cover = document.getElementById('sp_cover'); const module = document.getElementById('sp_module_posts'); return !!module && module.classList.contains('active') && (!cover || getComputedStyle(cover).display !== 'none'); } function schedulePostHistoryLiveRender(source, remote) { const active = isPostHistoryPanelOpen(); const renderable = !!document.getElementById('sp_posts_results'); const now = Date.now(); if (!postHistoryLiveRenderFirstAt) postHistoryLiveRenderFirstAt = now; postHistoryLiveRenderPendingCount += 1; postHistoryLiveRenderDirty = true; logPostHistory('live sync', { source, remote: !!remote, active, renderable, pendingCount: postHistoryLiveRenderPendingCount, firstAt: postHistoryLiveRenderFirstAt }); if (!renderable) { if (postHistoryLiveRenderTimer) clearTimeout(postHistoryLiveRenderTimer); postHistoryLiveRenderTimer = 0; postHistoryLiveRenderFirstAt = 0; postHistoryLiveRenderPendingCount = 0; return; } const run = () => { postHistoryLiveRenderTimer = 0; postHistoryLiveRenderFirstAt = 0; postHistoryLiveRenderPendingCount = 0; postHistoryLiveRenderDirty = false; if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => renderPostHistoryModule()); else renderPostHistoryModule(); }; if (postHistoryLiveRenderTimer) clearTimeout(postHistoryLiveRenderTimer); const elapsed = now - postHistoryLiveRenderFirstAt; 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); postHistoryLiveRenderTimer = setTimeout(run, delay); } function notifyPostHistoryStoreChanged(source, remote) { try { window.dispatchEvent(new CustomEvent(POST_HISTORY_SYNC_EVENT, { detail: { source, remote: !!remote, at: Date.now() } })); } catch (e) {} logPostHistory('store notify', { source, remote: !!remote }); schedulePostHistoryLiveRender(source, remote); } function setPostHistoryStore(store) { const normalized = normalizePostHistoryStore(store); GM_setValue(POST_HISTORY_STORAGE_KEY, normalized); notifyPostHistoryStoreChanged('local-write', false); return normalized; } function bindPostHistoryLiveSync() { if (postHistoryLiveSyncBound) return; postHistoryLiveSyncBound = true; if (typeof GM_addValueChangeListener === 'function') { try { GM_addValueChangeListener(POST_HISTORY_STORAGE_KEY, (_key, _oldValue, _newValue, remote) => { schedulePostHistoryLiveRender('gm-value-change', remote); }); } catch (e) { logPostHistory('live sync listener failed', { error: e && e.message ? e.message : String(e) }, 'warn'); } } window.addEventListener(POST_HISTORY_SYNC_EVENT, (event) => { const detail = event && event.detail || {}; schedulePostHistoryLiveRender(detail.source || 'window-event', !!detail.remote); }); } function buildCanonicalReplyUrl(threadId, replyId) { const tid = String(threadId || '').trim(); const rid = String(replyId || '').trim(); if (!tid || !rid) return ''; return `${location.origin}/t/${tid}?r=${rid}`; } function buildPostHistoryUrl(type, id, resto) { const postId = String(id || '').trim(); const threadId = String(type === 'reply' ? resto : id || '').trim(); if (!postId && !threadId) return ''; if (type === 'reply') return buildCanonicalReplyUrl(threadId, postId); // 主题也使用 ?r=threadId 格式 // return `${location.origin}/t/${threadId}`; return `${location.origin}/t/${threadId}?r=${threadId}`; } function buildPostHistoryReplyActionUrl(type, id, resto, page) { const postId = String(id || '').trim(); const threadId = String(type === 'reply' ? resto : id || '').trim(); const pageNum = Math.max(0, Number(page) || 0); // reply 类型保持原来的行为:有 page 就用 ?page=N,否则回退到 ?r=replyId if (type === 'reply') { if (threadId && pageNum > 0) return `${location.origin}/t/${threadId}?page=${pageNum}`; return buildPostHistoryUrl(type, postId, threadId); } // thread 类型优先从浏览历史获取最新页面(动态更新) const historyUrl = getLatestThreadHistoryUrl(threadId); if (historyUrl) return historyUrl; // 没有浏览历史,回退到发言历史记录的页面 const fallbackPage = Math.max(1, Number(page) || 1); if (threadId) return `${location.origin}/t/${threadId}?page=${fallbackPage}`; return buildPostHistoryUrl(type, postId, threadId); } function getConfirmedPostHistoryIds(store) { const ids = new Set(); Object.keys(store.items || {}).forEach(key => { const item = store.items[key]; if (item && item.status === 'confirmed' && item.id) ids.add(String(item.id)); }); return ids; } function upsertPostHistoryRecord(record) { if (!record || !record.localId) return getPostHistoryStore(); const store = getPostHistoryStore(); const old = store.items[record.localId] || {}; const merged = Object.assign({}, old, record, { localId: record.localId, status: normalizePostHistoryStatus(record.status), type: normalizePostHistoryType(record.type), contentText: normalizePostHistoryText(record.contentText || record.contentRaw || old.contentText || ''), contentHash: record.contentHash || old.contentHash || hashPostHistoryText(record.contentText || record.contentRaw || old.contentText || ''), submittedAt: Number(record.submittedAt || old.submittedAt) || Date.now() }); store.items[merged.localId] = merged; store.order = [merged.localId].concat((store.order || []).filter(key => key !== merged.localId)); const typeCounts = { thread: 0, reply: 0 }; store.order.forEach(key => { const type = normalizePostHistoryType(store.items[key]?.type); typeCounts[type]++; }); logPostHistory('store upsert', { localId: merged.localId, status: merged.status, type: merged.type, contentHash: merged.contentHash, submittedAt: merged.submittedAt, total: store.order.length, threadCount: typeCounts.thread, replyCount: typeCounts.reply }); return setPostHistoryStore(store); } function updatePostHistoryRecord(localId, patch) { const store = getPostHistoryStore(); if (!store.items[localId]) { logPostHistory('store update skipped', { localId, patch: patch || {} }, 'warn'); return store; } store.items[localId] = Object.assign({}, store.items[localId], patch || {}); logPostHistory('store update', { localId, patch: patch || {}, status: store.items[localId].status, type: store.items[localId].type, id: store.items[localId].id || '', resto: store.items[localId].resto || '' }); return setPostHistoryStore(store); } function deletePostHistoryItem(localId) { const store = getPostHistoryStore(); delete store.items[localId]; store.order = (store.order || []).filter(key => key !== localId); return setPostHistoryStore(store); } function clearPostHistory() { return setPostHistoryStore(createDefaultPostHistoryStore()); } function searchPostHistory(query, type) { const store = getPostHistoryStore(); const selectedType = normalizePostHistoryType(type || postHistoryActiveType); const { filters, tokens } = parsePostHistorySearchQuery(query); return (store.order || []) .map(key => ({ key, item: store.items[key] })) .filter(result => { const item = result.item || {}; if (normalizePostHistoryType(item.type) !== selectedType) return false; if (filters.statusFilters.length && !filters.statusFilters.includes(normalizePostHistoryStatus(item.status))) return false; if (filters.fidFilters.length && !filters.fidFilters.includes(normalizePostHistoryFid(item.fid))) return false; if (filters.forumFilters.length && !filters.forumFilters.every(value => getPostHistoryForumSearchText(item).includes(value))) return false; if (filters.hasImage && !item.imageFile) return false; if (filters.isGif && !/\.gif(?:$|[?#])/i.test(String(item.imageFile || item.imageExt || ''))) return false; if (filters.hasZeroWidth && !ZERO_WIDTH_RE.test(String(item.contentRaw || item.contentText || ''))) return false; if (filters.fieldFilters.length && !filters.fieldFilters.every(filter => getPostHistorySearchFieldText(item, filter.field).includes(filter.value))) return false; const text = buildPostHistorySearchText(item); return tokens.every(token => text.includes(token)); }); } function parsePostHistorySearchQuery(query) { const filters = { statusFilters: [], fidFilters: [], forumFilters: [], fieldFilters: [], hasImage: false, isGif: false, hasZeroWidth: false }; const tokens = []; String(query || '').split(/\s+/).filter(Boolean).forEach(rawToken => { const token = normalizeHistorySearchValue(rawToken); const pair = token.match(/^([a-z]+):(.+)$/); if (!pair) { tokens.push(token); return; } const key = pair[1]; const value = normalizeHistorySearchValue(pair[2]); if (!value) return; if (key === 'status' && ['pending', 'confirmed', 'unconfirmed', 'failed'].includes(value)) filters.statusFilters.push(value); else if (key === 'fid') { const fid = normalizePostHistoryFid(value); if (fid) filters.fidFilters.push(fid); } else if (key === 'forum') filters.forumFilters.push(value); else if (key === 'has' && value === 'image') filters.hasImage = true; else if (key === 'has' && value === 'gif') filters.isGif = true; else if (key === 'has' && (value === 'zwsp' || value === 'zerowidth')) filters.hasZeroWidth = true; else if (['id', 'thread', 'page', 'cookie', 'name', 'email'].includes(key)) filters.fieldFilters.push({ field: key, value }); else tokens.push(token); }); return { filters, tokens }; } function getPostHistorySearchFieldText(item, field) { if (field === 'id') return normalizeHistorySearchValue([item.id, item.postId].join(' ')); if (field === 'thread') return normalizeHistorySearchValue([item.threadId, item.resto].join(' ')); if (field === 'page') return normalizeHistorySearchValue(item.page); if (field === 'cookie') return normalizeHistorySearchValue(item.userHash); if (field === 'name') return normalizeHistorySearchValue(item.name); if (field === 'email') return normalizeHistorySearchValue(item.email); return ''; } function buildPostHistorySearchText(item) { return [ item.id, item.threadId, item.postId, item.resto, item.fid, item.forumName, getPostHistoryForumSearchText(item), item.title, item.name, item.email, item.contentText, item.contentRaw, item.userHash, item.status, item.type, item.page, item.url, item.sourceUrl, item.imageFile, item.imageImg, item.imageExt ].join(' ').toLowerCase(); } function parseLastPostResponse(resp, context) { const text = resp && (resp.responseText || resp.response) || ''; try { const json = typeof text === 'string' ? JSON.parse(text) : text; const data = json && (json.data || json.post || json); const post = Array.isArray(data) ? (data[0] || null) : data; if (!post) { logPostHistory('getLastPost empty', Object.assign({}, context || {}, { responseLength: String(text || '').length })); return null; } logPostHistory('getLastPost parse', Object.assign({}, context || {}, { candidate: summarizePostHistoryCandidate(post) })); return post; } catch (e) { logPostHistory('getLastPost parse failed', Object.assign({}, context || {}, { error: e && e.message ? e.message : String(e), responseLength: String(text || '').length, preview: String(text || '').slice(0, 80) }), 'warn'); return null; } } function fetchPostHistorySameOriginText(url, context, stage) { const label = stage || 'post history api'; logPostHistory(label + ' request', Object.assign({}, context || {}, { url })); return fetch(url, { credentials: 'include', cache: 'no-store' }).then(resp => { return resp.text().then(text => { logPostHistory(label + ' response', Object.assign({}, context || {}, { status: resp.status, responseLength: String(text || '').length })); if (!resp.ok) throw new Error(`HTTP ${resp.status} ${url}`); return { status: resp.status, statusText: resp.statusText, responseText: text, response: text, finalUrl: resp.url || url }; }); }).catch(e => { logPostHistory(label + ' error', Object.assign({}, context || {}, { error: e && e.message ? e.message : String(e) }), 'warn'); throw e; }); } function fetchLastPostHistoryPost(context) { const url = `${POST_HISTORY_API_BASE}/getLastPost`; return fetchPostHistorySameOriginText(url, context, 'getLastPost').then(resp => parseLastPostResponse(resp, context)); } function getPostHistoryApiCookieHeaders() { const userhash = getCurrentBrowserUserhash(); return userhash ? { Cookie: `userhash=${userhash}` } : null; } function buildPostHistoryImageFile(img, ext) { const base = String(img || '').trim(); const extValue = String(ext || '').trim(); if (!base) return ''; const suffix = extValue ? (extValue[0] === '.' ? extValue : `.${extValue}`) : ''; if (!suffix) return base; return base.toLowerCase().endsWith(suffix.toLowerCase()) ? base : base + suffix; } function parsePostHistoryRefResponse(resp, context) { const text = resp && (resp.responseText || resp.response) || ''; try { const json = typeof text === 'string' ? JSON.parse(text) : text; const data = json && (json.data || json.post || json); const refPost = data && !Array.isArray(data) ? data : null; if (refPost && refPost.success === false) throw new Error(refPost.error || 'ref api error'); if (!refPost) { logPostHistory('ref empty', Object.assign({}, context || {}, { responseLength: String(text || '').length })); return null; } logPostHistory('ref parse', Object.assign({}, context || {}, { id: refPost.id || '', imageFile: buildPostHistoryImageFile(refPost.img, refPost.ext) })); return refPost; } catch (e) { logPostHistory('ref parse failed', Object.assign({}, context || {}, { error: e && e.message ? e.message : String(e), responseLength: String(text || '').length, preview: String(text || '').slice(0, 80) }), 'warn'); return null; } } function postHistoryRefPostHasImage(refPost) { return !!buildPostHistoryImageFile(refPost && refPost.img, refPost && refPost.ext); } function parsePostHistoryRefHtmlResponse(resp, context) { const html = resp && (resp.responseText || resp.response) || ''; try { const doc = new DOMParser().parseFromString(String(html || ''), 'text/html'); const root = doc.querySelector('.h-threads-img-box') || doc.querySelector('.h-threads-item-main') || doc.body; let imageFile = extractThreadHistoryImageFile(root); if (!imageFile) { const imageNode = doc.querySelector('.h-threads-img-a[href], img.h-threads-image, img.h-threads-img, a[href*="/image/"], a[href*="/thumb/"]'); const imageAnchor = imageNode && imageNode.closest ? imageNode.closest('a[href]') : imageNode; imageFile = normalizeThreadHistoryImageFile( (imageAnchor && imageAnchor.getAttribute && imageAnchor.getAttribute('href')) || (imageNode && imageNode.getAttribute && (imageNode.getAttribute('data-src') || imageNode.getAttribute('src'))) ); } if (!imageFile) { logPostHistory('ref html empty', Object.assign({}, context || {}, { responseLength: String(html || '').length })); return null; } logPostHistory('ref html parse', Object.assign({}, context || {}, { imageFile })); return { img: imageFile, ext: '', imageFile }; } catch (e) { logPostHistory('ref html parse failed', Object.assign({}, context || {}, { error: e && e.message ? e.message : String(e), responseLength: String(html || '').length, preview: String(html || '').slice(0, 80) }), 'warn'); return null; } } function fetchPostHistoryRefPost(id, context) { const postId = String(id || '').trim(); if (!postId) return Promise.resolve(null); const detail = Object.assign({}, context || {}, { id: postId }); return fetchPostHistoryRefApiPost(postId, detail) .then(refPost => { if (postHistoryRefPostHasImage(refPost)) return refPost; return fetchPostHistorySameOriginRefPost(postId, detail); }) .catch(() => fetchPostHistorySameOriginRefPost(postId, detail)); } function fetchPostHistoryRefApiPost(id, context) { const postId = String(id || '').trim(); if (!postId) return Promise.resolve(null); const url = `${POST_HISTORY_REF_API_FALLBACK_BASE}/ref?id=${encodeURIComponent(postId)}`; const headers = getPostHistoryApiCookieHeaders(); const detail = Object.assign({}, context || {}, { id: postId, api: true, authenticated: !!headers }); logPostHistory('ref api request', Object.assign({}, detail, { url })); return gmRequest(url, 'text', headers).then(resp => { logPostHistory('ref api response', Object.assign({}, detail, { status: resp.status, responseLength: String(resp.responseText || resp.response || '').length })); const refPost = parsePostHistoryRefResponse(resp, detail); return postHistoryRefPostHasImage(refPost) ? refPost : null; }).catch(e => { logPostHistory('ref api error', Object.assign({}, detail, { error: e && e.message ? e.message : String(e) }), 'warn'); throw e; }); } function fetchPostHistorySameOriginRefPost(id, context) { const postId = String(id || '').trim(); if (!postId) return Promise.resolve(null); const url = `${POST_HISTORY_API_BASE}/ref?id=${encodeURIComponent(postId)}`; const detail = Object.assign({}, context || {}, { id: postId, sameOriginFallback: true }); return fetchPostHistorySameOriginText(url, detail, 'ref same-origin fallback') .then(resp => { const refPost = parsePostHistoryRefResponse(resp, detail); if (postHistoryRefPostHasImage(refPost)) return refPost; return fetchPostHistoryRefHtmlFallbackPost(postId, detail); }) .catch(() => fetchPostHistoryRefHtmlFallbackPost(postId, detail)); } function fetchPostHistoryRefHtmlFallbackPost(id, context) { const postId = String(id || '').trim(); if (!postId) return Promise.resolve(null); const url = `/Home/Forum/ref?id=${encodeURIComponent(postId)}`; const detail = Object.assign({}, context || {}, { id: postId, htmlFallback: true }); return fetchPostHistorySameOriginText(url, detail, 'ref html fallback') .then(resp => parsePostHistoryRefHtmlResponse(resp, detail)); } function enrichPostHistoryRefImage(localId, postId) { return fetchPostHistoryRefPost(postId, { localId }).then(refPost => { const imageFile = refPost ? buildPostHistoryImageFile(refPost.img, refPost.ext) : ''; if (!imageFile) return false; updatePostHistoryRecord(localId, { imageFile, imageImg: refPost.img || '', imageExt: refPost.ext || '' }); return true; }).catch(e => { logPostHistory('ref image error', { localId, id: postId, error: e && e.message ? e.message : String(e) }, 'warn'); return false; }); } function parsePostHistoryThreadResponse(resp, context) { const text = resp && (resp.responseText || resp.response) || ''; try { const thread = typeof text === 'string' ? JSON.parse(text) : text; if (thread && thread.success === false) throw new Error(thread.error || 'thread api error'); const replies = Array.isArray(thread && thread.Replies) ? thread.Replies : []; const replyCount = Number(thread && (thread.ReplyCount || thread.replyCount || thread.reply_count)) || replies.length; logPostHistory('thread fallback parse', Object.assign({}, context || {}, { replyCount, replies: replies.length })); return { thread, replies, replyCount, page: Math.max(1, Number(context && context.page) || 1) }; } catch (e) { logPostHistory('thread fallback parse failed', Object.assign({}, context || {}, { error: e && e.message ? e.message : String(e), responseLength: String(text || '').length, preview: String(text || '').slice(0, 80) }), 'warn'); throw e; } } function fetchPostHistoryThreadPage(threadId, page, context) { const detail = Object.assign({}, context || {}, { threadId, page }); return fetchPostHistoryThreadApiPage(threadId, page, detail) .catch(() => fetchPostHistorySameOriginThreadPage(threadId, page, detail)); } function fetchPostHistoryThreadApiPage(threadId, page, context) { const url = `${POST_HISTORY_THREAD_API_BASE}/thread?id=${encodeURIComponent(threadId)}&page=${encodeURIComponent(page)}`; const headers = getPostHistoryApiCookieHeaders(); const detail = Object.assign({}, context || {}, { threadId, page, api: true, authenticated: !!headers }); logPostHistory('thread api request', Object.assign({}, detail, { url })); return gmRequest(url, 'text', headers).then(resp => { logPostHistory('thread api response', Object.assign({}, detail, { status: resp.status, responseLength: String(resp.responseText || resp.response || '').length })); return parsePostHistoryThreadResponse(resp, detail); }).catch(e => { logPostHistory('thread api error', Object.assign({}, detail, { error: e && e.message ? e.message : String(e) }), 'warn'); throw e; }); } function fetchPostHistorySameOriginThreadPage(threadId, page, context) { const url = `${POST_HISTORY_API_BASE}/thread?id=${encodeURIComponent(threadId)}&page=${encodeURIComponent(page)}`; const detail = Object.assign({}, context || {}, { threadId, page }); return fetchPostHistorySameOriginText(url, detail, 'thread same-origin fallback').then(resp => parsePostHistoryThreadResponse(resp, detail)); } function getPostHistoryThreadFallbackPages(replyCount) { const total = Number(replyCount) || 0; const tailPage = Math.max(1, Math.ceil(total / POST_HISTORY_REPLIES_PER_PAGE)); const pages = [tailPage, tailPage - 1] .filter(page => page >= 1) .map(page => Math.max(1, Number(page) || 1)); return Array.from(new Set(pages)).sort((a, b) => b - a); } function buildPostHistoryThreadCandidate(reply, thread, page) { const threadId = String(thread && thread.id || '').trim(); const fid = getPostHistoryPostFid(reply) || getPostHistoryPostFid(thread); return Object.assign({}, reply || {}, { fid, resto: threadId, page: Math.max(1, Number(page) || 1) }); } function findPostHistoryThreadFallbackMatch(pageData, snapshot, usedIds) { const replies = Array.isArray(pageData && pageData.replies) ? pageData.replies : []; for (let i = replies.length - 1; i >= 0; i--) { const candidate = buildPostHistoryThreadCandidate(replies[i], pageData && pageData.thread || { id: snapshot && snapshot.resto, fid: snapshot && snapshot.fid }, pageData && pageData.page); if (postHistoryMatchesSnapshot(candidate, snapshot, usedIds)) return candidate; } return null; } const POST_HISTORY_THREAD_PAGE_RETRY_DELAYS = [5000, 15000, 30000, 60000, 120000]; async function completePostHistoryFromThreadFallback(localId, snapshot, retryAttempt) { if (!snapshot || snapshot.type !== 'reply' || !String(snapshot.resto || '').trim()) { logPostHistory('thread fallback exhausted', { localId, reason: 'unsupported-snapshot', snapshot: summarizePostHistorySnapshot(snapshot) }, 'warn'); return false; } const threadId = String(snapshot.resto || '').trim(); try { const firstPage = await fetchPostHistoryThreadPage(threadId, 1, { localId, phase: 'count', retryAttempt: retryAttempt || 0 }); const pages = getPostHistoryThreadFallbackPages(firstPage.replyCount); const usedIds = getConfirmedPostHistoryIds(getPostHistoryStore()); for (const page of pages) { const pageData = page === 1 ? firstPage : await fetchPostHistoryThreadPage(threadId, page, { localId, phase: 'scan' }); const post = findPostHistoryThreadFallbackMatch(pageData, snapshot, usedIds); if (post) { logPostHistory('thread fallback confirmed', { localId, page, retryAttempt: retryAttempt || 0, candidate: summarizePostHistoryCandidate(post) }); confirmPostHistorySnapshot(localId, post); return true; } } logPostHistory('thread fallback exhausted', { localId, pages, snapshot: summarizePostHistorySnapshot(snapshot) }, 'warn'); return false; } catch (e) { const attempt = retryAttempt || 0; if (attempt < POST_HISTORY_THREAD_PAGE_RETRY_DELAYS.length) { const delay = POST_HISTORY_THREAD_PAGE_RETRY_DELAYS[attempt]; logPostHistory('thread page verify retry scheduled', { localId, threadId, attempt, nextAttempt: attempt + 1, delay, error: e && e.message ? e.message : String(e) }); setTimeout(() => { completePostHistoryFromThreadFallback(localId, snapshot, attempt + 1); }, delay); return false; } logPostHistory('thread page verify error', { localId, threadId, attempts: attempt + 1, error: e && e.message ? e.message : String(e) }, 'warn'); return false; } } function postHistoryMatchesSnapshot(post, snapshot, usedIds) { const reject = (reason, extra) => { logPostHistory('match rejected', Object.assign({ reason, snapshot: summarizePostHistorySnapshot(snapshot), candidate: summarizePostHistoryCandidate(post) }, extra || {})); return false; }; if (!post || !snapshot) return reject('missing-post-or-snapshot'); const id = String(post.id || '').trim(); if (!id) return reject('missing-id'); const expectedId = String(snapshot.id || snapshot.postId || '').trim(); if (expectedId && id !== expectedId) return reject('id-mismatch', { expectedId, actualId: id }); if (usedIds.has(id) && id !== expectedId) return reject('duplicate-confirmed-id', { id }); const resto = String(post.resto || '0').trim(); const type = Number(resto) === 0 ? 'thread' : 'reply'; if (type !== snapshot.type) return reject('type-mismatch', { expectedType: snapshot.type, actualType: type }); if (type === 'reply' && String(snapshot.resto || '').trim() && String(snapshot.resto || '').trim() !== resto) return reject('reply-resto-mismatch', { expectedResto: String(snapshot.resto || '').trim(), actualResto: resto }); const postText = normalizePostHistoryText(post.content || ''); if (postText && snapshot.contentHash && hashPostHistoryText(postText) !== snapshot.contentHash && postText !== snapshot.contentText) { logPostHistory('server content differs', { snapshot: summarizePostHistorySnapshot(snapshot), candidate: summarizePostHistoryCandidate(post), expectedHash: snapshot.contentHash, actualHash: hashPostHistoryText(postText) }); } const postTs = Date.parse(post.now || ''); if (!postTs) return reject('missing-time'); const timeDiff = Math.abs(postTs - Number(snapshot.submittedAt || Date.now())); if (timeDiff > POST_HISTORY_MATCH_TIME_WINDOW_MS) return reject('time-window-mismatch', { postTs, submittedAt: Number(snapshot.submittedAt || Date.now()), timeDiff }); logPostHistory('match accepted', { snapshot: summarizePostHistorySnapshot(snapshot), candidate: summarizePostHistoryCandidate(post), timeDiff }); return true; } function confirmPostHistorySnapshot(localId, post) { const resto = String(post.resto || '0').trim(); const type = Number(resto) === 0 ? 'thread' : 'reply'; const id = String(post.id || '').trim(); const url = buildPostHistoryUrl(type, id, resto); const existing = getPostHistoryStore().items[localId] || {}; const existingPage = type === 'thread' ? (Number(existing.page) || 0) : 0; const imageFile = buildPostHistoryImageFile(post.img, post.ext); const serverContentRaw = post.content || ''; const serverContentText = normalizePostHistoryText(serverContentRaw); const fid = getPostHistoryPostFid(post) || normalizePostHistoryFid(existing.fid); const update = { status: 'confirmed', type, id, resto, threadId: type === 'reply' ? resto : id, postId: id, page: Math.max(0, Number(post.page) || existingPage || (type === 'thread' ? 1 : 0)), fid, forumName: getPostHistoryForumNameByFid(fid), title: post.title || '', email: post.email || '', contentRaw: serverContentRaw, contentText: serverContentText, contentHash: hashPostHistoryText(serverContentText), contentHtml: sanitizePostHistoryServerContentHtml(serverContentRaw), userHash: post.user_hash || post.userHash || '', confirmedAt: Date.now(), url }; if (imageFile) Object.assign(update, { imageFile, imageImg: post.img || '', imageExt: post.ext || '' }); logPostHistory('confirmed', { localId, type, id, resto, url }); updatePostHistoryRecord(localId, update); const resolver = postHistoryConfirmationMap.get(localId); if (!imageFile) { // 没有图片,异步获取后再通知等待者 enrichPostHistoryRefImage(localId, id).then(() => { if (resolver) { resolver(Object.assign({ localId }, update)); postHistoryConfirmationMap.delete(localId); } }).catch(() => { if (resolver) { resolver(Object.assign({ localId }, update)); postHistoryConfirmationMap.delete(localId); } }); } else { // 已有图片,直接通知等待者 if (resolver) { resolver(Object.assign({ localId }, update)); postHistoryConfirmationMap.delete(localId); } } } function completePostHistorySnapshot(localId, snapshot, attempt = 0) { const delay = POST_HISTORY_GET_LAST_POST_RETRY_DELAYS[attempt]; if (delay == null) { logPostHistory('completion exhausted', { localId, attempt, snapshot: summarizePostHistorySnapshot(snapshot) }, 'warn'); // 清理等待者(确认失败或超时) const resolver = postHistoryConfirmationMap.get(localId); if (resolver) { resolver(null); postHistoryConfirmationMap.delete(localId); } completePostHistoryFromThreadFallback(localId, snapshot).then(confirmed => { if (confirmed) return; logPostHistory('unconfirmed', { localId, attempt, snapshot: summarizePostHistorySnapshot(snapshot) }, 'warn'); updatePostHistoryRecord(localId, { status: 'unconfirmed' }); }).catch(e => { logPostHistory('thread fallback error', { localId, error: e && e.message ? e.message : String(e) }, 'warn'); logPostHistory('unconfirmed', { localId, attempt, snapshot: summarizePostHistorySnapshot(snapshot) }, 'warn'); updatePostHistoryRecord(localId, { status: 'unconfirmed' }); }); return; } logPostHistory('completion scheduled', { localId, attempt, delay, snapshot: summarizePostHistorySnapshot(snapshot) }); setTimeout(() => { fetchLastPostHistoryPost({ localId, attempt }).then(post => { const store = getPostHistoryStore(); if (!postHistoryMatchesSnapshot(post, snapshot, getConfirmedPostHistoryIds(store))) { logPostHistory('completion retry', { localId, attempt, nextAttempt: attempt + 1 }); completePostHistorySnapshot(localId, snapshot, attempt + 1); return; } const id = String(post.id || '').trim(); const confirmedResto = String(post.resto || snapshot.resto || '').trim(); const confirmedSnapshot = Object.assign({}, snapshot, { id, postId: id, resto: confirmedResto, threadId: confirmedResto }); confirmPostHistorySnapshot(localId, post); if (confirmedSnapshot.type === 'reply') { completePostHistoryFromThreadFallback(localId, confirmedSnapshot); } }).catch(e => { logPostHistory('completion retry', { localId, attempt, nextAttempt: attempt + 1, error: e && e.message ? e.message : String(e) }, 'warn'); completePostHistorySnapshot(localId, snapshot, attempt + 1); }); }, delay); } function snapshotSubmittedPostHistory(fd, options) { const type = options && options.isReply ? 'reply' : 'thread'; const submittedAt = Date.now(); const contentRaw = fd && fd.get ? String(fd.get('content') || '') : ''; const contentText = normalizePostHistoryText(contentRaw); const resto = fd && fd.get ? String(fd.get('resto') || '').trim() : ''; const fallbackFid = getCurrentPostHistoryFid(); const localId = `local-${submittedAt}-${Math.random().toString(36).slice(2, 8)}`; const parsedSource = parseThreadHistoryUrl(location.href); const snapshot = { status: 'pending', type, localId, id: '', resto, threadId: type === 'reply' ? resto : '', postId: '', page: type === 'thread' ? (parsedSource ? parsedSource.page : 1) : 0, fid: fallbackFid, forumName: getPostHistoryForumNameByFid(fallbackFid), title: fd && fd.get ? String(fd.get('title') || '') : '', name: fd && fd.get ? String(fd.get('name') || '') : '', email: fd && fd.get ? String(fd.get('email') || '') : '', contentRaw, contentText, contentHash: hashPostHistoryText(contentText), userHash: '', submittedAt, confirmedAt: 0, sourceUrl: location.href, url: '' }; // 为发串创建一个可等待的 Promise,用于确认后跳转 let confirmResolver; const confirmPromise = new Promise(res => { confirmResolver = res; }); postHistoryConfirmationMap.set(localId, confirmResolver); logPostHistory('snapshot', { snapshot: summarizePostHistorySnapshot(snapshot), content: summarizePostHistoryText(contentText) }); upsertPostHistoryRecord(snapshot); completePostHistorySnapshot(localId, snapshot, 0); return { snapshot, localId, confirmPromise }; } 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(); const tipsEl = mainEl.querySelector('.h-threads-tips'); const sageHtml = (tipsEl && /SAGE/i.test(tipsEl.textContent || '')) ? ' 本串已经被SAGE' : ''; 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 }, sageHtml, 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, isSage: !!(item && item.sageHtml), 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, isSage: 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 if (token === 'has:sage') filters.isSage = 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; if (filters.isSage && !entry.isSage) 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 formatRelativeTimeMachineTime(ts) { if (!ts) return ''; const d = new Date(ts); if (Number.isNaN(d.getTime())) return ''; const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); const weekday = '日一二三四五六'[d.getDay()]; const hh = String(d.getHours()).padStart(2, '0'); const mm = String(d.getMinutes()).padStart(2, '0'); const ss = String(d.getSeconds()).padStart(2, '0'); return `${y}-${m}-${day}(${weekday})${hh}:${mm}:${ss}`; } 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 buildHistorySearchHelpMark(title) { const mark = document.createElement('span'); mark.className = 'xdex-history-search-help'; mark.textContent = '?'; mark.title = title || ''; mark.style.textDecoration = 'underline'; mark.style.cursor = 'help'; mark.style.whiteSpace = 'nowrap'; mark.setAttribute('aria-label', title || '高级检索说明'); return mark; } 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 historyReplyUrl = buildCanonicalReplyUrl(item.threadId, item.threadId); const historyReplyActionUrl = buildThreadHistoryItemUrl(item); const replyLink = document.createElement('a'); replyLink.className = 'h-threads-info-id xdex-history-thread-id'; replyLink.href = historyReplyUrl; 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 = historyReplyActionUrl; 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.dataset.historyQuoteId = item.threadId || ''; 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); } if (item.sageHtml) { const sageDiv = document.createElement('div'); sageDiv.className = 'h-threads-tips uk-text-danger uk-text-bold'; sageDiv.innerHTML = item.sageHtml; main.appendChild(sageDiv); } 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); enhanceHistoryRenderedContent(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); enhanceHistoryRenderedContent(footer); markAllCookies(getFilterConfig().markedGroups || [], wrapper); return wrapper; } const HISTORY_RENDER_INITIAL_COUNT = 50; const HISTORY_RENDER_BATCH_SIZE = 20; const HISTORY_RENDER_BATCH_THRESHOLD = 400; const historyRenderQueues = new Map(); function findHistoryScrollContainer(element) { let el = element; while (el && el !== document.body && el !== document.documentElement) { const style = window.getComputedStyle(el); if (style.overflowY === 'auto' || style.overflowY === 'scroll' || style.overflow === 'auto' || style.overflow === 'scroll') { return el; } el = el.parentElement; } return element; } function batchRenderHistoryItems(root, results, buildFn, queueId) { const prev = historyRenderQueues.get(queueId); if (prev) { prev.cancelled = true; if (prev.scrollHandler && prev.scrollContainer) { prev.scrollContainer.removeEventListener('scroll', prev.scrollHandler, { passive: true }); } } if (!root) return; const total = results.length; if (total <= 0) return; let cursor = 0; const state = { cancelled: false }; const scrollContainer = findHistoryScrollContainer(root); historyRenderQueues.set(queueId, state); function appendBatch(count) { if (state.cancelled) return; const slice = results.slice(cursor, cursor + count); const fragment = document.createDocumentFragment(); slice.forEach(r => fragment.appendChild(buildFn(r))); root.appendChild(fragment); cursor += slice.length; } function maybeLoadMore() { if (state.cancelled || cursor >= total) return; const nearBottom = scrollContainer.scrollTop + scrollContainer.clientHeight >= scrollContainer.scrollHeight - HISTORY_RENDER_BATCH_THRESHOLD; if (nearBottom) { appendBatch(HISTORY_RENDER_BATCH_SIZE); requestAnimationFrame(() => { if (!state.cancelled) maybeLoadMore(); }); } } const scrollHandler = () => { if (state.cancelled) return; if (cursor >= total) return; requestAnimationFrame(() => { if (state.cancelled) return; maybeLoadMore(); }); }; state.scrollHandler = scrollHandler; state.scrollContainer = scrollContainer; scrollContainer.addEventListener('scroll', scrollHandler, { passive: true }); appendBatch(HISTORY_RENDER_INITIAL_COUNT); if (cursor < total) { requestAnimationFrame(() => { if (state.cancelled) return; maybeLoadMore(); }); } } 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} 条 `; count.appendChild(buildHistorySearchHelpMark(THREAD_HISTORY_SEARCH_HELP_TEXT)); } root.textContent = ''; if (!results.length) { const empty = document.createElement('div'); empty.className = 'xdex-history-empty'; empty.textContent = effectiveQuery ? '没有匹配的浏览历史' : '暂无浏览历史'; root.appendChild(empty); return; } batchRenderHistoryItems(root, results, buildThreadHistoryItemElement, 'threadHistory'); 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 openHistoryImageQuotePreview(tid) { const quoteId = String(tid || '').trim(); if (!/^\d+$/.test(quoteId) || quoteId === '9999999') return false; try { if (typeof window.__xdexOpenQuoteByTid !== 'function' && typeof enableQuotePreview === 'function') { enableQuotePreview(); } if (typeof window.__xdexOpenQuoteByTid !== 'function') return false; const ret = window.__xdexOpenQuoteByTid(quoteId, { fromPOImage: true }); if (ret && typeof ret.then === 'function') ret.catch(() => {}); return true; } catch (e) { return false; } } function enhanceHistoryRenderedContent(root) { if (!root) return; try { renderHiddenTextContent(root); } catch (e) {} try { if (typeof extendQuote === 'function') extendQuote(root); } catch (e) {} try { if (typeof initExtendedContent === 'function') initExtendedContent(root); } catch (e) {} try { const cfg = Object.assign({}, SettingPanel.defaults, GM_getValue(SettingPanel.key, {})); if (cfg && cfg.enableImageHideMode) applyImageHideMode(cfg.applyImageHideMode || 'default', root); if (cfg && cfg.enableAutoUrlLinkify && typeof runAutoUrlLinkify === 'function') runAutoUrlLinkify(root); } catch (e) {} } 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-image-quote', '.xdex-history-image').on('click.xdex-history-image-quote', '.xdex-history-image', function (e) { if (e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; const opened = openHistoryImageQuotePreview(this.dataset.historyQuoteId || ''); if (!opened) return; e.preventDefault(); e.stopPropagation(); if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation(); }); $('#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(); if (!window.confirm('确定要清空全部浏览历史吗?')) return; clearThreadHistory(); renderThreadHistoryModule(); toast('已清空浏览历史'); }); } function buildPostHistoryItemElement(result) { const item = result.item || {}; const wrapper = document.createElement('div'); wrapper.className = 'xdex-history-item xdex-post-history-item'; wrapper.dataset.postHistoryKey = 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 xdex-post-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.name)) appendThreadHistoryInfoText(infoMain, 'h-threads-info-email', item.name); if (item.email) appendThreadHistoryInfoText(infoMain, 'h-threads-info-email', item.email); const submittedAtText = formatRelativeTimeMachineTime(item.submittedAt); const createdAtNode = appendThreadHistoryInfoText(infoMain, 'h-threads-info-createdat', submittedAtText); if (createdAtNode) { createdAtNode.dataset.xdexOriginalTime = submittedAtText; createdAtNode.title = submittedAtText; } if (item.userHash) appendThreadHistoryInfoText(infoMain, 'h-threads-info-uid', `ID:${item.userHash}`); const displayPostId = item.postId || item.id || (item.type === 'reply' ? '' : item.threadId); const postUrl = buildPostHistoryUrl(item.type, displayPostId, item.resto || item.threadId); const postReplyActionUrl = buildPostHistoryReplyActionUrl(item.type, displayPostId, item.resto || item.threadId, item.page); if (postUrl) { const postLink = document.createElement('a'); postLink.className = 'h-threads-info-id xdex-post-history-thread-id'; postLink.href = postUrl; postLink.textContent = `No.${displayPostId}`; infoMain.appendChild(postLink); const replyAction = document.createElement('span'); replyAction.className = 'h-threads-info-reply-btn xdex-post-history-reply-label'; const replyActionLink = document.createElement('a'); replyActionLink.className = 'xdex-post-history-reply-action'; replyActionLink.href = postReplyActionUrl; replyActionLink.target = '_blank'; replyActionLink.rel = 'noopener'; replyActionLink.textContent = '回应'; replyAction.appendChild(document.createTextNode('[')); replyAction.appendChild(replyActionLink); replyAction.appendChild(document.createTextNode(']')); infoMain.appendChild(replyAction); } else { appendThreadHistoryInfoText(infoMain, 'h-threads-info-id xdex-post-history-thread-id', '未确认'); } const deleteButton = document.createElement('button'); deleteButton.type = 'button'; deleteButton.className = 'xdex-post-history-delete'; deleteButton.dataset.postHistoryKey = result.key; deleteButton.title = '删除'; deleteButton.textContent = '×'; main.appendChild(deleteButton); if (item.imageFile) { const imageLink = document.createElement('a'); imageLink.className = 'h-threads-img-a xdex-post-history-image'; imageLink.href = buildThreadHistoryImageUrl(item.imageFile, true); imageLink.dataset.postHistoryQuoteId = displayPostId || item.threadId || ''; imageLink.target = '_blank'; imageLink.rel = 'noopener'; const image = document.createElement('img'); image.className = 'h-threads-img'; image.src = buildThreadHistoryImageUrl(item.imageFile, false); image.alt = item.imageFile; imageLink.appendChild(image); 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.contentRaw || ''; main.appendChild(content); enhanceHistoryRenderedContent(content); const footer = document.createElement('div'); footer.className = 'xdex-history-footer xdex-post-history-footer'; if (item.status !== 'confirmed') appendThreadHistoryText(footer, 'span', 'xdex-post-history-status', item.status === 'pending' ? '确认中' : item.status === 'failed' ? '失败' : '未确认'); const forumName = item.forumName || getPostHistoryForumNameByFid(item.fid); if (forumName) appendThreadHistoryText(footer, 'span', 'xdex-post-history-forum', `${forumName}`); appendThreadHistoryText(footer, 'span', 'xdex-post-history-type', item.type === 'reply' ? '回复' : '主题'); if (item.threadId) appendThreadHistoryText(footer, 'span', 'xdex-post-history-thread', `串号:${item.threadId}`); // 优先从浏览历史获取最近浏览页(仅 thread 类型),否则回退到记录中的 page // const displayPage = item.page; let displayPage = item.page; if (item.type === 'thread' && item.threadId) { const historyUrl = getLatestThreadHistoryUrl(item.threadId); if (historyUrl) { const parsed = parseThreadHistoryUrl(historyUrl); if (parsed && parsed.page) displayPage = parsed.page; } } if (displayPage) appendThreadHistoryText(footer, 'span', 'xdex-post-history-page', `所在页:P${displayPage}`); main.appendChild(footer); enhanceHistoryRenderedContent(footer); markAllCookies(getFilterConfig().markedGroups || [], wrapper); return wrapper; } function renderPostHistoryModule(query) { const root = document.getElementById('sp_posts_results'); if (!root) return; postHistoryLiveRenderDirty = false; const input = document.getElementById('sp_posts_search'); const effectiveQuery = query == null && input ? input.value : query; const results = searchPostHistory(effectiveQuery || '', postHistoryActiveType); const count = document.getElementById('sp_posts_count'); if (count) { count.textContent = `${results.length} 条 `; count.appendChild(buildHistorySearchHelpMark(POST_HISTORY_SEARCH_HELP_TEXT)); } root.textContent = ''; if (!results.length) { const empty = document.createElement('div'); empty.className = 'xdex-history-empty xdex-post-history-empty'; empty.textContent = effectiveQuery ? '没有匹配的我的发言' : (postHistoryActiveType === 'reply' ? '暂无我的回复' : '暂无我的主题'); root.appendChild(empty); return; } batchRenderHistoryItems(root, results, buildPostHistoryItemElement, 'postHistory'); } function renderPostHistoryModuleSoon(query) { postHistoryLiveRenderDirty = false; if (typeof requestAnimationFrame === 'function') { requestAnimationFrame(() => renderPostHistoryModule(query)); return; } setTimeout(() => renderPostHistoryModule(query), 0); } // ─── 订阅 Feed 渲染 ────────────────────────────────────────────────────── const SUBSCRIPTION_FEED_API_BASE = 'https://api.nmb.best/api'; let subscriptionFeedCurrentPage = 1; let subscriptionFeedCurrentUuid = ''; let subscriptionFeedLoading = false; let subscriptionFeedAllItems = []; let subscriptionFeedHasMore = true; const ACTIVE_FEED_STORAGE_KEY = 'xdex_active_subscription_feed_uuid'; function getActiveSubscriptionFeedUuid() { try { return GM_getValue(ACTIVE_FEED_STORAGE_KEY, ''); } catch (e) { return ''; } } function setActiveSubscriptionFeedUuid(uuid) { try { GM_setValue(ACTIVE_FEED_STORAGE_KEY, uuid || ''); } catch (e) {} } function populateSubscriptionFeedSelector() { const $sel = $('#sp_feeds_selector').empty(); const $display = $('#sp_feeds_selector_display'); const $dropdown = $('#sp_feeds_selector_dropdown').empty().hide(); const $desc = $display.find('.xdex-feed-display-desc'); const $uuid = $display.find('.xdex-feed-display-uuid'); const feeds = (typeof getFilterConfig === 'function' ? getFilterConfig() : {}).subscriptionFeeds || []; if (!feeds.length) { $sel.append(''); $desc.text('请先在设置中添加订阅号'); $uuid.text(''); return ''; } feeds.forEach((f, i) => { const label = f.desc ? `${f.desc}:${f.uuid}` : f.uuid; $sel.append(``); const $opt = $('
') .attr('data-uuid', f.uuid) .text(label); $dropdown.append($opt); }); const saved = getActiveSubscriptionFeedUuid(); const match = feeds.find(f => f.uuid === saved); const selected = match ? match.uuid : feeds[0].uuid; $sel.val(selected); const activeFeed = feeds.find(f => f.uuid === selected); $desc.text(activeFeed && activeFeed.desc ? activeFeed.desc : ''); $uuid.text(activeFeed && activeFeed.desc ? activeFeed.uuid : ''); $dropdown.find('.xdex-feed-option').removeClass('active').filter(`[data-uuid="${selected}"]`).addClass('active'); return selected; } function buildSubscriptionFeedItemElement(item) { const threadId = Number(item.id) || 0; const wrapper = document.createElement('div'); wrapper.className = 'xdex-history-item'; wrapper.dataset.feedThreadId = String(threadId); 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); const title = String(item.title || ''); const email = String(item.email || ''); const now = String(item.now || ''); const userHash = String(item.user_hash || ''); if (title && title !== '无标题') appendThreadHistoryInfoText(infoMain, 'h-threads-info-title', title); if (email) appendThreadHistoryInfoText(infoMain, 'h-threads-info-email', email); const createdAtNode = appendThreadHistoryInfoText(infoMain, 'h-threads-info-createdat', now); if (createdAtNode) { createdAtNode.dataset.xdexOriginalTime = now; createdAtNode.title = now; } if (userHash) { const cookieSpan = appendThreadHistoryText(infoMain, 'span', 'h-threads-info-uid', `ID:${userHash}`); if (cookieSpan) cookieSpan.setAttribute('data-xdex-cookie-id', userHash); } const replyLink = document.createElement('a'); replyLink.className = 'h-threads-info-id xdex-history-thread-id'; replyLink.href = `${location.origin}/t/${threadId}`; replyLink.textContent = `No.${threadId}`; infoMain.appendChild(replyLink); const replyCount = Number(item.reply_count) || 0; if (replyCount > 0) { appendThreadHistoryText(infoMain, 'span', 'xdex-history-visit-count', `${replyCount} 回`); } // 从浏览历史查找最近查看页 const tid = String(threadId || '').trim(); const histStore = getThreadHistoryStore(); const histCandidates = ['normal', 'po'] .map((mode) => histStore.items[getThreadHistoryKey(mode, tid)]) .filter(Boolean) .sort((a, b) => (Number(b.lastVisitedAt) || 0) - (Number(a.lastVisitedAt) || 0)); const histItem = histCandidates[0] || null; const histPage = histItem ? (Number(histItem.page) || 1) : 1; const histMaxPage = histItem ? (Number(histItem.maxVisitedPage) || histPage) : 1; // [回应] 链接 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 = `${location.origin}/t/${tid}?page=${histPage}`; 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-post-history-delete'; deleteButton.dataset.feedThreadId = String(threadId); deleteButton.title = '取消订阅'; deleteButton.textContent = '×'; main.appendChild(deleteButton); // 图片 const imgRaw = String(item.img || ''); const extRaw = String(item.ext || ''); if (imgRaw) { const suffix = extRaw ? (extRaw[0] === '.' ? extRaw : `.${extRaw}`) : ''; const imageFile = suffix && imgRaw.toLowerCase().endsWith(suffix.toLowerCase()) ? imgRaw : imgRaw + suffix; const imageLink = document.createElement('a'); imageLink.className = 'h-threads-img-a xdex-history-image'; imageLink.dataset.historyQuoteId = threadId; imageLink.href = buildThreadHistoryImageUrl(imageFile, true); imageLink.target = '_blank'; imageLink.rel = 'noopener'; const img = document.createElement('img'); img.className = 'h-threads-img'; img.src = buildThreadHistoryImageUrl(imageFile, false); img.alt = imageFile; imageLink.appendChild(img); main.appendChild(imageLink); } // 正文 const content = document.createElement('div'); content.className = 'h-threads-content'; const contentHtml = String(item.content || ''); if (contentHtml) content.innerHTML = contentHtml; else content.textContent = ''; main.appendChild(content); enhanceHistoryRenderedContent(content); // 脚注 const footer = document.createElement('div'); footer.className = 'xdex-history-footer'; const fid = String(item.fid || ''); const forumName = fid ? (POST_HISTORY_FORUM_FID_MAP[fid] || '') : ''; if (forumName) appendThreadHistoryText(footer, 'span', 'xdex-post-history-forum', forumName); appendThreadHistoryText(footer, 'span', 'xdex-post-history-type', '订阅'); appendThreadHistoryText(footer, 'span', 'xdex-history-time', now); main.appendChild(footer); enhanceHistoryRenderedContent(footer); markAllCookies(getFilterConfig().markedGroups || [], wrapper); return wrapper; } async function fetchSubscriptionFeedPage(uuid, page) { const url = `${SUBSCRIPTION_FEED_API_BASE}/feed?uuid=${encodeURIComponent(uuid)}&page=${encodeURIComponent(page)}`; const resp = await gmRequest(url, 'json'); const data = resp.response || resp.responseText; if (Array.isArray(data)) return data; try { return JSON.parse(typeof data === 'string' ? data : '[]'); } catch (e) { return []; } } function renderSubscriptionFeedModule() { const $results = $('#sp_feeds_results').empty(); const uuid = populateSubscriptionFeedSelector(); if (!uuid) { $results.html('
请先在设置中添加订阅号
'); subscriptionFeedCurrentUuid = ''; subscriptionFeedHasMore = false; return; } subscriptionFeedCurrentUuid = uuid; subscriptionFeedCurrentPage = 1; subscriptionFeedAllItems = []; subscriptionFeedHasMore = true; $results.html('
正在获取订阅……
'); loadSubscriptionFeedPage(uuid, 1, true); } async function loadSubscriptionFeedPage(uuid, page, replace) { subscriptionFeedLoading = true; const $results = $('#sp_feeds_results'); if (replace) $results.empty(); try { const items = await fetchSubscriptionFeedPage(uuid, page); if (uuid !== subscriptionFeedCurrentUuid) return; if (!items.length) { subscriptionFeedHasMore = false; if (replace) { $results.html('
暂无订阅内容
'); } return; } // 页码分隔线:只在下一页实际有内容时显示,避免未满页触底后留下空的“第N页”提示 subscriptionFeedAllItems = subscriptionFeedAllItems.concat(items); subscriptionFeedCurrentPage = page; $('#sp_feeds_page_label').text(`第${page}页`); items.forEach(item => { $results[0].appendChild(buildSubscriptionFeedItemElement(item)); }); } catch (err) { console.error('[subscription-feed] load error', err); if (replace) { $results.html(`
加载失败:${Utils.escapeHTML ? Utils.escapeHTML(err.message) : err.message}
`); } } finally { subscriptionFeedLoading = false; } } function bindSubscriptionFeedModuleEvents() { // 订阅号切换 // 自定义下拉菜单交互 const $wrap = $('.xdex-feed-selector-wrap'); const $display = $('#sp_feeds_selector_display'); const $dropdown = $('#sp_feeds_selector_dropdown'); $display.off('click.feedDropdown').on('click.feedDropdown', (e) => { e.stopPropagation(); const isOpen = $dropdown.is(':visible'); $dropdown.toggle(!isOpen); $display.attr('aria-expanded', String(!isOpen)); }); $dropdown.off('click.feedOption', '.xdex-feed-option').on('click.feedOption', '.xdex-feed-option', function (e) { e.stopPropagation(); const uuid = $(this).data('uuid') || ''; if (!uuid) return; $('#sp_feeds_selector').val(uuid).trigger('change.subscriptionFeed'); const feeds = (typeof getFilterConfig === 'function' ? getFilterConfig() : {}).subscriptionFeeds || []; const feed = feeds.find(f => f.uuid === uuid); $display.find('.xdex-feed-display-desc').text(feed && feed.desc ? feed.desc : ''); $display.find('.xdex-feed-display-uuid').text(feed && feed.desc ? feed.uuid : ''); $dropdown.find('.xdex-feed-option').removeClass('active').filter('[data-uuid="' + uuid + '"]').addClass('active'); $dropdown.hide(); $display.attr('aria-expanded', 'false'); }); $(document).off('click.feedDropdownClose').on('click.feedDropdownClose', () => { $dropdown.hide(); $display.attr('aria-expanded', 'false'); }); $('#sp_feeds_selector').off('change.subscriptionFeed').on('change.subscriptionFeed', function () { subscriptionFeedCurrentUuid = $(this).val() || ''; if (subscriptionFeedCurrentUuid) { setActiveSubscriptionFeedUuid(subscriptionFeedCurrentUuid); subscriptionFeedCurrentPage = 1; subscriptionFeedAllItems = []; subscriptionFeedHasMore = true; loadSubscriptionFeedPage(subscriptionFeedCurrentUuid, 1, true); } }); // 跨页面同步:其他标签页切换订阅号时自动刷新 if (typeof GM_addValueChangeListener === 'function') { GM_addValueChangeListener(ACTIVE_FEED_STORAGE_KEY, (_key, _oldVal, newVal, remote) => { if (!remote) return; const uuid = String(newVal || ''); if (uuid && uuid !== subscriptionFeedCurrentUuid) { subscriptionFeedCurrentUuid = uuid; subscriptionFeedCurrentPage = 1; subscriptionFeedAllItems = []; subscriptionFeedHasMore = true; $('#sp_feeds_selector').val(uuid); loadSubscriptionFeedPage(uuid, 1, true); } }); } // 跳转 $('#sp_feeds_page_jump').off('click.subscriptionFeed').on('click.subscriptionFeed', (e) => { e.preventDefault(); const page = parseInt($('#sp_feeds_page_input').val(), 10); if (!page || page < 1 || !subscriptionFeedCurrentUuid) return; subscriptionFeedCurrentPage = page; subscriptionFeedAllItems = []; subscriptionFeedHasMore = true; loadSubscriptionFeedPage(subscriptionFeedCurrentUuid, page, true); }); // 滚动加载下一页 const $scrollContainer = $('#sp_module_feeds .sp_panel_content'); $scrollContainer.off('scroll.subscriptionFeed').on('scroll.subscriptionFeed', function () { if (subscriptionFeedLoading || !subscriptionFeedHasMore || !subscriptionFeedCurrentUuid) return; const el = this; if (el.scrollTop + el.clientHeight >= el.scrollHeight - 400) { loadSubscriptionFeedPage(subscriptionFeedCurrentUuid, subscriptionFeedCurrentPage + 1, false); } }); // 上一页 $('#sp_feeds_prev').off('click.subscriptionFeed').on('click.subscriptionFeed', (e) => { e.preventDefault(); if (subscriptionFeedCurrentPage <= 1 || !subscriptionFeedCurrentUuid) return; subscriptionFeedCurrentPage--; subscriptionFeedAllItems = []; subscriptionFeedHasMore = true; loadSubscriptionFeedPage(subscriptionFeedCurrentUuid, subscriptionFeedCurrentPage, true); }); // 下一页 $('#sp_feeds_next').off('click.subscriptionFeed').on('click.subscriptionFeed', (e) => { e.preventDefault(); if (!subscriptionFeedCurrentUuid) return; subscriptionFeedCurrentPage++; subscriptionFeedAllItems = []; subscriptionFeedHasMore = true; loadSubscriptionFeedPage(subscriptionFeedCurrentUuid, subscriptionFeedCurrentPage, true); }); // 订阅面板图片点击 → 打开引用弹窗(图片激活态) $('#sp_feeds_results').off('click.xdex-feed-image-quote', '.xdex-history-image').on('click.xdex-feed-image-quote', '.xdex-history-image', function (e) { if (e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; const opened = openHistoryImageQuotePreview(this.dataset.historyQuoteId || ''); if (!opened) return; e.preventDefault(); e.stopPropagation(); if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation(); }); // 取消订阅 $('#sp_feeds_results').off('click.xdex-feed-delete', '.xdex-post-history-delete').on('click.xdex-feed-delete', '.xdex-post-history-delete', function (e) { e.preventDefault(); const tid = this.dataset.feedThreadId || ''; if (!tid || !subscriptionFeedCurrentUuid) return; if (!window.confirm('确定要取消订阅这个串吗?')) return; GM_xmlhttpRequest({ method: 'POST', url: `${SUBSCRIPTION_FEED_API_BASE}/delFeed`, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, data: `uuid=${encodeURIComponent(subscriptionFeedCurrentUuid)}&tid=${encodeURIComponent(tid)}`, onload: () => { toast('已取消订阅'); renderSubscriptionFeedModule(); }, onerror: () => toast('取消订阅失败') }); }); } function setPostHistoryType(type) { postHistoryActiveType = normalizePostHistoryType(type); $('#sp_posts_type_buttons [data-post-history-type]').removeClass('active') .filter(`[data-post-history-type="${postHistoryActiveType}"]`).addClass('active'); renderPostHistoryModule(); } function bindPostHistoryModuleEvents() { $('#sp_posts_search').off('input.xdex-post-history').on('input.xdex-post-history', function () { renderPostHistoryModule(this.value || ''); }); $('#sp_posts_type_buttons').off('click.xdex-post-history', '[data-post-history-type]').on('click.xdex-post-history', '[data-post-history-type]', function (e) { e.preventDefault(); setPostHistoryType(this.dataset.postHistoryType || 'thread'); }); $('#sp_posts_results').off('click.xdex-post-history-reply', '.xdex-post-history-reply-action').on('click.xdex-post-history-reply', '.xdex-post-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_posts_results').off('click.xdex-post-history-image-quote', '.xdex-post-history-image').on('click.xdex-post-history-image-quote', '.xdex-post-history-image', function (e) { if (e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; const opened = openHistoryImageQuotePreview(this.dataset.postHistoryQuoteId || ''); if (!opened) return; e.preventDefault(); e.stopPropagation(); if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation(); }); $('#sp_posts_results').off('click.xdex-post-history-delete', '.xdex-post-history-delete').on('click.xdex-post-history-delete', '.xdex-post-history-delete', function (e) { e.preventDefault(); const key = this.dataset.postHistoryKey || ''; if (!key) return; if (!window.confirm('确定要删除这条发言记录吗?')) return; deletePostHistoryItem(key); renderPostHistoryModule(); toast('已删除发言记录'); }); $('#sp_posts_clear').off('click.xdex-post-history').on('click.xdex-post-history', function (e) { e.preventDefault(); if (!window.confirm('确定要清空全部我的发言记录吗?')) return; clearPostHistory(); renderPostHistoryModule(); 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', headers = null) { return new Promise((resolve, reject) => { const request = { 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}`)) }; if (headers) request.headers = headers; GM_xmlhttpRequest(request); }); } 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 = $(``); $('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 collectSubscriptionFeedsFromPanel() { const parsed = []; const seen = new Map(); let valid = true; $('#subscription-feed-inputs-container .subscription-feed-row').each((idx, el) => { const $row = $(el); const desc = ($row.find('.subscription-feed-desc-input').val() || '').trim(); const uuid = ($row.find('.subscription-feed-uuid-input').val() || '').trim(); if (!desc && !uuid) return; if (!isValidDesc(desc)) { toast(`第${idx + 1}组备注过长`); valid = false; return false; } if (!uuid) { toast(`第${idx + 1}组未指定订阅号`); valid = false; return false; } if (seen.has(uuid)) { const first = seen.get(uuid); const suffix = first.desc ? `(${first.desc})` : ''; toast(`第${idx + 1}组与第${first.index}组${suffix}订阅号重复`); valid = false; return false; } seen.set(uuid, { index: idx + 1, desc }); parsed.push({ desc, uuid }); }); return valid ? parsed : null; } 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 `
#${index}
`; } function buildSubscriptionFeedRowHtml(index, item = {}) { const desc = item.desc || ''; const uuid = item.uuid || ''; return `
#${index}
`; } function parseThreadCookieWhitelistRule(raw) { const idx = Math.max(raw.lastIndexOf(':'), raw.lastIndexOf(':')); let threadPart = ''; let cookiePart = ''; if (idx > 0) { threadPart = raw.slice(0, idx).trim(); cookiePart = raw.slice(idx + 1).trim(); } else { threadPart = raw.trim(); } return { threads: Utils.strToList(threadPart), cookies: Utils.strToList(cookiePart), }; } function buildThreadCookieWhitelistRowHtml(index, group = {}) { const desc = group.desc || ''; const threadText = Array.isArray(group.threads) ? group.threads.join(',') : ''; const cookieText = Array.isArray(group.cookies) ? group.cookies.join(',') : ''; return ` `; } function buildCookieGroupRowHtml(type, index, value, placeholder) { return `
#${index}
`; } function 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' ? `
` : ''; return `
#${index}
${markedSwatchHtml}
`; } 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, // 侧边栏收起功能 postAfterAction: 'jump', // 发串后:jump=新标签页打开 / refresh=刷新页面回板块第一页 threadCookieWhitelistGroups: [], threadCookieWhitelistDisplayMode: 'fold', // 只看饼干:fold | hide | column poAnnotationSideDisplayMode: 'collapse', // 分栏侧栏:collapse | expand replyModeDefault: '回复', // 板块页默认模式:发串/回复 replyExtraDefault: '临时', // 板块/时间线默认额外模式:临时/连续 markedGroups: [], blockedCookies: [], blockedKeywords: [], favoriteThreads: [], subscriptionFeeds: [], // [{desc, uuid}, ...] 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'; } const postAfterSelect = document.getElementById('sp_postAfterAction'); if (postAfterSelect) { postAfterSelect.value = (this.state && this.state.postAfterAction === 'refresh') ? 'refresh' : 'jump'; } }, 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) => `
${title}
`; const checkboxItemStyle = 'width:50%; display:flex; align-items:center; gap:8px;'; const checkboxRowStyle = 'width:50%; display:flex; align-items:center; gap:8px;'; const quickReplyRowStyle = 'display:flex; align-items:center; gap:12px; margin:4px 0;'; const quickReplyColumnStyle = 'flex:1; display:flex; flex-direction:column; align-items:flex-start; gap:4px;'; const html = ` `; $('#sp_cover').remove(); $('body').append(html); 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' || nextModule === 'posts' || nextModule === 'feeds') ? '300px' : '0' }); if (nextModule === 'history' || nextModule === 'posts' || nextModule === 'feeds') { $activeModule.find('.sp_panel_content').css({ display: 'block', flex: '1 1 auto', minHeight: '300px', overflowY: 'auto', boxSizing: 'border-box' }); $activeModule.find('#sp_history_content,#sp_posts_content,#sp_feeds_content').css({ display: 'block' }); $activeModule.find('#sp_history_results,#sp_posts_results,#sp_feeds_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'); $('#sp_feeds_pager').toggle(nextModule === 'feeds'); 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(); if ($(e.currentTarget).data('spModule') === 'posts') renderPostHistoryModuleSoon(); if ($(e.currentTarget).data('spModule') === 'feeds') renderSubscriptionFeedModule(); }); $('#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(); bindPostHistoryModuleEvents(); bindPostHistoryLiveSync(); bindSubscriptionFeedModuleEvents(); renderThreadHistoryModule(); renderPostHistoryModule(); // 折叠头:统一控制 $('.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 initPostAfterActionSelect() { const sel = document.getElementById('sp_postAfterAction'); if (!sel) return; sel.value = (SettingPanel.state && SettingPanel.state.postAfterAction) || 'jump'; sel.addEventListener('change', (e) => { e.stopPropagation(); const action = (sel.value || 'jump') === 'jump' ? 'jump' : 'refresh'; SettingPanel.state.postAfterAction = action; try { GM_setValue(SettingPanel.key, SettingPanel.state); } catch (err) {} toast(action === 'jump' ? '已切换为:发串后新标签页打开' : '已切换为:发串后刷新页面回顶部'); }); })(); (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(); }); $('#btn_group_subscriptionFeeds').off('click').on('click', e=>{ e.stopPropagation(); const nextIndex = $('#subscription-feed-inputs-container .subscription-feed-row').length + 1; $('#subscription-feed-inputs-container').append(buildSubscriptionFeedRowHtml(nextIndex)); $('#subscription-feed-inputs-container .subscription-feed-row').last().find('.subscription-feed-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(); }); $('#subscription-feed-inputs-container').off('click', '.subscription-feed-delete').on('click', '.subscription-feed-delete', (e) => { e.preventDefault(); e.stopPropagation(); $(e.currentTarget).closest('.subscription-feed-row').remove(); saveSubscriptionFeeds({ fromDelete: true }); return false; }); // 订阅号:保存 const saveSubscriptionFeeds = ({ fromDelete = false } = {}) => { const parsed = collectSubscriptionFeedsFromPanel(); if (!parsed) return false; this.state.subscriptionFeeds = parsed; GM_setValue(this.key, this.state); this.syncInputs(); toast(fromDelete ? '已删除订阅号' : '订阅号已保存'); return true; }; $('#btn_sp_subscriptionFeeds').off('click').on('click', e=>{ e.stopPropagation(); saveSubscriptionFeeds(); }); $('#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: '保存浏览历史,支持搜索,可切换多种排序方式', sp_enablePostHistory: '保存发言历史,分为“我的主题/我的回复”,并记录回复所在页面,支持搜索,可切换多种排序方式', sp_postAfterAction: '发串成功后的行为:新标签页打开新串,或刷新当前板块页回到顶部', sp_subscriptionFeeds: '管理X岛订阅号,可添加多个订阅号并设置备注,用于在"我的订阅"标签中查看和管理订阅内容', }; // 更新日志弹窗(放在 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; } `; $('