ElasticPress + WordPress REST API:搜索高亮为什么「时有时无」,以及如何修

在 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 序列化为 JSON
PHP

默认高亮标签是:

<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
);
PHP

is_integrated_request() 把请求分成 adminajaxrestpublic 四类。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.renderedget_the_excerpt → wp_trim_excerpt → wp_trim_words需额外处理
title.renderedget_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);
PHP

content 为什么正常

content 直接读 $post->post_content。这个 $post 就是 EP 刚刚改过的那个对象,高亮自然在。

excerpt 为什么需要第二个开关

WordPress 默认用 wp_trim_words() 生成摘要,会 strip 掉所有 HTML,<mark> 标签一并消失。

ElasticPress 为前台搜索准备了 excerpt 专用 hook(allow_excerpt_htmlep_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解决什么
1ep_highlight_should_add_clauseREST 搜索时 ES 查询包含 highlight 子句
2ep_is_integrated_request → trueexcerpt hooks 在 REST 搜索时生效
3rest_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 是否该高亮
NGSWhole Exome Sequencing否 — 标题里没有 NGS
sRNASmall 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 中的搜索高亮问题,本质上是 两个系统之间的接缝:

  1. ElasticPress 侧:高亮默认只对前台 public 请求开放,REST 需要显式启用。
  2. 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 里却不对」的问题。

Leave a Reply

Your email address will not be published. Required fields are marked *