简介:面向开发者与科研人员的GitHub资源批量获取工具,可针对关键词搜索并一键下载指定起始页到结束页的仓库,自动过滤涉政等无关内容,大幅提升批量收集效率。工具调用官方API,运行安全稳定,适合需要系统性整理开源代码、数据集或主题资源的中高频GitHub用户。压缩包内共193个文件,以程序运行所需的dll组件为主,含3个exe主程序、少量json配置文件与ini参数设置,整体约30.84MB,结构偏向软件分发形态。目前已有199人学习下载。借助该工具,用户无需逐页手动打开仓库,即可按关键词与页码范围自动抓取资源,并获取官方API解析、过滤逻辑与批量调度等实现思路,便于二次开发或嵌入自己的工作流。
1. 批量下载 GitHub 仓库,先从“根据搜索条件生成下载清单”开始
如果你要维护开源组件清单,或者做代码审计,会发现一个高频重复的操作:把符合某些特征的 GitHub 仓库拉到本地。特征可能是“语言是 Python、星标超过 100、最近还有提交”,也可能是“名字里带某个特定关键词”。手工做要复制每一个仓库地址,再逐条执行下载,仓库一旦超过十个就会非常耗费时间。GitHub 仓库批量下载工具的本质是合并这两步:接收一个查询表达式,用 GitHub Search API 拿到候选仓库列表,再按预设方式把代码落盘。工具本身不复杂,但用熟后能节省大量时间,也让“搜索条件 + 下载产物”变得可复现,适合需要定期下载开源依赖、做组件分析或离线归档的研发和运维人员。下面给出可直接复刻成 Python 命令行脚本的完整实现思路。
2. 数据源选型:用 GitHub Search API 替代网页抓取
2.1 为什么 Search API 是这个工具的默认通道
有人会把“根据搜索下载”做成直接抓 github.com 搜索页的方案。这种方案不是不能用,但它有一个长期的维护成本:搜索结果页的 DOM 结构、加载方式、是否要求登录都可能随前端改版变化,解析代码需要定期返工;而且抓取结果缺少default_branch、archived这类结构化字段,后续还要为每个仓库单独再请求一次详情接口。GitHub 提供的 Search API 恰好把这些问题处理干净:GET https://api.github.com/search/repositories返回标准 JSON,字段包含full_name、owner、default_branch、stargazers_count、archived、pushed_at等。正是这些字段让批量下载工具可以在“下载”这个动作之前做筛选,比如跳过 archived 仓库、只下载指定 owner 的项目。所以把 Search API 当作默认数据源是合理的,网页抓取只作为特殊场景的补充。
不带 token 时这个接口的限速为 10 次/分钟,带 token 后为 30 次/分钟。批量下载规模推到几百个仓库时,搜索阶段不会产生太大压力;真正的限速压力出现在下载阶段,后面会专门说配置参数。
2.2 q 参数入门:把中文搜索意图翻译成可解析表达式
q 参数是 Search API 的入口,大多数下载需求都可以用空格拼接的片段来表达。常用条件如下表:
| 搜索片段 | 用途 |
|---|---|
in:name,description | 关键词同时匹配仓库名和描述 |
language:python | 只返回主语言为 Python 的仓库 |
stars:>100 | 星标数大于 100 |
pushed:>2024-06-01 | 最近提交晚于指定日期,过滤死仓库 |
archived:false | 排除归档仓库 |
size:>1000 | 仓库体积大于 1 MB,避免空壳项目 |
例如“查找名字里带 docker、语言是 Go、最近有提交”的完整 q 为docker in:name language:go pushed:>2023-01-01 archived:false。这个 q 不能直接拼到 URL 里,因为>和空格会被服务器解析错。为了快速验证,先用 curl 加 jq 看一次返回,具体命令:
curl -s -H "Accept: application/vnd.github+json" \ "https://api.github.com/search/repositories?q=docker+in:name+language:go+pushed:%3E2023-01-01&per_page=3" \ | jq '{total: .total_count, first: [.items[] | {full_name, default_branch}]}'这里把空格换成+,把>编码成%3E。per_page=3用来压缩输出,正常使用会设为 100。jq 只提取total_count和两个关键字段,就能看出查询是否命中目标范围。如果 total 是 0,优先检查pushed:后面的日期格式,这个参数写错最常见。
2.3 分页要从总数推导,而不是固定循环 10 次
Search API 的per_page上限是 100,默认是 30。批量下载为了减少请求次数,通常直接传per_page=100。翻页逻辑的参数来自响应里的total_count:
total_count = data["total_count"] page_limit = min(10, (total_count + per_page - 1) // per_page) page_limit = min(page_limit, 10) # 搜索结果最多取前 1000 条(total_count + per_page - 1) // per_page是向上取整的标准写法,避免总数为 101 时只翻一页漏掉一个仓库。搜索接口限制最多返回前 1000 条结果,所以这里用min(..., 10)封顶。当total_count明显超过 1000,单独做一次全量下载意义不大,正确的做法是收紧 q 参数,把时间范围拆成几段分别查询,最后合并结果。这里有一个容易踩的坑:如果查询里带了sort=stars,然后用page翻页,结果顺序会保持稳定,但per_page改变后总页数也要同步重算,否则多出来的页会拿到空items。
2.4 把搜索阶段和下载阶段解耦
我习惯把 Search API 的原文响应原样缓存在本地,例如search_cache/page_1.json。工具搜索阶段只把每个请求的 JSON 写入磁盘,再从磁盘读取items生成下载任务。好处有两个:其一,批量下载执行时间通常远长于搜索阶段,一旦中途断网或机器掉电,重启后可以直接复用缓存,不消耗请求次数;其二,搜索和下载是两类完全不同的错误,分开之后可以先确认搜索条件有没有问题,再判断下载逻辑。缓存目录按查询参数命名,换查询条件后自动区分文件即可。
3. 实现核心下载器:zip 与 git clone 双模式切换
3.1 为什么第一版优先走 codeload 的 zip 下载
“下载”要落盘为完整仓库,常见两条路:拉 zip 包,或者用 git clone。批量下载工具的第一版建议尽量用 zip。理由有三条:第一,zip 下载对本地 Git 环境没有依赖,命令里只需要 HTTP 请求;第二,下载过程不会创建.git目录,不会因为本地已存在同名文件夹而报 already exists;第三,GitHub 为公开仓库提供了https://codeload.github.com/{owner}/{repo}/zip/refs/heads/{branch}这个下载地址,分支名可以从 Search API 返回的default_branch字段直接获得。zip 的缺点也明显:拿不到历史提交,也无法增量拉取新提交。所以面对“离线归档”“静态扫描”这类需求,zip 完全够用;但如果想在下载结果上继续写代码,就应该用 git clone,而且最好加--depth 1做浅克隆。
3.2 主体代码:从查询表达式到本地目录
下面的 Python 脚本是这个工具的最小可行版本。代码把搜索、下载、解压三个步骤分开,方便替换成自己的任务队列:
import argparse import json import time import zipfile from pathlib import Path import requests SEARCH_API = "https://api.github.com/search/repositories" CODELOAD = "https://codeload.github.com/{full_name}/zip/refs/heads/{branch}" def search_query(query, token, per_page=100, max_pages=10): """搜索并返回候选仓库列表,限制每页条数和翻页数。""" headers = {"Accept": "application/vnd.github+json"} if token: headers["Authorization"] = f"Bearer {token}" cache = Path("search_cache") cache.mkdir(exist_ok=True) items: list[dict] = [] for page in range(1, max_pages + 1): cache_file = cache / f"page_{page}.json" if cache_file.exists(): data = json.loads(cache_file.read_text("utf-8")) else: resp = requests.get( SEARCH_API, params={"q": query, "per_page": per_page, "page": page}, headers=headers, timeout=30, ) resp.raise_for_status() data = resp.json() cache_file.write_text(json.dumps(data, ensure_ascii=False), "utf-8") time.sleep(0.3) items.extend(data.get("items", [])) total = data.get("total_count", 0) if len(items) >= min(total, per_page * max_pages): break return items def download_zip(repo, out_root="downloads"): """按返回的 default_branch 从 codeload 下载 zip 并解压。""" headers = {"User-Agent": "batch-downloader"} full_name = repo["full_name"] branch = repo.get("default_branch") or "main" zip_url = CODELOAD.format(full_name=full_name, branch=branch) resp = requests.get(zip_url, headers=headers, timeout=60) resp.raise_for_status() out_dir = Path(out_root) out_dir.mkdir(parents=True, exist_ok=True) zip_path = out_dir / f"{full_name.replace('/', '_')}.zip" zip_path.write_bytes(resp.content) target = out_dir / full_name.replace("/", "_") target.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(zip_path) as zf: zf.extractall(target) return str(target)参数说明都写在注释里,这里再补充三点。第一,搜索缓存判断条件只有文件是否存在,所以换一次搜索词就要清理search_cache,否则会用上一轮的仓库列表去下载。第二,download_zip的分支名取自default_branch,如果个别仓库返回字段缺失就回退到 main,这个防御性写法可以避免因为默认分支是 master 而下载到 404。第三,zip 解压后会自动带一层owner-repo-branch的顶层目录,如果直接把这个目录作为最终产物,代码里需要再做一次 rename,这个在 4.2 节展开。
3.3 批量与重试的常用参数表
把所有可调项统一成命令行参数,便于在 CI 或定时任务里改配置。常用参数如下:
| 参数 | 默认值 | 作用 |
|---|---|---|
--query | 必填 | 传给 Search API 的完整查询表达式 |
--token | 空 | GitHub personal access token,提升搜索限速 |
--mode | zip | 下载方式,可选 zip 或 clone |
--per-page | 100 | 每页返回仓库数,上限 100 |
--max-pages | 10 | 最多翻页数,1 表示只取前 100 条 |
--retry | 2 | 单个仓库下载失败后的重试次数 |
--sleep | 0.3 | 搜索请求之间的暂停秒数 |
参数之间的联动关系需要留意:--max-pages 1 --per-page 30等价于只获取前 30 个仓库,适合先用少量结果验证流程;--retry 2只对 zip 下载有效,git clone 重试时要把目标目录先删除,否则第二次 clone 会报 already exists and is not an empty directory。
3.4 失败特征与超时处理
批量场景里最典型的问题是单个仓库下载失败导致整个进程中断。所以要在调用download_zip的外层包一个失败处理:
for repo in results: try: download_zip(repo) except (requests.exceptions.RequestException, zipfile.BadZipFile) as exc: print(f"skip {repo['full_name']}: {exc}")把 RequestException 和 BadZipFile 一起捕获,提示这是网络层和文件层两类可重试错误。超时设置方面,timeout=60是 zip 下载的上限,比普通 API 请求长一倍,因为 zip 包可能达到几十 MB;如果目标仓库普遍很大,建议改到 300 秒,否则会频繁触发超时误报。
4. 把工具放进真实工作流:范围精调、目录规整、迁移到内网 Git
4.1 先用范围压缩,再让“根据搜索下载”不超出磁盘
盲目用q=docker会得到上万条结果,下载工具会跑几个小时。在写批量下载前,用几个条件组合压缩范围:
- 指定
language:,把组件限定在熟悉的技术栈里。 - 指定
stars:>,过滤掉个人练习项目。 - 指定
pushed:>,确保仓库仍在维护。 - 指定
archived:false,避免下载只读归档项目。 - 指定
size:>,排除只有 README 的空壳仓库。
这四个条件组合后的示例 q 为etcd in:name,description language:go stars:>50 pushed:>2023-01-01 archived:false size:>1000。这里的size单位是 KB,size:>1000表示大于 1 MB。很多刚接触 Search API 的开发者会把pushed:写成updated:,API 并不支持这个字段,查询会直接返回空结果;这个差异在调试时值得优先排查。
4.2 解压后的目录整理为 owner_repo
下载工具把 zip 解压后,目录名自动带上一长串,比如owner-repo-branch,在批量归档时并不方便。常见做法是在下载后立刻把仓库目录重命名为owner_repo,同时把 zip 包集中放到archives/子目录,避免与源码混在一起。这可以合并进download_zip的返回处理:
target = out_dir / full_name.replace("/", "_") tmp_dir = next(target.iterdir()) # zip 解压后唯一的一层顶层目录 tmp_dir.rename(target)注意这行假设解压产物只有一层顶层目录。如果一个 zip 里打包了多个根目录,直接用 next() 会漏掉其余内容,所以我在脚本里会用list(target.iterdir())取长度,超过 1 就打印警告并把 zip 保留备份,而不是直接 rename。
4.3 与内网 Git 平台批量衔接
批量下载到的源码可能需要在内部的 Git 平台归档,例如 GitLab 或 Gitee。这里只提常用操作,不属于工具本职。对每个已解压的目录执行:
cd /data/repos/owner_repo git init git add . git commit -m "import from github snapshot" git remote add origin git@gitlab.example.com:archive/owner_repo.git git push -u origin main这种做法的边界要说清楚:git init之后生成的 git 历史是全新的,与原仓库的提交记录没有任何关系。如果业务要求保留原提交历史,就不能走 zip 下载这条路,而要提前切换到git clone模式,拿到完整.git之后再改 remote。这也是在 3.1 节坚持保留双模式的原因。
4.4 增量下载:用本地目录做去重
批量下载工具在每天定时执行时,最好支持“只下载新增仓库”。去重逻辑很简单:
done_dirs = {p.name for p in Path(out_root).glob("*/") if p.is_dir()} results = [r for r in results if r["full_name"].replace("/", "_") not in done_dirs]把已经存在的owner_repo目录名收集起来,再从搜索结果里过滤掉。优点是零依赖,不用维护状态文件;缺点是如果某个仓库在本地被手动改名,它会被当成新仓库重新下载。下载量大时,可以观察脚本打印的跳过率,决定是否需要加一层内容哈希校验。
5. 下载完成后的完整性校验与断点续传技巧
5.1 用 CRC 校验和文件数验证下载结果
下载结束后的状态需要验证,不能只看文件是否落盘。Python 的 ZipFile 提供了一次性校验全部文件的方法:
def verify_archive(path): with zipfile.ZipFile(path) as zf: bad_file = zf.testzip() return (bad_file is None, len(zf.namelist()))testzip()会检查全部 zip 条目的 CRC,返回第一个损坏文件的名称,没有损坏则返回 None。第二个返回值是包内文件数,用于判断“内容是否过少”。若校验失败,只需重新下载规划中的该仓库,不需要全部重跑。
批量场景里可以把校验步骤放在每次下载后,然后通过一个小技巧记录结果:在仓库目录内写入隐藏的.download_state文件,包含 zip 的 CRC 和文件数。下一次运行时先比较该文件,相同的跳过解压,不同的重新拉取。这种方法把校验成本降到了最低。
5.2 断点续传:用标记文件避免重复下载
真正要落地定时任务,还需要处理“下载了一半进程被杀掉”的情况。常规做法是引入.partial标记文件:下载前在目标目录里创建.partial,下载完成并校验通过后删除;下次运行时如果看到.partial仍存在,说明上一次没有完成,就把这个半成品目录移动到broken/子目录,再重新下载:
mark = target / ".partial" if mark.exists(): (target.parent / "broken").mkdir(exist_ok=True) target.rename(target.parent / "broken" / target.name) mark.touch() download_zip(repo) mark.unlink()这样所有仓库的下载过程都有明确的幂等状态:要么是完整目录,要么是 broken 目录,不会存在一个不确定的半成品。配合 5.1 的验证函数,每次结束后都能得到一份可信任的下载清单。若查询条件被修改,记得为新的 q 参数单独建缓存目录,避免上一轮的搜索结果污染本轮任务。
本文还有配套的精品资源,点击获取