GitHub 有个老约定:建一个和账号同名的仓库(我的就是 v2yy/v2yy),它的 README.md 会直接渲染在个人主页顶部。我想把个人页做成一个长期维护的门面:手写一段静态介绍(名字、博客链接、几行 bio、stats 徽章),下面挂一个"最新文章"区块,从博客 RSS 自动抓,再由 GitHub Actions 定时重写。整套思路其实就一句话:手写静态区 + 一对标记包住的自动生成区——
## 最近动态
<!-- ---start--- 以下由 Actions 自动生成,勿手改 -->
(RSS 抓来的列表)
<!-- ---end--- -->
更新脚本 main.py 我刻意只用标准库(urllib + xml.etree 解析 RSS、urllib 调 GitHub REST),这样 Actions 里连 pip install 都不用;替换区块用 re.sub(r"---start---(.|\n)*---end---", lambda m: ...),替换写成 lambda 形式,防止抓回来的内容里带反斜杠把替换串搅乱。每一个 fetch 都包 try/except、失败降级成一行占位文本——一个死掉的 feed 绝不能让整个 job 报错或把整块内容清空。公开 RSS 和匿名 repos API 都不需要 secret。
生成区里除了 RSS 文章列表,还有几个精选仓库卡片。筛选逻辑我一开始是直接拿 repos API 的前 N 个,很快发现不对——fork 和同名仓库混进来充数。定下的规矩是三条硬过滤加一条排序:not fork、仓库名不等于用户名(排除个人页仓库自己,不然自我介绍里出现 v2yy 套 v2yy 的套娃卡片)、再按 (stars, pushed_at) 降序取前几个。API 是匿名调的,公开仓库够用,不用配 token。
听起来很朴素,但从徽章到自托管到 Actions 首推,我前后踩了六七个坑,每个都实打实烧过排查时间。这篇把它们按"现象 → 排查 → 解法 → 复盘"记下来。
stats 徽章裂图:官方 demo 站早就凉了
现象:个人页挂的 star / commits 徽章,某天开始全变成裂图图标:

排查:直接探徽章 URL 的 HTTP 状态,而不是盯着渲染结果猜:
curl -s -o /dev/null -w "%{http_code} %{content_type}\n" \
"https://github-readme-stats.vercel.app/api?username=v2yy"
# 503 text/html —— 官方 demo 实例过载,基本处于弃养状态
换成网上教程常见的那个 …-git-masterrstaa-… 备用域名,返回 410 Gone,彻底没了。绝大多数 README 教程抄的都是这两个地址,所以裂图几乎是必然。
解法:先用还能活的社区实例顶上(zhaoolee 的个人页用的就是它):

