给 Sakurairo 写了个「延伸阅读」插件,顺带修掉一个查询放大的坑

Zou, Ning 发布于 2 小时前 技术实践 2500 字 25 次阅读


我的博客是 WordPress 搭的,跑 Sakurairo(iro)主题。前段时间做 SEO 体检,本来只想看看描述、标签、图片 alt 这些常规项,结果查到一个更根本的问题——我这 57 篇文章之间,几乎没有互相链接。

Image

站内链接倒是有:每篇文章底部挂着分类和标签,点进去是归档页。但文章跟文章之间,除了"上一篇/下一篇"这条线性的链,再没有别的路径。搜索引擎判断一个站"在某个领域有没有成体系的内容",靠的恰恰是页面之间的链接关系。我这情况在它眼里,大概就是五十多个孤岛。

顺手一查更确认了:全站没有任何"相关文章"模块。抽了 12 篇文章看正文内链的锚文本,出现最多的居然是作者头像那个链接(「Zou, Ning」,17 次)。

得想办法把这层关系补上。

先试常规办法,两条路都堵死

最省事的当然是主题自带。Sakurairo 功能挺全,先去设置里翻了一遍——没有相关文章这个开关。不死心,把主题的配置项全导出来数了一遍,一共 120 项,从"樱花飘落特效"到"页脚一言"都有,就是没有相关文章。

那就装个插件?思路没错,但我这边情况有点特殊:我是通过 MCP 协议让工具直接操作后台的,而这条通道没有安装插件的能力。装插件得我自己去 wp-admin 点。

再想还有别的入口吗——侧边栏小工具?查了一下,Sakurairo 的文章页根本没有侧栏(整页 <aside> 出现 0 次),小工具压根渲染不出来。这条也不通。

三条路都堵死了。

第一版:往正文里硬塞

既然没有现成的,那就先手动做。思路很土:把「延伸阅读」这段 HTML 直接追加到文章正文末尾。

相关文章怎么定?按标签和分类算:

  • 先取出当前文章的标签和分类
  • 拿这些标签/分类去查候选文章
  • 给每个候选打分:共享标签数 × 2 + 共享分类数 × 1
  • 取分数最高的三条

样式方面不想跟主题打架,所以配色没写死。Sakurairo 会把主色暴露成 CSS 变量,用 var(--theme-skin-matching) 取就行。这点后面再讲。

先在一篇文章上试了一把,效果不错——三条链接里两条都是"共享 2 个标签"的最高分匹配。

但这么干有个问题:它是静态的。今天塞进去的内容,明天发了新文章不会自己更新;而且五十多篇文章的正文都被改了一遍,以后想调整还得进正文 HTML 里翻。临时能用,长期不行。

那就自己写一个插件

想清楚之后决定写个插件——不改正文,纯动态计算,新文章发布后自动进关联池。

顺便把"手工运维"能用到的东西一起做了。

设置页(18 项) 里最有用的是这几个:

  • 共享标签权重 / 共享分类权重——默认 2 和 1,可调。想严格点就把分类权重改成 0,只认标签。
  • 最低共享数——要求至少共享 N 项才推荐。设成 1 就能过滤掉只靠"补充策略"凑数的弱关联。
  • 排除 noindex 文章——不把内链指向已经设了 noindex 的页面,免得把权重送出去。它会自己去读 Slim SEO / Yoast / Rank Math 的字段。
  • 同分候选轮换——分数相同的候选按"年-周"轮换顺序。同一周内稳定,跨周自然变化,避免长期只推同样几篇。

文章编辑页右侧面板:可以单篇关闭,也可以手动指定要推荐的几篇。下面还会列出"当前自动匹配的前 5 条",一眼就能看出算得对不对。

设置页的「诊断」:输入文章 ID,会告诉你它会匹配到哪些文章、每条共享了几个标签几个分类。不满意就知道该去补什么标签。

样式怎么跟着主题走

这里有个意外发现。

本来打算把主色写死成 #a4cdf6(Sakurairo 的一个默认配色),后来翻主题设置项的时候看到一项叫 extract_theme_skin_from_cover,值是 true——意思是主色会从当前文章的封面图里提取

也就是说,同一套样式,在每篇文章上的强调色其实是不一样的。

