碎碎念

起初,我管理友链时采取的是手动点击检验的方式,但随着时间的推移,友链数量逐渐增加至73条,这一做法显然已不再高效。我曾看到一些大佬实现了直接在友链卡片上标注可达状态的功能,遗憾的是,我并没有找到相关的教程。在探索过程中,我发现友链圈里存在一个API,它能够返回未能成功抓取的链接,原理是,如果某个站点在过去两个月内未曾产出新文章,则被视为不可达。然而,这种滞后性的判定机制明显影响了友链监测的即时性,所以生成的结果还是仅供参考。

于是,我动手编写了一个Python脚本,安排在执行hexo d命令时同步运行,以此来检测友链状态,并将检测结果输出到控制台,虽稍显原始,但也算是也勉强能用哈哈。偶然间在一次日常的糖果屋QQ群闲聊中,我看到了群友安小歪分享的一个方案,他利用GitHub Actions调度脚本运行,并最终生成比较简洁的HTML页面展示检测结果,这一思路启发了我。

在此基础上,我进一步优化了这一方案,设计出更为美观的前端展示界面,并额外写了一项类似API的功能,输出所有友链数据的可达性,针对适配性问题,我还使用根目录下的更加简洁的txt文件进行了适配检测并输出同样的内容。最终,借助编写好的JavaScript代码,我成功地将这些实时检测结果嵌入到了友情链接页面的每个卡片左上角,大大提升了友链管理的效率与直观性。

功能概览

  1. github action自动定时检测友链状态,结果输出到page分支下的result.json
  2. 友链状态展示页面,可以部署到zeabur或者vercel,加速api访问速度。
  3. 为确保兼容性,实现了两种检测方案:
    • 非兼容:使用该格式文件动态读取友链内容,实现功能,友链列表自动实时性更新。
    • 兼容:使用TXT存储所有友链信息,兼容性好,适合所有站点,但是添加友链后可能需要手动更新文件。
  4. API访问数据,api包含数据包括可达链接,不可达链接,可达链接数目。不可达链接数目,更新时间戳,其中链接中包含站点名称和地址,便于前端部署。
  5. 测试脚本使用python,使用Request包的gethead两种检测方式检测,尽可能减少误判概率。
  6. 前端采用本地缓存,减少api调用次数,缓存半个小时刷新,不影响实时性。

使用教程

github配置

  1. 配置仓库权限

    GitHub仓库的设置中,确保Actions有写权限,步骤如下:

    • 打开你的GitHub仓库,点击右上角的Settings

    • 在左侧栏中找到并点击Actions

    • 选择General

    • Workflow permissions部分,选择Read and write permissions

    • 点击Save按钮保存设置。

获取方式

动态Json获取

该方法适用于hexo-theme-butterfly,其他主题理论上也适配,但是需要自行修改代码实现相关功能,其实就是维护一个固定格式的友链在线列表。

首先,在hexo根目录下创建link.js,写入以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
const YML = require('yamljs')
const fs = require('fs')

let ls = [],
data = YML.parse(fs.readFileSync('source/_data/link.yml').toString().replace(/(?<=rss:)\s*\n/g, ' ""\n'));

data.forEach((e, i) => {
let j = 2; //获取友链数组的范围(除了最后,前面的都获取)
if (i < j) ls = ls.concat(e.link_list)
});
fs.writeFileSync('./source/flink_count.json', `{"link_list": ${JSON.stringify(ls)},"length":${ls.length}}`)
console.log('flink_count.json 文件已生成。');

其中的j表示获取的友链数组的范围,比如你只想要第一组,那么填写1即可。

根目录下执行以下内容:

1
node link.js

你将在[HexoRoot]/source文件夹下看到flink_count.json文件,文件格式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"link_list": [
{
"name": "String",
"link": "String",
"avatar": "String",
"descr": "String",
"siteshot": "String"
},{
"name": "String",
"link": "String",
"avatar": "String",
"descr": "String",
"siteshot": "String"
},
// ... 其他76个博客站点信息
],
"length": 77
}

该文件将在执行hexo g命令时进入[BlogRoot]/public目录下,并上传到网络上。

为了方便,可以写一个脚本,代替执行hexo d的功能(可选):

1
2
3
4
@echo off
E:
cd E:\Programming\HTML_Language\willow-God\blog
node link.js && hexo g && hexo algolia && hexo d

如果你使用 github action 构建站点,则更方便,在 hexo g 前面任意位置添加:

1
2
npm install yamljs --save
node link.js

