您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Greasy Fork is available in English.
多站点阅读标记 + 导出导入清除 + JSONBin云同步,彻底用GM存储,无localStorage冗余
// ==UserScript== // @name Multi Forum Read Marker // @namespace https://felixchristian.dev/userscripts/multi-forum-read-marker // @version 2.3.2 // @description 多站点阅读标记 + 导出导入清除 + JSONBin云同步,彻底用GM存储,无localStorage冗余 // @author Felix + ChatGPT + Gemini // @license MIT // @match https://soutong.men/forum.php?mod=forumdisplay&fid=* // @match https://soutong.men/forum.php?mod=viewthread&tid=* // @match https://74.222.3.60/forum.php?mod=forumdisplay&fid=* // @match https://74.222.3.60/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 hostnameAliases = { '74.222.3.60': 'soutong.men' }; // 获取当前域名,如果存在于别名设置中,则使用主域名 const originalHostname = location.hostname; const hostname = hostnameAliases[originalHostname] || originalHostname; // --- 修改结束 --- const STORAGE_KEY = `visitedTids_${hostname}`; const DOMAIN_INDEX_KEY = 'visitedTids_index'; const JSONBIN_BASE = 'https://api.jsonbin.io/v3/b'; let visitedTids = {}; const jsonbinId = GM_getValue('jsonbin_id', ''); const jsonbinKey = GM_getValue('jsonbin_key', ''); 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 { const data = GM_getValue(STORAGE_KEY, {}); visitedTids = (typeof data === 'object' && data !== null) ? data : {}; } catch { visitedTids = {}; } } function saveVisited() { try { GM_setValue(STORAGE_KEY, visitedTids); } catch (e) { console.error('保存失败:', e); } } function getDomainIndex() { const idx = GM_getValue(DOMAIN_INDEX_KEY, []); return Array.isArray(idx) ? idx : []; } function updateDomainIndex() { let domainList = getDomainIndex(); if (!domainList.includes(hostname)) { domainList.push(hostname); GM_setValue(DOMAIN_INDEX_KEY, domainList); } } function exportVisitedData() { let domainList = getDomainIndex(); const exportData = {}; domainList.forEach(domain => { const key = `visitedTids_${domain}`; try { const gmData = GM_getValue(key, {}); const cleaned = {}; for (const tid in gmData) { if (gmData[tid]?.visitedAt) { cleaned[tid] = { visitedAt: gmData[tid].visitedAt }; } } exportData[domain] = cleaned; } catch (e) { console.error(`导出 ${domain} 失败:`, e); exportData[domain] = {}; } }); 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' && newData !== null) { let domainList = getDomainIndex(); for (const site in newData) { const key = `visitedTids_${site}`; const old = GM_getValue(key, {}); const merged = { ...old, ...newData[site] }; GM_setValue(key, 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("⚠️ 确定清除所有站点记录?此操作不可恢复!")) { let domainList = getDomainIndex(); domainList.forEach(domain => { const key = `visitedTids_${domain}`; GM_setValue(key, {}); }); GM_setValue(DOMAIN_INDEX_KEY, []); alert("✅ 所有站点记录已清除!"); location.reload(); } } async function uploadToJsonBin() { if (!jsonbinId || !jsonbinKey) { alert('请先设置 JSONBin 的 Bin ID 和 API Key'); return; } try { // 读取所有域名列表 let domainList = getDomainIndex(); let allData = {}; // 收集每个域名的visitedTids for (const domain of domainList) { const key = `visitedTids_${domain}`; const data = GM_getValue(key, {}); allData[domain] = data; } // 上传整个多域名数据 const resp = await fetch(`${JSONBIN_BASE}/${jsonbinId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-Master-Key': jsonbinKey }, body: JSON.stringify({ updatedAt: new Date().toISOString(), visitedData: allData }) }); if (!resp.ok) throw new Error('上传失败'); alert('✅ 云端备份成功!'); } catch (e) { alert('❌ 云端备份失败:' + e.message); } } async function downloadFromJsonBin() { if (!jsonbinId || !jsonbinKey) { alert('请先设置 JSONBin 的 Bin ID 和 API Key'); return; } try { const resp = await fetch(`${JSONBIN_BASE}/${jsonbinId}/latest`, { method: 'GET', headers: { 'X-Master-Key': jsonbinKey } }); if (!resp.ok) throw new Error('记录不存在'); const json = await resp.json(); if (json.record && json.record.visitedData && typeof json.record.visitedData === 'object') { const allData = json.record.visitedData; // 清空本地域名索引,重建 const domainList = Object.keys(allData); GM_setValue(DOMAIN_INDEX_KEY, domainList); // 按域名分别写入GM存储 for (const domain of domainList) { const key = `visitedTids_${domain}`; const oldData = GM_getValue(key, {}); const newData = allData[domain]; // 合并,避免丢失本地未备份的数据 GM_setValue(key, { ...oldData, ...newData }); } // 如果当前页面域名数据恢复了,更新当前变量 if (visitedTids && domainList.includes(hostname)) { visitedTids = GM_getValue(`visitedTids_${hostname}`, {}); } alert('✅ 云端恢复成功!页面将刷新以应用数据'); location.reload(); } else { alert('❌ 云端数据格式错误'); } } catch (e) { alert('❌ 云端恢复失败:' + e.message); } } 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 createButton = (text, handler) => { const btn = document.createElement("button"); btn.textContent = text; btn.style.marginLeft = "5px"; btn.onclick = handler; return btn; }; container.appendChild(createButton("导出记录", exportVisitedData)); container.appendChild(createButton("导入记录", () => { 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(); })); container.appendChild(createButton("清除记录", clearVisitedData)); container.appendChild(createButton("云端备份", uploadToJsonBin)); container.appendChild(createButton("云端恢复", downloadFromJsonBin)); container.appendChild(createButton("设置JSONBin", () => { const binId = prompt("请输入 JSONBin Bin ID", GM_getValue("jsonbin_id", "")); const apiKey = prompt("请输入 JSONBin API Key(私密)", GM_getValue("jsonbin_key", "")); if (binId && apiKey) { GM_setValue("jsonbin_id", binId.trim()); GM_setValue("jsonbin_key", apiKey.trim()); alert("✅ JSONBin 设置成功!"); } })); 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(); } })();