支持多站点已读记录(点击即记录)、支持导入导出和清除(仅记录 visitedAt),数据按站点分离存储,格式干净统一。
Fra og med
// ==UserScript==
// @name Multi Forum Read Marker
// @namespace https://felixchristian.dev/userscripts/multi-forum-read-marker
// @version 2.0.2
// @description 支持多站点已读记录(点击即记录)、支持导入导出和清除(仅记录 visitedAt),数据按站点分离存储,格式干净统一。
// @author Felix + ChatGPT
// @license MIT
// @match https://soutong.men/forum.php?mod=forumdisplay&fid=*
// @match https://soutong.men/forum.php?mod=viewthread&tid=*
// @match https://www.tt1069.com/bbs/thread-*-*-*.html
// @match https://www.tt1069.com/bbs/forum-*-*.html
// @match https://www.tt1069.com/bbs/forum.php?mod=forumdisplay&fid=*
// @match https://www.tt1069.com/bbs/forum.php?mod=viewthread&tid=*
// @grant GM_getValue
// @grant GM_setValue
// ==/UserScript==
(function () {
'use strict';
const hostname = location.hostname;
const STORAGE_KEY = `visitedTids_${hostname}`;
const DOMAIN_INDEX_KEY = 'visitedTids_index';
let visitedTids = {};
function formatDate(d = new Date()) {
return d.getFullYear() + '-' +
String(d.getMonth() + 1).padStart(2, '0') + '-' +
String(d.getDate()).padStart(2, '0') + ' ' +
String(d.getHours()).padStart(2, '0') + ':' +
String(d.getMinutes()).padStart(2, '0') + ':' +
String(d.getSeconds()).padStart(2, '0');
}
function getTidFromUrl(url) {
try {
const u = new URL(url, location.origin);
let tid = u.searchParams.get('tid');
if (!tid) {
const match = url.match(/thread-(\d+)-/);
tid = match ? match[1] : null;
}
return tid;
} catch {
return null;
}
}
function loadVisited() {
try {
visitedTids = GM_getValue(STORAGE_KEY, {});
const localData = localStorage.getItem(STORAGE_KEY + '_temp');
if (localData) {
const localObj = JSON.parse(localData);
visitedTids = { ...visitedTids, ...localObj };
}
} catch {
visitedTids = {};
}
}
function saveVisited() {
try {
GM_setValue(STORAGE_KEY, visitedTids);
localStorage.setItem(STORAGE_KEY + '_temp', JSON.stringify(visitedTids));
} catch (e) {
console.error('保存失败:', e);
}
}
// 更新站点索引,记录所有访问过的域名
function updateDomainIndex() {
let domainList = GM_getValue(DOMAIN_INDEX_KEY, []);
if (!domainList.includes(hostname)) {
domainList.push(hostname);
GM_setValue(DOMAIN_INDEX_KEY, domainList);
}
}
function exportVisitedData() {
const domainList = GM_getValue(DOMAIN_INDEX_KEY, []);
const exportData = {};
domainList.forEach(domain => {
const key = `visitedTids_${domain}`;
try {
const local = JSON.parse(localStorage.getItem(key + '_temp') || '{}');
const gm = GM_getValue(key, {});
const merged = { ...gm, ...local };
const cleaned = {};
for (const tid in merged) {
if (merged[tid]?.visitedAt) {
cleaned[tid] = { visitedAt: merged[tid].visitedAt };
}
}
exportData[domain] = cleaned;
} catch (e) {
console.error(`导出 ${domain} 失败:`, e);
}
});
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `visitedTids_${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
}
function importVisitedData(jsonText) {
try {
const newData = JSON.parse(jsonText);
if (typeof newData === 'object') {
let domainList = GM_getValue(DOMAIN_INDEX_KEY, []);
for (const site in newData) {
const key = `visitedTids_${site}`;
const old = GM_getValue(key, {});
const merged = { ...old, ...newData[site] };
GM_setValue(key, merged);
localStorage.setItem(key + '_temp', JSON.stringify(merged));
if (!domainList.includes(site)) {
domainList.push(site);
}
}
GM_setValue(DOMAIN_INDEX_KEY, domainList);
alert("导入成功!");
location.reload();
} else {
alert("导入失败:格式错误");
}
} catch (e) {
alert("导入失败:JSON解析错误");
}
}
// 修正后的清除所有站点记录
function clearVisitedData() {
if (confirm("⚠️ 确定清除所有站点记录?此操作不可恢复!")) {
const domainList = GM_getValue(DOMAIN_INDEX_KEY, []);
domainList.forEach(domain => {
const key = `visitedTids_${domain}`;
GM_setValue(key, {});
localStorage.removeItem(key + '_temp');
localStorage.removeItem(key);
});
GM_setValue(DOMAIN_INDEX_KEY, []);
alert("✅ 所有站点记录已清除!");
location.reload();
}
}
function addImportExportUI() {
const container = document.createElement("div");
container.style.position = "fixed";
container.style.bottom = "10px";
container.style.right = "10px";
container.style.zIndex = "9999";
container.style.backgroundColor = "#fff";
container.style.border = "1px solid #888";
container.style.padding = "5px";
container.style.fontSize = "12px";
const exportBtn = document.createElement("button");
exportBtn.textContent = "导出记录";
exportBtn.onclick = exportVisitedData;
const importBtn = document.createElement("button");
importBtn.textContent = "导入记录";
importBtn.style.marginLeft = "5px";
importBtn.onclick = () => {
const input = document.createElement("input");
input.type = "file";
input.accept = ".json";
input.onchange = e => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = () => {
importVisitedData(reader.result);
};
reader.readAsText(file);
}
};
input.click();
};
const clearBtn = document.createElement("button");
clearBtn.textContent = "清除记录";
clearBtn.style.marginLeft = "5px";
clearBtn.onclick = clearVisitedData;
container.appendChild(exportBtn);
container.appendChild(importBtn);
container.appendChild(clearBtn);
document.body.appendChild(container);
}
function markReadThreads() {
const threadLinks = document.querySelectorAll('a.s.xst');
threadLinks.forEach(link => {
if (link.dataset.markedVisited) return;
const tid = getTidFromUrl(link.href);
if (tid && visitedTids[tid]) {
const tag = document.createElement('span');
tag.textContent = '[已读] ';
tag.style.color = 'red';
tag.style.fontWeight = 'bold';
tag.style.marginRight = '4px';
link.insertBefore(tag, link.firstChild);
link.dataset.markedVisited = 'true';
}
});
}
function attachClickListeners() {
const threadLinks = document.querySelectorAll('a.s.xst');
threadLinks.forEach(link => {
if (link.dataset.clickListenerAdded) return;
link.addEventListener('click', () => {
const tid = getTidFromUrl(link.href);
if (tid) {
visitedTids[tid] = {
visitedAt: formatDate()
};
saveVisited();
}
});
link.dataset.clickListenerAdded = 'true';
});
}
function handleThreadPage() {
const tid = getTidFromUrl(location.href);
if (tid) {
visitedTids[tid] = {
visitedAt: formatDate()
};
saveVisited();
}
}
loadVisited();
updateDomainIndex();
if (location.href.includes('forumdisplay') || /forum-\d+-\d+\.html/.test(location.pathname)) {
window.addEventListener('load', () => {
markReadThreads();
attachClickListeners();
addImportExportUI();
});
const observer = new MutationObserver(() => {
markReadThreads();
attachClickListeners();
});
observer.observe(document.body, { childList: true, subtree: true });
}
if (location.href.includes('mod=viewthread') || /thread-\d+-/.test(location.pathname)) {
handleThreadPage();
}
})();