资讯动态

利用GitHub API批量下载仓库图片:从Trees API到并发下载

发布时间:2026/10/13 5:05:30 来源:尧图企业网站定制
不知道你有没有遇到过这种情况看到一个挺有意思的开源项目想把仓库里的效果截图、UI设计稿、README配图批量存到本地做归档结果发现要么一张张手动另存要么写脚本却总在半路撞上限流。这个“批量下载GitHub中项目的图片”的爬虫小项目想解决的问题就是把这套流程彻底自动化输入一个仓库地址本地就能得到一份按原目录结构归档好的图片文件夹。它能做的事主要有三块拉取整个仓库的文件清单、过滤出图片类型文件、走并发通道批量下载进阶一点还能顺带解析README里的markdown图片引用把文档中引用的仓库内图片一并抓下来。适合需要收集开源项目素材、做离线文档备份、或者想整理某类设计资源的开发者参考。我这里把完整思路、踩过的坑和可直接复制的代码一次讲清楚。1. 整体思路拆解为什么爬GitHub图片要放弃网页抓取1.1 先想清楚不能直接爬HTML页面很多人第一反应是拿Python去抓仓库页面然后正则匹配img标签。我一开始也这么干过但很快就放弃了原因有几个。GitHub仓库页面的文件列表是动态渲染的直接用requests拿到的HTML里并没有完整的图片路径。你以为能看到img src/owner/repo/raw/main/docs/a.png实际上页面脚本会先加载一个JSON中间层然后再由前端拼接出最终地址。也就是说静态HTML里根本拿不全。就算你把页面源码翻个底朝天勉强抠出一堆链接这些链接也大概率是/blob/开头指向的是带页面装饰的“浏览页”不是真实的图片二进制内容。你得再写一套转换逻辑把blob换成raw这中间任何一次页面结构调整都会导致脚本失效。另一个问题是分页和延迟加载。仓库文件一多页面会按目录逐级展开每次滚动都可能触发新的异步请求光靠一个requests.get根本模拟不了完整交互。真要做成一个能用的工具你实际上是在逆向GitHub的前端渲染逻辑维护成本高还容易触发风控。1.2 为什么选择走GitHub API绕开网页之后摆在面前的有三条路Contents API、Git Trees API、直接拼raw链接。我最终选的是Trees API为主raw链接为辅的组合方案原因是效率差距太大了。Contents API是GET /repos/{owner}/{repo}/contents/{path}它可以列出某个目录下的文件但每次只能返回一层目录。一个稍微成型一点的项目目录嵌套三四层很常见每层十几个目录光列目录就要发几十上百个请求。如果仓库里有几千个文件这个方案的请求数量会暴涨很快就撞上API限流。Git Trees API有recursive1参数一次请求就能返回整个仓库的完整文件树包括路径、类型、文件大小、SHA和下载地址。这个数据结构是我要的全部信息一个网络往返就把问题解决大半。拿到文件树之后剩下的就是在本地做过滤和下载这一步几乎不消耗API配额。从个人经验来看凡是涉及“批量拉取仓库内容”的需求优先看Trees API永远是对的。它虽然有一定的数据量上限后面会说truncated的问题但对绝大多数中小型项目来说这是请求次数最省、代码最简洁的方案。1.3 整体流程怎么设计这个项目完整的执行链路是解析仓库地址得到owner和repo名字确定要下载的分支或提交号先调commits接口拿到tree的SHA再用Trees API递归拉取所有文件和目录按扩展名过滤出图片并发下载到本地目录同时保留原始目录结构。如果还想把README里的图片引用也补上再加一步markdown解析和路径归一化。对于下载环节我用的域名是raw.githubusercontent.com它走的是全球CDN速度和稳定性都优于从API返回的download_url。这里有个细节API返回里的download_url有时会指向codeload.github.com或者带签名参数直接拼raw链接反而更干净URL构造也完全可控。所以过滤阶段我只需要收集文件路径不需要额外保存download_url字段。2. 准备阶段GitHub Token与运行环境2.1 Token不是可选项是必需品公共仓库理论上可以不带Token直接调API但未认证的速率限制是60次/小时每台机器每个IP一个配额。真正跑批量任务的时候一次Trees请求、一次commits请求只是热身多处理几个仓库就把配额用完了。我印象很深的一次是脚本跑到一半突然开始抛403 rate limit exceeded当时还以为是代码问题排查半天才发现是配额耗尽。认证之后单小时配额提升到5000次这里面的差距是天壤之别。所以强烈建议先花两分钟生成一个Personal Access Token。生成路径是GitHub右上角头像 → Settings → Developer settings → Personal access tokens → Tokens (classic) → Generate new token。权限只需要勾public_repo一个读权限就够了千万不要顺手把所有权限都勾上那是给自己埋雷。Token的用法是放在HTTP请求头的Authorization: Bearer ...字段里而不是拼在URL后面。拼在URL里的Token会出现在访问日志里一旦链接被分享出去等于把钥匙拱手送人。我在代码里会用一个单独的变量保存Token使用时统一注入headers这样既方便替换也避免到处散落。注意生成的Token只用于本地脚本测试绝对不要把它提交到公开仓库也不要写进博客代码里。我下面示例代码里的Token占位符跑脚本前请替换成你自己的真实值。2.2 环境准备与依赖安装代码基于Python 3.8以上版本依赖包只有requests一个没有花哨的框架。运行前先创建一个虚拟环境然后安装requestspython3 -m venv venv source venv/bin/activate pip install requests如果你想实时看下载进度可以再装一个tqdm但这不是必需项。我实际使用时的经验是文件数量超过几百个之后进度条带来的反馈感很强看着转速条跑起来比干瞪眼舒服得多。后面代码里我会放一个不带tqdm的精简版逻辑更清楚方便理解核心流程。3. 核心代码实现仓库文件树遍历与图片批量下载3.1 先拿到仓库的tree SHATrees API不能直接用分支名调用它要求的是Git对象库里的tree SHA。获取这个SHA最直接的办法是调commits接口它在返回体里带着当前分支最新提交对应的tree哈希。这一步看似多绕了一圈但其实只是一个额外的API请求完全可接受。import os import json import re import time import posixpath import urllib.parse import requests from concurrent.futures import ThreadPoolExecutor, as_completed GITHUB_TOKEN ghp_你的token HEADERS { Authorization: fBearer {GITHUB_TOKEN}, Accept: application/vnd.githubjson, User-Agent: batch-image-downloader, } BASE https://api.github.com def get_tree_sha(session, owner, repo, refHEAD): 获取某个分支/提交对应的 git tree sha。 ref 可以是分支名、tag、commit sha传 HEAD 则用默认分支。 url f{BASE}/repos/{owner}/{repo}/commits/{ref} response session.get(url, headersHEADERS) response.raise_for_status() return response.json()[commit][tree][sha]有人可能会问为什么不直接用/repos/{owner}/{repo}/git/trees/{branch}缩写成分支名实测下来这个接口对部分分支名是会报错的因为Git的tree引用和分支引用不是同一个命名空间。为了避免这种莫名其妙的坑老老实实走commits接口拿SHA最可靠。3.2 拉取完整文件树并处理截断标记拿到tree SHA之后就能用recursive1参数拉全量文件树。这个接口返回的数组里每一项都包含path、type、mode、sha、size、url等字段。type字段用来区分是blob文件还是tree目录mode字段用来判断文件权限其中120000代表符号链接后面过滤图片时要额外留意。def get_repo_tree(session, owner, repo, refHEAD): 返回仓库完整文件树元素是包含 path/type/mode/size 的 dict。 tree_sha get_tree_sha(session, owner, repo, ref) url f{BASE}/repos/{owner}/{repo}/git/trees/{tree_sha}?recursive1 response session.get(url, headersHEADERS) response.raise_for_status() payload response.json() if payload.get(truncated): # 文件树太大被截断后面第5节专门讲怎么处理 print(warning: tree truncated, need fallback strategy) return payload.get(tree, [])这里有个小细节值得注意tree字段里既有文件也有目录目录项的type是tree文件项的type是blob。过滤图片时如果只看扩展名很容易把类型判断忽略掉。目录路径可能正好叫image.png不检查type就把整个目录当图片下载requests会直接报404或者下载到一个HTML错误页。所以每个节点的type字段必须过滤。3.3 图片过滤与下载链接构造过滤条件说起来很简单必须是type blobmode不能是120000符号链接扩展名落在图片白名单里。这里的白名单按实际需求调整我平时处理开源项目时覆盖jpeg、jpg、png、gif、svg、webp、bmp、ico这几种就足够了。IMAGE_EXTS {.png, .jpg, .jpeg, .gif, .svg, .webp, .bmp, .ico} def is_image(item): 判断树节点是否是需要下载的图片文件。 if item.get(type) ! blob: return False if item.get(mode) 120000: # 符号链接直接跳过 return False path item.get(path, ) return os.path.splitext(path)[1].lower() in IMAGE_EXTS下载链接的构造非常简单https://raw.githubusercontent.com/{owner}/{repo}/{ref}/{path}其中ref是分支名、tag或commit shapath是文件在仓库里的完整路径。如果你手里拿到的ref里含有特殊字符比如feature/foo这种带斜杠的分支名直接拼进URL是没问题的浏览器和requests都能正确处理。但有个特殊情况分支名里如果包含#或?拼URL时会产生歧义因为这两个字符在URL里是保留字符。我遇到过一次分支名带#的情况后来改用urllib.parse.quote(ref, safe/)对ref做了一次编码才彻底解决。这个属于罕见场景但知道了能少踩一次坑。3.4 并发下载与断点续传的实现下载图片是个典型的IO密集任务网络等待时间远大于CPU处理时间用多线程可以显著提升效率。我实际用的线程数是6到8之间再高也快不了多少反而容易被CDN限流。线程池太大会同时发起大量连接GitHub的CDN会认为你在攻击它轻则单个请求变慢重则短暂封禁IP。下载函数做了几件事根据原路径生成本地路径并保持目录结构如果目标文件已存在且大小大于0就直接跳过用流式方式写入避免大文件撑爆内存下载后检查前64字节判断是否为LFS指针文件。这里的LFS判断是个很实用的技巧后面会专门解释。def download_item(item, owner, repo, ref, save_dir, session): 下载单个图片文件返回 (path, status)。 rel_path item[path] safe_path rel_path.replace(.., _) # 防止路径穿越 local_path os.path.join(save_dir, safe_path) # 断点续传本地已有且非空文件直接跳过 if os.path.exists(local_path) and os.path.getsize(local_path) 0: return rel_path, skip os.makedirs(os.path.dirname(local_path), exist_okTrue) url fhttps://raw.githubusercontent.com/{owner}/{repo}/{ref}/{rel_path} try: response session.get(url, streamTrue, timeout20) except requests.RequestException as exc: return rel_path, frequest_error: {exc} if response.status_code ! 200: return rel_path, fhttp_{response.status_code} with open(local_path, wb) as f: for chunk in response.iter_content(chunk_size64 * 1024): if chunk: f.write(chunk) # 极小的文件有可能是LFS指针读文件头判断 if os.path.getsize(local_path) 200: with open(local_path, rb) as f: head f.read(64) if bgit-lfs in head: os.remove(local_path) return rel_path, lfs_pointer_skipped return rel_path, ok主调度的写法是把过滤后的任务列表提交给线程池同时把进度记录到本地JSON文件里。脚本中断后再次运行已经下载过的文件直接跳过不用从头再来。这种做法在下载几百个文件时特别实用跑一半断网也不会让人崩溃。def batch_download(tree, owner, repo, ref, save_dir, workers6): tasks [item for item in tree if is_image(item)] session requests.Session() session.headers.update(HEADERS) # 进度持久化 progress_file os.path.join(save_dir, .download_progress.json) done set() if os.path.exists(progress_file): with open(progress_file, r, encodingutf-8) as f: done set(json.load(f)) with ThreadPoolExecutor(max_workersworkers) as pool: futures {} for item in tasks: if item[path] in done: continue future pool.submit(download_item, item, owner, repo, ref, save_dir, session) futures[future] item[path] for future in as_completed(futures): path, status future.result() done.add(path) if status ! ok and status ! skip: print(f[{status}] {path}) with open(progress_file, w, encodingutf-8) as f: json.dump(sorted(done), f, ensure_asciiFalse, indent2) return len(done)关于线程池和Session共用的问题多说一句requests.Session是线程安全的但它内部的连接池在多线程下的表现取决于HTTPAdapter的配置。默认的pool_maxsize10对6个线程绰绰有余所以示例代码里就没有单独调整适配器。如果你把workers调到16以上建议手动创建一个HTTPAdapter并调大pool_maxsize否则连接池会成为瓶颈。4. 进阶用法从README解析markdown图片并一并下载4.1 为什么光有文件树还不够很多项目的README会用图片展示界面截图、架构图、使用演示这些图片的物理位置往往在docs/images/、assets/或者项目根目录下理论上文件树已经包含了它们。但实战中有两类情况会漏网。一类是README使用相对路径引用图片这些路径在文件树里存在但在过滤时你只是按全仓库范围扫描了一遍图片并没有意识到“这个图片在文档里被引用过”如果它不在常规目录里很容易被忽略。另一类是README引用的是目录级别图片中的某一张但那张图恰好是符号链接指向的等下载时才发现内容不对。换句话说单纯按文件树扫描是“广撒网”而解析README是“重点捕捞”。前者保证不遗漏任何图片文件后者保证文档里的展示图一定被收录。两套逻辑叠加才不会出现“文件都在但文档里的图没了”的尴尬。4.2 提取markdown链接并做路径归一化README里的图片引用格式是![alt text](url)其中URL可能是完整链接也可能是相对路径。完整的http(s)链接指向外部图床或github.com的页面相对路径则指向仓库里的某个文件。这一步做的事情就是找出所有相对路径补全成仓库内路径再和文件树里的真实路径匹配。def parse_readme_images(readme_text, repo_path_set, readme_pathREADME.md): 从 README 文本中提取仓库内图片引用。 repo_path_set 是文件树里所有 path 组成的集合。 found set() base_dir os.path.dirname(readme_path) or pattern re.compile(r!\[[^\]]*\]\(([^)])\)) for match in pattern.finditer(readme_text): raw match.group(1).strip() # 去掉锚点和 query 参数避免干扰路径匹配 raw raw.split(#)[0].split(?)[0] if raw.startswith((http://, https://, data:)): continue decoded urllib.parse.unquote(raw) # 处理 %20 等转义 if decoded.startswith(/): # 以 / 开头相对仓库根目录 full decoded.lstrip(/) else: # 相对 README 所在目录再用 posixpath 做归一化 full posixpath.normpath(posixpath.join(base_dir, decoded)) if full in repo_path_set: found.add(full) else: # 有可能引用的是目录本身比如 ./screenshots # 这时候把该目录下所有图片都加入 prefix full / for p in repo_path_set: if p.startswith(prefix) and os.path.splitext(p)[1].lower() in IMAGE_EXTS: found.add(p) return found这里有一个容易忽略的细节markdown链接里的相对路径是相对于README所在目录而不是仓库根目录。如果README放在docs/README.md里面写images/demo.png实际路径是docs/images/demo.png。不先做posixpath.join(base_dir, decoded)就对不上文件树。我自己第一次写的时候漏了这个结果有一半图片解析失败。posixpath而不是os.path的原因是GitHub仓库里的路径一律用Unix风格的正斜杠而Windows下os.path.join会生成反斜杠直接匹配文件树必然失败。跨平台脚本要注意这个区别。4.3 获取README并触发补充下载要解析README第一步是先把README内容下载下来。这里继续用raw域名路径固定是README.md如果仓库根目录没有通常还有readme.md或Readme.md。更稳妥的办法是去文件树集合里找不区分大小写的readme后缀文件。拿到解析结果后把新收集到的图片路径转换成一个最小结构塞进已有的下载队列即可。这一步的关键是把文件树的完整节点信息还原出来因为download_item需要的是带path的dict。可以这样处理先扫描文件树把path到节点的映射关系存成字典解析完成后按key直接取用。def collect_readme_images(tree, session, owner, repo, ref, save_dir): tree_map {item[path]: item for item in tree if item.get(type) blob} repo_path_set set(tree_map.keys()) readme_candidates [p for p in repo_path_set if p.lower().endswith(readme.md)] if not readme_candidates: return [] readme_path readme_candidates[0] url fhttps://raw.githubusercontent.com/{owner}/{repo}/{ref}/{readme_path} resp session.get(url, timeout20) if resp.status_code ! 200: return [] found_paths parse_readme_images(resp.text, repo_path_set, readme_path) found_items [tree_map[p] for p in found_paths if p in tree_map] # 复用线程池下载这些图片 batch_download(found_items, owner, repo, ref, save_dir, workers4) return found_items我把这部分的workers调低到4因为README引用的图片数量通常不多没必要开太高的并发也避免和主下载流程抢配额。5. 高频报错排查限流、树截断、LFS伪图片5.1 403/429请求限流了怎么办下载图片走的是raw域名正常情况下不消耗API配额。但获取文件树和commits信息走的是API域名配额消耗就在这一步。未认证时60次/小时认证后5000次/小时听起来很多但如果你用循环遍历多个仓库几十个仓库下来配额就见底了。我在脚本里加了一个防护逻辑每次API请求后读取响应头里的X-RateLimit-Remaining当剩余次数少于5时就暂停根据X-RateLimit-Reset字段计算需要等待的秒数。这个做法能避免脚本在配额耗尽后不断重试、浪费时间的尴尬。实现如下def api_get_with_backoff(session, url): 简单的限流自保护剩余配额不足时主动休眠到重置时间。 while True: response session.get(url, headersHEADERS, timeout20) remaining response.headers.get(X-RateLimit-Remaining) if remaining is not None and int(remaining) 5: reset_ts int(response.headers.get(X-RateLimit-Reset, time.time())) wait_time max(reset_ts - int(time.time()) 2, 5) print(frate limit low, sleep {wait_time}s) time.sleep(wait_time) continue response.raise_for_status() return response这个api_get_with_backoff可以作为get_tree_sha和get_repo_tree的内部调用比直接session.get更稳。要注意的是X-RateLimit-Reset是Unix时间戳单位是秒别直接当剩余秒数用我第一次就写反了time.sleep直接睡了一天。5.2 truncated字段仓库太大导致文件树被截断Trees API虽然能一次拉全量文件树但有数据量上限。当仓库文件太多时响应里truncated字段会变成true返回的只是部分数据。这种情况在大仓库、Monorepo里很常见。被截断的树里后面的文件路径就缺失了。补救方案有两种。第一种是把recursive的粒度缩小改成按顶层目录分别请求比如分别请求src/、docs/、assets/的树。但Trees API对目录请求的入参仍然是tree SHA不是路径你还需要先拿到每个目录对应的tree节点再逐个调git/trees接口复杂度上来了。第二种更省事的方案是降级到Contents API按目录逐层遍历拿一个目录列表遇到typedir就递归往下走遇到图片文件就收集路径。请求数会暴涨但胜在稳定可靠。我平时对中小仓库直接用Trees API遇到truncatedtrue才做降级。在这个项目里降级逻辑约等于重新写一套遍历所以我通常手动指定几个大目录分几次跑反而更省心。5.3 LFS指针文件伪装成图片这是最容易迷惑人的坑。项目用了Git LFS管理大文件时仓库里存的并不是真实图片而是一个几百字节的文本指针文件。文件内容长这样version https://git-lfs.github.com/spec/v1 oid sha256:xxxxxxxxxx size 123456它的文件路径后缀名是.png或.jpg文件树里的类型也是blob大小很小。如果你不检查内容直接下载保存本地会出现一批只有几行文本的假图片打开就报错。我的下载函数里加了一个小文件头检查文件下载成功且体积小于200字节时读前64字节看是否包含git-lfs字样命中就删掉本地文件并标记为跳过。这个方法简单有效覆盖了绝大多数LFS指针场景。更严谨的做法是解析oid字段调LFS API拉真实文件但那个流程需要额外处理认证和分块下载性价比不高。真需要LFS内容的话直接用git clone更合适。5.4 同名文件互相覆盖与大小写冲突仓库里不同目录下出现同名图片太常见了比如多个模块各有一个cover.png。如果本地保存时把所有文件平铺到一个目录后面的会覆盖前面的最终只剩一份丢图丢得悄无声息。解决办法就是我在下载函数里做的事用os.path.join(save_dir, rel_path)保留完整的目录层级让docs/a/cover.png和guide/a/cover.png分别存到各自目录下。还有一个冷门问题是Windows文件系统不区分大小写仓库里Demos/readme.png和demos/readme.png同时存在时Windows下会冲突。我实际遇到过一次解决办法是在保存前检查目标路径是否存在且是目录若同名路径冲突就在文件名后追加一段哈希值。这个逻辑示例代码里没有展开但使用时要留意自己的运行环境。现象原因排查要点脚本报403 rate limitAPI配额耗尽看响应头X-RateLimit-Remaining加token认证下载到HTML文件把目录当文件下载检查树节点type是否为blob本地图片打不开且内容是一段文本LFS指针文件检查文件头是否包含git-lfs部分文件找不到文件树truncated对截断目录改用Contents API降级下载数量比预期少过滤条件太严确认扩展名白名单覆盖svg/webp等格式6. 写在最后批量下载脚本的几条实战建议这个脚本我在整理开源素材时反复用过好几轮最大的体会是“先列清单、再下载、最后校验”这件事放到任何资源采集场景里都通用。脚本跑起来之前建议先加一个--dry-run参数只打印将要下载的文件数量和总大小不实际执行下载。这个习惯帮我在一次意外中避免了两万多次无效请求——当时某个仓库里全是自动生成的测试图片全下下来纯属浪费。另外不要把并发数调到离谱的程度。有人觉得16线程比4线程快4倍实际遇到CDN限流时反而更慢甚至触发IP级别的临时封禁。我维持在6到8个线程不做特殊需求调整。下载速度瓶颈基本在网络带宽而不是线程数。最后再多说一句这个脚本本质上是个通用的“GitHub目录内容下沉器”你把图片扩展名换成pdf、zip、json稍作修改就能变成批量下载文档或数据文件的工具。框架搭好之后扩展成本很低这也是我乐于把它整理成博客的原因。希望这份代码能帮你省下一些手动点图的重复时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