前言

在运营博客的过程中,友情链接是提升网站曝光度和 SEO 的重要方式。但随着链接数量增加,手动检查每个链接是否有效变得耗时耗力。经常会遇到友链失效、网站关闭或对方悄悄移除链接的情况,这些都会影响网站的信誉和 SEO 效果。

为了解决这个问题,设计了一套自动检测友情链接的方案,通过 JavaScript 代码定时检测所有友链状态,并将结果可视化展示。这样可以及时发现无效链接,维护友链的健康状态。

页面展示

反链检测

工程搭建步骤

初始化 Node.js 项目

  1. 创建一个新文件夹 friend-link-checker 作为项目目录
  2. 进入该目录,执行命令:
1
npm init -y

安装依赖库

执行以下命令安装所需的 Node.js 库:

1
npm install axios cheerio

创建自动检测脚本

创建检测脚本文件

创建 ./friend-link-checker.js 文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
const axios = require("axios");
const cheerio = require("cheerio");
const fs = require("fs");

const config = {
friendChecker: {
ignore: ["hexo.io"], // 需要忽略检查的域名
export: true, // 是否导出检测结果
exportPath: "./friend.json", // 检测结果导出路径
linkPage: "https://blog.yik.at/page/link", // 自己博客的友链页面地址
backLink: ["yik.at"], // 自己博客的域名(用于检测反链)
oldLink: ["yep.vin", "daiyu.fun", "yeppioo.vip"], // 自己博客的旧域名
page: [
"links",
"link",
"links.html",
"friendlychain",
"youlian",
"site/link/",
"social/link/",
"friends",
"pages/links",
"pages/link",
"friendLlinks",
"friend-links",
"2bfriends",
].reverse(),
},
};

// 检查页面内容是否包含反链
function checkBackLink(html, backLinks, oldLinks) {
let isBack = false;
let isOld = false;
let detectedOld = null;
// 检查新反链
for (const back of backLinks) {
if (html.includes(back)) {
isBack = true;
break;
}
}
// 检查旧反链
if (!isBack) {
for (const old of oldLinks) {
if (html.includes(old)) {
isOld = true;
detectedOld = old;
break;
}
}
}
return { isBack, isOld, detectedOld };
}

// 动态适配终端宽度的进度条输出
function printProgress(current, total, url, finishedLinks, linkTotal) {
const percent = total === 0 ? 0 : Math.floor((current / total) * 100);
const info = `${current}/${total}(${percent}%) [${finishedLinks}/${linkTotal}]`;
const cyan = "\x1b[36m";
const reset = "\x1b[0m";
const terminalWidth = process.stdout.columns || 80;
const barMax = Math.max(10, terminalWidth - info.length - url.length - 5);
const filledLength = Math.floor((barMax * percent) / 100);
let bar = "";
if (filledLength >= barMax) {
bar = `\x1b[32m${"=".repeat(barMax)}\x1b[0m`;
} else {
bar = `\x1b[32m${"=".repeat(filledLength)}>${" ".repeat(
barMax - filledLength - 1
)}\x1b[0m`;
}
if (
process.stdout.isTTY &&
typeof process.stdout.clearLine === "function" &&
typeof process.stdout.cursorTo === "function"
) {
process.stdout.clearLine();
process.stdout.cursorTo(0);
process.stdout.write(`[${bar}] ${cyan}${url}${reset} ${info}`);
if (current === total) process.stdout.write("\n");
} else {
// 非TTY环境,直接输出一行文本
console.log(`[${bar}] ${url} ${info}`);
}
}

// 并发处理友链检测,每拼接一个页面都实时刷新进度条
async function checkLink(link, config, progress, finishedLinks, linkTotal) {
let checkedUrl = "";
let hasSuccess = false; // 是否有页面访问成功
const pageCount = config.friendChecker.page.length;
let i = 0;
const errorCodes = new Set();
for (; i < pageCount; i++) {
let page = config.friendChecker.page[i];
let pageUrl = link.url;
if (!pageUrl.endsWith("/")) pageUrl += "/";
pageUrl += page;
checkedUrl = pageUrl;
progress(pageUrl, finishedLinks, linkTotal);
try {
const res = await axios.get(pageUrl, {
timeout: 12000,
validateStatus: null,
});
if (res.status >= 200 && res.status < 400) {
hasSuccess = true;
const pageHtml = res.data;
const {
isBack,
isOld: isOldLink,
detectedOld,
} = checkBackLink(
pageHtml,
config.friendChecker.backLink,
config.friendChecker.oldLink
);
if (isBack) {
// 检测到反链,补齐剩余进度
for (let j = i + 1; j < pageCount; j++) {
progress("", finishedLinks, linkTotal);
}
return {
type: "success",
link: { ...link, page: pageUrl },
url: checkedUrl,
};
} else if (isOldLink) {
for (let j = i + 1; j < pageCount; j++) {
progress("", finishedLinks, linkTotal);
}
// 返回检测到的旧域名
return {
type: "old",
link: { ...link, detectedOldDomain: detectedOld },
url: checkedUrl,
};
}
} else {
errorCodes.add(res.status);
}
} catch (e) {
// 网络错误等,继续尝试下一个页面
if (e.response && e.response.status) {
errorCodes.add(e.response.status);
} else if (e.code) {
errorCodes.add(e.code);
} else {
errorCodes.add("UNKNOWN");
}
continue;
}
}
// 补齐未提前return时的进度(正常遍历完)
for (let j = i; j < pageCount; j++) {
progress("", finishedLinks, linkTotal);
}
// 如果所有页面都访问失败(无2xx/3xx),归为fail,否则notFound
if (!hasSuccess) {
return {
type: "fail",
link: { ...link, errorCodes: Array.from(errorCodes) },
url: checkedUrl,
};
} else {
return { type: "notFound", link, url: checkedUrl };
}
}

