

前言#
最近放假闲着没事干,抽了点时间完善了下友链方面的检测,现在可以更清楚地看到朋友们的 站点状态 了!不过还有点不足, 只检测了状态,并没有做到通知方面的功能,实现方式也比较潦草,后续会不断完善滴!
流程#
简单画了个运行的流程图😋
引入#
check-links.ts文件#
创建 scripts/check-links.ts 文件并写入以下内容:
import fs from 'node:fs/promises'
import path from 'node:path'
import pLimit from 'p-limit'
import links from '../public/links.json' with { type: 'json' }
const DATA_PATH = path.resolve('public/links.json')
const CHECK_TIMEOUT = 15000
const PLimit_NUM = 5
const MAX_RETRIES = 3
const RETRY_DELAY = 1000
const SKIP_CHECK_NAMES = ['']
interface FriendLink {
name: string
link: string
responseTime?: number
}
interface FriendGroup {
id_name: 'cf-links' | 'inactive-links' | 'special-links'
link_list: FriendLink[]
}
interface FriendLinksConfig {
friends: FriendGroup[]
}
type LinkStatus = 'ok' | 'timeout' | 'error'
interface LinkCheckResult {
name: string
link: string
status?: LinkStatus
httpStatus?: number
responseTime?: number
reason?: string
}
async function fetchLink(url: string) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), CHECK_TIMEOUT)
try {
const start = Date.now()
const res = await fetch(url, {
method: 'HEAD',
signal: controller.signal,
redirect: 'follow',
cache: 'no-store',
headers: { 'User-Agent': 'Mozilla/5.0 FriendLinkChecker/1.0' }
})
const time = Date.now() - start
return {
ok: res.ok,
status: res.status,
time
}
} finally {
clearTimeout(timer)
}
}
const ENV_SKIP_NAMES = process.env.SKIP_CHECK_NAMES?.split(',') || []
const SKIP_NAMES = new Set(
SKIP_CHECK_NAMES.concat(ENV_SKIP_NAMES)
.map((s) => s.trim())
.filter(Boolean)
)
async function checkLink(link: FriendLink): Promise<LinkCheckResult> {
if (SKIP_NAMES.has(link.name)) {
console.log(`[Check-Links] ${link.name} (${link.link}) skipped 🧹`)
return {
name: link.name,
link: link.link,
status: 'ok',
reason: 'skip_check',
responseTime: 0
}
}
let lastError: Error | null = null
for (let i = 0; i < MAX_RETRIES; i++) {
try {
const res = await fetchLink(link.link)
console.log(`[Check-Links] ${link.name} responded in ${res.time}ms ✨`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return {
name: link.name,
link: link.link,
status: 'ok',
httpStatus: res.status,
responseTime: res.time
}
} catch (e: unknown) {
lastError = e instanceof Error ? e : new Error(String(e))
if (i < MAX_RETRIES - 1) {
const delay = RETRY_DELAY * 2 ** i + Math.floor(Math.random() * 100)
console.warn(
`[Check-Links] Retry attempt (${i + 1}/${MAX_RETRIES}) for ${link.name} after ${delay}ms due to: ${lastError.message} 😭`
)
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
}
return {
name: link.name,
link: link.link,
status: lastError?.name === 'AbortError' ? 'timeout' : 'error',
reason: lastError?.message,
responseTime: 0
}
}
async function main() {
console.log('[Check-Links] Start checking friend links... ❤️')
const config = links as FriendLinksConfig
const limit = pLimit(PLimit_NUM)
const tasks = config.friends
.filter((g) => g.id_name === 'cf-links')
.flatMap((group) => group.link_list.map((link) => limit(() => checkLink(link))))
const results = await Promise.allSettled(tasks)
const linkMap = new Map<string, LinkCheckResult>()
for (const r of results) {
if (r.status === 'fulfilled') {
linkMap.set(r.value.link, r.value)
} else {
console.error(`[Check-Links] Unexpected error (${r.reason}) 🤔`)
}
}
for (const group of config.friends) {
for (const link of group.link_list) {
const res = linkMap.get(link.link)
if (res) {
link.responseTime = res.responseTime ?? 0
}
}
}
await fs.writeFile(DATA_PATH, JSON.stringify(config, null, 2))
const failed = Array.from(linkMap.values()).filter((r) => r.status !== 'ok')
if (failed.length > 0) {
console.error(
`[Check-Links] Friend link check failed (${failed.length} inactive links checked) 😡:`
)
for (const f of failed) {
console.error(
`[Check-Links] - ${f.name} (${f.link}) => ${f.status}`,
f.reason ? ` | ${f.reason}` : ''
)
}
process.exit(1)
}
console.log(
`[Check-Links] All links are healthy and responseTime updated (${results.length} links checked) 😋`
)
}
main()
ts相关配置说明
| 配置项 | 默认值 | 说明 |
|---|---|---|
CHECK_TIMEOUT | 15000 | 单个链接检测的超时时间,超过该时间视为请求失败 |
PLimit_NUM | 5 | 并发检测的最大数量,用于限制同时进行的请求数 |
MAX_RETRIES | 3 | 链接检测失败后的最大重试次数 |
RETRY_DELAY | 1000 | 每次重试之间的等待时间 |
SKIP_CHECK_NAMES | [''] | 跳过检测的站点名称列表,名称匹配时将不会进行可用性检测 |
跳过检测的站点名称列表也支持在后续的 GitHub Actions 变量中进行控制(可同时存在)[填写格式:友链名称1,友链名称2]
check-links.yml文件#
创建 .github/workflows/check-links.yml 并写入以下内容:
name: 🔗 Friend Links Check
on:
push:
branches:
- main
paths:
- 'public/links.json'
schedule:
- cron: '0 8 * * *'
workflow_dispatch:
jobs:
check-links-and-update:
name: Link Check & Upload
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install deps
run: npm install p-limit tsx
- name: Run link check
run: npx tsx scripts/check-links.tsyml后续的步骤我采用上传至存储桶的方式而非git提交回仓库,主要是怕污染git的提交信息(而且运行一次脚本,本地仓库就要再拉取一次感觉挺麻烦),你也可以改成你想要的方式,或者把git提交这部分直接写进前面的 check-links.ts 脚本
- name: Upload link.json to bucket
run: |
aws s3api put-object \
--bucket ${{ secrets.BITIFUL_BUCKET_NAME }} \
--key link.json \
--body public/links.json \
--endpoint-url https://${{ secrets.BITIFUL_S3_ENDPOINT }} \
--region ${{ secrets.BITIFUL_REGION }}
env:
AWS_ACCESS_KEY_ID: ${{ secrets.BITIFUL_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.BITIFUL_SECRET_ACCESS_KEY }}yml这里的存储桶我选择的是 缤纷云 ↗ ,相关变量配置在 GitHub Actions 的 secrets 里,变量获取在 控制台页面 ↗,对应如下
| Secrets 名称 | 含义说明 | 示例值 |
|---|---|---|
BITIFUL_BUCKET_NAME | 缤纷云对象存储桶名称(Bucket Name) | my-static-assets |
BITIFUL_S3_ENDPOINT | 缤纷云 S3 兼容 API Endpoint | https://s3.bitiful.net |
BITIFUL_REGION | 存储桶所在区域(Region) | cn-east-1 |
BITIFUL_ACCESS_KEY_ID | 缤纷云访问密钥 ID(Access Key ID) | AKIAxxxxxxxxxxxx |
BITIFUL_SECRET_ACCESS_KEY | 缤纷云访问密钥 Secret(Secret Access Key) | xxxxxxxxxxxxxxxxxxxx |
由于我给博客进行了国内外分流,国外托管到了CF上,加上前面并没有选择git提交的方式,所以检测完友链后需要让其重新构建一遍,以同步进度。
trigger-pages-build:
name: Trigger Cloudflare Pages build
needs: check-links-and-update
runs-on: ubuntu-latest
if: ${{ needs.check-links-and-update.result == 'success' }}
steps:
- name: Update Cloudflare Pages
run: |
curl -X POST \
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CF_ACCOUNT_ID }}/pages/projects/${{ secrets.CF_PROJECT_NAME }}/deployments" \
-H "Authorization: Bearer ${{ secrets.CF_API_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{}'yml所需变量对应如下:
| Secrets 名称 | 含义说明 | 示例值 |
|---|---|---|
CF_ACCOUNT_ID | Cloudflare 账户 ID(Account Identifier) | a1b2c3d4e5f6g7h8i9j0 |
CF_PROJECT_NAME | Cloudflare Pages 项目名称(Project Name) | my-pages-site |
CF_API_TOKEN | Cloudflare API Token(用于 Pages / API 操作) | cf_api_token_xxxxxxxxxxxx |
CF_API_TOKE 只需 page 的编辑权限就可以了
如果你的国内站点也是通过 GitHub Actions 来构建拉取的话,需在相关工作流文件加入对 check-links.yml 完成状态的检测 纯静态站点就这点麻烦😡 或许是我给工作复杂化了🤔?
on:
push:
branches:
- main
paths-ignore:
- public/link.json
workflow_dispatch:
workflow_run:
workflows: ["🔗 Friend Links Check"]
types:
- completedyml完整版 check-links.yml 文件
name: 🔗 Friend Links Check
on:
push:
branches:
- main
paths:
- 'public/links.json'
schedule:
- cron: '0 8 * * *'
workflow_dispatch:
jobs:
check-links-and-update:
name: Link Check & Upload
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install deps
run: npm install p-limit tsx
- name: Run link check
run: npx tsx scripts/check-links.ts
- name: Upload link.json to bucket
run: |
aws s3api put-object \
--bucket ${{ secrets.BITIFUL_BUCKET_NAME }} \
--key link.json \
--body public/links.json \
--endpoint-url https://${{ secrets.BITIFUL_S3_ENDPOINT }} \
--region ${{ secrets.BITIFUL_REGION }}
env:
AWS_ACCESS_KEY_ID: ${{ secrets.BITIFUL_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.BITIFUL_SECRET_ACCESS_KEY }}
trigger-pages-build:
name: Trigger Cloudflare Pages build
needs: check-links-and-update
runs-on: ubuntu-latest
if: ${{ needs.check-links-and-update.result == 'success' }}
steps:
- name: Update Cloudflare Pages
run: |
curl -X POST \
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CF_ACCOUNT_ID }}/pages/projects/${{ secrets.CF_PROJECT_NAME }}/deployments" \
-H "Authorization: Bearer ${{ secrets.CF_API_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{}'yml前端渲染#
由于新的 link.json 文件放在了存储桶里,友链的数据需要改为从远端读取
需修改 src/components/links/FriendList.astro 文件如下:
---
//...
interface friend {
avatar: string
avatar_cache?: AvatarCache
name: string
intro: string
link: string
responseTime?: number
}
//...
const { title, list: friendList, ...props } = Astro.props
let remoteData: Record<string, number> = {}
try {
const res = await fetch('your own bucket url')
const json = await res.json()
for (const group of json.friends) {
for (const f of group.link_list) {
remoteData[f.link] = f.responseTime
}
}
} catch (e) {
console.warn('Failed to fetch remote link.json', e)
}
const enrichedLinkList = friendList.link_list.map((frd) => ({
...frd,
responseTime: remoteData[frd.link] ?? frd.responseTime
}))
const getAvatarSrc = (friend: friend) =>
(config.integ?.links?.cacheAvatar ?? false)
? (friend.avatar_cache?.path ?? friend.avatar)
: friend.avatar
---
{title && <h2 id={friendList.id_name}>{title}</h2>}
<div class='grid gap-3.5 sm:grid-cols-2 sm:gap-4 lg:grid-cols-3' {...props}>
{
friendList.link_list.length > 0 ? (
shuffle(friendList.link_list).map((frd: friend) => (
shuffle(enrichedLinkList).map((frd: friend) => (
<a href={frd.link} target='_blank' class='no-underline'>
<div class='group relative h-full overflow-hidden rounded-2xl border bg-background px-2.5 py-1.5 transition-colors hover:bg-muted sm:px-4 sm:py-2'>
{frd.responseTime !== undefined && (
<div class='absolute right-1.5 top-2 z-1 flex items-center gap-1 rounded-2xl bg-muted/50 px-1.5 py-0 text-[10px] leading-[1rem] text-muted-foreground backdrop-blur-sm transition-colors group-hover:bg-background/50 group-hover:text-foreground'>
<div
class:list={[
'size-1.5 rounded-full',
frd.responseTime < 500
? 'bg-green-400'
: frd.responseTime < 1500
? 'bg-yellow-400'
: 'bg-red-400'
]}
/>
{frd.responseTime}ms
</div>
)}
<div class='relative z-10 flex h-full items-center gap-3'>
//...
</div>
{/* avatar bg */}
//...
</div>
</a>
))
) : (
<p>Nothing here.</p>
)
}
</div>astro使用#
在 GitHub Actions 中找到 🔗 Friend Links Check ,点击 Run workflow 并运行,后续会在每天 固定时间 ↗ 运行,或者 links.json 更新时自动运行❤️