前言

由于最近有些网站频繁被 PCDN 滥用(包括我自己的站点),我就重新写了一个用于爬取友链文章的项目。 之前那个项目对某些使用特定 RSS 格式的网站兼容性不是很好,而且我也不太想去改动原项目的代码,于是干脆决定自己重写一个。

项目说明

简介

这是一个用 Go 编写的轻量级友链聚合工具,主要用于:

定时从远程配置中获取友链 RSS 地址
并发抓取每个站点的最新文章
生成一个结构统一的 feed_result.json 文件
供前端页面进行可视化展示

主要功能特点如下:

  • ✅ 定时任务:基于 cron 表达式设定抓取频率
  • ✅ 并发爬取:最大限度提升效率,抓完即停
  • ✅ JSON 输出:标准结构、字段统一,前端好对接
  • ✅ HTTP 接口:支持手动触发或在线查看当前抓取结果
  • ✅ Docker 部署:环境变量灵活控制,使用方便

项目部署

编写 docker-compose.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
version: '3.8'

services:
fcircle:
image: txm123/fcircle:latest
container_name: fcircle
restart: always
ports:
- "8521:8080"
volumes:
- ./logs:/app/output
environment:
- GIN_MODE=release # 设定 Gin 运行环境
- SERVER_PORT=8080 # 对应 容器启动端口
- SECRET_KEY=##### # /fetch接口手动请求密钥
- CRON_EXPR=0 0 3 * * * # 设置定时调用的间隔时间
- CONFIG_URL=https://cdn.aimiliy.top/npm/json/RSS.json # 配置文件url
- OUTPUT_FILE=output/feed_result.json # 朋友圈json文件路径
- LOG_FILE=output/crawl.log # 日志文件路径

配置说明

环境变量说明示例值
SERVER_PORTHTTP 服务监听端口8080
SECRET_KEYfetch接口请求密钥#####
CRON_EXPRcron表达式0 0 3 * * *
CONFIG_URL远程 RSS 配置文件地址https://xxx.com/path/RSS.json
OUTPUT_FILE抓取结果保存路径(容器内)output/feed_result.json
LOG_FILE日志输出路径(容器内)output/crawl.log

启动容器

1
docker-compose up -d

运行后,程序会:

1、立即执行一次抓取任务
2、后续按照配置的 cron 表达式定时执行
3、抓取完的 JSON 可通过 代理地址 + /feed 进行访问
4、可以通过 代理地址 +/fetch?key=XXXX 来手动调用RSS解析,其中 key 为上方环境变量中的SECRET_KEY
📎 示例访问地址:点击查看抓取结果 JSON

注意:目前系统中所有接口均对IP做了限流处理,其中/fetch 接口请求 QPS 超过阈值时会被封禁24小时,其主要目的是为了防止滥用。

image

配置代理

根据配置文件中设置的容器的映射端口,在面板中配置代理服务。

友链配置文件示例

1
2
3
4
5
6
7
8
9
[
{
"name": "示例博客",
"link": "https://example.com",
"avatar": "https://example.com/avatar.png",
"rss": "https://example.com/feed"
},
...
]

只需要确保每一项包含站点名称、链接、头像地址和对应的 RSS 地址即可,程序会自动遍历处理。
img.png

生成结果文件示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"meta": {
"fetch_time": "2025-07-17 16:09:27",
"friend_count": 28,
"success_count": 18,
"fail_count": 10,
"article_count": 88
},
"items": [
{
"title": "站点更新!CDN优化!腾讯EO白嫖经历",
"link": "https://www.xscnet.cn/posts/p2620250716",
"published": "2025-07-16 14:32:00",
"author": "Mete0r",
"avatar": "https://cdn.aimiliy.top/avatar/www.xscnas.top.webp",
"content": "Guys!又半个月没见啦,这半个月有好多人和我吐槽我的博客长时间没法正常访问哈哈哈,原因是在筹划一个大计划呢\n博客更新\n\n图床链接更新\n\n由于最近十年之约的虫洞图标被恶意刷\n\u003Cbr\u003E访问 \u003Ca href=\"https://www.xscnet.cn/posts/p2620250716\"\u003Ehttps://www.xscnet.cn/posts/p2620250716\u003C/a\u003E 阅读全文。",
"url": "https://www.xscnas.top/"
},
]
}

程序会尝试提取每篇文章的标题、发布时间、链接、摘要,以及可选的 content 字段(HTML 内容片段)。不过要注意,部分站点的 RSS feed 格式不标准,可能会出现 content 抽取异常或为空的情况。
img_1.png

