Multi Forum Read Marker

多站点阅读标记 + 导出导入清除 + JSONBin云同步,彻底用GM存储,无localStorage冗余

Versão de: 12/06/2025. Veja: a última versão.

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==UserScript==
// @name         Multi Forum Read Marker
// @namespace    https://felixchristian.dev/userscripts/multi-forum-read-marker
// @version      2.3.1
// @description  多站点阅读标记 + 导出导入清除 + JSONBin云同步,彻底用GM存储,无localStorage冗余
// @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 {
            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();
    }
})();