GitHub Stars Pagination

Add clickable page number navigation to GitHub Stars pages

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         GitHub Stars Pagination
// @name:zh-CN   GitHub Stars 分页导航
// @namespace    https://github.com/dyxang
// @version      0.4.3
// @description  Add clickable page number navigation to GitHub Stars pages
// @description:zh-CN  为 GitHub 标星页面在 Previous 和 Next 按钮之间添加可点击的页码导航
// @author       GitHub Community
// @match        https://github.com/*?tab=stars*
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @run-at       document-end
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';
    
    // 公共工具函数
    const utils = {
        // 安全的localStorage操作
        safeLocalStorage: {
            get: (key) => {
                try {
                    return localStorage.getItem(key);
                } catch (e) {
                    console.error('❌ Error getting from localStorage:', e);
                    return null;
                }
            },
            set: (key, value) => {
                try {
                    localStorage.setItem(key, value);
                } catch (e) {
                    console.error('❌ Error setting to localStorage:', e);
                }
            },
            remove: (key) => {
                try {
                    localStorage.removeItem(key);
                } catch (e) {
                    console.error('❌ Error removing from localStorage:', e);
                }
            }
        },
        
        // 安全的sessionStorage操作
        safeSessionStorage: {
            get: (key) => {
                try {
                    return sessionStorage.getItem(key);
                } catch (e) {
                    console.error('❌ Error getting from sessionStorage:', e);
                    return null;
                }
            },
            set: (key, value) => {
                try {
                    sessionStorage.setItem(key, value);
                } catch (e) {
                    console.error('❌ Error setting to sessionStorage:', e);
                }
            },
            remove: (key) => {
                try {
                    sessionStorage.removeItem(key);
                } catch (e) {
                    console.error('❌ Error removing from sessionStorage:', e);
                }
            }
        },
        
        // 查找DOM元素
        findElement: (selector, container = document) => {
            try {
                return container.querySelector(selector);
            } catch (e) {
                console.error('❌ Error finding element:', e);
                return null;
            }
        },
        
        // 查找多个DOM元素
        findElements: (selector, container = document) => {
            try {
                return Array.from(container.querySelectorAll(selector));
            } catch (e) {
                console.error('❌ Error finding elements:', e);
                return [];
            }
        },
        
        // 查找包含特定文本的元素
        findElementByText: (text, tags = ['button', 'a'], container = document) => {
            try {
                return utils.findElements(tags.join(','), container).find(el => 
                    el.textContent.trim() === text
                );
            } catch (e) {
                console.error('❌ Error finding element by text:', e);
                return null;
            }
        },
        
        // 安全的URL解析
        extractParamFromUrl: (url, param) => {
            try {
                const urlObj = new URL(url);
                return urlObj.searchParams.get(param);
            } catch (e) {
                return null;
            }
        },
        
        // 错误处理包装函数
        safe: (fn, fallback = null) => {
            try {
                return fn();
            } catch (e) {
                console.error('❌ Error in safe function:', e);
                return fallback;
            }
        }
    };

    let retryCount = 0;
    const MAX_RETRIES = 15;
    const RETRY_INTERVAL = 500;
    const STARS_PER_PAGE = 30;
    const MAX_PAGES = 15;
    const REQUEST_DELAY = 800;
    
    let isLoading = false;
    let pages = {};
    let username = '';
    let isUpdating = false;
    let currentPage = 1;
    let lastStarsContent = '';
    
    function getUsername() {
        if (username) return username;
        const pathParts = window.location.pathname.split('/');
        username = pathParts[1] || 'unknown';
        return username;
    }
    
    function getCacheKey() {
        return `githubStarsPages_${getUsername()}`;
    }
    
    function getSessionKey() {
        return `githubStarsCurrentPage_${getUsername()}`;
    }
    

    
    function clearCache() {
        utils.safeLocalStorage.remove(getCacheKey());
        utils.safeLocalStorage.remove(getCacheKey() + '_timestamp');
        pages = {};
    }
    
    function getCacheTimestampKey() {
        return getCacheKey() + '_timestamp';
    }
    
    function isCacheExpired() {
        return utils.safe(() => {
            const timestamp = utils.safeLocalStorage.get(getCacheTimestampKey());
            if (!timestamp) return true;
            
            const now = Date.now();
            const cacheTime = parseInt(timestamp, 10);
            const cacheAge = now - cacheTime;
            const HOUR = 60 * 60 * 1000;
            const CACHE_EXPIRY = 24 * HOUR; // 24小时缓存过期
            
            return cacheAge > CACHE_EXPIRY;
        }, true);
    }
    
    function storePages() {
        utils.safeLocalStorage.set(getCacheKey(), JSON.stringify(pages));
        utils.safeLocalStorage.set(getCacheTimestampKey(), Date.now().toString());
    }
    
    function loadPages() {
        utils.safe(() => {
            if (isCacheExpired()) {
                clearCache();
                return;
            }
            
            const stored = utils.safeLocalStorage.get(getCacheKey());
            if (stored) {
                pages = JSON.parse(stored);
            }
        });
        if (!pages) pages = {};
    }
    
    function clearSession() {
        utils.safeSessionStorage.remove(getSessionKey());
        currentPage = 1;
    }
    
    function loadCurrentPage() {
        utils.safe(() => {
            const stored = utils.safeSessionStorage.get(getSessionKey());
            if (stored) {
                currentPage = parseInt(stored, 10);
            }
        });
    }
    
    function storeCurrentPage(page) {
        utils.safeSessionStorage.set(getSessionKey(), page.toString());
        currentPage = page;
    }
    
    function getTotalStars() {
        return utils.safe(() => {
            const starsLink = utils.findElements('a').find(a => 
                a.textContent && a.textContent.includes('Stars') && !a.textContent.includes('Starred')
            );
            
            if (starsLink) {
                const text = starsLink.textContent;
                const numbers = text.match(/\d+/g);
                if (numbers && numbers.length > 0) {
                    const totalStars = parseInt(numbers[0].replace(/,/g, ''), 10);
                    return totalStars;
                }
            }
            
            const allText = document.body.textContent;
            const starMatches = allText.match(/Stars[^\d]*?(\d+(?:,\d+)*)/);
            if (starMatches && starMatches[1]) {
                const countStr = starMatches[1].replace(/,/g, '');
                const totalStars = parseInt(countStr, 10);
                return totalStars;
            }
            
            return null;
        }, null);
    }
    
    function calculateTotalPages(totalStars) {
        if (!totalStars || totalStars < 1) {
            return 0;
        }
        return Math.ceil(totalStars / STARS_PER_PAGE);
    }
    
    function findPaginationContainer() {
        return utils.safe(() => {
            const previousBtn = utils.findElementByText('Previous');
            const nextBtn = utils.findElementByText('Next');
            
            if (previousBtn) {
                let parent = previousBtn.parentElement;
                for (let i = 0; i < 5 && parent; i++) {
                    if (utils.findElement('button, a', parent)) {
                        return parent;
                    }
                    parent = parent.parentElement;
                }
                return previousBtn.parentElement;
            } else if (nextBtn) {
                let parent = nextBtn.parentElement;
                for (let i = 0; i < 5 && parent; i++) {
                    if (utils.findElement('button, a', parent)) {
                        return parent;
                    }
                    parent = parent.parentElement;
                }
                return nextBtn.parentElement;
            }
            
            return null;
        }, null);
    }
    
    function getCurrentPage() {
        return currentPage;
    }
    
    function extractAfterFromUrl(url) {
        return utils.extractParamFromUrl(url, 'after');
    }
    
    async function preloadPages() {
        if (isLoading) {

            return false;
        }
        
        isLoading = true;

        
        try {
            const currentUrl = window.location.href;
            let nextUrl = currentUrl;
            
            if (!pages['1']) {
                pages['1'] = '';
                storePages();
            }
            
            let loadedPages = Object.keys(pages).length;
            
            while (loadedPages < MAX_PAGES) {

                
                try {
                    const response = await fetch(nextUrl, {
                        method: 'GET',
                        credentials: 'include',
                        headers: {
                            'Accept': 'text/html'
                        }
                    });
                    
                    if (!response.ok) {
                        console.error('❌ Failed to fetch page:', response.status);
                        break;
                    }
                    
                    const html = await response.text();
                    const parser = new DOMParser();
                    const doc = parser.parseFromString(html, 'text/html');
                    
                    const nextBtn = utils.findElementByText('Next', ['button', 'a'], doc);
                    
                    if (!nextBtn) {

                        break;
                    }
                    
                    const after = extractAfterFromUrl(nextBtn.href);
                    if (after) {
                        loadedPages++;
                        pages[loadedPages] = after;
                        nextUrl = nextBtn.href;

                        storePages();
                    } else {

                        break;
                    }
                    
                    await new Promise(resolve => setTimeout(resolve, REQUEST_DELAY));
                    
                } catch (e) {
                    console.error('❌ Error preloading page:', e);
                    break;
                }
            }
            

            return true;
            
        } catch (e) {
            console.error('❌ Error in preloadPages:', e);
            return false;
        } finally {
            isLoading = false;
            updatePagination();
        }
    }
    
    function createLoadingIndicator() {
        const indicator = document.createElement('div');
        indicator.textContent = '正在加载页码...';
        indicator.style.cssText = `
            display: inline-flex;
            align-items: center;
            padding: 0 16px;
            color: var(--color-fg-muted, #57606a);
            font-size: 14px;
            font-weight: 500;
        `;
        return indicator;
    }
    
    function createPageButton(page, currentPage) {
        const btn = document.createElement('a');
        btn.textContent = page;
        
        if (page === 1) {
            btn.href = window.location.pathname + '?tab=stars';
        } else {
            const after = pages[page];
            if (after) {
                btn.href = window.location.pathname + `?tab=stars&after=${after}`;
            } else {
                btn.href = '#';
                btn.style.opacity = '0.5';
                btn.style.cursor = 'not-allowed';
            }
        }
        
        // 与原生按钮样式一致
        btn.style.cssText = `
            padding: 5px 16px;
            border-radius: 6px;
            text-decoration: none;
            font-size: 14px;
            font-weight: 500;
            font-family: "Mona Sans VF", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
            transition: all 0.15s ease;
            display: inline-flex;
            align-items: center;
            justify-content: center;
            height: 31.6px;
            min-width: 32px;
            margin: 0 4px;
            box-shadow: none;
            border: 1px solid transparent;
            box-sizing: border-box;
        `;
        
        if (page === currentPage) {
            // 活跃状态与原生按钮一致
            btn.style.background = 'var(--color-accent-emphasis, #0969da)';
            btn.style.color = 'white';
            btn.style.cursor = 'default';
        } else {
            // 非活跃状态与原生按钮一致
            btn.style.background = 'var(--color-bg-secondary, #f6f8fa)';
            btn.style.color = 'var(--color-fg-default, #1f2328)';
            btn.style.borderColor = 'var(--color-border-default, #d0d7de)';
            
            btn.addEventListener('mouseenter', function() {
                this.style.background = 'var(--color-bg-tertiary, #f3f4f6)';
                this.style.borderColor = 'var(--color-border-muted, #d0d7de)';
            });
            btn.addEventListener('mouseleave', function() {
                this.style.background = 'var(--color-bg-secondary, #f6f8fa)';
                this.style.borderColor = 'var(--color-border-default, #d0d7de)';
            });
            
            // 添加点击事件监听器,确保页码状态更新
            btn.addEventListener('click', function(e) {
                // 存储当前页码
                storeCurrentPage(page);
                // 延迟更新分页,等待AJAX加载完成
                setTimeout(updatePagination, 500);
            });
        }
        
        return btn;
    }
    
    function createEllipsis() {
        const ellipsis = document.createElement('span');
        ellipsis.textContent = '...';
        ellipsis.style.cssText = `
            padding: 0 10px;
            color: var(--color-fg-muted, #57606a);
            font-size: 14px;
            font-weight: 500;
        `;
        return ellipsis;
    }
    
    function createRefreshButton() {
        const btn = document.createElement('button');
        btn.textContent = '刷新缓存';
        btn.style.cssText = `
            padding: 5px 10px;
            border: 1px solid var(--color-border-default, #d0d7de);
            border-radius: 6px;
            background: var(--color-bg-default, #ffffff);
            color: var(--color-fg-default, #1f2328);
            font-size: 12px;
            font-weight: 500;
            cursor: pointer;
            transition: all 0.15s ease;
            margin-left: 10px;
        `;
        
        btn.addEventListener('click', async function() {

            clearCache();
            clearSession();
            
            const existingInfo = document.querySelector('.custom-stars-page-info');
            if (existingInfo) {
                existingInfo.remove();
            }
            
            await init();
        });
        
        return btn;
    }
    
    function createPageInfo(totalStars, totalPages, currentPage) {
        const container = document.createElement('div');
        container.style.cssText = `
            display: inline-flex;
            align-items: center;
            gap: 8px;
            padding: 0;
            color: var(--color-fg-muted, #57606a);
            font-size: 14px;
            font-weight: 500;
            background: transparent;
            border-radius: 0;
            box-shadow: none;
            transition: all 0.2s ease;
        `;
        
        if (isLoading) {
            const loadingIndicator = createLoadingIndicator();
            container.appendChild(loadingIndicator);
            return container;
        }
        
        const numPages = Object.keys(pages).length;

        
        const pageText = document.createElement('span');
        const displayTotal = totalPages > 0 ? Math.min(totalPages, numPages) : numPages;
        pageText.textContent = `第 ${currentPage} 页 / 共 ${displayTotal} 页`;
        pageText.style.cssText = `
            white-space: nowrap;
            font-weight: 600;
            color: var(--color-fg-default, #1f2328);
        `;
        container.appendChild(pageText);
        
        if (numPages > 1) {
            const pageButtonsContainer = document.createElement('div');
            pageButtonsContainer.style.cssText = `
                display: inline-flex;
                align-items: center;
                gap: 4px;
            `;
            
            if (numPages <= 7) {

                for (let i = 1; i <= numPages; i++) {
                    const pageBtn = createPageButton(i, currentPage);
                    pageButtonsContainer.appendChild(pageBtn);
                }
            } else {

                pageButtonsContainer.appendChild(createPageButton(1, currentPage));
                
                if (currentPage > 3) {
                    pageButtonsContainer.appendChild(createEllipsis());
                }
                
                const start = Math.max(2, currentPage - 2);
                const end = Math.min(numPages - 1, currentPage + 2);
                

                
                for (let i = start; i <= end; i++) {
                    const pageBtn = createPageButton(i, currentPage);
                    pageButtonsContainer.appendChild(pageBtn);
                }
                
                if (currentPage < numPages - 2) {
                    pageButtonsContainer.appendChild(createEllipsis());
                }
                
                pageButtonsContainer.appendChild(createPageButton(numPages, currentPage));
            }
            
            container.appendChild(pageButtonsContainer);
        }
        
        return container;
    }
    
    async function insertPageInfo() {
        return utils.safe(() => {
            loadPages();
            loadCurrentPage();
            
            const paginationContainer = findPaginationContainer();
            if (!paginationContainer) {
                return false;
            }
            
            const existingInfo = utils.findElement('.custom-stars-page-info');
            if (existingInfo) {
                existingInfo.remove();
            }
            
            const currentPage = getCurrentPage();
            const totalStars = getTotalStars();
            const totalPages = totalStars ? calculateTotalPages(totalStars) : 0;
            
            const pageInfo = createPageInfo(totalStars, totalPages, currentPage);
            pageInfo.className = 'custom-stars-page-info';
            
            const previousBtn = utils.findElements('*', paginationContainer).find(el => 
                el.textContent.trim() === 'Previous'
            );
            const nextBtn = utils.findElements('*', paginationContainer).find(el => 
                el.textContent.trim() === 'Next'
            );
            
            if (previousBtn && nextBtn) {
                paginationContainer.insertBefore(pageInfo, nextBtn);
            } else if (nextBtn) {
                paginationContainer.insertBefore(pageInfo, nextBtn);
            } else if (previousBtn) {
                paginationContainer.insertBefore(pageInfo, previousBtn.nextSibling);
            } else {
                paginationContainer.appendChild(pageInfo);
            }
            
            if (Object.keys(pages).length < 2 && !isLoading) {
                setTimeout(preloadPages, 1000);
            }
            
            return true;
        }, false);
    }
    
    async function init() {
        try {

            
            loadPages();
            loadCurrentPage();
            
            const success = await insertPageInfo();
            
            if (success) {

                retryCount = 0;
            } else {

                scheduleRetry();
            }
        } catch (e) {
            console.error('❌ Error in init:', e);
            scheduleRetry();
        }
    }
    
    function scheduleRetry() {
        if (retryCount < MAX_RETRIES) {
            retryCount++;
            setTimeout(init, RETRY_INTERVAL);
        } else {

        }
    }
    
    function updatePagination() {
        if (isUpdating) {
            return;
        }
        
        isUpdating = true;
        
        const existingInfo = utils.findElement('.custom-stars-page-info');
        if (existingInfo) {
            existingInfo.remove();
        }
        
        retryCount = 0;
        loadPages();
        loadCurrentPage();
        
        init().finally(() => {
            isUpdating = false;
        });
    }
    
    function detectPageChange() {
        const starsContainer = utils.findElement('[data-testid="stars-list"]') || 
                             utils.findElement('.js-stars-container') ||
                             utils.findElement('.col-10');
        
        if (starsContainer) {
            const currentContent = starsContainer.textContent;
            if (currentContent !== lastStarsContent) {
                lastStarsContent = currentContent;
                
                const nextBtn = utils.findElementByText('Next');
                
                if (nextBtn) {
                    const after = extractAfterFromUrl(nextBtn.href);
                    if (after) {
                        for (let pageNum in pages) {
                            if (pages[pageNum] === after) {
                                const page = parseInt(pageNum, 10);
                                storeCurrentPage(page);
                                updatePagination();
                                return;
                            }
                        }
                    } else {
                        // 如果没有 after 参数,可能是第一页
                        storeCurrentPage(1);
                        updatePagination();
                        return;
                    }
                }
            }
        }
    }
    
    function setupNetworkMonitor() {
        // 统一的网络请求处理函数
        function handleNetworkRequest(url) {
            if (url.includes('tab=stars') || (typeof url === 'string' && url.includes('/stars'))) {
                const after = extractAfterFromUrl(url);
                if (after) {
                    for (let pageNum in pages) {
                        if (pages[pageNum] === after) {
                            const page = parseInt(pageNum, 10);
                            storeCurrentPage(page);
                            setTimeout(updatePagination, 300);
                            break;
                        }
                    }
                } else {
                    // 如果没有 after 参数,可能是第一页
                    storeCurrentPage(1);
                    setTimeout(updatePagination, 300);
                }
            }
        }
        
        // 监听 fetch 请求
        const originalFetch = window.fetch;
        window.fetch = function(url, options) {
            handleNetworkRequest(url);
            return originalFetch.apply(this, arguments);
        };
        
        // 监听 XMLHttpRequest 请求
        const originalXhrOpen = XMLHttpRequest.prototype.open;
        XMLHttpRequest.prototype.open = function(method, url) {
            handleNetworkRequest(url);
            return originalXhrOpen.apply(this, arguments);
        };
    }
    
    loadPages();
    loadCurrentPage();
    setTimeout(init, 500);
    
    const observer = new MutationObserver(function(mutations) {
        utils.safe(() => {
            const existingInfo = utils.findElement('.custom-stars-page-info');
            if (!existingInfo) {
                const paginationContainer = findPaginationContainer();
                if (paginationContainer) {
                    retryCount = 0;
                    loadPages();
                    loadCurrentPage();
                    init();
                }
            }
            
            detectPageChange();
        });
    });
    
    // 优化:只监听可能包含分页和stars内容的容器
    const targetContainers = [
        utils.findElement('.paginate-container'),
        utils.findElement('.js-stars-container'),
        utils.findElement('[data-testid="stars-list"]'),
        document.body // 作为 fallback
    ].filter(Boolean);
    
    targetContainers.forEach(container => {
        observer.observe(container, {
            childList: true,
            subtree: true
        });
    });
    
    window.addEventListener('popstate', function() {

        updatePagination();
    });
    
    let lastUrl = location.href;
    
    function checkUrlChange() {
        if (location.href !== lastUrl) {
            lastUrl = location.href;

            updatePagination();
        }
    }
    
    setInterval(checkUrlChange, 500);
    setInterval(detectPageChange, 300);
    
    setupNetworkMonitor();
    
    // 注册Tampermonkey菜单命令
    GM_registerMenuCommand('清除缓存', async function() {

        clearCache();
        clearSession();
        
        const existingInfo = document.querySelector('.custom-stars-page-info');
        if (existingInfo) {
            existingInfo.remove();
        }
        
        await init();
    });
    
})();