GitLabeler

分析 GitHub 项目全部贡献者的头像风格。

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

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

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==UserScript==
// @name         GitLabeler
// @namespace    http://tampermonkey.net/
// @version      4.0
// @description  分析 GitHub 项目全部贡献者的头像风格。
// @author       TokimoriSeisa
// @match        https://github.com/*/*
// @grant        GM_xmlhttpRequest
// @connect      avatars.githubusercontent.com
// @connect      github.com
// @connect      generativelanguage.googleapis.com
// @license MIT
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    const apiKey = ""; 
    const modelName = "gemini-3-flash-preview"; 
    const BATCH_SIZE = 20; 
    const MAX_TOTAL_AVATARS = 1000; 

    const STYLE_COLOR_MAP = {
        'Anime/Manga': '#f1e05a',
        'Real Human': '#e34c26',
        'Pixel Art': '#563d7c',
        'Identicon/Default': '#b07219',
        'Abstract/Logo': '#2b7489',
        '3D Render': '#89e051',
        'Other': '#95a5a6'
    };

    function arrayBufferToBase64(buffer) {
        let binary = '';
        const bytes = new Uint8Array(buffer);
        const len = bytes.byteLength;
        for (let i = 0; i < len; i++) {
            binary += String.fromCharCode(bytes[i]);
        }
        return btoa(binary);
    }

    function fetchImageAsBase64(url) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: url,
                responseType: 'arraybuffer',
                onload: function(response) {
                    if (response.status >= 200 && response.status < 300) {
                        try {
                            const base64 = arrayBufferToBase64(response.response);
                            resolve(base64);
                        } catch (e) { reject(new Error("Base64 conversion failed")); }
                    } else { reject(new Error(`Image fetch failed: ${response.status}`)); }
                },
                onerror: (err) => reject(err)
            });
        });
    }

    async function analyzeBatchWithRetry(base64Images, retries = 3) {
        if (!apiKey) {
            throw new Error("Missing Gemini API Key.");
        }

        const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${modelName}:generateContent?key=${apiKey}`;
        const userParts = [
            { text: "Output ONLY a JSON object. Categorize these avatar styles: 'Anime/Manga', 'Real Human', 'Pixel Art', 'Identicon/Default', 'Abstract/Logo', '3D Render'. Format: {\"StyleName\": count}" }
        ];

        base64Images.forEach(base64 => {
            userParts.push({ inlineData: { mimeType: "image/jpeg", data: base64 } });
        });

        const payload = { contents: [{ role: "user", parts: userParts }] };

        for (let attempt = 0; attempt < retries; attempt++) {
            try {
                return await new Promise((resolve, reject) => {
                    GM_xmlhttpRequest({
                        method: 'POST',
                        url: apiUrl,
                        headers: { 'Content-Type': 'application/json' },
                        data: JSON.stringify(payload),
                        onload: function(response) {
                            if (response.status === 429) {
                                reject(new Error("Rate limit (429)"));
                            } else if (response.status >= 200 && response.status < 300) {
                                try {
                                    const data = JSON.parse(response.responseText);
                                    let text = data.candidates?.[0]?.content?.parts?.[0]?.text;
                                    if (text) {
                                        text = text.replace(/```json/g, '').replace(/```/g, '').trim();
                                        resolve(JSON.parse(text));
                                    } else resolve({});
                                } catch (e) { reject(new Error("JSON parse error")); }
                            } else {
                                reject(new Error(`API Error ${response.status}`));
                            }
                        },
                        onerror: reject
                    });
                });
            } catch (err) {
                if (attempt === retries - 1) throw err;
                await new Promise(r => setTimeout(r, 2000)); 
            }
        }
    }

    function updateUI(containerElement, stats, current, total, isDone = false, statusText = "") {
        let rowDiv = document.querySelector('.GitLabeler-row');
        if (!rowDiv) {
            rowDiv = document.createElement('div');
            rowDiv.className = 'BorderGrid-row GitLabeler-row';
            containerElement.after(rowDiv);
        }

        const sortedStats = Object.entries(stats).sort((a, b) => b[1] - a[1]);
        const totalAnalyzed = Object.values(stats).reduce((a, b) => a + b, 0);

        let html = `
            <div class="BorderGrid-cell">
                <h2 class="h4 mb-3">Avatar Styles 
                    <span class="Label Label--secondary v-align-middle ml-1">
                        ${isDone ? 'Finished' : (statusText || `Analyzing ${current}/${total}`)}
                    </span>
                </h2>
        `;

        if (totalAnalyzed > 0 || current > 0) {
            html += `<div class="mb-2"><span class="Progress Progress--large">`;
            sortedStats.forEach(([style, count]) => {
                const perc = ((count / totalAnalyzed) * 100).toFixed(1);
                const color = STYLE_COLOR_MAP[style] || STYLE_COLOR_MAP['Other'];
                html += `<span class="Progress-item" style="background-color:${color}; width:${perc}%;"></span>`;
            });
            if (!isDone && total > 0) {
                const downloadPerc = Math.max(0, 100 - (totalAnalyzed/total*100));
                html += `<span class="Progress-item" style="background-color:#eee; width:${downloadPerc}%;"></span>`;
            }
            html += `</span></div><ul class="list-style-none d-flex flex-wrap mb-n1">`;
            
            sortedStats.forEach(([style, count]) => {
                const perc = ((count / totalAnalyzed) * 100).toFixed(1);
                const color = STYLE_COLOR_MAP[style] || STYLE_COLOR_MAP['Other'];
                html += `
                    <li class="d-inline-flex flex-items-center mr-3 mb-1">
                        <svg width="8" height="8" class="mr-2" style="color:${color};"><circle cx="4" cy="4" r="4" fill="currentColor"></circle></svg>
                        <span class="color-fg-default text-bold mr-1">${style}</span>
                        <span class="color-fg-muted">${perc}% (${count})</span>
                    </li>`;
            });
            html += `</ul>`;
        } else {
            html += `<p class="color-fg-muted text-small">${statusText || 'Initializing...'}</p>`;
        }

        html += `</div>`;
        rowDiv.innerHTML = html;
    }

    async function getAllAvatarUrls(rowElement) {
        const pathParts = window.location.pathname.split('/').filter(Boolean);
        if (pathParts.length < 2) return [];
        const owner = pathParts[0];
        const repo = pathParts[1];

        const fetchByRestAPI = async () => {
            let allUrls = [];
            let page = 1;
            const perPage = 100;
            
            while (allUrls.length < MAX_TOTAL_AVATARS) {
                const pageUrls = await new Promise((resolve) => {
                    GM_xmlhttpRequest({
                        method: 'GET',
                        url: `https://api.github.com/repos/${owner}/${repo}/contributors?per_page=${perPage}&page=${page}`,
                        onload: function(response) {
                            if (response.status === 200) {
                                try {
                                    const data = JSON.parse(response.responseText);
                                    if (Array.isArray(data) && data.length > 0) {
                                        resolve(data.map(c => c.avatar_url));
                                    } else resolve([]);
                                } catch (e) { resolve([]); }
                            } else resolve([]);
                        },
                        onerror: () => resolve([])
                    });
                });

                if (pageUrls.length === 0) break;
                allUrls = allUrls.concat(pageUrls);
                if (pageUrls.length < perPage) break;
                page++;
            }
            return allUrls;
        };

        const fetchByInternalAPI = () => new Promise((resolve) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: `https://github.com/${owner}/${repo}/graphs/contributors-data`,
                headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
                onload: function(response) {
                    if (response.status === 200) {
                        try {
                            const data = JSON.parse(response.responseText);
                            resolve(data.map(item => item.author.avatar_url));
                        } catch (e) { resolve([]); }
                    } else resolve([]);
                }
            });
        });

        let urls = await fetchByRestAPI();
        if (urls.length === 0) urls = await fetchByInternalAPI();
        
        if (urls.length === 0) {
            const avatars = Array.from(document.querySelectorAll('img.avatar, .avatar img, [data-hovercard-type="user"] img'));
            urls = Array.from(new Set(avatars.map(img => img.src))).filter(src => src && src.includes('avatars'));
        }

        return urls;
    }

    async function processAllContributors() {
        const grid = document.querySelector('.BorderGrid');
        if (!grid) return;

        const header = Array.from(grid.querySelectorAll('h2')).find(h => h.textContent.trim().includes('Contributors'));
        if (!header) return;

        const row = header.closest('.BorderGrid-row');
        if (!row || row.dataset.gitlabelerProcessed) return;
        row.dataset.gitlabelerProcessed = 'true';

        updateUI(row, {}, 0, 0, false, "Fetching contributor list...");

        const allUrls = await getAllAvatarUrls(row);
        const validUrls = allUrls.filter(url => typeof url === 'string' && url.length > 0);
        const targetUrls = validUrls.slice(0, MAX_TOTAL_AVATARS);
        const total = targetUrls.length;
        
        if (total === 0) {
            updateUI(row, {}, 0, 0, false, "Could not fetch list.");
            return;
        }

        let cumulativeStats = {};
        updateUI(row, cumulativeStats, 0, total, false, `Analyzing ${total} contributors...`);

        for (let i = 0; i < total; i += BATCH_SIZE) {
            const end = Math.min(i + BATCH_SIZE, total);
            const batchSlice = targetUrls.slice(i, end).map(url => {
                if (!url) return null;
                return url.split('?')[0].split('&')[0] + '?s=64';
            }).filter(u => u !== null);

            updateUI(row, cumulativeStats, i, total, false, `Downloading batch ${i+1}-${end}...`);

            try {
                const base64Batch = await Promise.all(batchSlice.map(url => 
                    fetchImageAsBase64(url).catch(() => null)
                ));
                
                const filteredBatch = base64Batch.filter(b => b !== null);

                if (filteredBatch.length > 0) {
                    updateUI(row, cumulativeStats, end, total, false, `AI Processing (${filteredBatch.length} images)...`);
                    const result = await analyzeBatchWithRetry(filteredBatch);
                    
                    for (const [style, count] of Object.entries(result)) {
                        cumulativeStats[style] = (cumulativeStats[style] || 0) + count;
                    }
                }
                
                updateUI(row, cumulativeStats, end, total, end === total);
            } catch (err) {
                updateUI(row, cumulativeStats, end, total, end === total, "Error in batch.");
            }
        }
    }

    const observer = new MutationObserver((mutations) => {
        for (const mutation of mutations) {
            if (mutation.addedNodes.length) {
                processAllContributors();
                break;
            }
        }
    });
    
    observer.observe(document.body, { childList: true, subtree: true });
    processAllContributors();

})();