前端部署

本项目前端主要适配butterfly主题,其他主题暂未适配。

页面文件部署

首先在项目的page目录下方新建fcircle.pug文件,写入以下内容:

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
.fcircle-header
h1
i.fas.fa-blog
| Fcircle - 友链朋友圈

.stats
.stat-item
i.fas.fa-user-friends
| 订阅总数:
span#friend-count 0
.stat-item
i.fas.fa-check-circle
| 成功:
span#success-count 0
.stat-item
i.fas.fa-times-circle
| 失败:
span#fail-count 0
.stat-item
i.fas.fa-file-alt
| 文章数:
span#article-count 0

.controls
button#refresh-btn.btn
i.fas.fa-sync-alt
| 刷新文章
button#clear-cache-btn.btn
i.fas.fa-trash-alt
| 清除缓存

.fcircle-container
#error-container.error-container(style="display: none;")
i.fas.fa-exclamation-triangle
h3 数据加载失败
p 无法从API获取数据,正在使用缓存数据或显示错误信息
p
| 错误信息:
span#error-message

#masonry-container.masonry

#loading.fcircle-loading(style="display: none;")
i.fas.fa-spinner
| 正在加载更多文章...

#no-more.no-more(style="display: none;")
| 没有更多文章了~ (๑>◡<๑)

.cache-info
| 数据缓存中,下次更新:
span#cache-time --:--:--

然后在page.pug中加入以下内容:

1
2
when 'fcircle'
include includes/page/fcircle.pug

最后在你对应路径的index.md文件的front-matter中引入type: ‘fcircle’即可,例如:

1
2
3
4
5
6
---
title: 朋友圈
date: 2022-09-05 18:00:00
type: 'fcircle'
comments: false
---

样式引入

在你项目的CSS文件中引入如下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
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


:root {
--success: #198754;
--error: #dc3545;
--fc-card-color: rgba(255, 255, 255, 0.6);
--stat-item-bg: #f0f0f0;
--text-light: #6c757d;
--card-link-bg: var(--theme-color);
--card-link-color: #ffffff
}

[data-theme="dark"] {
--success: #4caf50;
--error: #f44336;
--fc-card-color: #2c2c2c;
--stat-item-bg: #3a3a3a;
--text-light: #adb5bd;
--card-link-bg: #1f1f1f;
--card-link-color: #f1f1f1
}


.fcircle-header {
background: var(--fc-card-color);
color: var(--font-color) !important;
padding: 20px 0;
text-align: center;
top: 0;
z-index: 100;
box-shadow: var(--card-box-shadow);
border-radius: 15px;
}

.fcircle-header h1 {
color: var(--font-color) !important;
font-size: 2.2rem;
margin-bottom: 10px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}

.fcircle-header h1 i {
margin-right: 10px;
}

.fcircle-header .stats {
display: flex;
justify-content: center;
gap: 25px;
margin-top: 30px;
flex-wrap: wrap;
}

.fcircle-header .stat-item {
background: var(--stat-item-bg);
padding: 8px 15px;
border-radius: 20px;
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
backdrop-filter: blur(5px);
box-shadow: var(--card-box-shadow);
}

.fcircle-header .controls {
display: flex;
justify-content: center;
gap: 15px;
margin-top: 20px;
}

.fcircle-header .controls .btn {
background: var(--theme-color);
color: #fff;
border: none;
padding: 8px 18px;
border-radius: 25px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
box-shadow: var(--card-box-shadow);
transition: all 0.3s ease;
}

.fcircle-header .controls .btn:hover {
transform: translateY(-3px);
box-shadow: var(--card-hover-box-shadow);
}

.fcircle-header .controls .btn:active {
transform: translateY(1px);
}

.fcircle-header .controls .btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}

.fcircle-container {
margin: 20px auto;
}

.fcircle-container .masonry {
max-width: 100%;
}

.fcircle-container .fc-card {
width: calc((100% - 40px) / 3);
margin-bottom: 20px;
transition: box-shadow .3s ease;
background: var(--fc-card-color);
border-radius: 15px;
overflow: hidden;
box-shadow: var(--card-box-shadow);
display: flex;
flex-direction: column;
}


@media (max-width: 1100px) {
.fcircle-container .fc-card {
width: calc((100% - 20px) / 2);
}
}

@media (max-width: 700px) {
.fcircle-container .fc-card {
width: 100%;
}
}

.fcircle-container .fc-card:hover {
box-shadow: var(--card-hover-box-shadow);
}


.fcircle-container .card-header {
padding: 10px 20px 12px;
}