function main() {
(async () => {
try {
const response = await axios.get(config.friendChecker.linkPage);
const html = response.data;
const $ = cheerio.load(html);
// 解析分组和每组下的友链
const links = [];
// 新的HTML结构解析
$(".flink > h2").each((i, el) => {
const groupName = $(el)
.text()
.replace(/\s+/g, "")
.replace(/\(.*\)/, "")
.trim();
if (groupName === "我的信息") return;

// 新的友链列表结构:.anzhiyu-flink-list
let flinkList = $(el).next(".anzhiyu-flink-list");
if (flinkList.length) {
flinkList.find(".flink-list-item").each((j, item) => {
const a = $(item).find("a.cf-friends-link");
const name = a.find(".flink-item-name").text().trim();
const url = a.attr("href") || a.attr("cf-href"); // 尝试获取 cf-href 属性
const avatar =
a.find("img").attr("src") || a.find("img").attr("cf-src"); // 尝试获取 cf-src 属性

// 排除ignore中的域名
let ignore = false;
for (const ignoreDomain of config.friendChecker.ignore || []) {
if (url && url.includes(ignoreDomain)) {
ignore = true;
break;
}
}
if (!ignore) {
links.push({ name, url, avatar });
}
});
}
});

// 结果分类
const result = {
success: [], // 正确反链
old: [], // 旧版反链
fail: [], // 全部访问失败,含错误码
notFound: [], // 有页面访问成功但没有反链
updateTime: "", // 更新时间
};

// 统计总拼接页面数
const total = links.length * config.friendChecker.page.length;
let current = 0;
let lastUrl = "";
// 实时并发控制
const concurrency = 100;
let index = 0;
const linkTotal = links.length;
let finishedLinks = 0;

// 动态任务池实现
function next() {
if (index >= links.length) return null;
const link = links[index++];
return { link, linkIndex: index, linkTotal };
}
async function runOne() {
const nextLink = next();
if (!nextLink) return;
const { link, linkIndex, linkTotal } = nextLink;
const r = await checkLink(
link,
config,
(url, finished, totalLinks) => {
current++;
lastUrl = url;
printProgress(current, total, lastUrl, finishedLinks, linkTotal);
},
finishedLinks,
linkTotal
);
result[r.type].push(r.link);
finishedLinks++;
// 递归补充新任务
await runOne();
}
printProgress(0, total, "", 0, linkTotal); // 一开始就输出进度条
// 启动动态任务池
const pool = [];
for (let i = 0; i < concurrency; i++) {
pool.push(runOne());
}
await Promise.all(pool);
process.stdout.write("\n");
// 检测完成后设置更新时间
result.updateTime = new Date().toISOString();
if (config.friendChecker.export && config.friendChecker.exportPath) {
fs.writeFileSync(
config.friendChecker.exportPath,
JSON.stringify(result, null, 2),
"utf-8"
);
console.log(`检测结果已导出到 ${config.friendChecker.exportPath}`);
} else {
console.log(JSON.stringify(result, null, 2));
}
} catch (error) {
console.error("获取或解析页面失败:", error);
}
})();
}

main();

主要配置项说明:

  • ignore: 添加不需要检查的友链域名,这些域名不会被检测
  • linkPage: 输入自己博客的友链页面地址,脚本会从这个页面获取所有友链
  • backLink: 输入自己博客的域名,脚本会检测对方是否在页面中添加了这个域名作为反链
  • oldLink: 如果博客曾经更换过域名,输入旧域名,脚本会同时检测新旧域名的反链
  • exportPath: 检测结果将保存到这个文件中

配置 GitHub Action 实现自动运行

创建 GitHub Action 配置文件

创建 ./.github/workflows/friend-link-check.yml 文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
name: Update Data

