Repo2Gal:把GitHub仓库历史变成可交互视觉小说
2026/8/31 1:42:23 网站建设 项目流程

GitHub 仓库在开发者眼中往往是一堆代码、commit 记录、Issue 和 Pull Request,但在另一些人眼里,这些看起来冷冰冰的数据完全可以被“讲故事”。近期在整理开源项目复盘方案时,我尝试做一个叫 Repo2Gal 的项目,目标很简单:把 GitHub 仓库的历史元数据,转换成一款可以交互的视觉小说。本文围绕这个想法,完整拆解从 GitHub API 数据采集、剧本编译到前端渲染的全流程,包含可直接运行的示例代码与部署建议。无论你是想给开源项目做一个更生动的介绍页,还是想用视觉小说形式梳理代码仓库发展历程,这套流程都能直接复用。

1. Repo2Gal 是什么:给 GitHub 仓库写一部视觉小说

1.1 仓库数据本身就有“剧情潜力”

先来看一个 GitHub 仓库里到底有什么数据:仓库的基本信息、Star 和 Fork 数量、提交记录、Issue、Pull Request、Release、贡献者列表等。平时我们用 GitHub 网页或 Git 命令查看它们时,看到的是零散信息;但如果把这些数据映射到 Galgame 的叙事模型里,就会变得很有意思。

一个比较自然的映射思路是这样:提交历史是主角的成长线,每个 commit 都代表剧情向前推进的节点;Issue 是冒险过程中遇到的“事件”,需要被处理;Pull Request 是同伴加入的“分支事件”;Release 则是章节更新;Star 和 Fork 可以理解为观众对这部作品的好感度与传播度;贡献者则是故事中的角色。这种映射不改变数据本身,只是提供一个新的“观看视角”。

1.2 Repo2Gal 的技术定位

Repo2Gal 并不是一个随手写的小玩具,而是一条完整的数据流水线。它需要完成三件事:

  • 采集:从 GitHub REST API 拉取仓库原始数据。
  • 编译:把原始数据转换成视觉小说剧本 JSON。
  • 渲染:在前端播放器中展示对话、角色头像和选择分支。

这三个环节如果分开做,每一步都可以复用。例如数据采集部分不仅可以服务视觉小说,也可以用于生成仓库周报、年度报告、看板数据;剧本编译部分可以调整模板,生成不同风格的文案;渲染部分则可以直接接入 WebGAL、Ren'Py 这类游戏引擎。

1.3 适合哪些使用场景

这个方案主要有四类典型使用场景:

  • 开源项目展示:把项目 README 之外的“活数据”做成可交互页面,访客通过游戏形式了解项目历史。
  • 程序员个人主页:把自己维护的仓库做成一部“编程生涯物语”,比普通简历更容易给人留下印象。
  • 团队内部 Replay:新人入职后,通过视觉小说回顾团队项目的重大 bug 和功能迭代。
  • 教学演示:讲 GitHub 协作流程时,把 Issue、PR、Code Review 这些概念包装成剧情,降低理解门槛。

下面进入正题,我们来一步步实现这条流水线。

2. 核心设计:三个模块解决“仓库到游戏”的转换

2.1 整体流程

先看整体流程,我建议把项目分成三层,每层职责单一:

GitHub REST API(数据源) ↓ fetch_repo.py(数据采集层) ↓ repo_data.json(中间数据) ↓ build_script.py(剧本编译层) ↓ frontend/data.json(剧本 JSON) ↓ index.html + CSS + JS(渲染播放层) ↓ 浏览器 / GitHub Pages

这种分层的好处是,每一层都可以单独测试和替换。例如你想换一个数据源,比如从 GitLab API 拉数据,只需要替换第一层;你想把渲染层从自定义播放器换成 WebGAL,也只需要保证剧本 JSON 结构兼容。

2.2 数据模型设计

视觉小说最核心的数据模型包含四个概念:角色、场景、对话行、选项。

  • 角色:对应仓库贡献者。字段包含角色 ID、名称、头像地址。
  • 场景:对应一个剧情阶段。字段包含场景 ID、标题、对话行列表、下一场景 ID、选项列表。
  • 对话行:对应一句台词。字段包含说话人、头像、文本内容。
  • 选项:对应玩家交互。字段包含选项文本和跳转目标场景。

用 JSON 表示大概长这样:

{ "id": "scene_welcome", "title": "开场", "lines": [ { "speaker": "旁白", "text": "欢迎来到这个仓库的物语。" } ], "next": "scene_commit_0", "choices": [] }

在后续章节,我会用代码把 GitHub 原始数据映射到这个模型里。

2.3 技术选型说明

数据采集层使用 Python 3 和 requests 库,原因是 GitHub REST API 数据量不小,Python 处理 JSON 非常方便,后续即使要接入数据清洗、统计分析也顺手。剧本编译层同样使用 Python,保证与采集层无缝衔接。渲染层没有选择重量级游戏引擎,而是用最原始的原生 HTML、CSS、JavaScript 实现一个极简播放器,好处是依赖少、代码可直接运行、便于理解核心逻辑。

