前言

美化之前

美化的时候需要修改网站的源文件、添加样式、js之类的,需要一点基础,可以参考

参考链接

修改方案

整体由四部分组成,按顺序实现即可:

文件 作用
scripts/safego.js Hexo 脚本:替换 HTML 中的外链 + 生成 go.html
themes/butterfly/layout/includes/page/safego.pug 中转页面模板
source/static/css/safego.css 中转页样式
source/static/js/safego-open.js 拦截 window.open 的外链

1. 添加 safego.js 脚本

新建 [blogRoot]\scripts\safego.js。Hexo 会自动加载 scripts/ 目录下的脚本,无需额外配置。

核心逻辑就两步:

  1. 注册 after_render:html 过滤器,遍历所有 <a> 标签,把外链改成 /go.html?u=xxx
  2. 注册 generator,生成 go.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
'use strict';

const cheerio = require('cheerio');
const path = require('path');
const pug = require('pug');

// 从 _config.yml 中读取 hexo_safego 配置
const config = hexo.config.hexo_safego || {};

const general = config.general || {};
const enable = general.enable || false;
const enableBase64 = general.enable_base64_encode !== false;
const enableTargetBlank = general.enable_target_blank !== false;

const security = config.security || {};
const urlParamName = security.url_param_name || 'u';
const htmlFileName = security.html_file_name || 'go.html';
const ignoreAttrs = security.ignore_attrs || ['data-fancybox'];

const scope = config.scope || {};
const applyContainers = scope.apply_containers || ['#article-container'];
const applyPages = scope.apply_pages || ['/posts/'];
const excludePages = scope.exclude_pages || [];

const whitelist = config.whitelist || {};
const domainWhitelist = whitelist.domain_whitelist || [];

const appearance = config.appearance || {};
const title = appearance.title || "June's Blog";
const subtitle = appearance.subtitle || '安全中心';
const countdownTime = appearance.countdowntime !== undefined ? appearance.countdowntime : 5;

const debug = config.debug || {};
const debugEnable = debug.enable || false;

if (!enable) return;

/** 判断是否外部链接 */
function isExternalLink(url, siteUrl) {
if (!url) return false;
if (url.startsWith('#') || url.startsWith('/') || url.startsWith('./') || url.startsWith('../')) return false;
if (url.startsWith('javascript:') || url.startsWith('mailto:') || url.startsWith('tel:')) return false;
try {
return new URL(url).hostname !== new URL(siteUrl).hostname;
} catch (e) {
return false;
}
}

/** 是否在白名单 */
function isWhitelisted(url) {
return domainWhitelist.some(domain => url.includes(domain));
}

/** 是否需要忽略(按属性) */
function shouldIgnore($link) {
return ignoreAttrs.some(attr => $link.attr(attr) !== undefined);
}

/** 判断页面路径是否生效 */
function isApplyPage(pagePath) {
if (excludePages.some(p => pagePath.includes(p))) return false;
if (!applyPages.length || applyPages.includes('/')) return true;
return applyPages.some(p => pagePath.includes(p));
}

// 关键 1:替换 HTML 中的外链
hexo.extend.filter.register('after_render:html', function (str, data) {
const pagePath = '/' + (data.path || '');
if (!isApplyPage(pagePath)) return str;

const $ = cheerio.load(str, { decodeEntities: false });
const siteUrl = hexo.config.url;
const containers = applyContainers.length ? applyContainers.join(',') : 'body';
let count = 0;

$(containers).find('a[href]').each(function () {
const $link = $(this);
const href = $link.attr('href');
if (!href) return;
if (!isExternalLink(href, siteUrl)) return;
if (isWhitelisted(href)) return;
if (shouldIgnore($link)) return;

const encoded = enableBase64 ? Buffer.from(href).toString('base64') : href;
$link.attr('href', `/${htmlFileName}?${urlParamName}=${encodeURIComponent(encoded)}`);
$link.attr('rel', 'external nofollow noopener noreferrer');
if (enableTargetBlank) $link.attr('target', '_blank');
count++;
});

if (debugEnable && count) hexo.log.info(`[safego] ${pagePath} 替换 ${count} 个外链`);
return $.html();
});

// 关键 2:生成 go.html
hexo.extend.generator.register('safego', function () {
const themeConfig = hexo.config.theme_config || {};
const siteAvatar = (themeConfig.avatar && themeConfig.avatar.img) || '/img/favicon.png';
const templatePath = path.join(hexo.theme_dir, 'layout', 'includes', 'page', 'safego.pug');
const html = pug.compileFile(templatePath, { pretty: false })({
siteAvatar, title, subtitle, urlParamName, enableBase64, countdownTime
});
return { path: htmlFileName, data: html };
});

