GitHub Show Stars

Show star counts after every GitHub repo link on all webpages

Du musst eine Erweiterung wie Tampermonkey, Greasemonkey oder Violentmonkey installieren, um dieses Skript zu installieren.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

Sie müssten eine Skript Manager Erweiterung installieren damit sie dieses Skript installieren können

(Ich habe schon ein Skript Manager, Lass mich es installieren!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         GitHub Show Stars
// @namespace    https://github.com/h4rvey-g/github-show-stars
// @version      1.0.0
// @description  Show star counts after every GitHub repo link on all webpages
// @author       h4rvey-g
// @match        *://*/*
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @connect      api.github.com
// @run-at       document-idle
// ==/UserScript==

(function () {
    'use strict';

    // -------------------------------------------------------------------------
    // Configuration
    // -------------------------------------------------------------------------

    /**
     * Optional GitHub Personal Access Token.
     * Set via Tampermonkey storage to raise the API rate limit from 60 to 5000
     * requests/hour.  Never hard-code your token here.
     *
     * To set the token run the following one-time snippet in the browser console
     * while the userscript is installed:
     *   GM_setValue('github_token', 'ghp_yourTokenHere');
     */
    let GITHUB_TOKEN = GM_getValue('github_token', '');

    // -------------------------------------------------------------------------
    // Menu command – let the user set / clear the GitHub token from the
    // Tampermonkey extension menu without touching the browser console.
    // -------------------------------------------------------------------------
    GM_registerMenuCommand('Set GitHub Token', () => {
        const current = GM_getValue('github_token', '');
        const input = prompt(
            'Enter your GitHub Personal Access Token.\n' +
            'Leave blank and click OK to clear the stored token.\n\n' +
            (current ? 'A token is currently set.' : 'No token is currently set.'),
            current
        );
        // prompt() returns null when the user clicks Cancel – do nothing.
        if (input === null) return;

        const trimmed = input.trim();
        GM_setValue('github_token', trimmed);
        GITHUB_TOKEN = trimmed;
        if (trimmed) {
            alert('GitHub token saved. Reload the page to apply the new token.');
        } else {
            alert('GitHub token cleared. Reload the page to apply the change.');
        }
    });

    /** How long (ms) to keep a cached star-count before re-fetching. */
    const CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes

    /** Attribute added to links that have already been processed. */
    const PROCESSED_ATTR = 'data-gss-processed';

    /** Substring that must appear in an href for the CSS pre-filter to match. */
    const GITHUB_HREF_FILTER = '://github.com/';

    /**
     * GitHub top-level routes that are not repository owners.
     * This keeps us from treating pages like /settings/tokens as repos.
     */
    const RESERVED_TOP_LEVEL_PATHS = new Set([
        'about',
        'account',
        'apps',
        'blog',
        'collections',
        'contact',
        'customer-stories',
        'enterprise',
        'events',
        'explore',
        'features',
        'gist',
        'git-guides',
        'github-copilot',
        'issues',
        'login',
        'logout',
        'marketplace',
        'mobile',
        'new',
        'notifications',
        'orgs',
        'organizations',
        'pricing',
        'pulls',
        'readme',
        'search',
        'security',
        'settings',
        'signup',
        'site',
        'sponsors',
        'team',
        'teams',
        'topics',
        'trending',
    ]);

    /** Class name used on injected star badges. */
    const BADGE_CLASS = 'gss-star-badge';

    // -------------------------------------------------------------------------
    // In-memory cache  { "owner/repo": { stars: Number, pushedAt: String, createdAt: String, ts: Number } }
    // -------------------------------------------------------------------------
    const cache = {};

    // -------------------------------------------------------------------------
    // Inject stylesheet once
    // -------------------------------------------------------------------------
    (function injectStyles() {
        const style = document.createElement('style');
        style.textContent = `
            .${BADGE_CLASS} {
                display: inline-flex;
                align-items: center;
                gap: 2px;
                margin-left: 4px;
                padding: 1px 5px;
                border-radius: 10px;
                font-size: 0.78em;
                font-weight: 600;
                line-height: 1.5;
                vertical-align: middle;
                white-space: nowrap;
                background: #f1f8ff;
                color: #0969da;
                border: 1px solid #d0e4f7;
                text-decoration: none !important;
                cursor: default;
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
            }
            .${BADGE_CLASS}:hover {
                background: #dbeafe;
            }
            .${BADGE_CLASS}--loading {
                color: #8b949e;
                border-color: #d0d7de;
                background: #f6f8fa;
            }
            .${BADGE_CLASS}--error {
                color: #cf222e;
                border-color: #f8d7da;
                background: #fff0f0;
            }
            @media (prefers-color-scheme: dark) {
                .${BADGE_CLASS} {
                    background: #1c2d40;
                    color: #58a6ff;
                    border-color: #1f4068;
                }
                .${BADGE_CLASS}:hover {
                    background: #1f3a5c;
                }
                .${BADGE_CLASS}--loading {
                    color: #8b949e;
                    border-color: #30363d;
                    background: #161b22;
                }
                .${BADGE_CLASS}--error {
                    color: #ff7b72;
                    border-color: #6e2f2f;
                    background: #3d1414;
                }
            }
        `;
        document.head.appendChild(style);
    })();

    // -------------------------------------------------------------------------
    // Helpers
    // -------------------------------------------------------------------------

    /**
     * Format a date string as a human-readable relative time.
     * e.g.  "just now"  |  "5 minutes ago"  |  "3 hours ago"  |  "2 days ago"
     *       "4 months ago"  |  "2 years ago"
     */
    function timeAgo(dateString) {
        const seconds = Math.floor((Date.now() - new Date(dateString)) / 1000);
        if (seconds < 60) return 'just now';
        const minutes = Math.floor(seconds / 60);
        if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
        const hours = Math.floor(minutes / 60);
        if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
        const days = Math.floor(hours / 24);
        if (days <= 30) return `${days} day${days !== 1 ? 's' : ''} ago`;
        const months = Math.floor(days / 30.44); // average days per month (365.25 / 12)
        if (months < 12) return `${months} month${months !== 1 ? 's' : ''} ago`;
        const years = Math.floor(days / 365.25);
        return `${years} year${years !== 1 ? 's' : ''} ago`;
    }

    /**
     * Format a raw star count into a compact human-readable string.
     * e.g.  42 → "42"  |  1234 → "1.2k"  |  23456 → "23.5k"  |  1234567 → "1.2M"
     */
    function formatStars(n) {
        if (n >= 1_000_000) {
            return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M';
        }
        if (n >= 1_000) {
            return (n / 1_000).toFixed(1).replace(/\.0$/, '') + 'k';
        }
        return String(n);
    }

    /**
     * Extract "owner/repo" from a GitHub repository URL.
     * Returns null unless the URL is the repository root itself
     * (e.g. https://github.com/owner/repo or /owner/repo/).
     */
    function extractRepo(href) {
        try {
            const url = new URL(href);
            // Only handle github.com links
            if (url.hostname !== 'github.com') return null;

            const parts = url.pathname.split('/').filter(Boolean);
            if (parts.length !== 2) return null;

            const [owner, repo] = parts;
            if (!owner || !repo) return null;
            if (owner.startsWith('.') || repo.startsWith('.')) return null;
            if (RESERVED_TOP_LEVEL_PATHS.has(owner.toLowerCase())) return null;
            if (repo === 'repositories') return null;

            return `${owner}/${repo}`;
        } catch {
            return null;
        }
    }

    /**
     * Fetch repo info for "owner/repo" via the GitHub REST API.
     * Returns a Promise<{ stars, pushedAt, createdAt }>.
     */
    function fetchRepoInfo(repoPath) {
        return new Promise((resolve, reject) => {
            const headers = {
                Accept: 'application/vnd.github+json',
                'X-GitHub-Api-Version': '2022-11-28',
            };
            if (GITHUB_TOKEN) {
                headers['Authorization'] = `Bearer ${GITHUB_TOKEN}`;
            }

            GM_xmlhttpRequest({
                method: 'GET',
                url: `https://api.github.com/repos/${repoPath}`,
                headers,
                onload(response) {
                    if (response.status === 200) {
                        try {
                            const data = JSON.parse(response.responseText);
                            resolve({
                                stars: data.stargazers_count,
                                pushedAt: data.pushed_at,
                                createdAt: data.created_at,
                            });
                        } catch {
                            reject(new Error('JSON parse error'));
                        }
                    } else {
                        reject(new Error(`HTTP ${response.status}`));
                    }
                },
                onerror() {
                    reject(new Error('Network error'));
                },
                ontimeout() {
                    reject(new Error('Timeout'));
                },
                timeout: 10000,
            });
        });
    }

    /**
     * Return the cached repo info if still fresh, or null otherwise.
     */
    function getCached(repoPath) {
        const entry = cache[repoPath];
        if (!entry) return null;
        if (Date.now() - entry.ts > CACHE_TTL_MS) return null;
        return { stars: entry.stars, pushedAt: entry.pushedAt, createdAt: entry.createdAt };
    }

    /**
     * Store repo info in the cache.
     */
    function setCached(repoPath, info) {
        cache[repoPath] = { stars: info.stars, pushedAt: info.pushedAt, createdAt: info.createdAt, ts: Date.now() };
    }

    // In-flight promise map to deduplicate concurrent requests for the same repo
    const inFlight = {};

    /**
     * Get repo info for a repo, using cache and deduplication.
     */
    function getRepoInfo(repoPath) {
        const cached = getCached(repoPath);
        if (cached !== null) return Promise.resolve(cached);

        if (inFlight[repoPath]) return inFlight[repoPath];

        const promise = fetchRepoInfo(repoPath)
            .then((info) => {
                setCached(repoPath, info);
                delete inFlight[repoPath];
                return info;
            })
            .catch((err) => {
                delete inFlight[repoPath];
                throw err;
            });

        inFlight[repoPath] = promise;
        return promise;
    }

    // -------------------------------------------------------------------------
    // Badge rendering
    // -------------------------------------------------------------------------

    /**
     * Create a loading-state badge element.
     */
    function createBadge() {
        const badge = document.createElement('span');
        badge.className = `${BADGE_CLASS} ${BADGE_CLASS}--loading`;
        badge.setAttribute('aria-label', 'Loading star count');
        badge.textContent = '⭐ …';
        return badge;
    }

    /**
     * Update badge once we have the repo info (or an error).
     */
    function updateBadge(badge, repoPath, info) {
        badge.classList.remove(`${BADGE_CLASS}--loading`, `${BADGE_CLASS}--error`);
        badge.setAttribute('aria-label', `${info.stars} stars on GitHub`);
        let tooltip = `${info.stars.toLocaleString()} stars — ${repoPath}`;
        if (info.pushedAt) tooltip += `\nLast commit: ${timeAgo(info.pushedAt)}`;
        if (info.createdAt) tooltip += `\nCreated: ${timeAgo(info.createdAt)}`;
        badge.title = tooltip;
        badge.textContent = `⭐ ${formatStars(info.stars)}`;
    }

    function setBadgeError(badge, repoPath, err) {
        badge.classList.remove(`${BADGE_CLASS}--loading`);
        badge.classList.add(`${BADGE_CLASS}--error`);
        badge.setAttribute('aria-label', 'Could not load star count');
        badge.title = `Could not load stars for ${repoPath}: ${err.message}`;
        badge.textContent = '⭐ ?';
    }

    // -------------------------------------------------------------------------
    // Link processing
    // -------------------------------------------------------------------------

    /**
     * Process a single anchor element: extract the repo, create a badge, and
     * start the async fetch.
     */
    function processLink(anchor) {
        if (anchor.hasAttribute(PROCESSED_ATTR)) return;
        anchor.setAttribute(PROCESSED_ATTR, '1');

        const repoPath = extractRepo(anchor.href);
        if (!repoPath) return;

        const badge = createBadge();
        anchor.insertAdjacentElement('afterend', badge);

        getRepoInfo(repoPath)
            .then((info) => updateBadge(badge, repoPath, info))
            .catch((err) => setBadgeError(badge, repoPath, err));
    }

    // -------------------------------------------------------------------------
    // Idle-callback scheduler – process links in small chunks to avoid long
    // synchronous tasks (>50 ms) that trigger GitHub's long-task analytics
    // observer, which then tries to POST to collector.github.com and gets
    // blocked by ad-blockers (ERR_BLOCKED_BY_CLIENT).
    // -------------------------------------------------------------------------

    /** Maximum number of anchors to process per idle slice. */
    const BATCH_SIZE = 20;

    /** Pending anchor queue fed by the MutationObserver and initial scan. */
    const pendingAnchors = [];
    let idleCallbackScheduled = false;

    const scheduleIdle = typeof requestIdleCallback === 'function'
        ? (cb) => requestIdleCallback(cb, { timeout: 2000 })
        : (cb) => setTimeout(cb, 0);

    function flushPending(deadline) {
        const hasTime = () => deadline && typeof deadline.timeRemaining === 'function'
            ? deadline.timeRemaining() > 0
            : true;

        while (pendingAnchors.length > 0 && hasTime()) {
            const batch = pendingAnchors.splice(0, BATCH_SIZE);
            batch.forEach(processLink);
        }

        if (pendingAnchors.length > 0) {
            scheduleIdle(flushPending);
        } else {
            idleCallbackScheduled = false;
        }
    }

    function enqueueLinks(root) {
        const anchors = root.querySelectorAll
            ? root.querySelectorAll(`a[href*="${GITHUB_HREF_FILTER}"]:not([${PROCESSED_ATTR}])`)
            : [];
        anchors.forEach((a) => pendingAnchors.push(a));
        if (!idleCallbackScheduled && pendingAnchors.length > 0) {
            idleCallbackScheduled = true;
            scheduleIdle(flushPending);
        }
    }

    // -------------------------------------------------------------------------
    // MutationObserver – handle dynamically added content (SPAs, infinite scroll)
    // -------------------------------------------------------------------------
    const observer = new MutationObserver((mutations) => {
        for (const mutation of mutations) {
            for (const node of mutation.addedNodes) {
                if (node.nodeType !== Node.ELEMENT_NODE) continue;
                // Check the node itself
                if (node.tagName === 'A' && extractRepo(node.href)) pendingAnchors.push(node);
                // Check descendants
                enqueueLinks(node);
            }
        }
        if (!idleCallbackScheduled && pendingAnchors.length > 0) {
            idleCallbackScheduled = true;
            scheduleIdle(flushPending);
        }
    });

    observer.observe(document.body, { childList: true, subtree: true });

    // Initial scan of the already-loaded DOM (batched to avoid long tasks)
    enqueueLinks(document);
})();