// ==UserScript==
// @name NGA Cache History
// @name:zh-CN NGA 帖子缓存插件
// @namespace https://greasyfork.org/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);