285 lines
9.0 KiB
PHP
285 lines
9.0 KiB
PHP
<?php
|
|
/**
|
|
* AKSRT MeiliSearch Integration
|
|
*
|
|
* Optional high-performance full-text search engine integration.
|
|
* Falls back to WordPress native search when not configured.
|
|
* Indexes posts via WP-Cron.
|
|
*
|
|
* @package AKSRT
|
|
*/
|
|
|
|
defined('ABSPATH') || exit;
|
|
|
|
class AKSRT_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')) return false;
|
|
$host = AKSRT_Settings::get_value('meilisearch_host', '');
|
|
$key = AKSRT_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::get_value('meilisearch_host', ''),
|
|
'api_key' => (string) AKSRT_Settings::get_value('meilisearch_api_key', ''),
|
|
'index' => (string) AKSRT_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);
|
|
}
|
|
}
|