安然无恙,各位;

前言

新年快乐!2026年的第一笔~

这段时间难得的一个五天假期,就打算折腾下自己的小站,改动最多的就是安全方面的事情了,由于个人的想法比较多,过度敏感,对这方面做的比较离谱。

现在经常看着有博友不少因为留言的问题被请去喝茶谈心的,连忙转头看看自己的,发现也有不少奇怪的链接,以前也没注意这方面问题,靠自己一点点审查的话很麻烦,就打算弄一个中间页提示。首先在网上找了一下,发现修改的都是hexo文章和构建页面的跳转中间页,而且有的实现起来有些麻烦,当然是指对于不懂的人来说,有很多专业术语或者是技术层面的代沟的“对牛弹琴”,

当然,可能没什么实际作用,大家当个乐子看看就行。

插件

不过还是有一个不错的插件实现的:安全跳转页面·插件版 | LiuShen’s Blog 柳哥在很早之前就弄了一个插件一键完成的。

效果如下:支持白昼黑夜主题、

liushen的实现方法就不是简单的使用外挂JS进行实现,因为检测的内容是直接对HTML内容进行检测,并不会经过JavaScript,于是liushen就开发了这么一个插件:hexo-safego

插件功能

  • 外部链接跳转:将外部链接替换为自定义的跳转页面,可以自定义文件名称,增加安全性。
  • 灵活配置:支持多个容器,使用css选择器进行选择、支持白名单域名和生效页面路径的配置。
  • Base64 编码:可选将外链链接编码为Base64加密,在跳转时再使用js转为正常网站地址,增强安全性能。
  • 调试模式:调试模式输出详细信息,便于开发和调试。
  • 自定义页面:支持设置标题、副标题、头像、暗黑模式,如果有前端基础,还可以自己定义跳转页面进行美化。

留言

但是,柳哥的插件只是支持hexo构建的页面,对于twikoo等外嵌的留言系统来说是没作用的,在探索了一下变量,参考了空白Koobai老麦笔记的文章终于造出了一堆史山代码。

期间出现了名字跳转或内容跳转失效,如果使用有问题或者其他系统自行看F12看,这个问题的本质是昵称链接的 DOM 选择器不匹配 + 异步加载的昵称链接未被脚本捕获,其他系统我没尝试过各位可以自行测试。

用鼠标点击评论区的昵称,或者Ctrl+Shift+C,此时开发者工具会定位到昵称对应的 HTML 代码;查看昵称链接的 HTML 结构,示例如下(你需要看自己的):

1
<a href="https://koxiuqiu.cn" target="_blank" rel="noopener noreferrer" class="tk-nick tk-nick-link"><strong>朽丘秋雨</strong></a>
可放心食用,一键CV复制粘贴即用!

实现如下:

前往/source目录创捷一个html文件,命名:link.html,当然你可以自己改:

PS: 小提示

如果发现hexo g 构建public目录不存在,可以自己再复制一份过去到public也可以,不使用hexo clean就不用操作了,我个人的习惯就是直接hexo g&d两件套了,clean除非改样式一般不用。

样式参考的是柳哥的(其实一模一样的样式),还是挺喜欢柳哥的样式审美的。

link.html

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
389
390
<!DOCTYPE html>
<html lang="zh">
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
<link rel="icon" href="/xiaoke.png" type="image/x-icon">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="robots" content="noindex, nofollow"/>
<title>😃页面加载中,请稍候...</title>
<style type="text/css">
body {
overflow: hidden;
height: 100vh;
width: 100vw;
margin: 0;
padding: 0;
transition: background 0.3s ease-in-out;
}
.container {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
margin: 0;
flex-direction: column;
}
.avatar-placeholder, .avatar {
width: 100px;
height: 100px;
border-radius: 50%;
margin-bottom: 15px;
display: block;
}
.avatar {
display: none;
}
.description {
font-size: 20px;
font-weight: 600;
}
.subtitle {
font-size: 15px;
margin-bottom: 20px;
color: #C4C4C4;
}
.loading {
text-align: center;
padding: 30px;
border-radius: 25px;
animation: fadein 2s;
width: 450px;
max-width: 80%;
transition: all 0.3s ease-in-out;
}
@keyframes fadein {
from { opacity: 0 }
to { opacity: 1 }
}
.content {
margin-bottom: 20px;
}
.url-text {
margin-bottom: 10px;
font-size: 16px;
letter-spacing: 1px;
}
.jump-url {
position: relative;
font-size: 20px;
display: block;
margin-top: 5px;
margin-bottom: 25px;
padding: 15px;
border-radius: 18px;
height: 25px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.copy-btn-container {
position: absolute;
display: flex;
align-items: center;
right: 10px;
top: 50%;
transform: translateY(-50%);
height: 100%;
width: 63px;
flex-direction: row-reverse;
}
.copy-btn {
width: 40px;
height: 40px;
border-radius: 12px;
border: 1px solid #a4a4a4;
transition: all 0.3s ease-in-out;
cursor: pointer;
}
.copy-btn-container svg {
width: 25px;
height: 25px;
fill: #888;
}
.countdown-text {
margin-top: 12px;
font-size: 12px;
}
.button-container {
display: flex;
justify-content: center;
gap: 20%;
margin-top: 20px;
}
.button {
padding: 10px 20px;
border-radius: 16px;
border: none;
cursor: pointer;
font-size: 16px;
width: 120px;
height: 40px;
}
.cancel-button {
color: black;
}
.confirm-button {
color: white;
}
/* 白天模式 */
body.light {
background: linear-gradient(135deg, #E9E9E9, #FFFFFF);
}
body.light .loading {
border: 1px solid #ccc;
background: rgba(255,255,255,0.7);
box-shadow: 0 16px 32px rgba(0,0,0,0.1);
}
body.light .loading:hover {
box-shadow: 0 16px 32px rgba(0,0,0,0.2);
}
body.light .url-text {
color: #333;
}
body.light .jump-url {
border: 1px solid #ccc;
background-color: #F7F9FE;
color: #333;
}
body.light .copy-btn-container {
background: linear-gradient(to left, #F7F9FE 75%,transparent 100%);
}
body.light .copy-btn {
background-color: #F7F9FE;
}
body.light .copy-btn:hover {
box-shadow: 0 16px 32px rgba(100,100,100,0.2);
}
body.light .countdown-text {
color: #515151;
}
body.light .cancel-button {
background-color: #a6e3e9;
}
body.light .confirm-button {
background-color: #3fc1c9;
}
/* 黑夜模式 */
body.dark {
background: linear-gradient(135deg, #364f6b, #222831);
}
body.dark .loading {
border: 1px solid #777;
background: #393e46;
color: #EFEFEF;
box-shadow: 0 16px 32px rgba(100,100,100,0.1);
}
body.dark .loading:hover {
box-shadow: 0 16px 32px rgba(100,100,100,0.2);
}
body.dark .description {
color: #F3F3F3;
}
body.dark .url-text, body.dark .countdown-text {
color: #EFEFEF;
}
body.dark .jump-url {
border: 1px solid #777;
background-color: #333;
color: #EFEFEF;
}
body.dark .copy-btn-container {
background: linear-gradient(to left, #333 75%,transparent 100%);
}
body.dark .copy-btn {
background-color: #222831;
}
body.dark .copy-btn:hover {
box-shadow: 0 16px 32px rgba(100,100,100,0.2);
}
body.dark .cancel-button {
background-color: #872C2C;
color: #FFF;
}
body.dark .confirm-button {
background-color: #28566F;
color: #FFF;
}
</style>
<script type="text/javascript">
// 适配Stellar主题的日夜模式
function detectStellarTheme() {
const body = document.body;
const theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
body.classList.add('dark');
body.classList.remove('light');
} else {
body.classList.add('light');
body.classList.remove('dark');
}
}

// 获取URL参数
function GetQueryString(name) {
var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)", "i");
var r = window.location.search.substr(1).match(reg);
return r ? decodeURIComponent(r[2]) : null; // 改用decodeURIComponent,兼容URL编码的参数
}

// 安全解码URL参数(优化容错性)
function decodeSafeUrlParam(paramStr) {
try {
// 修复base64替换逻辑,添加补全等号的处理
const base64 = paramStr.replace(/-/g, '+').replace(/_/g, '/');
// 补全base64缺失的等号
const paddedBase64 = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, '=');
const decodedBinary = atob(paddedBase64);
const decoded = decodeURIComponent(Array.from(decodedBinary).map(c =>
'%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
).join(''));
return decoded;
} catch (e) {
console.warn('URL解码失败,使用原始值:', e);
return paramStr;
}
}

// 跳转逻辑
let jump_url = GetQueryString('url');
// 解码处理
if (jump_url) {
jump_url = decodeSafeUrlParam(jump_url);
}

// 修复核心:正确创建正则表达式对象,添加i忽略大小写
const UrlReg = new RegExp('^((http|https|thunder|qqdl|ed2k|Flashget|qbrowser|ftp|rtsp|mms)://)', 'i');
// 验证URL合法性
if (!jump_url || !UrlReg.test(jump_url)) { // 改用test方法更高效
document.title = '参数错误,正在返回首页...';
jump_url = location.origin;
}

// 手动跳转函数(仅修改此处,从新建窗口改为当前窗口)
function jump() {
location.href = jump_url; // 当前窗口直接跳转
}

// 关闭/返回逻辑
function closeWindow() {
function isWeChat() {
return /MicroMessenger/i.test(navigator.userAgent);
}
function isQQ() {
return /QQ/i.test(navigator.userAgent) && !/MicroMessenger/i.test(navigator.userAgent);
}

if (isWeChat()) {
if (typeof WeixinJSBridge !== "undefined") {
WeixinJSBridge.call('closeWindow');
} else {
document.addEventListener('WeixinJSBridgeReady', function () {
WeixinJSBridge.call('closeWindow');
}, { once: true });
setTimeout(() => fallbackBack(), 500);
}
} else if (isQQ()) {
try {
if (typeof mqq !== "undefined" && mqq.ui && mqq.ui.closeWebView) {
mqq.ui.closeWebView();
} else {
fallbackBack();
}
} catch (e) {
fallbackBack();
}
} else {
fallbackBack();
}
}

// 返回兜底函数
function fallbackBack() {
if (window.history.length <= 1) {
window.location.href = location.origin;
} else {
window.history.back();
}
}

// 复制链接功能
function copyToClipboard() {
const urlText = document.getElementById('jump-url-text').textContent;
// 改用Clipboard API,替代过时的execCommand
navigator.clipboard.writeText(urlText).then(() => {
alert('链接已复制到剪贴板!');
}).catch(() => {
// 降级方案
const tempInput = document.createElement('input');
tempInput.value = urlText;
document.body.appendChild(tempInput);
tempInput.select();
document.execCommand('copy');
document.body.removeChild(tempInput);
alert('链接已复制到剪贴板!');
});
}

// 加载头像
async function loadAvatar() {
const avatarImg = document.querySelector('.avatar');
const placeholder = document.querySelector('.avatar-placeholder');
const img = new Image();
img.src = '/xiaoke.png';
img.onload = function () {
avatarImg.src = img.src;
avatarImg.style.display = 'block';
placeholder.style.display = 'none';
}
img.onerror = function() {
placeholder.style.display = 'none'; // 加载失败隐藏占位符
}
}

// 页面初始化
window.addEventListener('load', function () {
detectStellarTheme();
loadAvatar();
// 显示目标链接
const jumpUrlElement = document.getElementById('jump-url-text');
jumpUrlElement.textContent = jump_url;
// 更新提示文本
const countdownText = document.querySelector('.countdown-text');
countdownText.textContent = "💡请自行确认链接安全性,手动点击跳转";
// 监听主题切换
window.addEventListener('storage', (e) => {
if (e.key === 'theme') detectStellarTheme();
});
});
</script>
</head>
<body>
<div class="container">
<div class="avatar-placeholder"></div>
<img src="" alt="头像" class="avatar">
<div class="description">朽丘秋雨</div>
<div class="subtitle">安全提示</div>
<div class="loading">
<div class="content">
<div class="url-text">您即将离开本站,跳转到:</div>
<div class="jump-url" id="jump-url">
<span id="jump-url-text"></span>
<div class="copy-btn-container">
<button class="copy-btn" onclick="copyToClipboard()">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
<path d="M208 0L332.1 0c12.7 0 24.9 5.1 33.9 14.1l67.9 67.9c9 9 14.1 21.2 14.1 33.9L448 336c0 26.5-21.5 48-48 48l-192 0c-26.5 0-48-21.5-48-48l0-288c0-26.5 21.5-48 48-48zM48 128l80 0 0 64-64 0 0 256 192 0 0-32 64 0 0 48c0 26.5-21.5 48-48 48L48 512c-26.5 0-48-21.5-48-48L0 176c0-26.5 21.5-48 48-48z"/>
</svg>
</button>
</div>
</div>
</div>
<div class="countdown-text">⚡请自行确认链接安全性,手动点击跳转</div>
<div class="button-container">
<button class="button cancel-button" onclick="closeWindow()">取消跳转</button>
<button class="button confirm-button" onclick="jump()">立即跳转</button>
</div>
</div>
</div>
</body>
</html>

source/js (没有js文件夹自行创捷) 创捷一个redirect.jsredirect-1.js文件,分别复制一下代码:

redirect.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
(function() {
var siteDomain = window.location.hostname; // 自动获取本站域名,无需手动改
var redirectPage = '/link.html'; // 中间页路径

// 核心函数:判断是否为外部链接
function isExternalLink(url) {
if (!url || !url.startsWith('http')) return false;
// 排除mailto/tel等非网页链接
if (url.startsWith('mailto:') || url.startsWith('tel:')) return false;
// 排除本站链接
var linkHost = new URL(url).hostname.replace(/^www\./, '');
var siteHost = siteDomain.replace(/^www\./, '');
return linkHost !== siteHost;
}

// 核心函数:替换链接跳转
function replaceLinkHref(link) {
var originalHref = link.getAttribute('href');
if (isExternalLink(originalHref)) {
// 保留原链接的target属性(比如_blank)
var target = link.getAttribute('target') || '_self';
// 核心:替换为中间页跳转
link.setAttribute('href', redirectPage + '?url=' + encodeURIComponent(originalHref));
// 修复:如果原链接是新窗口打开,保持行为
link.setAttribute('target', target);
// 移除可能的onclick冲突
link.onclick = null;
}
}

// 核心函数:处理Twikoo评论区链接(解决异步加载问题)
function handleTwikooLinks() {
// Stellar主题下Twikoo的实际选择器(必对!)
var twikooLinkSelectors = [
'.tk-content a', // 评论内容里的链接(Stellar+Twikoo默认类名)
'.tk-nick a' // 留言人昵称链接(Stellar+Twikoo默认类名)
];

// 遍历并替换链接
twikooLinkSelectors.forEach(function(selector) {
var links = document.querySelectorAll(selector);
links.forEach(function(link) {
replaceLinkHref(link);
});
});
}

// 核心:页面加载后执行 + 监听Twikoo异步加载
window.onload = function() {
// 初始加载的评论链接
handleTwikooLinks();

// 监听Twikoo评论区DOM变化(解决异步加载无效问题)
var twikooContainer = document.getElementById('twikoo');
if (twikooContainer) {
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.addedNodes.length > 0) {
handleTwikooLinks(); // 新评论加载后重新处理链接
}
});
});
observer.observe(twikooContainer, {
childList: true,
subtree: true
});
}

