前言

博客中不可避免地会引用外部链接。默认情况下,点击外部链接会直接跳转到目标站点,用户没有任何心理准备。这存在两个问题:

  1. 用户体验 — 用户无法感知即将离开博客,容易产生困惑
  2. 安全风险 — 不熟悉的外部链接可能带来钓鱼或恶意网站风险

本文将实现一个外部链接安全跳转页面:用户点击任何外部链接后,先跳到一个中间提示页,展示目标地址和安全警告,确认后才跳转。效果类似 V2EX、知乎的外链跳转机制。


实现思路

整体方案采用构建时重写 + 静态跳转页

  1. Hexo Filter — 在 HTML 渲染完成后,通过 after_render:html 钩子扫描所有 <a> 标签,将外部链接重写为 /go/?url=<encoded_url>
  2. 静态跳转页 — 在 source/go/ 生成一个页面,通过 JS 解析 URL 参数,展示目标信息并实现跳转
  3. 样式隔离 — 通过内联 CSS 隐藏主题的导航栏、侧边栏等元素,使跳转页成为独立的全屏页面

这种方案是纯静态的,兼容 GitHub Pages 和 Vercel 等静态托管平台,无需服务器支持。


一、创建 Hexo Filter 脚本

scripts/external-link-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
const cheerio = require("cheerio");

hexo.extend.filter.register("after_render:html", function (html) {
const siteUrl = hexo.config.url || "";
const siteHost = siteUrl ? new URL(siteUrl).hostname : "";
const excludedHosts = [siteHost, "localhost"].filter(Boolean);

const $ = cheerio.load(html, null, false);

$("a[href]").each(function () {
const el = $(this);
const href = el.attr("href");
if (!href) return;
// 跳过非HTTP链接和已重写的链接
if (/^\/go\/|^#|^mailto:|^javascript:|^tel:/.test(href)) return;

let linkHost;
try {
if (/^https?:\/\//i.test(href)) {
linkHost = new URL(href).hostname;
} else if (/^\/\/[^/]/.test(href)) {
linkHost = new URL("https:" + href).hostname;
} else {
return;
}
} catch (e) {
return;
}

// 跳过自身域名
if (excludedHosts.some((h) => linkHost === h || linkHost.endsWith("." + h))) return;

// 重写链接
el.attr("href", "/go/?url=" + encodeURIComponent(href));
if (!el.attr("target")) el.attr("target", "_blank");
if (!el.attr("rel")) el.attr("rel", "noopener noreferrer");
});

return $.html();
});

关键设计:

  • 使用 cheerio 而非正则表达式解析 HTML,更可靠地处理各种边界情况
  • 通过 hexo.config.url 自动获取站点域名,排除自身链接
  • mailto:javascript:tel: 等非 HTTP 协议链接不做处理
  • 自动为外部链接添加 target="_blank"rel="noopener noreferrer"

Hexo 会自动加载 scripts/ 目录下的 JS 文件,无需额外配置。


二、创建跳转页面

source/go/index.md 创建跳转页面。页面由三部分组成:内联样式、HTML 结构、内联脚本。

2.1 内联样式 — 隐藏主题元素

1
2
3
4
5
6
7
8
9
10
11
<style>
html,body,#body-wrap,#page,#content-inner,#article-container{
height:100%!important;margin:0!important;padding:0!important;
overflow:hidden!important;background:#F5F5F5!important;max-width:100%!important
}
#nav,#footer,#sidebar,#rightside,.page-title,#page-header{display:none!important}
[data-theme="dark"] #body-wrap,[data-theme="dark"] #page,
[data-theme="dark"] #content-inner,[data-theme="dark"] #article-container{
background:#1a1a2e!important
}
</style>

通过内联 <style> 立即隐藏主题的导航栏、侧边栏、页脚等元素,避免页面加载时出现闪烁。同时将所有父容器的背景色统一为 #F5F5F5,消除白边。

