分析 GitHub 项目全部贡献者的头像风格。
// ==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();
})();