.fcircle-container .card-title {
font-size: 1.2rem;
font-weight: 700;
color: var(--font-color);
line-height: 1.4;
min-height: 2em;
margin: 10px 0;
}

.fcircle-container .card-author {
display: flex;
align-items: center;
gap: 12px;
}

.fcircle-container .avatar {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
background: #f0f0f0;
box-shadow: var(--card-box-shadow);
}

.fcircle-container .author-name {
font-weight: 600;
color: var(--font-color);
}

.fcircle-container .card-date {
font-size: 0.85rem;
color: var(--text-light);
}

.fcircle-container .card-link {
display: block;
text-align: center;
background: var(--card-link-bg);
color: var(--card-link-color);
text-decoration: none;
font-weight: 600;
transition: all 0.3s ease;
}

.fc-card-content {
font-size: 0.95rem;
line-height: 2rem;
padding: 0 20px 20px;
word-break: break-word;
overflow-wrap: break-word;
white-space: normal;
text-indent: 2em;
}

.fc-card-content p {
margin: 5px 0;
}


.fcircle-container .card-link:hover {
opacity: 0.9;
letter-spacing: 1px;
}

.fcircle-container .card-link i {
margin-left: 8px;
}

.fcircle-loading {
text-align: center;
padding: 30px;
font-size: 1.2rem;
color: var(--text-light);
}

.fcircle-loading i {
animation: spin 1s linear infinite;
margin-right: 10px;
}

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

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

.cache-info {
text-align: center;
margin-top: 10px;
color: var(--text-light);
font-size: 0.9rem;
}

.no-more {
text-align: center;
padding: 20px;
color: var(--text-light);
font-size: 1rem;
}

.error-container {
background-color: #ffebee;
border-radius: 10px;
padding: 20px;
margin: 20px 0;
text-align: center;
}

.error-container i {
font-size: 2rem;
color: var(--error);
margin-bottom: 10px;
}

js文件引入

1、在的主题文件中引入下方js插件

1
2
3
4
5
6
7

// - Masonry 布局库
script(src="https://cdnjs.cloudflare.com/ajax/libs/masonry/4.2.2/masonry.pkgd.min.js")
script(src="https://cdn.jsdelivr.net/npm/marked/marked.min.js")

// - imagesLoaded 用于图片加载后触发 Masonry 布局刷新
script(src="https://cdnjs.cloudflare.com/ajax/libs/jquery.imagesloaded/4.1.4/imagesloaded.pkgd.min.js")

