C#调用GitHub API实现用户仓库批量下载与克隆
2026/8/31 2:12:46 网站建设 项目流程

如果你经常逛 GitHub,又用 C# 写工具,大概率有想过一个问题:能不能用程序把某个用户的所有公开项目一次性拉下来。我最近真写了这么一个小工具,用来备份、学习和分析开源作者的项目结构,整体效果还不错。这篇就把思路、核心代码、参数取舍和踩坑点拆开讲。需要先说明白:它不是爬网页,而是走 GitHub 官方 API 拿仓库列表,再按需下载 zip 或用 git clone 落盘。适合谁看?经常看开源项目、想批量整理别人仓库、给开源作者做备份、或者想分析一个账号下项目结构的人,都可以参考。

1. 先搞清楚“全部项目”到底指什么

1.1 列表信息用 GitHub API,不用页面 HTML

很多人想到的第一版方案是直接请求用户主页,然后从 HTML 里抠仓库链接。这个做法能跑通,但非常脆。GitHub 的页面结构会调整,登录状态和页面渲染方式也会影响结果,而且纯文本解析很容易被网页里的大量脚本和标签干扰。

更稳的做法是使用 GitHub 官方 REST API。取某个用户所有公开仓库时,接口很简单:

https://api.github.com/users/{username}/repos?per_page=100&page=1

这个接口返回的是 JSON 数组,每一条就是一个仓库,包含仓库名、完整名称、clone 地址、默认分支、大小、描述、是否 fork 等信息。结构化、稳定、好解析,比从 HTML 里抠数据靠谱得多。

需要先确认一个容易忽略的问题:这个接口默认只会返回该用户的公开且为 owner 的仓库。换句话说,私有仓库不会出现,除非你有授权;fork 过的仓库默认也不会出现。如果你想把 fork 也算进去,可以显式加type=all,然后在代码里再根据fork字段过滤。我一般默认不包含 fork,因为大多数人备份某个开源作者的资料时,看的是原创项目。

1.2 下载到本地有两条路线:zip 和 git clone

拿完仓库列表后,真正“把项目弄下来”有两种方式,取决于你的用途。

第一种:下载 zip 压缩包。这种方式速度最快,只拿当前默认分支的文件,不带 git 历史,适合只要源码、文档、配置的人。GitHub 的 zip 下载地址是:

https://github.com/{owner}/{repo}/archive/refs/heads/{default_branch}.zip

这里面default_branch通常就是mainmaster,但不要写死,应该从列表接口返回的default_branch字段取。

第二种:执行git clone这种方式会拿到完整仓库,包括历史记录、分支、标签,下载体积通常比 zip 大,但后面可以继续在本地做git pull更新。如果仓库很大,也可以只做浅克隆,只拿最新 commit。

我的建议是:如果你只是想批量整理学习资料,默认用 zip;如果你要长期关注某个项目的代码变化,或者会在本地改代码、追踪历史,就用 clone。工具最好两个都支持,通过参数切换。

1.3 运行条件和基本限制

写这个工具不需要太高的机器配置,普通开发机能跑就行,但有几个前置条件要确认:

条件要求说明
操作系统Windows / Linux / macOS控制台工具,跨平台
.NET SDK6.0 或更高dotnet new console创建项目
Git 命令行clone 模式才需要执行git clone命令
网络能访问api.github.comgithub.com下载 zip 走官网地址
磁盘空间根据仓库体积预留API 返回的size字段可做参考
API Token非必需,但强烈建议提高 API 限额,减少 403 概率

这里说的 token 不是必须的。只拉公开仓库,不提供 token 也能跑,但 GitHub API 对未认证请求限制在每小时 60 次。如果你只拉一个只有 20 个仓库的用户,一次分页请求可能就够了,60 次完全没问题;但如果你想批量处理几个账号,或者某个账号仓库特别多,强烈建议准备一个 Personal Access Token。

申请 token 时不需要额外选择仓库读写权限,只为了拉公开数据的话,一个没有任何权限的 token 也能提升到每小时 5000 次请求。创建之后通过环境变量传进去,不要硬编码到代码里。

2. 用 C# 搭出可运行的最小版本

2.1 创建控制台项目

先建一个控制台项目,名字可以根据自己的习惯取:

dotnet new console -n RepoSnatcher cd RepoSnatcher

