
HY
折腾进行时
Hello, I'm HY.








今天翻 blog.amamo.top 的友链页,看到页面上有一行小字:「自动友链 点击上方进入申请表,按提示填写站点信息并提交。」
点进去是个 GitHub Issue 表单。

我这边友链页的申请方式还是老一套:复制模板,发评论区或者发我邮箱,等人手动加。
差别挺明显的。他那边提交完就等结果,我这边至少得等我打开邮箱。
顺手把他的仓库扒了一遍。他的站也是 fork 的 Firefly,仓库公开,qwc-ch/Firefly。
实现全在 .github/ 下面,一共三个文件:
| 文件 | 干什么的 |
|---|---|
.github/ISSUE_TEMPLATE/friend-request.yml | 申请表本体 |
.github/scripts/process-friend-request.cjs | 校验 + 改配置 + 提交 |
.github/workflows/friend-link-checker.yml | 触发(文件名起得有点误导,内容其实是「自动更新友链」) |
流程是这样的:申请人在 Issue 表单里填站点名、主站链接、友链页地址;workflow 监听到 issues: [opened] 就启动,跑脚本去访问那个友链页,确认页面上确实有他的站,然后改 friendsConfig.ts 并提交。
还有个细节我觉得挺聪明:触发条件里还带了 issue_comment: [created],而且只有 Issue 作者本人的回复才会重新跑一遍。申请人第一次没挂链接被拒了,改完自己回一句,机器人重新验一次,不用关掉重开。
入口也不是什么站内表单,就是一条链接:
1https://github.com/qwc-ch/Firefly/issues/new?template=friend-request.yml搜「自动友链」的时候翻到一篇写得很细的文章,讲的是把友链数据抽成 links.json、把站切成 SSR、再加一个 API 接口负责校验和写文件。看完我一度以为得动构建模式。
扒完他的仓库才发现,他压根没上 SSR。站还是纯静态的,部署在 GitHub Pages(deploy.yml 里就是 on: push: branches: [master])。
关键在于:校验和写入不发生在访客访问的时候,而是发生在 GitHub Actions 的容器里。
所以静态站完全够用。那篇文章里的 SSR 是作者自己站的部署方式,不是这件事的必要条件。
有三处我这边过不去。
他脚本里的写入逻辑是:先把现有的 friendsConfig 全部解析成对象,再全部重新渲染回去。
1const friendBlocks = [...listMatch[1].matchAll(/\{([\s\S]*?)\n\t\},?/g)].map((item) => item[1]);2return friendBlocks.map((block) => ({3 title: extractString(block, 'title'),4 imgurl: extractString(block, 'imgurl'),5 desc: extractString(block, 'desc'),6 siteurl: extractString(block, 'siteurl'),7 tags: extractTags(block),8 weight: extractNumber(block, 'weight', 10),9 enabled: extractBoolean(block, 'enabled', true),10 issue_id: extractNumber(block, 'issue_id', 0),11}));问题出在字段白名单上。他的 FriendLink 类型里没有 rss 和 homepage,而他这个站也确实没用这两个字段,所以他跑着没事。
我这边的 friendsConfig.ts 正在用:rss 有 2 条、homepage 有 1 条,另外还有一堆分组的注释。按他这个流程走一遍,那些字段和注释全会被吃掉——因为整个数组会被”重新渲染”成他认识的那 8 个字段。
所以我把写入改成只往数组头部插一条,其他位置一个字节都不碰:
1const anchor = 'export const friendsConfig: FriendLink[] = [';2const at = raw.indexOf(anchor);3const updated =4 raw.slice(0, at + anchor.length) + eol + entry + raw.slice(at + anchor.length);顺带加了按域名去重,否则同一个人重复申请会插进去两条。
他的 workflow 里有这两步:
1- run: pnpm install --frozen-lockfile2- run: pnpm exec playwright install --with-deps chromium因为他的校验是用 Playwright 真的开一个浏览器去渲染友链页,再读渲染后的 DOM。
对靠 JS 出内容的站来说这确实更准,代价是每跑一次都要装一遍依赖外加一个 Chromium,几分钟起步。
我这边的校验只需要判断「页面 HTML 里有没有那个域名」,Node 内置的 fetch 就够了,零第三方依赖,跑完二十来秒:
1- name: Setup Node.js2 uses: actions/setup-node@v43 with:4 node-version: "22"5
6- name: Process friend request7 uses: actions/github-script@v78 with:9 github-token: ${{ secrets.GITHUB_TOKEN }}10 script: |11 const path = require('node:path');12 const handler = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/auto-friend-link.cjs'));13 await handler({ github, context, core });代价也说清楚:纯前端渲染的友链页我抓不到,页面内容靠 JS 出来的站会被误判成「没挂链接」,这类只能走人工。
他博客里写的是「主站域名与友链页域名一致(避免广告跳转)」,但脚本里其实只做了两件事:打开友链页、看页面里有没有出现他的域名或者站名。
站名那条尤其松——拿「年华」两个字去页面里做 includes,随便哪里出现这两个字就过了。
我这边把三项都写实了,全过才写入。
同样是三个新增文件,职责划得很清楚:
| 文件 | 说明 |
|---|---|
.github/ISSUE_TEMPLATE/friend-request.yml | 申请表,7 个字段(多了 RSS 和首页照片两个选填) |
.github/scripts/auto-friend-link.cjs | 校验 + 写入,零依赖 |
.github/workflows/auto-friend-link.yml | 触发 + 建 label |
友链页那边的入口做成这样:

原来的「发评论区 / 发邮件」那条路我没删,压缩成一行小字留在按钮下面。用 GitHub 的门槛确实有,得给人留个后路。
| 检查 | 怎么做的 | 不过会怎样 |
|---|---|---|
| 主站能打开 | fetch 主站,跟随重定向后要求 200 | 拒绝,提示「主站无法访问」 |
| 友链页能打开 | fetch 友链页,用重定向后的最终地址 | 拒绝,附上状态码和错误 |
| 两站同域 | 比 registrable domain,blog.example.com 和 example.com 算同域 | 拒绝,提示域名不一致 |
| 页面挂了本站链接 | 在 HTML 里找 9ll.uk | 拒绝,把需要挂的站点信息列出来 |
另外做了两个防呆。
字段里的换行和引号会压平转义。有人手打 Issue 的时候如果在「网站名称」里塞一段 "]; 加换行,不处理就会把 friendsConfig.ts 撕成语法错误。
还有 label 不存在时不能让整个 job 挂掉。GitHub API 对不存在的 label 直接返回 422,流程会当场炸掉,所以 addLabels 外面包了 try/catch,workflow 里也顺手 gh label create ... || true 先建一遍。
### 网站名称 读不出来#Issue 表单提交之后,正文长这样:
1### 网站名称2
3测试小站4
5### 网站链接6
7https://example.com我一开始按 \n### 切分,每块的第一行当字段名。写完跑单元测试,发现第一个字段永远解析不出来。
原因挺蠢的:正文开头就是 ### 网站名称,它前面没有换行,所以没被切走,留在了第一块里,字段名变成带 ### 前缀的字符串,怎么都匹配不上。
修法是在清洗字段名时补一句:
1String(label).replace(/^#+\s*/, '') // split 后第一块的 label 会带 "### " 前缀还有个更值得记的教训:测试得走生产路径。
我第一版测试是自己在测试里手动拼了个对象塞进去测的,跑得干干净净——因为它压根没碰真正解析 Issue 正文的那段代码。后来把字段整理抽成 buildForm(),测试和主流程共用同一个函数,上面那个 bug 才浮出来。
要复刻的话,需要动的就四处:
.github/ISSUE_TEMPLATE/friend-request.yml —— 申请表,字段按自己的站点信息改.github/scripts/auto-friend-link.cjs —— 校验和写入,改开头 SITE_INFO 里的域名.github/workflows/auto-friend-link.yml —— 触发然后两个仓库设置:
1Settings → General → Features → 勾上 Issues2Settings → Actions → General → Workflow permissions → Read and write第一条必须开——fork 出来的仓库 GitHub 默认把 Issues 关着,不开的话申请链接直接 404,模板文件在不在都一样。
第二条是给机器人 push 配置用的,默认就是 Read and write,但如果之前手动收紧过,这里不放开提交会失败。
前置条件就这些。零依赖、零服务器,整套东西三个文件。
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!
部分内容可能已过时
分享你的想法,与大家交流讨论