Files
wordpress-theme-aksrt/src/AKSRT/Extensions/MeiliSearch.php
T
kate522 aaf458af1c refactor(autoload): migrate theme classes to Composer PSR-4
Move 288 types from global AKSRT_* + inc/classes manual requires into
namespace AKSRT\{Domain} under src/AKSRT/, wire composer.json psr-4,
and slim bootstrap to helpers/modules/hooks only. Split multi-class
widget/gateway files, update admin contract audit paths, and document
the layout in ADR/README.
2026-07-17 13:29:22 +08:00

281 lines
8.9 KiB
PHP

<?php
/**
* @package AKSRT
*/
namespace AKSRT\Extensions;
defined('ABSPATH') || exit;
class MeiliSearch {
private static $instance = null;
private function __construct() {}
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
if (!$this->is_configured()) return;
add_filter('posts_search', [$this, 'override_search'], 10, 2);
add_action('aksrt_meilisearch_sync', [$this, 'sync_all_posts']);
add_action('save_post', [$this, 'on_save_post'], 10, 3);
add_action('delete_post', [$this, 'on_delete_post']);
// Register cron schedule
add_filter('cron_schedules', function ($schedules) {
$schedules['aksrt_meilisearch_hourly'] = [
'interval' => HOUR_IN_SECONDS,
'display' => __('每小时', 'aksrt'),
];
return $schedules;
});
if (!wp_next_scheduled('aksrt_meilisearch_sync')) {
wp_schedule_event(time(), 'aksrt_meilisearch_hourly', 'aksrt_meilisearch_sync');
}
}
/**
* Check if MeiliSearch is configured
*/
public function is_configured(): bool {
if (!class_exists('\AKSRT\Settings\Settings')) return false;
$host = \AKSRT\Settings\Settings::get_value('meilisearch_host', '');
$key = \AKSRT\Settings\Settings::get_value('meilisearch_api_key', '');
return !empty($host) && !empty($key);
}
/**
* Resolve which post types are indexed/searched. Shares the single
* data-selection contract with front search and search push.
*
* @return string[]
*/
private function indexable_post_types(): array {
if (function_exists('aksrt_searchable_post_types')) {
return aksrt_searchable_post_types();
}
return ['post'];
}
/**
* Get MeiliSearch config
*/
private function get_config(): array {
return [
'host' => (string) \AKSRT\Settings\Settings::get_value('meilisearch_host', ''),
'api_key' => (string) \AKSRT\Settings\Settings::get_value('meilisearch_api_key', ''),
'index' => (string) \AKSRT\Settings\Settings::get_value('meilisearch_index', 'aksrt_posts'),
];
}
/**
* Make HTTP request to MeiliSearch
*/
private function request(string $method, string $path, array $body = []): ?array {
$config = $this->get_config();
$url = rtrim($config['host'], '/') . $path;
$args = [
'method' => $method,
'timeout' => 10,
'headers' => [
'Authorization' => 'Bearer ' . $config['api_key'],
'Content-Type' => 'application/json',
],
];
if (!empty($body)) {
$args['body'] = wp_json_encode($body);
}
$response = wp_remote_request($url, $args);
if (is_wp_error($response)) {
return null;
}
$code = wp_remote_retrieve_response_code($response);
if ($code < 200 || $code >= 300) {
return null;
}
$data = json_decode(wp_remote_retrieve_body($response), true);
return is_array($data) ? $data : null;
}
/**
* Search posts via MeiliSearch
*/
public function search(string $query, array $args = []): array {
$config = $this->get_config();
$index = $config['index'];
$body = [
'q' => $query,
'limit' => $args['posts_per_page'] ?? get_option('posts_per_page', 10),
'offset' => (($args['paged'] ?? 1) - 1) * ($args['posts_per_page'] ?? get_option('posts_per_page', 10)),
'attributesToRetrieve' => ['id', 'title', 'excerpt', 'url', 'date', 'thumbnail'],
];
// Filter by post type
if (!empty($args['post_type'])) {
$body['filter'] = 'post_type = ' . $args['post_type'];
}
$result = $this->request('POST', "/indexes/{$index}/search", $body);
if (!$result) return [];
return [
'hits' => $result['hits'] ?? [],
'total' => $result['estimatedTotalHits'] ?? 0,
'query_time' => $result['processingTimeMs'] ?? 0,
];
}
/**
* Override WordPress search with MeiliSearch
*/
public function override_search(string $search, \WP_Query $query): string {
if (!$query->is_search() || !$query->is_main_query()) {
return $search;
}
$q = $query->get('s');
if (empty($q)) return $search;
$results = $this->search($q, [
'posts_per_page' => $query->get('posts_per_page'),
'paged' => $query->get('paged') ?: 1,
'post_type' => $query->get('post_type') ?: 'post',
]);
if (empty($results['hits'])) {
// No results — force empty
return 'AND 1=0';
}
$ids = array_map(function ($hit) {
return (int) ($hit['id'] ?? 0);
}, $results['hits']);
$ids = array_filter($ids);
if (empty($ids)) return 'AND 1=0';
global $wpdb;
return 'AND ' . $wpdb->posts . '.ID IN (' . implode(',', $ids) . ')';
}
/**
* Index a single post
*/
public function index_post(int $post_id): bool {
$post = get_post($post_id);
if (!$post || $post->post_status !== 'publish') return false;
$config = $this->get_config();
$index = $config['index'];
$doc = [
'id' => $post_id,
'title' => $post->post_title,
'excerpt' => wp_strip_all_tags(get_the_excerpt($post)),
'content' => wp_strip_all_tags($post->post_content),
'url' => get_permalink($post_id),
'date' => $post->post_date,
'post_type' => $post->post_type,
'thumbnail' => get_the_post_thumbnail_url($post_id, 'medium') ?: '',
];
$result = $this->request('PUT', "/indexes/{$index}/documents", [$doc]);
return $result !== null;
}
/**
* Delete a post from index
*/
public function delete_post(int $post_id): bool {
$config = $this->get_config();
$index = $config['index'];
$result = $this->request('DELETE', "/indexes/{$index}/documents/{$post_id}");
return $result !== null;
}
/**
* Sync all published posts
*/
public function sync_all_posts(): void {
$config = $this->get_config();
$index = $config['index'];
// Create index if not exists
$this->request('POST', '/indexes', ['uid' => $index, 'primaryKey' => 'id']);
// Configure filterable attributes
$this->request('PUT', "/indexes/{$index}/settings", [
'filterableAttributes' => ['post_type'],
'searchableAttributes' => ['title', 'excerpt', 'content'],
'displayedAttributes' => ['id', 'title', 'excerpt', 'url', 'date', 'thumbnail', 'post_type'],
]);
$posts = get_posts([
'post_type' => $this->indexable_post_types(),
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
]);
$batch = [];
foreach ($posts as $post_id) {
$post = get_post($post_id);
if (!$post) continue;
$batch[] = [
'id' => (int) $post_id,
'title' => $post->post_title,
'excerpt' => wp_strip_all_tags(get_the_excerpt($post)),
'content' => wp_strip_all_tags($post->post_content),
'url' => get_permalink($post_id),
'date' => $post->post_date,
'post_type' => $post->post_type,
'thumbnail' => get_the_post_thumbnail_url($post_id, 'medium') ?: '',
];
// Batch 100 at a time
if (count($batch) >= 100) {
$this->request('PUT', "/indexes/{$index}/documents", $batch);
$batch = [];
}
}
if (!empty($batch)) {
$this->request('PUT', "/indexes/{$index}/documents", $batch);
}
}
/**
* Hook: re-index on post save
*/
public function on_save_post(int $post_id, $post, bool $update): void {
if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) return;
if ($post->post_status !== 'publish') return;
if (!in_array($post->post_type, $this->indexable_post_types(), true)) return;
// Debounce: schedule async
wp_schedule_single_event(time() + 5, 'aksrt_meilisearch_index_single', [$post_id]);
}
/**
* Hook: remove from index on post delete
*/
public function on_delete_post(int $post_id): void {
$this->delete_post($post_id);
}
}