事情的起因
一个用了好几年的 GitHub 账号,说没就没了。
收到那封来自 GitHub 的邮件时,我第一反应倒不是心疼代码——本地和远程都有备份——而是脑子里嗡了一下:那几百个 Star 怎么办?
从入门时收藏的第一个 awesome 清单,到后来陆陆续续标记的各种工具库、轮子、教程,几年攒下来全锁在一个再也登不进去的账号里。手动重新搜一遍再点一遍 Star?几百个仓库,搜到明年也搜不完,更何况很多仓库当时为什么 Star 早忘了,连名字都记不全。
于是写了个脚本,跑了几分钟,全部搬完。这篇文章把思路和代码都放出来,希望你永远用不上,但真到那天不至于抓瞎。
先花一分钟说说「被封」这件事
很多人以为封号就是登录报错,其实 GitHub 有个更隐蔽的机制叫 flag:被标记的账号,你自己登录进去看,一切正常——主页在、仓库在、Star 也在;但换个无痕窗口(或者让朋友)去访问你的主页,得到的是一个冷冰冰的 404。
这意味着两件事:一是你可能被封了很久都没发现;二是自查方法特别简单——退出登录或开无痕窗口访问自己的主页,能看到就没被标记,看不到就该去 GitHub 官方的申诉页面提交申诉了。
至于为什么会被封,社区里被提及最多的几种情况:一台设备上来回登录多个账号、IP 频繁变动、以及各种账号关联行为。申诉能不能成看运气,所以重要数据永远别只放在一个账号里——这也是下面整个迁移脚本的出发点。
这个脚本能成立的原理:Star 列表是公开的
很多人没意识到一点:GitHub 的 Star 列表是公开数据。哪怕账号被封,只要数据还在,任何人不登录都能通过 API 读到某个用户 Star 过哪些仓库:
https://api.github.com/users/用户名/starred返回的是 JSON,一页最多 100 条,用 page 参数往后翻。所以「拿到旧账号的收藏清单」这一步,完全不需要旧账号的任何凭证——这大概是整个方案里最让人松一口气的部分。
真正需要 Token 的是另一半:用新账号把仓库重新 Star 一遍。对着 PUT /user/stared/仓库名 发一个请求,返回 204 就是 Star 成功了。
一句话概括整个流程:公开接口拉清单 → 拿着新账号的 Token 逐个 PUT 回去。
代码
依赖只有 requests,改三个常量就能跑:
import requests
import time
# 你的新 GitHub 用户名
NEW_USERNAME = "***"
# 新账号的 GitHub Token(必须有 public_repo 权限)
GITHUB_TOKEN = "ghp_****"
# 被封禁的用户名
BANNED_USERNAME = "***"
# API 地址
STARRED_API = f"https://api.github.com/users/{BANNED_USERNAME}/starred"
HEADERS = {
"Authorization": f"token {GITHUB_TOKEN}",
"Accept": "application/vnd.github.v3+json"
}
def get_all_starred_repos():
"""获取被封禁账号的所有 Star 仓库列表"""
repos = []
page = 1
print(f"正在获取 @{BANNED_USERNAME} 的所有 Star 仓库...")
while True:
url = f"{STARRED_API}?page={page}&per_page=100"
resp = requests.get(url)
if resp.status_code != 200:
print(f"获取失败,状态码: {resp.status_code}")
break
data = resp.json()
if not data:
break
for repo in data:
repos.append({
"full_name": repo["full_name"],
"url": repo["html_url"]
})
print(f"已获取第 {page} 页,累计:{len(repos)} 个")
page += 1
time.sleep(1) # 防限流
print(f"\n总共获取到 {len(repos)} 个 Star 仓库")
return repos
def star_repos(repos):
"""批量给新账号 Star 仓库"""
success = 0
failed = []
total = len(repos)
print(f"\n开始批量 Star 到 @{NEW_USERNAME}...")
for i, repo in enumerate(repos, 1):
full_name = repo["full_name"]
url = f"https://api.github.com/user/starred/{full_name}"
try:
resp = requests.put(url, headers=HEADERS)
if resp.status_code in (204, 404):
# 204 = Star 成功
# 404 = 仓库不存在/已删除,跳过
success += 1
print(f"[{i}/{total}] {full_name}")
else:
failed.append(full_name)
print(f"[{i}/{total}] 失败 {full_name} | 状态码: {resp.status_code}")
time.sleep(2) # 防 GitHub API 限流
except Exception as e:
failed.append(full_name)
print(f"[{i}/{total}] 异常 {full_name} | {str(e)}")
# 最终统计
print("\n" + "="*50)
print(f"迁移完成!")
print(f"成功 Star:{success} 个")
print(f"失败/不存在:{len(failed)} 个")
if failed:
print("\n失败列表(可手动检查):")
for f in failed:
print(f" - {f}")
if __name__ == "__main__":
starred_repos = get_all_starred_repos()
star_repos(starred_repos)Token 怎么生成
新账号的 Token 是唯一需要提前准备的东西,步骤:
- 打开 GitHub,右上角头像 → Settings
- 左侧栏拉到最底下 → Developer settings → Personal access tokens → Tokens (classic)
- 点 Generate new token (classic)
- 权限只勾 public_repo 就够了——Star 是个公开操作,没必要给更多权限,令牌权限最小化永远是好习惯
- 生成后立刻复制(离开页面就再也看不到了),填进脚本:
NEW_USERNAME = "你的新GitHub用户名"
GITHUB_TOKEN = "你刚复制的token"
BANNED_USERNAME = "你的旧Github用户名"跑之前最好知道的几件事
限流是真实存在的。 GitHub API 不带 Token 每小时只能请求 60 次,带了 Token 是 5000 次。拉清单那步虽然不用 Token 也行,但脚本里两处 time.sleep 都是给限流留的余量,几百个仓库跑个十几分钟很正常,别嫌慢。
状态码的含义要对得上。 204 才是 Star 成功;404 说明那个仓库本身没了(作者删库、转私有),这不是你的问题,跳过就好。脚本里把 404 也算进了成功数——毕竟「该 Star 的都处理过了」,如果你想把这类死链单独拎出来统计,改一下那个 if 分支就行。
失败列表记得看一眼。 最后打印出来的 failed 列表通常是网络抖动或偶发限流,隔一会儿重跑一次脚本,已 Star 过的会秒过(重复 PUT 没有副作用),不用怕跑两遍。
更好的做法:别等封号才想起备份
这次是有惊无险地搬完了,但回头想想还是后怕——如果那个账号是被彻底删除而不是 flag,公开 API 也救不回来。
Star 列表说到底是自己的知识资产,与其指望平台,不如定期把它变成一份攥在自己手里的文件。几个思路:
- 导出成 Markdown 或 CSV:社区里现成的导出脚本不少,导出来的表格能直接扔进 Obsidian、Notion 这类笔记工具,平时翻起来比 GitHub 自带的 Star 页还好用
- GitHub Action 定期跑:写个定时任务每周自动导出一次,提交到私有仓库,从此有一份带版本历史的收藏记录,新 Star 了什么、取关了什么,diff 一目了然
- 顺手喂给 AI:导出的列表丢给大模型,让它帮你归类整理,「我到底收藏了哪些和测试相关的库」这种问题一问就有答案
说到底,这次迁移脚本解决的是「账号没了怎么抢救」,而定期备份解决的是「根本不需要抢救」。前者是应急,后者才是习惯。
希望你的账号永远平安,但备份这件事,今天就可以开始。