做开源项目资料归集或者搭内部知识库的时候最头疼的往往不是代码本身而是散落在仓库各个角落的图片。前阵子我把某个开源项目的文档做了本地化存档想把它 README 里的架构图、UI 预览图和几个子目录下的资源图全部扒下来手动一个个右键另存为显然不现实。尝试直接抓 HTML 再解析img标签也很快遇到了问题项目仓库体量不小目录嵌套深并且 README 里的图片链接还有相对路径和 CDN 外链混着的情况。折腾完这一趟我把整个思路、踩坑点和可复用的脚本都整理了出来这篇就用“批量下载 GitHub 项目图片”这个场景聊聊爬虫和 GitHub API 配合使用的完整方案。这篇内容适合几种人参考需要把开源项目截图批量存档的人准备做图像数据集采集的开发者以及想把自己维护的开源项目文档做离线镜像的维护者。整个方案的核心思路不复杂一句话就能说清通过 GitHub 的 Git Trees API 拿到仓库的完整目录树过滤出目标格式的图片文件再走 raw 域名的高速通道直接下载对 README 里的外链图片则退回到传统爬虫思路去解析连接和抓取。下面把每个环节为什么这样做、代码长什么样、会踩到什么坑逐一拆开讲。1. 需求场景与整体方案选型这批任务在最开始的时候很多人第一反应是写个 requests 脚本去抓仓库主页的 HTML再把所有img的src属性拎出来下载。这个思路不能说错但实操下来你会觉得像在泥地里走路。GitHub 页面本身是动态渲染的直接抓 HTML 返回的只是服务端渲染的部分骨架图片链接可能出现在注释掉的代码块里、藏在 JavaScript 变量里甚至以懒加载的>https://api.github.com/repos/{owner}/{repo}/git/trees/{tree_sha}?recursive1这里的tree_sha并不一定是一个真正的 commit sha也可以是分支名比如main或者master。GitHub 会把这个值当成分支最近一次提交的树对象来处理。返回的数据是一个 JSON 结构核心字段是tree数组每个元素大致长这样{ path: docs/images/architecture.png, mode: 100644, type: blob, size: 123456, sha: abcdef... }注意一点type只有两种值blob代表文件tree代表子目录。因为加了recursive1子目录下的文件也会被扁平化地列在这个数组里这就不需要自己写递归遍历逻辑了。这个接口的实际体验有一个明显的边界条件仓库文件数量非常多的时候响应体会很大GitHub 对超大仓库可能会截断结果并在响应里带上一个truncated字段值为true。这种情况就需要退回到逐层调用的模式先拿到每个子目录的sha再对每个子目录单独请求一棵子树。不过常规项目很少触发这条边界真碰到了也只需要对特定目录额外处理。2.2 raw 下载链路的原理GitHub 对仓库里的原始文件提供了静态下载地址规则是https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{file_path}比如要下载main分支下docs/logo.png请求地址就是https://raw.githubusercontent.com/someone/some-repo/main/docs/logo.png。这个域名设计出来就是为了给用户直接获取文件内容的不经过 API 的频率计数也没有 JSON 包装响应体就是文件本身的字节流。用它来并发下载图片是最理想的路径。不过有个前提这个域名上的缓存有时候会有点滞后新上传的文件偶尔会返回旧版本碰到这个问题可以给 URL 加一个?raw1参数多数情况下能绕过缓存层拿到最新内容。这里也补充一个容易偷懒踩坑的点文件路径中的空格、中文名、特殊符号直接拼接成 URL 会在请求时报 404 或者被转义出问题。正确的方法是先对路径参数做 URL 编码用标准库的urllib.parse.quote来处理就可以大幅减少这类莫名其妙的 404。2.3 README 外链图片的两种存在形式很多项目里真正需要下载的图片并不全在仓库里README 内容里还会出现两类额外资源。第一类是外部图床链接就是以http或https开头的完整地址形式可能是架构图、徽章badge、示例截图。第二类是相对路径引用形如或这种写法在 README 文档里很常见。第一类直接下载就行但需要注意不少图床有防盗链机制服务端会检查Referer头。直接从 GitHub API 拿到的文本里并不会有 Referer 这个信息所以下载外链图片时需要自己伪装请求头把Referer设置为仓库主页或者图床允许的域名否则很容易收到 403 响应。第二类相对路径就需要手动拼 URL拿到仓库的 owner、repo 和当前分支名再把相对路径中开头多余的./或../部分规整化最终拼成直的 raw 链接。2.4 正则提取 Markdown 图片链接的边界从 README 文本里提取图片链接用正则就够了最常用的是匹配 Markdown 图片语法!\[[^\]]*\]\(([^)])\)这个正则只匹配到一个括号里的内容能覆盖绝大多数的写法。但有两类特殊情况它容易漏或者误判一类是链接里本身包含了中文括号或者转义字符另一类是 GitLab 风格的引用语法或者 HTML 的原生img标签。处理这类杂项一条正则是搞不定的建议在脚本里加一个二次清洗函数把http开头之外的情况也纳入路径拼接的逻辑。3. 完整代码实现与分析设计思路说完直接上代码。下面这个版本是我实际用过的脚本改造而来的去掉了业务相关的部分保留了核心框架。重点是让你跑通再根据自己项目里的特殊情况去扩展。3.1 基础依赖和参数设计脚本用 Python 实现依赖只有requests和标准库。文件开头先定义几个配置项把 owner、repo、分支、输出目录和并发数都抽成变量方便复用。import os import re import time import json import requests from urllib.parse import quote from concurrent.futures import ThreadPoolExecutor, as_completed OWNER some-owner REPO some-repo BRANCH main OUTPUT_DIR ./images MAX_WORKERS 8 MAX_RETRIES 3 TIMEOUT 15 HEADERS { User-Agent: Mozilla/5.0 (compatible; ImageDownloader/1.0), }MAX_WORKERS控制并发度实测下来 8-16 是性能功耗比比较合理的区间。开太小下载大仓库会明显慢开太大容易触发服务端限流反而得不偿失。MAX_RETRIES和TIMEOUT是用来保证稳定性的在网络抖动频繁的场景里尤其重要。3.2 获取仓库目录树这一步负责把整个仓库的目录树拿到手。用分支名直接替代tree_sha加上?recursive1参数一次请求就能拿到完整的扁平化文件列表。def get_tree_data(owner, repo, branch): url fhttps://api.github.com/repos/{owner}/{repo}/git/trees/{branch}?recursive1 resp requests.get(url, headersHEADERS, timeoutTIMEOUT) if resp.status_code 404: # 分支名可能不是默认分支尝试通过仓库信息获取默认分支 repo_url fhttps://api.github.com/repos/{owner}/{repo} repo_resp requests.get(repo_url, headersHEADERS, timeoutTIMEOUT) repo_resp.raise_for_status() default_branch repo_resp.json().get(default_branch, main) url fhttps://api.github.com/repos/{owner}/{repo}/git/trees/{default_branch}?recursive1 resp requests.get(url, headersHEADERS, timeoutTIMEOUT) resp.raise_for_status() return resp.json()这里做了一个 404 兜底逻辑。很多项目的默认分支不叫main也可能是master或者其他名称第一次请求如果 404就先去仓库信息接口把default_branch查到再重新请求目录树。这个细节看似不起眼实际遇到的分支名五花八门写死分支名的脚本大概率要翻车。有个小提示GitHub 未认证的 API 请求限额是每小时 60 次如果用这个脚本批量处理很多仓库非常容易撞上限。建议在HEADERS里带上认证 tokenHEADERS { User-Agent: Mozilla/5.0 (compatible; ImageDownloader/1.0), Authorization: token YOUR_GITHUB_TOKEN, }带上 token 之后限额会提高到每小时 5000 次正常批量下载完全够用。3.3 过滤图片文件并生成 raw 链接拿到目录树后处理逻辑就清晰了。遍历tree数组筛选出所有type为blob且路径后缀是图片格式的文件。生成 raw 链接时要记得对文件路径做 URL 编码同时保持目录结构落盘。IMAGE_EXTENSIONS {.png, .jpg, .jpeg, .gif, .svg, .webp, .bmp, .ico} def filter_image_files(tree_data): image_files [] for item in tree_data.get(tree, []): if item[type] ! blob: continue path item[path] ext os.path.splitext(path)[1].lower() if ext in IMAGE_EXTENSIONS: image_files.append(path) return image_files def build_raw_url(owner, repo, branch, path): encoded_path quote(path, safe/) return fhttps://raw.githubusercontent.com/{owner}/{repo}/{branch}/{encoded_path}os.path.splitext(path)[1].lower()这段做了两点处理一是提取后缀名二是把后缀全部转成小写。像.JPG和.jpg这种大小写不一致的情况在这里就会被正常识别不至于漏下载。3.4 并发下载器与重试机制下载器的整体设计是队列消费模式用线程池发起并发请求。每个任务做三件事请求图片数据、创建目标目录、写入本地文件。为了应对网络抖动这里做了一层重试逻辑使用Retry适配器统一处理。from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry def create_session(): session requests.Session() retries Retry( totalMAX_RETRIES, backoff_factor0.5, status_forcelist[500, 502, 503, 504], allowed_methods[GET], ) session.mount(https://, HTTPAdapter(max_retriesretries)) return session def download_image(session, url, save_path): if os.path.exists(save_path): return skipped resp session.get(url, headersHEADERS, timeoutTIMEOUT, streamTrue) resp.raise_for_status() os.makedirs(os.path.dirname(save_path), exist_okTrue) with open(save_path, wb) as f: for chunk in resp.iter_content(chunk_size8192): if chunk: f.write(chunk) return ok def download_all(image_files, output_dir): session create_session() tasks [] with ThreadPoolExecutor(max_workersMAX_WORKERS) as executor: for path in image_files: raw_url build_raw_url(OWNER, REPO, BRANCH, path) save_path os.path.join(output_dir, path) tasks.append(executor.submit(download_image, session, raw_url, save_path)) for future in as_completed(tasks): try: status future.result() if status skipped: print(f[SKIP] {future}) except Exception as e: print(f[FAIL] {e})这里有一个很容易被忽略的细节requests.Session()在并发场景里是线程安全的多个线程可以共用同一个 Session 对象发请求最大的好处是会自动复用底层的 TCP 连接。相比每个线程各自创建连接这种方式在大量小图片下载时能显著减少握手开销。另外download_image函数第一行判断了文件是否已经存在如果存在就直接跳过。这个“断点续传”的思路很朴素但实际批量下载过程中只要中断过一次你就会发现它能帮你省掉大量重新下载的时间。3.5 补充处理 README 外链图片对 README 里引用的图片单独写一个函数处理。先用 API 获取 README 内容文件内容会做 Base64 编码需要解码再通过正则提取图片链接分别送到下载器里。import base64 def get_readme_content(owner, repo, branch): url fhttps://api.github.com/repos/{owner}/{repo}/readme?ref{branch} resp requests.get(url, headersHEADERS, timeoutTIMEOUT) resp.raise_for_status() content resp.json().get(content, ) return base64.b64decode(content).decode(utf-8, errorsignore) def extract_image_links(readme_text): pattern r!\[[^\]]*\]\(([^)])\) return re.findall(pattern, readme_text) def resolve_link(owner, repo, branch, link): if link.startswith(http): return link cleaned link.lstrip(./) return build_raw_url(owner, repo, branch, cleaned) def handle_readme_images(owner, repo, branch, output_dir): text get_readme_content(owner, repo, branch) links extract_image_links(text) session create_session() for idx, link in enumerate(links): url resolve_link(owner, repo, branch, link) if github.com in url or raw.githubusercontent.com in url: save_path os.path.join(output_dir, freadme_img_{idx} os.path.splitext(url)[1]) else: # 外链图片使用图床地址文件名取后半段带随机数避免冲突 save_path os.path.join(output_dir, fexternal_{idx} os.path.splitext(url)[1]) headers dict(HEADERS) headers[Referer] fhttps://github.com/{owner}/{repo} try: download_image(session, url, save_path) except Exception as e: print(f[README_IMG_FAIL] {link}, error: {e})这里有几个细节值得展开。第一/readme接口返回的内容是按 Base64 编码的必须解码后再做正则提取。第二对于外部图床链接文件名直接用external_{序号}这种方式生成避免不同图床 URL 中文件名重名导致互相覆盖。第三因为外链图片服务器的防盗链机制各不相同这个示例里统一加了Referer指向仓库主页对一部分图床是有效的。4. 常见问题排查与避坑清单这一段直接进入实战中积累的坑位清单。我把踩过的问题整理成一个速查表再针对几个高频项单独展开说明。问题现象常见原因解决思路API 返回 403未认证请求触发 Rate Limit加AuthorizationHeader或降低请求频率下载返回 404分支名不对或者路径拼错先查default_branch对路径做 URL 编码下载返回 403图床防盗链设置Referer和User-Agent必要时带 Cookie下载内容是一段 HTMLURL 被重定向到登录页或错误页检查 URL 是否完整stream模式下检测Content-Type图片文件名冲突不同目录下有同名文件保持目录结构落盘或者文件名加 hash 前缀README 图片链接提取不全正则没覆盖 HTMLimg标签追加img[^]src([^])的正则超大仓库目录树被截断文件数量超过 API 返回上限检查truncated字段改为逐目录获取第一个高频问题是 API 限额。GitHub 的 Rate Limit 是全局的不只是这一棵树接口其他 API 也会共享同一个配额池。如果你在用脚本拉取多个仓库的时候没有配置认证 token大概处理到第三个仓库就等着 403 乱飞吧。第二个高频问题是下载到 HTML 文件。发生这种情况时十有八九是路径拼错了服务端返回了一个软 404 的 HTML 页面。建议在download_image函数里加上一层内容类型检查content_type resp.headers.get(Content-Type, ) if text/html in content_type: raise ValueError(fGot HTML instead of image: {url})还有一个容易忽略的坑仓库中图片文件的后缀名可能五花八门如.tiff、.avif这类少见格式。建议把扩展名列表调整成集合类型并增加.tif、.avif、.heic等常见格式避免漏掉本该下载的资源。本方案里的扩展名集合是常规基础版你可以按项目实际需求动态扩展。路径安全性也是一个要点。虽然 GitHub 路径本身不会包含绝对路径或者..跳级但是在把路径拼接到本机文件系统的时候最好还是做一次规范化处理防止畸形路径覆盖到预期之外的目录。在download_all里加一行os.path.normpath(save_path)再确认它确实位于输出目录内属于有备无患的操作。另外一个从实践里总结的经验如果下载的图片里包含 SVG 这类文本型文件建议对文件内容也简单检查一下因为 SVG 里可能会嵌入外部资源引用或者脚本虽然下载场景下这个风险不高但做安全扫描的时候别漏掉它。5. 扩展方向与个人经验这个下载器的核心链路已经能解决大多数“GitHub 项目图片批量下载”的需求。实际使用中我还在它之上扩展过几个比较有价值的功能。第一个扩展是支持私有仓库。在HEADERS里配置带有相应权限的 token 之后这套逻辑对私有仓库同样生效只是要注意私有仓库的 API 配额使用更快下载完一批之后留意一下剩余配额。第二个扩展是把 README 里提取到的图片链接也缓存起来做去重。同一个仓库里同一张图片可能被多个文档引用多次如果不做去重会产生很多重复下载。可以用一个集合记录已经下载过的图片 URL下载前先查一下集合能省不少时间和流量。第三个扩展是加一个交互式的过滤列表。下载前先把筛选出来的图片清单打印出来允许用户手动排除一部分不想下载的目录比如test/fixtures、node_modules这类资源密集但无关的目录再开始批量下载。这个需求在实际场景里出现频率极高几乎是刚需级别的优化。最后再分享一个让我印象很深的小教训。第一次跑大规模下载脚本的时候我把并发数调到了 32想着越快越好。结果中间有一批请求出现了大量超时和重试排查了半天才发现问题不在 GitHub 端而是本地对同时打开的 TCP 连接数限制。后来把并发数降回 16加了一层backoff_factor0.3的退避策略一切就正常了。爬虫这种东西快不是第一位的稳才是。批量任务跑起来之后让它安安静静地在后台跑完比什么都重要。这套方案目前跑过的最大一个仓库大概有 2000 多个图片文件加上外部链接一起全程十几分钟自动完成文件目录结构和仓库保持高度一致。如果你也有类似的归档、采集需求拿这套代码改改 owner、repo 和分支名基本就能直接用了。