add file
This commit is contained in:
982
ak-opensearch.php
Normal file
982
ak-opensearch.php
Normal file
@@ -0,0 +1,982 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: AK OpenSearch
|
||||||
|
* Description: Индексация постов WordPress в OpenSearch с поддержкой пакетной обработки и WP-CLI.
|
||||||
|
* Version: 0.1.0
|
||||||
|
* Author: Andrey Delfin
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* wp opensearch status - проверяем статус
|
||||||
|
* wp opensearch test_index - тест
|
||||||
|
* wp opensearch delete_index - удаляем индекс
|
||||||
|
* wp opensearch create_index --force - создаем индекс
|
||||||
|
* wp opensearch test_index - тестируем индекс (10 штук отправляем)
|
||||||
|
* wp opensearch test_search тестируем поиск
|
||||||
|
*
|
||||||
|
* wp opensearch index --post-type=anew --batch-size=50 --offset=0
|
||||||
|
*
|
||||||
|
* # Полный маппинг в JSON
|
||||||
|
* wp opensearch get_mapping
|
||||||
|
*
|
||||||
|
* # Маппинг в виде таблицы
|
||||||
|
* wp opensearch get_mapping --format=table
|
||||||
|
*
|
||||||
|
* # Информация о конкретном поле
|
||||||
|
* wp opensearch get_mapping --field=title
|
||||||
|
|
||||||
|
* # Pretty JSON вывод
|
||||||
|
* wp opensearch get_mapping --pretty
|
||||||
|
|
||||||
|
* # Маппинг в CSV формате
|
||||||
|
* wp opensearch get_mapping --format=csv
|
||||||
|
*/
|
||||||
|
|
||||||
|
if ( ! defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ----------------------------
|
||||||
|
* БАЗОВЫЙ КЛАСС КЛИЕНТА OPENSEARCH
|
||||||
|
* ----------------------------
|
||||||
|
*/
|
||||||
|
class AK_OpenSearch_Base_Client {
|
||||||
|
protected $host;
|
||||||
|
protected $user;
|
||||||
|
protected $pass;
|
||||||
|
public $index;
|
||||||
|
|
||||||
|
public function __construct($host, $user, $pass, $index = 'wordpress') {
|
||||||
|
$this->host = rtrim($host, '/');
|
||||||
|
$this->user = $user;
|
||||||
|
$this->pass = $pass;
|
||||||
|
$this->index = $index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отправка запроса к OpenSearch
|
||||||
|
*/
|
||||||
|
protected function send_request($method, $endpoint, $body = null, $is_raw_body = false) {
|
||||||
|
$url = $this->host . '/' . $this->index . '/' . ltrim($endpoint, '/');
|
||||||
|
|
||||||
|
$args = [
|
||||||
|
'method' => $method,
|
||||||
|
'headers' => [
|
||||||
|
'Authorization' => 'Basic ' . base64_encode($this->user . ':' . $this->pass),
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
],
|
||||||
|
'sslverify' => apply_filters('wp_opensearch_sslverify', false),
|
||||||
|
'timeout' => 30,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Для GET-запросов параметры передаются в URL, а не в теле
|
||||||
|
if ($method === 'GET' && !is_null($body) && is_array($body)) {
|
||||||
|
$url .= '?' . http_build_query($body);
|
||||||
|
$body = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем тело для не-GET запросов
|
||||||
|
if (!is_null($body) && $method !== 'GET') {
|
||||||
|
if ($is_raw_body && is_string($body)) {
|
||||||
|
$args['body'] = $body;
|
||||||
|
} else {
|
||||||
|
$args['body'] = wp_json_encode($body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = wp_remote_request($url, $args);
|
||||||
|
|
||||||
|
if (is_wp_error($response)) {
|
||||||
|
return new WP_Error(
|
||||||
|
'opensearch_network_error',
|
||||||
|
$response->get_error_message(),
|
||||||
|
['method' => $method, 'endpoint' => $endpoint]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response_code = wp_remote_retrieve_response_code($response);
|
||||||
|
$response_body = json_decode(wp_remote_retrieve_body($response), true);
|
||||||
|
|
||||||
|
if ($response_code >= 400) {
|
||||||
|
$error_message = isset($response_body['error'])
|
||||||
|
? (is_array($response_body['error']) ? json_encode($response_body['error']) : $response_body['error'])
|
||||||
|
: (isset($response_body['message']) ? $response_body['message'] : 'Unknown OpenSearch error');
|
||||||
|
|
||||||
|
return new WP_Error(
|
||||||
|
'opensearch_api_error',
|
||||||
|
$error_message,
|
||||||
|
[
|
||||||
|
'status' => $response_code,
|
||||||
|
'request' => ['method' => $method, 'endpoint' => $endpoint, 'body' => $body],
|
||||||
|
'response' => $response_body
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка ошибок bulk операций
|
||||||
|
if (strpos($endpoint, '_bulk') !== false && isset($response_body['items'])) {
|
||||||
|
$failed_items = array_filter($response_body['items'], function($item) {
|
||||||
|
return isset($item['index']['error']);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!empty($failed_items)) {
|
||||||
|
return new WP_Error(
|
||||||
|
'opensearch_bulk_partial_failure',
|
||||||
|
'Bulk operation completed with some failures',
|
||||||
|
[
|
||||||
|
'failed_count' => count($failed_items),
|
||||||
|
'successful_count'=> count($response_body['items']) - count($failed_items),
|
||||||
|
'failures' => array_values($failed_items)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response_body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверка существования индекса
|
||||||
|
*/
|
||||||
|
public function index_exists() {
|
||||||
|
$response = wp_remote_request($this->host . '/' . $this->index, [
|
||||||
|
'method' => 'HEAD',
|
||||||
|
'headers' => [
|
||||||
|
'Authorization' => 'Basic ' . base64_encode($this->user . ':' . $this->pass),
|
||||||
|
],
|
||||||
|
'sslverify' => apply_filters('wp_opensearch_sslverify', false),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return !is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаление индекса
|
||||||
|
*/
|
||||||
|
public function delete_index() {
|
||||||
|
return $this->send_request('DELETE', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение статистики индекса
|
||||||
|
*/
|
||||||
|
public function get_stats() {
|
||||||
|
return $this->send_request('GET', '_stats');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение маппинга индекса
|
||||||
|
*/
|
||||||
|
public function get_mapping() {
|
||||||
|
return $this->send_request('GET', '_mapping');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверка существующих документов
|
||||||
|
*/
|
||||||
|
public function check_existing_posts($post_ids) {
|
||||||
|
if (empty($post_ids)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = [
|
||||||
|
'query' => [
|
||||||
|
'terms' => [
|
||||||
|
'_id' => $post_ids
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'size' => count($post_ids),
|
||||||
|
'_source' => false
|
||||||
|
];
|
||||||
|
|
||||||
|
$response = $this->send_request('POST', '_search', $body);
|
||||||
|
|
||||||
|
if (is_wp_error($response)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map(function($hit) {
|
||||||
|
return $hit['_id'];
|
||||||
|
}, $response['hits']['hits'] ?? []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ----------------------------
|
||||||
|
* КЛАСС ДЛЯ ИНДЕКСАЦИИ ДАННЫХ
|
||||||
|
* ----------------------------
|
||||||
|
*/
|
||||||
|
class AK_OpenSearch_Indexer extends AK_OpenSearch_Base_Client {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создание индекса
|
||||||
|
*/
|
||||||
|
public function create_index() {
|
||||||
|
$settings = [
|
||||||
|
'settings' => [
|
||||||
|
'analysis' => [
|
||||||
|
'filter' => [
|
||||||
|
'russian_stop' => ['type' => 'stop','stopwords' => '_russian_'],
|
||||||
|
'russian_stemmer' => ['type' => 'stemmer','language' => 'russian'],
|
||||||
|
'russian_keywords' => ['type' => 'keyword_marker','keywords' => ['пример']]
|
||||||
|
],
|
||||||
|
'analyzer' => [
|
||||||
|
'russian_analyzer' => [
|
||||||
|
'tokenizer' => 'standard',
|
||||||
|
'filter' => ['lowercase','russian_stop','russian_keywords','russian_stemmer']
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'index' => ['number_of_shards' => 1,'number_of_replicas' => 1]
|
||||||
|
],
|
||||||
|
'mappings' => [
|
||||||
|
'properties' => [
|
||||||
|
'id' => ['type' => 'integer'],
|
||||||
|
'post_type' => ['type' => 'keyword'],
|
||||||
|
'title' => ['type' => 'text','analyzer' => 'russian_analyzer'],
|
||||||
|
'excerpt' => ['type' => 'text','analyzer' => 'russian_analyzer','fielddata' => true],
|
||||||
|
'content' => ['type' => 'text','analyzer' => 'russian_analyzer'],
|
||||||
|
'meta' => ['type' => 'object'],
|
||||||
|
'author' => [
|
||||||
|
'properties' => [
|
||||||
|
'id' => ['type' => 'integer'],
|
||||||
|
'name' => ['type' => 'text'],
|
||||||
|
'slug' => ['type' => 'keyword']
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'date' => [
|
||||||
|
'type' => 'date',
|
||||||
|
'format' => 'yyyy-MM-dd HH:mm:ss||strict_date_optional_time||epoch_millis'
|
||||||
|
],
|
||||||
|
'modified' => [
|
||||||
|
'type' => 'date',
|
||||||
|
'format' => 'yyyy-MM-dd HH:mm:ss||strict_date_optional_time||epoch_millis'
|
||||||
|
],
|
||||||
|
'url' => ['type' => 'keyword']
|
||||||
|
]
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
return $this->send_request('PUT', '', $settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение мета-данных поста
|
||||||
|
*/
|
||||||
|
private function get_post_meta($post_id) {
|
||||||
|
$meta = get_post_meta($post_id);
|
||||||
|
$result = [];
|
||||||
|
foreach ($meta as $key => $values) {
|
||||||
|
if (strpos($key, '_') === 0) continue;
|
||||||
|
$result[$key] = count($values) === 1 ? maybe_unserialize($values[0]) : array_map('maybe_unserialize', $values);
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Индексация документов пачками
|
||||||
|
*/
|
||||||
|
public function bulk_index_documents($post_type = 'post', $limit = 100, $offset = 0) {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$posts = $wpdb->get_results($wpdb->prepare(
|
||||||
|
"SELECT ID, post_title, post_content, post_excerpt, post_type,
|
||||||
|
post_author, post_date_gmt, post_modified_gmt
|
||||||
|
FROM {$wpdb->posts}
|
||||||
|
WHERE post_status = 'publish' AND post_type = %s
|
||||||
|
ORDER BY post_date DESC
|
||||||
|
LIMIT %d OFFSET %d",
|
||||||
|
$post_type,
|
||||||
|
$limit,
|
||||||
|
$offset
|
||||||
|
));
|
||||||
|
|
||||||
|
if (empty($posts)) {
|
||||||
|
return new WP_Error('empty_posts', 'No posts found for indexing');
|
||||||
|
}
|
||||||
|
|
||||||
|
$bulk_body = '';
|
||||||
|
$post_ids = [];
|
||||||
|
|
||||||
|
foreach ($posts as $post) {
|
||||||
|
$meta = $this->get_post_meta($post->ID);
|
||||||
|
|
||||||
|
// Очищаем мета-данные от несериализуемых значений
|
||||||
|
$meta = array_filter($meta, function($item) {
|
||||||
|
return !is_resource($item);
|
||||||
|
});
|
||||||
|
|
||||||
|
$document = [
|
||||||
|
'id' => $post->ID,
|
||||||
|
'title' => $post->post_title,
|
||||||
|
'post_type' => $post->post_type,
|
||||||
|
'excerpt' => wp_strip_all_tags($post->post_excerpt),
|
||||||
|
'content' => wp_strip_all_tags($post->post_content),
|
||||||
|
'meta' => $meta,
|
||||||
|
'author' => [
|
||||||
|
'id' => $post->post_author,
|
||||||
|
'name' => get_the_author_meta('display_name', $post->post_author),
|
||||||
|
'slug' => get_the_author_meta('user_nicename', $post->post_author),
|
||||||
|
],
|
||||||
|
'date' => $post->post_date_gmt,
|
||||||
|
'modified' => $post->post_modified_gmt,
|
||||||
|
];
|
||||||
|
|
||||||
|
$bulk_body .= wp_json_encode(['index' => ['_index' => $this->index, '_id' => $post->ID]]) . "\n";
|
||||||
|
$bulk_body .= wp_json_encode($document, JSON_UNESCAPED_UNICODE) . "\n";
|
||||||
|
$post_ids[] = $post->ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->send_request('POST', '_bulk', $bulk_body, true);
|
||||||
|
|
||||||
|
if (is_wp_error($response)) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка ошибок в bulk операции
|
||||||
|
if (isset($response['items'])) {
|
||||||
|
$errors = [];
|
||||||
|
foreach ($response['items'] as $item) {
|
||||||
|
if (isset($item['index']['error'])) {
|
||||||
|
$errors[] = [
|
||||||
|
'post_id' => $item['index']['_id'],
|
||||||
|
'reason' => $item['index']['error']['reason'] ?? 'Unknown error',
|
||||||
|
'type' => $item['index']['error']['type'] ?? 'unknown'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($errors)) {
|
||||||
|
$error_message = sprintf(
|
||||||
|
"Bulk operation completed with %d failures out of %d items.",
|
||||||
|
count($errors),
|
||||||
|
count($response['items'])
|
||||||
|
);
|
||||||
|
|
||||||
|
return new WP_Error('bulk_partial_failure', $error_message, [
|
||||||
|
'total_items' => count($response['items']),
|
||||||
|
'failed_items' => count($errors),
|
||||||
|
'errors' => $errors
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Индексация конкретных постов
|
||||||
|
*/
|
||||||
|
public function bulk_index($post_ids) {
|
||||||
|
if (empty($post_ids)) {
|
||||||
|
return new WP_Error('empty_posts', 'No post IDs provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
$bulk_body = '';
|
||||||
|
|
||||||
|
foreach ($post_ids as $post_id) {
|
||||||
|
$post = get_post($post_id);
|
||||||
|
if (!$post || $post->post_status !== 'publish') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta = $this->get_post_meta($post_id);
|
||||||
|
$meta = array_filter($meta, function($item) {
|
||||||
|
return !is_resource($item);
|
||||||
|
});
|
||||||
|
|
||||||
|
$document = [
|
||||||
|
'id' => $post->ID,
|
||||||
|
'title' => $post->post_title,
|
||||||
|
'post_type' => $post->post_type,
|
||||||
|
'excerpt' => wp_strip_all_tags($post->post_excerpt),
|
||||||
|
'content' => wp_strip_all_tags($post->post_content),
|
||||||
|
'meta' => $meta,
|
||||||
|
'author' => [
|
||||||
|
'id' => $post->post_author,
|
||||||
|
'name' => get_the_author_meta('display_name', $post->post_author),
|
||||||
|
'slug' => get_the_author_meta('user_nicename', $post->post_author),
|
||||||
|
],
|
||||||
|
'date' => $post->post_date_gmt,
|
||||||
|
'modified' => $post->post_modified_gmt,
|
||||||
|
];
|
||||||
|
|
||||||
|
$bulk_body .= wp_json_encode(['index' => ['_index' => $this->index, '_id' => $post->ID]]) . "\n";
|
||||||
|
$bulk_body .= wp_json_encode($document, JSON_UNESCAPED_UNICODE) . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->send_request('POST', '_bulk', $bulk_body, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Индексация одного поста
|
||||||
|
*/
|
||||||
|
public function index_post($post_id) {
|
||||||
|
$post = get_post($post_id);
|
||||||
|
if (!$post || $post->post_status !== 'publish') {
|
||||||
|
return new WP_Error('invalid_post', 'Post not found or not published');
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta = $this->get_post_meta($post_id);
|
||||||
|
$meta = array_filter($meta, function($item) {
|
||||||
|
return !is_resource($item);
|
||||||
|
});
|
||||||
|
|
||||||
|
$document = [
|
||||||
|
'id' => $post->ID,
|
||||||
|
'title' => $post->post_title,
|
||||||
|
'post_type' => $post->post_type,
|
||||||
|
'excerpt' => wp_strip_all_tags($post->post_excerpt),
|
||||||
|
'content' => wp_strip_all_tags($post->post_content),
|
||||||
|
'meta' => $meta,
|
||||||
|
'author' => [
|
||||||
|
'id' => $post->post_author,
|
||||||
|
'name' => get_the_author_meta('display_name', $post->post_author),
|
||||||
|
'slug' => get_the_author_meta('user_nicename', $post->post_author),
|
||||||
|
],
|
||||||
|
'date' => $post->post_date_gmt,
|
||||||
|
'modified' => $post->post_modified_gmt,
|
||||||
|
];
|
||||||
|
|
||||||
|
return $this->send_request('PUT', '_doc/' . $post_id, $document);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаление поста из индекса
|
||||||
|
*/
|
||||||
|
public function delete_post($post_id) {
|
||||||
|
return $this->send_request('DELETE', '_doc/' . $post_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ----------------------------
|
||||||
|
* КЛАСС ДЛЯ ПОИСКА
|
||||||
|
* ----------------------------
|
||||||
|
*/
|
||||||
|
class AK_OpenSearch_Searcher extends AK_OpenSearch_Base_Client {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поиск документов
|
||||||
|
*/
|
||||||
|
public function search($query, $page = 1, $per_page = 10, $params = []) {
|
||||||
|
$from = ($page - 1) * $per_page;
|
||||||
|
|
||||||
|
$defaults = [
|
||||||
|
'from' => $from,
|
||||||
|
'size' => $per_page,
|
||||||
|
'_source' => ['id', 'title', 'excerpt', 'date', 'url', 'author'],
|
||||||
|
'sort' => [['date' => 'desc']]
|
||||||
|
];
|
||||||
|
|
||||||
|
$params = wp_parse_args($params, $defaults);
|
||||||
|
|
||||||
|
$search_body = [
|
||||||
|
'query' => [
|
||||||
|
'multi_match' => [
|
||||||
|
'query' => $query,
|
||||||
|
'fields' => ['title^3', 'content^2', 'excerpt'],
|
||||||
|
'fuzziness' => 'AUTO'
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'highlight' => [
|
||||||
|
'fields' => [
|
||||||
|
'content' => new stdClass(),
|
||||||
|
'title' => new stdClass()
|
||||||
|
]
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
// Удаляем из параметров то, что уже есть в search_body
|
||||||
|
unset($params['query'], $params['highlight']);
|
||||||
|
|
||||||
|
// Объединяем параметры
|
||||||
|
$search_body = array_merge($search_body, $params);
|
||||||
|
|
||||||
|
return $this->send_request('POST', '_search', $search_body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Полнотекстовый поиск с дополнительными параметрами
|
||||||
|
*/
|
||||||
|
public function advanced_search($args = []) {
|
||||||
|
$defaults = [
|
||||||
|
'query' => '',
|
||||||
|
'page' => 1,
|
||||||
|
'per_page' => 10,
|
||||||
|
'post_type' => [],
|
||||||
|
'date_range' => [],
|
||||||
|
'sort' => [['date' => 'desc']]
|
||||||
|
];
|
||||||
|
|
||||||
|
$args = wp_parse_args($args, $defaults);
|
||||||
|
|
||||||
|
$from = ($args['page'] - 1) * $args['per_page'];
|
||||||
|
$query = [];
|
||||||
|
|
||||||
|
// Базовый запрос
|
||||||
|
if (!empty($args['query'])) {
|
||||||
|
$query['bool']['must'][] = [
|
||||||
|
'multi_match' => [
|
||||||
|
'query' => $args['query'],
|
||||||
|
'fields' => ['title^3', 'content^2', 'excerpt'],
|
||||||
|
'fuzziness' => 'AUTO'
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по типу поста
|
||||||
|
if (!empty($args['post_type'])) {
|
||||||
|
$query['bool']['filter'][] = [
|
||||||
|
'terms' => ['post_type' => (array)$args['post_type']]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по дате
|
||||||
|
if (!empty($args['date_range'])) {
|
||||||
|
$date_filter = [];
|
||||||
|
if (!empty($args['date_range']['from'])) {
|
||||||
|
$date_filter['gte'] = $args['date_range']['from'];
|
||||||
|
}
|
||||||
|
if (!empty($args['date_range']['to'])) {
|
||||||
|
$date_filter['lte'] = $args['date_range']['to'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($date_filter)) {
|
||||||
|
$query['bool']['filter'][] = [
|
||||||
|
'range' => ['date' => $date_filter]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если нет условий запроса, ищем все документы
|
||||||
|
if (empty($query)) {
|
||||||
|
$query = ['match_all' => new stdClass()];
|
||||||
|
}
|
||||||
|
|
||||||
|
$search_body = [
|
||||||
|
'query' => $query,
|
||||||
|
'from' => $from,
|
||||||
|
'size' => $args['per_page'],
|
||||||
|
'sort' => $args['sort'],
|
||||||
|
'_source' => ['id', 'title', 'excerpt', 'date', 'url', 'author'],
|
||||||
|
'highlight' => [
|
||||||
|
'fields' => [
|
||||||
|
'content' => new stdClass(),
|
||||||
|
'title' => new stdClass()
|
||||||
|
]
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
return $this->send_request('POST', '_search', $search_body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение документа по ID
|
||||||
|
*/
|
||||||
|
public function get_document($post_id) {
|
||||||
|
return $this->send_request('GET', '_doc/' . $post_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ----------------------------
|
||||||
|
* WP-CLI КОМАНДЫ
|
||||||
|
* ----------------------------
|
||||||
|
*/
|
||||||
|
if (defined('WP_CLI') && WP_CLI) {
|
||||||
|
class WP_OpenSearch_CLI_Command extends WP_CLI_Command {
|
||||||
|
private $indexer;
|
||||||
|
private $searcher;
|
||||||
|
|
||||||
|
public function __construct() {
|
||||||
|
$host = defined('OPENSEARCH_HOST') ? OPENSEARCH_HOST : 'https://localhost:9200';
|
||||||
|
$user = defined('OPENSEARCH_USER') ? OPENSEARCH_USER : 'admin';
|
||||||
|
$pass = defined('OPENSEARCH_PASS') ? OPENSEARCH_PASS : 'password';
|
||||||
|
$index = defined('OPENSEARCH_INDEX') ? OPENSEARCH_INDEX : 'wordpress';
|
||||||
|
|
||||||
|
$this->indexer = new AK_OpenSearch_Indexer($host, $user, $pass, $index);
|
||||||
|
$this->searcher = new AK_OpenSearch_Searcher($host, $user, $pass, $index);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** wp opensearch create_index */
|
||||||
|
public function create_index($args, $assoc_args) {
|
||||||
|
$force = isset($assoc_args['force']);
|
||||||
|
if ($this->indexer->index_exists()) {
|
||||||
|
if ($force) {
|
||||||
|
WP_CLI::log("Deleting existing index...");
|
||||||
|
$res = $this->indexer->delete_index();
|
||||||
|
if (is_wp_error($res)) WP_CLI::error("Failed: ".$res->get_error_message());
|
||||||
|
WP_CLI::success("Index deleted");
|
||||||
|
} else {
|
||||||
|
WP_CLI::error("Index exists. Use --force to recreate.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WP_CLI::log("Creating index...");
|
||||||
|
$res = $this->indexer->create_index();
|
||||||
|
if (is_wp_error($res)) WP_CLI::error("Failed: ".$res->get_error_message());
|
||||||
|
WP_CLI::success("Index created");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** wp opensearch delete_index */
|
||||||
|
public function delete_index($args, $assoc_args) {
|
||||||
|
if (!$this->indexer->index_exists()) WP_CLI::error("Index does not exist");
|
||||||
|
WP_CLI::confirm("Delete index?", $assoc_args);
|
||||||
|
$res = $this->indexer->delete_index();
|
||||||
|
if (is_wp_error($res)) WP_CLI::error("Failed: ".$res->get_error_message());
|
||||||
|
WP_CLI::success("Index deleted");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** wp opensearch status */
|
||||||
|
public function status($args, $assoc_args) {
|
||||||
|
if (!$this->indexer->index_exists()) WP_CLI::error("Index does not exist");
|
||||||
|
$stats = $this->indexer->get_stats();
|
||||||
|
if (is_wp_error($stats)) WP_CLI::error("Failed: ".$stats->get_error_message());
|
||||||
|
$s = $stats['indices'][$this->indexer->index];
|
||||||
|
WP_CLI::line("Index: ".$this->indexer->index);
|
||||||
|
WP_CLI::line("Docs: ".$s['total']['docs']['count']);
|
||||||
|
WP_CLI::line("Size: ".size_format($s['total']['store']['size_in_bytes']));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** wp opensearch index_posts */
|
||||||
|
public function index_posts($args, $assoc_args) {
|
||||||
|
$batch_size = isset($assoc_args['batch']) ? (int)$assoc_args['batch'] : 100;
|
||||||
|
$post_types = isset($assoc_args['post-types']) ? explode(',', $assoc_args['post-types']) : ['post','page'];
|
||||||
|
$skip_existing= isset($assoc_args['skip-existing']);
|
||||||
|
|
||||||
|
if (!$this->indexer->index_exists()) {
|
||||||
|
WP_CLI::error("Index does not exist. Run: wp opensearch create_index");
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = 0;
|
||||||
|
foreach ($post_types as $type) {
|
||||||
|
$total += wp_count_posts($type)->publish;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($total === 0) WP_CLI::error("No posts found");
|
||||||
|
|
||||||
|
$progress = \WP_CLI\Utils\make_progress_bar("Indexing", $total);
|
||||||
|
$indexed = $skipped = $failed = 0;
|
||||||
|
|
||||||
|
foreach ($post_types as $post_type) {
|
||||||
|
$paged = 1;
|
||||||
|
while ($posts = get_posts([
|
||||||
|
'post_type' => $post_type,
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'posts_per_page' => $batch_size,
|
||||||
|
'paged' => $paged++,
|
||||||
|
'fields' => 'ids',
|
||||||
|
'orderby' => 'ID',
|
||||||
|
'order' => 'ASC',
|
||||||
|
'no_found_rows' => true,
|
||||||
|
])) {
|
||||||
|
$posts_to_index = $posts;
|
||||||
|
if ($skip_existing) {
|
||||||
|
$existing = $this->indexer->check_existing_posts($posts);
|
||||||
|
$posts_to_index = array_diff($posts, $existing);
|
||||||
|
$skipped += count($existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($posts_to_index)) {
|
||||||
|
$res = $this->indexer->bulk_index($posts_to_index);
|
||||||
|
if (is_wp_error($res)) {
|
||||||
|
$failed += count($posts_to_index);
|
||||||
|
WP_CLI::warning("Batch failed: ".$res->get_error_message());
|
||||||
|
} else {
|
||||||
|
$indexed += count($posts_to_index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$progress->tick(count($posts));
|
||||||
|
usleep(300000); // 0.3s пауза
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$progress->finish();
|
||||||
|
WP_CLI::success("Done. Total: $total, Indexed: $indexed, Skipped: $skipped, Failed: $failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index posts to OpenSearch
|
||||||
|
*/
|
||||||
|
public function index($args, $assoc_args) {
|
||||||
|
$post_type = $assoc_args['post-type'] ?? 'post';
|
||||||
|
$batch_size = (int) ($assoc_args['batch-size'] ?? 100);
|
||||||
|
$offset = (int) ($assoc_args['offset'] ?? 0);
|
||||||
|
$all = isset($assoc_args['all']);
|
||||||
|
|
||||||
|
if ($all) {
|
||||||
|
WP_CLI::log("Starting full index of '$post_type' posts...");
|
||||||
|
$this->index_all_posts($post_type, $batch_size);
|
||||||
|
} else {
|
||||||
|
WP_CLI::log("Indexing batch of $batch_size '$post_type' posts starting from offset $offset...");
|
||||||
|
$result = $this->indexer->bulk_index_documents($post_type, $batch_size, $offset);
|
||||||
|
|
||||||
|
if (is_wp_error($result)) {
|
||||||
|
WP_CLI::error($result->get_error_message());
|
||||||
|
} else {
|
||||||
|
WP_CLI::success(sprintf(
|
||||||
|
"Successfully indexed %d posts (offset: %d)",
|
||||||
|
$batch_size,
|
||||||
|
$offset
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index all posts of a given type in batches
|
||||||
|
*/
|
||||||
|
private function index_all_posts($post_type, $batch_size) {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$total = (int) $wpdb->get_var($wpdb->prepare(
|
||||||
|
"SELECT COUNT(ID) FROM {$wpdb->posts}
|
||||||
|
WHERE post_status = 'publish' AND post_type = %s",
|
||||||
|
$post_type
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($total === 0) {
|
||||||
|
WP_CLI::warning("No published posts of type '$post_type' found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$progress = WP_CLI\Utils\make_progress_bar("Indexing $total posts", $total);
|
||||||
|
$processed = 0;
|
||||||
|
|
||||||
|
while ($processed < $total) {
|
||||||
|
$result = $this->indexer->bulk_index_documents($post_type, $batch_size, $processed);
|
||||||
|
|
||||||
|
if (is_wp_error($result)) {
|
||||||
|
$progress->finish();
|
||||||
|
WP_CLI::error($result->get_error_message());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$processed += $batch_size;
|
||||||
|
$progress->tick($batch_size);
|
||||||
|
|
||||||
|
usleep(500000); // 0.5 second
|
||||||
|
}
|
||||||
|
|
||||||
|
$progress->finish();
|
||||||
|
WP_CLI::success("Successfully indexed all $total posts of type '$post_type'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get index mapping information
|
||||||
|
*/
|
||||||
|
public function get_mapping($args, $assoc_args) {
|
||||||
|
|
||||||
|
// Получаем параметры из командной строки
|
||||||
|
$field = !empty($assoc_args['field']) ? $assoc_args['field'] : '';
|
||||||
|
$format = !empty($assoc_args['format']) ? $assoc_args['format'] : 'json';
|
||||||
|
$pretty = isset($assoc_args['pretty']);
|
||||||
|
|
||||||
|
// Используем публичный метод для получения маппинга
|
||||||
|
$result = $this->indexer->get_mapping();
|
||||||
|
|
||||||
|
if (is_wp_error($result)) {
|
||||||
|
WP_CLI::error("Failed to get mapping: " . $result->get_error_message());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если указано конкретное поле
|
||||||
|
if (!empty($field)) {
|
||||||
|
if (!empty($result) && is_array($result)) {
|
||||||
|
$index_name = array_key_first($result);
|
||||||
|
if ($index_name && isset($result[$index_name]['mappings']['properties'][$field])) {
|
||||||
|
$field_data = $result[$index_name]['mappings']['properties'][$field];
|
||||||
|
|
||||||
|
// Вывод в запрошенном формате
|
||||||
|
switch ($format) {
|
||||||
|
case 'table':
|
||||||
|
$table_data = [
|
||||||
|
['Field' => $field, 'Type' => $field_data['type'] ?? 'unknown']
|
||||||
|
];
|
||||||
|
|
||||||
|
// Добавляем дополнительные свойства поля
|
||||||
|
if (isset($field_data['analyzer'])) {
|
||||||
|
$table_data[0]['Analyzer'] = $field_data['analyzer'];
|
||||||
|
}
|
||||||
|
if (isset($field_data['search_analyzer'])) {
|
||||||
|
$table_data[0]['Search Analyzer'] = $field_data['search_analyzer'];
|
||||||
|
}
|
||||||
|
if (isset($field_data['index'])) {
|
||||||
|
$table_data[0]['Index'] = $field_data['index'] ? 'yes' : 'no';
|
||||||
|
}
|
||||||
|
|
||||||
|
WP_CLI\Utils\format_items('table', $table_data, array_keys($table_data[0]));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'csv':
|
||||||
|
$csv_data = [
|
||||||
|
['Field', 'Type', 'Analyzer', 'Search Analyzer', 'Index']
|
||||||
|
];
|
||||||
|
|
||||||
|
$row = [
|
||||||
|
$field,
|
||||||
|
$field_data['type'] ?? 'unknown',
|
||||||
|
$field_data['analyzer'] ?? '',
|
||||||
|
$field_data['search_analyzer'] ?? '',
|
||||||
|
isset($field_data['index']) ? ($field_data['index'] ? 'yes' : 'no') : ''
|
||||||
|
];
|
||||||
|
|
||||||
|
$csv_data[] = $row;
|
||||||
|
WP_CLI\Utils\format_items('csv', $csv_data, array_keys($csv_data[0]));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'yaml':
|
||||||
|
WP_CLI::line(yaml_emit($field_data));
|
||||||
|
break;
|
||||||
|
|
||||||
|
default: // json
|
||||||
|
if ($pretty) {
|
||||||
|
WP_CLI::line(json_encode($field_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||||
|
} else {
|
||||||
|
WP_CLI::line(json_encode($field_data));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
WP_CLI::warning("Field '$field' not found in mapping");
|
||||||
|
|
||||||
|
// Показываем доступные поля
|
||||||
|
$available_fields = [];
|
||||||
|
if (!empty($result) && is_array($result)) {
|
||||||
|
$index_name = array_key_first($result);
|
||||||
|
if ($index_name && isset($result[$index_name]['mappings']['properties'])) {
|
||||||
|
$available_fields = array_keys($result[$index_name]['mappings']['properties']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($available_fields)) {
|
||||||
|
WP_CLI::line("\nAvailable fields: " . implode(', ', $available_fields));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Полный маппинг или список всех полей
|
||||||
|
switch ($format) {
|
||||||
|
case 'table':
|
||||||
|
$table_data = [];
|
||||||
|
|
||||||
|
if (!empty($result) && is_array($result)) {
|
||||||
|
$index_name = array_key_first($result);
|
||||||
|
if ($index_name && isset($result[$index_name]['mappings']['properties'])) {
|
||||||
|
$properties = $result[$index_name]['mappings']['properties'];
|
||||||
|
|
||||||
|
foreach ($properties as $field_name => $field_config) {
|
||||||
|
$row = [
|
||||||
|
'Field' => $field_name,
|
||||||
|
'Type' => $field_config['type'] ?? 'unknown',
|
||||||
|
'Analyzer' => $field_config['analyzer'] ?? '-',
|
||||||
|
'Search Analyzer' => $field_config['search_analyzer'] ?? '-',
|
||||||
|
'Index' => isset($field_config['index']) ? ($field_config['index'] ? 'yes' : 'no') : 'yes'
|
||||||
|
];
|
||||||
|
$table_data[] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WP_CLI\Utils\format_items('table', $table_data, ['Field', 'Type', 'Analyzer', 'Search Analyzer', 'Index']);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'csv':
|
||||||
|
$csv_data = [['Field', 'Type', 'Analyzer', 'Search Analyzer', 'Index']];
|
||||||
|
|
||||||
|
if (!empty($result) && is_array($result)) {
|
||||||
|
$index_name = array_key_first($result);
|
||||||
|
if ($index_name && isset($result[$index_name]['mappings']['properties'])) {
|
||||||
|
$properties = $result[$index_name]['mappings']['properties'];
|
||||||
|
|
||||||
|
foreach ($properties as $field_name => $field_config) {
|
||||||
|
$row = [
|
||||||
|
$field_name,
|
||||||
|
$field_config['type'] ?? 'unknown',
|
||||||
|
$field_config['analyzer'] ?? '',
|
||||||
|
$field_config['search_analyzer'] ?? '',
|
||||||
|
isset($field_config['index']) ? ($field_config['index'] ? 'yes' : 'no') : 'yes'
|
||||||
|
];
|
||||||
|
$csv_data[] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WP_CLI\Utils\format_items('csv', $csv_data, array_keys($csv_data[0]));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'yaml':
|
||||||
|
WP_CLI::line(yaml_emit($result));
|
||||||
|
break;
|
||||||
|
|
||||||
|
default: // json
|
||||||
|
if ($pretty) {
|
||||||
|
WP_CLI::line(json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||||
|
} else {
|
||||||
|
WP_CLI::line(json_encode($result));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test command to index 10 latest 'anew' posts
|
||||||
|
*/
|
||||||
|
public function test_index($args, $assoc_args) {
|
||||||
|
WP_CLI::log("Starting test index of 10 latest 'anew' posts...");
|
||||||
|
|
||||||
|
$result = $this->indexer->bulk_index_documents('anew', 10, 0);
|
||||||
|
|
||||||
|
if (is_wp_error($result)) {
|
||||||
|
WP_CLI::error("Error during indexing: " . $result->get_error_message());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($result) && isset($result['errors'])) {
|
||||||
|
if ($result['errors']) {
|
||||||
|
$error_count = 0;
|
||||||
|
foreach ($result['items'] as $item) {
|
||||||
|
if (isset($item['index']['error'])) {
|
||||||
|
$error_count++;
|
||||||
|
WP_CLI::warning(sprintf(
|
||||||
|
"Failed to index post ID %d: %s",
|
||||||
|
$item['index']['_id'],
|
||||||
|
$item['index']['error']['reason'] ?? 'Unknown error'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WP_CLI::warning(sprintf(
|
||||||
|
"Test index completed with %d errors out of %d items",
|
||||||
|
$error_count,
|
||||||
|
count($result['items'])
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
WP_CLI::success("Successfully indexed 10 'anew' posts without errors");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WP_CLI::get_config('verbose')) {
|
||||||
|
WP_CLI::log("\nIndexing stats:");
|
||||||
|
WP_CLI::log("Took: " . ($result['took'] ?? 'N/A') . "ms");
|
||||||
|
WP_CLI::log("Errors: " . ($result['errors'] ? 'Yes' : 'No'));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
WP_CLI::success("Test index request completed. Response: " . json_encode($result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_search($args, $assoc_args) {
|
||||||
|
$result = $this->searcher->search('Набиуллина');
|
||||||
|
|
||||||
|
if (is_wp_error($result)) {
|
||||||
|
WP_CLI::error("Error during search: " . $result->get_error_message());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var_dump( $result );
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
WP_CLI::add_command('opensearch', 'WP_OpenSearch_CLI_Command');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user