但这只是过渡——公共实例随时限流或缓存击穿,一崩就是全站读者陪你一起裂图。持久解法是下一节的 VPS 自托管。
复盘:任何要写进 README 的第三方 URL,先 curl -o /dev/null -w "%{http_code} %{content_type}" 探活再接;对上游 readme-stats 这类开源库,遇到疑似 bug 先查它的 issues 确认是不是已知问题并留证据,要改就先 fork 再优化,别在公共实例上死等别人修。
徽章裂图的另一个嫌疑人:缓存
有一次明明实例活着、徽章还是裂。排查顺序应该是先破缓存再判故障——徽章 SVG 会被 README 渲染链路缓存,你看到的是几小时前的旧图。GitHub 对 README 里外链图片的代理缓存和 CDN 层层叠加,浏览器强刷都刷不掉,唯一的快速鉴别手段是让 URL 变成一个它从没见过的参数:
# 给同一张卡加个随机 query 参数强制回源
curl -s -o /dev/null -w "%{http_code} %{content_type}\n" \
"http://127.0.0.1:8093/?username=v2yy&t=$RANDOM"
加了随机参数就好了,那是缓存问题,等缓存过期或去 CDN 手动清一下即可;还是裂,才是实例挂了。自托管之后图片统一走 Cloudflare 缓存,这个坑基本不会再咬人,但排查时的"先破缓存"姿势值得留着——它把"实例故障"这种要熬夜的事,和"再等十分钟"这种不用管的事干净地切开。
自托管 github-readme-stats:装到跑起来的四层坑
仓库是 Rickstaa/github-readme-stats,我不用 Docker,直接在 VPS 上以 node 源码跑。每层都真实烧掉一轮调试。
第一层:npm install 被 husky 打断。 按 README 装生产依赖,装到一半报错退出:
npm install --omit=dev
# husky: .git can't be found ... prepare: husky install
原因:仓库的 prepare 钩子调 husky,而 husky 是 devDep,--omit=dev 之后它已不在,钩子却照样执行并让整场 install 失败。加参数跳过脚本,再把漏掉的生产依赖单独补上——express 和 dotenv 在这个仓库里竟然被归到 devDependencies,prod-only 安装后 express.js 起不来:
npm install --omit=dev --ignore-scripts
npm i express dotenv --ignore-scripts
第二层:入口选错,进程"活着"但不监听。 api/index.js 是 Vercel serverless handler,它导出函数、从不 listen。要跑的是 express.js。而且它读的是小写的 process.env.port——你按常规设 PORT=8093 会被静默忽略。路由也没有 /api 前缀(nginx 反代时按原路径透传):/ 出 stats 卡,/top-langs 出语言条,/pin?repo=owner/name 出仓库卡。
第三层:200 的裂图——PAT 是强制的。 服务 active、curl 返回 200、content_type 是 image/svg+xml,浏览器里却还是裂图/错误图:
curl -s "http://127.0.0.1:8093/?username=v2yy" | grep -o "Something went wrong"
# Something went wrong ← 状态码和响应头全在说谎
根因在 src/common/retryer.js:它数的是环境变量里的 PAT_1..PAT_N,一个都没有就直接渲染 "Something went wrong" 错误 SVG(NO_TOKENS)。而且数据中心 IP 走匿名 GraphQL 本身就是 403,没有绕过的余地。解法:建一个不勾选任何 scope 的 classic token 就够了(只读公开数据),写进 /opt/<svc>/.env 的 PAT_1=...,重启服务。
复盘:验证图像服务,grep SVG 正文里的错误文案,别信 200;状态码和 content-type 只证明"服务在答话",不证明"答的是图"。凭据这边更要较真:token 不要从本机拷贝、也不要经过聊天记录或任何会留痕的管道——我写了个服务器端脚本,让使用者自己在终端 read -rs 静默输入 token,脚本落盘 /opt/<svc>/.env(umask 077,目录权限收紧)、重启服务、然后自己拉一张卡自检,全程不回显不留副本。另外自托管实例一定要加限流:URL 是公开的,谁都能拿你的实例当枪去打 GitHub API,烧的是你自己的 PAT 配额;图片统一走 CF 缓存兜住正常流量,源站只承接缓存 miss。
systemd 与 nginx/certbot:两个小坑各吃掉半天
systemd unit 里 ExecStart 图省事写了个猜测的路径,服务直接 crash loop:
[Service]
ExecStart=/usr/local/bin/node /opt/readme-stats/express.js
Environment=port=8093
Restart=always
# status=203/EXEC ← ExecStart 指向了一个不存在的文件
status=203/EXEC 十有八九是 ExecStart 路径不对,node 在别处。用 $(which node) 的结果填绝对路径,systemctl enable --now 收尾。
HTTPS 这边:域名在 Cloudflare,A 记录开着橙色云代理也没关系,certbot 的 HTTP-01 验证能穿透 CF 代理签发证书。真正阴我一手的是签发之后改 nginx:certbot 会把 80 端口 block 和 443 端口 block 拆成两个 server 块,我用脚本给限流配置做 s.replace('location / {', ..., 1),count=1 只插进了第一个块——443 那个块(也就是真正对外服务的那个)根本没打上配置。复盘:改 nginx 配置片段时,断言插入次数和你打算覆盖的 server 块数量一致,改完 nginx -t 之外还要实际打一次外网 URL 确认行为。
Actions 自动更新:首次推送的三连坑
生成区的工作流本体长这样(cron 15 1,9 * * *,北京时间每天 09:15 / 17:15 各跑一次,另支持 push 和手动触发):
name: update profile
on:
schedule:
- cron: '15 1,9 * * *'
push:
workflow_dispatch:
permissions:
contents: write
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: python3 main.py
- name: commit if changed
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
if ! git diff --quiet -- README.md; then
git add README.md
git commit -m "chore: auto-update profile block"
git push
fi
只有 README.md 真的变了才提交,commit 身份固定为 bot。坑全在第一次推送:
- push 失败了但没人知道。 我在本地
git init之前就先gh repo create,结果仓库建了、remote 却没配好;更糟的是我把 push 输出管道进了tail -1,管道吃掉了退出码,脚本一路"成功"跑完。判断有没有真推上去,别信终端回显,查服务器侧:
bash
gh api "repos/v2yy/v2yy/git/trees/HEAD?recursive=1"
# {"message":"Git Repository is empty."} —— 什么都没推上去
Could not read from remote repository。 remote 默认走了 SSH 而这台机器没有 GitHub 私钥。改走 HTTPS 并让 gh 接管凭据:
bash
git remote set-url origin https://github.com/v2yy/v2yy.git
gh auth setup-git
git push -u origin main
- push 被 reject:
fetch first。 Actions bot 在你本地 pull 和 push 的间隙往同一分支提交了一笔(定时任务生成的 commit)。解法是git pull --rebase origin main再推,永远不要 force-push——bot 的那笔提交是你自动化管线在正常运行的证据,强推等于删日志。
验证要信对的源头
改完 README 在 raw.githubusercontent.com 上看没变化,我差点把已经正确的代码再"修"一遍——那个域名是 CDN,缓存几分钟是常态,"我的修复怎么没生效"在那里读到的多半是旧字节,先走 API 复核再动手。两个可靠的事实来源,各管一层:
# 仓库侧权威内容:走 API 拿 base64 解码
gh api repos/v2yy/v2yy/contents/README.md -q .content | base64 -d
# 个人页侧渲染证据:curl 主页 grep 生成区里的字符串
curl -s https://github.com/v2yy | grep -c "最近动态"
前者证明"仓库里是什么",后者证明"个人页真的渲染了它"——两件事都要查,只查其一都会漏:Actions 提交成功但内容没变,和 README 正确但主页还在渲染旧快照,是两种完全不同的故障。raw 域名的读数只配当参考,永远不要拿它当"验证通过"的依据。
收尾
现在个人页的状态:静态区手写,动态区由 Actions 每天两次从博客 RSS 重写,feed 挂了也只是占位一行;stats 徽章全部指向自托管实例,经 nginx + CF 缓存对外服务,token 只存在服务器 .env 里。整套管线里最贵的一课不是任何具体报错,而是两条通用纪律:图像接口的 200 不代表图是好的,要 grep 正文;管道的退出码不代表命令的成败,要向服务端要证据。
评论