2.2 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
<div class="go-wrap">
<div class="go-card-glow">
<div class="go-card">
<div class="go-icon"><!-- 外部链接 SVG 图标 --></div>
<h1 class="go-title">您即将离开本站,跳转到:</h1>
<div class="go-url-box">
<code class="go-url" id="go-url"></code>
<button class="go-copy-btn" id="go-copy" title="复制链接">
<!-- 复制图标 SVG -->
</button>
</div>
<div class="go-warn-group">
<p class="go-warn"><!-- 警告图标 --> 请自行确认链接安全性</p>
<p class="go-warn"><!-- 警告图标 --> 请注意账号和财产安全</p>
</div>
<div class="go-actions">
<a class="go-btn go-btn-primary" id="go-continue" href="#">继续访问</a>
<a class="go-btn go-btn-secondary" id="go-back" href="#">取消跳转</a>
</div>
<div class="go-error" id="go-error">
<p>链接无效或缺失</p>
<a class="go-btn go-btn-secondary" href="/">返回首页</a>
</div>
</div>
</div>
</div>

2.3 内联脚本

1
2
3
4
5
6
7
8
9
10
11
(function(){
document.title = "Lay - 安全中心";
var params = new URLSearchParams(window.location.search);
var rawUrl = params.get("url");

// 无URL参数时显示错误
if (!rawUrl) { /* 隐藏正常内容,显示错误提示 */ return; }

var decodedUrl = decodeURIComponent(rawUrl);
// 显示目标URL、绑定复制/跳转/取消事件
})();

注意事项: Markdown 渲染器(CommonMark 规范)在 HTML 块内遇到空行时,会将后续缩进内容识别为代码块并转义。因此所有 HTML 内容必须连续书写,不能有空行。


三、页面样式

source/css/go.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
/* 全屏背景 */
.go-wrap {
position: relative;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
min-height: 100dvh;
background: #F5F5F5;
}

/* 卡片悬浮效果 */
.go-card {
background: #FDFDFD;
border-radius: 18px;
box-shadow:
0 1px 3px rgba(0,0,0,0.04),
0 8px 24px rgba(0,0,0,0.06),
0 20px 44px rgba(0,0,0,0.04);
transition: transform 0.35s, box-shadow 0.35s;
}

.go-card-glow:hover .go-card {
transform: translateY(-4px);
box-shadow:
0 2px 4px rgba(0,0,0,0.04),
0 12px 32px rgba(0,0,0,0.08),
0 28px 56px rgba(0,0,0,0.06);
}

/* 渐变动画边框 */
.go-card-glow {
background: linear-gradient(135deg, #cbd5e1, #e2e8f0, #cbd5e1, #e2e8f0);
background-size: 300% 300%;
animation: borderGlow 8s ease infinite;
}

完整样式文件还包含暗色模式([data-theme="dark"])和 prefers-reduced-motion 适配。


四、注入样式

_config.butterfly.ymlinject.head 中添加 CSS 引用:

1
2
3
inject:
head:
- <link rel="stylesheet" href="/css/go.css">

五、工作流程

1
2
3
4
5
6
7
8
9
10
用户点击外部链接

Hexo 构建时已将 href 重写为 /go/?url=<encoded_url>

浏览器跳转到 /go/ 中间页

JS 解析 url 参数,展示目标地址

用户点击"继续访问" → 跳转到目标站点
用户点击"取消跳转" → 关闭当前标签页

总结

通过 Hexo Filter + 静态跳转页的方案,实现了:

  • 构建时重写 — 使用 cheerio 解析 HTML,可靠地识别和重写外部链接
  • 中间提示页 — 展示目标地址、安全警告,用户确认后才跳转
  • 样式隔离 — 内联 CSS 隐藏主题元素,避免加载闪烁和布局抖动
  • 悬浮交互 — 卡片上浮 + 阴影扩散,提供悬停反馈
  • 暗色模式 — 完整的深色主题适配
  • 纯静态方案 — 无需服务器,兼容所有静态托管平台