Multi Forum Read Marker

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

Versione datata 12/06/2025. Vedi la nuova versione l'ultima versione.

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==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();
    }
})();