NodeSeek & DeepFlood 双边会晤

在NodeSeek和DeepFlood之间阅读对方站点的帖子

Asenna tämä skripti?
Author's suggested script

Saatat myös pitää

Asenna tämä skripti
// ==UserScript==
// @name         NodeSeek & DeepFlood 双边会晤
// @namespace    http://www.nodeseek.com/
// @version      1.0.4
// @description  在NodeSeek和DeepFlood之间阅读对方站点的帖子
// @author       dabao
// @match        *://www.nodeseek.com/*
// @match        *://www.deepflood.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @connect      www.nodeseek.com
// @connect      www.deepflood.com
// @run-at       document-end
// @license      GPL-3.0
// ==/UserScript==

(function() {
    'use strict';

    // ========== 配置模块 ==========
    const Config = {
        // 调试配置
        DEBUG: false, // 设置为 true 启用调试日志
        // 站点配置
        SITES: {
            NODESEEK: { name: 'NodeSeek', url: 'https://www.nodeseek.com', hostname: 'www.nodeseek.com' },
            DEEPFLOOD: { name: 'DeepFlood', url: 'https://www.deepflood.com', hostname: 'www.deepflood.com' }
        },

        // 路径匹配规则
        ALLOWED_PATHS: [
            /^\/$/,
            /^\?sortBy=/,
            /^\/page-\d+/,
            /^\/post-\d+/,
            /^\/categories\//,
            /^\/award/
        ],

        // DOM 选择器
        SELECTORS: {
            // 目标站点需要移除的元素
            REMOVE_ELEMENTS: 'body > header, body > footer, #nsk-left-panel-container, #nsk-right-panel-container',
            // iframe 内容选择器
            POST_LIST: 'ul.post-list',
            PAGER_TOP: 'div.nsk-pager.pager-top',
            PAGER_BOTTOM: 'div.nsk-pager.pager-bottom',
            SORTER: 'div.sorter',
            SORTER_LINKS: 'a[data-sort]',
            LINKS: 'a[href]'
        },

        // 时间配置
        TIMING: {
            INITIAL_LOAD_DELAY: 1000,           // 初始加载延迟
            NOTIFICATION_INIT_DELAY: 2000,       // 通知初始化延迟
            NOTIFICATION_INTERVAL: 5000,         // 通知轮询间隔
            BLOB_REVOKE_DELAY: 1000,            // Blob URL 释放延迟
            SCROLL_THROTTLE: 200,               // 滚动节流
            SCROLL_DEBOUNCE: 300                // 滚动防抖
        },

        // 滚动配置
        SCROLL: {
            THRESHOLD: 690,                     // 触发加载的距离阈值
            INITIAL_PAGE: 2                     // 初始分页页码
        },

        // 样式配置
        STYLES: {
            MODAL_WIDTH: '400px',
            MODAL_TOP: '40px',
            MODAL_OPACITY: 0.6,
            MODAL_OPACITY_HOVER: 1,
            HEADER_HEIGHT: '49px',
            COLLAPSED_HEIGHT: 'auto'  // 折叠后的高度(只显示标题栏)
        },

        // 折叠配置
        COLLAPSE: {
            STORAGE_KEY: 'dual_site_modal_collapsed',  // localStorage 键名
            DEFAULT_STATE: false  // 默认是否折叠
        }
    };

    // ========== 日志系统 ==========
    const Logger = {
        log(...args) {
            if (Config.DEBUG) {
                console.log('[双边会晤]', ...args);
            }
        },
        warn(...args) {
            if (Config.DEBUG) {
                console.warn('[双边会晤]', ...args);
            }
        },
        error(...args) {
            console.error('[双边会晤]', ...args); // 错误始终输出
        },
        info(...args) {
            if (Config.DEBUG) {
                console.info('[双边会晤]', ...args);
            }
        }
    };

    // ========== 工具模块 ==========
    const Utils = {
        // 节流函数
        throttle(fn, delay) {
            let last = 0;
            return (...args) => {
                const now = Date.now();
                if (now - last >= delay) {
                    last = now;
                    fn(...args);
                }
            };
        },

        // 防抖函数
        debounce(fn, delay) {
            let timer;
            return (...args) => {
                clearTimeout(timer);
                timer = setTimeout(() => fn(...args), delay);
            };
        },

        // 封装 GM_xmlhttpRequest 为 Promise
        request(options) {
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: options.method || 'GET',
                    url: options.url,
                    onload: (response) => resolve(response),
                    onerror: (error) => reject(error)
                });
            });
        },

        // 解析 HTML 字符串
        parseHTML(htmlString) {
            const parser = new DOMParser();
            return parser.parseFromString(htmlString, "text/html");
        },

        // 在文档中注入 base 标签
        injectBase(doc, baseUrl) {
            const base = doc.createElement("base");
            base.href = baseUrl;
            const head = doc.querySelector("head") || doc.documentElement;
            head.insertBefore(base, head.firstChild);
        },

        // 设置所有链接在新标签页打开
        setLinksTarget(doc) {
            try {
                doc.querySelectorAll(Config.SELECTORS.LINKS).forEach(a => a.target = '_blank');
            } catch (e) {
                Logger.error('无法修改 iframe 内部链接:', e);
            }
        },

        // 移除指定元素
        removeElements(doc, selector) {
            try {
                doc.querySelectorAll(selector).forEach(el => el.remove());
            } catch (e) {
                Logger.error('移除元素失败:', e);
            }
        }
    };

    // BroadcastManager 类用于多标签页同步
    class BroadcastManager {
        static instances = new Map();

        constructor(channelName = "nsx_channel") {
            if (BroadcastManager.instances.has(channelName)) {
                return BroadcastManager.instances.get(channelName);
            }

            this.channelName = channelName;
            this.myId = `${Date.now()}-${Math.random()}`;
            this.receivers = [];
            this.ch = new BroadcastChannel(channelName);
            this.KEY = `only_last_tab_${channelName}`;
            this.active = false;

            this._init();
            BroadcastManager.instances.set(channelName, this);
        }

        // 初始化(私有方法)
        _init() {
            // 广播接收
            this.ch.onmessage = e => this._notify(e.data);

            // 主控权管理
            localStorage.setItem(this.KEY, this.myId);
            this._updateActive();

            // 事件监听
            addEventListener("storage", e => this._handleStorage(e));
            addEventListener("beforeunload", () => this._handleUnload());
        }

        // 处理存储事件(私有方法)
        _handleStorage(e) {
            if (e.key === this.KEY) {
                if (!e.newValue) {
                    localStorage.setItem(this.KEY, this.myId);
                }
                this._updateActive();
            }
        }

        // 处理卸载事件(私有方法)
        _handleUnload() {
            if (this.active) {
                localStorage.removeItem(this.KEY);
            }
        }

        // 更新主控状态(私有方法)
        _updateActive() {
            this.active = localStorage.getItem(this.KEY) === this.myId;
            Logger.log(`标签页 ${this.myId} 主控状态:`, this.active);
        }

        // 通知所有接收器(私有方法)
        _notify(data) {
            this.receivers.forEach(fn => {
                try {
                    fn(data);
                } catch (err) {
                    Logger.error('接收器执行错误:', err);
                }
            });
        }

        // 注册接收器(公开方法)
        registerReceiver(fn) {
            if (typeof fn === 'function') {
                this.receivers.push(fn);
                Logger.log('注册接收器,当前接收器数量:', this.receivers.length);
            } else {
                Logger.warn('注册接收器失败:参数不是函数');
            }
        }

        // 广播消息(公开方法)
        broadcast(data) {
            const message = { sender: this.myId, data };
            this.ch.postMessage(message);
            this._notify(message);
        }

        // 启动定时任务(公开方法)
        startTask(taskFn, interval) {
            Logger.log(`启动定时任务,间隔: ${interval}ms`);
            setInterval(async () => {
                if (!this.active) return;

                try {
                    const result = await taskFn();
                    this.broadcast(result);
                } catch (err) {
                    Logger.error('定时任务执行错误:', err);
                }
            }, interval);
        }
    }

    // ========== UI 管理器 ==========
    class UIManager {
        constructor(targetSite) {
            this.targetSite = targetSite;
            this.elements = null;
            this.isCollapsed = this._loadCollapseState(); // 加载折叠状态
            this._createModal(); // 自动初始化
            Logger.log('UI 管理器初始化完成,折叠状态:', this.isCollapsed);
        }

        // 加载折叠状态(私有方法)
        _loadCollapseState() {
            const stored = localStorage.getItem(Config.COLLAPSE.STORAGE_KEY);
            return stored !== null ? stored === 'true' : Config.COLLAPSE.DEFAULT_STATE;
        }

        // 保存折叠状态(私有方法)
        _saveCollapseState() {
            localStorage.setItem(Config.COLLAPSE.STORAGE_KEY, this.isCollapsed.toString());
            Logger.log('保存折叠状态:', this.isCollapsed);
        }

        // 创建浮动面板(私有方法)
        _createModal() {
            const modal = document.createElement('div');
            modal.id = 'dual-site-modal';
            modal.innerHTML = `
                <div id="dual-site-header">
                    <div id="dual-site-header-left">
                        <button id="dual-site-toggle" class="btn-toggle" title="折叠/展开">
                            <span class="toggle-icon">${this.isCollapsed ? '▼' : '▲'}</span>
                        </button>
                        <h3 id="dual-site-title">
                            <a href="${this.targetSite.url}" target="_blank">${this.targetSite.name}</a>
                            <div id="dual-site-notifications">
                                <span title="未读通知">
                                    <a href="${this.targetSite.url}/notification" target="_blank"><span class="notify-badge zero" id="notify-all">0</span></a>
                                </span>
                            </div>
                        </h3>
                    </div>
                    <button id="dual-site-refresh" class="btn">刷新</button>
                </div>
                <iframe id="dual-site-iframe" style="display:none"></iframe>
                <div id="dual-site-loading">加载中...</div>
            `;
            document.body.appendChild(modal);

            this.elements = {
                modal,
                iframe: modal.querySelector('#dual-site-iframe'),
                loading: modal.querySelector('#dual-site-loading'),
                refreshBtn: modal.querySelector('#dual-site-refresh'),
                toggleBtn: modal.querySelector('#dual-site-toggle'),
                toggleIcon: modal.querySelector('.toggle-icon')
            };

            // 绑定折叠按钮事件
            this.elements.toggleBtn.addEventListener('click', () => this.toggleCollapse());

            // 应用初始折叠状态
            this._applyCollapseState();

            return this.elements;
        }

        // 显示加载状态
        showLoading() {
            if (!this.elements) return;
            this.elements.loading.style.display = 'block';
            this.elements.iframe.style.display = 'none';
            this.elements.refreshBtn.disabled = true;
            this.elements.refreshBtn.textContent = '加载中...';
        }

        // 隐藏加载状态
        hideLoading() {
            if (!this.elements) return;
            this.elements.loading.style.display = 'none';
            this.elements.iframe.style.display = 'block';
            this.elements.refreshBtn.disabled = false;
            this.elements.refreshBtn.textContent = '刷新';
        }

        // 显示错误状态
        showError(message = '加载失败') {
            if (!this.elements) return;
            this.elements.loading.innerHTML = `<span style="color: #dc3545;">${message}</span>`;
            this.elements.refreshBtn.disabled = false;
            this.elements.refreshBtn.textContent = '重试';
        }

        // 应用折叠状态(私有方法)
        _applyCollapseState() {
            if (this.isCollapsed) {
                this.elements.modal.classList.add('collapsed');
                this.elements.toggleIcon.textContent = '▼';  // 折叠状态:向下箭头(点击展开)
            } else {
                this.elements.modal.classList.remove('collapsed');
                this.elements.toggleIcon.textContent = '▲';  // 展开状态:向上箭头(点击折叠)
            }
        }

        // 切换折叠状态
        toggleCollapse() {
            this.isCollapsed = !this.isCollapsed;
            this._applyCollapseState();
            this._saveCollapseState();
            Logger.log('切换折叠状态:', this.isCollapsed);
        }

        // 更新通知徽章
        updateNotificationBadge(count) {
            const badge = this.elements.modal.querySelector('#notify-all');
            if (badge) {
                badge.textContent = count;
                badge.classList.toggle('zero', count === 0);
            }
        }
    }

    // ========== 内容加载器 ==========
    class ContentLoader {
        constructor(targetSite, uiManager) {
            this.targetSite = targetSite;
            this.uiManager = uiManager;
        }

        // 加载页面内容
        async loadPage(sortBy = '') {
            this.uiManager.showLoading();

            const url = `${this.targetSite.url}${sortBy?.trim() ? `?sortBy=${sortBy}` : ''}`;

            try {
                const response = await Utils.request({ method: 'GET', url });
                const doc = Utils.parseHTML(response.responseText);

                // 处理文档
                this.processDocument(doc);

                // 创建 Blob URL 并加载到 iframe
                const htmlStr = '<!DOCTYPE html>\n' + doc.documentElement.outerHTML;
                const blob = new Blob([htmlStr], { type: "text/html" });
                const blobUrl = URL.createObjectURL(blob);

                const iframe = this.uiManager.elements.iframe;
                iframe.src = blobUrl;

                iframe.onload = () => {
                    this.uiManager.hideLoading();
                    this.setupIframeContent(iframe.contentDocument);
                    setTimeout(() => URL.revokeObjectURL(blobUrl), Config.TIMING.BLOB_REVOKE_DELAY);
                };
            } catch (error) {
                Logger.error('加载页面失败:', error);
                this.uiManager.showError();
            }
        }

        // 处理文档内容
        processDocument(doc) {
            // 注入 base 标签
            Utils.injectBase(doc, `${this.targetSite.url}/`);

            // 移除不需要的元素
            Utils.removeElements(doc, Config.SELECTORS.REMOVE_ELEMENTS);
        }

        // 设置 iframe 内容
        setupIframeContent(doc) {
            // 设置链接在新标签页打开
            Utils.setLinksTarget(doc);

            // 附加排序处理器
            this.attachSorterHandlers(doc);

            // 设置无限滚动
            this.setupInfiniteScroll(doc);
        }

        // 附加排序处理器
        attachSorterHandlers(doc) {
            const sorter = doc.querySelector(Config.SELECTORS.SORTER);
            if (!sorter) return;

            sorter.querySelectorAll(Config.SELECTORS.SORTER_LINKS).forEach(a => {
                a.addEventListener('click', e => {
                    e.preventDefault();
                    e.stopImmediatePropagation();
                    const sortBy = a.dataset.sort;
                    this.loadPage(sortBy);
                }, true);
            });
        }

        // 设置无限滚动
        setupInfiniteScroll(doc) {
            const postList = doc.querySelector(Config.SELECTORS.POST_LIST);
            const topPager = doc.querySelector(Config.SELECTORS.PAGER_TOP);
            const bottomPager = doc.querySelector(Config.SELECTORS.PAGER_BOTTOM);

            if (!postList) {
                Logger.warn('未找到帖子列表,无法启用无限滚动');
                return;
            }

            // 创建分页管理器
            const paginationManager = new PaginationManager(
                this.targetSite,
                postList,
                { top: topPager, bottom: bottomPager },
                doc
            );

            // 初始化分页
            paginationManager.init();
        }
    }

    // ========== 分页管理器 ==========
    class PaginationManager {
        constructor(targetSite, postList, pagers, doc) {
            this.targetSite = targetSite;
            this.postList = postList;
            this.topPager = pagers.top;
            this.bottomPager = pagers.bottom;
            this.doc = doc;
            this.page = Config.SCROLL.INITIAL_PAGE;
            this.isLoading = false;
            Logger.log('分页管理器初始化完成');
        }

        // 初始化滚动监听
        init() {
            const throttledCheck = Utils.throttle(
                () => this._checkShouldLoad(),
                Config.TIMING.SCROLL_THROTTLE
            );
            const debouncedCheck = Utils.debounce(
                () => this._checkShouldLoad(),
                Config.TIMING.SCROLL_DEBOUNCE
            );

            this.doc.addEventListener('scroll', () => {
                throttledCheck();
                debouncedCheck();
            });

            Logger.log('分页滚动监听已启动');
        }

        // 检查是否应该加载更多(私有方法)
        _checkShouldLoad() {
            const { scrollTop, clientHeight, scrollHeight } = this.doc.documentElement;
            if (scrollTop + clientHeight >= scrollHeight - Config.SCROLL.THRESHOLD) {
                this._loadMore();
            }
        }

        // 加载更多内容(私有方法)
        async _loadMore() {
            if (this.isLoading) return;

            this.isLoading = true;
            Logger.log(`开始加载第 ${this.page} 页`);

            try {
                const res = await Utils.request({
                    method: 'GET',
                    url: `${this.targetSite.url}/page-${this.page}`
                });

                const newDoc = Utils.parseHTML(res.responseText);
                Utils.setLinksTarget(newDoc);

                const newList = newDoc.querySelector(Config.SELECTORS.POST_LIST);
                const newTopPager = newDoc.querySelector(Config.SELECTORS.PAGER_TOP);
                const newBottomPager = newDoc.querySelector(Config.SELECTORS.PAGER_BOTTOM);

                if (newList && newList.children.length > 0) {
                    // 追加新内容
                    Array.from(newList.children).forEach(li => this.postList.appendChild(li));

                    // 更新分页器
                    if (newTopPager && this.topPager) {
                        this.topPager.innerHTML = newTopPager.innerHTML;
                    }
                    if (newBottomPager && this.bottomPager) {
                        this.bottomPager.innerHTML = newBottomPager.innerHTML;
                    }

                    this.page++;
                    Logger.log(`第 ${this.page - 1} 页加载成功,新增 ${newList.children.length} 项`);
                } else {
                    Logger.log('没有更多内容了');
                }
            } catch (error) {
                Logger.error('分页加载失败:', error);
            } finally {
                this.isLoading = false;
            }
        }
    }

    // ========== 通知同步器 ==========
    class NotificationSync {
        constructor(baseUrl, uiManager) {
            this.baseUrl = baseUrl;
            this.uiManager = uiManager;
            this.broadcastManager = null;
        }

        // 初始化通知同步
        init() {
            this.broadcastManager = new BroadcastManager("ns_df_notification");

            // 注册数据接收器
            this.broadcastManager.registerReceiver(({ data }) => {
                if (data.type === 'unreadCount' && data.counts) {
                    Logger.log('接收到通知数据:', data.counts);
                    this.uiManager.updateNotificationBadge(data.counts.all || 0);
                }
            });

            // 启动定时任务
            this.broadcastManager.startTask(async () => {
                return await this.fetchNotifications();
            }, Config.TIMING.NOTIFICATION_INTERVAL);
        }

        // 获取通知数据
        async fetchNotifications() {
            try {
                const response = await Utils.request({
                    method: 'GET',
                    url: `${this.baseUrl}/api/notification/unread-count`
                });

                if (response.status !== 200) {
                    throw new Error(`HTTP ${response.status}`);
                }

                const data = JSON.parse(response.responseText);
                if (data.success && data.unreadCount) {
                    Logger.log('获取到新通知数据:', data.unreadCount);
                    return {
                        type: 'unreadCount',
                        counts: data.unreadCount,
                        timestamp: Date.now()
                    };
                } else {
                    throw new Error('Invalid response');
                }
            } catch (err) {
                Logger.error('获取通知失败:', err);
                throw err;
            }
        }
    }

    // ========== 样式生成器 ==========
    const generateStyles = () => {
        const { MODAL_WIDTH, MODAL_TOP, MODAL_OPACITY, MODAL_OPACITY_HOVER, HEADER_HEIGHT, COLLAPSED_HEIGHT } = Config.STYLES;
        return `
            #dual-site-modal {
                position: fixed;
                top: ${MODAL_TOP};
                right: 0;
                bottom: 0;
                width: ${MODAL_WIDTH};
                background: #fff;
                border: 1px solid #ddd;
                border-radius: 8px 0 0 0;
                box-shadow: 0 4px 12px rgba(0,0,0,0.15);
                opacity: ${MODAL_OPACITY};
                transition: opacity 0.3s ease, height 0.3s ease, bottom 0.3s ease;
                z-index: 10000;
                font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
                display: flex;
                flex-direction: column;
            }
            #dual-site-modal:hover { opacity: ${MODAL_OPACITY_HOVER}; }
            #dual-site-modal.collapsed {
                height: ${COLLAPSED_HEIGHT};
                bottom: auto;
            }
            #dual-site-modal.collapsed #dual-site-iframe { display: none !important; }
            #dual-site-modal.collapsed #dual-site-loading { display: none !important; }
            #dual-site-header { padding: 8px 16px; background: #f8f9fa; border-bottom: 1px solid #eee; border-radius: 8px 0 0 0; display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; }
            #dual-site-header-left { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
            #dual-site-title { font-weight: 600; font-size: 14px; color: #333; margin: 0; display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
            #dual-site-notifications { display: flex; gap: 10px; font-size: 12px; }
            #dual-site-notifications > span { display: flex; align-items: center; gap: 4px; color: #666; }
            #dual-site-notifications .notify-badge { background: #ff4444; color: #fff; padding: 1px 6px; border-radius: 10px; font-weight: 600; min-width: 18px; text-align: center; }
            #dual-site-notifications .notify-badge.zero { background: #ccc; }
            .btn-toggle { background: none; border: none; cursor: pointer; padding: 4px 8px; font-size: 14px; color: #666; transition: color 0.2s; }
            .btn-toggle:hover { color: #333; background: #e9ecef; border-radius: 4px; }
            .toggle-icon { display: inline-block; line-height: 1; }
            #dual-site-refresh:disabled { background: #6c757d; cursor: not-allowed; }
            #dual-site-iframe { width: 100%; height: calc(100% - ${HEADER_HEIGHT}); border: none; overflow: hidden; flex: 1; }
            #dual-site-loading { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #666; font-size: 14px; }
            #fast-nav-button-group { right: calc(50% - 540px) !important; }
        `;
    };

    // ========== 主应用 ==========
    class Application {
        constructor() {
            this.targetSite = null;
            this.uiManager = null;
            this.contentLoader = null;
            this.notificationSync = null;
        }

        // 检查路径是否匹配
        checkPathMatch() {
            const path = window.location.pathname + window.location.search;
            return Config.ALLOWED_PATHS.some(re => re.test(path));
        }

        // 确定目标站点
        determineTargetSite() {
            const currentHost = window.location.hostname;
            return currentHost === Config.SITES.NODESEEK.hostname
                ? Config.SITES.DEEPFLOOD
                : Config.SITES.NODESEEK;
        }

        // 注入样式
        injectStyles() {
            GM_addStyle(generateStyles());
        }

        // 初始化组件
        initializeComponents() {
            // 创建 UI 管理器(自动创建模态框)
            this.uiManager = new UIManager(this.targetSite);

            // 创建内容加载器
            this.contentLoader = new ContentLoader(this.targetSite, this.uiManager);

            // 绑定刷新按钮事件
            this.uiManager.elements.refreshBtn.addEventListener('click', () => this.contentLoader.loadPage());

            // 延迟初始加载
            setTimeout(() => this.contentLoader.loadPage(), Config.TIMING.INITIAL_LOAD_DELAY);

            // 创建通知同步器并延迟初始化
            this.notificationSync = new NotificationSync(this.targetSite.url, this.uiManager);
            setTimeout(() => this.notificationSync.init(), Config.TIMING.NOTIFICATION_INIT_DELAY);
        }

        // 启动应用
        init() {
            // 检查路径是否匹配
            if (!this.checkPathMatch()) {
                Logger.info('当前路径不匹配,脚本不执行');
                return;
            }

            // 确定目标站点
            this.targetSite = this.determineTargetSite();
            Logger.info(`当前站点: ${window.location.hostname}, 目标站点: ${this.targetSite.name}`);

            // 注入样式
            this.injectStyles();

            // 初始化组件
            this.initializeComponents();
        }
    }

    // ========== 启动应用 ==========
    const app = new Application();
    app.init();
})();