C#调用GitHub REST API批量下载用户公开仓库备份工具 📅 发布时间:2026/8/31 17:40:42 👁 浏览次数: 页面里明明挂着几十个项目非要一个个点下载这种活干一次就够够的了。这次我们用 C# 把它一次性解决掉写一个命令行小工具输入一个 GitHub 用户名自动把该用户名下的全部公开仓库按目录拉回本地。工具的逻辑不复杂核心就是调用 GitHub REST API 做分页拉取再决定是只下载 zip 源码包还是用 git clone 保留完整提交历史。因为实际用起来很顺手我把整个设计思路、代码实现和踩坑点整理成这篇文章。先给结论这个方案不需要第三方 SDK纯 C# 写 HttpClient 调用就行Windows、Linux、macOS 都能跑支持批量任务、并发控制、断点续传如果配合 GitHub Token速率限制可以从匿名时的 60 次/小时提升到认证后的 5000 次/小时。下面按环境准备、接口设计、代码实现、运行验证、问题排查的顺序展开文章末尾给一份常用排查清单。1. 核心能力速览能力项说明项目类型GitHub 用户公开仓库批量下载 / 归档工具开发语言C#基于 .NET 8 控制台应用主要依赖内置 HttpClient、System.Text.Json不需要第三方 SDK输入GitHub 用户名、输出目录、模式zip / clone、并发数输出按用户名/仓库名组织的本地目录可附加 repos.json 清单保存方式方式一下载 GitHub 官方 zip 源码包方式二git clone 完整仓库是否支持批量支持自动分页拉取该用户全部公开仓库是否支持断点续传支持已存在且非空的仓库目录会自动跳过是否支持接口集成可作为命令行程序调用也可以把核心方法嵌入到 .NET 项目中运行平台Windows / Linux / macOS需要安装 .NET 运行时GitHub API 速率匿名 60 次/小时带 Token 认证后 5000 次/小时典型场景项目备份、开源项目归档、账号迁移前的本地副本整理这里先解释一下为什么要自己写而不是直接用现成脚本。GitHub 官方提供的ghCLI 有gh repo clone但它一次只处理一个仓库要批量拉取某用户名下全部仓库还得自己写循环去拿仓库列表。网页端手动点 Download ZIP 对几十个仓库来说太低效而且目录命名、并发控制、失败重试都不好管理。用 C# 写一个专用小工具最大的好处是逻辑自己控制目录结构、跳过规则、并发数、是否保留 Git 历史全部按需求来。2. 适用场景与使用边界这个工具适合以下场景想把某位开源作者的所有仓库备份到本地避免之后仓库被删除或改名为 private。开发者在账号交接、迁移前需要留存一份完整的本地副本。团队内部定期对依赖的开源仓库做镜像归档。C# 开发者想学习怎么用 HttpClient 调用 REST API、处理分页和 JSON 解析。它也能解决一个很实际的问题GitHub 的用户主页只展示部分仓库不点「Repositories」标签看不到全部列表通过GET /users/{username}/repos接口可以一页 100 个把所有公开仓库拉全避免漏掉。使用边界和合规方面也要说清楚本工具默认只拉取公开仓库不要用它去爬取私有仓库或未授权内容。下载他人的开源项目做本地备份通常没问题但如果要重新分发或商用必须遵守仓库的 License 约定。仓库里可能包含密钥、配置文件、内部文档等敏感信息备份后要妥善保管不能随意公开。GitHub API 有速率限制超大账号要控制并发不要做超出平台规则的滥用式请求。一句话总结工具本身解决的是「批量归档公开项目」的合法需求不要在授权范围外使用。3. 环境准备与前置条件这个项目的环境要求很低完全不是那种吃显卡的 AI 项目。操作系统层面Windows 10/11、Linux、macOS 都可以。代码基于 .NET 8需要安装对应版本的 .NET SDK 或运行时。如果本机已经装了 Visual Studio 2022 或 JetBrains Rider可以直接新建控制台项目。依赖方面如果使用 zip 模式只需要 .NET 自带的 HttpClient 和 System.Text.Json如果使用 clone 模式还需要本机安装 Git并且git命令已经在 PATH 环境变量中。可以用下面的命令确认git --version dotnet --list-sdksGitHub Token 不是必须的。匿名请求也能拉公开仓库列表但速率限制只有 60 次/小时。正常情况下拉一个用户的仓库列表只需要 1 到几个 API 请求所以 60 次/小时通常够用如果要大批量、频繁执行建议配置一个 GitHub Personal Access Token推荐使用 fine-grained token只授予读取公开仓库元数据的权限不要给不必要的写权限。网络方面工具访问的域名有两个api.github.com负责拉取仓库元数据codeload.github.com负责下载 zip 源码包clone 模式会直接访问github.com。如果本机访问 GitHub 不稳定可以先确认这几个域名是否通、是否由本机网络环境导致选择网络状态较好的时段再跑。工具内部建议做重试但不要在代码里内置任何绕过网络限制的逻辑。磁盘空间按仓库总量预留。GitHub API 返回的仓库列表里有size字段单位是 KB可以先汇总一下估算总大小再决定下载方式。4. 设计思路GitHub REST API 与两种保存方式写这个工具之前先明确要调用哪些接口。获取用户仓库列表的 API 是GET https://api.github.com/users/{username}/repos?per_page100page{page}per_page最大可以设成 100page从 1 开始。返回值是一个 JSON 数组每个元素对应一个仓库。实际字段很多本文只关心这几个[ { id: 123456789, name: qzonearchive, full_name: gaoshu705/qzonearchive, private: false, html_url: https://github.com/gaoshu705/qzonearchive, description: 示例仓库, fork: false, language: Python, default_branch: main, size: 1234, clone_url: https://github.com/gaoshu705/qzonearchive.git } ]分页逻辑有两种写法。简单写法是如果当前页返回的仓库数量小于per_page说明已经到了最后一页否则继续请求下一页。更严谨的写法是解析响应头里的Link字段看有没有relnext。推荐在实际项目中优先用 Link header 判断上面的items.Count perPage判断作为兜底。拿到仓库列表后有两种保存方式。方式一zip 下载。公开仓库在 GitHub 官方下载域名codeload.github.com上有稳定的 zip 包URL 格式是https://codeload.github.com/{owner}/{repo}/zip/refs/heads/{branch}例如gaoshu705/qzonearchive的主分支是main那么 zip 地址就是https://codeload.github.com/gaoshu705/qzonearchive/zip/refs/heads/main这个方式的好处是快、占空间小坏处是不包含.git目录也就是没有提交历史。方式二git clone。保留完整提交历史适合做真正的备份。命令是git clone https://github.com/{owner}/{repo}.git如果要浅克隆只拉最近一次提交可以加--depth 1下载量会小很多。目录组织上我建议输出根目录下先按用户名建一层目录再按仓库名建一层目录backup/ └── gaoshu705/ ├── qzonearchive/ │ └── ... ├── other-repo/ │ └── ... └── repos.json这样做的好处是如果以后要同时备份多个用户目录不会互相混在一起。repos.json用来保存仓库元数据清单方便后续做索引和增量判断。5. 核心代码实现C# 完整示例下面直接给出一个可运行的控制台项目代码。为方便贴到 CSDN代码尽量精简核心方法拆成多个代码块读者可以按需组合。5.1 参数解析与主流程用最朴素的方式解析命令行参数避免引入额外的命令行解析库static (string username, string outputDir, string mode, int concurrency, bool dryRun, string? token) ParseArgs(string[] args) { string username ; string outputDir ./backup; string mode zip; int concurrency 3; bool dryRun false; string? token Environment.GetEnvironmentVariable(GITHUB_TOKEN); for (int i 0; i args.Length; i) { switch (args[i]) { case -u: username args[i]; break; case -o: outputDir args[i]; break; case --mode: mode args[i]; break; case --concurrency: concurrency int.Parse(args[i]); break; case --dry-run: dryRun true; break; case --token: token args[i]; break; } } if (string.IsNullOrEmpty(username)) throw new ArgumentException(缺少用户名请用 -u 用户名 指定); if (mode ! zip mode ! clone) throw new ArgumentException(--mode 仅支持 zip 或 clone); return (username, outputDir, mode, concurrency, dryRun, token); }主流程就是先拉列表再决定是 dry-run 打印清单还是逐个下载。using System.Text.Json; using System.Text.Json.Serialization; using System.Net.Http.Headers; Console.OutputEncoding System.Text.Encoding.UTF8; var (username, outputDir, mode, concurrency, dryRun, token) ParseArgs(args); var repos await GetAllReposAsync(username, token); Console.WriteLine($共发现 {repos.Count} 个公开仓库); string userRoot Path.Combine(outputDir, username); Directory.CreateDirectory(userRoot); if (dryRun) { foreach (var repo in repos) { Console.WriteLine(${repo.FullName} | {repo.Language ?? 未知语言} | {repo.DefaultBranch}); } return; } await ExportManifestAsync(repos, Path.Combine(userRoot, repos.json)); SemaphoreSlim semaphore new(concurrency); await Parallel.ForEachAsync(repos, async (repo, ct) { await semaphore.WaitAsync(ct); try { string targetDir Path.Combine(userRoot, repo.Name); if (ShouldSkip(repo.Name, userRoot)) { Console.WriteLine($跳过已存在的仓库{repo.Name}); return; } if (mode zip) { string zipPath targetDir .zip; await DownloadRepoZipAsync(repo, zipPath, token, ct); Console.WriteLine($zip 下载完成{repo.FullName}); } else { await CloneRepoAsync(repo.CloneUrl, targetDir, ct); Console.WriteLine($clone 完成{repo.FullName}); } } finally { semaphore.Release(); } }); Console.WriteLine(全部任务处理完成);注意这里 zip 模式我把 zip 文件保存成仓库名.zip避免和后续解压目录冲突。如果希望直接解压成目录可以用System.IO.Compression.ZipFile.ExtractToDirectory再做一步解压。5.2 分页拉取仓库列表核心的分页方法如下。为了代码清晰加了一个CreateHttpClient方法统一设置请求头static HttpClient CreateHttpClient(string? token) { var http new HttpClient(); http.DefaultRequestHeaders.Add(User-Agent, GitHubRepoSync/1.0); http.DefaultRequestHeaders.Add(Accept, application/vnd.githubjson); if (!string.IsNullOrEmpty(token)) { http.DefaultRequestHeaders.Authorization new AuthenticationHeaderValue(Bearer, token); } return http; } static async TaskListRepoInfo GetAllReposAsync(string username, string? token, CancellationToken ct default) { var repos new ListRepoInfo(); int page 1; const int perPage 100; using var http CreateHttpClient(token); while (true) { string url $https://api.github.com/users/{Uri.EscapeDataString(username)}/repos?per_page{perPage}page{page}; using var request new HttpRequestMessage(HttpMethod.Get, url); using var response await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); response.EnsureSuccessStatusCode(); string json await response.Content.ReadAsStringAsync(ct); var items JsonSerializer.DeserializeListRepoInfo(json); if (items null || items.Count 0) break; repos.AddRange(items); if (items.Count perPage) break; page; } return repos; }RepoInfo类只保留需要用到的字段internal class RepoInfo { [JsonPropertyName(name)] public string Name { get; set; } ; [JsonPropertyName(full_name)] public string FullName { get; set; } ; [JsonPropertyName(clone_url)] public string CloneUrl { get; set; } ; [JsonPropertyName(private)] public bool IsPrivate { get; set; } [JsonPropertyName(default_branch)] public string DefaultBranch { get; set; } main; [JsonPropertyName(language)] public string? Language { get; set; } }如果返回列表里包含了 private 仓库而你没有对应权限API 不会返回权限范围内私有仓库也会出现。默认公开账号的仓库列表不可能出现 private 仓库所以这个字段多数情况下是false。5.3 下载 zip 源码zip 下载直接用 codeload 域名公开仓库不需要 Token。下载时用流式写入避免一次性把大文件读进内存static async Task DownloadRepoZipAsync(RepoInfo repo, string zipPath, string? token, CancellationToken ct default) { string url $https://codeload.github.com/{repo.FullName}/zip/refs/heads/{repo.DefaultBranch}; using var http CreateHttpClient(token); using var response await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct); response.EnsureSuccessStatusCode(); Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(zipPath))!); await using var fs File.Create(zipPath); await response.Content.CopyToAsync(fs, ct); Console.WriteLine($仓库 {repo.FullName} 已下载到 {zipPath}); }这里有几个细节Path.GetDirectoryName返回空值时要处理!File.Create会覆盖同名文件所以最好先判断文件是否已存在避免重复下载。5.4 用 git clone 保留完整提交历史clone 模式需要调用本机 git 命令推荐用ProcessStartInfo.ArgumentList而不是字符串拼接避免路径里有空格或特殊字符时出问题static async Task CloneRepoAsync(string cloneUrl, string targetDir, CancellationToken ct default) { Directory.CreateDirectory(targetDir); var psi new ProcessStartInfo { FileName git, RedirectStandardOutput true, RedirectStandardError true, UseShellExecute false }; psi.ArgumentList.Add(clone); psi.ArgumentList.Add(--depth); psi.ArgumentList.Add(1); psi.ArgumentList.Add(cloneUrl); psi.ArgumentList.Add(targetDir); using var process Process.Start(psi); if (process null) throw new InvalidOperationException(无法启动 git 进程); string stderr await process.StandardError.ReadToEndAsync(ct); await process.WaitForExitAsync(ct); if (process.ExitCode ! 0) throw new InvalidOperationException($git clone 失败{stderr}); }--depth 1表示浅克隆只保留最近一次提交的快照速度比完整 clone 快很多。如果确实需要完整提交历史去掉这三行参数即可。5.5 并发控制与断点续传并发控制用SemaphoreSlim限制同时进行的 git 或下载任务数量避免把网络和磁盘打满static bool ShouldSkip(string repoName, string userRoot) { string targetDir Path.Combine(userRoot, repoName); return Directory.Exists(targetDir) Directory.EnumerateFileSystemEntries(targetDir).Any(); } static async Task ExportManifestAsync(ListRepoInfo repos, string jsonPath, CancellationToken ct default) { var options new JsonSerializerOptions { WriteIndented true }; await File.WriteAllTextAsync(jsonPath, JsonSerializer.Serialize(repos, options), ct); }ShouldSkip的规则很直接目标目录存在且非空就认为这个仓库已经备份过。zlong 目录只有.zip文件不算解压过下次如果要重新解压需要单独处理。这里最简单的方式是zip 模式下载完成后把 zip 文件先解压到临时目录然后移动到最终目录或者直接以 zip 文件形式保存只要ShouldSkip的规则与保存方式一致就行。6. 启动运行与功能验证6.1 命令行使用编译并运行控制台项目命令示例# 只打印仓库清单不下载 dotnet run -- -u gaoshu705 --dry-run # 下载 zip 源码包输出到 ./backup dotnet run -- -u gaoshu705 -o ./backup --mode zip # git clone 方式并发 2 dotnet run -- -u gaoshu705 -o ./backup --mode clone --concurrency 2 # 使用环境变量 GITHUB_TOKEN不写在命令行里 export GITHUB_TOKENghp_xxx dotnet run -- -u gaoshu705 -o ./backup --mode zip6.2 功能验证步骤第一次运行建议先用--dry-run验证用户名和 API 是否正常运行dotnet run -- -u gaoshu705 --dry-run。观察控制台输出的仓库数量和网页端「Repositories」标签页数量是否一致。再跑一次 zip 模式拉一两个仓库作为小规模测试。检查backup/gaoshu705/目录下是否生成对应的 zip 文件或仓库目录。打开repos.json确认仓库 name、full_name、default_branch 字段都已经写入。判断成功标准本地目录数量与 API 返回的仓库数量一致。每个仓库目录都存在且非空。下载较大的仓库时没有报 403 或 502。有 Token 时API 响应头里的X-RateLimit-Remaining不会降到 0。中断后再次运行已经下载完成的仓库会被自动跳过。6.3 失败排查思路如果--dry-run就报错先看 API 返回的 HTTP 状态码。404 说明用户名可能写错或账号不存在403 说明触发了速率限制或缺少必要的请求头如果是 JSON 解析异常多半是把 HTML 错误页当成 JSON 来解析了。先用 curl 直接请求一下 API 确认curl -i https://api.github.com/users/gaoshu705/repos?per_page100page1如果 curl 能返回 JSON说明问题在 C# 请求头设置或网络代理配置如果 curl 也不通先检查本机到api.github.com的网络连通性。7. 资源占用与性能观察这个工具不吃 GPU 也不吃大量内存主要资源瓶颈在磁盘和网络。GitHub API 拉取列表本身很快一页 100 个仓库的响应通常在几百毫秒到几秒之间。主要耗时在 zip 下载和 git clone。zip 模式不包含.git目录体积小下载快clone 模式会拉取完整 Git 对象仓库较大时明显更慢。如果只是要做源码快照zip 模式性价比最高如果要保留历史记录和分支信息才需要用 clone。磁盘占用方面GitHub API 返回的size字段单位是 KB可以用它预估总大小# 只统计非 fork 仓库的总大小单位 KB curl -s https://api.github.com/users/gaoshu705/repos?per_page100page1 | jq [.[] | select(.fork false) | .size] | add注意这个 size 并不完全等于实际下载体积因为 zip 压缩后会更小而 git clone 因为有.git目录会更接近原始仓库体积。把它当成粗略估算就行。并发数对性能影响最大。--concurrency 1时最稳但速度慢--concurrency 5以上可能触发 GitHub 的二级速率限制或者把你本机带宽占满。我的建议是普通场景用 2-4仓库数量特别多的时候可以先用--dry-run看一下总数量再根据网络情况调整。显存和 CPU 占用完全不用关心这类工具在笔记本上跑也不会发烫。8. 常见问题与排查方法问题现象可能原因排查方式解决方案返回 403 Forbidden匿名请求触发 API 速率限制或没有设置 User-Agent查看响应头X-RateLimit-Remaining配置 Token降低请求频率返回 404用户名不存在、大小写错误或仓库已删除浏览器打开用户主页确认核对用户名返回空列表用户存在但没有任何公开仓库或写错了 API 路径使用 curl 请求仓库列表接口检查路径确认账号类型仓库列表只有 30 个没有显式设置per_page100查看 API URL 参数请求时加per_page100zip 下载 404default_branch不是 main/master查看仓库 API 里 default_branch 字段用默认分支字段拼 URLgit clone 失败本机未安装 Git或 git 命令不在 PATH运行git --version安装 Git把 Git 加入 PATH下载大仓库很慢仓库体积大、网络带宽有限看 git 输出是否有进度降低并发或改用 zip 模式运行中断后重复下载没有跳过已存在目录检查日志增加断点续传逻辑JSON 解析异常返回的不是 JSON可能是 403 或 502 页面curl 直接请求看响应内容调整请求头等待限速窗口端口冲突本项目是命令行工具不监听端口无需处理无GitHub 网页打不开但 API 通github.com和api.github.com是不同服务curl api.github.com 单独测试优先处理网络连通性问题工具内做好重试最容易踩的坑其实是两个一个是per_page默认是 30不显式设 100 就会漏仓库另一个是 fork 仓库要不要跳过。很多人备份某用户的仓库时其实只想备份他原创建的项目fork 来的仓库会在列表里占很大比例。建议在GetAllReposAsync之后加一个过滤repos repos.Where(r !r.Fork).ToList();RepoInfo里加一个Fork属性即可[JsonPropertyName(fork)] public bool Fork { get; set; }是否过滤 fork 取决于需求。做镜像备份就保留 fork做源码整理就过滤掉。9. 最佳实践与使用建议先说安全。Token 不要硬编码在代码里也不要直接写在命令行参数里命令行参数会被进程列表看到。推荐用环境变量GITHUB_TOKEN传入或者写进配置文件后把配置文件加入.gitignore。如果使用 Linux 服务器还可以把 token 文件权限设成 600。工程习惯上建议把下载源、输出目录、并发数、Token 都做成配置文件保持 CLI 参数尽量简单。第一次执行时先跑--dry-run确认仓库数量符合预期再跑正式下载。批量任务一定要加日志每条任务都输出仓库名和最终状态这样即使中途断开恢复后也能确认哪些仓库已完成。模型或代码类备份的方向上有一个很容易被忽略的点仓库里如果有 Git LFS 大文件zip 模式下载的压缩包不一定包含 LFS 文件本体clone 模式也需要注意 LFS 是否已经 fetch。如果仓库依赖 LFS建议单独验证备份文件是否完整。再补充一个批量任务的重试策略。上面的Parallel.ForEachAsync是全部任务统一并发如果一个仓库失败会抛异常打断整个循环。工程上更稳妥的方式是给每个任务包一层 try-catch记录失败原因统计完成后统一重试失败项int successCount 0; int failCount 0; await Parallel.ForEachAsync(repos, async (repo, ct) { try { // 下载或 clone Interlocked.Increment(ref successCount); } catch (Exception ex) { Interlocked.Increment(ref failCount); Console.WriteLine($失败{repo.FullName}原因{ex.Message}); } }); Console.WriteLine($成功 {successCount}失败 {failCount});对于超时和大仓库可以给 HttpClient 设置较长的超时时间比如 10 分钟。codeload 下载大文件时如果长时间没有响应需要判断是网络问题还是仓库本身太大不要盲目增加超时。目录管理方面建议最终形成这样的结构backup/ ├── gaoshu705/ │ ├── repos.json │ ├── qzonearchive/ │ └── other-repo/ ├── logs/ │ └── 2025-01-01.log └── temp/临时目录专门放下载中的 zip 文件下载完成后移动到正式目录防止半截文件被误认为完整备份。10. 总结与下一步这个 C# 工具解决了一个很具体的问题批量把某个 GitHub 用户下的公开仓库完整拉回本地。它不是 AI 项目不需要显卡不需要几百 GB 的模型文件只要有 .NET 8 和 Git几十行代码就能跑通。最值得试的一点是「一条命令拉全某用户全部仓库」尤其适合做开源项目归档、账号迁移前的本地副本和项目备份。建议先验证三件事一是用--dry-run确认能拿到完整的仓库列表二是用一个小账号跑通 zip 下载三是在大账号上测试断点续传和并发控制。最容易踩的坑是速率限制只要记得带 Token并把per_page设为 100大部分问题都能绕开。后续可以扩展的方向不少增量更新时对比每个仓库最新的 commit SHA只拉有变动的仓库把repos.json转成 Markdown 索引生成一个本地可读的项目导航页把工具包装成定时任务每周自动化同步一次依赖仓库的镜像或者把核心方法改造成一个类库统一供多个内部工具调用。只要把 API 分页、下载模式和断点续传这三块写稳这个工具就能从「临时脚本」变成「长期可用的备份基础设施」。建议收藏备用下次需要批量归档 GitHub 项目时直接照着这篇搭一个就行。