Multi Forum Read Marker (Soutong + TT1069)

支持搜同网和TT1069论坛的帖子访问记录标记功能,点击即标记为已读,并支持导出导入清除阅读记录,记录发帖时间,防止丢失。

Pada tanggal 11 Juni 2025. Lihat %(latest_version_link).

// ==UserScript==
// @name         Multi Forum Read Marker (Soutong + TT1069)
// @namespace    https://felixchristian.dev/userscripts/multi-forum-read-marker
// @version      1.6.0
// @description  支持搜同网和TT1069论坛的帖子访问记录标记功能,点击即标记为已读,并支持导出导入清除阅读记录,记录发帖时间,防止丢失。
// @author       FelixChristian
// @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=forumdisplay&fid=*&*
// @match        https://www.tt1069.com/bbs/forum.php?mod=viewthread&tid=*
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

(function () {
    'use strict';

    const STORAGE_KEY = 'visitedTids';
    let visitedTids = {};

    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 (e) {
            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 (e) {
            visitedTids = {};
        }
    }

    function saveVisited() {
        try {
            GM_setValue(STORAGE_KEY, visitedTids);
            localStorage.setItem(STORAGE_KEY + '_temp', JSON.stringify(visitedTids));
        } catch (e) {
            console.error('保存失败:', e);
        }
    }

    function exportVisitedData() {
        const dataStr = JSON.stringify(visitedTids, null, 2);
        const blob = new Blob([dataStr], {type: "application/json"});
        const url = URL.createObjectURL(blob);
        const a = document.createElement("a");
        a.href = url;
        a.download = `visitedTids_backup_${Date.now()}.json`;
        a.click();
        URL.revokeObjectURL(url);
    }

    function importVisitedData(jsonText) {
        try {
            const newData = JSON.parse(jsonText);
            if (typeof newData === 'object') {
                visitedTids = {...visitedTids, ...newData};
                saveVisited();
                alert("导入成功!");
                location.reload();
            } else {
                alert("导入失败:格式错误");
            }
        } catch (e) {
            alert("导入失败:JSON解析错误");
        }
    }

    function clearVisitedData() {
        if (confirm("确定要清除所有已读记录吗?")) {
            visitedTids = {};
            saveVisited();
            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);
    }

    loadVisited();

    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]) {
                    visitedTids[tid] = {
                        visitedAt: Date.now()
                    };
                    saveVisited();
                }
            });
            link.dataset.clickListenerAdded = 'true';
        });
    }

    // 添加发帖时间(在帖子页面中)
    function extractPostTime() {
        const tid = getTidFromUrl(location.href);
        if (!tid) return;
        const em = document.querySelector('div.authi em[id^="authorposton"]');
        if (em && em.textContent.includes("发表于")) {
            const dateText = em.textContent.replace("发表于", "").trim();
            if (!visitedTids[tid]) visitedTids[tid] = {};
            visitedTids[tid].postDate = dateText;
            visitedTids[tid].visitedAt = visitedTids[tid].visitedAt || Date.now();
            saveVisited();
        }
    }

    // 在论坛页
    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)) {
        const tid = getTidFromUrl(location.href);
        if (tid) {
            if (!visitedTids[tid]) visitedTids[tid] = {};
            visitedTids[tid].visitedAt = Date.now();
            saveVisited();
            extractPostTime();
        }
    }
})();