// ==UserScript==
// @name X岛-EX
// @namespace https://github.com/SayaGoodBye/nmbxd-EX
// @version 3.1.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.板块页发串可选"发串后跳转"或"发串后刷新刷新",前者可以直接跳转发布的新串详情,后者则回到板块页首页。\n2.对浏览历史/我的发言面板进行分批渲染,避免卡顿。\n3."我的主题"中串号链接使用 ?r=threadId 格式,所在页优先从浏览历史同步最新页面,避免每次都从第一页进入。\n\n修复\n1.修复颜文字插入后无法 Ctrl+Z 撤销的问题。\n2.修复无回复串内回复时先创建空容器再局部刷新,避免回复后无法完成页面的更新。\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 含零宽字符\n可组合:mode:po has:image 关键词';
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;
}
async function completePostHistoryFromThreadFallback(localId, snapshot) {
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();
const firstPage = await fetchPostHistoryThreadPage(threadId, 1, { localId, phase: 'count' });
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, candidate: summarizePostHistoryCandidate(post) });
confirmPostHistorySnapshot(localId, post);
return true;
}
}
logPostHistory('thread fallback exhausted', { localId, pages, snapshot: summarizePostHistorySnapshot(snapshot) }, '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('thread page verify error', { localId, id, error: e && e.message ? e.message : String(e) }, 'warn');
});
}
}).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();
return {
key: getThreadHistoryKey(parsed.mode, parsed.threadId),
mode: parsed.mode,
threadId: parsed.threadId,
page: parsed.page,
url: parsed.url,
title,
author,
cookieId,
cookieHtml,
createdAt,
contentText,
contentHtml,
contentTruncated,
excerpt: contentText.slice(0, THREAD_HISTORY_EXCERPT_LIMIT),
imageFile,
contentFlags: { hasVisibleText, hasWhitespaceOnly, hasZeroWidth },
lastScrollY: Math.max(0, Math.floor(window.scrollY || 0))
};
}
function buildThreadHistoryIndexEntry(item) {
const contentFlags = item && item.contentFlags ? item.contentFlags : {};
const imageFile = String(item && item.imageFile || '');
const titleText = String(item && item.title || '').toLowerCase();
const authorText = String(item && item.author || '').toLowerCase();
const cookieIdText = String(item && item.cookieId || '').toLowerCase();
const excerptText = String(item && (item.contentText || item.excerpt) || '').toLowerCase();
const threadIdText = String(item && item.threadId || '');
return {
searchText: [threadIdText, titleText, authorText, cookieIdText, excerptText].join(' ').toLowerCase(),
threadIdText,
titleText,
authorText,
cookieIdText,
excerptText,
mode: item && item.mode === 'po' ? 'po' : 'normal',
hasImage: !!imageFile,
isGif: /\.gif(?:$|[?#])/i.test(imageFile),
hasZeroWidth: !!contentFlags.hasZeroWidth,
hasVisibleText: !!contentFlags.hasVisibleText,
hasWhitespaceOnly: !!contentFlags.hasWhitespaceOnly,
lastVisitedAt: Number(item && item.lastVisitedAt) || 0
};
}
function upsertThreadHistoryRecord(nextRecord, options = {}) {
if (!nextRecord || !nextRecord.threadId || !nextRecord.mode) return getThreadHistoryStore();
const now = Date.now();
const countVisit = options.countVisit !== false;
const touchVisitedAt = countVisit || options.touchVisitedAt === true;
const store = getThreadHistoryStore();
nextRecord = applyThreadHistoryPageBounds(nextRecord);
const key = nextRecord.key || getThreadHistoryKey(nextRecord.mode, nextRecord.threadId);
const old = store.items[key] || {};
const maxVisitedPage = Math.max(Number(old.maxVisitedPage) || 1, Number(nextRecord.page) || 1);
const boundedMaxVisitedPage = nextRecord.lastKnownPage ? Math.min(maxVisitedPage, Number(nextRecord.lastKnownPage) || maxVisitedPage) : maxVisitedPage;
const mergedBase = Object.assign({}, old, nextRecord);
const merged = Object.assign({}, applyThreadHistoryPageBounds(mergedBase), {
key,
firstVisitedAt: old.firstVisitedAt || now,
lastVisitedAt: touchVisitedAt ? now : (Number(old.lastVisitedAt) || now),
visitCount: (Number(old.visitCount) || 0) + (countVisit ? 1 : 0),
maxVisitedPage: boundedMaxVisitedPage,
cookieHtml: nextRecord.cookieHtml || old.cookieHtml || ''
});
store.items[key] = merged;
store.index[key] = buildThreadHistoryIndexEntry(merged);
store.order = [key].concat((store.order || []).filter(itemKey => itemKey !== key));
const saved = setThreadHistoryStore(store);
logThreadHistory('record saved', { key, total: saved.order.length, countVisit, reason: options.reason || '', record: merged });
return saved;
}
function touchThreadHistoryCurrentLocation(options = {}) {
const parsed = parseThreadHistoryUrl(options.url || location.href);
if (!parsed) return getThreadHistoryStore();
const key = getThreadHistoryKey(parsed.mode, parsed.threadId);
const store = getThreadHistoryStore();
const item = store.items[key];
if (!item) return store;
const now = Date.now();
const bounded = applyThreadHistoryPageBounds(Object.assign({}, item, {
page: Math.max(Number(item.page) || 1, Number(options.page || parsed.page) || 1),
url: options.url || parsed.url
}));
item.page = bounded.page;
item.url = bounded.url;
item.maxVisitedPage = bounded.lastKnownPage
? Math.min(Math.max(Number(item.maxVisitedPage) || 1, Number(item.page) || 1), Number(bounded.lastKnownPage) || Number(item.page) || 1)
: Math.max(Number(item.maxVisitedPage) || 1, Number(item.page) || 1);
if (bounded.lastKnownPage) item.lastKnownPage = bounded.lastKnownPage;
item.lastScrollY = Math.max(0, Math.floor(window.scrollY || 0));
if (options.touchVisitedAt) item.lastVisitedAt = now;
store.index[key] = buildThreadHistoryIndexEntry(item);
store.order = [key].concat((store.order || []).filter(itemKey => itemKey !== key));
const saved = setThreadHistoryStore(store);
logThreadHistory('location touched', { key, reason: options.reason || '', page: item.page, url: item.url, maxVisitedPage: item.maxVisitedPage, touchVisitedAt: !!options.touchVisitedAt });
return saved;
}
function recordThreadHistoryProgress(options = {}) {
const parsed = parseThreadHistoryUrl(options.url || location.href);
if (!parsed) return getThreadHistoryStore();
const record = extractThreadHistoryRecord(document) || {
key: getThreadHistoryKey(parsed.mode, parsed.threadId),
mode: parsed.mode,
threadId: parsed.threadId
};
record.key = getThreadHistoryKey(parsed.mode, parsed.threadId);
record.mode = parsed.mode;
record.threadId = parsed.threadId;
record.page = Math.max(Number(record.page) || 1, Number(options.page || parsed.page) || 1);
record.url = options.url || parsed.url;
record.lastScrollY = Math.max(0, Math.floor(window.scrollY || 0));
return upsertThreadHistoryRecord(record, { countVisit: false, touchVisitedAt: options.touchVisitedAt === true, reason: options.reason || 'progress' });
}
function updateThreadHistoryScrollPosition() {
touchThreadHistoryCurrentLocation({ reason: 'scroll-position' });
}
function deleteThreadHistoryItem(key) {
const store = getThreadHistoryStore();
delete store.items[key];
delete store.index[key];
store.order = (store.order || []).filter(itemKey => itemKey !== key);
return setThreadHistoryStore(store);
}
function clearThreadHistory() {
return setThreadHistoryStore(createDefaultThreadHistoryStore());
}
function parseThreadHistorySearchQuery(query) {
const filters = { mode: '', hasImage: false, isGif: false, hasZeroWidth: false };
const tokens = [];
String(query || '').toLowerCase().split(/\s+/).filter(Boolean).forEach(token => {
if (token === 'mode:po') filters.mode = 'po';
else if (token === 'mode:normal') filters.mode = 'normal';
else if (token === 'has:image') filters.hasImage = true;
else if (token === 'has:gif') filters.isGif = true;
else if (token === 'has:zwsp' || token === 'has:zerowidth') filters.hasZeroWidth = true;
else tokens.push(token);
});
return { filters, tokens };
}
function scoreThreadHistoryIndexEntry(entry, tokens) {
let score = Number(entry.lastVisitedAt) || 0;
tokens.forEach(token => {
if (/^\d{1,8}$/.test(token)) {
if (entry.threadIdText === token) score += 1000000000000000;
else if (entry.threadIdText.includes(token)) score += 500000000000000;
}
});
return score;
}
function getThreadHistorySortValue(item, field) {
if (!item) return 0;
if (field === 'visitCount') return Number(item.visitCount) || 0;
if (field === 'maxVisitedPage') return Number(item.maxVisitedPage || item.page) || 0;
return Number(item.lastVisitedAt) || 0;
}
function compareThreadHistoryResults(a, b, sortMode, tokens) {
const itemA = a.item || {};
const itemB = b.item || {};
if (sortMode === 'last-asc') return getThreadHistorySortValue(itemA, 'lastVisitedAt') - getThreadHistorySortValue(itemB, 'lastVisitedAt');
if (sortMode === 'visits-desc') return getThreadHistorySortValue(itemB, 'visitCount') - getThreadHistorySortValue(itemA, 'visitCount') || getThreadHistorySortValue(itemB, 'lastVisitedAt') - getThreadHistorySortValue(itemA, 'lastVisitedAt');
if (sortMode === 'visits-asc') return getThreadHistorySortValue(itemA, 'visitCount') - getThreadHistorySortValue(itemB, 'visitCount') || getThreadHistorySortValue(itemB, 'lastVisitedAt') - getThreadHistorySortValue(itemA, 'lastVisitedAt');
if (sortMode === 'page-desc') return getThreadHistorySortValue(itemB, 'maxVisitedPage') - getThreadHistorySortValue(itemA, 'maxVisitedPage') || getThreadHistorySortValue(itemB, 'lastVisitedAt') - getThreadHistorySortValue(itemA, 'lastVisitedAt');
return scoreThreadHistoryIndexEntry(b.index, tokens) - scoreThreadHistoryIndexEntry(a.index, tokens);
}
function searchThreadHistory(query, storeInput, sortMode) {
const store = normalizeThreadHistoryStore(storeInput || getThreadHistoryStore());
const { filters, tokens } = parseThreadHistorySearchQuery(query);
return (store.order || [])
.filter(key => {
const entry = store.index[key];
if (!entry || !store.items[key]) return false;
if (filters.mode && entry.mode !== filters.mode) return false;
if (filters.hasImage && !entry.hasImage) return false;
if (filters.isGif && !entry.isGif) return false;
if (filters.hasZeroWidth && !entry.hasZeroWidth) return false;
return tokens.every(token => entry.searchText.includes(token));
})
.map(key => ({ key, item: store.items[key], index: store.index[key] }))
.sort((a, b) => compareThreadHistoryResults(a, b, sortMode || 'last-desc', tokens));
}
let threadHistoryScrollTrackingInstalled = false;
function installThreadHistoryScrollTracking() {
if (threadHistoryScrollTrackingInstalled) return;
threadHistoryScrollTrackingInstalled = true;
let scrollTimer = 0;
window.addEventListener('scroll', () => {
if (scrollTimer) return;
scrollTimer = setTimeout(() => {
scrollTimer = 0;
updateThreadHistoryScrollPosition();
}, 1200);
}, { passive: true });
window.addEventListener('pagehide', updateThreadHistoryScrollPosition, { passive: true });
}
function isThreadHistoryPageActive() {
return document.visibilityState === 'visible' && (typeof document.hasFocus !== 'function' || document.hasFocus());
}
function cancelThreadHistoryDwellTimer(resetSession) {
if (threadHistoryDwellTimer) clearTimeout(threadHistoryDwellTimer);
threadHistoryDwellTimer = 0;
threadHistoryVisibleSince = 0;
if (resetSession) threadHistoryVisibleSessionCounted = false;
}
function scheduleThreadHistoryReactivationVisit(source) {
if (!parseThreadHistoryUrl(location.href)) return;
if (!isThreadHistoryPageActive()) {
cancelThreadHistoryDwellTimer(document.visibilityState !== 'visible');
return;
}
if (threadHistoryVisibleSessionCounted || threadHistoryDwellTimer) return;
threadHistoryVisibleSince = Date.now();
updateThreadHistoryDebugState({ lastDwell: { source, status: 'scheduled', threshold: THREAD_HISTORY_REVISIT_DWELL_MS, at: new Date().toISOString() } });
threadHistoryDwellTimer = setTimeout(() => {
threadHistoryDwellTimer = 0;
if (!threadHistoryVisibleSince || !isThreadHistoryPageActive()) return;
if (Date.now() - threadHistoryVisibleSince < THREAD_HISTORY_REVISIT_DWELL_MS) return;
threadHistoryVisibleSessionCounted = true;
updateThreadHistoryDebugState({ lastDwell: { source, status: 'counted', threshold: THREAD_HISTORY_REVISIT_DWELL_MS, at: new Date().toISOString() } });
recordCurrentThreadHistory(0, { reason: 'reactivation-dwell', countVisit: true });
}, THREAD_HISTORY_REVISIT_DWELL_MS);
}
function installThreadHistoryReactivationTracking(initialCounted) {
if (threadHistoryReactivationTrackingInstalled) return;
threadHistoryReactivationTrackingInstalled = true;
threadHistoryVisibleSessionCounted = !!initialCounted && document.visibilityState === 'visible';
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') scheduleThreadHistoryReactivationVisit('visibilitychange');
else cancelThreadHistoryDwellTimer(true);
}, { passive: true });
window.addEventListener('focus', () => scheduleThreadHistoryReactivationVisit('focus'), { passive: true });
window.addEventListener('blur', () => cancelThreadHistoryDwellTimer(false), { passive: true });
window.addEventListener('pagehide', () => cancelThreadHistoryDwellTimer(true), { passive: true });
}
function recordCurrentThreadHistory(attempt = 0, options = {}) {
const record = extractThreadHistoryRecord(document);
if (!record) {
const parsed = parseThreadHistoryUrl(location.href);
const missingMain = !!parsed && !findThreadHistoryMainElement(document, parsed);
updateThreadHistoryDebugState({
lastRecord: {
status: parsed ? (missingMain ? 'missing-main' : 'extract-failed') : 'unsupported-url',
attempt,
parsed,
at: new Date().toISOString(),
readyState: document.readyState,
mainCount: document.querySelectorAll('.h-threads-item-main').length,
listCount: document.querySelectorAll('.h-threads-list').length
}
});
if (missingMain && attempt < THREAD_HISTORY_RECORD_RETRY_LIMIT) {
logThreadHistory('retry record: waiting for h-threads-item-main', {
attempt: attempt + 1,
limit: THREAD_HISTORY_RECORD_RETRY_LIMIT,
delay: THREAD_HISTORY_RECORD_RETRY_DELAY,
readyState: document.readyState
});
setTimeout(() => recordCurrentThreadHistory(attempt + 1), THREAD_HISTORY_RECORD_RETRY_DELAY);
}
return;
}
upsertThreadHistoryRecord(record, Object.assign({ reason: 'initial-load', countVisit: true }, options));
updateThreadHistoryDebugState({ lastRecord: { status: 'saved', attempt, reason: options.reason || 'initial-load', record, at: new Date().toISOString() } });
installThreadHistoryScrollTracking();
}
function formatThreadHistoryTime(ts) {
if (!ts) return '';
const d = new Date(ts);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
return `${y}-${m}-${day} ${hh}:${mm}`;
}
function 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);
}
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);
}
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 = $(`