Multi Forum Read Marker

多站点阅读标记 + 导出导入清除 + JSONBin云同步

As of 12.06.2025. See ბოლო ვერსია.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Multi Forum Read Marker
// @namespace    https://felixchristian.dev/userscripts/multi-forum-read-marker
// @version      2.2.0
// @description  多站点阅读标记 + 导出导入清除 + JSONBin云同步
// @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';

    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 {
            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');
            });
            GM_setValue(DOMAIN_INDEX_KEY, []);
            alert("✅ 所有站点记录已清除!");
            location.reload();
        }
    }

    async function uploadToJsonBin() {
        if (!jsonbinId || !jsonbinKey) {
            alert('请先设置 JSONBin 的 Bin ID 和 API Key');
            return;
        }

        try {
            const resp = await fetch(`${JSONBIN_BASE}/${jsonbinId}`, {
                method: 'PUT',
                headers: {
                    'Content-Type': 'application/json',
                    'X-Master-Key': jsonbinKey
                },
                body: JSON.stringify({
                    domain: hostname,
                    updatedAt: new Date().toISOString(),
                    visitedTids: visitedTids
                })
            });

            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.visitedTids) {
                visitedTids = { ...visitedTids, ...json.record.visitedTids };
                saveVisited();
                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();
    }
})();