// 兼容Twikoo官方回调(双重保障)
if (window.twikoo) {
twikoo.init({
onCommentLoaded: handleTwikooLinks
});
}
};
})();

redirect-1.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
(function() {
var siteDomain = window.location.hostname;
var redirectPage = '/link.html';

function isExternalLink(url) {
if (!url || !url.startsWith('http')) return false;
if (url.startsWith('mailto:') || url.startsWith('tel:')) return false;
var linkHost = new URL(url).hostname.replace(/^www\./, '');
var siteHost = siteDomain.replace(/^www\./, '');
return linkHost !== siteHost;
}

function replaceLinkHref(link) {
var originalHref = link.getAttribute('href');
if (originalHref.includes(redirectPage + '?url=')) return;
if (isExternalLink(originalHref)) {
var target = link.getAttribute('target') || '_self';
link.setAttribute('href', redirectPage + '?url=' + encodeURIComponent(originalHref));
link.setAttribute('target', target);
link.onclick = null;
}
}

function handleTwikooLinks() {
var nicknameSelector = '.tk-nick';

var twikooLinkSelectors = [
nicknameSelector,
'.tk-content a'
];

twikooLinkSelectors.forEach(function(selector) {
var links = document.querySelectorAll(selector);
links.forEach(function(link) {
replaceLinkHref(link);
});
});
}

function initObserver() {
var twikooContainer = document.getElementById('twikoo');
if (!twikooContainer) return;

var observer = new MutationObserver(function() {
handleTwikooLinks();
});
observer.observe(twikooContainer, {
childList: true,
subtree: true
});
}

window.addEventListener('load', function() {
handleTwikooLinks();
initObserver();
});
})();

最后引入JS样式,_config.stellar.yml找到inject引用就行,这里是我主题所以配置文件是这个名字,根据自己的博客主题找到主题配置引用就行:

1
2
3
4
5
6
7
inject:
head:
- <link rel="stylesheet" href="...">
script:
- ...
- <script src="/js/redirect.js"></script> #twikoo跳转中间页
- <script src="/js/redirect-1.js"></script> #twikoo跳转中间页

杂记

后续又调整修复了中间页的BUG,如跳转链接会本站、显示错误…

其他主题通用,只是修改了留言系统部分。

当然,首先twikoo本身来说不是hexo本身的功能,其实这玩意更多只是给访客一个提示,我能做的就是该提示提示了,有问题把链接留言下来我给他处理了就好!

欢迎交流,写这类的文比较生疏,有什么缺漏问题可以提出。