您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
将帖子内容缓存 IndexedDB 里,以便在帖子被审核/删除时仍能查看
// ==UserScript== // @name NGA Cache History // @name:zh-CN NGA 帖子缓存插件 // @namespace https://gf.qytechs.cn/users/263018 // @version 1.2.4 // @author snyssss // @description 将帖子内容缓存 IndexedDB 里,以便在帖子被审核/删除时仍能查看 // @license MIT // @match *://bbs.nga.cn/* // @match *://ngabbs.com/* // @match *://nga.178.com/* // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @grant unsafeWindow // @noframes // ==/UserScript== (async ({ commonui: ui, _LOADERREAD: loader }) => { // 检查是否支持 IndexedDB if (window.indexedDB === undefined) { return; } // 常量 const VERSION = 1; const DB_NAME = "NGA_CACHE"; const TABLE_NAME = "reads"; const SHOW_DIFFRENCE_KEY = "SHOW_DIFFRENCE"; const EXPIRE_DURATION_KEY = "EXPIRE_DURATION"; const REFETCH_NOTIFICATION_INTERVAL_KEY = "REFETCH_NOTIFICATION_INTERVAL"; // 显示差异 const SHOW_DIFFRENCE = GM_getValue(SHOW_DIFFRENCE_KEY, false); // 缓存时长 const EXPIRE_DURATION = GM_getValue(EXPIRE_DURATION_KEY, 7); // 获取提示信息间隔 const REFETCH_NOTIFICATION_INTERVAL = GM_getValue( REFETCH_NOTIFICATION_INTERVAL_KEY, 10 ); // 判断帖子是否正常 const isSuccess = () => { return ui; }; // 格式化 URL const formatUrl = (url) => { // 分割 URL const urlSplit = url.split("?"); // 获取页面参数 const params = new URLSearchParams(urlSplit[1]); // 如果是第一页,移除页码参数 if (params.get("page") === "1") { params.delete("page"); } // 移除 _ff 参数 params.delete("_ff"); // 返回格式化后的结果 return `${urlSplit[0]}?${params.toString()}`; }; // 获取首页 URL const getHeadUrl = (url) => { // 格式化 URL url = formatUrl(url); // 分割 URL const urlSplit = url.split("?"); // 获取页面参数 const params = new URLSearchParams(urlSplit[1]); // 获取 TID const tid = params.get("tid"); // 返回首页 URL return `${urlSplit[0]}?tid=${tid}`; }; // 获取数据库实例 const db = await new Promise((resolve) => { // 打开 IndexedDB 数据库 const request = window.indexedDB.open(DB_NAME, VERSION); // 如果数据库不存在则创建 request.onupgradeneeded = (event) => { // 创建表 const store = event.target.result.createObjectStore(TABLE_NAME, { keyPath: "url", }); // 创建索引,用于清除过期数据 store.createIndex("timestamp", "timestamp"); }; // 成功后返回实例 request.onsuccess = (event) => { resolve(event.target.result); }; }); // 获取数据 const get = (url, onsuccess, onerror = () => {}) => { // 格式化 URL url = formatUrl(url); // 只缓存帖子内容 if (url.indexOf("/read.php") < 0) { return; } // 创建事务 const transaction = db.transaction([TABLE_NAME], "readonly"); // 获取对象仓库 const store = transaction.objectStore(TABLE_NAME); // 获取数据 const request = store.get(url); // 成功后处理数据 request.onsuccess = (event) => { // 获取页面对象 const data = event.target.result; // 不存在则抛出异常 if (data === undefined) { onerror(); return; } // 处理数据 onsuccess(data); }; // 失败后抛出异常 request.onerror = () => { onerror(); }; }; // 删除超时数据 const expire = (offset) => { // 创建事务 const transaction = db.transaction([TABLE_NAME], "readwrite"); // 获取对象仓库 const store = transaction.objectStore(TABLE_NAME); // 获取索引 const index = store.index("timestamp"); // 查找超时数据 const request = index.openCursor( IDBKeyRange.upperBound(Date.now() - offset) ); // 成功后删除数据 request.onsuccess = (event) => { const cursor = event.target.result; if (cursor) { store.delete(cursor.primaryKey); cursor.continue(); } }; }; // 删除数据 const remove = (url, onsuccess = () => {}, onerror = () => {}) => { // 格式化 URL url = formatUrl(url); // 创建事务 const transaction = db.transaction([TABLE_NAME], "readwrite"); // 获取对象仓库 const store = transaction.objectStore(TABLE_NAME); // 删除数据 const request = store.delete(url); // 成功后回调 request.onsuccess = () => { onsuccess(); }; // 失败后回调 request.onerror = () => { onerror(); }; }; // 写入数据 const put = (url, data, onsuccess = () => {}, onerror = () => {}) => { // 格式化 URL url = formatUrl(url); // 创建事务 const transaction = db.transaction([TABLE_NAME], "readwrite"); // 获取对象仓库 const store = transaction.objectStore(TABLE_NAME); // 写入数据 const request = store.put({ url, timestamp: Date.now(), ...data, }); // 成功后回调 request.onsuccess = () => { onsuccess(); }; // 失败后回调 request.onerror = () => { onerror(); }; }; // 缓存数据 const save = (url) => { // 格式化 URL url = formatUrl(url); // 只缓存帖子内容 if (url.indexOf("/read.php") < 0) { return; } // 重新请求原始数据用于缓存 fetch(url) .then((res) => res.blob()) .then((res) => { // 读取内容 const reader = new FileReader(); reader.onload = async () => { // 读取内容 const content = reader.result; // 解析标题 const parser = new DOMParser(); const html = parser.parseFromString(content, "text/html"); const title = (() => { const str = html.querySelector("title").textContent; const index = str.lastIndexOf(" "); if (index > 0) { return str.substring(0, index); } return str; })(); // 没有楼层,说明卡审核 if (content.indexOf("commonui.postArg.proc(") < 0) { return; } // 找到 ID 是 postcontainer 开头的元素 const containers = html.querySelectorAll("[id^=postcontainer]"); if (containers.length === 0) { return; } // 有锚点,但是找不到楼层,也是卡审核 const anchor = url.match(/(#pid\d+Anchor)$/); if (anchor && html.querySelector(anchor[1]) === null) { return; } // 如果未开启浏览记录,直接写入缓存 if (SHOW_DIFFRENCE === false) { put(url, { title, content, }); } // 否则判断是否是正常的翻页,如果是则需要更新最大楼层数 else { // 分割 URL const urlSplit = url.split("?"); // 获取页面参数 const params = new URLSearchParams(urlSplit[1]); // 移除 TID 参数 params.delete("tid"); // 移除页码参数 params.delete("page"); // 如果仍有参数,只缓存当前页,无需更新最大楼层数 if (params.size > 0) { put(url, { title, content, }); } // 否则需要更新最大楼层数 else { // 获取首页 URL const headUrl = getHeadUrl(url); // 当前页不是首页,写入缓存 if (headUrl !== url) { put(url, { title, content, }); } // 获取当前页面的最大楼层数 const count = parseInt( containers[containers.length - 1] .getAttribute("id") .replace("postcontainer", ""), 10 ); // 获取首页缓存 get( headUrl, (data) => { // 获取缓存楼层数 const cache = data.rows || 0; // 计算最大楼层数 const max = Math.max(count, cache); // 当前页是首页,直接更新缓存 if (headUrl === url) { put(url, { title, content, rows: max, }); loadAction(); return; } // 如果与缓存的最大楼层数相同,无需更新 if (max === cache) { return; } // 更新缓存 put(headUrl, { ...data, rows: max, }); }, () => { // 当前页是首页,直接更新缓存 if (headUrl === url) { put(url, { title, content, rows: count, }); loadAction(); } } ); } } }; reader.readAsText(res, "GBK"); }); }; // 读取数据 const load = (url, document) => { // 格式化 URL url = formatUrl(url); return get(url, (data) => { // 加载缓存内容 const html = document.open("text/html", "replace"); html.write(data.content); html.close(); // 缓存时间格式 const formatedDate = (() => { const date = new Date(data.timestamp); const year = date.getFullYear(); const month = ("0" + (date.getMonth() + 1)).slice(-2); const day = ("0" + date.getDate()).slice(-2); const hours = ("0" + date.getHours()).slice(-2); const minutes = ("0" + date.getMinutes()).slice(-2); return `${year}-${month}-${day} ${hours}:${minutes}`; })(); // 写入缓存时间 (() => { const execute = () => { const container = document.querySelector('td[id^="postcontainer"]'); if (container) { const elements = container.querySelectorAll(":scope > .clear"); const anchor = elements[elements.length - 1]; if (anchor) { anchor.insertAdjacentHTML( "afterend", `<h4 class="silver subtitle">缓存</h4><span class="block_txt block_txt_c3">${formatedDate}</span>` ); return; } } setTimeout(execute, 160); }; execute(); })(); }); }; // STYLE GM_addStyle(` .s-table-wrapper { height: calc((2em + 10px) * 11 + 3px); overflow-y: auto; } .s-table { margin: 0; } .s-table th, .s-table td { position: relative; white-space: nowrap; } .s-table th { position: sticky; top: 2px; z-index: 1; } .s-text-ellipsis > * { flex: 1; width: 1px; overflow: hidden; text-overflow: ellipsis; } `); // UI const loadUI = () => { if (!ui) { return; } const content = (() => { const c = document.createElement("div"); c.innerHTML = ` <div class="s-table-wrapper" style="width: 1000px; max-width: 95vw;"> <table class="s-table forumbox"> <thead> <tr class="block_txt_c0"> <th class="c1" width="1">时间</th> <th class="c2">内容</th> <th class="c3" width="1">操作</th> </tr> </thead> <tbody></tbody> </table> </div> <div style="display: flex; margin-top: 10px;"> <input type="text" style="flex: 1;" placeholder="目前支持通过帖子链接或标题进行筛选,查询旧数据可能需要一些时间" /> <button>筛选</button> </div> `; return c; })(); let position = null; let hasNext = true; let isFetching = false; let keyword = ""; const list = content.querySelector("TBODY"); const wrapper = content.querySelector(".s-table-wrapper"); const keywordInput = content.querySelector("INPUT"); const filterButton = content.querySelector("BUTTON"); const fetchData = () => { isFetching = true; // 声明查询数量 let limit = 10; // 创建事务 const transaction = db.transaction([TABLE_NAME], "readonly"); // 获取对象仓库 const store = transaction.objectStore(TABLE_NAME); // 获取索引 const index = store.index("timestamp"); // 查找数据 const request = index.openCursor( position ? IDBKeyRange.upperBound(position) : null, "prev" ); // 加载列表 request.onsuccess = (event) => { const cursor = event.target.result; if (cursor) { const { url, title, timestamp } = cursor.value; position = timestamp; if (list.querySelector(`[data-url="${url}"]`)) { cursor.continue(); return; } if (keyword) { if (url.indexOf(keyword) < 0 && title.indexOf(keyword) < 0) { cursor.continue(); return; } } const item = document.createElement("TR"); item.className = `row${(list.querySelectorAll("TR").length % 2) + 1}`; item.setAttribute("data-url", url); item.innerHTML = ` <td class="c1"> <span class="nobr">${ui.time2dis(timestamp / 1000)}</span> </td> <td class="c2"> <div class="s-text-ellipsis"> <span> <a href="${url}" title="${title}" class="b nobr">${title}</a> </span> </div> </td> <td class="c3"> <button>查看缓存版本</button> <button>删除</button> </td> `; const buttons = item.querySelectorAll("button"); // 查看缓存版本 buttons[0].onclick = () => { const iWindow = ui.createCommmonWindow(); const iframe = document.createElement("IFRAME"); iframe.style.width = "80vw"; iframe.style.height = "80vh"; iframe.style.border = "none"; const iframeLoad = () => { iframe.removeEventListener("load", iframeLoad); load(url, iframe.contentDocument); }; iframe.addEventListener("load", iframeLoad); iWindow._.addTitle(title); iWindow._.addContent(iframe); iWindow._.show(); }; // 删除缓存 buttons[1].onclick = () => { remove(url, () => { list.removeChild(item); if (list.childElementCount < 10) { fetchData(); } }); }; list.appendChild(item); if (limit > 1) { cursor.continue(); } else { isFetching = false; } } else { hasNext = false; } limit -= 1; }; }; const refetch = (value = ``) => { list.innerHTML = ``; position = null; hasNext = true; isFetching = false; keyword = value; keywordInput.value = value; fetchData(); }; wrapper.onscroll = () => { if (isFetching || !hasNext) { return; } if ( wrapper.scrollHeight - wrapper.scrollTop <= wrapper.clientHeight * 1.1 ) { fetchData(); } }; filterButton.onclick = () => { refetch(keywordInput.value); }; // 增加菜单项 (() => { const title = "浏览记录"; let window; ui.mainMenu.addItemOnTheFly(title, null, () => { if (window === undefined) { window = ui.createCommmonWindow(); } refetch(); window._.addTitle(title); window._.addContent(content); window._.show(); }); })(); }; // 加载操作按钮 // 目前只有主楼的删除缓存 const loadAction = () => { if (ui && ui.postArg) { const { data } = ui.postArg; if (data && data["0"] && data["0"]["pid"] === 0) { const item = data["0"]; const pInfoC = item["pInfoC"]; const anchor = pInfoC.querySelector(`[title="操作菜单"]`); const action = pInfoC.querySelector(`[title="缓存"]`); if (anchor && action === null) { const element = document.createElement("A"); element.href = "javascript:void(0)"; element.className = `postinfob postfavb postoptb small_colored_text_btn block_txt_c0 stxt`; element.title = "缓存"; element.append(...__TXT.svg("turned_in", "", 8)); element.onclick = () => { const url = window.location.href; // 判断是否已有缓存 // 目前默认缓存所有页面,所以一定会有缓存 const cached = element.classList.contains("postoptb"); if (cached) { remove(url, () => { element.classList.remove("postoptb"); }); } else { save(url); element.classList.add("postoptb"); } }; anchor.parentElement.insertBefore(element, anchor); } } } }; // 加载消息 const loadMessage = () => { if (!ui) { return; } // 获取消息并写入缓存 const execute = () => { fetch("/nuke.php?lite=js&__lib=noti&__act=get_all") .then((res) => res.blob()) .then((blob) => { const reader = new FileReader(); reader.onload = () => { const text = reader.result; const result = JSON.parse( text .replace("window.script_muti_get_var_store=", "") .replace(/(['"])?([a-zA-Z0-9_]+)(['"])?:/g, '"$2": ') ); if (result.data) { const data = result.data[0]; const list = ["0", "1", "2"].reduce( (res, key) => ({ ...res, [key]: data[key], }), {} ); // 有未读消息,说明抢先获取了,需要弹出提醒 if (data.unread) { for (let type in list) { const group = list[type]; if (!group) { continue; } for (let i = 0; i < group.length; i += 1) { const item = group[i]; if (!item) { continue; } if (i < group.length - 5) { continue; } ui.notification._add(type, item); } if (group.length > 5) { ui.notification._more.style.display = ""; } } ui.notification.openBox(); } // 处理缓存 // 只处理 0,也就是 _BIT_REPLY 的情况 if (list["0"]) { const group = list["0"]; for (let i = 0; i < group.length; i += 1) { const item = group[i]; if (!item) { continue; } // 消息的时间 const time = item[9] * 1000; // 消息的内容,参考 js_notification.js 的 TPL let str = TPL[0][item[0]]; if (typeof str == "function") { str = str(item); } str = str .replace(/\{(_[A-Z0-9_]+)\}/g, function ($0, $1) { return TPLSUB[$1] ? TPLSUB[$1] : $0; }) .replace(/\{(_[A-Z0-9_]+)\}/g, function ($0, $1) { return item[KEY[$1]] ? item[KEY[$1]] : $0; }); // 获取里面出现的所有页面链接 const urls = [ ...str.matchAll(/href="(\/read.php[^"]*)"/gi), ].map((match) => `${window.location.origin}${match[1]}`); for (let index in urls) { // 链接地址 const url = urls[index]; // 创建事务 const transaction = db.transaction( [TABLE_NAME], "readonly" ); // 获取对象仓库 const store = transaction.objectStore(TABLE_NAME); // 获取数据 const request = store.get(url); // 成功后处理数据 request.onsuccess = (event) => { // 获取页面对象 const data = event.target.result; // 存在,且缓存的时间晚于消息时间则跳过 if (data && data.timestamp > time) { return; } // 写入缓存 save(url); }; } } } } }; reader.readAsText(blob, "GBK"); }); }; // NGA 的消息机制是在页面加载的时候由服务端写在页面里再请求消息 // 这会导致页面不刷新的时候,收到的提醒不能及时获知,等刷新时帖子可能已经没了 // 所以需要定时获取最新消息,保证不刷论坛的情况下也会缓存提醒 // 泥潭审核机制导致有消息提示但是找不到帖子的情况待解决 const excuteInterval = () => { if (REFETCH_NOTIFICATION_INTERVAL > 0) { execute(); setInterval(execute, REFETCH_NOTIFICATION_INTERVAL * 60 * 1000); } }; // 启动定时器 if (ui.notification) { excuteInterval(); } else { ui.loadNotiScript(excuteInterval); } }; // 绑定事件 const hook = () => { // 钩子 const hookFunction = (object, functionName, callback) => { ((originalFunction) => { object[functionName] = function () { const returnValue = originalFunction.apply(this, arguments); callback.apply(this, [returnValue, originalFunction, arguments]); return returnValue; }; })(object[functionName]); }; // 页面跳转 if (loader) { hookFunction(loader, "go", (returnValue, originalFunction, arguments) => { if (arguments[1]) { const { url } = arguments[1]; save(url); } }); } // 快速翻页 if (ui) { hookFunction( ui, "loadReadHidden", (returnValue, originalFunction, arguments) => { if (arguments && __PAGE) { const p = (() => { if (arguments[1] & 2) { return __PAGE[2] + 1; } if (arguments[1] & 4) { return __PAGE[2] - 1; } return arguments[0]; })(); if (p < 1 || (__PAGE[1] > 0 && p > __PAGE[1])) { return; } const urlParams = new URLSearchParams(window.location.search); urlParams.set("page", p); const url = `${window.location.origin}${ window.location.pathname }?${urlParams.toString()}`; save(url); } } ); } // 显示浏览记录或恢复帖子列表里异常的帖子 if (ui && ui.topicArg) { const execute = () => { ui.topicArg.data.forEach((item) => { const tid = item[8]; const postDate = item[12]; const url = `${window.location.origin}/read.php?tid=${tid}`; get(url, (data) => { if (postDate > 0) { if (SHOW_DIFFRENCE) { const replies = parseInt(item[0].innerHTML, 10); const rows = data.rows === undefined ? replies : data.rows; const diffrence = replies - rows; if (diffrence > 0) { const page = Math.ceil(rows / 20); if (page > 1) { item[0].setAttribute("href", `${url}&page=${page}`); } item[0].innerHTML = `${replies}<small>(+${diffrence})</small>`; } item[1].style.opacity = "0.5"; } return; } item[1].innerHTML = data.title; item[2].innerHTML = "缓存"; item[3].innerHTML = ui.time2dis(data.timestamp / 1000); }); }); }; hookFunction(ui.topicArg, "loadAll", execute); execute(); } }; // 加载菜单项 (() => { GM_registerMenuCommand( `浏览记录:${SHOW_DIFFRENCE ? "显示" : "关闭"}`, () => { GM_setValue(SHOW_DIFFRENCE_KEY, !SHOW_DIFFRENCE); location.reload(); } ); GM_registerMenuCommand(`缓存天数:${EXPIRE_DURATION} 天`, () => { const input = prompt("请输入缓存天数(最大1000):", EXPIRE_DURATION); if (input) { const value = parseInt(input, 10); if (value > 0 && value <= 1000) { GM_setValue(EXPIRE_DURATION_KEY, value); location.reload(); } } }); GM_registerMenuCommand( `消息刷新间隔:${REFETCH_NOTIFICATION_INTERVAL} 分钟`, () => { const input = prompt( "请输入消息刷新间隔(单位:分钟,设置为 0 的时候不启用):", REFETCH_NOTIFICATION_INTERVAL ); if (input) { const value = parseInt(input, 10); if (value <= 1440) { GM_setValue(REFETCH_NOTIFICATION_INTERVAL_KEY, value); location.reload(); } } } ); })(); // 执行脚本 (() => { // 绑定事件 hook(); // 删除超时数据 expire(EXPIRE_DURATION * 24 * 60 * 60 * 1000); // 加载UI loadUI(); // 加载消息 loadMessage(); // 当前链接地址 const url = window.location.href; // 帖子正常的情况下缓存数据,否则尝试从缓存中读取 if (isSuccess()) { save(url); } else { load(url, document); } })(); })(unsafeWindow);
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址