Last active
December 12, 2025 06:20
-
-
Save chenxuan520/01757d8ce84f7acac71adcae2651f7a4 to your computer and use it in GitHub Desktop.
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
| /** | |
| * ====================================================================================== | |
| * Cloudflare Worker: GitHub Stats SVG Generator | |
| * Source: https://gist.github.com/chenxuan520/01757d8ce84f7acac71adcae2651f7a4 | |
| * ====================================================================================== | |
| * | |
| * [中文说明] | |
| * 这个 Worker 用于生成展示 GitHub 用户统计信息的 SVG 图片。 | |
| * 支持显示 Earned Stars (所有仓库), Forks, Repos, Followers, Gists 以及 Top Languages。 | |
| * *注意:Top Languages 统计仅包含原创项目,Stars 包含所有项目。* | |
| * *更新:移除加入时间显示,保持界面简洁。* | |
| * 具备完美的垂直居中对齐、防 API 限流机制以及 KV 缓存功能。 | |
| * | |
| * 🎨 支持的主题 (通过 ?theme= 参数设置): | |
| * dark, radical, merko, gruvbox, tokyonight (默认), onedark, cobalt, synthwave, highcontrast, dracula | |
| * | |
| * 🚀 部署与使用说明 | |
| * -------------------------------------------------------------------------------------- | |
| * 1. 部署: | |
| * - 登录 Cloudflare Dashboard -> Workers & Pages -> Create Application -> Create Worker. | |
| * - 将本文件的所有代码复制粘贴到编辑器中 (worker.js). | |
| * - 点击 "Save and Deploy". | |
| * | |
| * 2. 使用: | |
| * - 访问格式: https://<你的Worker域名>/?username=<GitHub用户名>&theme=<主题名> | |
| * - 示例: https://my-worker.username.workers.dev/?username=chenxuan520&theme=dracula | |
| * - 将生成的 URL 放在你的 GitHub Profile README.md 中即可: | |
| *  | |
| * | |
| * 🔑 环境变量配置 (可选但推荐) | |
| * -------------------------------------------------------------------------------------- | |
| * [配置 GITHUB_TOKEN] (防止限流) | |
| * - 去 GitHub Settings -> Developer settings -> Tokens (Classic) 生成一个无权限的 Token。 | |
| * - 在 Cloudflare Worker 设置 -> 变量中添加 `GITHUB_TOKEN`。 | |
| * -> 效果: API 限额从 60次/小时 提升至 5000次/小时。 | |
| * | |
| * [配置 KV 缓存] (提升速度) | |
| * - 在 Cloudflare 创建一个 KV Namespace (如 github_stats)。 | |
| * - 在 Worker 设置 -> 变量 -> KV Namespace Bindings 中绑定,变量名必须填 `STATS_CACHE`。 | |
| * -> 效果: 数据缓存 60 秒,极速响应。 | |
| * | |
| * ====================================================================================== | |
| * | |
| * [English Description] | |
| * A Cloudflare Worker that generates a dynamic SVG image displaying GitHub user statistics. | |
| * Source Code: https://gist.github.com/chenxuan520/01757d8ce84f7acac71adcae2651f7a4 | |
| * | |
| * Features: Earned Stars (All Repos), Forks, Repos, Followers, Gists, and Top Languages. | |
| * *Note: Top Languages stats exclude forked repositories, but Stars count includes everything.* | |
| * *Update: Removed joined date for a cleaner look.* | |
| * Includes: Perfect vertical alignment, API rate limit handling, and KV caching support. | |
| * | |
| * 🎨 Supported Themes (via ?theme= parameter): | |
| * dark, radical, merko, gruvbox, tokyonight (default), onedark, cobalt, synthwave, highcontrast, dracula | |
| * | |
| * 🚀 Deployment & Usage | |
| * -------------------------------------------------------------------------------------- | |
| * 1. Deploy: | |
| * - Log in to Cloudflare Dashboard -> Workers & Pages -> Create Application -> Create Worker. | |
| * - Copy and paste the code below into the editor (worker.js). | |
| * - Click "Save and Deploy". | |
| * | |
| * 2. Usage: | |
| * - URL Format: https://<YOUR_WORKER_DOMAIN>/?username=<GITHUB_USERNAME>&theme=<THEME_NAME> | |
| * - Example: https://my-worker.username.workers.dev/?username=chenxuan520&theme=dracula | |
| * - Embed in Markdown: | |
| *  | |
| * | |
| * 🔑 Configuration (Optional but Recommended) | |
| * -------------------------------------------------------------------------------------- | |
| * [Configure GITHUB_TOKEN] (Avoid Rate Limits) | |
| * - Generate a classic token with no scopes at GitHub Settings -> Developer settings. | |
| * - Add `GITHUB_TOKEN` in Cloudflare Worker Settings -> Variables. | |
| * -> Result: Increases API limit to 5000 reqs/hour. | |
| * | |
| * [Configure KV Caching] (Boost Speed) | |
| * - Create a KV Namespace in Cloudflare (e.g., github_stats). | |
| * - Bind it in Worker Settings -> Variables -> KV Namespace Bindings with variable name `STATS_CACHE`. | |
| * -> Result: Responses are cached for 60 seconds. | |
| * | |
| * ====================================================================================== | |
| */ | |
| // --- Theme Definitions --- | |
| const themes = { | |
| tokyonight: { | |
| bg: "#1a1b26", | |
| border: "#1a1b26", | |
| title: "#70a5fd", | |
| label: "#9aa5ce", | |
| stat: "#c0caf5", | |
| langBar: ["#7aa2f7", "#9ece6a", "#e0af68", "#bb9af7", "#f7768e"] | |
| }, | |
| dark: { | |
| bg: "#0d1117", | |
| border: "#30363d", | |
| title: "#58a6ff", | |
| label: "#8b949e", | |
| stat: "#c9d1d9", | |
| langBar: ["#58a6ff", "#3fb950", "#d29922", "#db6d28", "#f85149"] | |
| }, | |
| radical: { | |
| bg: "#141321", | |
| border: "#141321", | |
| title: "#fe428e", | |
| label: "#a9fef7", | |
| stat: "#fe428e", | |
| langBar: ["#fe428e", "#f8d847", "#a9fef7", "#f8d847", "#fe428e"] | |
| }, | |
| merko: { | |
| bg: "#0a0f0b", | |
| border: "#0a0f0b", | |
| title: "#abd200", | |
| label: "#68b587", | |
| stat: "#b7d364", | |
| langBar: ["#abd200", "#b7d364", "#68b587", "#abd200", "#b7d364"] | |
| }, | |
| gruvbox: { | |
| bg: "#282828", | |
| border: "#282828", | |
| title: "#fabd2f", | |
| label: "#a89984", | |
| stat: "#ebdbb2", | |
| langBar: ["#fabd2f", "#fe8019", "#8ec07c", "#d3869b", "#fb4934"] | |
| }, | |
| onedark: { | |
| bg: "#282c34", | |
| border: "#282c34", | |
| title: "#61afef", | |
| label: "#98c379", | |
| stat: "#e06c75", | |
| langBar: ["#61afef", "#98c379", "#e5c07b", "#c678dd", "#e06c75"] | |
| }, | |
| cobalt: { | |
| bg: "#002240", | |
| border: "#002240", | |
| title: "#ffc600", | |
| label: "#9effff", | |
| stat: "#ffffff", | |
| langBar: ["#ffc600", "#0088ff", "#ff9d00", "#ffc600", "#0088ff"] | |
| }, | |
| synthwave: { | |
| bg: "#2b213a", | |
| border: "#2b213a", | |
| title: "#e2e9ec", | |
| label: "#fff5f6", | |
| stat: "#ef8539", | |
| langBar: ["#e2e9ec", "#ef8539", "#e5289e", "#e2e9ec", "#ef8539"] | |
| }, | |
| highcontrast: { | |
| bg: "#000000", | |
| border: "#ffffff", | |
| title: "#e7f216", | |
| label: "#ffffff", | |
| stat: "#00ffff", | |
| langBar: ["#e7f216", "#00ffff", "#ff00ff", "#e7f216", "#00ffff"] | |
| }, | |
| dracula: { | |
| bg: "#282a36", | |
| border: "#282a36", | |
| title: "#ff79c6", | |
| label: "#f8f8f2", | |
| stat: "#8be9fd", | |
| langBar: ["#ff79c6", "#8be9fd", "#50fa7b", "#bd93f9", "#ffb86c"] | |
| } | |
| }; | |
| /** | |
| * Helper function to fetch all repositories with pagination | |
| * 辅助函数:自动分页获取所有仓库 | |
| */ | |
| async function fetchAllRepos(username, headers) { | |
| let page = 1; | |
| let allRepos = []; | |
| const MAX_PAGES = 10; // Limit to 1000 repos | |
| while (true) { | |
| if (page > MAX_PAGES) break; | |
| const url = `https://api.github.com/users/${username}/repos?per_page=100&page=${page}`; | |
| const resp = await fetch(url, { headers }); | |
| if (!resp.ok) break; | |
| const data = await resp.json(); | |
| if (!Array.isArray(data) || data.length === 0) break; | |
| allRepos = allRepos.concat(data); | |
| if (data.length < 100) break; | |
| page++; | |
| } | |
| return allRepos; | |
| } | |
| /** | |
| * Helper function to calculate Rank and Color | |
| * 辅助函数:计算评级和对应的颜色 | |
| */ | |
| function calculateRank(stars, followers) { | |
| const score = (stars * 4) + (followers * 1); | |
| // Rank Colors | |
| const colors = { | |
| S_PLUS: "#f8d847", // Gold (Legendary) | |
| S: "#f8d847", | |
| A_PLUS: "#bb9af7", // Purple (Epic) | |
| A: "#bb9af7", | |
| B_PLUS: "#3fb950", // Green (Good) | |
| B: "#3fb950", | |
| C: "#f0883e" // Orange (Fair) | |
| }; | |
| if (score > 2000) return { level: "S+", color: colors.S_PLUS }; | |
| if (score > 1000) return { level: "S", color: colors.S }; | |
| if (score > 500) return { level: "A+", color: colors.A_PLUS }; | |
| if (score > 200) return { level: "A", color: colors.A }; | |
| if (score > 100) return { level: "B+", color: colors.B_PLUS }; | |
| if (score > 50) return { level: "B", color: colors.B }; | |
| return { level: "C", color: colors.C }; | |
| } | |
| export default { | |
| async fetch(request, env, ctx) { | |
| const url = new URL(request.url); | |
| // 1. Intercept favicon.ico requests | |
| if (url.pathname.includes("favicon.ico")) return new Response(null, { status: 204 }); | |
| const username = url.searchParams.get("username"); | |
| const themeParam = url.searchParams.get("theme") || "tokyonight"; | |
| const selectedTheme = themes[themeParam] || themes["tokyonight"]; | |
| // 2. Homepage usage hint | |
| if (!username) { | |
| return new Response( | |
| "⚠️ Missing username parameter.\n\nUsage:\nhttps://" + url.hostname + "/?username=YOUR_GITHUB_ID&theme=tokyonight\n\nCheck the script comments for deployment instructions.", | |
| { status: 200, headers: { "Content-Type": "text/plain; charset=utf-8" } } | |
| ); | |
| } | |
| // --- KV Cache Read Logic --- | |
| const cacheKeyStr = `${username}_${themeParam}`; | |
| if (env.STATS_CACHE) { | |
| const cachedSvg = await env.STATS_CACHE.get(cacheKeyStr); | |
| if (cachedSvg) { | |
| return new Response(cachedSvg, { | |
| headers: { | |
| "Content-Type": "image/svg+xml", | |
| "Cache-Control": "public, max-age=60", | |
| "X-CF-Worker-Cache": "HIT" | |
| } | |
| }); | |
| } | |
| } | |
| // --- Prepare Request Headers --- | |
| const headers = { | |
| "User-Agent": "CF-Worker-Stats", | |
| "Accept": "application/vnd.github.v3+json" | |
| }; | |
| if (env.GITHUB_TOKEN && typeof env.GITHUB_TOKEN === 'string' && env.GITHUB_TOKEN.length > 0) { | |
| headers["Authorization"] = `Bearer ${env.GITHUB_TOKEN}`; | |
| } | |
| // --- Fetch User Info --- | |
| const userResp = await fetch(`https://api.github.com/users/${username}`, { headers }); | |
| if (!userResp.ok) { | |
| if (userResp.status === 403 && !env.GITHUB_TOKEN) { | |
| return new Response(`GitHub API Error: 403 Forbidden.\nRate limit exceeded. Please configure GITHUB_TOKEN in Cloudflare Workers settings.`, { status: 403 }); | |
| } | |
| return new Response(`GitHub API Error: ${userResp.status} - ${userResp.statusText}`, { status: userResp.status }); | |
| } | |
| const user = await userResp.json(); | |
| const displayUsername = user.login; | |
| // --- Fetch User Repositories (With Pagination) --- | |
| let repos = await fetchAllRepos(username, headers); | |
| // --- Calculate Statistics --- | |
| const stars = repos.reduce((acc, r) => acc + (r.stargazers_count || 0), 0); | |
| const forks = repos.reduce((acc, r) => acc + (r.forks_count || 0), 0); | |
| const publicRepos = user.public_repos; | |
| const followers = user.followers; | |
| const gists = user.public_gists; | |
| // --- Calculate Rank & Color --- | |
| const rankObj = calculateRank(stars, followers); | |
| // --- Calculate Top Languages (Original Repos Only) --- | |
| const originalRepos = repos.filter(r => !r.fork); | |
| const langMap = {}; | |
| originalRepos.forEach(r => { if(r.language) langMap[r.language]=(langMap[r.language]||0)+1; }); | |
| const total = Object.values(langMap).reduce((a,b)=>a+b,0); | |
| const topLangs = total === 0 ? [] : Object.entries(langMap) | |
| .sort((a,b)=>b[1]-a[1]) | |
| .slice(0,5) | |
| .map(([name,count])=>({ | |
| name, | |
| count, | |
| pct: ((count/total)*100).toFixed(0) | |
| })); | |
| // XML Escape function | |
| const escapeXml = (unsafe) => { | |
| return unsafe ? unsafe.replace(/[<>&'"]/g, c => { | |
| switch (c) { | |
| case '<': return '<'; | |
| case '>': return '>'; | |
| case '&': return '&'; | |
| case '\'': return '''; | |
| case '"': return '"'; | |
| } | |
| }) : ''; | |
| }; | |
| // --- Layout Parameters --- | |
| const startY = 65; | |
| const rowHeight = 25; | |
| const barHeight = 12; | |
| const halfRow = rowHeight / 2; | |
| const rectTopOffset = halfRow - (barHeight / 2); | |
| // --- Generate SVG Content --- | |
| let langBarsSvg = ""; | |
| const barStartX = 350; | |
| const maxBarWidth = 120; | |
| topLangs.forEach((lang, idx) => { | |
| const rowTopY = startY + (idx * rowHeight); | |
| const centerY = rowTopY + halfRow; | |
| const width = maxBarWidth * (Number(lang.pct) / 100); | |
| const safeLangName = escapeXml(lang.name); | |
| langBarsSvg += `<text x="${barStartX - 10}" y="${centerY}" dominant-baseline="middle" text-anchor="end" class="langText">${safeLangName} (${lang.pct}%)</text>`; | |
| langBarsSvg += `<rect x="${barStartX}" y="${rowTopY + rectTopOffset}" width="${width}" height="${barHeight}" fill="${selectedTheme.langBar[idx % selectedTheme.langBar.length]}" rx="3"/>`; | |
| }); | |
| let statsSvg = ""; | |
| const statsData = [ | |
| { label: "⭐ Earned Stars", value: stars }, | |
| { label: "👤 Followers", value: followers }, | |
| { label: "📦 Public Repos", value: publicRepos }, | |
| { label: "📝 Gists", value: gists }, | |
| { label: "🔁 Forks", value: forks }, | |
| ]; | |
| statsData.forEach((stat, idx) => { | |
| const rowTopY = startY + (idx * rowHeight); | |
| const centerY = rowTopY + halfRow; | |
| statsSvg += `<text x="25" y="${centerY}" dominant-baseline="middle" class="label">${stat.label}:</text>`; | |
| statsSvg += `<text x="150" y="${centerY}" dominant-baseline="middle" class="stat">${stat.value}</text>`; | |
| }); | |
| const svg = ` | |
| <svg width="495" height="195" viewBox="0 0 495 195" xmlns="http://www.w3.org/2000/svg"> | |
| <style> | |
| .title{font:600 20px "Inter","Segoe UI",Ubuntu,Sans-serif; fill:${selectedTheme.title}} | |
| .label{font:600 14px "Inter","Segoe UI",Ubuntu,Sans-serif; fill:${selectedTheme.label}} | |
| .stat{font:600 14px "Inter","Segoe UI",Ubuntu,Sans-serif; fill:${selectedTheme.stat}} | |
| .langText{font:600 11px "Inter","Segoe UI",Ubuntu,Sans-serif; fill:${selectedTheme.label};} | |
| .rankCircle{stroke: ${rankObj.color}; stroke-width: 5; fill: none; opacity: 0.8;} | |
| .rankText{font: 800 24px "Inter","Segoe UI",Ubuntu,Sans-serif; fill: ${rankObj.color}; dominant-baseline: middle; text-anchor: middle;} | |
| </style> | |
| <rect x="0.5" y="0.5" width="494" height="194" rx="10" fill="${selectedTheme.bg}" stroke="${selectedTheme.border}" stroke-width="1"/> | |
| <!-- Title --> | |
| <text x="25" y="40" class="title">${escapeXml(displayUsername)}'s GitHub Stats</text> | |
| <!-- Rank Circle (Top Right) --> | |
| <circle cx="440" cy="35" r="22" class="rankCircle"/> | |
| <text x="440" y="35" class="rankText">${rankObj.level}</text> | |
| ${statsSvg} | |
| ${langBarsSvg} | |
| </svg> | |
| `; | |
| // --- KV Cache Write Logic --- | |
| if (env.STATS_CACHE) { | |
| ctx.waitUntil( | |
| env.STATS_CACHE.put(cacheKeyStr, svg, { expirationTtl: 60 }) | |
| ); | |
| } | |
| return new Response(svg, { | |
| headers: { | |
| "Content-Type": "image/svg+xml", | |
| "Cache-Control": "public, max-age=60", | |
| "X-CF-Worker-Cache": "MISS" | |
| } | |
| }); | |
| } | |
| }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment