我把友链系统重做成了一套自动巡检 + 自助申请的玩意
友链每次改都要重新部署还是很麻烦的,特别是像我这种懒人。
我后来实在烦了,干脆重做了一套:真源只留一份,申请走 GitHub Issue 模板让机器人核验反链,每天定时巡检谁挂了自动提醒。博客侧只管展示,检测逻辑全在另一个仓库里跑。
检测系统这套(check-flink)是我基于 fqzlr/check-flink 改造的——在原版友链巡检基础上,加了自助申请核验、SSRF 防护和自动同步。
整体思路:两个仓库,一个真源
友链清单两边各存一份:真源放在 check-flink,本地这份只作兜底。
check-flink:友链的「数据库」加检测系统。真源是static/friends.json,部署到friends.yufish.cn;两个 workflow 管「申请核验」和「定时巡检」。my-blog:博客本身(Firefly 主题,Astro)。友链页构建期fetch远程清单,本地friendsConfig.ts只是断网兜底。
为啥拆两个仓库?因为检测系统要读写 Issue、强推 page 分支,权限和操作都跟博客构建没关系。拆开后我博客仓库保持干净,检测系统那边怎么折腾都不影响线上站点。
真源只留一份,边界一开始就定死
友链清单我只留一份真源,在 check-flink 的 static/friends.json。博客本地的 friendsConfig.ts 从一开始就是「断网 / 404 时的保底」,平时根本不参与显示——因为 friends.astro 的 loadFriends() 和客户端 refreshFriends() 都只读远程 friends.yufish.cn/friends.json,那个端点就是 check-flink 部署出来的。
加 / 改 / 删友链,只动 check-flink/static/friends.json 一处,博客侧全自动跟随。
博客这边 loadFriends() 就是「远程优先、本地兜底」:
async function loadFriends(): Promise<RemoteFriend[]> { if (!useRemoteFriends) { console.log("[friends] 已配置 useRemote=false,仅使用本地 friendsConfig.ts"); return getEnabledFriends(); } try { const res = await fetch("https://friends.yufish.cn/friends.json", { signal: AbortSignal.timeout(15000), }); if (res.ok) { const data = (await res.json()) as { friends?: RemoteFriend[] }; const remote = (data.friends || []) .filter((f) => f.enabled !== false) .sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0)); if (remote.length > 0) return remote; console.warn("[friends] friends.json 为空,回退本地配置"); } else { console.warn(`[friends] friends.json HTTP ${res.status},回退本地配置`); } } catch (e) { console.warn("[friends] 读取远程 friends.json 失败,回退本地配置:", e); } return getEnabledFriends();}这个开关的值来自 friendsPageConfig.useRemote,在组件里叫 useRemoteFriends。
自助申请:Issue 模板 + 机器人核验
申请入口做成 GitHub Issue 模板(friend-link.yml)。申请人填网站名称 / 链接 / 描述 / 头像 / 友链页 URL,提交后 apply-friend.yml 自动跑三步:
- 解析:从 Issue body 按中文标签提字段,顺手清洗用户粘贴的 URL(去 markdown 链接语法、补
https://前缀),跟现有清单去重。 - 核验:调
verify-friend.mjs,用 Playwright 打开对方友链页,等 JS 渲染完 + 滚到底触发懒加载,再看页面里有没有指向我站的真实超链接(href/data-url)。顺带查可访问性。 - 写入 / 拒收:通过就写进
static/friends.json(带issue_id),评论并关 Issue;不通过(不可达 / 没反链)就打待更新标签、保持开放,对方修完在 Issue 下回一句就能重验。
友链页上的「自助申请」按钮直接跳这个模板:
<a href="https://github.com/ImYufish/check-flink/issues/new?template=friend-link.yml" target="_blank" rel="noopener noreferrer" class="shrink-0 inline-flex items-center gap-1 rounded-lg bg-(--primary) px-3 py-1.5 text-xs font-medium text-white no-underline opacity-100 hover:opacity-80 transition-opacity"> <svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg> 自助申请</a>每天定时巡检,谁挂了自动提醒
check_links.yml 里状态检测设了每天两次(cron 0 1 * * * / 0 13 * * *);另外有个单独 job 每 6 天截一次友链主页。检测完把结果写回 result.json 强推 page 分支,博客友链页读这个状态来标「异常 / 已失效」。
巡检发现异常(不可达 / 没反链),会去 static/friends.json 查这条友链的 issue_id,在对应 Issue 下评论并打 待更新;等对方修好、下次巡检正常了,自动摘标签并评论「已恢复」。当然友链是自助申请的才有issue_id,自己添加的没有。
另外,截图和检测是两个 job,都会强推 page 分支,所以加了 concurrency 串行,免得并发覆盖把数据搞丢:
concurrency: group: check-links cancel-in-progress: false另外我让 push: [main] 也触发检测——手动在 static/friends.json 加一条,push 后 CI 立刻核验 + 部署,不用干等下一个定时点。
安全闸门:SSRF
检测系统跑在公开仓库,谁都能在公开 Issue 里提交任意 URL。如果验证器直接 fetch 这些地址,等于给人开了个内网探测跳板、内网服务随便打。所以 verify-friend.mjs 在做任何网络访问之前,先过一道闸门:scheme 白名单 + 私有 / 保留地址拦截。
function isPrivateHost(ip) { if (!ip) return true; if (ip.includes(":")) { const v = ip.toLowerCase(); if (v === "::1" || v === "::" || v === "0:0:0:0:0:0:0:0") return true; if (v.startsWith("fe80")) return true; // 链路本地 if (v.startsWith("fc") || v.startsWith("fd")) return true; // 唯一本地 return false; } const p = ip.split(".").map(Number); if (p.length !== 4 || p.some(Number.isNaN)) return true; if (p[0] === 0) return true; if (p[0] === 10) return true; // 10.x if (p[0] === 127) return true; // 回环 if (p[0] === 169 && p[1] === 254) return true; // 链路本地 / 云元数据 if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return true; // 172.16-31.x if (p[0] === 192 && p[1] === 168) return true; // 192.168.x return false;}
// 任何网络访问前先校验if (!isHttpUrl(target) || (await isPrivateHost(getHostname(target)))) { out.reason = "友链地址不合法或指向内网/保留地址,已拒绝访问(安全策略)"; fs.writeFileSync(outputPath, JSON.stringify(out, null, 2)); process.exit(0);}重点:不只拦 IP 字面量,还要 dns.lookup 把域名解析出来再判私有——攻击者可以拿一个公网域名指向内网 IP 绕过纯字符串判断。isPrivateHost 解析失败也保守拒绝。Playwright 启动失败降级到纯 fetch 检测,那也是在 SSRF 校验通过之后才发生的。
本地兜底 + 自动同步,不用我天天手动改
远程是真源,但本地 friendsConfig.ts 也得留一份兜底(断网 / 远程 404 时友链页不至于空白)。问题是这份兜底怎么跟真源保持一致——总不能每次手动改两处。
我在 my-blog 里加了 scripts/sync-friends.mjs,构建 / 构建前从 friends.yufish.cn/friends.json 拉取,只重写 friendsConfig 数组,其它(friendsPageConfig / getEnabledFriends)原样不动。任何失败都只告警跳过、不阻塞构建:
// 用锚点精确定位 friendsConfig 数组,而非脆弱正则:// 起点 = "export const friendsConfig: FriendLink[] = ["// 终点 = "export const getEnabledFriends"const startMarker = "export const friendsConfig: FriendLink[] = [";const endMarker = "export const getEnabledFriends";const si = file.indexOf(startMarker);const ei = file.indexOf(endMarker);if (si === -1 || ei === -1 || ei < si) { /* 跳过 */ }const before = file.slice(0, si);const after = file.slice(ei);await writeFile(SRC, before + replacement + "\n" + after, "utf8");这里还做了字段兜底:远程某条缺 weight / enabled 时补 0 / true,否则会写出不合法的 TS,让 astro build 类型检查挂掉、整站部署失败。
同步脚本我也挂进了 GitHub Actions(sync-friends.yml),每天跑一次,有变才 commit 回仓库,无变就不动。从此本地兜底自动跟随真源,我只用改 check-flink 一处。
数据源开关:远程 / 本地想切就切
有时候我想临时冻结友链(调试、或远程不可达时不想依赖它),加了个开关,放在 friendsPageConfig.useRemote:
export const friendsPageConfig: FriendsPageConfig = { // ... // true(默认)= 使用远程 friends.yufish.cn/friends.json // false = 仅用本地 friendsConfig.ts useRemote: true,};这开关有个坑我记一下:必须两处都拦。构建期 loadFriends() 要拦,客户端 refreshFriends() 也得拦。客户端脚本读不到 TS 常量,所以用 Astro 的 define:vars 把值注进去:
<script is:inline define:vars={{ USE_REMOTE: useRemoteFriends }}> async function refreshFriends() { if (!USE_REMOTE) { console.log("[friends] 已配置仅用本地友链,跳过远程刷新"); return; } // ...拉远程重渲染 }</script>只拦构建期不够——会出现「构建期读本地、页面加载完客户端又偷偷拉远程盖掉」的假象。开关放在 friendsPageConfig 里,而 sync 脚本的锚点是 friendsConfig 数组到 getEnabledFriends,开关在区间之外,自动同步永远碰不到它。
几个需要注意的点
- 真源边界别搞混:友链只改 check-flink 的
static/friends.json。博客本地friendsConfig.ts只是兜底,改它页面不会显示、检测系统也扫不到。 - 锚点定位比正则稳:一开始用
[\s\S]*?\];切friendsConfig数组,结果数组里某条数据自带];导致切片错位、整脚本跳过。改成按export const friendsConfig: FriendLink[] = [和export const getEnabledFriends两个锚点indexOf切片,再没错位过。 - 开关要双处控:
useRemote只拦构建期不够,客户端refreshFriends()也得拦,否则出现「本地构建、远程覆盖」的假象。
现在加一条友链的标准动作
只有一步:在 check-flink 的 static/friends.json 加一条,或者走 Issue 自助申请模板填表提交。剩下的——核验反链、定时巡检、状态提醒、博客侧同步、断网兜底——全自动。
整套跑在 GitHub Actions 上,公共仓库免费,零成本。
上面这些实现都在我的两个仓库 check-flink 和 my-blog 里,感兴趣可以自己翻。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!