2、在你项目的js文件引入下方js代码,注意修改下方代码中的配置文件,将其中的API_URL修改为你自己代理地址+/feedERROR_IMG修改为你自己的占位图片地址。
参数说明:
CACHE_KEY:缓存名称
CACHE_DURATION:缓存时间
ITEMS_PER_PAGE:每次展示文章数量

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
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
const FcircleModule = (() => {

const config = {
API_URL: 'https://代理地址/feed',
CACHE_KEY: 'blog_feed_cache',
CACHE_DURATION: 6 * 60 * 60 * 1000,
ITEMS_PER_PAGE: 20,
ERROR_IMG: 'xxxxxx'
}

const getCachedData = () => {
const cached = localStorage.getItem(config.CACHE_KEY);
if (!cached) return null;

const data = JSON.parse(cached);
const now = new Date().getTime();

if (now - data.timestamp < config.CACHE_DURATION) {
return data;
}

return null;
}


const cleanContent = (content) => {
if (!content) return '';
return content
.replace(/<br\s*\/?>/gi, '\n')
.trim();
}


const decodeShortcodes = (rawContent) => {
if (!/\[scode|\[collapse|\[hplayer|\[Music/.test(rawContent)) {
return renderMarkdown(cleanContent(rawContent));
}

let content = rawContent;

content = content.replace(/\[scode\s+type="([^"]+)"\](.*?)\[\/scode\]/g,
(_, type, inner) => `<span class="scode scode-${type}">${inner}</span>`);

content = content.replace(/\[collapse\s+status="([^"]+)"\s+title="([^"]+)"\]([\s\S]*?)\[\/collapse\]/g,
(_, status, title, inner) => {
const open = status === "true" ? "open" : "";
return `<details ${open}><summary>${title}</summary><div class="collapse-content">${inner.trim()}</div></details>`;
});

content = content.replace(/<br\s*\/?>/gi, "<br>");

if (content.includes('[hplayer]') || content.includes('[Music')) {
content = extractMeting(content);
}

return renderMarkdown(cleanContent(content));
};


const renderMarkdown = (content) => {
return marked.parse(content);
}


const extractMeting = (content) => {
return content.replace(
/\[hplayer\][\s\S]*?\[Music\s+server="([^"]+)"\s+id="([^"]+)"\s+type="([^"]+)"\s*\/\][\s\S]*?\[\/hplayer\]/gi,
(_, server, id, type) => {
return `<meting-js id="${id}" server="${server}" type="${type}" mutex="true" preload="auto"></meting-js>`;
}
);
}


const normalizeLink = (url, link) => {
if (/^https?:\/\//i.test(link)) {
return link;
}

if (!url) {
return link;
}

url = url.replace(/\/+$/, '');
if (!link.startsWith('/')) {
link = '/' + link;
}

return url + link;
}

const runIdle = (callback, timeout = 0) => {
if ('requestIdleCallback' in window) {
requestIdleCallback(callback, {timeout});
} else {
setTimeout(callback, timeout);
}
}


return {
init: () => {

let allArticles = [];
let displayedCount = 0;
let isLoading = false;
let masonry = null;
let isRendering = false;

const masonryContainer = document.getElementById('masonry-container');
const loadingElement = document.getElementById('loading');
const noMoreElement = document.getElementById('no-more');
const refreshBtn = document.getElementById('refresh-btn');
const clearCacheBtn = document.getElementById('clear-cache-btn');
const cacheTimeElement = document.getElementById('cache-time');
const errorContainer = document.getElementById('error-container');
const errorMessage = document.getElementById('error-message');

const updateStats = (data) => {
if (!data) return;
document.getElementById('friend-count').textContent = data.friendCount || 0;
document.getElementById('success-count').textContent = data.successCount || 0;
document.getElementById('fail-count').textContent = data.failCount || 0;
document.getElementById('article-count').textContent = allArticles.length || 0;
}

const loadData = async () => {
const cachedData = getCachedData();

if (cachedData) {
allArticles = cachedData.items;

updateStats(cachedData.meta);
renderArticles(() => {
updateCacheTimeDisplay(cachedData.timestamp);
});
} else {
await fetchData();
}
}

const fetchData = async (forceRefresh = false) => {
try {
showLoading(true);
refreshBtn.disabled = true;

if (!forceRefresh) {
const cachedData = getCachedData();
if (cachedData) {
allArticles = cachedData.items;

updateStats(cachedData.meta);
renderArticles(() => {
updateCacheTimeDisplay(cachedData.timestamp);
showLoading(false);
refreshBtn.disabled = false;
});

return;
}
}

const response = await fetch(config.API_URL);

if (!response.ok) {
throw new Error(`API请求失败: ${response.status} ${response.statusText}`);
}

const data = await response.json();
if (!data || !Array.isArray(data.items)) {
throw new Error('无效的API响应数据');
}

allArticles = data.items;
const cacheData = {
items: allArticles,
meta: {
friendCount: data.meta.friend_count || 0,
successCount: data.meta.success_count || 0,
failCount: data.meta.fail_count || 0
},
timestamp: new Date().getTime()
};

localStorage.setItem(config.CACHE_KEY, JSON.stringify(cacheData));

btf.scrollToDest(0, 500);
displayedCount = 0;
masonryContainer.innerHTML = '';

if (masonry) {
masonry.layout();
masonry.destroy();
masonry = null;
}

updateStats(cacheData.meta);
renderArticles(() => {
updateCacheTimeDisplay(cacheData.timestamp);
errorContainer.style.display = 'none';
showLoading(false);
refreshBtn.disabled = false;
});

} catch (error) {
console.error('数据加载失败:', error);

errorContainer.style.display = 'block';
errorMessage.textContent = error.message;

const cachedData = getCachedData();
if (cachedData) {
allArticles = cachedData.items;

updateStats(cachedData.meta);
renderArticles(() => {
updateCacheTimeDisplay(cachedData.timestamp);
showLoading(false);
refreshBtn.disabled = false;
});
} else {
masonryContainer.innerHTML = '';
if (masonry) {
masonry.layout();
}

showLoading(false);
refreshBtn.disabled = false;
}
}
}

const initMasonry = () => {
masonry = new Masonry(masonryContainer, {
itemSelector: '.fc-card',
columnWidth: '.fc-card',
percentPosition: true,
gutter: 20,
});
}

const renderArticles = (callback = () => {}) => {
if (allArticles.length === 0) {
masonryContainer.innerHTML = '<div class="no-more" style="display:block;">没有找到文章数据</div>';
noMoreElement.style.display = 'none';
callback();
return;
}

if (isRendering) return;
isRendering = true;

const startIndex = displayedCount;
const endIndex = Math.min(startIndex + config.ITEMS_PER_PAGE, allArticles.length);

if (!masonry) {
initMasonry();
}

let currentIndex = startIndex;

const processNext = () => {
if (currentIndex >= endIndex) {
displayedCount = endIndex;
noMoreElement.style.display = (displayedCount >= allArticles.length) ? 'block' : 'none';
isRendering = false;
callback();
return;
}

const article = allArticles[currentIndex];
const card = createArticleCard(article);

imagesLoaded(card)
.on('always', () => {
masonryContainer.appendChild(card);
masonry.appended(card);
masonry.layout();

card.style.opacity = '1';
card.style.transform = 'translateY(0)';
card.style.transition = 'opacity 0.4s ease, transform 0.4s ease';

currentIndex++;
runIdle(processNext, 50);
});
}
processNext();
}


const createArticleCard = (article) => {
const card = document.createElement('div');
card.className = 'fc-card';
card.style.opacity = '0';
card.style.transform = 'translateY(20px)';

const date = article.published ? new Date(article.published) : new Date();
const formattedDate = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`;

const fixedIcon = '📝';

card.innerHTML = `
<div class="card-header">
<h3 class="card-title">
<a href="${normalizeLink(article.url, article.link)}" target="_blank" rel="noopener noreferrer">
${fixedIcon} ${article.title}
</a>
</h3>
<div class="card-author">
<img src="${article.avatar}" alt="${article.author}" class="avatar" loading="lazy" onerror="this.onerror=null;this.src='${config.ERROR_IMG}';">
<div>
<div class="author-name">${article.author}</div>
<div class="card-date">${formattedDate}</div>
</div>
</div>
</div>
<div class="fc-card-content">
${decodeShortcodes(article.content) || '暂无内容'}
</div>
`;
return card;
}

let lastScrollTop = 0;

const handleScroll = btf.throttle(() => {
if (isRendering || isLoading) return;

const container = document.querySelector('#masonry-container'); // 替换成你实际容器的选择器
if (!container) return;

const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;

const isScrollingDown = scrollTop > lastScrollTop;
lastScrollTop = scrollTop;

if (!isScrollingDown) return;

const rect = container.getBoundingClientRect();
const distanceToBottom = rect.bottom - window.innerHeight;

if (distanceToBottom <= 200) {
loadMoreArticles();
}
}, 150);

const showLoading = (show) => {
loadingElement.style.display = show ? 'block' : 'none';
}

const updateCacheTimeDisplay = (timestamp) => {
if (!timestamp) {
cacheTimeElement.textContent = '--:--:--';
return;
}

const expireDate = new Date(timestamp + config.CACHE_DURATION);
const formattedTime = expireDate.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
cacheTimeElement.textContent = `${formattedTime}`;
}

loadData();

btf.addEventListenerPjax("scroll", window, handleScroll, {passive: true})

refreshBtn.addEventListener('click', btf.debounce(() => {
fetchData(true);
}, 300));

clearCacheBtn.addEventListener('click', btf.debounce(() => {
localStorage.removeItem(config.CACHE_KEY);
fetchData(true);
}, 300));

btf.addGlobalFn('pjaxSendOnce', () => {
if (masonry) {
masonry.destroy();
masonry = null;
}
}, 'removeFcirleMasonry')
}
}
})();
FcircleModule.init();
document.addEventListener('pjax:complete', () => {
FcircleModule.init()
});

最终效果

在以上部署操作完成后,重新编译项目启动即可。

本站实际效果如图所示。
本站实际效果如图所示。

总结

最近 PCDN 真是猖獗得离谱,我自己的个人站点也因此被迫升级到了阿里云的 ESA(感觉跟腾讯的 EdgeOne 差不多一回事)。当时网站带宽直接被刷到 100 多 Mbps,一整天流量干掉了 30 多个 G,直接把我整破防了。 攻击持续了好几天,期间我只要上线域名,就立马给我带宽干超,然后域名立马就下线。封 IP 根本没用,其主要是根据IP代理+伪造的user-agent请求的。最后实在扛不住了,只能上 ESA,幸好上线之后才总算稳下来。
7e9051eade4345ba9bcb2ab488dc9039
说实话,我这种小站点,平时访问量也就那点,纯靠兴趣更新更新博客,结果也被盯上了。真不知道他们图啥,刷我这点内容能干嘛?反正除了恶心人,也没别的解释了。