如果你经常逛 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通常就是main或master,但不要写死,应该从列表接口返回的default_branch字段取。
第二种:执行git clone。这种方式会拿到完整仓库,包括历史记录、分支、标签,下载体积通常比 zip 大,但后面可以继续在本地做git pull更新。如果仓库很大,也可以只做浅克隆,只拿最新 commit。
我的建议是:如果你只是想批量整理学习资料,默认用 zip;如果你要长期关注某个项目的代码变化,或者会在本地改代码、追踪历史,就用 clone。工具最好两个都支持,通过参数切换。
1.3 运行条件和基本限制
写这个工具不需要太高的机器配置,普通开发机能跑就行,但有几个前置条件要确认:
| 条件 | 要求 | 说明 |
|---|---|---|
| 操作系统 | Windows / Linux / macOS | 控制台工具,跨平台 |
| .NET SDK | 6.0 或更高 | 用dotnet new console创建项目 |
| Git 命令行 | clone 模式才需要 | 执行git clone命令 |
| 网络 | 能访问api.github.com和github.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 自带的HttpClient和System.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; } }FullName是owner/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 日志、进度和清单
批量下载时,我建议至少保存三样东西:
repos.txt:仓库清单,包含完整名称、clone 地址、默认分支。download.log:成功和失败记录,一行一个仓库。- 控制台进度:
[3/100] owner/repo ... 完成。
有了日志,后续排查会轻松很多。我最怕的是那种“没报错但就是少了一个仓库”的情况,没有日志的话,根本不知道它是在哪一步丢的。
每次 API 请求之后,还可以顺带读取响应头里的X-RateLimit-Remaining,如果剩余次数很低,就提前做降速处理。这个字段不是每次响应都一定带,但带了的时候很有用。
4. 实测中常遇到的问题和排查顺序
4.1 先跑一轮小测试
工具刚写完,不要直接跑一个大账号。先找一个仓库数量比较少的用户,或者直接用自己账号测。
测试时可以按这个顺序看:
- 列表是否拉取成功,数量和网页展示是否一致。
- 清单文件
repos.txt是否生成,内容是否完整。 - zip 模式下载一个仓库,压缩包能否正常解压。
- clone 模式能否正常 clone,目录结构是否正确。
- 连续跑 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 没设置、路径名不合法、磁盘不足这些前置条件没处理好。如果你也要做类似的事,我的建议是从一个小账号开始,先把单条链路跑通,再考虑并发、续传和批量重试。踩过几次之后会发现,真正省时间的不是一次下载多少仓库,而是列表清晰、日志完整、失败能重试。