上传之后,你就可以使用路径:https://blog.example.com/flink_count.json获取到所有的友链数据,下面修改github上的文件:test-friend.py

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
import json
import requests
import warnings
import time
import concurrent.futures
from datetime import datetime
from queue import Queue
import os

# 忽略警告信息
warnings.filterwarnings("ignore", message="Unverified HTTPS request is being made.*")

# 用户代理字符串,模仿浏览器
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36"

# API Key 和 请求URL的模板
# 判断是否在本地运行,如果是则从环境变量中获取API Key
if os.getenv("LIJIANGAPI_TOKEN") is None:
print("本地运行,从环境变量中加载并获取API Key")
from dotenv import load_dotenv
load_dotenv()
else:
print("在服务器上运行,从环境变量中获取API Key")

api_key = os.getenv("LIJIANGAPI_TOKEN")
api_url_template = "https://api.nsmao.net/api/web/query?key={}&url={}"

# 代理链接的模板,代理是通过在代理地址后加目标 URL 来请求,代理地址确保以 / 结尾
proxy_url = os.getenv("PROXY_URL")
if proxy_url is not None:
proxy_url_template = proxy_url + "{}"
else:
proxy_url_template = None

# 初始化一个队列来处理API请求
api_request_queue = Queue()

# API 请求处理函数,确保每秒不超过5次请求
def handle_api_requests():
while not api_request_queue.empty():
item = api_request_queue.get()
headers = {"User-Agent": user_agent}
link = item['link']
if api_key is None:
print("API Key 未提供,无法通过API访问")
item['latency'] = -1
break
api_url = api_url_template.format(api_key, link)

try:
response = requests.get(api_url, headers=headers, timeout=15, verify=True)
response_data = response.json()

# 提取API返回的code和exec_time
if response_data['code'] == 200:
latency = round(response_data['exec_time'], 2)
print(f"成功通过API访问 {link}, 延迟为 {latency} 秒")
item['latency'] = latency
else:
print(f"API返回错误,code: {response_data['code']},无法访问 {link}")
item['latency'] = -1
except requests.RequestException:
print(f"API请求失败,无法访问 {link}")
item['latency'] = -1

time.sleep(0.2) # 控制API请求速率,确保每秒不超过5次

# 检查链接是否可访问的函数并测量时延
def check_link_accessibility(item):
headers = {"User-Agent": user_agent}
link = item['link']
latency = -1

# 1. 首先尝试直接访问
try:
start_time = time.time()
response = requests.get(link, headers=headers, timeout=15, verify=True)
latency = round(time.time() - start_time, 2)
if response.status_code == 200:
print(f"成功通过直接访问 {link}, 延迟为 {latency} 秒")
return [item, latency]
except requests.RequestException:
print(f"直接访问失败 {link}")

# 2. 尝试通过代理访问(可选)
if proxy_url_template is None:
print("未提供代理地址,无法通过代理访问")
else:
proxy_url = proxy_url_template.format(link)
try:
start_time = time.time()
response = requests.get(proxy_url, headers=headers, timeout=15, verify=True)
latency = round(time.time() - start_time, 2)
if response.status_code == 200:
print(f"成功通过代理访问 {link}, 延迟为 {latency} 秒")
return [item, latency]
except requests.RequestException:
print(f"代理访问失败 {link}")

# 3. 如果代理也失败,添加到API队列中
item['latency'] = -1
api_request_queue.put(item)
return [item, latency]

# 目标JSON数据的URL
json_url = 'https://blog.liushen.fun/flink_count.json' # 改的是这里!!!

# 发送HTTP GET请求获取JSON数据
response = requests.get(json_url)
if response.status_code == 200:
data = response.json() # 解析JSON数据
link_list = data['link_list'] # 提取所有的链接项
else:
print(f"Failed to retrieve data, status code: {response.status_code}")
exit()

# 使用ThreadPoolExecutor并发检查多个链接
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
results = list(executor.map(check_link_accessibility, link_list))

# 处理API请求
handle_api_requests()

# 添加时延信息到每个链接项
link_status = [{'name': result[0]['name'], 'link': result[0]['link'], 'latency': result[0].get('latency', result[1])} for result in results]

# 获取当前时间
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

# 统计可访问和不可访问的链接数,计算 accessible_count 和 inaccessible_count
accessible_count = 0
inaccessible_count = 0

for result in results:
# print(result[1])
if result[1] != -1:
accessible_count += 1
else:
inaccessible_count += 1

# 计算 total_count
total_count = len(results)

# 将结果写入JSON文件
output_json_path = './result.json'
with open(output_json_path, 'w', encoding='utf-8') as file:
json.dump({
'timestamp': current_time,
'accessible_count': accessible_count,
'inaccessible_count': inaccessible_count,
'total_count': total_count,
'link_status': link_status
}, file, ensure_ascii=False, indent=4)

