尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

C#调用GitHub API批量获取用户仓库并导出CSV

C#调用GitHub API批量获取用户仓库并导出CSV 做开源项目调研、整理团队技术资产、或者想把某位开发者的所有仓库信息备份下来时很多人都会遇到同一个尴尬场景GitHub 网页翻页翻到手指发酸仓库数量一多项目名称、语言、Star 数、最后更新时间手工根本记不过来。网上搜“GitHub 爬虫”出来的大多又是 Python 方案对平时主要写 C# 的开发者来说还得额外搭一套环境。本文就用 C# 写一个命令行工具通过 GitHub 官方 REST API 把指定用户的所有公开仓库批量拉取下来并支持导出 CSV、按条件筛选、批量下载源码包。我们会先讲清楚 API 调用规则再给出完整可运行的代码最后补充常见报错和工程化建议。全程不需要第三方爬虫框架.NET 自带的 HttpClient 和 System.Text.Json 就够了。1. 场景与核心概念1.1 这个工具解决什么问题假设你现在有这样一个需求把某个 GitHub 账号下的全部仓库信息整理成表格包含仓库名、描述、主要语言、Star 数、Fork 数、最后更新时间。如果仓库只有五六个手动复制倒是无所谓。但遇到几百个仓库的账号网页翻页就会非常痛苦而且 GitHub 网页上每页展示的仓库数量有限翻到最后很容易漏掉。更麻烦的是手工整理出来的数据格式五花八门后续做排序、筛选、统计都得重来。这时候写一个小工具批量调接口几秒钟就能拿到结构化数据。工具可以做的几件事包括拉取指定用户的所有公开仓库。按 Star 数、语言、更新时间排序。去掉 fork 的仓库只保留原创项目。导出 CSV 表格。批量下载仓库源码压缩包。这些功能本质上都是“读数据”不需要模拟登录也不需要处理网页反爬唯一要做的就是把 GitHub 官方 API 用对。1.2 为什么选择 GitHub REST API标题里写的是“爬虫”但真正实现时更推荐直接调 GitHub REST API而不是用 HttpClient 去抓 HTML 页面再解析。主要原因有三个数据是标准 JSON字段稳定。仓库名、描述、语言、Star 数等字段都有固定命名反序列化之后直接用不需要处理 HTML 结构变化。不需要处理登录、动态渲染、反爬策略。GitHub 网页本身是动态页面直接抓 HTML 只能拿到部分数据还得额外解析。更合规。GitHub 官方 API 就是给开发者批量读取数据用的只要控制好频率、带上认证信息就属于正常使用。而高强度抓取网页可能违反服务条款。所以这里的“爬取”本质上就是把官方数据通道用熟练。理解这一点后面写代码就不会走偏。1.3 用 C# 实现的优势很多 GitHub API 示例都是用 Python 或 Node.js 写的但 C# 开发者完全没必要为了一个小工具切换技术栈。.NET 生态里几样东西已经足够HttpClient发起 HTTP 请求。System.Text.Json解析 JSON。顶层语句Top-Level Statements用最少的代码写完一个工具。而且 .NET 是跨平台的Windows、macOS、Linux 上都能跑。哪怕你只是为了调研某个技术大牛的仓库用 C# 写个几十行的工具也完全够用。2. 环境准备与项目创建2.1 开发环境说明本文示例以 .NET 8 控制台项目为例代码中用到的 API 在 .NET 6 / .NET 7 / .NET 9 下同样可以使用。版本需要根据你的项目实际情况调整如果本地只装了 .NET 6 SDK直接按同样步骤创建项目即可。建议环境如下项目说明操作系统Windows 10/11、macOS、Linux 均可.NET SDK.NET 8.0或其他已安装版本IDEVisual Studio 2022、VS Code 或 JetBrains Rider额外依赖无使用 .NET 内置库先确认本机 .NET 环境是否正常dotnet --version如果能正常输出版本号说明 SDK 已经装好。2.2 创建控制台项目打开终端新建一个名为GitHubRepoCrawler的控制台项目dotnet new console -n GitHubRepoCrawler cd GitHubRepoCrawler创建完成后的目录结构是这样的GitHubRepoCrawler/ ├── GitHubRepoCrawler.csproj └── Program.cs我们会在后续步骤中手动补充Models和Services两个文件夹。2.3 项目结构设计项目虽小但建议还是按职责分一下文件避免所有代码堆在 Program.cs 里。最终结构如下GitHubRepoCrawler/ ├── GitHubRepoCrawler.csproj ├── Program.cs ├── Models/ │ └── GitHubRepo.cs └── Services/ ├── GitHubApiClient.cs └── ExportHelper.csModels定义 GitHub 仓库数据模型。Services封装 API 调用和 CSV 导出逻辑。Program.cs只负责用户交互和流程编排。这样后续想增加搜索、下载、统计功能都有地方放代码。3. GitHub API 调用规则拆解在写代码之前先把几个关键规则弄清楚。很多人第一次调 GitHub API 就卡在分页和限流上其实这些规则并不复杂。3.1 仓库列表接口获取某个用户所有公开仓库的接口是GET https://api.github.com/users/{username}/repos其中{username}就是 GitHub 用户名例如octocat。这个接口支持很多查询参数常用的有参数作用示例per_page每页返回数量最大 100per_page100page页码从 1 开始page2type仓库类型可选 all/owner/membertypeownersort排序字段可选 created/updated/pushed/full_namesortupdateddirection排序方向asc 或 descdirectiondesc重点记住per_page和page这是实现翻页抓取的关键。3.2 分页机制GitHub REST API 默认每页只返回 30 条数据。如果不设置per_page一个 100 个仓库的账号你只能拿到前 30 个这是新手最容易踩的坑。分页逻辑很简单设置per_page100从page1开始请求如果这一页返回的仓库数量等于 100说明可能还有下一页继续请求page2如果返回数量小于 100说明已经到最后一页结束循环。另外GitHub 会在响应头里返回Link字段例如Link: https://api.github.com/user/repos?page2; relnext, https://api.github.com/user/repos?page34; rellast通过解析relnext和rellast也能判断翻页但对我们的工具来说直接判断返回数量反而更简单直观。3.3 身份认证与访问限制GitHub API 的访问限制非常明确未认证请求每个 IP 每小时 60 次。认证请求每个账号每小时 5000 次。拉取大量仓库时60 次限额根本不够用所以建议一定带上认证信息。认证方式是在请求头里加Authorization: Bearer token同时 GitHub 强制要求请求带上User-Agent请求头否则直接返回 403。申请一个 Personal Access Token 的流程如下登录 GitHub打开 Settings。进入 Developer settings - Personal access tokens。选择 Tokens (classic)点击 Generate new token (classic)。如果只读公开仓库不需要勾选任何 scope如果要读私有仓库需要勾选repo。生成后复制 token保存到环境变量GITHUB_TOKEN中。现在 GitHub 也推荐使用 Fine-grained personal access token可以精确限制到某个仓库、只读权限对安全性要求更高的场景更合适。无论是 classic 还是 fine-grained在请求头里的用法是一样的。3.4 响应中的关键字段仓库列表接口返回的是一个 JSON 数组每个元素对应一个仓库。我们关心的字段如下JSON 字段含义id仓库 IDname仓库名full_name完整名称例如owner/repohtml_url仓库页面地址description仓库描述language主要编程语言stargazers_countStar 数forks_countFork 数fork是否为 fork 仓库created_at创建时间updated_at最后更新时间clone_urlgit clone 地址default_branch默认分支名C# 反序列化时需要处理一个命名差异JSON 字段是小写下划线命名C# 属性是 PascalCase 命名。解决方案是用[JsonPropertyName]特性做映射例如[JsonPropertyName(stargazers_count)] public int StargazersCount { get; set; }不写这个特性反序列化出来全是默认值这是很常见的错误。4. 完整实战C# 爬取 GitHub 用户仓库4.1 定义仓库模型在项目中新建Models/GitHubRepo.cs内容如下using System.Text.Json.Serialization; namespace GitHubRepoCrawler.Models; public class GitHubRepo { [JsonPropertyName(id)] public long Id { get; set; } [JsonPropertyName(name)] public string Name { get; set; } string.Empty; [JsonPropertyName(full_name)] public string FullName { get; set; } string.Empty; [JsonPropertyName(html_url)] public string HtmlUrl { get; set; } string.Empty; [JsonPropertyName(description)] public string? Description { get; set; } [JsonPropertyName(language)] public string? Language { get; set; } [JsonPropertyName(stargazers_count)] public int StargazersCount { get; set; } [JsonPropertyName(forks_count)] public int ForksCount { get; set; } [JsonPropertyName(fork)] public bool IsFork { get; set; } [JsonPropertyName(created_at)] public DateTime CreatedAt { get; set; } [JsonPropertyName(updated_at)] public DateTime UpdatedAt { get; set; } [JsonPropertyName(clone_url)] public string CloneUrl { get; set; } string.Empty; [JsonPropertyName(default_branch)] public string DefaultBranch { get; set; } string.Empty; }这里把fork字段映射成IsFork是因为 C# 中Fork作为属性名没问题但语义上IsFork更清楚而且代码里读起来更自然。4.2 封装 API 客户端在Services/GitHubApiClient.cs中封装所有 API 调用。这样主程序不需要关心 URL 拼接、请求头、限流处理等细节。using System.Net; using System.Text.Json; using GitHubRepoCrawler.Models; namespace GitHubRepoCrawler.Services; public class GitHubApiClient { private readonly HttpClient _http; private const string BaseUrl https://api.github.com; private const int PerPage 100; public GitHubApiClient(string? token null) { _http new HttpClient(); _http.Timeout TimeSpan.FromSeconds(30); // GitHub API 强制要求 User-Agent缺少会返回 403 _http.DefaultRequestHeaders.Add(User-Agent, GitHubRepoCrawler/1.0); _http.DefaultRequestHeaders.Add(Accept, application/vnd.githubjson); if (!string.IsNullOrEmpty(token)) { _http.DefaultRequestHeaders.Authorization new System.Net.Http.Headers.AuthenticationHeaderValue(Bearer, token); } } public async TaskListGitHubRepo GetUserReposAsync(string username, bool includeForks false) { var result new ListGitHubRepo(); var page 1; while (true) { var url ${BaseUrl}/users/{username}/repos?per_page{PerPage}page{page}; var response await _http.GetAsync(url); if (response.StatusCode HttpStatusCode.Forbidden || response.StatusCode HttpStatusCode.TooManyRequests) { var resetHeader response.Headers.TryGetValues(X-RateLimit-Reset, out var resetValues) ? resetValues.FirstOrDefault() : 未知; var remainingHeader response.Headers.TryGetValues(X-RateLimit-Remaining, out var remainValues) ? remainValues.FirstOrDefault() : 未知; Console.WriteLine($请求已触发限流剩余配额: {remainingHeader}重置时间戳: {resetHeader}); break; } response.EnsureSuccessStatusCode(); var json await response.Content.ReadAsStringAsync(); var pageRepoList JsonSerializer.DeserializeListGitHubRepo(json) ?? new ListGitHubRepo(); if (pageRepoList.Count 0) { break; } // 记录原始页大小用于判断是否已到最后一页 var rawCount pageRepoList.Count; if (!includeForks) { pageRepoList pageRepoList.Where(r !r.IsFork).ToList(); } result.AddRange(pageRepoList); // 如果这一页不足 100 条说明已经是最后一页 if (rawCount PerPage) { break; } page; } return result; } }这里有一个很容易被忽略的细节判断是否到最后一页时要看过滤前的数量。如果用过滤后的数量判断一个页面里恰好 100 条全是 fork 仓库时过滤后数量为 0程序会误判为“没有更多数据”提前退出循环。如果不需要排除 fork直接调用GetUserReposAsync(username, includeForks: true)即可。4.3 编写主程序Program.cs 负责用户交互和结果展示using System.Text; using GitHubRepoCrawler.Models; using GitHubRepoCrawler.Services; Console.OutputEncoding Encoding.UTF8; Console.Write(请输入 GitHub 用户名: ); var username Console.ReadLine()?.Trim(); if (string.IsNullOrEmpty(username)) { Console.WriteLine(用户名不能为空。); return; } var token Environment.GetEnvironmentVariable(GITHUB_TOKEN); var client new GitHubApiClient(token); Console.WriteLine($正在从 GitHub API 拉取 {username} 的公开仓库...); var repos await client.GetUserReposAsync(username, includeForks: false); Console.WriteLine($共获取 {repos.Count} 个仓库已排除 fork。); // 按 Star 数排序后打印前 20 个 var topRepos repos.OrderByDescending(r r.StargazersCount).Take(20).ToList(); foreach (var repo in topRepos) { var language string.IsNullOrEmpty(repo.Language) ? 未知 : repo.Language; Console.WriteLine(${repo.StargazersCount,5} ★ {language,-12} {repo.FullName}); } // 导出 CSV var csvPath repos.csv; await ExportHelper.ExportToCsvAsync(repos, csvPath); Console.WriteLine($仓库列表已导出到: {Path.GetFullPath(csvPath)});4.4 导出 CSV 的工具类为了让表格能在 Excel / WPS 里直接打开我们再写一个 CSV 导出工具。注意描述字段经常包含逗号和双引号必须做转义否则导出的 CSV 会错列。using System.Text; using GitHubRepoCrawler.Models; namespace GitHubRepoCrawler.Services; public static class ExportHelper { public static async Task ExportToCsvAsync(ListGitHubRepo repos, string path) { var sb new StringBuilder(); sb.AppendLine(name,full_name,language,description,stargazers_count,forks_count,html_url,updated_at); foreach (var repo in repos.OrderByDescending(r r.StargazersCount)) { var line string.Join(,, CsvEscape(repo.Name), CsvEscape(repo.FullName), CsvEscape(repo.Language ?? ), CsvEscape(repo.Description ?? ), repo.StargazersCount.ToString(), repo.ForksCount.ToString(), CsvEscape(repo.HtmlUrl), repo.UpdatedAt.ToString(yyyy-MM-dd)); sb.AppendLine(line); } await File.WriteAllTextAsync(path, sb.ToString(), Encoding.UTF8); } private static string CsvEscape(string value) { if (value.Contains(,) || value.Contains() || value.Contains(\n) || value.Contains(\r)) { return $\{value.Replace(\, \\)}\; } return value; } }CSV 转义规则并不复杂如果字段里包含逗号、双引号或换行就用双引号把整个字段包起来字段内的双引号用两个双引号代替。这是标准 CSV 的处理方式。4.5 运行与验证回到终端运行项目dotnet run首次运行时如果本机没有配置GITHUB_TOKEN程序也能运行但受限流影响每小时只能请求 60 次。建议先设置环境变量# Windows PowerShell $env:GITHUB_TOKEN 你的token # macOS / Linux export GITHUB_TOKEN你的token以某个仓库数量较多的账号为例正常输出如下请输入 GitHub 用户名: octocat 正在从 GitHub API 拉取 octocat 的公开仓库... 共获取 8 个仓库已排除 fork。 2626 ★ Hello-World octocat/Hello-World 136 ★ Spoon-Knife octocat/Spoon-Knife 1 ★ octocat.github.io octocat/octocat.github.io 仓库列表已导出到: /Users/xxx/GitHubRepoCrawler/repos.csv打开repos.csv就能看到一个带表头的仓库清单可以直接用 Excel 打开做进一步筛选。5. 功能扩展基础功能已经跑通接下来可以按实际需要扩展。5.1 按条件筛选拿到完整仓库列表后可以用 LINQ 做各种筛选不需要额外请求接口。只看 C# 项目var csharpRepos repos .Where(r string.Equals(r.Language, C#, StringComparison.OrdinalIgnoreCase)) .OrderByDescending(r r.StargazersCount) .ToList();只看 Star 数超过 100 的项目var hotRepos repos .Where(r r.StargazersCount 100) .OrderByDescending(r r.StargazersCount) .ToList();筛选逻辑写在内存里速度非常快也比反复调 API 更节省配额。5.2 批量下载仓库源码包如果你想把某个账号的所有仓库源码备份到本地可以调 GitHub 的存档下载地址。下载链接的格式是https://github.com/{owner}/{repo}/archive/refs/heads/{default_branch}.zip在GitHubApiClient中增加一个下载方法public async Task DownloadArchiveAsync(GitHubRepo repo, string outputDir) { if (repo.IsFork) { return; } Directory.CreateDirectory(outputDir); var zipUrl $https://github.com/{repo.FullName}/archive/refs/heads/{repo.DefaultBranch}.zip; var filePath Path.Combine(outputDir, ${repo.Name}-{repo.DefaultBranch}.zip); using var response await _http.GetAsync(zipUrl); if (!response.IsSuccessStatusCode) { Console.WriteLine($下载失败: {repo.FullName}状态码: {(int)response.StatusCode}); return; } await using var fileStream File.Create(filePath); await response.Content.CopyToAsync(fileStream); Console.WriteLine($下载完成: {filePath}); }然后在 Program.cs 中调用Console.Write(是否下载全部仓库的 zip 包? (y/n): ); if (Console.ReadLine()?.Trim().ToLower() y) { var outputDir Path.Combine(Directory.GetCurrentDirectory(), downloads); foreach (var repo in repos) { await client.DownloadArchiveAsync(repo, outputDir); } }这个链接默认是跟随重定向到codeload.github.com的HttpClient 默认会自动处理重定向所以直接请求即可。5.3 使用搜索接口如果不想先拉全部仓库再筛选也可以用 GitHub 搜索接口直接在服务端过滤GET https://api.github.com/search/repositories?quser:{username}language:C%23per_page100在GitHubApiClient中增加搜索方法public async TaskListGitHubRepo SearchReposAsync(string query) { var url ${BaseUrl}/search/repositories?q{Uri.EscapeDataString(query)}per_page{PerPage}; var response await _http.GetAsync(url); response.EnsureSuccessStatusCode(); var json await response.Content.ReadAsStringAsync(); var searchResult JsonSerializer.DeserializeSearchResult(json); return searchResult?.Items ?? new ListGitHubRepo(); } private class SearchResult { [JsonPropertyName(items)] public ListGitHubRepo Items { get; set; } new(); }调用方式var result await client.SearchReposAsync(user:octocat language:C#);注意搜索接口的配额限制独立计算未认证时每小时只有 10 次认证后是每小时 30 次。如果只是拉取某个账号的全部仓库用普通列表接口就够了。6. 常见问题与排查实际运行中最容易遇到下面几类问题问题现象常见原因解决思路返回 401 UnauthorizedToken 无效或已过期重新生成 Token检查环境变量是否生效返回 403 Forbidden缺少 User-Agent或触发限流添加 User-Agent 请求头等待配额重置返回 0 个仓库用户名不存在或全部是 fork 且被过滤先确认用户名拼写临时设置 includeForks: true只拿到 30 条没有设置 per_page100请求 URL 中补充 per_page 参数反序列化后字段全是默认值缺少 JsonPropertyName 映射检查属性名是否和 JSON 小写下划线命名对应中文描述乱码控制台编码问题设置 Console.OutputEncoding Encoding.UTF8请求超时网络不通或连接不稳定检查本机能否正常访问 api.github.com适当调大 HttpClient.Timeout搜索接口返回 422查询语法有问题检查 q 参数是否 URL 编码空格用 代替逐个解释容易踩坑的几个。404 用户不存在时EnsureSuccessStatusCode会抛异常程序直接中断。如果你想更友好地提示可以改成if (response.StatusCode HttpStatusCode.NotFound) { Console.WriteLine($用户 {username} 不存在请检查拼写。); return new ListGitHubRepo(); }403 有两种常见情况。一种是没有 User-Agent这种情况加上头就好。另一种是限流响应头里的X-RateLimit-Remaining会变成 0此时不要再继续循环应该等待X-RateLimit-Reset时间戳对应的时间后再请求。中文乱码问题在 Windows 控制台尤其明显。Windows 默认代码页可能是 GBK而输出 UTF-8 字符串时就会显示成乱码。在 Program.cs 开头加一行Console.OutputEncoding Encoding.UTF8;即可macOS 和 Linux 终端一般不需要。7. 工程化最佳实践小工具写完能跑只是第一步。如果要放到团队内部持续使用下面这些点值得认真考虑。7.1 凭据管理Token 绝对不要硬编码在代码里也不要提交到 Git 仓库。用环境变量读取是最简单的方式var token Environment.GetEnvironmentVariable(GITHUB_TOKEN);如果是 Visual Studio 项目也可以用 User Secrets 保存开发环境的 Token。生产环境如果跑在服务器上建议结合密钥管理服务例如 Azure Key Vault 或云厂商的密钥管理产品。原则只有一个Token 属于敏感信息最小范围保存最小权限使用。读公开仓库时不需要给 Token 任何写权限。7.2 限流与重试即使带了 Token仍然可能触发限流。比较稳妥的做法是请求前检查X-RateLimit-Remaining当剩余次数很少时主动暂停。触发限流后读取Retry-After或X-RateLimit-Reset等到重置时间再继续。对网络异常做重试重试间隔采用指数退避比如 1 秒、2 秒、4 秒。一个简单的指数退避重试逻辑可以这样写for (int retry 0; retry 3; retry) { try { var response await _http.GetAsync(url); response.EnsureSuccessStatusCode(); return response; } catch (HttpRequestException) when (retry 2) { await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, retry))); } }7.3 增量同步与本地缓存如果每天都要拉取同一批仓库每次都全量请求很浪费配额。可以利用仓库的updated_at字段做增量更新第一次全量拉取后把数据存到本地 JSON 文件下次只对比时间重新拉取更新时间更晚的仓库。当然 GitHub API 的列表接口本身也支持since参数但对“某个用户的全部仓库”这种场景本地缓存往往更简单可控。7.4 并发控制批量下载 zip 包时并发能明显提速但也不能无脑并发。用SemaphoreSlim限制同时下载的任务数例如同时最多下载 5 个using var semaphore new SemaphoreSlim(5); var tasks repos.Select(async repo { await semaphore.WaitAsync(); try { await client.DownloadArchiveAsync(repo, outputDir); } finally { semaphore.Release(); } }); await Task.WhenAll(tasks);这样做既提升效率又不会因为并发过高导致网络连接被 GitHub 拒绝。7.5 合规边界这个工具读的是公开数据用的是官方 API本身没有合规问题。但要注意几点控制请求频率不要用超高频请求骚扰服务器。拉取的数据如果用于商业用途注意遵守 GitHub 的服务条款和数据处理要求。如果涉及私有仓库必须确保你有合法授权并且遵循最小权限原则。一句话总结官方 API 能做到的事优先用 API不要为了“爬虫”两个字去抓 HTML既不高效也不安全。8. 总结与下一步到这一步我们已经用 C# 完成了一个能用的 GitHub 仓库抓取工具支持按用户名拉取全部公开仓库。自动处理分页最多每页 100 条。支持排除 fork 仓库。支持按 Star 数排序和打印。支持导出 CSV。支持扩展批量下载源码包和搜索接口。代码量不大但对新手来说完整走一遍还是能学到不少东西HttpClient 请求头设置、System.Text.Json 反序列化、分页循环的判断、CSV 特殊字符转义、限流响应处理这些都是日常开发里非常常用的能力。下一步如果想继续深入方向也很清晰把控制台工具改造成 WinForm / WPF 界面输入用户名、点击按钮就能看到仓库表格。增加统计功能比如按语言分组统计仓库数量画出饼图。换成 GitHub GraphQL API一次请求拿更多字段减少请求次数。代码写好了接下来就看你拿它去整理哪位大佬的仓库了。建议先拿一个仓库数量不多的账号试跑一遍确认输出格式符合预期再处理几百个仓库的大账号避免首次运行就触发限流。
返回列表