Last active
February 12, 2026 10:09
-
-
Save Wxh16144/bd63cb3789be02c088a781bb03b73cf6 to your computer and use it in GitHub Desktop.
GitLab 贡献热力图生成器 (Contribution Graph)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * GitLab 贡献热力图生成器 (Contribution Graph) | |
| * | |
| * 1. 必填配置:修改下方 `GITLAB_CONFIG` (GitLab地址, Token, 目标用户)。 | |
| * 2. 运行脚本:node gitlab-contribution.js | |
| * // or envx run -f .env -- node gitlab-contribution.js | |
| */ | |
| const http = require('http'); | |
| /** | |
| * ================= CONFIGURATION ================= | |
| * 请在这里配置您的 GitLab 信息 | |
| */ | |
| const GITLAB_CONFIG = { | |
| baseUrl: process.env.GITLAB_BASE_URL || 'https://gitlab.com', // 替换为您的 GitLab 实例地址 | |
| token: process.env.GITLAB_TOKEN, // 替换为您的 Access Token | |
| targetUser: '54', // 想要查询的用户名或ID | |
| excludeWeekend: false, // 是否排除周末 | |
| weekStart: 1, // 0 = 周日起始, 1 = 周一起始 | |
| dateRange: { | |
| // 入职日期 | |
| start: '2023-04-17', | |
| // 默认到今天 | |
| end: new Date().toISOString().split('T')[0] | |
| } | |
| }; | |
| /** | |
| * 获取用户 ID | |
| */ | |
| async function getUserId(username) { | |
| if (!isNaN(username)) return username; | |
| const apiUrl = `${GITLAB_CONFIG.baseUrl}/api/v4/users?username=${username}`; | |
| try { | |
| const res = await fetch(apiUrl, { headers: { 'PRIVATE-TOKEN': GITLAB_CONFIG.token } }); | |
| const data = await res.json(); | |
| if (data && data.length > 0) return data[0].id; | |
| throw new Error(`User ${username} not found`); | |
| } catch (error) { | |
| console.error('Error fetching user ID:', error); | |
| process.exit(1); | |
| } | |
| } | |
| /** | |
| * 获取贡献数据 | |
| */ | |
| async function fetchContributions(userId, afterDate, beforeDate) { | |
| let allEvents = []; | |
| let page = 1; | |
| const perPage = 100; | |
| console.log(`Fetching events for user ${userId} from ${afterDate} to ${beforeDate}...`); | |
| while (true) { | |
| const apiUrl = `${GITLAB_CONFIG.baseUrl}/api/v4/users/${userId}/events?after=${afterDate}&before=${beforeDate}&per_page=${perPage}&page=${page}`; | |
| try { | |
| const res = await fetch(apiUrl, { headers: { 'PRIVATE-TOKEN': GITLAB_CONFIG.token } }); | |
| if (!res.ok) throw new Error(`API Error: ${res.statusText}`); | |
| const events = await res.json(); | |
| if (events.length === 0) break; | |
| allEvents = allEvents.concat(events); | |
| console.log(` Fetched page ${page}, total so far: ${allEvents.length}`); | |
| if (events.length < perPage) break; | |
| page++; | |
| } catch (error) { | |
| console.error('Error fetching events:', error); | |
| break; | |
| } | |
| } | |
| return allEvents; | |
| } | |
| /** | |
| * 处理数据 | |
| */ | |
| function processEvents(events) { | |
| const contributions = {}; | |
| events.forEach(event => { | |
| const dateStr = event.created_at.split('T')[0]; | |
| if (['push', 'issue', 'merge_request', 'comment'].includes(event.action_name) || true) { | |
| contributions[dateStr] = (contributions[dateStr] || 0) + 1; | |
| } | |
| }); | |
| return contributions; | |
| } | |
| /** | |
| * 生成 HTML 页面 | |
| */ | |
| function generateHtml(contributions, startStr, endStr, weekStart = 0) { | |
| const startDate = new Date(startStr); | |
| const endDate = new Date(endStr); | |
| let currentDate = new Date(startDate); | |
| // 颜色配置 | |
| const levels = [ | |
| { min: 0, color: '#ebedf0' }, | |
| { min: 1, color: '#9be9a8' }, | |
| { min: 4, color: '#40c463' }, | |
| { min: 7, color: '#30a14e' }, | |
| { min: 10, color: '#216e39' } | |
| ]; | |
| const getColor = (count) => { | |
| if (!count) return levels[0].color; | |
| if (count >= 10) return levels[4].color; | |
| if (count >= 7) return levels[3].color; | |
| if (count >= 4) return levels[2].color; | |
| return levels[1].color; | |
| }; | |
| // 1. 生成扁平的天列表 | |
| const dayCells = []; | |
| while (currentDate <= endDate) { | |
| const isoDate = currentDate.toISOString().split('T')[0]; | |
| const count = contributions[isoDate] || 0; | |
| dayCells.push({ date: isoDate, count, color: getColor(count), dayOfWeek: currentDate.getDay() }); | |
| currentDate.setDate(currentDate.getDate() + 1); | |
| } | |
| // 2. 根据 weekStart 重新组织为周 (gridWeeks) | |
| const getAdjustedIndex = (dayIndex) => (dayIndex - weekStart + 7) % 7; | |
| const gridWeeks = []; | |
| let currentWeek = new Array(7).fill(null); | |
| // 填充起始空白 | |
| if (dayCells.length > 0) { | |
| const firstAdj = getAdjustedIndex(dayCells[0].dayOfWeek); | |
| for (let i = 0; i < firstAdj; i++) currentWeek[i] = { color: 'transparent', count: 0, date: null }; | |
| } | |
| dayCells.forEach(day => { | |
| const adjIndex = getAdjustedIndex(day.dayOfWeek); | |
| currentWeek[adjIndex] = day; | |
| if (adjIndex === 6) { | |
| gridWeeks.push(currentWeek); | |
| currentWeek = new Array(7).fill(null); | |
| } | |
| }); | |
| // 填充末尾空白 | |
| if (currentWeek.some(d => d !== null)) { | |
| for (let i = 0; i < 7; i++) if (!currentWeek[i]) currentWeek[i] = { color: 'transparent', count: 0, date: null }; | |
| gridWeeks.push(currentWeek); | |
| } | |
| // 3. 构建 HTML 字符串 (同步生成 Weeks 和 Months 部分,处理分割线) | |
| const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; | |
| let weeksHtml = ''; | |
| let monthsHtml = ''; | |
| let lastYear = null; | |
| let lastMonth = -1; | |
| gridWeeks.forEach((week) => { | |
| // 找到这一周主要属于哪一年/哪个月 (取第一个有效日期) | |
| const validDay = week.find(d => d && d.date); | |
| let currentYear = null; | |
| let currentMonth = null; | |
| if (validDay) { | |
| const d = new Date(validDay.date); | |
| currentYear = d.getFullYear(); | |
| currentMonth = d.getMonth(); | |
| } | |
| // --- 年份分割线逻辑 --- | |
| // 如果年份发生了变化,且不是第一年,插入分割线 | |
| if (lastYear !== null && currentYear !== null && currentYear !== lastYear) { | |
| weeksHtml += `<div class="year-divider"></div>`; | |
| // 为了保持对齐,Months 行也必须插入一个同样宽度的(透明)分割占位 | |
| monthsHtml += `<div class="year-divider"></div>`; | |
| } | |
| if (currentYear !== null) lastYear = currentYear; | |
| // --- 热力图列 --- | |
| weeksHtml += `<div class="week-column"> | |
| ${week.map(day => ` | |
| <div class="day-cell" | |
| style="background-color: ${day.color}" | |
| title="${day.date || ''}: ${day.count} contributions"></div> | |
| `).join('')} | |
| </div>`; | |
| // --- 月份标签列 --- | |
| let label = ''; | |
| if (currentMonth !== null) { | |
| if (currentMonth !== lastMonth) { | |
| label = monthNames[currentMonth]; | |
| lastMonth = currentMonth; | |
| } | |
| } | |
| monthsHtml += `<div class="month-cell">${label}</div>`; | |
| }); | |
| return ` | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <title>GitLab Contribution Graph</title> | |
| <style> | |
| body { font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif; display: flex; justify-content: center; padding-top: 50px; background: #f6f8fa; color: #24292f; } | |
| .graph-container { background: #fff; padding: 20px; border: 1px solid #d0d7de; border-radius: 6px; overflow-x: auto; max-width: 95vw; } | |
| .weeks, .months { display: flex; gap: 3px; } | |
| .week-column { display: flex; flex-direction: column; gap: 3px; } | |
| .day-cell { width: 10px; height: 10px; border-radius: 2px; } | |
| .day-cell:hover { outline: 1px solid rgba(0,0,0,0.5); cursor: pointer; } | |
| /* 月份标签样式 */ | |
| .months { margin-top: 5px; height: 15px; } | |
| .month-cell { width: 10px; font-size: 10px; color: #768390; overflow: visible; white-space: nowrap; line-height: 1; } | |
| /* 年份分割线样式 */ | |
| .year-divider { | |
| width: 1px; | |
| background-color: #d0d7de; /* 灰色线 */ | |
| margin: 0 2px; /* 分割线左右留白 */ | |
| flex-shrink: 0; | |
| border-radius: 1px; | |
| } | |
| /* 月份区域的分割线设为透明,仅占位用 */ | |
| .months .year-divider { background-color: transparent; } | |
| h1 { text-align: center; margin-bottom: 20px; } | |
| .legend { display: flex; align-items: center; gap: 4px; font-size: 12px; margin-top: 15px; color: #57606a; justify-content: flex-end; } | |
| </style> | |
| </head> | |
| <body> | |
| <div> | |
| <h1>Contributions (${startStr} ~ ${endStr})</h1> | |
| <div class="graph-container"> | |
| <div class="weeks">${weeksHtml}</div> | |
| <div class="months">${monthsHtml}</div> | |
| <div class="legend"> | |
| Less | |
| <div class="day-cell" style="background-color: ${levels[0].color}"></div> | |
| <div class="day-cell" style="background-color: ${levels[1].color}"></div> | |
| <div class="day-cell" style="background-color: ${levels[2].color}"></div> | |
| <div class="day-cell" style="background-color: ${levels[3].color}"></div> | |
| <div class="day-cell" style="background-color: ${levels[4].color}"></div> | |
| More | |
| </div> | |
| </div> | |
| </div> | |
| </body> | |
| </html> | |
| `; | |
| } | |
| /** | |
| * 主程序 | |
| */ | |
| async function main() { | |
| console.log('Starting GitLab Contribution fetcher...'); | |
| if (GITLAB_CONFIG.baseUrl.includes('example.com')) { | |
| console.error('ERROR: Please configure GITLAB_CONFIG first.'); | |
| process.exit(1); | |
| } | |
| const userId = await getUserId(GITLAB_CONFIG.targetUser); | |
| console.log(`Resolved User ID: ${userId}`); | |
| const { start: startStr, end: endStr } = GITLAB_CONFIG.dateRange; | |
| const weekStart = GITLAB_CONFIG.weekStart !== undefined ? GITLAB_CONFIG.weekStart : 0; | |
| const events = await fetchContributions(userId, startStr, endStr); | |
| const contributions = processEvents(events); | |
| const total = Object.values(contributions).reduce((a, b) => a + b, 0); | |
| console.log(`Total contributions: ${total}`); | |
| const server = http.createServer((req, res) => { | |
| res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); | |
| res.end(generateHtml(contributions, startStr, endStr, weekStart)); | |
| }); | |
| const PORT = 3000; | |
| server.listen(PORT, () => { | |
| console.log(`Server running at http://localhost:${PORT}/`); | |
| }); | |
| } | |
| // 直接运行时执行 | |
| main(); |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
GitLab Contribution Graph Generator
轻量级的 Node.js 脚本,用于连接(自部署)GitLab 实例,获取指定用户的贡献数据,并在本地生成类似 GitHub 的贡献热力图(Contribution Heatmap)。
✨ 主要功能
node_modules(Node.js 18+ 环境下)。🚀 快速开始
方式一:直接下载并运行 (推荐)
下载脚本
配置与运行
您可以直接编辑文件顶部的
GITLAB_CONFIG,或者使用环境变量运行(无需修改文件):方式二:手动配置
打开脚本文件
gitlab-contribution.js,修改顶部的配置对象: