Add clickable page number navigation to GitHub Stars pages
// ==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();
});
})();