那写死颜色就错了。改成 var(--theme-skin-matching, #a4cdf6) 之后,色块会自动跟着那篇的封面主色走,跟页面其它元素不打架。

暗色模式我也没去猜主题的类名——那是浏览器端 JS 切换的,爬虫根本抓不到。直接用继承色加中性半透明底:

.xzrp-rel {
  background: rgba(128,128,128,.07);
  border: 1px solid rgba(128,128,128,.16);
}
.xzrp-rel li a { color: inherit; }
Image

亮色深色两种模式都成立,也不用关心主题怎么实现的。

踩坑:一页跑了几十次查询

功能跑通之后顺手过了一遍代码,发现一个挺蠢的问题。

相关度算完要排序,排序的第三优先级是"按发布时间新"。取时间用的是 get_post_time()

'score' => $score,
'date'  => (int) get_post_time( 'U', true, $cid ),  // 对每一篇候选都调一次

get_post_time() 内部会走 get_post()。而前面查候选文章的时候,我用了:

'fields' => 'ids',   // 只取 ID,不加载完整 post 对象

**fields => 'ids' 意味着 WP_Query 不会预热 post 缓存。** 于是这个 get_post() 每次都得真去查库。

候选池默认 60 篇——光这一步就是 60 次查询。

同样的问题还有几处:给每条相关文章取共享标签名的时候,又各查了两次术语;按排除分类过滤候选的时候,对每篇候选各查一次。

我这站文章少、数据库在本地(环境:WordPress 7.0.4 / PHP 8.3.22 / MariaDB 10.6),实测响应时间看着还行——文章页中位数 0.21 秒——所以功能上没暴露出来。但这属于明显的写法问题:查询次数会随文章数线性增长,文章一多就难看了。

修法不复杂,核心就一句:先批量预热,再在内存里算。

// 取出候选之后,一次性把 post 对象和 meta 灌进缓存
_prime_post_caches( $candidates, false, true );

// 术语也一次性批量取回,写进请求级缓存
wp_get_object_terms( $candidates, array( 'post_tag', 'category' ),
                     array( 'fields' => 'all_with_object_id' ) );

预热之后,后面所有 get_the_title()get_permalink()get_post_time()wp_get_post_terms()get_post_meta() 全部命中缓存,不再产生查询。

插件内部我加了一层 XZRP_Engine::terms() 作为统一入口,并且把话写进注释了:**新增代码一律用它取术语,不要直接调 wp_get_post_terms()**,否则又会退化成 N+1。

改完之后,一页的查询从"随候选数增长"变成固定的三批预热,跟文章总数没关系了。

为了以后能自己验证,我在后台「诊断」里加了一行输出,直接显示本次计算实际消耗了多少次查询。哪天这个数字突然变高,就说明有别的插件或主题在干扰对象缓存。

实测

  • 全站 58 篇文章逐页抓渲染结果核对:每篇都是 1 个块,无缺无重,每篇 3 条内链,HTML 标签全部闭合
  • 首页、分类归档页、RSS 里都不输出(只在该出现的地方出现)
  • 页面体积增加 1.9 KB,占整页 2.23%
  • 响应时间中位数 0.21 秒,跟不跑插件的分类归档页(0.20 秒)基本持平

另外一个意外的好结果:插件只在真实页面输出,REST 接口和编辑器读到的原文里都没有它。原因是入口处有 in_the_loop() && is_main_query() 判断,而 REST 请求没有主循环。

这样一来,Google 抓页面能看到内链,但数据库和 API 一个字节都没被污染——以后我用工具做正文级操作,不会被它干扰。

Image

小结

这次折腾下来,几个经验算是记住了:

  1. 先搞清楚问题在哪,再谈优化。 我一开始只是想做常规 SEO 体检,要不是顺手看了眼内链,可能一直在调描述和标题这些细枝末节。
  2. 没有现成方案时,自己写反而更贴。 通用插件也能做相关文章,但它不知道我的主题会从封面图取色,也不知道站里哪些页面设了 noindex。自己写这些都能照顾到。
  3. **fields => 'ids' 不预热缓存,这坑挺容易踩。** 几十次查询在我这个体量上看不出任何异常,但换个文章量大的站就是另一回事了。写完顺手过一遍查询逻辑,比等它出问题再查便宜得多。
  4. 别硬编码主题的东西。 那个 extract_theme_skin_from_cover 要是我没翻到,大概率就把颜色写死了,之后每篇文章的色块都跟封面打架。

现在 58 篇文章底部都能看到「延伸阅读」,色块跟着各自的封面主色走,新文章发布也会自动进关联池。这事儿就算告一段落了。

项目成果

最后附上打包好的插件:

欢迎来到XiaoZou123,这里是一个电脑极客、数码爱好者网站。我平时喜欢关注数码新闻,研究计算机技术。如果你看我头像觉得我是二次元,那我其实还算不上!
最后更新于 2026-09-13