print(f"检查完成,结果已保存至 '{output_json_path}' 文件。")

修改其中的json_url为你的对应地址,保存即可。由于github action脚本默认方式即为这种方式,所以不需要进行修改。

静态csv获取

这个方式较为简单,但是维护稍微有点点麻烦,你需要将所有数据写到仓库根目录的link.csv文件中,格式如下:

1
2
3
4
5
6
7
8
清羽飞扬,https://blog.liushen.fun/
ChrisKim,https://www.zouht.com/
Akilar,https://akilar.top/
张洪Heo,https://blog.zhheo.com/
安知鱼,https://blog.anheyu.com/
杜老师说,https://dusays.com
Tianli,https://tianli-blog.club/
贰猹,https://noionion.top/

其中前面是名称,后面是链接,名称是为了使我们的结果json数据更加全面,同时和上面动态Json获取的方式统一,减少后面部分的工作量,增强兼容性。

处理这部分可能比较消耗时间。

这里可以使用kimi帮你整理,自行组织语言并命令,复制最终结果并保存即可。

我的整理方式

下面修改github action脚本内容,修改其中运行python脚本的部分:

1
2
3
4
5
- name: Run Python script to check frined-links
env:
LIJIANGAPI_TOKEN: ${{ secrets.LIJIANGAPI_TOKEN }}
PROXY_URL: ${{ secrets.PROXY_URL }}
run: python test-friend-in-txt.py

修改结束,最终结果是一样的,所以并不影响任何后续操作,如果action正确运行,则你的结果应该出现在另一个分支了。(page)分支。

提升准确率

由于该脚本通过action进行,可能会出现由于各种原因导致准确率极低的现象发生,此时我们需要通过各种方式提升检测的准确率,本脚本内置了通过梨酱APICloudFlare Worker转发的方式,可以分别应对屏蔽国外,以及使用国外CDN且防火墙屏蔽Github的两种情况,经过实测,最终准确率基本达到100%。

启用两种方式非常简单,仅需要设置对应的环境变量即可。

  1. 梨酱API:

    打开梨酱API并在右上角注册完成,进入控制台。进入控制台后,点击左边的密钥管理,生成密钥。

    梨酱api

    然后再在仓库设置->secret->action中,添加LIJIANGAPI_TOKEN的密钥即可自动启用。

  2. CloudFlare Worker

    其实上面的梨酱api就可以达到很高的准确率了,如果仍然有部分站本身能访问却无法检测,你可以尝试使用下面的方式进行检测:

    首先部署转发代理,具体教程可以点击查看文章,不需要绑定域名,因为github action本身就是国外环境。

    然后添加密钥PROXY_URL,设置密钥即自动启用。

部署展示页面

在成功运行一次后,该仓库即成为了一个前端页面,可以自行安排上传到哪里,这里选择zeabur上传,以下是大致步骤:

  1. 登录Vercel或Zeabur
    • 如果还没有账户,请先注册一个 VercelZeabur 账户。
    • 登录后进入仪表板。
  2. 导入GitHub仓库
    • 点击New ProjectImport Project按钮。
    • 选择Import Git Repository
    • 连接到您的 GitHub账户,并选择该链接检查项目的仓库。
  3. 配置项目
    • 确保选择正确的分支(page)。
    • 对于 Vercel,在 Build and Output Settings中,确保 output.json 文件在构建输出目录中。
  4. 部署项目
    • 点击Deploy按钮开始部署。
    • 部署完成后,VercelZeabur 会生成一个 URL,您可以使用这个 URL 访问部署的网页。

此时如果不出意外,前端页面应该可以展示数据了,如下:

展示数据

使用zeabur或者vercel部署的目的是加快结果文件国内访问并且展示最终结果, Vercel 或 Zeabur 平台可以跟随仓库更新实时同步内容,配合上前端的缓存和异步加载,哪怕是国内访问也可以得到非常好的体验,可以在我的友链页面测试。

将数据展示到前端

通用解释

通过上面部署,我们就可以通过地址访问获得的数据了(用本站部署的作为示例):

1
https://check.zeabur.app/result.json

链接检查结果以JSON格式存储,主要包含以下字段:

  • accessible_links: 可访问的链接列表。
  • inaccessible_links: 不可访问的链接列表。
  • timestamp: 生成检查结果的时间戳。

