GitLabeler

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==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();

})();