GitHub Dashboard Explore & Trending

Restores Explore widget with "k" notation for star counts and robust feed parsing.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==UserScript==
// @name         GitHub Dashboard Explore & Trending
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Restores Explore widget with "k" notation for star counts and robust feed parsing.
// @author       Longlone & Gemini 3
// @match        https://github.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    const WIDGET_ID = 'gemini-dual-explore-widget';

    // 样式:保持大字体,增加一些微调
    GM_addStyle(`
        #${WIDGET_ID} { animation: fadeIn 0.3s ease-in-out; margin-bottom: 16px; }
        #${WIDGET_ID} .tab-btn {
            cursor: pointer; padding-bottom: 6px; border-bottom: 2px solid transparent;
            color: var(--fgColor-muted, #656d76); transition: all 0.2s; font-size: 14px;
        }
        #${WIDGET_ID} .tab-btn:hover { color: var(--fgColor-default, #1f2328); }
        #${WIDGET_ID} .tab-btn.active {
            color: var(--fgColor-default, #1f2328); border-bottom-color: var(--fgColor-accent, #0969da); font-weight: 600;
        }
        #${WIDGET_ID} .repo-link { font-size: 14px !important; line-height: 1.25; font-weight: 600; }
        #${WIDGET_ID} .repo-desc {
            display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
            margin-top: 4px; margin-bottom: 8px; font-size: 13px !important; line-height: 1.5; color: var(--fgColor-muted, #656d76);
        }
        #${WIDGET_ID} .repo-meta { font-size: 12px !important; color: var(--fgColor-muted, #656d76); white-space: nowrap; display: flex; align-items: center; }
        #${WIDGET_ID} .repo-list-container { min-height: 150px; }
        @keyframes fadeIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
    `);

    const STATE = {
        activeTab: 'explore',
        cache: { explore: null, trending: null },
        loading: { explore: false, trending: false }
    };

    // --- 工具:数字格式化 (1,234 -> 1.2k) ---
    function formatStars(rawStr) {
        if (!rawStr) return '';
        // 移除逗号和多余空格
        const cleanStr = rawStr.replace(/,/g, '').trim();

        // 如果已经是 k/m 结尾,直接返回
        if (cleanStr.toLowerCase().endsWith('k') || cleanStr.toLowerCase().endsWith('m')) {
            return cleanStr;
        }

        const num = parseFloat(cleanStr);
        if (isNaN(num)) return rawStr; // 解析失败则原样返回

        if (num >= 1000) {
            // 除以1000,保留1位小数,如果小数是.0则去掉
            return (num / 1000).toFixed(1).replace('.0', '') + 'k';
        }
        return num.toString();
    }

    async function init() {
        if (window.location.pathname !== '/' && window.location.pathname !== '/dashboard') return;
        if (document.getElementById(WIDGET_ID)) return;

        // 定位侧边栏
        let rightSidebar = document.querySelector('aside.feed-right-column') ||
                           document.querySelector('aside[aria-label="Explore"]');

        if (!rightSidebar) {
            const changelogHeading = Array.from(document.querySelectorAll('h2, h3')).find(el =>
                el.innerText && el.innerText.toLowerCase().includes('changelog')
            );
            if (changelogHeading) {
                let parent = changelogHeading.closest('div.border');
                if (parent && parent.parentElement) rightSidebar = parent.parentElement;
            }
        }

        if (!rightSidebar) return;

        const container = document.createElement('div');
        container.id = WIDGET_ID;
        container.className = 'color-bg-default color-border-muted border rounded-2 p-3';

        container.innerHTML = `
            <div class="d-flex flex-items-center mb-3 border-bottom color-border-muted pb-1 gap-3">
                <div id="tab-explore" class="tab-btn active">Explore</div>
                <div id="tab-trending" class="tab-btn">Trending</div>
            </div>
            <div id="gemini-list-area" class="repo-list-container d-flex flex-column">
                <p class="text-center color-fg-muted m-4" style="font-size: 14px;">Loading...</p>
            </div>
            <div class="text-right mt-2">
                 <a id="gemini-more-link" href="https://github.com/explore" class="Link--secondary" style="font-size: 13px;">View more →</a>
            </div>
        `;

        rightSidebar.appendChild(container);

        document.getElementById('tab-explore').onclick = () => switchTab('explore');
        document.getElementById('tab-trending').onclick = () => switchTab('trending');

        loadData('explore');
        loadData('trending');
    }

    function switchTab(tabName) {
        STATE.activeTab = tabName;
        document.querySelectorAll(`#${WIDGET_ID} .tab-btn`).forEach(el => el.classList.remove('active'));
        document.getElementById(`tab-${tabName}`).classList.add('active');

        const moreLink = document.getElementById('gemini-more-link');
        moreLink.href = tabName === 'explore' ? 'https://github.com/explore' : 'https://github.com/trending';

        renderList();
        if (!STATE.cache[tabName] && !STATE.loading[tabName]) loadData(tabName);
    }

    async function loadData(source) {
        if (STATE.loading[source]) return;
        STATE.loading[source] = true;

        const url = source === 'explore'
            ? 'https://github.com/explore'
            : 'https://github.com/trending?since=daily';

        try {
            const data = await fetchAndParse(url, source);
            STATE.cache[source] = data;
            if (STATE.activeTab === source) renderList();
        } catch (e) {
            console.error(`[GH-Explore] Error loading ${source}:`, e);
            if (STATE.activeTab === source) {
                const area = document.getElementById('gemini-list-area');
                if(area) area.innerHTML = `<p class="color-fg-danger text-center" style="font-size:13px">Load failed.</p>`;
            }
        } finally {
            STATE.loading[source] = false;
        }
    }

    function fetchAndParse(url, source) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "GET",
                url: url,
                onload: function(response) {
                    const parser = new DOMParser();
                    const doc = parser.parseFromString(response.responseText, "text/html");
                    let data = [];

                    if (source === 'trending') {
                        const rows = doc.querySelectorAll('article.Box-row');
                        data = parseTrendingRows(rows);
                    } else {
                        data = parseExploreFeed(doc);
                    }

                    resolve(data.slice(0, 5));
                },
                onerror: reject
            });
        });
    }

    // --- 1. Explore Feed 解析器 ---
    function parseExploreFeed(doc) {
        const data = [];
        const articles = doc.querySelectorAll('article.border');

        articles.forEach(article => {
            const titleLink = article.querySelector('h3 a.text-bold') || article.querySelector('h1 a');
            if (!titleLink) return;

            const href = titleLink.getAttribute('href');
            if (!isValidRepo(href)) return;

            const descEl = article.querySelector('p.color-fg-muted.mb-0') || article.querySelector('p');
            const description = descEl ? descEl.innerText.trim() : '';

            const starCounter = article.querySelector('.Counter.js-social-count');
            let starText = '';

            if (starCounter) {
                starText = starCounter.innerText.trim();
            } else {
                const starBtn = article.querySelector('button[aria-label*="starred"]');
                if (starBtn) {
                    const match = starBtn.getAttribute('aria-label').match(/(\d[\d,.]*[km]?)/i);
                    if (match) starText = match[1];
                }
            }

            data.push({
                name: titleLink.innerText.trim(),
                href: href,
                description: description,
                stars: formatStars(starText) // 应用格式化
            });
        });

        return data;
    }

    // --- 2. Trending 解析器 ---
    function parseTrendingRows(rows) {
        const data = [];
        for (let i = 0; i < rows.length; i++) {
            const row = rows[i];
            const titleEl = row.querySelector('h2 a');
            if (!titleEl) continue;

            const descEl = row.querySelector('p.col-9') || row.querySelector('p');
            const starLink = row.querySelector('a[href$="/stargazers"]');

            let starText = '';
            if (starLink) {
                starText = starLink.innerText.trim();
            }

            data.push({
                name: titleEl.innerText.trim().replace(/\s+/g, ''),
                href: titleEl.getAttribute('href'),
                description: descEl ? descEl.innerText.trim() : '',
                stars: formatStars(starText) // 应用格式化
            });
        }
        return data;
    }

    function isValidRepo(href) {
        if (!href) return false;
        const invalidPrefixes = ['/topics/', '/collections/', '/site/', '/features/', '/enterprise', '/login', '/marketplace', '/sponsors'];
        if (invalidPrefixes.some(prefix => href.startsWith(prefix))) return false;
        return /^\/[a-zA-Z0-9-._]+\/[a-zA-Z0-9-._]+$/.test(href.split('?')[0]);
    }

    function renderList() {
        const container = document.getElementById('gemini-list-area');
        if(!container) return;

        const data = STATE.cache[STATE.activeTab];

        if (!data) {
            container.innerHTML = '<p class="text-center color-fg-muted m-4" style="font-size: 14px;">Loading...</p>';
            return;
        }

        if (data.length === 0) {
            container.innerHTML = '<p class="color-fg-muted m-2 text-center" style="font-size: 13px;">No qualified repos found.</p>';
            return;
        }

        let html = '';
        data.forEach((repo, index) => {
            const borderClass = index === 0 ? '' : 'border-top color-border-muted pt-2';
            const marginClass = index === 0 ? '' : 'mt-2';

            let starHtml = '';
            if (repo.stars) {
                starHtml = `<span class="repo-meta ml-2">⭐ ${repo.stars}</span>`;
            }

            html += `
                <div class="${borderClass} ${marginClass}">
                    <div class="d-flex flex-justify-between flex-items-baseline">
                        <a href="${repo.href}" class="Link--primary text-truncate repo-link" title="${repo.name}" style="max-width: 70%;">
                            ${repo.name}
                        </a>
                        ${starHtml}
                    </div>
                    <div class="repo-desc">
                        ${repo.description || 'No description available.'}
                    </div>
                </div>
            `;
        });
        container.innerHTML = html;
    }

    // Observer
    const observer = new MutationObserver((mutations) => {
        if (!document.getElementById(WIDGET_ID)) init();
    });
    observer.observe(document.body, { childList: true, subtree: true });

    document.addEventListener('turbo:load', init);
    document.addEventListener('turbo:render', init);

    init();

})();