以下是一个示例结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
"timestamp": "2024-09-19 09:18:49",
"accessible_count": 64,
"inaccessible_count": 12,
"total_count": 76,
"link_status": [
{
"name": "清羽飞扬",
"link": "https://blog.qyliu.top/",
"latency": -1,
},
{
"name": "ChrisKim",
"link": "https://www.zouht.com/",
"latency": 0.76,
},
{
"name": "Akilar",
"link": "https://akilar.top/",
"latency": 3.31,
},
]
}

比如,你可以通过以下页面展示其友链数据到前端,当然,该代码仅作解释,具体效果请自行实现:

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
fetch('./result.json')
.then(response => response.json())
.then(data => {
// 提取整体统计信息
const summary = `总数: ${data.total_count} | 可访问: ${data.accessible_count} | 不可访问: ${data.inaccessible_count}`;
document.getElementById('summary').textContent = summary + ` | 检测时间: ${data.timestamp}`;

// 动态生成表格数据
const tbody = document.getElementById('link-table-body');
data.link_status.forEach(item => {
const row = document.createElement('tr');

// 名称列
const nameCell = document.createElement('td');
nameCell.textContent = item.name;
row.appendChild(nameCell);

// 链接列
const linkCell = document.createElement('td');
const linkElement = document.createElement('a');
linkElement.href = item.link;
linkElement.target = "_blank";
linkElement.textContent = item.link;
linkCell.appendChild(linkElement);
row.appendChild(linkCell);

// 时延列
const latencyCell = document.createElement('td');
latencyCell.textContent = item.latency >= 0 ? item.latency.toFixed(2) : '不可达';
row.appendChild(latencyCell);

// SSL 状态列
const sslCell = document.createElement('td');
sslCell.textContent = item.ssl_status ? '✔️' : '❌';
row.appendChild(sslCell);

tbody.appendChild(row);
});
})
.catch(error => {
console.error('Error loading JSON data:', error);
const tbody = document.getElementById('link-table-body');
const row = document.createElement('tr');
const cell = document.createElement('td');
cell.colSpan = 4;
cell.textContent = '无法加载数据';
cell.style.textAlign = 'center';
row.appendChild(cell);
tbody.appendChild(row);
});

本站方案

注意,本站采用方案需要按照本站教程魔改友情链接页面,否则需要自行修改代码内容,所以仅供参考,请以自身情况为准

[BlogRoot]/source/link/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
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
<style>
.status-tag {
position: absolute;
top: 0px;
left: 0px;
padding: 3px 8px;
border-radius: 12px 0px 12px 0px;
font-size: 12px;
color: white;
font-weight: bold;
transition: font-size 0.3s ease-out, width 0.3s ease-out, opacity 0.3s ease-out;
}
.flink-list-item:hover .status-tag {
font-size: 0px;
opacity: 0;
}
/* 固态颜色 */
.status-tag-green {
background-color: #005E00; /* 绿色 */
}
.status-tag-light-yellow {
background-color: #FED101; /* 浅黄色 */
}
.status-tag-dark-yellow {
background-color: #F0B606; /* 深黄色 */
}
.status-tag-red {
background-color: #B90000; /* 红色 */
}
</style>
<script>
function addStatusTagsWithCache(jsonUrl) {
const cacheKey = "statusTagsData";
const cacheExpirationTime = 30 * 60 * 1000; // 半小时
function applyStatusTags(data) {
const linkStatus = data.link_status;
document.querySelectorAll('.flink-list-item').forEach(card => { // 一定要注意这里的类名,小心匹配不上
if (!card.href) return;
const link = card.href.replace(/\/$/, '');
const statusTag = document.createElement('div');
statusTag.classList.add('status-tag');
let matched = false;
// 查找链接状态
const status = linkStatus.find(item => item.link.replace(/\/$/, '') === link);
if (status) {
let latencyText = '未知';
let className = 'status-tag-red'; // 默认红色
if (status.latency === -1) {
latencyText = '未知';
} else {
latencyText = status.latency.toFixed(2) + ' s';
if (status.latency <= 2) {
className = 'status-tag-green';
} else if (status.latency <= 5) {
className = 'status-tag-light-yellow';
} else if (status.latency <= 10) {
className = 'status-tag-dark-yellow';
}
}
statusTag.textContent = latencyText;
statusTag.classList.add(className);
matched = true;
}
if (matched) {
card.style.position = 'relative';
card.appendChild(statusTag);
}
});
}
function fetchDataAndUpdateUI() {
fetch(jsonUrl)
.then(response => response.json())
.then(data => {
applyStatusTags(data);
const cacheData = {
data: data,
timestamp: Date.now()
};
localStorage.setItem(cacheKey, JSON.stringify(cacheData));
})
.catch(error => console.error('Error fetching test-flink result.json:', error));
}
const cachedData = localStorage.getItem(cacheKey);
if (cachedData) {
const { data, timestamp } = JSON.parse(cachedData);
if (Date.now() - timestamp < cacheExpirationTime) {
applyStatusTags(data);
return;
}
}
fetchDataAndUpdateUI();
}
setTimeout(() => {
addStatusTagsWithCache('https://check.zeabur.app/result.json');
}, 0);
</script>