on:
schedule:
- cron: "0 */12 * * *"
workflow_dispatch:
repository_dispatch:
types: [external_check_friend]
# push:
# branches:
# - main
# paths-ignore:
# - "api/static/data/*"

jobs:
update-data:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"

- name: Install dependencies
run: npm install

- name: Run
run: node ./friend-link-checker.js

- name: Commit and push
run: |
git config --global user.name 'github-actions[bot]'
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
git add friend.json
git commit -m 'chore: update friend.json [auto]' || echo 'No changes to commit'
git push

创建 GitHub 仓库并推送代码

创建 GitHub 仓库后,在本地项目目录中执行以下命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 初始化 Git 仓库
git init

# 配置 Git 用户信息
git config --global user.name "你的 GitHub 用户名"
git config --global user.email "你的 GitHub 邮箱"

# 添加远程仓库
git remote add origin https://github.com/你的用户名/你的仓库名.git

# 添加所有文件
git add .

# 提交代码
git commit -m "initial commit"

# 推送代码到 GitHub
git push -u origin main

配置仓库权限

  1. 进入 GitHub 仓库,点击 Settings
  2. 选择 Actions -> General
  3. Workflow permissions 部分,选择 Read and write permissions
  4. 点击 Save

部署检测结果页面

通过 Raw GitHub 访问(推荐)

检测脚本生成的结果文件可以直接通过 Raw GitHub URL 访问,无需额外部署。

访问格式:https://raw.githubusercontent.com/your-username/your-repo/main/friend.json

部署到 Vercel(可选)

Vercel 部署是可选的,如果不想部署,可以直接使用 Raw GitHub 访问方式

  1. 访问 Vercel 并登录
  2. 点击 Add New Project
  3. 选择 Import Git Repository,连接 GitHub 仓库
  4. 保持默认配置,点击 Deploy
  5. 部署完成后,访问 Vercel 提供的域名即可查看结果
    (中国大陆无法访问,需要添加自己的域名)

在 Hexo 博客中创建友情链接检测页面

创建页面文件

在 Hexo 博客中创建 [博客根目录]/source/check/index.md 文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
---
orderby: random
title: 友链检测
top_img: false
comments: false
aside: false
---

<link rel="stylesheet" type="text/css" href="/check/link-check.css">
<div id="lc-title">反向链接检测</div>
<div id="lc-loader">
<div id="lc-spinner"></div>
<div id="lc-text">正在获取数据...</div>
</div>
<div id="lc-result">
<div id="lc-result-title"></div>
<div id="lc-result-title">正确反向链接</div>
<div id="lc-result-success"></div>

<div id="lc-result-title">指向弃用域名</div>
<div id="lc-result-old"></div>

<div id="lc-result-title">未发现反向链接</div>
<div id="lc-result-notFound"></div>

<div id="lc-result-title">访问失败</div>
<div id="lc-result-fail"></div>
</div>
<script data-pjax src="/check/link-check.js"></script>

创建 CSS 样式文件

创建 [博客根目录]/source/check/link-check.css 文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
#lc-loader {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 20vh;
}

#lc-spinner {
width: 60px;
height: 60px;
border: 6px solid #e0e0e0;
border-top: 6px solid #3498db;
border-radius: 50%;
animation: lc-spin 1s linear infinite;
margin-bottom: 18px;
}

@keyframes lc-spin {
0% {
transform: rotate(0deg);
}

100% {
transform: rotate(360deg);
}
}

#lc-text {
font-size: 15px;
color: #555;
text-align: center;
letter-spacing: 2px;
}

#lc-result {
display: none;
}

#lc-title {
margin-top: 26px;
margin-left: 10px;
font-size: 20px;
}

#lc-result-title {
margin-left: 30px;
font-size: 14px;
margin-top: 14px;
}

/* 友链卡片样式 */
.lc-card-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 18px;
margin: 18px 0;
}

.lc-card {
display: flex;
flex-direction: row;
align-items: center;
background: #fff;
border-radius: 18px;
box-shadow: 0 2px 12px rgba(52, 152, 219, 0.08), 0 1.5px 4px rgba(0, 0, 0, 0.04);
padding: 14px 28px 14px 18px;
min-width: 0;
max-width: 100%;
width: 100%;
transition: box-shadow 0.18s cubic-bezier(0.4, 2, 0.6, 1), border-color 0.18s,
background 0.18s;
text-decoration: none;
color: #222;
cursor: pointer;
border: 1.5px solid #f0f0f0;
position: relative;
overflow: hidden;
word-break: break-all;
height: 86px;
will-change: box-shadow, border-color, background;
}

.lc-card:hover {
background: #f6fafd;
box-shadow: 0 4px 16px rgba(52, 152, 219, 0.1), 0 1.5px 4px rgba(0, 0, 0, 0.04);
border-color: #3498db;
}

