前言 美化之前 美化的时候需要修改网站的源文件、添加样式、js之类的,需要一点基础,可以参考
🏠 站内链接,放心访问
Hexo博客添加自定义css和js文件
June's Blog
🏠 站内链接,放心访问
Butterfly自用全局变量
June's Blog
参考链接
🔗 站外链接,请注意甄别
修改方案 整体由四部分组成,按顺序实现即可:
文件
作用
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/ 目录下的脚本,无需额外配置。
核心逻辑就两步:
注册 after_render:html 过滤器,遍历所有 <a> 标签,把外链改成 /go.html?u=xxx
注册 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' );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)); } 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 (); }); 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,关键有三点:
用 meta name="robots" content="noindex,nofollow" 防止搜索引擎收录中转页
在 <head> 里同步 localStorage 的主题,避免暗色模式闪屏
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 ); }.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 !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.yml 的 inject 段把它加到 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 enable_target_blank: true security: url_param_name: 'u' html_file_name: 'go.html' ignore_attrs: - 'data-fancybox' scope: apply_containers: - 'body' apply_pages: - '/' 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 countdowntime: 10 debug: enable: false
几个坑 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 安全检测接口),按需扩展。