这段代码是一个JavaScript脚本,它定义了一个名为addStatusTagsWithCache的函数,该函数用于在网页上的链接卡片上添加状态标签。

  1. CSS样式定义:首先定义了一个.status-tag的CSS类,这个类为状态标签设置了样式,包括绝对定位、填充、边框圆角、字体大小、颜色和字体粗细。

  2. JavaScript函数定义:定义了一个addStatusTagsWithCache函数,该函数接收一个参数jsonUrl,这个参数是一个JSON格式的URL,用于获取链接状态数据。

  3. 缓存机制:函数内部使用localStorage来实现缓存机制,通过cacheKeycacheExpirationTime来存储和控制缓存数据的有效期,减少对于api的请求次数并减少通信延迟。

  4. 数据获取与UI更新fetchDataAndUpdateUI是一个内部函数,用于从提供的URL获取数据,并更新页面上的UI。它首先使用fetch API请求JSON数据,然后解析数据,并根据数据中的可访问链接和不可访问链接列表,为页面上的.site-card元素添加状态标签。

  5. 状态标签样式:根据链接的状态,状态标签的文本和背景颜色会有所不同。如果链接是可访问的,则文本为“正常”,背景颜色为绿色;如果链接是不可访问的,则文本为“疑问”,背景颜色为红色。

  6. 缓存检查:在执行fetchDataAndUpdateUI之前,脚本会检查是否存在有效的缓存数据。如果缓存数据存在并且未过期,则直接使用缓存数据更新UI,否则调用fetchDataAndUpdateUI来获取最新数据。

  7. 延迟执行:使用setTimeout函数延迟执行addStatusTagsWithCache函数,确保在页面加载完成后再执行此函数。

  8. 实际URL调用:最后,脚本通过调用addStatusTagsWithCache函数,并传入实际的JSON URL(’https://check.zeabur.app/result.json'),来启动整个流程。

整个脚本的目的是动态地根据服务器返回的链接状态数据,在页面上为每个链接卡片添加相应的状态标签,以提示用户链接的当前状态。同时,通过使用缓存机制,可以减少对服务器的请求次数,提高页面性能。

最终展示图如下:

友链页面

缺陷

  1. 网络延迟:网络延迟会影响请求的响应时间,特别是当检测的链接位于地理位置较远或网络条件较差的服务器上时(已解决)。
  2. 国外网络环境限制:如果GitHub Actions的服务器位于国外,可能会因为某些国家或地区的网络审查制度而无法访问部分网站(已解决)。
  3. Python检测缺陷:使用Python的requests库进行检测可能无法完全模拟浏览器行为,例如,它可能无法处理JavaScript渲染的页面或执行某些客户端脚本(已解决)。
  4. 请求限制:某些网站可能会对频繁的请求进行限制,导致GitHub Actions的IP地址被暂时或永久地封禁(已解决)。
  5. HTTP头信息:使用head方法虽然可以获取页面的元数据,但不会获取到页面的实际内容,这可能导致一些需要分析页面内容才能判断的可访问性问题被忽略(已解决)。
  6. HTTPS证书问题:如果检测的链接使用自签名证书或不受信任的证书,requests可能会抛出警告或错误,导致检测失败(已解决)。
  7. 重定向处理:某些链接可能会进行重定向,如果脚本没有正确处理重定向,可能会误判链接的状态。

总结

虽然这个方式有缺陷,但也在很大程度上减少了我们的工作量,可以不用手动一个个检测了。自动化的检测流程不仅节约了大量时间,还提高了检测的一致性和准确性。通过脚本,我们可以快速地对大量链接进行批量检查,及时地发现问题并进行相应的处理。此外,自动化测试可以很容易地集成到持续集成/持续部署(CI/CD)的流程中,确保在软件开发周期的早期阶段就能识别和修复问题。尽管存在一些局限性,但通过适当的配置和优化,我们可以最大限度地减少这些缺陷的影响,同时享受自动化带来的便利。

每日一图

咕咕!

参考链接

— 柳影曳曳,清酒孤灯,扬笔撒墨,心境如霜