在 Headless WordPress 项目里,前端往往通过 REST API 做站内搜索:
GET /wp-json/wp/v2/pages?search=sRNA&_fields=id,title,excerpt,content
ElasticPress 已经接管了搜索,Elasticsearch 也返回了命中结果——但打开响应 JSON,你可能会看到一种很迷惑的现象:
content.rendered里有<mark class='ep-highlight'>sRNA</mark>excerpt.rendered有时也有高亮title.rendered却是纯文本,哪怕标题里明明包含sRNA
更诡异的是:你在 class-wp-rest-posts-controller.php 里加一行 add_filter('ep_is_integrated_request', '__return_true'),content 和 excerpt 突然都有了高亮,title 还是没有。
这不是 ES 没干活,而是 ElasticPress 的默认策略 和 WordPress REST 的字段组装方式 叠在一起,制造了两层「高亮丢失」。
高亮本来是怎么工作的
ElasticPress Search 功能会在 Elasticsearch 查询里注入 highlight 子句。命中后,插件在 QueryIntegration 里把高亮片段写回 WP_Query 结果中的 $post 对象:
?search=关键词
→ WP_Query(ep_integrate=true)
→ ES 返回 hit.highlight(post_title、post_content 等)
→ $post->post_title / $post->post_content 被替换为带 <mark> 的 HTML
→ REST Controller 把 $post 序列化为 JSONPHP默认高亮标签是:
<mark class="ep-highlight">搜索词</mark>PHP到这里为止,逻辑很清晰:高亮是写在内存里的 WP_Post 对象上的,不是写进数据库的。
第一层问题:REST 默认不会向 ES 要 highlight
ElasticPress 是否给查询加 highlight 子句,由 Search 功能里的这段逻辑决定:
$add_highlight_clause = apply_filters(
'ep_highlight_should_add_clause',
Utils\is_integrated_request('highlighting', ['public']), // 注意:只有 public
$formatted_args,
$args
);PHPis_integrated_request() 把请求分成 admin、ajax、rest、public 四类。REST API 请求属于 rest,不属于 public。
因此,即便你已经通过 ep_integrate=true 让 REST 搜索走了 Elasticsearch,ES 查询里也可能根本没有 highlight 子句——content 自然不会出现 <mark>。
修复 1:让 REST 搜索也加 highlight 子句
add_filter('ep_highlight_should_add_clause', function (bool $add, array $formatted_args, array $args): bool {
if (defined('REST_REQUEST') && REST_REQUEST && ! empty($args['s'])) {
return true;
}
return $add;
}, 10, 3);PHP这一步解决的是:ES 愿不愿意返回高亮。
第二层问题:就算 ES 返回了高亮,REST 三个字段命运不同
假设 highlight 子句已经加上,ES 也返回了高亮片段,QueryIntegration 已经把 $post->post_content 改好了——REST 响应里三个字段的表现仍然不一致。
原因在于 WP_REST_Posts_Controller::prepare_item_for_response() 对每个字段走了不同的路径:
| 字段 | REST 组装方式 | 能否保留 EP 高亮 |
|---|---|---|
| content.rendered | 直接用 $post->post_content + the_content | ✓ |
| excerpt.rendered | get_the_excerpt → wp_trim_excerpt → wp_trim_words | 需额外处理 |
| title.rendered | get_the_title( $post->ID ) | ✗ |
核心代码对比:
// 保留高亮:用的是 WP_Query 循环里的 post 对象
$data['content']['rendered'] = apply_filters('the_content', $post->post_content);
// 丢失高亮:传的是 ID,不是对象
$data['title']['rendered'] = get_the_title($post->ID);PHPcontent 为什么正常
content 直接读 $post->post_content。这个 $post 就是 EP 刚刚改过的那个对象,高亮自然在。
excerpt 为什么需要第二个开关
WordPress 默认用 wp_trim_words() 生成摘要,会 strip 掉所有 HTML,<mark> 标签一并消失。
ElasticPress 为前台搜索准备了 excerpt 专用 hook(allow_excerpt_html、ep_highlight_excerpt),但它们同样受 is_integrated_request('highlighting', ['public']) 约束。
所以在 REST 搜索场景下,除了修复 1,还需要:
修复 2:在 REST 搜索时启用 EP 集成请求判定
add_action('rest_api_init', function () {
$s = $_REQUEST['search'] ?? $_REQUEST['s'] ?? null;
if ($s) {
add_filter('ep_is_integrated_request', '__return_true', 10, 2);
}
}, PHP_INT_MAX - 1);PHP这样 EP 的 excerpt 相关 hook 会在 REST 搜索时生效,excerpt.rendered 也能保留 <mark>。
注意:
__return_true影响的是所有is_integrated_request()检查,范围较宽。如果只想开 excerpt,可以改为按$context === 'highlighting'做更精细的判断。
title 为什么总是最后一个「漏网之鱼」
这是本文最想强调的一点。
get_the_title() 的签名虽然接受 ID 或 WP_Post,但内部都会调用 get_post():
function get_the_title($post = 0) {
$post = get_post($post);
$post_title = $post->post_title;
// ...
}PHP行为差异在于传入的参数类型:
get_the_title($post)— 传入WP_Query结果里的WP_Post对象时,get_post()返回同一个对象,post_title仍含 EP 注入的<mark>。get_the_title($post->ID)— 传入整数 ID 时,get_post()从对象缓存/数据库取新副本,post_title是数据库原文,不含高亮。
WordPress REST 核心用的是后者。所以你会看到:
- content / excerpt 有高亮(修复 1 + 2 之后)
- title 永远 plain text
这不是 ES 没高亮 title,而是 REST 输出 title 时绕开了 EP 改过的 post 对象。
验证方式很简单:在 rest_prepare_* 里打印 $post->post_title,你会发现它已经是:
Small RNA Sequencing (<mark class=’ep-highlight’>sRNA</mark>‑seq)
而 $data['title']['rendered'] 仍是:
Small RNA Sequencing (sRNA‑seq)
修复 3:在 rest_prepare_* 里恢复 title 高亮
不要改 WordPress 核心文件(升级会被覆盖)。在主题或 bootstrap 插件里挂 rest_prepare_{post_type}:
add_filter('rest_prepare_page', function ($response, $post, $request) {
if (trim((string) $request->get_param('search')) === '') {
return $response;
}
$data = $response->get_data();
if (
isset($data['title']['rendered'])
&& str_contains($post->post_title, 'ep-highlight')
&& ! str_contains($data['title']['rendered'], 'ep-highlight')
) {
// 关键:传 $post 对象,不要传 $post->ID
$data['title']['rendered'] = get_the_title($post);
$response->set_data($data);
}
return $response;
}, PHP_INT_MAX, 3);PHP也可以直接用 $post->post_title,若不需要 the_title filter 的额外处理。
三层修复一览
| 步骤 | Filter / Hook | 解决什么 |
|---|---|---|
| 1 | ep_highlight_should_add_clause | REST 搜索时 ES 查询包含 highlight 子句 |
| 2 | ep_is_integrated_request → true | excerpt hooks 在 REST 搜索时生效 |
| 3 | rest_prepare_{post_type} + get_the_title($post) | title 不再因传 ID 而丢高亮 |
三者缺一不可,但职责不同:
- 只有 1 → content 可能高亮,excerpt / title 不一定
- 1 + 2 → content + excerpt 正常,title 仍丢
- 1 + 2 + 3 → 三个字段行为一致(在 ES 确实高亮了对应字段的前提下)
一种容易误判的「假 bug」
修复完成后,仍可能看到 title 没有高亮——这可能是预期行为。
Elasticsearch 的 highlight 是按字段标记匹配词的,不会把 content 里的命中「投影」到 title 上。
| 搜索词 | 标题 | title 是否该高亮 |
|---|---|---|
NGS | Whole Exome Sequencing | 否 — 标题里没有 NGS |
sRNA | Small RNA Sequencing (sRNA‑seq) | 是 — 标题里就有 sRNA |
搜 NGS 时 content / excerpt 出现高亮、title 没有,是正常的。
搜 sRNA 时 title 仍无高亮,才说明修复 3 还没生效。
自定义 REST 搜索接口的注意事项
如果你自己写 WP_Query + REST 响应(而不是走 /wp/v2/{post_type}),同样遵守上述规则:
// ✓ 保留高亮
‘title’ => [‘rendered’ => get_the_title($post)],
// 或
‘title’ => [‘rendered’ => $post->post_title],
// ✗ 丢失高亮
‘title’ => [‘rendered’ => get_the_title($post->ID)],
excerpt 若用 get_the_excerpt($post),也要确保 EP excerpt hooks 已启用,否则 wp_trim_words 仍会剥掉 <mark>。
总结
ElasticPress 在 WordPress REST API 中的搜索高亮问题,本质上是 两个系统之间的接缝:
- ElasticPress 侧:高亮默认只对前台 public 请求开放,REST 需要显式启用。
- WordPress REST 侧:content、excerpt、title 三条序列化路径不一致;尤其是
get_the_title($post->ID)会重新取 post,把 EP 写在内存对象上的高亮全部抹掉。
理解这一点之后,修复并不复杂——不需要 fork ElasticPress,也不需要改 WordPress 核心,三个 filter 就能让 Headless 搜索响应和前台 Instant Results 一样,带上 <mark class="ep-highlight">。
最后一条经验:在 REST 场景下,凡是 EP 改过的字段,都应优先使用 WP_Query 返回的 $post 对象本身,而不是再传 ID 去 get_post() 取一遍。 这个习惯能帮你避开不少类似的「数据在内存里是对的,JSON 里却不对」的问题。