Qexo 说说 API 适配 AnZhiYu 主题

前言

AnZhiYu 主题自带的 essay(即刻短文)功能是基于本地 source/_data/essay.yml 静态渲染的,每次发说说都要修改配置文件重新构建部署,非常麻烦。而 Qexo 作为 Hexo 博客的在线管理后台,提供了 /pub/talks/ 公开 API,可在后台随时发布说说,无需重新构建。

本教程讲解如何用前端 JS 动态拉取 Qexo 说说 API,并适配 AnZhiYu 主题的 essay 时间线样式。

前置条件

  1. 首先 你得有Qexo
  2. 其次 你得有Qexo
  3. 最后 你得有Qexo

一、Qexo 说说 API 说明

Qexo 提供的公开 API 端点:

1
GET https://你的Qexo域名/pub/talks/?limit=每页条数&page=页码

返回数据结构(JSON):

1
2
3
4
5
6
7
8
9
10
11
{
"status": true,
"count": 17,
"data": [
{
"content": "<p>说说正文HTML</p><img src=\"https://...\">",
"time": "1690982400",
"tags": ["生活", "随笔"]
}
]
}
字段类型说明
statusboolean请求是否成功
countnumber说说总条数
dataarray当前页说说数组
data[].contentstring说说正文(HTML,可能含<p>/<br>/<img>
data[].timestringUnix 时间戳(秒)
data[].tagsarray标签数组

二、修改 essay 页面模板

编辑 themes/anzhiyu/layout/includes/page/essay.pug,将原来的静态渲染区域替换为动态容器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
if site.data.essay
each i in site.data.essay
#essay_page
.author-content.author-content-item.essayPage.single(style = i.top_background ? `background: url(${i.top_background}) left 28% / cover no-repeat;` : "")
.card-content
.author-content-item-tips=i.title
span.author-content-item-title=i.subTitle
.content-bottom
.tips=i.tips
.banner-button-group
a.banner-button(href=i.buttonLink)
i.anzhiyufont.anzhiyu-icon-arrow-circle-right
span.banner-button-text=i.buttonText
#bber
section.timeline.page-1
ul#waterfall.list
#bber-tips(style='color: var(--anzhiyu-secondtext);')
="- 加载中... -"
#essay-load-more-wrap(style='text-align: center; margin-top: 1.5rem;')
button#essay-load-more(style='padding: 8px 24px; border-radius: 20px; border: var(--style-border-always); background: var(--anzhiyu-card-bg); color: var(--anzhiyu-fontcolor); cursor: pointer; font-size: 14px; transition: all .3s;') 加载更多
script(data-cfasync="false", src=url_for('/js/essay_qexo.js') + '?v=4')

关键点

  • ul#waterfall.list 是动态渲染容器,初始为空
  • #bber-tips 显示加载状态
  • #essay-load-more 是分页加载按钮
  • data-cfasync="false" 防止 Cloudflare Rocket Loader 干扰脚本执行顺序
  • ?v=4 是版本号,更新脚本后递增强制浏览器刷新缓存

三、编写动态拉取脚本

source/js/essay_qexo.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
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
/**
* essay 页面动态拉取 qexo 说说 (分页加载)
* 数据源: https://cms.346247.xyz/pub/talks/
* 渲染为 AnZhiYu 主题 essay 时间线样式 (li.bber-item)
*/
(function () {
var QEXO_API = "https://你的Qexo域名/pub/talks/";
var PAGE_SIZE = 9; // 每页条数 (3列布局下3的倍数填满整行)
var currentPage = 1;
var totalCount = 0;
var loadedCount = 0;
var loading = false;

// HTML 转义(属性用)
function escapeAttr(s) {
return String(s).replace(/"/g, """).replace(/'/g, "'");
}

// 时间戳格式化
function formatDate(ts) {
var d = new Date(parseInt(ts, 10) * 1000);
var Y = d.getFullYear();
var M = ("0" + (d.getMonth() + 1)).slice(-2);
var D = ("0" + d.getDate()).slice(-2);
return Y + "/" + M + "/" + D;
}

// 构建单条说说的 HTML
function buildItemHTML(item) {
var tmp = document.createElement("div");
tmp.innerHTML = item.content || "";

// 提取所有图片 src (含 data-src 兼容懒加载)
var imgList = [];
var imgs = tmp.querySelectorAll("img");
for (var i = 0; i < imgs.length; i++) {
var src = imgs[i].getAttribute("src") || imgs[i].getAttribute("data-src") || "";
if (src) imgList.push(src);
}

// 移除图片节点, 保留文本结构
while (imgs.length > 0) {
imgs[0].parentNode.removeChild(imgs[0]);
imgs = tmp.querySelectorAll("img");
}
// 清理空 p 标签
var emptyP = tmp.querySelectorAll("p");
for (var j = 0; j < emptyP.length; j++) {
if (!emptyP[j].textContent.trim() && emptyP[j].children.length === 0) {
emptyP[j].parentNode.removeChild(emptyP[j]);
}
}
var contentHTML = tmp.innerHTML.trim();

var html = '<div class="bber-content">';
if (contentHTML) {
html += '<div class="datacont">' + contentHTML + '</div>';
}
if (imgList.length > 0) {
html += '<div class="bber-container-img">';
imgList.forEach(function (src) {
html += '<a class="bber-content-img" href="' + escapeAttr(src) + '" target="_blank" data-fancybox="gallery" data-caption="">' +
'<img src="' + escapeAttr(src) + '" alt=""></a>';
});
for (var k = imgList.length; k < 4; k++) {
html += '<div class="bber-content-noimg"></div>';
}
html += '</div>';
}
html += '</div><hr>';

// 底部信息
html += '<div class="bber-bottom"><div class="bber-info"><div class="bber-info-time">';
html += '<i class="anzhiyufont anzhiyu-icon-clock"></i>';
var dt = new Date(parseInt(item.time, 10) * 1000).toISOString();
html += '<time class="datatime" datetime="' + dt + '">' + formatDate(item.time) + '</time>';
html += '</div>';
if (item.tags && item.tags.length) {
item.tags.forEach(function (tag) {
html += '<span class="bber-info-from"><i class="anzhiyufont anzhiyu-icon-fw-fire"></i><span>' + escapeAttr(tag) + '</span></span>';
});
}
html += '</div></div>';
return html;
}

function loadPage(page) {
if (loading) return Promise.resolve();
loading = true;
var btn = document.getElementById("essay-load-more");
if (btn) { btn.textContent = "加载中..."; btn.disabled = true; }

var url = QEXO_API + "?limit=" + PAGE_SIZE + "&page=" + page;
return fetch(url)
.then(function (r) { return r.json(); })
.then(function (res) {
if (!res || !res.status || !res.data || !res.data.length) {
if (page === 1) updateTips();
loading = false;
if (btn) btn.disabled = false;
return;
}
totalCount = res.count || res.data.length;
var waterfall = document.getElementById("waterfall");
if (!waterfall) { loading = false; return; }

if (page === 1) waterfall.innerHTML = "";

res.data.forEach(function (item) {
var li = document.createElement("li");
li.className = "bber-item";
li.innerHTML = buildItemHTML(item);
waterfall.appendChild(li);
});

loadedCount += res.data.length;
waterfall.classList.add("show"); // 主题 CSS 默认 opacity:0
updateTips();
updateLoadMoreBtn();
loading = false;
if (btn) btn.disabled = false;
})
.catch(function (e) {
console.error("说说加载失败:", e);
var tips = document.getElementById("bber-tips");
if (tips && page === 1) tips.textContent = "- 说说加载失败 -";
loading = false;
if (btn) { btn.disabled = false; btn.textContent = "加载失败, 点击重试"; }
});
}

function init() {
currentPage = 1;
loadedCount = 0;
totalCount = 0;
loadPage(currentPage);
bindLoadMore();
}

function bindLoadMore() {
var btn = document.getElementById("essay-load-more");
if (!btn || btn.dataset.bound) return;
btn.dataset.bound = "1";
btn.addEventListener("click", function () {
if (loading) return;
currentPage++;
loadPage(currentPage);
});
}

if (document.readyState !== "loading") init();
else document.addEventListener("DOMContentLoaded", init);

// pjax 兼容
document.addEventListener("pjax:complete", function () {
setTimeout(init, 50);
});
})();

四、关键适配点解析

1. CSS 类名对应

脚本渲染的 HTML 必须严格匹配主题 essay 页面的 CSS 类名:

类名作用
li.bber-item单条说说卡片容器
.bber-content内容区(文字+图片)
.datacont文字内容
.bber-container-img图片容器
.bber-content-img单张图片(带 Fancybox)
.bber-content-noimg空位占位(凑齐 4 格)
.bber-bottom底部信息栏
.bber-info-time时间
.bber-info-from标签

2. 显示控制:.show

主题 CSS 默认 #waterfallopacity: 0 隐藏的,必须加 .show 类才会显示:

1
waterfall.classList.add("show");

3. 三列布局对齐

主题 CSS 中 #waterfall 用 flex 布局,li.bber-item 宽度为 calc((100% - 2 * 1rem) / 3),间距用 gap: 1rem 控制。

坑点:如果给 li.bber-itemmargin-right: 1rem,会导致第三列被挤到下一行。必须用父容器 gap 控制间距,不要给子项加 margin。

4. 图片处理逻辑

Qexo 说说的 content 字段是 HTML,图片可能混在文本中。脚本逻辑:

  1. 用临时 div 解析 HTML
  2. 提取所有 imgsrc(兼容 data-src 懒加载)
  3. 从 DOM 中移除图片节点
  4. 清理图片原来所在的空 <p> 标签
  5. 图片单独放入 .bber-container-img 容器
  6. 不足 4 张时用 .bber-content-noimg 占位凑齐网格

5. Pjax 兼容

主题开启 Pjax 后页面切换不会刷新,需监听 pjax:complete 事件重新初始化:

1
2
3
document.addEventListener("pjax:complete", function () {
setTimeout(init, 50); // 延迟等 DOM 就绪
});

6. Cloudflare Rocket Loader 兼容

如果博客套了 Cloudflare CDN 且开启了 Rocket Loader,会改变脚本加载顺序导致报错。引用脚本时加 data-cfasync="false" 排除 Rocket Loader 处理:

1
script(data-cfasync="false", src=url_for('/js/essay_qexo.js') + '?v=4')

五、首页即刻说说适配(看自己需求)

AnZhiYu 主题首页顶部有个”即刻短文”滚动条组件,默认从 source/_data/essay.yml 读取静态数据。

改为动态拉取最新1条

编辑 source/js/home_essay_bar.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
(function () {
var QEXO_API = "https://你的Qexo域名/pub/talks/?limit=1";

function render() {
var bberTalk = document.getElementById("bber-talk");
if (!bberTalk) return;

fetch(QEXO_API)
.then(function (r) { return r.json(); })
.then(function (res) {
if (!res || !res.status || !res.data || !res.data.length) return;
bberTalk.innerHTML = "";
var item = res.data[0];
var a = document.createElement("a");
a.className = "li-style swiper-slide";
a.href = "javascript:void(0);";
var tmp = document.createElement("div");
tmp.innerHTML = item.content || "";
var text = (tmp.textContent || "").trim();
if (tmp.querySelector("img")) text += " [图片]";
if (!text) text = "...";
a.textContent = text;
bberTalk.appendChild(a);
})
.catch(function (e) { console.error("首页说说加载失败:", e); });
}

if (document.readyState !== "loading") render();
else document.addEventListener("DOMContentLoaded", render);

document.addEventListener("pjax:complete", function () {
setTimeout(render, 50);
});
})();

关闭循环播放

编辑 themes/anzhiyu/source/js/utils.js,找到 initIndexEssay 函数,关闭 swiper 的 loop 和 autoplay:

1
2
3
4
5
6
7
let essay_bar_swiper = new Swiper(".essay_bar_swiper_container", {
passiveListeners: true,
direction: "vertical",
loop: false, // 关闭循环
autoplay: false, // 关闭自动播放
mousewheel: false,
});

六、essay.yml 配置

即使说说用动态拉取,source/_data/essay.yml 仍然需要保留(控制顶部 banner 展示):

1
2
3
4
5
6
7
8
9
- title: 即刻短文
subTitle: 咸鱼的日常生活。
tips: 随时随地,分享生活
buttonText: 关于我
buttonLink: /about/
limit: 50
home_essay: true
top_background: https://api.yppp.net/pc.php
essay_list: [] # 留空, 由 JS 动态填充

八、总结

通过本教程,你可以:

  1. 摆脱静态构建:在 Qexo 后台随时发说说,无需重新部署博客
  2. 完美适配主题:渲染样式与原版 essay 页面完全一致
  3. 分页加载:首次填满页面,按需加载更多
  4. Pjax 兼容:页面切换无刷新
  5. CDN 兼容:规避 Cloudflare Rocket Loader 干扰

核心思路就是:保留主题的页面骨架和 CSS,只把数据源从本地 yml 换成 Qexo API。这个思路同样适用于其他 Hexo 主题(如 Butterfly、Volantis)的说说/短文功能适配 可以丢给AI这个文章作参考