hexo.log.info('[safego] 安全跳转插件已加载');
注意

cheerio 和 pug 是 Butterfly 自带依赖,一般不用单独装;如果报错 Cannot find module,执行一次 npm i cheerio pug --save 即可。

2. 添加 safego.pug 中转页模板

新建 [blogRoot]\themes\butterfly\layout\includes\page\safego.pug

页面就是普通 HTML,关键有三点:

  1. meta name="robots" content="noindex,nofollow" 防止搜索引擎收录中转页
  2. <head> 里同步 localStorage 的主题,避免暗色模式闪屏
  3. URL 参数解码(base64 / encodeURIComponent 二选一)+ 倒计时
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
doctype html
html(lang="zh-CN")
head
meta(charset="UTF-8")
meta(name="viewport" content="width=device-width, initial-scale=1.0")
meta(name="robots" content="noindex,nofollow")
link(rel="icon" href=siteAvatar type="image/webp")
title #{title} - #{subtitle}
link(rel="stylesheet" href="/static/css/safego.css")
//- 同步暗色模式,避免闪烁
script.
!function(){
var theme;
try {
var item = localStorage.getItem('theme');
if (item) { var parsed = JSON.parse(item); theme = parsed.value; }
} catch(e) {}
if (theme === 'dark') {
document.documentElement.className = 'dark';
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.className = 'dark';
}
}();
body
script.
if(document.documentElement.className==='dark')document.body.classList.add('dark');

.wrapper
.header
img.avatar#safego-avatar(alt="avatar" decoding="async")
p.site-title #{title}
p.site-subtitle #{subtitle}

