// ==UserScript==
// @name NGA Cache History
// @namespace https://greasyfork.org/users/263018
// @version 1.1.0
// @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
// @downloadURL none
// ==/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 EXPIRE_DURATION_KEY = "EXPIRE_DURATION";
const REFETCH_NOTIFICATION_INTERVAL_KEY = "REFETCH_NOTIFICATION_INTERVAL";
// 缓存时长
const EXPIRE_DURATION = GM_getValue(EXPIRE_DURATION_KEY, 7);
// 获取提示信息间隔
const REFETCH_NOTIFICATION_INTERVAL = GM_getValue(
REFETCH_NOTIFICATION_INTERVAL_KEY,
10
);
// 判断帖子是否正常
const isSuccess = () => {
return ui;
};
// 获取数据库实例
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 expire = (store, offset) => {
// 获取索引
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 save = (url) => {
// 只缓存帖子内容
if (url.indexOf("/read.php") < 0) {
return;
}
// 重新请求原始数据用于缓存
fetch(url)
.then((res) => res.blob())
.then((res) => {
// 读取内容
const reader = new FileReader();
reader.onload = () => {
// 读取内容
const content = reader.result;
// 解析标题
const parser = new DOMParser();
const html = parser.parseFromString(content, "text/html");
const title = html.querySelector("title").textContent;
// 创建事务
const transaction = db.transaction([TABLE_NAME], "readwrite");
// 获取对象仓库
const store = transaction.objectStore(TABLE_NAME);
// 页面有效时,写入缓存
if (html.querySelector("#m_posts")) {
store.put({
url,
title,
content,
timestamp: Date.now(),
});
}
// 删除超时数据
expire(store, EXPIRE_DURATION * 24 * 60 * 60 * 1000);
};
reader.readAsText(res, "GBK");
});
};
// 读取数据
const load = (url, document) => {
// 只缓存帖子内容
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) {
return;
}
// 加载缓存内容
const html = document.open("text/html", "replace");
html.write(data.content);
html.close();
// 修改内容边框颜色以区分缓存
const anchor = document.querySelector("#m_posts");
if (anchor) {
anchor.style.borderColor = "#591804";
}
};
};
// 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 = `
`;
return c;
})();
let position = null;
let hasNext = true;
let isFetching = false;
const list = content.querySelector("TBODY");
const wrapper = content.querySelector(".s-table-wrapper");
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;
}
const item = document.createElement("TR");
item.className = `row${(list.querySelectorAll("TR").length % 2) + 1}`;
item.setAttribute("data-url", url);
item.innerHTML = `
${ui.time2dis(timestamp / 1000)}
|
|
|
`;
const button = item.querySelector("button");
button.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();
};
list.appendChild(item);
if (limit > 1) {
cursor.continue();
} else {
isFetching = false;
}
} else {
hasNext = false;
}
limit -= 1;
};
};
const refetch = () => {
list.innerHTML = ``;
position = null;
hasNext = true;
isFetching = false;
fetchData();
};
wrapper.onscroll = () => {
if (isFetching || !hasNext) {
return;
}
if (wrapper.scrollHeight - wrapper.scrollTop <= wrapper.clientHeight) {
fetchData();
}
};
// 增加菜单项
(() => {
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 loadMessage = () => {
if (!ui) {
return;
}
// 获取消息并写入缓存
const excute = () => {
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) {
excute();
setInterval(excute, 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);
}
}
);
}
};
// 加载菜单项
(() => {
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();
// 加载UI
loadUI();
// 加载消息
loadMessage();
// 当前链接地址
const url = window.location.href;
// 帖子正常的情况下缓存数据,否则尝试从缓存中读取
if (isSuccess()) {
save(url);
} else {
load(url, document);
}
})();
})(unsafeWindow);