如果你熟悉 WebGAL 或者 Ren'Py,后续也可以把生成的剧本 JSON 再转换一次,接入到更成熟的引擎中。

3. 环境准备:工具、令牌与目录结构

3.1 开发环境

本文示例在以下环境中验证,版本不需要完全一致,但建议不要太旧:

  • 操作系统:Windows 10/11、macOS、Linux 均可。
  • Python:3.9 及以上。
  • Git:任意近期版本。
  • 浏览器:Chrome、Edge、Firefox。
  • 本地 HTTP 服务:Python 自带http.server,用于预览前端页面。

需要安装的 Python 依赖只有一个:

pip install requests

建议创建虚拟环境,避免污染全局环境:

python -m venv venv source venv/bin/activate # Windows 使用 venv\Scripts\activate pip install requests

3.2 创建 GitHub 个人访问令牌

调用 GitHub REST API 时,未认证的请求有很严格的频率限制,每小时只能请求 60 次;如果带上个人访问令牌,限制可以提升到每小时 5000 次。因此创建令牌是必须的。

在 GitHub 网页上,路径为:

Settings → Developer settings → Personal access tokens → Fine-grained tokens → Generate new token

建议使用 Fine-grained token(细粒度令牌),权限范围尽量最小:

  • Repository access:选择你要采集的仓库。
  • Permissions → Metadata:Read-only。
  • Permissions → Contents:Read-only。
  • Permissions → Issues:Read-only。
  • Permissions → Pull requests:Read-only。

生成后把令牌保存下来,接下来通过环境变量使用,不要硬编码在代码或仓库中。Linux/macOS 设置方式:

export GITHUB_TOKEN="你的令牌"

Windows PowerShell 设置方式:

$env:GITHUB_TOKEN="你的令牌"

3.3 项目目录结构

为了便于阅读,整个项目按下面的目录组织:

repo2gal/ ├── fetch_repo.py ├── build_script.py ├── requirements.txt ├── repo_data.json └── frontend/ ├── index.html └── data.json

其中repo_data.json是数据采集层生成的中间文件,frontend/data.json是剧本编译层生成的最终剧本文件,index.html是前端播放器。下面先实现数据采集层。

4. 数据采集:用 GitHub REST API 获取仓库元数据

4.1 GitHub REST API 基础与注意事项

GitHub REST API 的基础地址是:

https://api.github.com

调用时需要携带几个 HTTP Header:

  • Accept: application/vnd.github+json:告诉 GitHub 我们期望接收 JSON 格式。
  • X-GitHub-Api-Version: 2022-11-28:指定 API 版本,避免后续接口变动影响程序。
  • Authorization: Bearer <token>:带上令牌,提高速率限制。

常用接口如下:

数据接口说明
仓库概要GET /repos/{owner}/{repo}仓库名称、描述、Star、Fork、License
提交记录GET /repos/{owner}/{repo}/commits按时间倒序返回提交
IssueGET /repos/{owner}/{repo}/issues?state=all列表会混合 Pull Request
Pull RequestGET /repos/{owner}/{repo}/pulls?state=all单独获取 PR
贡献者GET /repos/{owner}/{repo}/contributors按提交次数排序
ReleaseGET /repos/{owner}/{repo}/releases发行版信息

有两个容易踩的坑需要注意。第一个坑是issues接口和pulls接口有重叠:GitHub 把 Pull Request 也视为一种 Issue,所以在获取 Issue 时,需要用pull_request字段过滤掉 PR。第二个坑是分页问题,接口默认每页最多返回 100 条,如果仓库数据量大,必须处理分页,否则会丢掉后面几十条数据。

4.2 拉取仓库基本信息

先写一个最核心的请求函数。下面这段代码会组装请求头,并调用仓库信息接口:

import requests GITHUB_API = "https://api.github.com" def make_headers(token: str) -> dict: headers = { "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", } if token: headers["Authorization"] = f"Bearer {token}" return headers def fetch_repo_info(owner: str, repo: str, token: str) -> dict: url = f"{GITHUB_API}/repos/{owner}/{repo}" headers = make_headers(token) resp = requests.get(url, headers=headers, timeout=30) resp.raise_for_status() return resp.json()

仓库信息接口返回的字段非常多,我们重点关注full_namedescriptionstargazers_countforks_countopen_issues_countlicensehtml_url等字段。

4.3 获取提交记录、Issue 与 Pull Request

提交记录、Issue、PR 都适合用分页函数。为了避免重复代码,我封装一个分页请求函数:

def fetch_paged(url: str, headers: dict, per_page: int = 100, max_pages: int = 10) -> list: items = [] for page in range(1, max_pages + 1): params = {"per_page": per_page, "page": page} resp = requests.get(url, headers=headers, params=params, timeout=30) resp.raise_for_status() batch = resp.json() if not batch: break items.extend(batch) if len(batch) < per_page: break return items

这里默认最多拉取 10 页,也就是最多 1000 条记录。实际使用中,大多数中小型仓库足够用;如果仓库非常大,可以调大max_pages

