给Hexo和留言添加跳转中间页

安然无恙,各位;

前言

新年快乐!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 结构,示例如下(你需要看自己的):

<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除非改样式一般不用。

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

<!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文件,分别复制一下代码:

(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
            });
        }
    };
})();
(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引用就行,这里是我主题所以配置文件是这个名字,根据自己的博客主题找到主题配置引用就行:

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本身的功能,其实这玩意更多只是给访客一个提示,我能做的就是该提示提示了,有问题把链接留言下来我给他处理了就好!

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

小站最新装修
小站最新装修