GitZip Pro 源码解析:一个 GitHub 文件/文件夹下载扩展是如何工作的(三)URL 解析与 GitHub API 请求

项目地址:fthux/GitZipPro。本文继续从源码角度拆解 GitZip Pro 的下载器实现。

上一篇分析了 content.js 如何进入 GitHub 页面、扫描文件列表并维护选中项。页面层最终会把一个 selectedItems 传给下载器。这个 selectedItems 里保存的是 GitHub 文件或目录链接。

从这一篇开始,源码主角变成 source/downloader.js

downloader.js 的任务可以分成两半:前半段负责理解"用户选中了什么",也就是 URL 解析和 GitHub API 请求;后半段负责"把这些内容下载下来并打包",也就是递归收集、并发下载和 zip 生成。本篇先讲前半段。

下载器的输入

content.js 中,选中项保存在一个 Map 里:

js 复制代码
selectedItems.set(row, getPathFromRow(row));

也就是说,下载器拿到的不是一个已经结构化的对象,而是一组 GitHub href,例如:

text 复制代码
/fthux/GitZipPro/tree/main/source
/fthux/GitZipPro/blob/main/README.md

下载器第一步要做的事情,就是把这些路径解析成后续 API 请求需要的结构:

js 复制代码
{
  owner,
  repo,
  branch,
  path,
  type: 'file' | 'dir'
}

对应函数就是 parseGitHubUrl()

parseGitHubUrl:从 GitHub 链接中提取下载目标

parseGitHubUrl(href) 首先会把传入的 href 标准化成 URL:

js 复制代码
url = new URL(href, 'https://github.com');

这里第二个参数很关键。因为从 GitHub 页面 DOM 中拿到的 href 可能是完整 URL,也可能只是相对路径。使用 new URL(href, 'https://github.com') 可以统一处理这两种情况。

接着函数会限制 host:

js 复制代码
if (url.hostname !== 'github.com') return null;

这保证下载器只处理 GitHub 链接。

然后把 pathname 拆成数组:

js 复制代码
const parts = url.pathname.replace(/^\//, '').split('/').filter(Boolean);

典型文件路径会变成:

text 复制代码
fthux/GitZipPro/blob/main/README.md

拆开后:

text 复制代码
parts[0] = owner
parts[1] = repo
parts[2] = blob 或 tree
parts[3] = branch
parts[4...] = path

如果第三段是 tree,说明这是目录;如果第三段是 blob,说明这是文件。代码最终返回:

js 复制代码
return { owner, repo, branch, path, type: 'dir' };

或:

js 复制代码
return { owner, repo, branch, path, type: 'file' };

这个函数是下载器的入口转换器。它把 GitHub 页面链接转换成 GitHub API 可以理解的参数。

下载前的设置读取

下载入口 start(selectedItems, callbacks) 一开始会调用 getSettings()。这个函数从 chrome.storage.local 里读取下载相关设置:

js 复制代码
chrome.storage.local.get(
  [
    STORAGE.NAMING_PRESET,
    STORAGE.NAMING_CUSTOM,
    STORAGE.NOTIFY_SHOW,
    STORAGE.NOTIFY_SOUND,
    STORAGE.NOTIFY_OPEN,
    STORAGE.IGNORE_LABELS,
    STORAGE.IGNORE_CUSTOM_VARS,
    STORAGE.GITHUB_TOKEN,
    STORAGE.TOKEN_ACCESS_MODE
  ],
  ...
)

这里读到的设置会影响后面的流程:

  • 命名规则决定 zip 文件名。
  • 通知设置决定下载完成后是否提醒。
  • 忽略规则决定哪些文件不进入 zip。
  • GitHub token 和访问模式决定 API 请求头。

也就是说,downloader.js 不是一个纯粹的网络请求模块,它会把用户配置纳入下载流程。

buildHeaders:匿名模式与 token 模式

GitHub API 可以匿名访问,但匿名访问有更严格的 rate limit。GitZip Pro 支持用户配置 token,所以请求头构造被封装成了 buildHeaders()

js 复制代码
function buildHeaders(githubToken = '', tokenAccessMode = 'anonymous') {
  const headers = {
    'Accept': 'application/vnd.github.v3+json'
  };

  if (githubToken && tokenAccessMode === 'custom') {
    headers['Authorization'] = `token ${githubToken}`;
  }

  return headers;
}

这里的逻辑很明确:

  • 所有请求都带 Accept: application/vnd.github.v3+json
  • 只有在 custom 模式且 token 存在时,才添加 Authorization
  • 匿名模式不会把 token 放进请求头。

这让下载器可以统一调用 GitHub API,而不必在每个请求处重复判断 token。

githubFetch:统一 API 请求与重试

真正发起 GitHub API 请求的是 githubFetch(apiPath, signal, githubToken, tokenAccessMode, attempt)

它把相对 API 路径拼到 GitHub API base URL 上:

js 复制代码
const fullUrl = `${GITHUB_API_BASE}${apiPath}`;

然后调用 fetch

js 复制代码
resp = await fetch(fullUrl, { headers, signal });

这里的 signal 来自 AbortController,用于取消下载任务。比如用户取消下载时,后续请求可以被中断。

githubFetch 还处理了限流重试。如果返回状态是 429,或者是带有限流语义的 403,并且重试次数还没超过 3 次,就会等待一段时间后再次请求:

js 复制代码
const retryAfter = resp.headers.get('Retry-After');
const wait = retryAfter ? parseInt(retryAfter) * 1000 : (attempt + 1) * 2000;

这个封装让上层函数不需要关心每次请求的错误处理细节。它们只要调用 githubFetch(),拿到 JSON 数据即可。

listDir:请求目录内容

目录列表由 listDir() 完成:

js 复制代码
const apiPath = `/repos/${owner}/${repo}/contents/${encodeURIFilePath(path)}?ref=${branch}`;
const data = await githubFetch(apiPath, signal, githubToken, tokenAccessMode);

GitHub Contents API 对目录返回数组,对文件返回对象。因此 listDir() 会检查返回值必须是数组:

js 复制代码
if (!Array.isArray(data)) {
  throw new Error(`Expected directory listing for: ${path}`);
}

这说明 listDir() 只负责目录场景。文件内容的读取会交给 fetchFile()

fetchFile:请求文件内容

fetchFile() 根据 owner、repo、branch、path 获取单个文件内容。它同样依赖 GitHub Contents API,但文件响应中会包含编码后的内容。下载器后续会把内容转换成二进制数据,放入 zip。

本篇不展开 base64 转换和 symlink 处理,先把主线看清楚:
flowchart TD A"selectedItems 中的 GitHub href" --> B"parseGitHubUrl()" B --> C"owner / repo / branch / path / type" C --> D{"type"} D -->|"dir"| E"listDir()" D -->|"file"| F"fetchFile()" E --> G"githubFetch()" F --> G G --> H"buildHeaders()" H --> I"GitHub API"

这个流程是下载器前半段的核心。

URL 解析与 API 请求的分工

这一篇看到的几个函数边界非常清楚:

  • parseGitHubUrl() 只负责把 GitHub 页面 URL 解析成结构化信息。
  • buildHeaders() 只负责根据 token 设置构造请求头。
  • githubFetch() 只负责统一请求、错误处理和限流重试。
  • listDir() 只负责目录列表。
  • fetchFile() 只负责单文件内容。

这些函数组合起来,完成了从"页面链接"到"GitHub API 数据"的转换。

本篇小结

downloader.js 的前半段解决的是下载前的识别问题。它从 content script 传来的 href 出发,解析出仓库、分支、路径和类型,再通过 GitHub API 获取文件或目录数据。

下一篇继续沿着 downloader.js 往后走:当目录数据拿到之后,GitZip Pro 如何递归收集所有文件、控制并发下载、写入 JSZip,并最终把 zip 交给 background 触发浏览器下载。

你可以在 fthux/GitZipPro 查看完整项目源码,也欢迎 Star 支持这个 GitHub 文件/文件夹下载扩展。

如果你也好奇这个 Pro 版为什么会做起来,可以接着看这篇项目缘起:用了 GitZip 这么多年,我动手做了一个「Pro」版