获取提交记录时,只保留剧情需要的字段。每条 commit 我们关心提交时间、提交者名字、提交信息、作者头像等。代码如下:

commits = fetch_paged(f"{base}/commits", headers, per_page=100) commit_list = [] for commit in commits: author_info = commit.get("author") or {} commit_list.append({ "sha": commit.get("sha", ""), "date": (commit.get("commit", {}) or {}).get("author", {}).get("date", ""), "author": author_info.get("login") or (commit.get("commit", {}) or {}).get("author", {}).get("name", "unknown"), "avatar": author_info.get("avatar_url", ""), "message": (commit.get("commit", {}) or {}).get("message", ""), })

获取 Issue 和 PR 时同样处理:

issues = fetch_paged(f"{base}/issues", headers, per_page=50) issues = [item for item in issues if "pull_request" not in item] # 过滤掉 PR pulls = fetch_paged(f"{base}/pulls", headers, per_page=50)

注意issues接口返回的数据里,如果某个 issue 同时是 PR,会带上pull_request字段,所以要过滤掉。

4.4 获取贡献者、Star 与 Fork

贡献者列表可以调用/contributors接口,按提交次数从高到低排列。Star 和 Fork 数量不需要单独调接口,仓库信息里的stargazers_countforks_count字段已经包含。

contributors = fetch_paged(f"{base}/contributors", headers, per_page=100)

如果后续想获取具体的 Star 记录,比如“哪些人点了 Star”,可以调用/stargazers接口,但要注意该接口对访问权限和请求频率要求较高,本文只使用数量字段。

4.5 完整采集脚本

把上面的函数组合起来,就是一个完整的fetch_repo.py。这个脚本可以读取--repo参数,例如octocat/Hello-World,把结果写入repo_data.json

#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Repo2Gal 数据采集器 从 GitHub REST API 拉取仓库元数据,输出到 JSON 文件。 用法示例: python fetch_repo.py --repo octocat/Hello-World --output repo_data.json """ import argparse import json import os import time import requests GITHUB_API = "https://api.github.com" def make_headers(token: str) -> dict: headers = { "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", } if token: headers["Authorization"] = f"Bearer {token}" return headers def fetch_json(url: str, headers: dict, params: dict, retries: int = 3) -> dict: for attempt in range(1, retries + 1): resp = requests.get(url, headers=headers, params=params, timeout=30) if resp.status_code == 403 and attempt < retries: wait_seconds = 30 * attempt print(f"[警告] 触发 API 限流,等待 {wait_seconds} 秒后重试 ...") time.sleep(wait_seconds) continue resp.raise_for_status() return resp.json() def fetch_paged(url: str, headers: dict, per_page: int = 100, max_pages: int = 10) -> list: items = [] for page in range(1, max_pages + 1): params = {"per_page": per_page, "page": page} batch = fetch_json(url, headers, params) if not batch: break items.extend(batch) if len(batch) < per_page: break return items def collect_repo_data(owner: str, repo: str, token: str) -> dict: headers = make_headers(token) base = f"{GITHUB_API}/repos/{owner}/{repo}" repo_info = fetch_json(base, headers, {}) raw_commits = fetch_paged(f"{base}/commits", headers, per_page=100) raw_issues = fetch_paged(f"{base}/issues", headers, per_page=50) raw_pulls = fetch_paged(f"{base}/pulls", headers, per_page=50) raw_contributors = fetch_paged(f"{base}/contributors", headers, per_page=100) raw_releases = fetch_paged(f"{base}/releases", headers, per_page=50) commits = [] for commit in raw_commits: commit_data = commit.get("commit", {}) or {} author_data = commit.get("author") or {} commit_author = commit_data.get("author", {}) or {} commits.append({ "sha": commit.get("sha", ""), "date": commit_author.get("date", ""), "author": author_data.get("login") or commit_author.get("name", "unknown"), "avatar": author_data.get("avatar_url", ""), "message": commit_data.get("message", "").strip(), }) issues = [] for issue in raw_issues: if "pull_request" in issue: continue user = issue.get("user") or {} issues.append({ "number": issue.get("number"), "title": issue.get("title", ""), "body": issue.get("body", ""), "state": issue.get("state", ""), "user": user.get("login", "unknown"), "created_at": issue.get("created_at", ""), }) pulls = [] for pr in raw_pulls: user = pr.get("user") or {} pulls.append({ "number": pr.get("number"), "title": pr.get("title", ""), "body": pr.get("body", ""), "state": pr.get("state", ""), "merged": bool(pr.get("merged_at")), "user": user.get("login", "unknown"), "created_at": pr.get("created_at", ""), }) contributors = [] for contributor in raw_contributors: contributors.append({ "login": contributor.get("login", "unknown"), "avatar_url": contributor.get("avatar_url", ""), "contributions": contributor.get("contributions", 0), }) releases = [] for release in raw_releases: releases.append({ "tag_name": release.get("tag_name", ""), "name": release.get("name", ""), "published_at": release.get("published_at", ""), }) return { "repo": repo_info, "commits": commits

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询