.lc-card-avatar {
width: 50px;
height: 50px;
border-radius: 50%;
margin-bottom: 0 !important;
object-fit: cover;
margin-right: 28px !important;
border: 2.5px solid #eaf6fb;
background: #f6fafd;
transition: border-color 0.18s, box-shadow 0.18s;
flex-shrink: 0;
display: block;
}

.lc-card:hover .lc-card-avatar {
border-color: #3498db;
box-shadow: 0 2px 8px rgba(52, 152, 219, 0.1);
}

.lc-card-info {
display: flex;
flex-direction: column;
justify-content: center;
flex: 1 1 auto;
min-width: 0;
height: 100%;
align-items: flex-start;
gap: 2px;
}

.lc-card-name {
font-size: 17px;
font-weight: 600;
margin-bottom: 4px;
text-align: left;
word-break: break-all;
line-height: 1.2;
}

.lc-card-url {
font-size: 13px;
color: #3498db;
word-break: break-all;
text-align: left;
margin-bottom: 0;
line-height: 1.2;
}

.lc-card-error {
color: #e74c3c;
font-size: 12px;
margin-top: 4px;
word-break: break-all;
line-height: 1.3;
}

.lc-card-old-domain {
color: #888;
font-size: 12px;
margin-top: 2px;
word-break: break-all;
line-height: 1.3;
}

@media (max-width: 700px) {
.lc-card-list {
grid-template-columns: 1fr;
gap: 10px;
}

.lc-card {
padding: 10px 6px 10px 6px;
height: 60px;
}

.lc-card-avatar {
width: 34px;
height: 34px;
margin-right: 14px;
}

.lc-card-name {
font-size: 13px;
margin-bottom: 2px;
}

.lc-card-url {
font-size: 10px;
}
}

创建 JavaScript 文件

创建 [博客根目录]/source/check/link-check.js 文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
var data = undefined;

fetch("你的检测结果JSON文件URL/friend.json")
.then((response) => response.json())
.then((res) => {
data = res;
handle();
})
.catch((error) => {
document.getElementById("lc-spinner").style.display = "none";
document.getElementById("lc-text").innerHTML =
"获取友链数据失败<br>" + error;
});

function createCard(friend) {
let errorInfo = "";
if (friend.errorCodes) {
errorInfo = `<div class="lc-card-error">错误信息: ${
Array.isArray(friend.errorCodes)
? friend.errorCodes.join(", ")
: friend.errorCodes
}</div>`;
}
let oldDomainInfo = "";
if (friend.detectedOldDomain) {
oldDomainInfo = `<div class="lc-card-old-domain">旧域名: ${friend.detectedOldDomain}</div>`;
}
return `<a class="lc-card" href="${friend.url}" target="_blank" rel="noopener noreferrer">
<img class="lc-card-avatar" src="${friend.avatar}" alt="${friend.name}" loading="lazy" onerror="this.onerror=null;this.src='/static/img/erravatar.png';" />
<div class="lc-card-info">
<div class="lc-card-name">${friend.name}</div>
<div class="lc-card-url">${friend.url}</div>
${oldDomainInfo}
${errorInfo}
</div>
</a>`;
}

function renderCards(list, containerId) {
const container = document.getElementById(containerId);
if (!container) return;
if (!list || list.length === 0) {
container.innerHTML =
'<div style="color:#aaa;text-align:center;padding:12px 0;">暂无数据</div>';
return;
}
container.innerHTML = `<div class="lc-card-list">${list
.map(createCard)
.join("")}</div>`;
}

function handle() {
console.log(data);
document.getElementById("lc-loader").style.display = "none";
document.getElementById("lc-result").style.display = "block";
const localTime = new Date(data.updateTime).toLocaleString();
document.getElementById("lc-result-title").innerHTML = `成功获取 ${
data.old.length +
data.success.length +
data.fail.length +
data.notFound.length
} 条数据, 更新时间: ${localTime}`;
renderCards(data.success, "lc-result-success");
renderCards(data.old, "lc-result-old");
renderCards(data.notFound, "lc-result-notFound");
renderCards(data.fail, "lc-result-fail");
}

注意: 请将 你的检测结果JSON文件URL 替换为实际的 JSON 文件 URL

使用说明

  1. 确保所有文件都已正确创建并配置
  2. 将项目推送到 GitHub 仓库
  3. GitHub Action 会自动运行检测脚本并更新结果
  4. 通过 Hexo 博客的 /check/ 路径访问检测结果页面

总结

通过以上步骤,已经完成了友情链接自动检测系统的搭建。系统会每天自动检测所有友链的状态,并在博客中展示检测结果,方便及时发现和处理无效链接。

遇到问题时,可以查看 GitHub Action 的运行日志,或者检查脚本中的配置是否正确