然后用到的核心命名空间有:

using System.Net.Http.Headers; using System.Text.Json; using System.Text.Json.Serialization;

工具本身不需要额外第三方包,直接用 .NET 自带的HttpClientSystem.Text.Json就够了。这也是这个方案比较省事的地方。

2.2 定义仓库数据模型

GitHub API 返回的 JSON 字段很多,但工具只关心几个关键字段。定义一个模型类,只反序列化需要的属性:

public sealed class GitHubRepo { [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("clone_url")] public string CloneUrl { get; set; } = string.Empty; [JsonPropertyName("default_branch")] public string DefaultBranch { get; set; } = "main"; [JsonPropertyName("description")] public string? Description { get; set; } [JsonPropertyName("size")] public int Size { get; set; } [JsonPropertyName("fork")] public bool IsFork { get; set; } }

FullNameowner/repo的格式,后面下载 zip 时会用到。DefaultBranch用来拼下载地址。Size单位是 KB,不是 MB,只有参考意义,不能完全代表下载体积。

2.3 配置 HttpClient

GitHub API 要求请求头里必须带User-Agent,否则会直接拒绝。建议再设置Accept,接收 GitHub 推荐的 JSON 格式。

var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; client.DefaultRequestHeaders.Add("User-Agent", "RepoSnatcher"); client.DefaultRequestHeaders.Accept.Add( new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); if (!string.IsNullOrWhiteSpace(token)) { client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); }

这里有一个容易被忽略的点:HttpClient.Timeout是整体超时,不只是连接超时。拉仓库列表还好,但下载大 zip 不能依赖这个 30 秒限制,否则下载到一半就会超时。所以下载 zip 时我会单独构造一个HttpClient实例,或者给下载请求设置更长的超时时间,比如TimeSpan.FromMinutes(10)

还有,老项目里常见的是在请求头里加User-Agent时拼操作系统信息,GitHub 推荐的格式是{name} {version},例如RepoSnatcher 1.0。不用花太多心思,只要不是空字符串就行。

2.4 拉取仓库列表并处理分页

GitHub API 的per_page最大是 100,所以仓库数超过 100 时要翻页。

判断还有没有下一页,有几种做法。最直观的是看这一页返回的仓库数量是否等于per_page。等于 100,就继续请求下一页;少于 100,说明已经到最后一页。这个方式简单,但也有一点瑕疵:如果最后一页恰好也是 100 个,会多请求一次,再拿到一个空数组才结束。多一次请求成本很低,影响不大。

更严格的做法是解析响应头里的Link字段,里面有rel="next"rel="last"信息。不过为了代码简洁,示例里先用数量判断:

