Cubox助手

在Cubox左上角添加一个随机漫游的按钮(也可使用Ctrl+R触发)

// ==UserScript==
// @name         Cubox助手
// @author       岚浅浅
// @description  在Cubox左上角添加一个随机漫游的按钮(也可使用Ctrl+R触发)
// @namespace    http://tampermonkey.net/
// @version      2.0.2
// @include      *cubox.pro/*
// @exclude      *image.cubox.pro/*
// @grant        GM_addStyle
// @license      GPL-3.0 License
// @require      http://code.jquery.com/jquery-3.6.0.min.js
// ==/UserScript==

$(function () {
    'use strict';

    const HOST = location.hostname;
    const API_ENDPOINTS = {
        INBOX: `https://${HOST}/c/api/v2/search_engine/inbox`,
        MY: `https://${HOST}/c/api/v2/search_engine/my`
    };

    const MAX_VISITED_CARDS = 1000;
    const PAGE_CACHE_DURATION = 60 * 1000; // 1分钟
    const RETRY_COUNT = 10;

    const REQUEST_INTERVAL = 100;
    const REQUEST_TIMEOUT = 10000;

    const POPUP_DURATION = 3000;
    const FADE_DURATION = 300;

    class CacheManager {
        constructor() {
            this.initialize();
        }

        initialize() {
            this.token = this.loadToken();
            this.pageCache = this.loadPageCache();
            this.visitedCards = this.loadVisitedCards();
        }

        loadToken() {
            return localStorage.getItem('token');
        }

        loadApiSearchUrl() {
            const apiSearchUrl = localStorage.getItem('cuboxAssistant_apiSearchUrl');
            if (!apiSearchUrl) {
                console.log(location.href);
                throw new Error('URL信息缺失');
            }
            return apiSearchUrl;
        }

        loadPageCache() {
            const storedCacheJson = localStorage.getItem('cuboxAssistant_pageCache');
            if (!storedCacheJson) return {};

            const storedCache = JSON.parse(storedCacheJson);
            const now = Date.now();

            // 清理过期缓存
            const validCache = Object.fromEntries(
                Object.entries(storedCache).filter(([, value]) =>
                    now <= value.expirationTime
                )
            );

            if (Object.keys(validCache).length !== Object.keys(storedCache).length) {
                this.setPageCache(validCache);
            }

            return validCache;
        }

        loadVisitedCards() {
            const ids = localStorage.getItem('cuboxAssistant_visitedCards');
            return ids ? ids.split(',') : [];
        }

        getToken() {
            return this.token;
        }

        getVisitedCards() {
            return this.visitedCards;
        }

        setApiSearchUrl(url) {
            localStorage.setItem('cuboxAssistant_apiSearchUrl', url);
        }

        setPageCache(cache = this.pageCache) {
            localStorage.setItem('cuboxAssistant_pageCache', JSON.stringify(cache));
        }

        setVisitedCards(cards = this.visitedCards) {
            if (cards.length > MAX_VISITED_CARDS) {
                cards.splice(0, cards.length - MAX_VISITED_CARDS);
            }
            localStorage.setItem('cuboxAssistant_visitedCards', cards.join(','));
        }

        cacheSinglePage(apiSearchUrl, pageCount, page, lastBookmarkId) {
            const key = `${Utils.hash(apiSearchUrl)}_${page}`;
            this.pageCache[key] = {
                lastBookmarkId,
                expirationTime: Date.now() + pageCount * PAGE_CACHE_DURATION
            };
            this.setPageCache();
        }

        markCardAsVisited(cardId) {
            this.visitedCards.push(cardId);
            this.setVisitedCards();
        }

        findLatestPage(apiSearchUrl, targetPage) {
            const hashPrefix = Utils.hash(apiSearchUrl);
            let bestJump = { page: 1, lastBookmarkId: '' };

            Object.entries(this.pageCache).forEach(([key, value]) => {
                if (key.startsWith(hashPrefix)) {
                    const page = parseInt(key.split('_')[1]);
                    if (page < targetPage && page > bestJump.page) {
                        bestJump = { page, lastBookmarkId: value.lastBookmarkId };
                    }
                }
            });

            return bestJump;
        }

        clear() {
            this.setPageCache({});
            this.setVisitedCards([]);
            this.initialize();
        }
    }

    class CuboxAssistant {
        constructor() {
            StyleManager.initialize();
            this.bindEvents();
            this.initialize();
        }

        bindEvents() {
            const handleRandom = (e) => {
                e.preventDefault();
                this.startRandomNavigation();
            };

            $(document).on('click', '#cubox-start-btn', handleRandom);

            document.addEventListener('keydown', (event) => {
                if (event.ctrlKey && event.key === 'r') handleRandom(event);
            });

            // URL变化监听
            new MutationObserver(() => this.initialize())
                .observe(document, { subtree: true, childList: true });
        }

        async initialize() {
            if (this.currentUrl === location.href) {
                return;
            }

            this.currentUrl = location.href;
            StyleManager.updatePosition(this.currentUrl);

            const currentApiSearchUrl = Utils.matchApiSearchUrl(this.currentUrl) || cacheManager.loadApiSearchUrl();
            if (currentApiSearchUrl && currentApiSearchUrl !== this.apiSearchUrl) {
                this.apiSearchUrl = currentApiSearchUrl;
                cacheManager.setApiSearchUrl(currentApiSearchUrl);
                await this.preloadPageCount();
                await this.preloadPageCache();
            }
        }

        async startRandomNavigation() {
            StyleManager.updateState(true);

            try {
                let isSuccess = false;
                for (let i = 0; i < RETRY_COUNT && !isSuccess; i++) {
                    const randomPage = Math.floor(Math.random() * this.pageCount) + 1;
                    const response = await this.requestPageWithCache(randomPage);
                    isSuccess = this.navigateToRandomCard(response.data);
                }

                if (!isSuccess) {
                    cacheManager.clear();
                    StyleManager.showPopup('缓存过多,已自动清理,请重试');
                }
            } catch (error) {
                console.log(error);
                StyleManager.showPopup(`操作失败: ${error.message}`);
            } finally {
                StyleManager.updateState(false);
            }
        }

        async preloadPageCount() {
            const response = await Utils.safeRequest(this.apiSearchUrl);
            this.pageCount = response.pageCount || 1;
            console.log(`[Cubox助手] 共${this.pageCount}页`);

            const lastBookmarkId = Utils.extractLastBookmarkId(response);
            cacheManager.cacheSinglePage(this.apiSearchUrl, this.pageCount, 1, lastBookmarkId);
        }

        async preloadPageCache() {
            const missingPages = [];
            const hashPrefix = Utils.hash(this.apiSearchUrl);

            for (let page = 2; page <= this.pageCount; page++) {
                if (!cacheManager.pageCache[`${hashPrefix}_${page}`]) {
                    missingPages.push(page);
                }
            }
            console.log(`[Cubox助手] ${missingPages.length}页需要预加载`);

            for (const page of missingPages) {
                await this.requestPageWithCache(page);
                await new Promise(resolve => setTimeout(resolve, REQUEST_INTERVAL));
            }
        }

        async requestPageWithCache(targetPage) {
            if (targetPage === 1) {
                return await Utils.safeRequest(this.apiSearchUrl);
            }
            let { page, lastBookmarkId } = cacheManager.findLatestPage(this.apiSearchUrl, targetPage);
            let response;
            page++;
            while (page <= targetPage) {
                const url = `${this.apiSearchUrl.replace('page=1', `page=${page}`)}${lastBookmarkId ? `&lastBookmarkId=${lastBookmarkId}` : ''}`;
                response = await Utils.safeRequest(url);
                lastBookmarkId = Utils.extractLastBookmarkId(response);
                cacheManager.cacheSinglePage(this.apiSearchUrl, this.pageCount, page, lastBookmarkId);
                console.info(`[Cubox助手] 加载页面${page}成功`);
                page++;
            }
            return response;
        }

        navigateToRandomCard(cards) {
            const cardIds = cards.map(item => item.userSearchEngineID);
            const visitedIds = cacheManager.getVisitedCards();
            const unvisitedIds = cardIds.filter(id => !visitedIds.includes(id));

            if (unvisitedIds.length === 0) {
                return false;
            }

            const cardId = unvisitedIds[Math.floor(Math.random() * unvisitedIds.length)];
            cacheManager.markCardAsVisited(cardId);
            window.open(`https://${HOST}/my/card?id=${cardId}`, '_self');
            return true;
        }
    }

    class StyleManager {
        static initialize() {
            $('body').append(`
                <div id="cubox-assistant-toolbar">
                    <button id="cubox-start-btn" type="button">随机漫游</button>
                </div>
            `);
            GM_addStyle(`
                #cubox-assistant-toolbar {
                    position: fixed;
                    z-index: 999999;
                    width: 120px;
                    opacity: 0.8;
                    border: 1px solid #a38a54;
                    border-radius: 6px;
                    background: rgba(255, 255, 255, 0.95);
                    backdrop-filter: blur(10px);
                    box-shadow: 0 2px 12px rgba(0,0,0,0.15);
                    transition: all 0.3s ease;
                    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
                }
                #cubox-assistant-toolbar:hover { opacity: 1; transform: translateY(-1px); }
                #cubox-start-btn {
                    display: block;
                    width: 100%;
                    padding: 8px 12px;
                    margin: 0;
                    border: none;
                    background: transparent;
                    color: #a38a54;
                    text-align: center;
                    font-size: 13px;
                    font-weight: 500;
                    cursor: pointer;
                    border-radius: 4px;
                    transition: all 0.2s ease;
                }
                #cubox-start-btn:hover:not(:disabled) { background: rgba(163, 138, 84, 0.1); }
                #cubox-start-btn:disabled { opacity: 0.6; cursor: not-allowed; }
                .cubox-popup {
                    position: fixed;
                    top: 50px;
                    left: 50%;
                    transform: translate(-50%, -50%);
                    padding: 20px 30px;
                    background: rgba(255, 255, 255, 0.95);
                    backdrop-filter: blur(10px);
                    color: #333;
                    font-size: 16px;
                    border-radius: 8px;
                    box-shadow: 0 8px 32px rgba(0,0,0,0.2);
                    z-index: 1000000;
                    max-width: 400px;
                    text-align: center;
                    animation: fadeInScale 0.3s ease;
                }
                @keyframes fadeInScale {
                    from { opacity: 0; transform: translate(-50%, -50%) scale(0.9); }
                    to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
                }
            `);
        }

        static updatePosition(currentUrl) {
            const isCardPage = /.*cubox\.pro\/my\/card\?id=(\d+)/.test(currentUrl);
            const top = isCardPage ? 10 : 20;
            const left = isCardPage ? 120 : 180;

            GM_addStyle(`#cubox-assistant-toolbar { top: ${top}px; left: ${left}px; }`);
        }

        static updateState(isLoading) {
            $('#cubox-start-btn').text(isLoading ? '加载中...' : '随机漫游').prop('disabled', isLoading);
        }

        static showPopup(message) {
            $('.cubox-popup').remove();
            const popup = $(`<div class="cubox-popup">${$('<div>').text(message).html()}</div>`);
            $('body').append(popup);
            setTimeout(() => popup.fadeOut(FADE_DURATION, () => popup.remove()), POPUP_DURATION);
        }
    }

    class Utils {
        static matchApiSearchUrl(url) {
            const baseParams = 'asc=false&page=1&filters=&archiving=false';
            if (url.includes('cubox.pro/my/inbox')) {
                return `${API_ENDPOINTS.INBOX}?${baseParams}`;
            }
            if (url.includes('cubox.pro/my/all')) {
                return `${API_ENDPOINTS.MY}?${baseParams}`;
            }
            if (url.includes('cubox.pro/my/folder?id=')) {
                const folderId = url.match(/id=([^&]*)/)?.[1];
                if (folderId) {
                    return `${API_ENDPOINTS.MY}?${baseParams}&groupId=${folderId}`;
                }
            }
            if (url.includes('cubox.pro/my/tag?id=')) {
                const tagId = url.match(/id=([^&]*)/)?.[1];
                if (tagId) {
                    return `${API_ENDPOINTS.MY}?${baseParams}&tagId=${tagId}`;
                }
            }
        }

        static hash(str) {
            let hash = 0;
            for (let i = 0; i < str.length; i++) {
                hash = ((hash << 5) - hash) + str.charCodeAt(i);
                hash = hash & hash;
            }
            return Math.abs(hash).toString(36);
        }

        static async safeRequest(url) {
            const response = await fetch(url, {
                headers: { 'Authorization': cacheManager.getToken() },
                signal: AbortSignal.timeout(REQUEST_TIMEOUT)
            });

            if (!response.ok) throw new Error(`HTTP ${response.status}`);
            return response.json();
        }

        static extractLastBookmarkId(response) {
            return response.data?.at(-1)?.userSearchEngineID
                || (() => { throw new Error('响应数据为空') })();
        }
    }

    const cacheManager = new CacheManager();
    new CuboxAssistant();
});