1 从“单篇阅读”到“连续阅读”
我的博客和传统个人博客有个较大的不同之处,就是存在不少“连续性”很强的系列文章:比如“Cloudflare 系列”、“声音的觉醒”系列,也包括本文所处的“为博客构建‘轻量级知识索引’”系列。
而随着这类内容越来越多,我也开始逐渐意识到:对于博客而言,“让用户进入其中一篇文章”其实并不算特别困难,真正困难的,反而是如何让用户在读完一篇文章后,愿意继续往下阅读。
尤其是很多用户,本身并不是从博客首页进入,而是通过搜索引擎、转载链接、甚至 AI 搜索摘要等方式,“偶发性”进入某一篇文章。这时候,如果文章本身缺乏明确的结构关系与阅读路径,那么用户往往会在看完当前内容后直接关闭页面,从而达成“跳出率 100%”的成就。
而对于系列文章来说,这其实是一件很可惜的事情。因为系列文章本身天然就具备较强的上下文关系:前一篇负责铺垫,后一篇负责延伸,文章之间并不是彼此独立的“信息碎片”,而更像是一条连续展开的知识路径。
但传统博客系统对于这种“连续阅读关系”的支持其实并不算好,虽然 WordPress 本身也存在“上一篇 / 下一篇”功能,不过它更多是基于发布时间顺序,而不是基于真正的内容结构关系。
至于一些现成的“系列文章”插件,虽然能够针对此类文章提供一些类似”系列文章导航”的功能,但通常也会引入额外的数据结构、后台管理逻辑,以及相对较重的插件依赖。而这,又回到了我在之前文章中提到过的问题(参见:后话:为什么我没有选择现成的“相关文章”插件),我并不太希望自己的博客越来越依赖“功能型插件”来完成这些结构能力。
所以,这个“系列文章导航”的想法,其实一直存在,只是过去始终没有找到一个足够轻量、同时又足够自然的实现方式,因此被我搁置了下来。
直到现在,随着“article-index.json”的出现,我突然发现:原来很多过去必须依赖数据库查询、插件系统甚至复杂后台配置才能完成的功能,现在已经可以通过一个轻量级的索引文件,在 WordPress 运行时非常轻松地实现出来了,而“系列文章导航”,就是其中一个非常典型的例子。
2 “系列关系”与“语义关系”的区别
在上一篇文章中(参见:为博客构建“轻量级知识索引(三):基于预计算语义索引的 WordPress 轻量推荐系统设计与实现),我们已经基于 article-index.json,实现了博客中的“相关文章”功能。
不过,在真正开始研究“系列文章导航”功能时,我很快发现:虽然其和“相关文章”功能最终都表现为“文章之间存在关联”,但它们背后的关系类型,其实并不一样。
对于现在的“相关文章”功能而言,它本质上仍然属于一种“强关联关系”。也就是说,这些关联关系并不是随机生成的,而是经过预处理之后,主动写入 article-index.json 中的,例如:
“14257”: {
“title”: “xxx”,
“related”: [
“14244”,
“14228”,
“13534”
]
}
这里的 related,本质上其实已经属于一种“人工筛选后的内容关系”,它更像是一种:“看完当前这篇文章后,适合继续阅读哪些内容” 。因此,它虽然也属于“文章关系”的一种,但它本身并不存在严格的顺序结构。
而“系列文章导航”则完全不同,因为“系列关系”本质上属于一种“结构关系(Structure Edge)”。所谓结构关系,指的是:文章之间存在明确的前后顺序;存在连续性的阅读路径;当前文章在整个系列中具有确定的位置。
例如:《声音的觉醒(一)》、《声音的觉醒(二)》、《声音的觉醒(三)》,这里的关系,并不是:“这些文章彼此相关”,而是:“这些文章本身就是同一条阅读路径上的连续节点”。这种关系,其实比普通“相关文章”更加确定:因为“相关文章”更多是一种:内容延伸、阅读推荐、相关主题;而“系列关系”则是:阅读顺序、结构定位、上下文延续,两者解决的问题并不一样。
也正因为如此,我很快意识到:“系列文章导航”其实并不需要重新建立一套新的复杂系统。因为它既不需要额外数据库结构,也不需要复杂后台配置。真正需要的,仅仅只是:当前文章属于哪个系列、当前位于系列中的第几篇以及同系列中还有哪些文章。
而这些信息,其实完全可以直接基于 article-index.json 来完成。因为 article-index.json 本身就已经承担了“博客文章统一索引层”的角色。只要能够通过标题规则识别出:
- 系列名称(series_key)
- 当前序号(series_index)
那么剩下的问题其实就会变得非常简单:
- 遍历 article-index.json
- 找出同 series_key 的文章
- 按序号排序
- 定位当前文章位置
- 得到上一篇与下一篇
整个过程甚至不再需要数据库复杂查询。
换句话说:“系列文章导航”虽然看起来是一个新的功能,但它本质上其实只是 article-index.json 这层轻量级知识索引能力的一次自然延伸。
3 基于标题规则的系列识别机制
在明确“系列关系”和“语义关系”的区别之后,接下来要解决的问题就变得非常具体了:系统到底是如何判断一篇文章属于某个系列的?
在没有引入插件系统、没有额外数据库结构的前提下,其实可选方案并不多。而最终我选择的方式也比较直接——从文章标题本身提取结构信息。
原因也很简单:在实际写作中,系列文章往往天然就带有非常明显的命名规律,比如“声音的觉醒(一)(二)(三)”,这些标题本身并不是随机命名的,而是已经在表达一种隐含的结构关系,只是这个关系还没有被系统化利用。如果从这个角度来看,其实所谓“系列识别”,本质上并不是去“创建关系”,而是去“识别已经存在的关系”。
因此这里的核心思路就是从标题中提取两个最关键的信息:series_key 和 series_index。前者用于判断是否属于同一个系列,后者用于确定它在这个系列中的位置:series_key 的作用可以理解为“系列的身份标识”。只要 series_key 相同,那么这些文章就天然属于同一个集合。而 series_index 则更像是这个集合内部的顺序编号,用来表达先后关系,比如第一篇、第二篇、第三篇。
有了这两个信息之后,一个系列的结构就已经具备了基本的可计算性。但现实情况并不会总是那么整齐统一,不同文章在不同阶段可能会使用不同的命名方式,比如中文数字、阿拉伯数字,或者像 Part 1、Part 2 这样的英文形式。如果不做处理,这些差异会导致同一个系列被拆分成多个不连续的集合。
所以在实现上,需要对标题进行一定的归一化处理。通过简单的正则规则,把不同形式的编号统一转换为同一种数值结构,比如把“一、二、三”和“1、2、3”统一映射为整数序号。这个过程并不复杂,但它的意义在于让“人为的写作习惯”可以稳定映射为“系统可计算的结构”。完成这一层解析之后,下一步才是真正进入数据层的处理,也就是借助 article-index.json 来完成全局匹配。
article-index.json 在这里的角色并不是存储关系,而是作为一个轻量级的文章索引表存在。它记录了博客中所有文章的基本信息,包括标题、URL,以及文章 ID。这使得系统可以在运行时遍历所有文章,然后对每一篇文章执行同样的 series 识别逻辑。
当识别完成之后,只需要筛选出 series_key 相同的文章,再按照 series_index 进行排序,一个完整的系列结构就自然形成了。
到这里,系列的识别就不再依赖任何复杂的外部系统,而是完全由“标题规则 + 轻量索引”共同完成。它的本质其实是把原本隐藏在写作习惯中的结构关系显式化,并且让系统可以在运行时稳定地重建这种关系。
这一层做完之后,后面的事情就会变得非常直接:如何在 PHP 中利用这个已经结构化的系列结果,计算当前文章的位置,并生成上一篇和下一篇的导航关系。
4 系列文章导航的渲染以及 PHP 实现
在 UI 上,我并没有把系列导航设计成一个复杂的列表,而是尽量保持轻量化表达,只保留三个核心信息:系列名称、当前进度(例如 4 / 8)、以及上一篇 / 下一篇的跳转入口,显示效果类似:
📚 系列文章:声音的觉醒(4 / 8)← 上一篇 下一篇 →
这样做的原因很简单:系列导航的核心价值并不是展示全部内容,而是提供“继续阅读的路径”。如果把整个系列列表完整展开,反而会打断当前阅读流。
在 WordPress 的实现方式上,这一块通过 the_content filter 来完成插入。具体来说,是在内容输出过程中动态拼接一个 HTML 片段,然后插入到文章正文中。
这里有一个比较关键但容易忽略的细节:插入位置的顺序设计。在当前博客结构中,除了系列导航之外,还有一个“内容结构提示”模块,它用于引导用户进入博客的知识地图体系:

如果从纯技术角度来看,这两个模块都可以简单地 append 到文章末尾,但从阅读体验角度来看,这样的顺序其实是不理想的。
最终的设计是:系列文章导航优先于内容结构提示出现。也就是说,在文章正文之后,用户首先看到的是“当前系列的连续阅读路径”(所处位置如下图红框所示),然后才是“内容结构提示”:

这个顺序并不是随意的,而是一个非常明确的阅读优先级选择:系列导航解决的是“继续读同一条线性内容”的问题,而结构提示解决的是“跳出当前内容体系,进入全局知识结构”的问题。
在用户还处于阅读状态时,优先提供连续性路径,会更符合自然阅读行为;而当用户阅读完成之后,再提供结构化入口,则更像是一个“退出时的导航补充”。
因此,在实现上,通过控制 the_content 的 filter 优先级,以及 HTML 拼接顺序,最终保证了 Series Card 始终出现在内容结构提示之前,从而形成一个从“局部连续性”到“全局结构性”的过渡。
基于第3章的思路,PHP 实现如下(和前一篇文章一样,article-index.json 位于主题目录:wp-content/themes/argon-theme-master/cache/article-index.json):
function extract_series_info(title) {
// 去掉中文全角空格title = trim(str_replace(' ', ' ', title));
/*
* 匹配形式:
* xxx(一)
* xxx(二)
* xxx(十)
*/
if (preg_match('/^(.*?)[((]([一二三四五六七八九十百零]+)[))]/u',title, matches)) {series_key = trim(matches[1]);map = [
'一' => 1,
'二' => 2,
'三' => 3,
'四' => 4,
'五' => 5,
'六' => 6,
'七' => 7,
'八' => 8,
'九' => 9,
'十' => 10
];
index_cn =matches[2];
// 简单处理:支持一~十
index = isset(map[index_cn]) ?map[index_cn] : 0;
if (index > 0) {
return [
'series_key' => series_key,
'series_index' =>index
];
}
}
/*
* 匹配形式:
* xxx (1)
* xxx Part 1
* xxx part 2
*/
if (preg_match('/^(.*?)(?:Part\s*|part\s*|\()(\d+)\)?/u', title,matches)) {
return [
'series_key' => trim(matches[1]),
'series_index' => intval(matches[2])
];
}
return null;
}
function add_series_card_after_content(content) {
if (is_single() && in_the_loop() && is_main_query()) {
// 只处理普通文章
if (get_post_type() != 'post') {
returncontent;
}
// 当前文章 ID
current_id = get_the_ID();
// article-index.json 路径json_path = get_template_directory() . '/cache/article-index.json';
if (!file_exists(json_path)) {
returncontent;
}
json = file_get_contents(json_path);
if (!json) {
returncontent;
}
articles = json_decode(json, true);
if (!articles || !isset(articles[current_id])) {
returncontent;
}
// 当前文章标题
current_title =articles[current_id]['title'];
// 提取系列信息current_series = extract_series_info(current_title);
// 不是系列文章
if (!current_series) {
return content;
}series_key = current_series['series_key'];
// 收集同系列文章series_articles = [];
foreach (articles asid => article) {
if (!isset(article['title'])) {
continue;
}
series_info = extract_series_info(article['title']);
if (!series_info) {
continue;
}
// 同系列
if (series_info['series_key'] === series_key) {series_articles[] = [
'id' => id,
'title' =>article['title'],
'url' => article['url'],
'index' =>series_info['series_index']
];
}
}
// 系列数量不足2篇,不显示
if (count(series_articles)<2) {
returncontent;
}
// 按序号排序
usort(series_articles, function(a, b) {
returna['index'] <=> b['index'];
});
// 定位当前文章current_position = 0;
total = count(series_articles);
foreach (series_articles asi => article) {
if ((string)article['id'] === (string)current_id) {current_position = i;
break;
}
}
// 上一篇 / 下一篇prev_article = null;
next_article = null;
if (current_position > 0) {
prev_article =series_articles[current_position - 1];
}
if (current_position < total - 1) {next_article = series_articles[current_position + 1];
}
// UI(超紧凑版)
series_card = '
<div class="series-card" style="
margin:16px 0;
">
<div style="
display:inline-flex;
align-items:center;
gap:12px;
flex-wrap:wrap;
padding:6px 12px;
background:#f8fafc;
border:1px solid #e5e7eb;
border-radius:8px;
font-size:13px;
line-height:1.2;
color:#374151;
">
<span style="
font-weight:600;
color:#111827;
white-space:nowrap;
">
📚 系列文章:' . esc_html(series_key) . '(' . (current_position + 1) . ' / ' .total . ')
</span>
';
// 上一篇
if (prev_article) {series_card .= '
<a href="' . esc_url(prev_article['url']) . '" style="
color:#2563eb;
text-decoration:none;
font-weight:500;
white-space:nowrap;
">
← 上一篇
</a>
';
} else {series_card .= '
<span style="
color:#9ca3af;
white-space:nowrap;
">
← 第一篇
</span>
';
}
// 下一篇
if (next_article) {series_card .= '
<a href="' . esc_url(next_article['url']) . '" style="
color:#2563eb;
text-decoration:none;
font-weight:500;
white-space:nowrap;
">
下一篇 →
</a>
';
} else {series_card .= '
<span style="
color:#9ca3af;
white-space:nowrap;
">
最后一篇 →
</span>
';
}
series_card .= '
</div>
</div>';
// 放在内容结构提示前面
returncontent . series_card;
}
returncontent;
}
add_filter('the_content', 'add_series_card_after_content', 5);
最终实现效果展示:

注:”系列文章导航”卡片只会在系列文章中才会显示。
5 一个轻量但完整的阅读结构补丁
从实现层面看,“系列文章卡片”本质上只是基于 article-index.json 和标题规则做的一次运行时计算,没有引入新的数据结构,也没有增加额外的系统复杂度。它更像是在现有博客结构之上补上的一个很小的功能层。
但它的意义并不在代码本身,而是在于补齐了一种长期缺失的阅读体验:同一系列文章之间的连续性。在它出现之前,系列关系更多停留在写作层面,用户即使进入同一系列的某一篇文章,也往往需要依赖“相关文章”或者返回列表页,或者直接搜索系列名称进行查询才能继续阅读,路径是不明确的,甚至是跳跃式的。现在通过”系列文章卡片”中上一篇和下一篇的结构,这种状态被改变了:阅读不再依赖“再次选择”,而是自然地沿着既定顺序向前推进。
从实现方式上看,这一层能力也尽可能保持了轻量。article-index.json 提供全站文章索引,标题规则负责提取 series_key 与 series_index,PHP 在运行时完成最小必要计算,WordPress 通过 the_content filter 负责最终输出。
整个过程没有引入额外的数据库结构,也没有复杂的推荐逻辑,本质上只是利用已有信息重建了一条“顺序关系”。
如果放到整个博客体系中来看,这一层结构目前是独立存在的,它完全基于 article-index.json 构建,没有依赖额外的语义系统,也没有引入更复杂的关联计算。
article-index.json 在这里承担的是全站文章索引的角色,而”系列文章卡片”则是在这个索引之上,额外补充了一种“顺序关系”。换句话说,它并不关心内容是否相似,也不关心主题是否相关,它只关心一件事情:是否属于同一个系列,以及在这个系列中的前后位置。
在当前阶段,这已经足够形成一个完整的阅读路径体验。
这倒是个不错的思路,回头研究借鉴一下。
话说才发现你居然换了配色呢
换配色是因为文章正文下面新增”相关文章”功能的背景块不突出,颜色怎么改都差点意思,所以干脆把主题的配色改了来配合,这才和谐了一点。美观什么的,是我最不擅长的了。