static async Task<List<GitHubRepo>> FetchAllReposAsync( HttpClient client, string userName) { var result = new List<GitHubRepo>(); var page = 1; const int perPage = 100; while (true) { var url = $"https://api.github.com/users/{userName}/repos" + $"?per_page={perPage}&page={page}"; using var response = await client.GetAsync(url); response.EnsureSuccessStatusCode(); var json = await response.Content.ReadAsStringAsync(); var pageRepos = JsonSerializer.Deserialize<List<GitHubRepo>>( json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (pageRepos is null || pageRepos.Count == 0) break; result.AddRange(pageRepos); Console.WriteLine($"第 {page} 页:{pageRepos.Count} 个仓库,累计 {result.Count} 个"); if (pageRepos.Count < perPage) break; page++; } return result; }

这里有个很容易踩的坑:如果传入的userName不存在,GitHub API 返回的不是空数组,而是 404。EnsureSuccessStatusCode()会直接抛异常,程序立刻退出。如果你希望更健壮一点,可以把userName和错误码关系分开处理,在调工具前先用浏览器或curl确认这个用户是否存在。

还有一点:列表接口默认可能不包含 fork 仓库,但为了确认,最好在后续下载时再过滤一次:

var repos = await FetchAllReposAsync(client, userName); var toDownload = repos.Where(r => !r.IsFork).ToList();

这样就算某天 API 行为变了,也不至于把一堆 fork 的重复仓库下载下来。

2.5 下载 zip 的核心方法

拿到仓库列表后,下载 zip 的逻辑很直接:

static async Task DownloadZipAsync( HttpClient client, GitHubRepo repo, string outputDir, CancellationToken cancellationToken = default) { var branch = Uri.EscapeDataString(repo.DefaultBranch); var url = $"https://github.com/{repo.FullName}/archive/refs/heads/{branch}.zip"; using var response = await client.GetAsync( url, HttpCompletionOption.ResponseHeadersRead, cancellationToken); response.EnsureSuccessStatusCode(); var targetFile = Path.Combine( outputDir, $"{repo.Name}-{repo.DefaultBranch}.zip"); Directory.CreateDirectory(outputDir); await using var fs = new FileStream( targetFile, FileMode.Create, FileAccess.Write, FileShare.None); await response.Content.CopyToAsync(fs, cancellationToken); }

为什么用HttpCompletionOption.ResponseHeadersRead?因为下载大文件时,这个参数会让响应头一返回就开始读取内容,不需要等整个响应体缓冲到内存里。如果不加,HttpClient可能把整个 zip 先读进内存,再写入文件,小文件没感觉,大文件很容易内存暴涨。

为什么不直接写成https://github.com/{repo.FullName}/archive/master.zip?因为现在很多仓库默认分支是main,写死分支会让一批仓库直接下载失败。default_branch字段就是干这个用的。

2.6 用 git clone 处理大仓库

zip 模式适合小仓库。遇到体积大、历史深的仓库,我更倾向于走git clone,尤其是以后还想更新代码的情况。

static async Task CloneRepoAsync( GitHubRepo repo, string outputDir, bool shallow = true, CancellationToken cancellationToken = default) { var targetPath = Path.Combine(outputDir, repo.Name); if (Directory.Exists(Path.Combine(targetPath, ".git"))) { Console.WriteLine($"[跳过] {repo.FullName} 已存在"); return; } var startInfo = new ProcessStartInfo("git") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; startInfo.ArgumentList.Add("clone"); if (shallow) { startInfo.ArgumentList.Add("--depth"); startInfo.ArgumentList.Add("1"); } startInfo.ArgumentList.Add(repo.CloneUrl); startInfo.ArgumentList.Add(targetPath); using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("无法启动 git 进程"); var stderrTask = process.StandardError.ReadToEndAsync(); await process.WaitForExitAsync(cancellationToken); if (process.ExitCode != 0) { var stderr = await stderrTask; throw new InvalidOperationException($"git clone 失败:{stderr}"); } }

这里默认加了--depth 1,也就是浅克隆。好处是下载体积小、速度快,尤其适合只是看代码、不需要完整历史的场景。缺点是git log里只有最近一次提交,如果你要做历史分析,就把shallow参数改成false

还有一点要注意:如果本地已经存在同名目录,但里面没有.git,程序还是会在原来的路径上 clone,这可能导致 Git 报“already exists and is not an empty directory”。所以真正的判断不能只看目录是否存在,还要看目录里有没有.git目录。

2.7 组装 Main 流程

最后把流程串起来:

static async Task Main(string[] args) { var userName = args.ElementAtOrDefault(0) ?? "octocat"; var mode = args.ElementAtOrDefault(1) ?? "zip"; var outputDir = args.ElementAtOrDefault(2) ?? $"{userName}_repos"; var token = Environment.GetEnvironmentVariable("GITHUB_TOKEN"); var client = BuildHttpClient(token); var repos = await FetchAllReposAsync(client, userName); Console.WriteLine($"共获取到 {repos.Count} 个公开仓库"); Directory.CreateDirectory(outputDir); await File.WriteAllLinesAsync( Path.Combine(outputDir, "repos.txt"), repos.Select(r => $"{r.FullName}\t{r.CloneUrl}\t{r.DefaultBranch}")); if (mode == "zip") { foreach (var repo in repos) { await DownloadZipAsync(client, repo, outputDir); } } else { foreach (var repo in repos) { await CloneRepoAsync(repo, outputDir); } } }

BuildHttpClient就是把上面的 HttpClient 配置单独包装成一个方法。先跑一遍,能用,再往下加并发和重试。

3. 让它适合批量:并发、重试、续传和日志

3.1 批量任务不能只关心能不能跑

单条下载能跑通,不代表批量下载能安全结束。一次拉几十个仓库时,最常遇到的问题不是代码逻辑,而是这些:

  • 下载到一半网络断了
  • 其中一个仓库体积特别大,拖垮整个流程
  • 磁盘空间不够
  • GitHub API 临时限流
  • 文件名和本地目录冲突
  • 没有日志,失败后不知道从哪个仓库继续

所以批量任务真正要关注的是“可控”,而不是“能跑”。最简单的可控手段就是先保存仓库清单,再逐个下载。即使后面程序中断,也有一份repos.txt可以接着用。

3.2 控制并发下载数量

很多人拿到代码后第一件事就是把 for 循环改成并行,以为跑得越快越好。实际上 GitHub 对请求频率有限制,本地磁盘 IO 和带宽也会限制速度。并发调到十几二十,最后大概率是大量超时和 403。

我一般建议默认并发 3,最多 5。机器配置高、网络稳定也只是 5 到 8,不要再往上加。

用信号量控制并发很简单:

static async Task RunWithConcurrencyAsync( IEnumerable<GitHubRepo> repos, int maxConcurrency, Func<GitHubRepo, Task> worker) { using var semaphore = new SemaphoreSlim(maxConcurrency); var tasks = repos.Select(async repo => { await semaphore.WaitAsync(); try { await worker(repo); } catch (Exception ex) { Console.Error.WriteLine($"[失败] {repo.FullName}: {ex.Message}"); } finally { semaphore.Release(); } }); await Task.WhenAll(tasks); }

调用时:

await RunWithConcurrencyAsync( repos, maxConcurrency: 3, worker: repo => DownloadZipAsync(client, repo, outputDir));

这里有个容易被忽略的点:HttpClient是线程安全的,多个任务可以共享同一个实例。但如果你让每个任务都 new 一个HttpClient,没跑多久就会碰到 socket 端口耗尽的问题。尤其在高并发下,这个错误经常被误判成网络问题。

3.3 失败重试要区分可重试和不可重试

不是所有异常都值得重试。我习惯把失败情况分成两类:

类型示例是否重试
可重试网络超时、5xx、429延迟后重试
不可重试用户不存在、路径非法、没有权限记录日志,直接跳过

如果遇到 403 rate limit,重试三次的意义不大,因为限流需要等一段时间。应该做的是降低请求频率,或者换一个更稳定的 token。如果遇到 404,很可能是用户名称写错了,重试一百次也没用。

重试时最好用退避方式,第一次等 1 秒,第二次等 2 秒,第三次等 4 秒。不要一失败就立刻重试,尤其是批量任务,可能让 API 限流提前触发。

3.4 增量下载和续传

重复跑工具时,最理想的情况是能跳过已经下载过的仓库。

对 zip 模式来说,最简单是判断目标文件是否存在,并且文件大小大于 0:

if (File.Exists(targetFile) && new FileInfo(targetFile).Length > 0) { Console.WriteLine($"[跳过] {repo.FullName}"); return; }

但只判断存在还不够。如果上次下载只写了一半,程序中断,留下一个不完整的 zip,这个判断会误以为成功了。更稳的做法是先下载到临时文件,比如repo.zip.tmp,全部完成后改成正式文件名。这样中断之后,临时文件不会影响正式文件,下次重新下载即可。

clone 模式同理,判断目录里是否存在.git目录。如果存在,就跳过;如果你想更新代码,可以在跳过前执行:

git -C {targetPath} pull --ff-only

增量处理不用做得太复杂,先保证“已经完成的不重复下载”,再考虑“未完成的能续传”。

3.5 日志、进度和清单

批量下载时,我建议至少保存三样东西:

  1. repos.txt:仓库清单,包含完整名称、clone 地址、默认分支。
  2. download.log:成功和失败记录,一行一个仓库。
  3. 控制台进度:[3/100] owner/repo ... 完成

有了日志,后续排查会轻松很多。我最怕的是那种“没报错但就是少了一个仓库”的情况,没有日志的话,根本不知道它是在哪一步丢的。

每次 API 请求之后,还可以顺带读取响应头里的X-RateLimit-Remaining,如果剩余次数很低,就提前做降速处理。这个字段不是每次响应都一定带,但带了的时候很有用。

4. 实测中常遇到的问题和排查顺序

4.1 先跑一轮小测试

工具刚写完,不要直接跑一个大账号。先找一个仓库数量比较少的用户,或者直接用自己账号测。

测试时可以按这个顺序看:

  1. 列表是否拉取成功,数量和网页展示是否一致。
  2. 清单文件repos.txt是否生成,内容是否完整。
  3. zip 模式下载一个仓库,压缩包能否正常解压。
  4. clone 模式能否正常 clone,目录结构是否正确。
  5. 连续跑 10 个仓库,观察成功率、速度和日志。

如果小规模测试都正常,再放开并发和批量。不要一开始就把所有仓库一次性拉满,否则出了问题很难定位。

4.2 常见错误排查表

我在实测过程中整理了几个最容易遇到的问题,按排查顺序写在这里:

现象常见原因排查顺序
API 返回 403未认证请求被限流X-RateLimit-Remaining,设置 token,降低频率
返回 404用户名不存在先在浏览器里访问https://github.com/{username}确认
请求超时网络链路不稳定先单独访问api.github.com,再查代码超时设置
下载 zip 中断文件太大、超时太短换成 clone 模式,或增大超时时间
clone 报RPC failed仓库太大或网络抖动用浅克隆,减少传输量,重试
某个仓库目录已存在上次运行中断检查目录里有没有.git,确定是否要删除或跳过
磁盘空间不足仓库体积超过预期先看仓库列表里的size字段,再决定是否全量下载

这里最容易被误判的是“API 403”和“clone 失败”。403 不一定是工具写错了,很可能是 token 没配或者请求太频繁。clone 失败也不一定是 Git 命令错了,可能是网络和仓库体积问题。

4.3 网络慢或者中断怎么办

GitHub 在不同网络环境下访问情况差异很大。如果api.github.com能访问,但github.com下载很慢,先不要急着改并发,先把并发降回 1,单独下载一个仓库试试。如果单仓库也会断,多半是网络链路的问题,不是代码逻辑的问题。

这种时候反复重试同一个大仓库意义不大。更务实的做法是:

  • 优先用git clone --depth 1,减少传输量。
  • 下载大仓库时避开网络高峰时间段。
  • maxConcurrency降到 1,避免多个大仓库同时抢占带宽。
  • 单个仓库失败后先记日志,跳过,等第一轮跑完再统一重试。

单纯调大超时时间不一定有用,因为网络中断可能不是“慢”,而是连接直接被断开。判断标准是:小文件能不能稳定下载、大文件是不是总在同一个比例断掉。如果大文件总在 30% 或 60% 附近断,基本可以确定是网络链路导致的。

4.4 什么时候该用 API,什么时候该用 git 命令行

这个工具的核心是“用 API 拿清单,用 git 命令落地”,两者配合。

场景建议
只是想备份所有仓库API 列表 + zip 下载
想长期跟踪代码变化API 列表 + git clone
仓库很多但都不大zip 并发 3 到 5
仓库少但体积特别大clone 浅克隆,并发 1
只需要 README 和配置文件可以只看单个文件,不需要下载整个仓库
需要分析项目语言和星标变化API 列表已经包含大部分信息,不需要 clone

当仓库数量达到几百个时,最耗时间的不是 API 请求,而是下载本身。所以先保存清单、再分批下载,比一次性 for 循环更有实用价值。

4.5 这个工具还能扩展成什么

当前这个版本已经能完成“下载某个用户全部项目”的核心需求。在此基础上,还能做很多扩展:

  • 按更新时间过滤,只下载最近三个月更新的仓库。
  • 按语言过滤,比如只拉 C# 项目。
  • 统计每个仓库的大小、星标数、最近更新时间,生成一个本地索引页。
  • 记录历史版本,定时拉取仓库列表和 zip,形成增量备份。
  • 支持组织账号,把接口改成/orgs/{org}/repos
  • 把失败记录写到单独文件,第二次运行时优先处理失败任务。

扩展的时候只要记住一个原则:先保证基础链路稳定,再加功能。不要一上来就把工具做得特别复杂,否则出问题都不好查。

最后留几个我排查时会优先看的点:先看仓库清单有没有生成,再看失败日志集中在哪些仓库,然后看磁盘空间和网络状态。多数问题都不是 GitHub 不让你下载,而是用户名写错、token 没设置、路径名不合法、磁盘不足这些前置条件没处理好。如果你也要做类似的事,我的建议是从一个小账号开始,先把单条链路跑通,再考虑并发、续传和批量重试。踩过几次之后会发现,真正省时间的不是一次下载多少仓库,而是列表清晰、日志完整、失败能重试。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询