友链申请做成自动的:填完 Issue 表单,机器人验完自己上线

2065 字
10 分钟
友链申请做成自动的:填完 Issue 表单,机器人验完自己上线
友链申请做成自动的:填完 Issue 表单,机器人验完自己上线

今天翻 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 作者本人的回复才会重新跑一遍。申请人第一次没挂链接被拒了,改完自己回一句,机器人重新验一次,不用关掉重开。

入口也不是什么站内表单,就是一条链接:

https://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 的容器里。

  • 机器人跑在 GitHub 的机器上,改文件、提交、推送,都是它干的
  • 推送触发平台重新构建,新友链就出现在页面上了

所以静态站完全够用。那篇文章里的 SSR 是作者自己站的部署方式,不是这件事的必要条件。

但他的脚本不能直接抄#

有三处我这边过不去。

一、他会把整个数组重写一遍#

他脚本里的写入逻辑是:先把现有的 friendsConfig 全部解析成对象,再全部重新渲染回去。

const friendBlocks = [...listMatch[1].matchAll(/\{([\s\S]*?)\n\t\},?/g)].map((item) => item[1]);
return friendBlocks.map((block) => ({
title: extractString(block, 'title'),
imgurl: extractString(block, 'imgurl'),
desc: extractString(block, 'desc'),
siteurl: extractString(block, 'siteurl'),
tags: extractTags(block),
weight: extractNumber(block, 'weight', 10),
enabled: extractBoolean(block, 'enabled', true),
issue_id: extractNumber(block, 'issue_id', 0),
}));

问题出在字段白名单上。他的 FriendLink 类型里没有 rss 和 homepage,而他这个站也确实没用这两个字段,所以他跑着没事。

我这边的 friendsConfig.ts 正在用:rss 有 2 条、homepage 有 1 条,另外还有一堆分组的注释。按他这个流程走一遍,那些字段和注释全会被吃掉——因为整个数组会被”重新渲染”成他认识的那 8 个字段。

所以我把写入改成只往数组头部插一条,其他位置一个字节都不碰:

const anchor = 'export const friendsConfig: FriendLink[] = [';
const at = raw.indexOf(anchor);
const updated =
raw.slice(0, at + anchor.length) + eol + entry + raw.slice(at + anchor.length);

顺带加了按域名去重,否则同一个人重复申请会插进去两条。

二、他的方案有点重#

他的 workflow 里有这两步:

- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps chromium

因为他的校验是用 Playwright 真的开一个浏览器去渲染友链页,再读渲染后的 DOM。

对靠 JS 出内容的站来说这确实更准,代价是每跑一次都要装一遍依赖外加一个 Chromium,几分钟起步。

我这边的校验只需要判断「页面 HTML 里有没有那个域名」,Node 内置的 fetch 就够了,零第三方依赖,跑完二十来秒:

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Process friend request
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const path = require('node:path');
const handler = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/auto-friend-link.cjs'));
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 表单提交之后,正文长这样:

### 网站名称
测试小站
### 网站链接
https://example.com

我一开始按 \n### 切分,每块的第一行当字段名。写完跑单元测试,发现第一个字段永远解析不出来。

原因挺蠢的:正文开头就是 ### 网站名称,它前面没有换行,所以没被切走,留在了第一块里,字段名变成带 ### 前缀的字符串,怎么都匹配不上。

修法是在清洗字段名时补一句:

String(label).replace(/^#+\s*/, '') // split 后第一块的 label 会带 "### " 前缀

还有个更值得记的教训:测试得走生产路径。

我第一版测试是自己在测试里手动拼了个对象塞进去测的,跑得干干净净——因为它压根没碰真正解析 Issue 正文的那段代码。后来把字段整理抽成 buildForm(),测试和主流程共用同一个函数,上面那个 bug 才浮出来。

极简流程#

要复刻的话,需要动的就四处:

  1. .github/ISSUE_TEMPLATE/friend-request.yml —— 申请表,字段按自己的站点信息改
  2. .github/scripts/auto-friend-link.cjs —— 校验和写入,改开头 SITE_INFO 里的域名
  3. .github/workflows/auto-friend-link.yml —— 触发
  4. 友链页加一个入口按钮

然后两个仓库设置:

Settings → General → Features → 勾上 Issues
Settings → Actions → General → Workflow permissions → Read and write

第一条必须开——fork 出来的仓库 GitHub 默认把 Issues 关着,不开的话申请链接直接 404,模板文件在不在都一样。

第二条是给机器人 push 配置用的,默认就是 Read and write,但如果之前手动收紧过,这里不放开提交会失败。

前置条件就这些。零依赖、零服务器,整套东西三个文件。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
友链申请做成自动的:填完 Issue 表单,机器人验完自己上线
https://www.9ll.uk/posts/blog/autofriendlink/
作者
HY
发布于
2026-10-05
许可协议
CC BY-NC-SA 4.0
相关文章智能推荐
1
给友链页加了悬停预览:12 张截图离线截好,主题文件一行没动
博客在 blog.amamo.top 的友链页看到鼠标悬停弹首页截图的效果。我这边的约束是不能动主题源文件,所以换了个做法:截图用本机浏览器离线截、转 webp 存进仓库,卡片用一段独立脚本在浏览器里现场拼。12 张封面一共 190 KB,悬停之前一张都不加载。
2
给博客装了个朋友圈:把友链的更新聚成一条时间线
博客羡慕 rainzt.cn 的朋友圈,把它搬进自己的 Firefly:构建时抓取所有友链的 RSS 合流成时间线,快照兜底,GitHub Actions 每天自动刷新。纯静态,不用服务器,只新增文件不碰主题源码。后来又给时间线加了层来源配额,不让聚合站霸榜。
3
给博客加了个 Steam 游戏库:没后端,蹭朋友圈的定时任务喂数据
博客参考站用 Halo 插件在服务端拉 Steam Web API,我这静态站没后端,改成跟朋友圈同一套定时快照,顺带补了没 Key 时的降级路径。
4
给书签导航加悬停预览:复刻友链流程,多踩三个坑
博客书签导航那 64 个书签也加上了悬停预览:鼠标放上去展开一张该站点的首页截图。做法是把友链页那套原样搬过来,但书签和友链不是一个东西,中途多踩了三个坑——同域名多条会互相覆盖、Cloudflare 的 403 页截出来是张「看着正常」的白底黑字图、headless 的默认 UA 直接被拒。最后 56 条拿到了能看的封面。
5
把 Firefly 魔改了五十一版:一份完整的功能清单
博客博客是 fork 的 CuteLeaf/Firefly,从 V1.0 到 V1.49 一共 51 版改动。这篇不再讲故事,直接把改过的功能全部列出来:首页那套贴纸卡片、自己加出来的那批页面、友链与书签的悬停预览和自助申请、全站卡片描边与页脚统计、评论区的折叠与引用、右栏那几个自建组件、一长串性能优化,还有内容与主题分离这类基础设施。每条都附上它到底干什么、关键数字是多少。
随机文章随机推荐

评论区

Profile Image of the Author
HY
Hello, I'm HY.
公告
迷茫.... 为了访问速度,本站将会在11月上旬至中旬迁移到0dm.cn并完成备案
分类
标签
最新动态
站点统计
文章
74
分类
8
标签
192
总字数
161,979
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
Vercel
博客版本
Firefly v6.16.8
文章许可
CC BY-NC-SA 4.0
文章目录