暂未播放
0:00
0:00

我把友链系统重做成了一套自动巡检 + 自助申请的玩意

2318 字
12 分钟
我把友链系统重做成了一套自动巡检 + 自助申请的玩意

友链每次改都要重新部署还是很麻烦的,特别是像我这种懒人。

我后来实在烦了,干脆重做了一套:真源只留一份,申请走 GitHub Issue 模板让机器人核验反链,每天定时巡检谁挂了自动提醒。博客侧只管展示,检测逻辑全在另一个仓库里跑。

检测系统这套(check-flink)是我基于 fqzlr/check-flink 改造的——在原版友链巡检基础上,加了自助申请核验、SSRF 防护和自动同步。

fqzlr
/
check-flink
⚙️检查友链链接是否可连,可以大幅度减少检查工作量。
MIT
Python
ImYufish
/
check-flink
⚙️检查友链链接是否可连,可以大幅度减少检查工作量。
MIT
Python

整体思路:两个仓库,一个真源#

友链清单两边各存一份:真源放在 check-flink,本地这份只作兜底

static/friends.json 真源

博客构建期 fetch

定时巡检回写状态

apply-friend.yml 解析+验证

push main 触发

check-flink 仓库

部署到 friends.yufish.cn

my-blog 友链页

GitHub Issue 申请

check_links.yml 重新部署

static/friends.json 真源

博客构建期 fetch

定时巡检回写状态

apply-friend.yml 解析+验证

push main 触发

check-flink 仓库

部署到 friends.yufish.cn

my-blog 友链页

GitHub Issue 申请

check_links.yml 重新部署

  • 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.astroloadFriends() 和客户端 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 自动跑三步:

  1. 解析:从 Issue body 按中文标签提字段,顺手清洗用户粘贴的 URL(去 markdown 链接语法、补 https:// 前缀),跟现有清单去重。
  2. 核验:调 verify-friend.mjs,用 Playwright 打开对方友链页,等 JS 渲染完 + 滚到底触发懒加载,再看页面里有没有指向我站的真实超链接(href / data-url)。顺带查可访问性。
  3. 写入 / 拒收:通过就写进 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,开关在区间之外,自动同步永远碰不到它。

几个需要注意的点#

  1. 真源边界别搞混:友链只改 check-flink 的 static/friends.json。博客本地 friendsConfig.ts 只是兜底,改它页面不会显示、检测系统也扫不到。
  2. 锚点定位比正则稳:一开始用 [\s\S]*?\];friendsConfig 数组,结果数组里某条数据自带 ]; 导致切片错位、整脚本跳过。改成按 export const friendsConfig: FriendLink[] = [export const getEnabledFriends 两个锚点 indexOf 切片,再没错位过。
  3. 开关要双处控useRemote 只拦构建期不够,客户端 refreshFriends() 也得拦,否则出现「本地构建、远程覆盖」的假象。

现在加一条友链的标准动作#

只有一步:在 check-flink 的 static/friends.json 加一条,或者走 Issue 自助申请模板填表提交。剩下的——核验反链、定时巡检、状态提醒、博客侧同步、断网兜底——全自动。

整套跑在 GitHub Actions 上,公共仓库免费,零成本。

上面这些实现都在我的两个仓库 check-flinkmy-blog 里,感兴趣可以自己翻。

支持与分享

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

打赏
我把友链系统重做成了一套自动巡检 + 自助申请的玩意
https://x1anyu.cn/posts/7/
作者
羡鱼
发布于
2026-08-19
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
羡鱼
临渊空慕水中鱼, 不如携风自渡河.
分类
标签
最新动态
站点统计
文章
14
分类
7
标签
35
总字数
19,969
运行时长
0
最后活动
0 天前
文章目录