#用 GitHub Actions 实现友链申请自动审核
友情链接看起来只是几个 JSON 文件,但一次完整的申请通常包含很多重复工作:收集站点名称和头像,确认对方已经添加本站链接,打开对方页面检查反链,再手动创建文件、提交代码并回复申请者。申请数量少时,这些工作还能接受;一旦博客开始有稳定访问量,审核就会变成一项需要反复切换页面的维护任务。
这篇文章记录 D-blog 如何把这条链路交给 GitHub Issue 和 GitHub Actions:申请者在网页填写资料,网站生成一个预填好的 Issue 草稿;申请者在 GitHub 中确认并正式提交后,Action 先评论收件结果,等待一段时间再检查反链。通过审核后,Action 自动把友链写入 friends/ 目录并推送到 main。
整个过程不需要自建后端,也不需要 SMTP、Resend 或个人 GitHub Token。GitHub Issue 负责保存公开申请记录,GitHub Actions 负责运行校验脚本和提交数据。
#最终效果
申请者看到的是一个普通的网页表单,但后台实际经过以下步骤:
- 申请者先在自己的公开友链页加入 D-blog。
- 在
/friends页面点击“登录 GitHub”,填写站点资料和友链页地址。 - 页面生成一个标题以
[Friend Link]开头的 GitHub Issue 草稿。 - 申请者在 GitHub 页面检查内容,点击
Submit new issue正式提交。 issues.opened事件触发 Action,bot 立即评论“已收到申请”。- 定时 Action 每 5 分钟扫描一次开放的友链申请,只处理创建时间满 10 分钟的 Issue。
- bot 重新校验字段、公开 URL 和文件名,再请求友链页检查 D-blog 反链。
- 检查通过后生成
friends/<filename>.json,以github-actions[bot]身份提交并推送到main。 - bot 在 Issue 中评论结果,并将成功申请关闭为
completed,失败申请关闭为not_planned。
通常情况下,申请会在提交后的 10 至 15 分钟内完成处理。但 GitHub 的 schedule 由平台调度,繁忙时可能延迟,因此这里的时间是预期窗口,不是严格 SLA。
#为什么选择 Issue 和 Actions
友链申请不需要复杂的用户系统。申请内容本来就适合公开讨论,而 GitHub 已经提供了 Issue、评论、通知、权限和审计记录。把这些能力组合起来,可以避免为一个小型博客额外维护 API、数据库和邮件服务。
| 方案 | 优点 | 限制 |
|---|---|---|
| GitHub Issue + Actions | 无需自建后端,申请记录公开,校验和提交自动完成 | 申请者需要 GitHub 账号,定时任务存在调度延迟 |
| 普通网页表单 | 使用门槛低,界面完全可控 | 仍需要后端、存储和通知机制 |
| Pull Request | 变更可审查,适合严格维护分支 | 申请者操作复杂,审核流程更重 |
| 邮件申请 | 用户熟悉,沟通直接 | 需要处理垃圾邮件、附件解析、回复和人工落库 |
| 第三方表单服务 | 上线快,常带有管理后台 | 引入外部依赖,自动写回仓库仍需额外授权 |
这个方案并不意味着所有友链都应该自动通过。自动化的重点是把重复、明确、可验证的步骤交给脚本;对于需要人工判断的特殊情况,仍然可以在 Issue 中人工处理或修改 workflow。
#工作原理
D-blog 的友链源数据保存在 friends/*.json 中,构建阶段由 scripts/generate-site-data.mjs 汇总成前端读取的 generated/friends.json。因此 bot 不需要调用站点接口,只要成功写入一个合法的 JSON 文件,下一次部署就能展示新友链。
正在生成图表
正在加载 Mermaid 并适配当前主题。
这里有一个重要的职责边界:浏览器不直接提交申请,也不持有仓库写权限。网页只负责校验输入并打开 GitHub 的预填 Issue URL;真正拥有写仓库权限的只有 GitHub Actions 中的 GITHUB_TOKEN。
#两阶段审核为什么更合适
如果在 issues.opened 事件中立刻抓取友链页,申请者可能刚提交表单,还没来得及完成部署或 CDN 缓存刷新,审核就会得到错误结果。因此当前 workflow 把处理拆成两个阶段。
第一阶段监听 Issue 打开事件,只发布一条确认评论:
第二阶段使用定时任务扫描开放 Issue:
脚本本身又设置了 10 分钟门槛:
定时任务每 5 分钟触发一次,因此理论上第一次符合条件的扫描位于 10 至 15 分钟之间。将等待时间放在脚本里,而不是只依赖 cron 的精确触发,可以在手动运行 workflow_dispatch 时保持同样的审核规则。
#第一步:在前端生成标准 Issue
友链页面的表单位于 src/pages/Friends.tsx,字段定义和校验集中在 src/pages/friends/friendLinkApplication.ts。当前申请需要以下信息:
Site Name:站点名称。Short Description:站点简介。Avatar URL:头像地址。Site URL:站点主页。Friend Page URL:包含 D-blog 反链的友链页。Your Name / Contact:称呼或联系方式。Filename:写入friends/目录的文件名。
页面还要求勾选“我已经先在自己的博客友链页加入 D-blog”。这个复选框不能代替服务端检测,但能在提交前提醒申请者完成前置条件。
表单通过固定的英文标签生成正文:
标题使用统一前缀:
固定字段的好处是 bot 不需要猜测自然语言。网页上的“生成 GitHub Issue 草稿”只会打开 GitHub 的 issues/new URL,并不会创建 Issue。申请者必须在 GitHub 中确认预填内容,再点击 Submit new issue,提交后的 issues.opened 才会触发审核。
#第二步:配置 GitHub Actions
D-blog 使用 .github/workflows/friend-link-bot.yml,同时支持自动事件、定时扫描和手动运行:
三种触发方式的职责不同:
issues.opened:只处理刚提交的友链 Issue,并发布初始确认评论。schedule:扫描已经等待足够时间的开放申请。workflow_dispatch:管理员需要立即重试或排查时手动运行审核。
工作流只处理标题以 [Friend Link] 开头的 Issue,普通 Issue 不会被 bot 改动:
定时任务会使用完整历史 checkout,随后执行同一个脚本的 review 模式:
contents: write 用于提交和推送友链 JSON,issues: write 用于创建评论、更新 Issue 状态。除了仓库设置中的 workflow 写权限,不需要额外创建 Token Secret,也不需要邮箱服务配置。
#第三步:服务端重新解析申请
前端校验只能改善用户体验,不能作为安全边界。任何人都可以手动创建 Issue,因此 bot 会重新解析固定字段:
验证还会检查文件名格式、站点地址、友链页地址和头像地址。文件名只允许英文字母、数字、短横线和下划线,可选 .json 后缀,并且不能包含路径:
如果 Issue 内容不是页面生成的格式,脚本会返回“Issue 内容不完整,请使用本站生成的申请草稿”,随后评论原因并关闭 Issue。这样可以避免手工拼接的异常内容进入构建流程。
#第四步:限制公开 URL 和 SSRF 风险
bot 需要请求申请者提供的 URL,这是整个流程中最需要谨慎的部分。一个没有限制的抓取器可能被利用来访问 runner 所在网络的内部服务,因此脚本在发起请求前会验证目标地址。
当前规则包括:
- 只允许
http:和https:。 - 禁止 URL 中包含用户名或密码。
- 拒绝
localhost及其子域名。 - 使用 DNS 解析所有地址,拒绝回环、私有、链路本地和保留地址。
- 每次重定向都重新执行公开地址检查,最多跟随 3 次。
- 单次请求超时 15 秒,响应体最多读取 2 MiB。
核心判断从协议开始:
DNS 检查不能只看域名字符串。攻击者可以使用解析到内网的域名,或者利用重定向把第一次看似公开的地址转到内网地址,所以每一次跳转都必须重新解析并检查。
这些限制不是为了判断站点“是否好看”,而是为了确保 GitHub-hosted runner 只请求申请者明确提交的公开页面。
#第五步:检查静态 HTML 中的反链
脚本只请求 Friend Page URL,不会遍历申请者整个站点,也不会执行页面中的 JavaScript。抓取响应后,它会把常见的 HTML 转义、反斜杠和尾部斜杠形式归一化,再查找 D-blog 的规范地址:
因此,以下情况可能审核失败:
- 友链页需要登录或访问被防火墙拦截。
- 反链只在浏览器运行 JavaScript 后动态插入。
- 返回的是图片、脚本或其他非 HTML 内容。
- 页面中使用了与本站不同的域名或错误的协议。
- CDN 缓存仍然返回没有反链的旧版本。
最可靠的做法是在正式提交前查看友链页源代码,确认其中已经出现:
#第六步:去重并生成友链文件
通过反链检查后,bot 还会先检查文件名是否已被占用。站点 URL 或站点名称已经存在时,脚本会按幂等方式处理,不会重复写入同一条友链。
最终写入的文件只保存站点展示所需的四个字段:
联系方式、友链页地址和审核确认状态只用于申请过程,不会写入公开的 friends/*.json。这样既能让数据结构保持简单,也不会把申请者联系方式长期暴露在站点数据中。
#第七步:自动提交并关闭 Issue
文件创建后,脚本执行 Git 操作:
成功后 bot 会评论短 commit SHA,并以 completed 关闭 Issue。失败则评论具体原因,并以 not_planned 关闭。评论中使用隐藏 marker,例如:
下次扫描时如果发现 accepted 或 rejected marker,就会跳过这个 Issue。这个幂等保护很重要,因为定时任务可能重复触发,网络重试也可能让同一个任务再次运行。
直接推送 main 与 D-blog 目前的静态内容管理方式一致。如果仓库对 main 启用了不允许 Actions 直接推送的分支保护规则,需要把实现改成自动创建 Pull Request,或者为 GitHub Actions 配置允许的推送路径。
#第八步:前端申请体验
Friends.tsx 不只是几个输入框,还负责把审核规则提前告诉申请者:
- 展示 D-blog 的名称、描述、主页地址和头像,方便复制到自己的友链页。
- 说明先加反链,再登录 GitHub,最后提交 Issue。
- 为站点地址、头像地址、友链页地址和文件名提供即时校验。
- 提供独立的“登录 GitHub”入口。
- 生成草稿后,用弹窗区分“登录 GitHub”和“前往 GitHub 提交 Issue”。
- 说明 Action 会检查静态 HTML,GitHub 邮件通知取决于用户自己的设置。
页面不会尝试判断 GitHub 是否已经登录。跨域页面无法可靠读取 GitHub 登录状态,最终是否要求登录由 GitHub 自己决定。让登录按钮只负责打开 GitHub 官方页面,反而能避免伪造一个不准确的登录状态。
#GitHub 仓库需要哪些配置
代码合并到默认分支后,需要在仓库中确认以下设置:
- 在
Settings->Actions->General中确认 Actions 已启用。 - 在
Workflow permissions中选择 Read and write permissions。 - 确认默认分支为
main,因为 bot 使用git push origin HEAD:main。 - 确认
.github/workflows/friend-link-bot.yml已经位于默认分支。 - 如果
main有分支保护,确认规则允许 GitHub Actions 推送,或改用自动 PR 方案。
当前实现只使用 GitHub 自动提供的 secrets.GITHUB_TOKEN:
不需要配置个人访问令牌、SMTP、Resend、SendGrid 或其他邮件 Secret。GitHub 评论是否通过邮件送达,由申请者自己的 Notifications 设置决定。
#如何验证整条链路
建议先用测试站点或可随时修改的友链页创建一条测试申请,不要直接用真实站点测试失败场景。验证顺序如下:
- 在友链页加入 D-blog 的规范链接。
- 填写表单并生成 Issue 草稿。
- 在 GitHub 检查标题以
[Friend Link]开头,正文包含所有英文固定字段。 - 正式提交 Issue,确认几分钟内出现初始确认评论。
- 等待 Issue 创建时间超过 10 分钟,或在满足门槛后手动运行
Friend Link Bot。 - 查看 Actions 日志,确认 bot 请求的友链页、校验结果和 Git 操作没有报错。
- 审核通过时检查新建的
friends/<filename>.json、自动 commit、成功评论和 Issue 状态。 - 修改测试页面,移除反链后重新创建测试 Issue,确认失败原因会被评论并且 Issue 被关闭。
Actions 的定时任务并不保证精确时间。如果 Issue 已经超过 10 分钟但没有立即处理,先检查 workflow 是否在默认分支、Actions 是否启用,再从 Actions 页面手动运行一次 workflow_dispatch。
#常见失败与排查
| 现象 | 原因与处理方式 |
|---|---|
| 没有初始评论 | Issue 可能没有以 [Friend Link] 开头,或 Actions 没有 Issue 写权限。 |
| Issue 内容不完整 | 使用了手工编辑的正文,重新从友链页面生成标准草稿。 |
| 文件名不符合规则 | 只使用英文字母、数字、短横线和下划线,不要填写目录路径。 |
| URL 被拒绝 | 确认站点、头像和友链页都是公开 HTTP(S) 地址,不是 localhost、内网地址或需要认证的地址。 |
| 友链页无法访问 | 检查 DNS、证书、防火墙、状态码和 15 秒内是否能返回页面。 |
| 找不到反链 | 查看服务器返回的页面源代码,确认链接不是仅由 JavaScript 动态生成。 |
| 文件名已占用 | 换一个尚未存在于 friends/ 目录的文件名。 |
| 已经存在友链 | 脚本按规范化 URL 或站点名称识别重复申请,会直接记录为已存在。 |
| 文件写入成功但 push 失败 | 检查 contents: write、仓库的 Actions 写权限、默认分支和分支保护规则。 |
| 没有收到邮件 | 先确认 Issue 已出现评论,再检查个人 GitHub 通知渠道;评论创建和邮件投递是两件事。 |
自动检查失败时,bot 评论还会提供人工审核入口。部分框架只在浏览器执行 JavaScript 后渲染友链,静态 HTML 抓取无法完整反映页面最终状态;申请者可以点击评论中的邮件链接,将原 Issue 内容发送到 i@PLDDUCK.com。链接会预填邮件主题和正文,评论下方也会保留可展开复制的原 Issue 内容。
#安全与维护建议
- 只使用 GitHub 自动提供的
GITHUB_TOKEN,不要把个人 Token 放进前端或仓库文件。 - 保留
contents: write和issues: write这两个必要权限,不要无理由扩大权限范围。 - 不要删除 URL 的 DNS、私网和重定向检查。友链地址来自外部用户输入,必须把它当作不可信数据。
- 保留请求超时和响应体大小限制,避免单个申请长时间占用 runner。
- 保留 Issue marker 和文件名/站点去重逻辑,确保重复扫描不会重复提交。
- 当前脚本最多扫描 100 个开放 Issue。如果申请量明显增长,应改为分页扫描或使用 label/状态字段缩小范围。
- 每 5 分钟运行一次能缩短等待,但私有仓库会消耗更多 Actions runner 时间;公开仓库通常更适合保持这个频率,私有仓库可以考虑改为每 15 分钟一次。
- 如果主分支必须经过审查,不要强行绕过保护规则,改成 bot 创建 Pull Request 并由维护者合并。
#总结
这套友链自动审核流程可以概括为:浏览器负责收集资料并生成 Issue 草稿,GitHub 负责保存申请记录和通知,GitHub Actions 负责在服务端重新校验公开 URL、检查静态反链、生成友链 JSON,并把结果提交回仓库。
它没有消除所有不确定性:动态渲染的友链页可能无法被发现,GitHub 定时任务也不承诺严格的执行时间。但对于内容和源码都托管在 GitHub 的静态博客,这条链路已经把最重复的审核工作变成了可追踪、可重试、可审计的自动化流程。
作者 跑路的duck
帮助改进本文


评论