Skip to content

Instantly share code, notes, and snippets.

@chenxuan520
Last active December 12, 2025 06:20
Show Gist options
  • Select an option

  • Save chenxuan520/01757d8ce84f7acac71adcae2651f7a4 to your computer and use it in GitHub Desktop.

Select an option

Save chenxuan520/01757d8ce84f7acac71adcae2651f7a4 to your computer and use it in GitHub Desktop.
/**
* ======================================================================================
* 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 中即可:
* ![Stats](https://my-worker.username.workers.dev/?username=chenxuan520&theme=dracula)
*
* 🔑 环境变量配置 (可选但推荐)
* --------------------------------------------------------------------------------------
* [配置 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:
* ![Stats](https://my-worker.username.workers.dev/?username=chenxuan520&theme=dracula)
*
* 🔑 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 '&lt;';
case '>': return '&gt;';
case '&': return '&amp;';
case '\'': return '&apos;';
case '"': return '&quot;';
}
}) : '';
};
// --- 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