// ==UserScript==
// @name NGA Cache History
// @namespace https://greasyfork.org/users/263018
// @version 1.0.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
// @noframes
// @downloadURL none
// ==/UserScript==
(async ({ commonui: ui, _LOADERREAD: loader }) => {
// 检查是否支持 IndexedDB
if (window.indexedDB === undefined) {
return;
}
// 常量
const KEY = "NGA_CACHE";
const VERSION = 1;
const TABLE_NAME = "reads";
const EXPIRE_DURATION_KEY = "EXPIRE_DURATION";
// 缓存时长
const EXPIRE_DURATION = GM_getValue(EXPIRE_DURATION_KEY, 7);
// 判断帖子是否正常
const isSuccess = () => {
return ui;
};
// 获取数据库实例
const db = await new Promise((resolve) => {
// 打开 IndexedDB 数据库
const request = window.indexedDB.open(KEY, 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 (location.pathname !== "/read.php") {
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);
// 写入数据
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 (location.pathname !== "/read.php") {
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;
}
`);
// 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)}
|
${title}
|
|
`;
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 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 duration = parseInt(input, 10);
if (duration > 0 && duration <= 1000) {
GM_setValue(EXPIRE_DURATION, duration);
location.reload();
}
}
});
})();
// 执行脚本
(() => {
// 绑定事件
hook();
// 加载UI
loadUI();
// 当前链接地址
const url = window.location.href;
// 帖子正常的情况下缓存数据,否则尝试从缓存中读取
if (isSuccess()) {
save(url);
} else {
load(url, document);
}
})();
})(unsafeWindow);