.card
p.url-text 您即将离开本站,跳转到:
.url-box
span.url-content#jump-url 加载中...
button.copy-btn#copy-btn(title="复制链接")
svg(viewBox="0 0 24 24")
rect(x="9" y="9" width="13" height="13" rx="2" ry="2")
path(d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1")
p.countdown-text#countdown-text
.progress-bar
.progress#progress
.button-container
button.button.cancel-button(onclick="goBack()") 取消跳转
button.button.confirm-button(onclick="goTarget()") 立即跳转

#safego-toast.safego-toast

script.
window.addEventListener('load', function() {
var avatar = document.getElementById('safego-avatar');
if (avatar) avatar.src = '#{siteAvatar}';
});

(function() {
var params = new URLSearchParams(window.location.search);
var encodedUrl = params.get('#{urlParamName}');
var targetUrl = '';

if (encodedUrl) {
try {
!{enableBase64 ? "targetUrl = decodeURIComponent(escape(atob(encodedUrl)));" : "targetUrl = decodeURIComponent(encodedUrl);"}
} catch(e) {
targetUrl = encodedUrl;
}
}

var urlEl = document.getElementById('jump-url');
var countdownEl = document.getElementById('countdown-text');
var progressEl = document.getElementById('progress');
var copyBtn = document.getElementById('copy-btn');

urlEl.textContent = targetUrl || '无效的链接';
urlEl.title = targetUrl;

function showToast(text) {
var toast = document.getElementById('safego-toast');
toast.textContent = text;
toast.classList.add('show');
setTimeout(function() { toast.classList.remove('show'); }, 2500);
}

copyBtn.addEventListener('click', function() {
if (targetUrl && navigator.clipboard) {
navigator.clipboard.writeText(targetUrl).then(function() {
showToast('链接复制成功!快去分享吧!');
});
}
});

window.goTarget = function() { if (targetUrl) window.location.href = targetUrl; };
window.goBack = function() {
window.close();
setTimeout(function() {
if (window.history.length > 1) window.history.back();
else window.location.href = '/';
}, 100);
};

// 倒计时
var countdown = #{countdownTime};
if (countdown > 0 && targetUrl) {
countdownEl.innerHTML = '<span class="icon">⏳</span>' + countdown + ' 秒后将自动跳转';
progressEl.style.width = '100%';
var timer = setInterval(function() {
countdown--;
if (countdown <= 0) {
clearInterval(timer);
countdownEl.innerHTML = '<span class="icon">🚀</span>正在为您跳转...';
progressEl.style.width = '0%';
window.location.href = targetUrl;
} else {
countdownEl.innerHTML = '<span class="icon">⏳</span>' + countdown + ' 秒后将自动跳转';
progressEl.style.width = (countdown / #{countdownTime} * 100) + '%';
}
}, 1000);
setTimeout(function() {
progressEl.style.width = ((countdown - 1) / #{countdownTime} * 100) + '%';
}, 50);
} else if (countdown <= 0) {
countdownEl.innerHTML = '<span class="icon">🔒</span>请确认链接安全后点击跳转';
progressEl.parentElement.style.display = 'none';
}

// 后退时 bfcache 恢复
window.addEventListener('pageshow', function(e) {
if (e.persisted) window.location.reload();
});
})();
小提醒

中转页是独立页面(不走 Butterfly layout),所以它的 CSS 不能放在主题的 _custom/ 下,要单独放在 source/static/css/,由 Pug 模板用 <link> 引入。这一条在项目结构约定里也明确写过。

3. 添加 safego.css 跳转页样式

新建 [blogRoot]\source\static\css\safego.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
* { margin: 0; padding: 0; box-sizing: border-box; }

body {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
flex-direction: column;
background: #f2f3f5;
font-family: 'HYTMR', -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}

.wrapper {
display: flex;
flex-direction: column;
align-items: center;
animation: fadeUp 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}

/* 头像 + 标题(卡片外) */
.header { text-align: center; margin-bottom: 28px; }
.avatar { width: 88px; height: 88px; border-radius: 50%; margin: 0 auto 16px; display: block; object-fit: cover; }
.site-title { font-size: 22px; font-weight: bold; color: #333; margin-bottom: 6px; }
.site-subtitle { font-size: 13px; color: #aaa; letter-spacing: 3px; }

/* 内容卡片 */
.card {
text-align: center;
padding: 32px 36px 36px;
border-radius: 22px;
width: 480px;
max-width: 92vw;
background: rgba(255,255,255,0.92);
backdrop-filter: blur(10px);
box-shadow: 0 8px 40px rgba(0,0,0,0.06), 0 2px 8px rgba(0,0,0,0.04);
}

.url-text { font-size: 15px; color: #555; margin-bottom: 18px; }

.url-box {
display: flex;
align-items: center;
background: #f6f7f9;
border: 1px solid #eaeaea;
border-radius: 12px;
padding: 14px 16px;
margin-bottom: 20px;
gap: 10px;
}
.url-box .url-content {
flex: 1; font-size: 14px; color: #444;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
text-align: left;
}
.url-box .copy-btn {
flex-shrink: 0; width: 36px; height: 36px;
border: 1px solid #e0e0e0; border-radius: 8px;
background: #fff; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: all 0.2s;
}
.url-box .copy-btn:hover { border-color: #E68282; background: #fef5f5; }
.url-box .copy-btn svg { width: 18px; height: 18px; fill: none; stroke: #888; stroke-width: 2; }
.url-box .copy-btn:hover svg { stroke: #E68282; }

/* 倒计时 + 进度条 */
.countdown-text { font-size: 13px; color: #777; margin-bottom: 16px; }
.countdown-text .icon { margin-right: 4px; }
.progress-bar { width: 100%; height: 8px; border-radius: 6px; overflow: hidden; margin-bottom: 24px; background: #eef0f3; }
.progress { width: 100%; height: 100%; background: linear-gradient(90deg, #E68282, #f0a8a8); transition: width 1s linear; }

/* 按钮 */
.button-container { display: flex; gap: 16px; }
.button {
flex: 1; padding: 14px 0;
border-radius: 12px; border: none;
cursor: pointer; font-size: 15px; font-weight: bold;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.button:hover { transform: translateY(-2px); }
.cancel-button { background: #eef0f3; color: #555; }
.cancel-button:hover { background: #e4e6ea; box-shadow: 0 4px 12px rgba(0,0,0,0.06); }
.confirm-button { background: linear-gradient(135deg, #E68282, #d97070); color: #fff; }
.confirm-button:hover { box-shadow: 0 6px 20px rgba(230,130,130,0.35); }

/* Toast */
.safego-toast {
position: fixed; top: 24px; right: 24px;
background: #000000aa; color: #fff;
padding: 18px 24px; border-radius: 12px;
font-size: 14px; min-width: 288px;
opacity: 0; transform: translateY(-20px);
transition: opacity 0.3s, transform 0.3s;
backdrop-filter: blur(10px);
z-index: 9999;
}
.safego-toast.show { opacity: 1; transform: translateY(0); }

/* 暗色模式 */
body.dark { background: #1a1a2e; }
body.dark .site-title { color: #eee; }
body.dark .site-subtitle { color: #888; }
body.dark .card { background: rgba(28,32,48,0.92); }
body.dark .url-text { color: #ccc; }
body.dark .url-box { background: #252a3a; border-color: #3a3f50; }
body.dark .url-box .url-content { color: #ddd; }
body.dark .countdown-text { color: #999; }
body.dark .progress-bar { background: #2a2f40; }
body.dark .cancel-button { background: #2a2f40; color: #ccc; }
body.dark .confirm-button { background: linear-gradient(135deg, #f2b94b, #d4a03a); }
body.dark .progress { background: linear-gradient(90deg, #f2b94b, #f5cc6a); }

/* 移动端 */
@media (max-width: 520px) {
.card { padding: 24px 20px 28px; }
.avatar { width: 72px; height: 72px; }
.site-title { font-size: 19px; }
.button { font-size: 14px; padding: 12px 0; }
}

4. 拦截 window.open 的外链

<a> 标签那一层在 hexo generate 阶段就处理掉了,但运行时通过 window.open(url) 弹出的外链(比如评论系统、第三方组件、自己写的 JS)漏网了。再补一个钩子:

新建 [blogRoot]\source\static\js\safego-open.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* safego-open.js
* Hook window.open 拦截外链跳转(需在 head 中最早加载)
*/
!function () {
var o = window.open;
window.open = function (url, target, features) {
if (url && typeof url === 'string' && url.indexOf('http') === 0) {
try {
if (new URL(url).hostname !== location.hostname && url.indexOf('june-pj.cn') === -1) {
url = '/go.html?u=' + btoa(unescape(encodeURIComponent(url)));
}
} catch (e) {}
}
return o.call(window, url, target, features);
};
}();

记得在 _config.butterfly.ymlinject 段把它加到 head 最前面(必须在其他可能调用 window.open 的脚本之前):

1
2
3
inject:
head:
- <script src="/static/js/safego-open.js"></script>

5. 配置项

最后回到 _config.yml,加上 hexo_safego 这一段,所有行为都靠它驱动:

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
hexo_safego:
# 基本功能
general:
enable: true # 启用插件
enable_base64_encode: true # 用 Base64 编码 URL,防止特殊字符
enable_target_blank: true # 替换后的链接默认新窗口打开

# 安全配置
security:
url_param_name: 'u' # URL 参数名:/go.html?u=xxx
html_file_name: 'go.html' # 中转页文件名
ignore_attrs: # 这些属性的链接不替换
- 'data-fancybox' # 灯箱图片不能拦

# 生效范围
scope:
apply_containers: # 容器选择器,body 表示全页面
- 'body'
apply_pages: # '/' 表示全站;也可改成 '/posts/' 只处理文章
- '/'
exclude_pages: # 友链页直接放行(友链本来就要点出去)
- '/link/'
- '/fcircle/'

# 白名单(这些域名不替换)
whitelist:
domain_whitelist:
- 'june-pj.cn'
- 'blog.june-pj.cn'

# 跳转页外观
appearance:
avatar: /img/favicon.png # 头像
title: "June's Blog" # 标题
subtitle: '安全中心' # 副标题
darkmode: auto # true / false / auto
countdowntime: 10 # 倒计时秒数;负数则关闭自动跳转

# 调试
debug:
enable: false # true 时控制台会打印每个页面替换了多少个链接

几个坑

1. data-fancybox 一定要排除

Butterfly 的图片灯箱靠 fancybox,图片外链如果被替换了,灯箱就打不开了,会变成跳到 go.html 显示一张图片地址。ignore_attrs 加上 data-fancybox 解决。

2. 友链页 / 朋友圈页排除

/link//fcircle/ 这种页面整页都是外链,本来用户就是来点出去的,全部加跳转反而很烦。直接 exclude_pages 排掉。

3. base64 解码兼容性

中转页里解码时用了 decodeURIComponent(escape(atob(encodedUrl))) 这个老写法,是为了兼容 URL 里包含中文的情况(比如带中文 query 参数的链接)。直接 atob() 中文会乱码。

4. 暗色模式闪屏

中转页是独立页面,不会被 Butterfly 的主题切换脚本接管。我在 <head> 里写了一段 inline JS,从 localStorage.theme 读取主题、立刻给 <html> 加上 dark class,能避免页面先白后黑的闪烁。

5. window.open 必须 head 里最早加载

如果 safego-open.js 比业务脚本晚加载,那些早执行的 window.open 调用就拦不住了。_inject.yml 里把它放到 head 段,并且关闭 pjax 重新执行(已经是全局 hook,不需要每次 pjax 都跑)。


到这一步,整个安全跳转就闭环了:构建期处理静态外链,运行期 hook window.open,跳转页有动画、有倒计时、有暗色、能复制能取消。如果想再进一步,可以把白名单做成正则匹配,或者把跳转页加上风险等级判断(比如调云服务的 URL 安全检测接口),按需扩展。