前一阵我盯上一个 GitHub 上的开源项目,想把它 README 里的截图、架构示意图和 docs 目录下的配图全部整理到本地,做一次素材归档。刚开始还是“人肉另存为”,点开一个文件夹、右键、另存为、改文件名,循环往复。到第 20 多张图的时候,我确定自己需要写一个爬虫,把这批图片一次性批量下载下来。这篇博客就是这次批量下载 GitHub 项目图片的完整记录,核心思路是“用 Git 仓库的文件树接口拿图片清单,再用并发下载器按路径批量拉取”。如果你也遇到过类似需求,或者正想找一个真实、合规、又能练手 Python 爬虫的场景,这篇应该能帮你省下不少时间。
1. 为什么需要批量抓取 GitHub 项目图片:一个真实场景
1.1 从“人肉另存为”到“脚本批量拉”:我想解决的问题
先说触发场景。我当时需要给一个项目做竞品素材整理,几个同类开源仓库的截图、效果图、演示 GIF 都要收下来放进本地知识库。单个仓库还好,几十个仓库叠加在一起,手动操作根本顶不住,一下午可能只完成两三个项目,还容易漏图、重名覆盖。
另一个更常见的需求是离线文件归档。很多项目会在更新时替换说明文档里的示意图,旧图还留在仓库里,但你可能只关心某个版本。用脚本批量下载,可以直接锁定当前分支的文件树,按需拉取,顺便还能保留图片在仓库里的相对路径,归档结构一目了然。
还有一类需求是给文档写作者用的。你在维护自己的技术文档站,需要引用某个开源项目的图示,又不想直接把对方图床的热链地址粘进来。把这些图片全部爬下来放到自己的静态资源目录里,既避免外链失效,也方便本地预览。这个场景里“批量下载”不是锦上添花,而是刚需。
1.2 图片藏在哪儿:GitHub 仓库里图片的几种常见位置
动手写爬虫之前,必须先搞明白一件事:GitHub 仓库里的图片到底放在哪些地方?我实际跑下来,最常碰到的是这四种位置。
第一种是 README 配图。这是绝大多数人第一眼见到项目的地方,图片可能直接用相对路径(例如docs/images/logo.png)、绝对路径(https://raw.githubusercontent.com/owner/repo/branch/...)或外链图床地址引用。相对路径对应的文件就在仓库里,可以通过文件树找到;外链则必须直接下载。
第二种是各层级目录下的静态图片,比如docs/、assets/、images/、screenshots/这些文件夹。它们不一定会在 README 里出现,但也是项目的重要组成部分,尤其是文档站点类的仓库,图片数量往往很大。
第三种是发布包(Release)里的图片附件。有些项目会把打包好的安装包截图挂在 Release 页面,文件本身不一定在代码仓库里,需要通过 Release 接口单独拿下载地址。
第四种是容易被忽略的“动态生成的图”,比如 README 顶部常见的徽章图标(如img.shields.io生成的构建状态图)。这种图从技术上说也是一张图片,但通常是临时渲染出来的,不在仓库内,需要单独过滤或直接跳过。
如果不做区分,一锅端地只对着 HTML 页面去抓图片链接,很容易漏掉大量藏在文件树里的图;如果只抓文件树,又会把动态徽章这类外部资源漏掉。所以我的方案是先拿到仓库的完整文件树,再按规则过滤出图片文件,最后统一走并发下载。
2. 动手前必须想清楚的合规边界与速率限制
2.1 GitHub 对爬虫的约束:API 速率限制和 robots 约定
写爬虫的人最忌讳的就是“拿到需求就闷头写”。对于 GitHub 这个目标,合规边界尤其重要。GitHub 官方允许通过公开 API 访问公开仓库数据,但所有请求都要遵守明确的速率限制。
未认证的匿名请求,REST API 的核心接口通常是 60 次/小时;带上账号的 Token 认证后,同一个核心接口能到 5000 次/小时。文件树接口和仓库信息接口都属于核心接口,所以如果你打算批量处理几十个仓库,直接匿名请求最多发几十次就会被 403 拦住,必须配置 Token。
除了接口限制,GitHub 还建议爬虫设置一个独特的 User-Agent 请求头,最好带上联系方式或项目名,这样对方在日志里能认出来访者是谁,也方便处理误封。我在脚本里固定写了一个User-Agent: image-downloader-bot/0.1 (https://example.invalid/image-downloader),既满足标识要求,又不会和正常浏览器流量混淆。
还有一个很多人忽略的点:GitHub 对公开仓库的抓取并不以robots.txt为硬性门槛,但使用者仍然需要尊重项目本身的 License 和作者意图。图片下载到本地自用、学习或归档没问题,但如果要二次分发,必须把图片的原始出处和协议一起保留,不能因为“我在 GitHub 上爬的”就默认可以随意商用。
2.2 选择技术路线的依据:GitHub REST API 优于直接解析页面
面对同一个抓取目标,可以走两条完全不同的路线。第一条是直接请求仓库主页 HTML,用正则或解析库去抠图片链接;第二条是调 GitHub REST API 拿结构化数据。
我首选 API 路线,原因有三个。第一,README 页面现在是通过前端渲染的动态页面,用普通 HTTP 请求拿到的 HTML 里,图片链接往往被脚本后置注入,直接抓取经常只拿到一个空壳。第二,网页结构会随着 GitHub 前端改版频繁变化,正则表达式今天能用,明天可能就失效。第三,API 返回的是结构清晰的 JSON,文件树里每一项都带路径、类型、大小和对象 SHA,直接省掉大量解析容错工作。
当然,API 路线也有代价:需要处理分页、速率限制、超大仓库文件树截断等细节。但这些问题都有成熟的解决套路,我在后面的章节里会逐一展开。
3. 核心实现:用 Git 仓库文件树接口定位所有图片
3.1 拿到默认分支的两种方式与文件树接口的调用姿势
GitHub 仓库的分支名现在既有main也有master,老项目和新项目混在一起,写死任何一个都可能拿到不存在分支的 404。最稳妥的做法是先请求一次仓库信息接口,取default_branch字段,再拿这个分支名去请求文件树。
我写了一个小函数来封装这两步。第一步请求https://api.github.com/repos/{owner}/{repo},从响应 JSON 里读default_branch;第二步拼出https://api.github.com/repos/{owner}/{repo}/git/trees/{branch}?recursive=1,其中owner是用户名或组织名,repo是仓库名,branch是第一步拿到的默认分支。
这里要注意,GitHub API 对树接口的recursive=1参数很依赖仓库规模。小仓库能一次性返回所有路径,但大仓库会返回truncated: true,表示结果被截断,只能拿到部分对象。我一开始就被这个字段坑过一次,日志里看到只下载了不到一半的图片,排查后发现是树被截断了。解决办法是:当truncated为真时,从当前返回的tree数组里筛出type为tree的子目录对象,用它们各自的sha再次调用树接口,递归拉全整个目录树。
def fetch_full_tree(owner, repo, branch, headers): url = f"https://api.github.com/repos/{owner}/{repo}/git/trees/{branch}?recursive=1" resp = requests.get(url, headers=headers, timeout=30) resp.raise_for_status() data = resp.json() entries = data.get("tree", []) if data.get("truncated"): for entry in entries: if entry["type"] == "tree": sub_url = entry["url"] sub_resp = requests.get(sub_url, headers=headers, timeout=30) sub_resp.raise_for_status() sub_data = sub_resp.json() entries.extend(sub_data.get("tree", [])) return entries3.2 图片扩展名白名单与需要跳过的隐藏目录
文件树拿到手后,下一步就是过滤。GitHub 文件树里的每个对象都有path、type、size等字段,type为blob的才是文件,目录类型是tree,子模块是commit。过滤逻辑围绕path展开。
图片扩展名我用的白名单是.png、.jpg、.jpeg、.gif、.svg、.webp、.bmp、.ico。为什么用白名单而不是黑名单?因为仓库里可能存在大量非图片文件,白名单可以把干扰项直接挡在外面,误判率最低。大小写问题也要处理,GitHub 上的文件名有大写也有小写,我在判断时会先转成小写再取后缀。
同时还得维护一个目录黑名单。像node_modules、dist、build、.github、vendor这类目录里虽然也有图片,但通常是构建产物或第三方依赖,不属于项目原生的配图素材,甚至会拉进来几百张无意义的小图标。把这些目录名加入跳过列表后,下载量明显更可控,目录结构也干净很多。
| 场景 | 处理方式 | 说明 |
|---|---|---|
| 图片扩展名 | 白名单匹配 | png/jpg/jpeg/gif/svg/webp/bmp/ico,忽略大小写 |
| 隐藏/构建目录 | 目录黑名单过滤 | node_modules、dist、build、.github、vendor 等 |
| 非 blob 对象 | 直接跳过 | tree(目录)、commit(子模块)都不参与下载 |
| 超大文件 | 超过 100MB 跳过 | 普通仓库无法直接承载超大文件,raw 下载也不可靠 |
3.3 从文件路径到可下载的 raw 链接:拼接规则与验证
GitHub 上获取单个文件原始内容的直链有固定格式:
https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{path}这个链接不需要 Token 就能请求,但要注意path里的中文、空格、特殊符号必须做 URL 编码。比如路径带空格的文件,直接用原始字符串请求会报 404,需要先用urllib.parse.quote(path)转义,但斜杠/要保留。我在最开始写拼接函数时没注意编码问题,遇到一个文件名带#符号的仓库,下载直接失败,排查半天才发现是 URL 没编码。
拼接完成后,不要急着并发下载,先抽样验证几个链接。常用的验证方法是对第一个文件发起一次GET请求,检查响应状态码和Content-Type是否以image/开头。有些项目会把纯文本文档也命名为.png,这类“假图片”会在这一关就暴露出来。
4. 并行下载器的落地:断点续传、去重与重试
4.1 目录结构还原与文件名冲突处理
图片清单准备好之后,进入真正的下载环节。我建议把仓库里的相对路径原样映射到本地目录,比如远程路径docs/guide/a.png,本地就保存到{out_dir}/docs/guide/a.png。这样做的最大好处是:将来你想对照项目文档引用本地图片,目录层级完全一致,不会出现“下载了一堆无规则图片,回头找不到哪张是哪张”的情况。
路径映射时有一件事必须做:防止路径穿越。GitHub 允许的合法路径不会出现../,但作为一个健壮的下载器,不能假设输入一定合法。如果直接把远程路径拼到本地路径上,万一某条路径里有..片段,就可能覆盖到指定目录之外。我在保存前会对路径做一次清洗,把所有..和开头带/的片断处理掉,确保最终路径一定落在输出目录内部。
文件名冲突也是实际下载中经常遇到的问题。不同目录下可能有两张同名图片,如果保存时只取名不建目录,后下载的会直接覆盖先下载的。还原目录结构之后,这个问题自然消失。再额外加一道防线:下载前检查目标文件是否已存在,且大小不为 0。如果已存在,我默认跳过,这也天然实现了断点续传的效果——进程中断后重跑一遍,已完成的文件不会再重复拉取。
4.2 并发数、请求头与会话复用
下载阶段我用的是 Python 的concurrent.futures.ThreadPoolExecutor,配合requests.Session复用底层连接。并发数我设置在 8 到 16 之间,没有开更高,因为 raw.githubusercontent.com 对频繁请求同样有限制,开 100 个线程看着快,实际很快会触发 429 限流,最后反而拖慢整体速度。
请求头里除了前面提到的 User-Agent,还要带一个Accept: application/octet-stream。对 raw 链接来说,这个请求头能尽量让服务器返回原始字节流,避免一些文件被按文本格式处理。
核心下载代码并不复杂,关键在于把“判空”和“失败计数”写扎实。每下载完一张图,判断一下文件大小,太小(比如小于 1KB)的图片要单独列出来人工确认,因为有些 log 图本来就是小图标,有些则是坏文件。
def download_one(session, url, local_path): os.makedirs(os.path.dirname(local_path), exist_ok=True) if os.path.exists(local_path) and os.path.getsize(local_path) > 0: return "skipped", 0 try: resp = session.get(url, timeout=60) resp.raise_for_status() if len(resp.content) < 1024: return "too_small", len(resp.content) with open(local_path, "wb") as f: f.write(resp.content) return "ok", len(resp.content) except requests.RequestException: return "failed", 04.3 429、404、连接被重置:重试策略怎么写才不踩雷
下载脚本最怕的不是某张图失败,而是一失败就全部崩掉。我在写重试逻辑时遵循一个原则:区分“暂时性失败”和“确定性失败”。
暂时性失败包括连接超时、连接被重置、返回 429(限流)、返回 5xx。这类错误重试是有效的,重试间隔采用指数退避,第一次等 2 秒,第二次等 4 秒,第三次等 8 秒,最多重试 3 次。
确定性失败包括返回 404(仓库路径不存在)、401(权限问题,公开仓库基本遇不到)、403(如果是单纯限流则归为暂时性;如果是被明确拒绝,则说明需要检查 Token 或请求头)。这类错误重试没有意义,直接记录到失败清单里即可。
我把失败清单统一写进一个failed.log文件,每行一条 URL。脚本跑完还可以自动重跑一遍失败清单,第二次能补救大部分偶发超时问题。
5. 实测中容易翻车的几个高阶场景
5.1 徽章图、统计图这类外链怎么处理
README 里经常出现徽章图,典型的是构建状态、代码覆盖率、开源许可证这一类。它们大多指向img.shields.io或badgen.net,文件树里根本不存在。如果你用文件树方案,这个问题天然就被绕开了——因为徽章不在仓库里,自然不会被下载。但如果你的目标里有“把 README 视觉元素完整保存下来”这个需求,就必须额外解析 README 页面的<img>标签,把外链图片筛选出来。
我的做法是单独跑一个“README 增强下载”脚本:请求 README 的渲染页面,用 HTML 解析库提取所有<img>标签的src,再过滤出“不以raw.githubusercontent.com开头且不以仓库内路径开头”的外部链接,逐一下载。这类图数量通常不多,但下载时要注意 Referer 和 UA,有些图床会对直接下载做防盗链处理。
5.2 子模块仓库:文件树里突然出现的 commit 类型对象
GitHub 仓库如果嵌套了子模块,文件树返回的对象里会出现type: "commit"的项目。它代表的是一个外部仓库的快照,路径上看起来像是一级目录,但你无法用父仓库的分支名拼接 raw 链接去下载里面的文件。
第一次遇到这种对象时,我的脚本直接把它当成普通目录去拼接路径,结果下载了一堆 404。后来我调整了逻辑:凡是type为commit的对象,默认跳过,不做下载。如果确实需要子模块里的图片,正确的做法是先通过子模块的仓库地址和 SHA 单独构造请求,再确认子模块使用的分支或提交点。对于“只收集主仓库素材”的需求来说,跳过是最省事也最安全的方案。
5.3 只想下载 README 配图时的一键切换思路
有人可能只需要 README 里那几张配图,并不需要整个仓库的图片素材库。这种情况下,我还是建议先按文件树全量下载,再反过来筛 README 里实际引用了哪些图。
为什么不用“只下载 README 链接指向的图片”这个听起来更省事的方案?因为 README 里的相对路径图片往往还在文档的其他地方被引用,而且 README 中也可能存在“用了某张图,但渲染尺寸是缩略图”的情况。先全量拉一遍,再用 README 的图片名列表过滤一次,目录里剩下的就是要归档的核心素材,操作起来更可控,也不会出现缺图。
5.4 Release 附件和 LFS 文件的特殊处理思路
部分项目的安装包截图并不在代码仓库的日常文件树里,而是发布时上传到 Release 页面的附件。这类图片要先请求https://api.github.com/repos/{owner}/{repo}/releases,从返回的assets数组里取browser_download_url字段。
大文件这块也要留个心眼。GitHub 普通仓库对单文件大小有硬限制,超过 100MB 的文件不能通过常规方式存入仓库,部分项目会用 LFS 托管大文件。LFS 文件在文件树里表现为一个很小的“指针文件”,通过 raw 链接下载下来的不是真正的图片内容,而是一段文本。判断方法也很简单:下载完后检查文件是否以 LFS 版本头开头,或者直接看文件大小是否符合预期。我遇到过两次这类情况,判断出来后直接放弃处理,不在爬虫脚本里过度扩展 LFS 兼容逻辑。
6. 把临时脚本变成可复用的小工具
6.1 参数化设计:owner、repo、分支和输出目录全部抽出来
临时脚本跑完一次性需求后,我做的第一件事就是把它参数化。核心思路是:所有可能变化的内容,全部通过命令行参数传入,不写死在代码里。
我用argparse暴露了四个参数:--owner(仓库所有者)、--repo(仓库名)、--branch(分支名,可选,不传则自动获取默认分支)、--out(本地输出目录)。再加一个--skip-dirs参数,允许从命令行传入额外的目录跳过列表,用逗号分隔。
这样设计之后,我下载任意一个仓库只需要改命令行参数,不用动任何代码。如果你有几十个仓库要批量处理,还可以在外层套一个循环,把仓库列表读进 CSV 或者直接放在命令行脚本里。
6.2 运行过程中最常见的三个报错与排查方法
第一个常见报错是 API 返回 403。出现这个错误,优先检查 Token 是否有效,再检查请求频率是不是超限了。我自己的习惯是先在脚本里加一个GET /rate_limit的检查函数,打印剩余请求次数,方便在跑批前判断当前配额。
第二个常见报错是 raw 链接返回 404。这个一眼看上去很意外,因为文件树明明是从 GitHub API 动态拉出来的,路径怎么可能不存在?实测中我发现有两个原因:一是分支名传入错误,文件树来自main分支,但拼接 raw 链接时用了master;二是文件树返回了子模块或构建产物,路径本身存在,但 raw 服务对部分路径做了二次校验。排查方法是先把报错连接复制到浏览器看一眼,确认是不是分支问题。
第三个常见报错是下载过程中连接被重置。这个问题通常和本地网络环境、并发连接数有关。我把并发数调低、重试次数调高之后,成功率大幅提升。另外,requests.Session的timeout不要设得太短,单张图片在慢网环境下超过 10 秒很常见,我一般设 60 秒。
6.3 后续扩展方向:图片清单 JSON、变化检测和站内自托管
最后分享几个我觉得很有价值的扩展方向。
第一个是下载完成后自动生成一份images.json清单,记录每张图片在仓库里的原始路径、本地保存路径和文件大小。这样做的好处是,将来想在文档里引用某张图,直接在清单里搜索关键词就能定位,不用再对着目录树一张张找。
第二个是变化检测。GitHub API 提供按 SHA 获取提交信息的接口。如果你是想长期跟踪某个仓库的新增图片,可以隔一段时间拉一次文件树,和上次的清单做对比,只下载新增和变动的图片。这个思路适合我做素材归档的日常维护。
第三个是站内自托管。下载下来的图片可以统一放到自己的静态站点目录或对象存储里,然后用本地路径替换文档中的远程图片引用。我试过这种方式,加载速度比直接引用原图链接要稳定很多,也避免了原仓库调整目录后外链全部 404 的风险。
我这次跑完最大的体会是:批量下载 GitHub 项目图片这件事,难点根本不在“请求一张图片”,而在“如何稳定高效地拿到准确的图片清单”。文件树接口加白名单过滤加并发下载,这套组合到目前为止是我用过最顺手的方案。如果你只是临时用一次,照着我前面 3.1 和 4.2 的代码改一改就够了;如果你想把它沉淀成日常工具,强烈建议把清单 JSON 和失败重试机制补上,后续维护会轻松非常多。