fix(analytics): Corregir URLs en analytics usando post_name desde BD
Problema: - URLs en analytics mostraban dominio incorrecto (HTTP_HOST) - URLs usaban formato /?p=ID en lugar de permalinks Solución: - class-search-engine.php: Agregar propiedad $prefix, incluir p.post_name en 5 queries fetch, agregar helpers getSiteUrlFromDb(), getPermalinkStructure() y buildPermalink() - search-endpoint.php: Obtener site_url y permalink_structure desde wp_options, construir URLs con post_name - click-endpoint.php: Fallback de dest usando post_name desde BD Archivos modificados: - includes/class-search-engine.php - api/search-endpoint.php - api/click-endpoint.php 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
102
api/click-endpoint.php
Normal file
102
api/click-endpoint.php
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Click Tracking Endpoint with SHORTINIT bypass
|
||||||
|
*
|
||||||
|
* This endpoint logs clicks and redirects to the destination.
|
||||||
|
* Use this directly via plugin URL for fastest tracking.
|
||||||
|
*
|
||||||
|
* @package ROI_APU_Search
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// BYPASS: Don't load full WordPress
|
||||||
|
define('SHORTINIT', true);
|
||||||
|
define('WP_USE_THEMES', false);
|
||||||
|
|
||||||
|
// Find wp-load.php (go up from plugin directory)
|
||||||
|
$wp_load = dirname(__FILE__, 5) . '/wp-load.php';
|
||||||
|
|
||||||
|
if (!file_exists($wp_load)) {
|
||||||
|
// Fallback: try alternative paths
|
||||||
|
$wp_load = dirname(__FILE__, 6) . '/wp-load.php';
|
||||||
|
if (!file_exists($wp_load)) {
|
||||||
|
// Can't log, just redirect
|
||||||
|
$postId = isset($_GET['pid']) ? (int) $_GET['pid'] : 0;
|
||||||
|
$dest = isset($_GET['dest']) ? $_GET['dest'] : '/?p=' . $postId;
|
||||||
|
header('Location: ' . $dest, true, 302);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once $wp_load;
|
||||||
|
|
||||||
|
// Get request parameters
|
||||||
|
$searchId = isset($_GET['sid']) ? (int) $_GET['sid'] : 0;
|
||||||
|
$postId = isset($_GET['pid']) ? (int) $_GET['pid'] : 0;
|
||||||
|
$position = isset($_GET['pos']) ? (int) $_GET['pos'] : 0;
|
||||||
|
$page = isset($_GET['page']) ? (int) $_GET['page'] : 1;
|
||||||
|
$dest = isset($_GET['dest']) ? $_GET['dest'] : '';
|
||||||
|
|
||||||
|
// Load plugin classes first (needed for DB access)
|
||||||
|
$plugin_dir = dirname(__FILE__, 2);
|
||||||
|
require_once $plugin_dir . '/includes/class-db-connection.php';
|
||||||
|
require_once $plugin_dir . '/includes/class-analytics.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get database connection
|
||||||
|
$db = ROI_APU_Search_DB::get_instance();
|
||||||
|
$pdo = $db->get_pdo();
|
||||||
|
$prefix = $db->get_prefix();
|
||||||
|
|
||||||
|
// Build destination URL if empty
|
||||||
|
if (empty($dest)) {
|
||||||
|
// Obtain site_url from database (not HTTP_HOST)
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"SELECT option_value FROM {$prefix}options
|
||||||
|
WHERE option_name = 'home' LIMIT 1"
|
||||||
|
);
|
||||||
|
$stmt->execute();
|
||||||
|
$home = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
$site_url = $home ? rtrim($home['option_value'], '/') : '';
|
||||||
|
|
||||||
|
// Try to get post_name for proper permalink
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"SELECT post_name FROM {$prefix}posts WHERE ID = ? LIMIT 1"
|
||||||
|
);
|
||||||
|
$stmt->execute([$postId]);
|
||||||
|
$post = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if ($post && !empty($post['post_name'])) {
|
||||||
|
$dest = $site_url . '/' . $post['post_name'] . '/';
|
||||||
|
} else {
|
||||||
|
$dest = $site_url . '/?p=' . $postId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get analytics config
|
||||||
|
$cfg = ROI_APU_Search_Analytics::get_config();
|
||||||
|
|
||||||
|
// Log click
|
||||||
|
ROI_APU_Search_Analytics::logClick(
|
||||||
|
$pdo,
|
||||||
|
$prefix,
|
||||||
|
$cfg,
|
||||||
|
$searchId,
|
||||||
|
$postId,
|
||||||
|
max(1, $position),
|
||||||
|
max(1, $page),
|
||||||
|
$dest
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('ROI APU Search Click Error: ' . $e->getMessage());
|
||||||
|
// Never break redirect due to tracking
|
||||||
|
// Fallback dest if error occurred before setting it
|
||||||
|
if (empty($dest)) {
|
||||||
|
$dest = '/?p=' . $postId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to destination
|
||||||
|
header('Location: ' . $dest, true, 302);
|
||||||
|
exit;
|
||||||
191
api/search-endpoint.php
Normal file
191
api/search-endpoint.php
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Fast AJAX Search Endpoint with SHORTINIT bypass
|
||||||
|
*
|
||||||
|
* This endpoint loads minimal WordPress for maximum speed.
|
||||||
|
* Use this directly via plugin URL for fastest results.
|
||||||
|
*
|
||||||
|
* @package ROI_APU_Search
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// BYPASS: Don't load full WordPress
|
||||||
|
define('SHORTINIT', true);
|
||||||
|
define('WP_USE_THEMES', false);
|
||||||
|
|
||||||
|
// Find wp-load.php (go up from plugin directory)
|
||||||
|
$wp_load = dirname(__FILE__, 5) . '/wp-load.php';
|
||||||
|
|
||||||
|
if (!file_exists($wp_load)) {
|
||||||
|
// Fallback: try alternative paths
|
||||||
|
$wp_load = dirname(__FILE__, 6) . '/wp-load.php';
|
||||||
|
if (!file_exists($wp_load)) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'data' => ['message' => 'WordPress not found']]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once $wp_load;
|
||||||
|
|
||||||
|
// Set JSON headers
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('Cache-Control: no-cache, must-revalidate');
|
||||||
|
|
||||||
|
// Only accept POST requests
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['success' => false, 'data' => ['message' => 'Method not allowed']]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify nonce (with SHORTINIT, we need to load nonce functions manually)
|
||||||
|
// Since SHORTINIT doesn't load nonce functions, we'll implement basic validation
|
||||||
|
// sanitize_text_field() might not be available, use simple sanitization
|
||||||
|
$nonce = isset($_POST['nonce']) ? htmlspecialchars(strip_tags(trim($_POST['nonce'])), ENT_QUOTES, 'UTF-8') : '';
|
||||||
|
|
||||||
|
// Simple nonce validation using wp_hash
|
||||||
|
// In SHORTINIT mode, we have limited access to WP functions
|
||||||
|
// We'll verify based on a time-based token instead
|
||||||
|
if (empty($nonce)) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['success' => false, 'data' => ['message' => 'Missing security token']]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For SHORTINIT, we use a simplified token validation
|
||||||
|
// The token is generated with a salt and timestamp
|
||||||
|
$token_parts = explode('|', base64_decode($nonce));
|
||||||
|
if (count($token_parts) !== 2) {
|
||||||
|
// Fallback: accept WP nonce format if available
|
||||||
|
// This allows compatibility when called from admin-ajax.php
|
||||||
|
// We'll be permissive here but log suspicious requests
|
||||||
|
error_log('ROI APU Search: Non-standard nonce format from ' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get request parameters
|
||||||
|
$term = isset($_POST['term']) ? trim((string) $_POST['term']) : '';
|
||||||
|
$page = isset($_POST['page']) ? max(1, (int) $_POST['page']) : 1;
|
||||||
|
$per_page = isset($_POST['per_page']) ? max(1, min(100, (int) $_POST['per_page'])) : 10;
|
||||||
|
$categories = isset($_POST['categories']) ? trim((string) $_POST['categories']) : '';
|
||||||
|
|
||||||
|
// Validate term
|
||||||
|
$min_len = 3;
|
||||||
|
$max_len = 250;
|
||||||
|
|
||||||
|
if (mb_strlen($term, 'UTF-8') < $min_len) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'data' => ['message' => "Minimo {$min_len} caracteres"]
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mb_strlen($term, 'UTF-8') > $max_len) {
|
||||||
|
$term = mb_substr($term, 0, $max_len, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load plugin classes
|
||||||
|
$plugin_dir = dirname(__FILE__, 2);
|
||||||
|
require_once $plugin_dir . '/includes/class-db-connection.php';
|
||||||
|
require_once $plugin_dir . '/includes/class-redis-cache.php';
|
||||||
|
require_once $plugin_dir . '/includes/class-search-engine.php';
|
||||||
|
require_once $plugin_dir . '/includes/class-analytics.php';
|
||||||
|
|
||||||
|
// Store raw term before any processing
|
||||||
|
$term_raw = isset($_POST['term']) ? (string) $_POST['term'] : '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get database connection
|
||||||
|
$db = ROI_APU_Search_DB::get_instance();
|
||||||
|
$pdo = $db->get_pdo();
|
||||||
|
$prefix = $db->get_prefix();
|
||||||
|
|
||||||
|
// Parse categories
|
||||||
|
$category_ids = [];
|
||||||
|
if (!empty($categories)) {
|
||||||
|
$parts = array_filter(array_map('trim', explode(',', $categories)));
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
if (is_numeric($part)) {
|
||||||
|
$category_ids[] = (int) $part;
|
||||||
|
}
|
||||||
|
// Note: In SHORTINIT mode, we can't easily resolve slugs to IDs
|
||||||
|
// So we only accept numeric IDs in this fast endpoint
|
||||||
|
}
|
||||||
|
$category_ids = array_unique($category_ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute search
|
||||||
|
$search = new ROI_APU_Search_Engine($pdo);
|
||||||
|
$offset = ($page - 1) * $per_page;
|
||||||
|
$results = $search->run($term, $per_page, $offset, $category_ids);
|
||||||
|
|
||||||
|
// In SHORTINIT mode, get_permalink() and home_url() won't work
|
||||||
|
// Obtain site_url and permalink_structure from database (not HTTP_HOST)
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"SELECT option_name, option_value FROM {$prefix}options
|
||||||
|
WHERE option_name IN ('home', 'permalink_structure')"
|
||||||
|
);
|
||||||
|
$stmt->execute();
|
||||||
|
$options = [];
|
||||||
|
while ($optRow = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||||
|
$options[$optRow['option_name']] = $optRow['option_value'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$site_url = isset($options['home']) ? rtrim($options['home'], '/') : '';
|
||||||
|
$permalink_structure = $options['permalink_structure'] ?? '/%postname%/';
|
||||||
|
|
||||||
|
// Build permalinks for each result using post_name
|
||||||
|
foreach ($results['rows'] as &$row) {
|
||||||
|
if (empty($row['permalink'])) {
|
||||||
|
$postName = $row['post_name'] ?? '';
|
||||||
|
|
||||||
|
// Fallback if post_name is empty
|
||||||
|
if (empty($postName)) {
|
||||||
|
$row['permalink'] = $site_url . '/?p=' . $row['ID'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build according to permalink structure
|
||||||
|
if (strpos($permalink_structure, '%post_id%') !== false) {
|
||||||
|
$row['permalink'] = $site_url . '/' . $row['ID'] . '/';
|
||||||
|
} else {
|
||||||
|
$row['permalink'] = $site_url . '/' . $postName . '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($row);
|
||||||
|
|
||||||
|
// Log search for analytics
|
||||||
|
$analytics_cfg = ROI_APU_Search_Analytics::get_config();
|
||||||
|
$search_id = ROI_APU_Search_Analytics::logSearch(
|
||||||
|
$pdo,
|
||||||
|
$prefix,
|
||||||
|
$analytics_cfg,
|
||||||
|
$term_raw,
|
||||||
|
$term,
|
||||||
|
$results,
|
||||||
|
$page,
|
||||||
|
$per_page
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add search_id to response for click tracking
|
||||||
|
$results['search_id'] = $search_id;
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'data' => $results
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log('ROI APU Search Error: ' . $e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'data' => ['message' => 'Error en la busqueda']
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
exit;
|
||||||
414
assets/css/search-ui.css
Normal file
414
assets/css/search-ui.css
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
/**
|
||||||
|
* ROI APU Search UI Styles
|
||||||
|
*
|
||||||
|
* @package ROI_APU_Search
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Variables */
|
||||||
|
:root {
|
||||||
|
--roi-apu-primary: #0d6efd;
|
||||||
|
--roi-apu-primary-hover: #0b5ed7;
|
||||||
|
--roi-apu-bg: #ffffff;
|
||||||
|
--roi-apu-bg-secondary: #f8f9fa;
|
||||||
|
--roi-apu-border: #dee2e6;
|
||||||
|
--roi-apu-text: #212529;
|
||||||
|
--roi-apu-text-muted: #6c757d;
|
||||||
|
--roi-apu-highlight: #fff3cd;
|
||||||
|
--roi-apu-error: #dc3545;
|
||||||
|
--roi-apu-success: #198754;
|
||||||
|
--roi-apu-radius: 8px;
|
||||||
|
--roi-apu-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
--roi-apu-transition: 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container */
|
||||||
|
.roi-apu-search-container {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--roi-apu-text);
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search Form */
|
||||||
|
.roi-apu-search-form {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: var(--roi-apu-bg);
|
||||||
|
border: 2px solid var(--roi-apu-border);
|
||||||
|
border-radius: var(--roi-apu-radius);
|
||||||
|
padding: 0.25rem;
|
||||||
|
transition: border-color var(--roi-apu-transition), box-shadow var(--roi-apu-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-input-wrapper:focus-within {
|
||||||
|
border-color: var(--roi-apu-primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(13, 110, 253, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-search-input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: transparent;
|
||||||
|
outline: none;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-search-input::placeholder {
|
||||||
|
color: var(--roi-apu-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-search-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: var(--roi-apu-primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: calc(var(--roi-apu-radius) - 4px);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--roi-apu-transition);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-search-btn:hover:not(:disabled) {
|
||||||
|
background: var(--roi-apu-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-search-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-search-btn svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error Message */
|
||||||
|
.roi-apu-error {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: #f8d7da;
|
||||||
|
color: var(--roi-apu-error);
|
||||||
|
border-radius: var(--roi-apu-radius);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.roi-apu-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--roi-apu-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-spinner {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 3px solid var(--roi-apu-border);
|
||||||
|
border-top-color: var(--roi-apu-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: roi-apu-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes roi-apu-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info Bar */
|
||||||
|
.roi-apu-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--roi-apu-bg-secondary);
|
||||||
|
border-radius: var(--roi-apu-radius);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-info-total {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-info-time {
|
||||||
|
color: var(--roi-apu-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Results */
|
||||||
|
.roi-apu-results-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-result-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--roi-apu-bg);
|
||||||
|
border: 1px solid var(--roi-apu-border);
|
||||||
|
border-radius: var(--roi-apu-radius);
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
transition: border-color var(--roi-apu-transition), box-shadow var(--roi-apu-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-result-item:hover {
|
||||||
|
border-color: var(--roi-apu-primary);
|
||||||
|
box-shadow: var(--roi-apu-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-result-position {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: var(--roi-apu-bg-secondary);
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--roi-apu-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-result-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-result-title {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--roi-apu-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-result-title mark {
|
||||||
|
background: var(--roi-apu-highlight);
|
||||||
|
padding: 0 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-result-date {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--roi-apu-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-result-arrow {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--roi-apu-text-muted);
|
||||||
|
transition: transform var(--roi-apu-transition), color var(--roi-apu-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-result-item:hover .roi-apu-result-arrow {
|
||||||
|
transform: translateX(4px);
|
||||||
|
color: var(--roi-apu-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No Results */
|
||||||
|
.roi-apu-no-results {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 2rem;
|
||||||
|
background: var(--roi-apu-bg-secondary);
|
||||||
|
border-radius: var(--roi-apu-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-no-results p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-no-results p:first-child {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-suggestions {
|
||||||
|
color: var(--roi-apu-text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination */
|
||||||
|
.roi-apu-pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-pagination-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-page-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
background: var(--roi-apu-bg);
|
||||||
|
border: 1px solid var(--roi-apu-border);
|
||||||
|
border-radius: var(--roi-apu-radius);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--roi-apu-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-page-btn:hover:not(.roi-apu-page-active) {
|
||||||
|
background: var(--roi-apu-bg-secondary);
|
||||||
|
border-color: var(--roi-apu-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-page-btn.roi-apu-page-active {
|
||||||
|
background: var(--roi-apu-primary);
|
||||||
|
border-color: var(--roi-apu-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-page-btn.roi-apu-prev,
|
||||||
|
.roi-apu-page-btn.roi-apu-next {
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-page-ellipsis {
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
color: var(--roi-apu-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.roi-apu-input-wrapper {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-search-btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-btn-text {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-result-item {
|
||||||
|
padding: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-result-position {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-info {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-page-btn.roi-apu-prev,
|
||||||
|
.roi-apu-page-btn.roi-apu-next {
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide text on very small screens */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.roi-apu-btn-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-search-btn {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =================================================================
|
||||||
|
Anuncios en resultados de busqueda
|
||||||
|
Usa variables CSS del tema para consistencia
|
||||||
|
VISIBLES por defecto para que AdSense pueda medirlos y llenarlos
|
||||||
|
Se ocultan via JS si no se llenan despues del timeout
|
||||||
|
================================================================= */
|
||||||
|
|
||||||
|
.roi-apu-ad-item {
|
||||||
|
/* VISIBLE por defecto - AdSense necesita medir el elemento */
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
/* Spacing usando variables del tema o fallbacks */
|
||||||
|
padding: var(--roi-spacing-md, 1rem);
|
||||||
|
margin: var(--roi-spacing-sm, 0.5rem) 0;
|
||||||
|
|
||||||
|
/* Fondo sutil que no compita con resultados */
|
||||||
|
background: var(--roi-bg-light, #f8f9fa);
|
||||||
|
border-radius: var(--roi-radius-sm, 0.375rem);
|
||||||
|
|
||||||
|
/* Centrar contenido del anuncio */
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
/* Borde sutil para separar del contenido */
|
||||||
|
border: 1px solid var(--roi-border-light, rgba(0,0,0,0.05));
|
||||||
|
|
||||||
|
/* Altura minima para que AdSense pueda renderizar */
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ocultar ads que no se llenaron (clase agregada via JS) */
|
||||||
|
.roi-apu-ad-item.ad-unfilled {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-ad-item ins.adsbygoogle {
|
||||||
|
display: block !important;
|
||||||
|
min-height: 100px; /* Altura minima para evitar CLS */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Anuncio superior - mas prominente */
|
||||||
|
.roi-apu-ad-item[data-ad-position="top"] {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: var(--roi-spacing-md, 1rem);
|
||||||
|
background: var(--roi-bg-muted, #f1f3f4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Anuncios entre resultados - menos intrusivos */
|
||||||
|
.roi-apu-ad-item:not([data-ad-position="top"]) {
|
||||||
|
margin: var(--roi-spacing-md, 1rem) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive: En movil, padding reducido */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.roi-apu-ad-item {
|
||||||
|
padding: var(--roi-spacing-sm, 0.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roi-apu-ad-item ins.adsbygoogle {
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =================================================================
|
||||||
|
Dark Mode Support
|
||||||
|
El input siempre tiene fondo blanco, forzar texto oscuro
|
||||||
|
================================================================= */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.roi-apu-search-input {
|
||||||
|
color: var(--roi-apu-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
704
assets/js/search-handler.js
Normal file
704
assets/js/search-handler.js
Normal file
@@ -0,0 +1,704 @@
|
|||||||
|
/**
|
||||||
|
* ROI APU Search Handler
|
||||||
|
*
|
||||||
|
* Handles AJAX search requests with debouncing and pagination
|
||||||
|
*
|
||||||
|
* @package ROI_APU_Search
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Configuration from WordPress
|
||||||
|
const config = window.roiApuSearch || {
|
||||||
|
ajaxUrl: '/wp-admin/admin-ajax.php',
|
||||||
|
apiUrl: '',
|
||||||
|
clickUrl: '',
|
||||||
|
nonce: '',
|
||||||
|
ads: { enabled: false }
|
||||||
|
};
|
||||||
|
|
||||||
|
// State management per instance
|
||||||
|
const instances = new Map();
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// FUNCIONES DE ADSENSE
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera HTML de anuncio AdSense
|
||||||
|
* @param {string} format - Formato del anuncio (auto, in-article, autorelaxed, display)
|
||||||
|
* @param {string|number} position - Posicion del anuncio para identificacion
|
||||||
|
* @returns {string} HTML del anuncio o string vacio si hay error
|
||||||
|
*/
|
||||||
|
function generateAdHtml(format, position) {
|
||||||
|
try {
|
||||||
|
var ads = config.ads;
|
||||||
|
if (!ads || !ads.enabled) return '';
|
||||||
|
|
||||||
|
var slotInfo = getSlotAndFormatByType(format, ads.slots);
|
||||||
|
if (!slotInfo.slot || !ads.publisherId) return '';
|
||||||
|
|
||||||
|
// Determinar tipo de script segun delay
|
||||||
|
var scriptType = ads.delay && ads.delay.enabled ? 'text/plain' : 'text/javascript';
|
||||||
|
var dataAttr = ads.delay && ads.delay.enabled ? ' data-adsense-push' : '';
|
||||||
|
|
||||||
|
// Generar atributos segun formato
|
||||||
|
var formatAttrs = getAdFormatAttributes(format);
|
||||||
|
|
||||||
|
return '<div class="roi-apu-ad-item" data-ad-position="' + position + '">' +
|
||||||
|
'<ins class="adsbygoogle" ' +
|
||||||
|
'style="display:block;min-height:250px" ' +
|
||||||
|
'data-ad-client="' + ads.publisherId + '" ' +
|
||||||
|
'data-ad-slot="' + slotInfo.slot + '" ' +
|
||||||
|
formatAttrs + '></ins>' +
|
||||||
|
'<script type="' + scriptType + '"' + dataAttr + '>' +
|
||||||
|
'try { (adsbygoogle = window.adsbygoogle || []).push({}); } catch(e) { console.warn("AdSense push failed:", e); }' +
|
||||||
|
'<\/script>' +
|
||||||
|
'</div>';
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('generateAdHtml error:', e);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el slot y formato correcto segun el tipo
|
||||||
|
* @param {string} format - Tipo de formato solicitado
|
||||||
|
* @param {object} slots - Objeto con los slots disponibles
|
||||||
|
* @returns {object} {slot, format}
|
||||||
|
*/
|
||||||
|
function getSlotAndFormatByType(format, slots) {
|
||||||
|
if (!slots) return { slot: null, format: 'auto' };
|
||||||
|
|
||||||
|
switch(format) {
|
||||||
|
case 'in-article':
|
||||||
|
return { slot: slots.inArticle || slots.auto, format: 'fluid' };
|
||||||
|
case 'autorelaxed':
|
||||||
|
return { slot: slots.autorelaxed || slots.auto, format: 'autorelaxed' };
|
||||||
|
case 'display':
|
||||||
|
return { slot: slots.display || slots.auto, format: 'rectangle' };
|
||||||
|
case 'auto':
|
||||||
|
default:
|
||||||
|
return { slot: slots.auto, format: 'auto' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera atributos HTML segun el formato de anuncio
|
||||||
|
* @param {string} format - Tipo de formato
|
||||||
|
* @returns {string} Atributos HTML
|
||||||
|
*/
|
||||||
|
function getAdFormatAttributes(format) {
|
||||||
|
switch(format) {
|
||||||
|
case 'in-article':
|
||||||
|
return 'data-ad-layout="in-article" data-ad-format="fluid"';
|
||||||
|
case 'autorelaxed':
|
||||||
|
return 'data-ad-format="autorelaxed"';
|
||||||
|
case 'display':
|
||||||
|
return 'data-ad-format="rectangle" data-full-width-responsive="false"';
|
||||||
|
case 'auto':
|
||||||
|
default:
|
||||||
|
return 'data-ad-format="auto" data-full-width-responsive="true"';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mezcla un array aleatoriamente (Fisher-Yates)
|
||||||
|
* @param {Array} array - Array a mezclar
|
||||||
|
* @returns {Array} Array mezclado
|
||||||
|
*/
|
||||||
|
function shuffleArray(array) {
|
||||||
|
var arr = array.slice(); // Copia para no mutar original
|
||||||
|
for (var i = arr.length - 1; i > 0; i--) {
|
||||||
|
var j = Math.floor(Math.random() * (i + 1));
|
||||||
|
var temp = arr[i];
|
||||||
|
arr[i] = arr[j];
|
||||||
|
arr[j] = temp;
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula posiciones donde insertar anuncios
|
||||||
|
* @param {number} totalResults - Total de resultados de busqueda
|
||||||
|
* @param {object} adsConfig - Configuracion de ads.betweenAds
|
||||||
|
* @returns {number[]} Array de posiciones donde insertar anuncios
|
||||||
|
*/
|
||||||
|
function calculateAdPositions(totalResults, adsConfig) {
|
||||||
|
try {
|
||||||
|
if (!adsConfig || !adsConfig.enabled || totalResults < 2) return [];
|
||||||
|
|
||||||
|
// Max 3 por politicas AdSense, y no mas que resultados-1
|
||||||
|
var max = Math.min(adsConfig.max || 1, totalResults - 1, 3);
|
||||||
|
var positions = [];
|
||||||
|
var pool, i;
|
||||||
|
|
||||||
|
if (adsConfig.position === 'random') {
|
||||||
|
// Posiciones aleatorias entre resultados
|
||||||
|
pool = [];
|
||||||
|
for (i = 1; i < totalResults; i++) {
|
||||||
|
pool.push(i);
|
||||||
|
}
|
||||||
|
return shuffleArray(pool).slice(0, max);
|
||||||
|
|
||||||
|
} else if (adsConfig.position === 'fixed') {
|
||||||
|
// Cada N resultados
|
||||||
|
var every = adsConfig.every || 5;
|
||||||
|
for (i = every; i <= totalResults && positions.length < max; i += every) {
|
||||||
|
positions.push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (adsConfig.position === 'first_half') {
|
||||||
|
// Solo en la primera mitad de resultados
|
||||||
|
var halfPoint = Math.ceil(totalResults / 2);
|
||||||
|
pool = [];
|
||||||
|
for (i = 1; i < halfPoint; i++) {
|
||||||
|
pool.push(i);
|
||||||
|
}
|
||||||
|
return shuffleArray(pool).slice(0, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
return positions;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('calculateAdPositions error:', e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activa slots de AdSense despues de insertar en DOM
|
||||||
|
*/
|
||||||
|
function activateAdsenseSlots() {
|
||||||
|
try {
|
||||||
|
var ads = config.ads;
|
||||||
|
if (!ads || !ads.enabled) return;
|
||||||
|
|
||||||
|
if (ads.delay && ads.delay.enabled) {
|
||||||
|
// Disparar evento para que adsense-loader.js active los scripts
|
||||||
|
window.dispatchEvent(new CustomEvent('roi-adsense-activate'));
|
||||||
|
} else {
|
||||||
|
// Delay deshabilitado - los scripts inline ya se ejecutaron
|
||||||
|
// pero debemos asegurar que adsbygoogle.push() se llame
|
||||||
|
// para slots insertados dinamicamente
|
||||||
|
setTimeout(function() {
|
||||||
|
var newSlots = document.querySelectorAll('.roi-apu-ad-item ins.adsbygoogle:not([data-adsbygoogle-status])');
|
||||||
|
if (newSlots.length > 0 && typeof adsbygoogle !== 'undefined') {
|
||||||
|
newSlots.forEach(function() {
|
||||||
|
try {
|
||||||
|
(adsbygoogle = window.adsbygoogle || []).push({});
|
||||||
|
} catch(e) {
|
||||||
|
// Ignorar - el slot ya fue procesado
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observar estado de los ads para mostrar solo cuando esten llenos
|
||||||
|
watchAdSlots();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('activateAdsenseSlots error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observa los slots de AdSense y oculta los que no se llenan
|
||||||
|
* Los ads estan VISIBLES por defecto via CSS para que AdSense pueda medirlos
|
||||||
|
* Si no se llenan despues del timeout, se ocultan con clase .ad-unfilled
|
||||||
|
* AdSense agrega data-ad-status="filled" o "unfilled" cuando procesa el slot
|
||||||
|
*/
|
||||||
|
function watchAdSlots() {
|
||||||
|
var adContainers = document.querySelectorAll('.roi-apu-ad-item:not(.ad-watched)');
|
||||||
|
if (!adContainers.length) return;
|
||||||
|
|
||||||
|
adContainers.forEach(function(container) {
|
||||||
|
// Marcar como observado para evitar duplicados
|
||||||
|
container.classList.add('ad-watched');
|
||||||
|
|
||||||
|
var ins = container.querySelector('ins.adsbygoogle');
|
||||||
|
if (!ins) {
|
||||||
|
// Sin elemento ins, ocultar
|
||||||
|
container.classList.add('ad-unfilled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Funcion para verificar estado y actuar
|
||||||
|
function checkStatus() {
|
||||||
|
var status = ins.getAttribute('data-ad-status');
|
||||||
|
if (status === 'filled') {
|
||||||
|
// Ad llenado correctamente - mantener visible
|
||||||
|
container.classList.remove('ad-unfilled');
|
||||||
|
return true;
|
||||||
|
} else if (status === 'unfilled') {
|
||||||
|
// Ad no llenado - ocultar
|
||||||
|
container.classList.add('ad-unfilled');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false; // Status aun no definido
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar si ya tiene estado
|
||||||
|
if (checkStatus()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usar MutationObserver para detectar cuando AdSense procesa el slot
|
||||||
|
var observer = new MutationObserver(function(mutations) {
|
||||||
|
mutations.forEach(function(mutation) {
|
||||||
|
if (mutation.type === 'attributes' && mutation.attributeName === 'data-ad-status') {
|
||||||
|
if (checkStatus()) {
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(ins, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['data-ad-status']
|
||||||
|
});
|
||||||
|
|
||||||
|
// Timeout de seguridad: despues de 5s si no hay status, ocultar
|
||||||
|
// Esto maneja el caso donde AdSense no establece data-ad-status
|
||||||
|
setTimeout(function() {
|
||||||
|
observer.disconnect();
|
||||||
|
var status = ins.getAttribute('data-ad-status');
|
||||||
|
if (status !== 'filled') {
|
||||||
|
// No se lleno o sin respuesta - ocultar para evitar espacio vacio
|
||||||
|
container.classList.add('ad-unfilled');
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize all search containers on the page
|
||||||
|
*/
|
||||||
|
function init() {
|
||||||
|
const containers = document.querySelectorAll('.roi-apu-search-container');
|
||||||
|
containers.forEach(initContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize a single search container
|
||||||
|
*/
|
||||||
|
function initContainer(container) {
|
||||||
|
const id = container.id;
|
||||||
|
if (instances.has(id)) return;
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
container,
|
||||||
|
input: container.querySelector('.roi-apu-search-input'),
|
||||||
|
button: container.querySelector('.roi-apu-search-btn'),
|
||||||
|
errorEl: container.querySelector('.roi-apu-error'),
|
||||||
|
loadingEl: container.querySelector('.roi-apu-loading'),
|
||||||
|
infoEl: container.querySelector('.roi-apu-info'),
|
||||||
|
resultsEl: container.querySelector('.roi-apu-results'),
|
||||||
|
paginationEl: container.querySelector('.roi-apu-pagination'),
|
||||||
|
categories: container.dataset.categories || '',
|
||||||
|
perPage: parseInt(container.dataset.perPage, 10) || 10,
|
||||||
|
minChars: parseInt(container.dataset.minChars, 10) || 3,
|
||||||
|
showInfo: container.dataset.showInfo === 'true',
|
||||||
|
currentPage: 1,
|
||||||
|
currentTerm: '',
|
||||||
|
totalResults: 0,
|
||||||
|
searchId: null,
|
||||||
|
debounceTimer: null,
|
||||||
|
abortController: null
|
||||||
|
};
|
||||||
|
|
||||||
|
instances.set(id, state);
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
state.input.addEventListener('input', () => handleInput(state));
|
||||||
|
state.input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
doSearch(state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
state.button.addEventListener('click', () => doSearch(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle input with debounce
|
||||||
|
*/
|
||||||
|
function handleInput(state) {
|
||||||
|
clearTimeout(state.debounceTimer);
|
||||||
|
hideError(state);
|
||||||
|
|
||||||
|
const term = state.input.value.trim();
|
||||||
|
|
||||||
|
if (term.length === 0) {
|
||||||
|
clearResults(state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (term.length < state.minChars) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce search
|
||||||
|
state.debounceTimer = setTimeout(() => {
|
||||||
|
doSearch(state);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute search
|
||||||
|
*/
|
||||||
|
async function doSearch(state, page = 1) {
|
||||||
|
const term = state.input.value.trim();
|
||||||
|
|
||||||
|
if (term.length < state.minChars) {
|
||||||
|
showError(state, `Ingresa al menos ${state.minChars} caracteres`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel previous request
|
||||||
|
if (state.abortController) {
|
||||||
|
state.abortController.abort();
|
||||||
|
}
|
||||||
|
state.abortController = new AbortController();
|
||||||
|
|
||||||
|
state.currentTerm = term;
|
||||||
|
state.currentPage = page;
|
||||||
|
|
||||||
|
showLoading(state);
|
||||||
|
hideError(state);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('action', 'roi_apu_search');
|
||||||
|
formData.append('nonce', config.nonce);
|
||||||
|
formData.append('term', term);
|
||||||
|
formData.append('page', page.toString());
|
||||||
|
formData.append('per_page', state.perPage.toString());
|
||||||
|
if (state.categories) {
|
||||||
|
formData.append('categories', state.categories);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try fast endpoint first, fallback to admin-ajax
|
||||||
|
let url = config.apiUrl || config.ajaxUrl;
|
||||||
|
let response;
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
signal: state.abortController.signal
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Fallback to admin-ajax if fast endpoint fails
|
||||||
|
if (url !== config.ajaxUrl) {
|
||||||
|
url = config.ajaxUrl;
|
||||||
|
response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
signal: state.abortController.signal
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.data?.message || 'Error en la busqueda');
|
||||||
|
}
|
||||||
|
|
||||||
|
state.totalResults = data.data.total || 0;
|
||||||
|
state.searchId = data.data.search_id || null;
|
||||||
|
renderResults(state, data.data);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
return; // Ignore aborted requests
|
||||||
|
}
|
||||||
|
console.error('ROI APU Search Error:', err);
|
||||||
|
showError(state, 'Error al realizar la busqueda. Intenta de nuevo.');
|
||||||
|
clearResults(state);
|
||||||
|
} finally {
|
||||||
|
hideLoading(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render search results (con soporte para ads)
|
||||||
|
*/
|
||||||
|
function renderResults(state, data) {
|
||||||
|
var rows = data.rows;
|
||||||
|
var total = data.total;
|
||||||
|
var time_ms = data.time_ms;
|
||||||
|
var ads = config.ads;
|
||||||
|
|
||||||
|
// Show info
|
||||||
|
if (state.showInfo && total > 0) {
|
||||||
|
state.infoEl.innerHTML =
|
||||||
|
'<span class="roi-apu-info-total">' + formatNumber(total) + ' resultados</span>' +
|
||||||
|
'<span class="roi-apu-info-time">' + time_ms + 'ms</span>';
|
||||||
|
state.infoEl.style.display = 'flex';
|
||||||
|
} else {
|
||||||
|
state.infoEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render no results message
|
||||||
|
if (rows.length === 0) {
|
||||||
|
state.resultsEl.innerHTML =
|
||||||
|
'<div class="roi-apu-no-results">' +
|
||||||
|
'<p>No se encontraron resultados para "<strong>' + escapeHtml(state.currentTerm) + '</strong>"</p>' +
|
||||||
|
'<p class="roi-apu-suggestions">Intenta con terminos mas generales o revisa la ortografia.</p>' +
|
||||||
|
'</div>';
|
||||||
|
state.paginationEl.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular posiciones de anuncios (solo si hay resultados y ads habilitados)
|
||||||
|
var adPositions = {};
|
||||||
|
if (ads && ads.enabled && ads.betweenAds && ads.betweenAds.enabled && rows.length > 1) {
|
||||||
|
var positions = calculateAdPositions(rows.length, ads.betweenAds);
|
||||||
|
for (var p = 0; p < positions.length; p++) {
|
||||||
|
adPositions[positions[p]] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generar anuncio superior (solo en primera pagina y si hay resultados)
|
||||||
|
var topAdHtml = '';
|
||||||
|
if (ads && ads.enabled && ads.topAd && ads.topAd.enabled && state.currentPage === 1 && total > 0) {
|
||||||
|
topAdHtml = generateAdHtml(ads.topAd.format, 'top');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renderizar resultados con anuncios intercalados
|
||||||
|
var resultsHtml = '';
|
||||||
|
for (var index = 0; index < rows.length; index++) {
|
||||||
|
var row = rows[index];
|
||||||
|
var position = (state.currentPage - 1) * state.perPage + index + 1;
|
||||||
|
var title = highlightTerm(row.post_title, state.currentTerm);
|
||||||
|
var date = formatDate(row.post_date);
|
||||||
|
|
||||||
|
// Build click tracking URL if available
|
||||||
|
var href = row.permalink;
|
||||||
|
if (config.clickUrl && state.searchId) {
|
||||||
|
var params = new URLSearchParams({
|
||||||
|
sid: state.searchId,
|
||||||
|
pid: row.ID,
|
||||||
|
pos: position,
|
||||||
|
page: state.currentPage,
|
||||||
|
dest: row.permalink
|
||||||
|
});
|
||||||
|
href = config.clickUrl + '?' + params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsHtml +=
|
||||||
|
'<a href="' + escapeHtml(href) + '" class="roi-apu-result-item" target="_blank" rel="noopener">' +
|
||||||
|
'<span class="roi-apu-result-position">' + position + '</span>' +
|
||||||
|
'<div class="roi-apu-result-content">' +
|
||||||
|
'<h3 class="roi-apu-result-title">' + title + '</h3>' +
|
||||||
|
'<span class="roi-apu-result-date">' + date + '</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<svg class="roi-apu-result-arrow" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">' +
|
||||||
|
'<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/>' +
|
||||||
|
'</svg>' +
|
||||||
|
'</a>';
|
||||||
|
|
||||||
|
// Insertar anuncio despues de este item si corresponde
|
||||||
|
if (adPositions[index + 1]) {
|
||||||
|
resultsHtml += generateAdHtml(ads.betweenAds.format, 'between-' + (index + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construir HTML final
|
||||||
|
state.resultsEl.innerHTML = topAdHtml + '<div class="roi-apu-results-list">' + resultsHtml + '</div>';
|
||||||
|
|
||||||
|
// Render pagination
|
||||||
|
renderPagination(state);
|
||||||
|
|
||||||
|
// Activar anuncios despues de insertar en DOM
|
||||||
|
var hasAds = topAdHtml || Object.keys(adPositions).length > 0;
|
||||||
|
if (ads && ads.enabled && hasAds) {
|
||||||
|
activateAdsenseSlots();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render pagination controls
|
||||||
|
*/
|
||||||
|
function renderPagination(state) {
|
||||||
|
const totalPages = Math.ceil(state.totalResults / state.perPage);
|
||||||
|
|
||||||
|
if (totalPages <= 1) {
|
||||||
|
state.paginationEl.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPage = state.currentPage;
|
||||||
|
let html = '<div class="roi-apu-pagination-inner">';
|
||||||
|
|
||||||
|
// Previous button
|
||||||
|
if (currentPage > 1) {
|
||||||
|
html += `<button type="button" class="roi-apu-page-btn roi-apu-prev" data-page="${currentPage - 1}">« Anterior</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page numbers
|
||||||
|
const startPage = Math.max(1, currentPage - 2);
|
||||||
|
const endPage = Math.min(totalPages, startPage + 4);
|
||||||
|
|
||||||
|
if (startPage > 1) {
|
||||||
|
html += `<button type="button" class="roi-apu-page-btn" data-page="1">1</button>`;
|
||||||
|
if (startPage > 2) {
|
||||||
|
html += `<span class="roi-apu-page-ellipsis">...</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = startPage; i <= endPage; i++) {
|
||||||
|
const activeClass = i === currentPage ? ' roi-apu-page-active' : '';
|
||||||
|
html += `<button type="button" class="roi-apu-page-btn${activeClass}" data-page="${i}">${i}</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endPage < totalPages) {
|
||||||
|
if (endPage < totalPages - 1) {
|
||||||
|
html += `<span class="roi-apu-page-ellipsis">...</span>`;
|
||||||
|
}
|
||||||
|
html += `<button type="button" class="roi-apu-page-btn" data-page="${totalPages}">${totalPages}</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next button
|
||||||
|
if (currentPage < totalPages) {
|
||||||
|
html += `<button type="button" class="roi-apu-page-btn roi-apu-next" data-page="${currentPage + 1}">Siguiente »</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
state.paginationEl.innerHTML = html;
|
||||||
|
state.paginationEl.style.display = 'flex';
|
||||||
|
|
||||||
|
// Add click handlers
|
||||||
|
state.paginationEl.querySelectorAll('.roi-apu-page-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const page = parseInt(btn.dataset.page, 10);
|
||||||
|
doSearch(state, page);
|
||||||
|
// Scroll to top of results
|
||||||
|
state.container.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highlight search term in text
|
||||||
|
*/
|
||||||
|
function highlightTerm(text, term) {
|
||||||
|
const escaped = escapeHtml(text);
|
||||||
|
const terms = term.split(/\s+/).filter(t => t.length >= 2);
|
||||||
|
|
||||||
|
let result = escaped;
|
||||||
|
terms.forEach(t => {
|
||||||
|
const regex = new RegExp(`(${escapeRegex(t)})`, 'gi');
|
||||||
|
result = result.replace(regex, '<mark>$1</mark>');
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format number with thousands separator
|
||||||
|
*/
|
||||||
|
function formatNumber(num) {
|
||||||
|
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date
|
||||||
|
*/
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString('es-MX', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML entities
|
||||||
|
*/
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape regex special characters
|
||||||
|
*/
|
||||||
|
function escapeRegex(str) {
|
||||||
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show loading state
|
||||||
|
*/
|
||||||
|
function showLoading(state) {
|
||||||
|
state.loadingEl.style.display = 'flex';
|
||||||
|
state.button.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide loading state
|
||||||
|
*/
|
||||||
|
function hideLoading(state) {
|
||||||
|
state.loadingEl.style.display = 'none';
|
||||||
|
state.button.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show error message
|
||||||
|
*/
|
||||||
|
function showError(state, message) {
|
||||||
|
state.errorEl.textContent = message;
|
||||||
|
state.errorEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide error message
|
||||||
|
*/
|
||||||
|
function hideError(state) {
|
||||||
|
state.errorEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear results
|
||||||
|
*/
|
||||||
|
function clearResults(state) {
|
||||||
|
state.resultsEl.innerHTML = '';
|
||||||
|
state.paginationEl.style.display = 'none';
|
||||||
|
state.infoEl.style.display = 'none';
|
||||||
|
state.totalResults = 0;
|
||||||
|
state.currentPage = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize when DOM is ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose for external use
|
||||||
|
window.ROI_APU_Search = {
|
||||||
|
init,
|
||||||
|
search: (containerId, term) => {
|
||||||
|
const state = instances.get(containerId);
|
||||||
|
if (state) {
|
||||||
|
state.input.value = term;
|
||||||
|
doSearch(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
})();
|
||||||
295
includes/class-analytics.php
Normal file
295
includes/class-analytics.php
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Analytics - Search and Click Tracking
|
||||||
|
*
|
||||||
|
* Migrated from buscar-apus/app/Analytics.php
|
||||||
|
*
|
||||||
|
* @package ROI_APU_Search
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Prevent direct access
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analytics class for tracking searches and clicks
|
||||||
|
*/
|
||||||
|
final class ROI_APU_Search_Analytics
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Configuration defaults
|
||||||
|
*/
|
||||||
|
private const DEFAULT_CONFIG = [
|
||||||
|
'enabled' => true,
|
||||||
|
'hash_ip' => true,
|
||||||
|
'salt' => 'd!&5wIPA0Tnc0SlGrGso',
|
||||||
|
'table_log' => 'rcp_paginas_querys',
|
||||||
|
'table_click' => 'rcp_paginas_querys_log',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get analytics configuration
|
||||||
|
*/
|
||||||
|
public static function get_config(): array
|
||||||
|
{
|
||||||
|
$config = get_option('roi_apu_search_analytics', []);
|
||||||
|
return array_merge(self::DEFAULT_CONFIG, $config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create/read first-party cookie to identify visitors
|
||||||
|
*/
|
||||||
|
public static function ensureVisitorId(): string
|
||||||
|
{
|
||||||
|
$name = 'apu_vid';
|
||||||
|
$vid = $_COOKIE[$name] ?? '';
|
||||||
|
|
||||||
|
if (!preg_match('/^[a-f0-9]{32}$/', $vid)) {
|
||||||
|
$vid = bin2hex(random_bytes(16));
|
||||||
|
setcookie($name, $vid, [
|
||||||
|
'expires' => time() + 31536000, // 1 year
|
||||||
|
'path' => '/',
|
||||||
|
'secure' => !empty($_SERVER['HTTPS']),
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
]);
|
||||||
|
$_COOKIE[$name] = $vid;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $vid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash IP address (without storing clear IP)
|
||||||
|
*/
|
||||||
|
private static function ipHash(?string $salt): ?string
|
||||||
|
{
|
||||||
|
$ip = $_SERVER['HTTP_CF_CONNECTING_IP']
|
||||||
|
?? $_SERVER['HTTP_X_FORWARDED_FOR']
|
||||||
|
?? $_SERVER['REMOTE_ADDR']
|
||||||
|
?? '';
|
||||||
|
|
||||||
|
if ($ip === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strpos($ip, ',') !== false) {
|
||||||
|
$ip = trim(explode(',', $ip)[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sha1($ip . (string) $salt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse User-Agent string
|
||||||
|
*/
|
||||||
|
private static function parseUA(string $ua): array
|
||||||
|
{
|
||||||
|
$uaL = strtolower($ua);
|
||||||
|
|
||||||
|
// device
|
||||||
|
if (str_contains($uaL, 'ipad') || str_contains($uaL, 'tablet')) {
|
||||||
|
$device = 'tablet';
|
||||||
|
} elseif (
|
||||||
|
str_contains($uaL, 'mobile') || str_contains($uaL, 'iphone') || str_contains($uaL, 'android')
|
||||||
|
) {
|
||||||
|
$device = 'mobile';
|
||||||
|
} else {
|
||||||
|
$device = 'desktop';
|
||||||
|
}
|
||||||
|
|
||||||
|
// browser
|
||||||
|
if (str_contains($uaL, 'edg/')) {
|
||||||
|
$browser = 'edge';
|
||||||
|
} elseif (str_contains($uaL, 'opr/') || str_contains($uaL, 'opera')) {
|
||||||
|
$browser = 'opera';
|
||||||
|
} elseif (str_contains($uaL, 'chrome') && !str_contains($uaL, 'edg/')) {
|
||||||
|
$browser = 'chrome';
|
||||||
|
} elseif (str_contains($uaL, 'safari') && !str_contains($uaL, 'chrome')) {
|
||||||
|
$browser = 'safari';
|
||||||
|
} elseif (str_contains($uaL, 'firefox')) {
|
||||||
|
$browser = 'firefox';
|
||||||
|
} elseif (str_contains($uaL, 'msie') || str_contains($uaL, 'trident')) {
|
||||||
|
$browser = 'ie';
|
||||||
|
} else {
|
||||||
|
$browser = 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
// os
|
||||||
|
if (str_contains($uaL, 'android')) {
|
||||||
|
$os = 'android';
|
||||||
|
} elseif (str_contains($uaL, 'iphone') || str_contains($uaL, 'ipad')) {
|
||||||
|
$os = 'ios';
|
||||||
|
} elseif (str_contains($uaL, 'mac os x')) {
|
||||||
|
$os = 'mac';
|
||||||
|
} elseif (str_contains($uaL, 'windows')) {
|
||||||
|
$os = 'windows';
|
||||||
|
} elseif (str_contains($uaL, 'linux')) {
|
||||||
|
$os = 'linux';
|
||||||
|
} else {
|
||||||
|
$os = 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$device, $browser, $os];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract host and path from referer
|
||||||
|
*/
|
||||||
|
private static function refParts(?string $ref): array
|
||||||
|
{
|
||||||
|
$host = $path = null;
|
||||||
|
|
||||||
|
if ($ref) {
|
||||||
|
$p = parse_url($ref);
|
||||||
|
$host = $p['host'] ?? null;
|
||||||
|
$path = ($p['path'] ?? '') . (isset($p['query']) ? '?' . $p['query'] : '');
|
||||||
|
$path = $path !== '' ? substr($path, 0, 255) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$host, $path];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple tokenization for analytics
|
||||||
|
*/
|
||||||
|
private static function simpleTokens(string $qTerm): array
|
||||||
|
{
|
||||||
|
$s = mb_strtolower($qTerm, 'UTF-8');
|
||||||
|
$x = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
|
||||||
|
if ($x !== false) {
|
||||||
|
$s = $x;
|
||||||
|
}
|
||||||
|
$s = preg_replace('/[^a-z0-9 ]+/i', ' ', $s);
|
||||||
|
$s = preg_replace('/\s+/', ' ', trim($s));
|
||||||
|
$parts = array_filter(explode(' ', $s), static fn($w) => strlen($w) >= 2);
|
||||||
|
|
||||||
|
return array_values($parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a search and return its ID
|
||||||
|
*/
|
||||||
|
public static function logSearch(
|
||||||
|
PDO $pdo,
|
||||||
|
string $prefix,
|
||||||
|
array $cfg,
|
||||||
|
string $qRaw,
|
||||||
|
string $qTerm,
|
||||||
|
array $results,
|
||||||
|
int $page,
|
||||||
|
int $perPage
|
||||||
|
): ?int {
|
||||||
|
try {
|
||||||
|
if (empty($cfg['enabled'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
@session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
$vid = self::ensureVisitorId();
|
||||||
|
$sid = session_id() ?: null;
|
||||||
|
$uid = (function_exists('is_user_logged_in') && is_user_logged_in()) ? get_current_user_id() : null;
|
||||||
|
|
||||||
|
$ua = substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255);
|
||||||
|
[$device, $browser, $os] = self::parseUA($ua);
|
||||||
|
$country = substr((string) ($_SERVER['HTTP_CF_IPCOUNTRY'] ?? ''), 0, 2) ?: null;
|
||||||
|
$ipHash = !empty($cfg['hash_ip']) ? self::ipHash($cfg['salt'] ?? '') : null;
|
||||||
|
[$refHost, $refPath] = self::refParts($_SERVER['HTTP_REFERER'] ?? null);
|
||||||
|
|
||||||
|
$tokens = self::simpleTokens($qTerm);
|
||||||
|
$tokensJson = json_encode($tokens, JSON_UNESCAPED_UNICODE);
|
||||||
|
$qLen = mb_strlen($qTerm, 'UTF-8');
|
||||||
|
|
||||||
|
$total = (int) ($results['total'] ?? 0);
|
||||||
|
$shown = (isset($results['rows']) && is_array($results['rows'])) ? count($results['rows']) : 0;
|
||||||
|
|
||||||
|
$table = $prefix . ($cfg['table_log'] ?? 'rcp_paginas_querys');
|
||||||
|
$sql = "INSERT INTO `{$table}`
|
||||||
|
(visitor_id, session_id, user_id, ip_hash, country, user_agent, device, browser, os,
|
||||||
|
referer_host, referer_path, q_raw, q_term, q_tokens, q_len,
|
||||||
|
page, per_page, total_results, result_count, zero_results)
|
||||||
|
VALUES
|
||||||
|
(:vid, :sid, :uid, :ip_hash, :country, :ua, :device, :browser, :os,
|
||||||
|
:rhost, :rpath, :q_raw, :q_term, :q_tokens, :q_len,
|
||||||
|
:page, :per_page, :total, :shown, :zero)";
|
||||||
|
$st = $pdo->prepare($sql);
|
||||||
|
$st->bindValue(':vid', $vid);
|
||||||
|
$st->bindValue(':sid', $sid);
|
||||||
|
$st->bindValue(':uid', $uid);
|
||||||
|
$st->bindValue(':ip_hash', $ipHash);
|
||||||
|
$st->bindValue(':country', $country);
|
||||||
|
$st->bindValue(':ua', $ua);
|
||||||
|
$st->bindValue(':device', $device);
|
||||||
|
$st->bindValue(':browser', $browser);
|
||||||
|
$st->bindValue(':os', $os);
|
||||||
|
$st->bindValue(':rhost', $refHost);
|
||||||
|
$st->bindValue(':rpath', $refPath);
|
||||||
|
$st->bindValue(':q_raw', $qRaw);
|
||||||
|
$st->bindValue(':q_term', $qTerm);
|
||||||
|
$st->bindValue(':q_tokens', $tokensJson);
|
||||||
|
$st->bindValue(':q_len', $qLen, PDO::PARAM_INT);
|
||||||
|
$st->bindValue(':page', $page, PDO::PARAM_INT);
|
||||||
|
$st->bindValue(':per_page', $perPage, PDO::PARAM_INT);
|
||||||
|
$st->bindValue(':total', $total, PDO::PARAM_INT);
|
||||||
|
$st->bindValue(':shown', $shown, PDO::PARAM_INT);
|
||||||
|
$st->bindValue(':zero', $total === 0 ? 1 : 0, PDO::PARAM_INT);
|
||||||
|
$st->execute();
|
||||||
|
|
||||||
|
return (int) $pdo->lastInsertId();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('ROI APU Search Analytics Error (logSearch): ' . $e->getMessage());
|
||||||
|
return null; // Never break search due to analytics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a click - does not interrupt navigation if it fails
|
||||||
|
*/
|
||||||
|
public static function logClick(
|
||||||
|
PDO $pdo,
|
||||||
|
string $prefix,
|
||||||
|
array $cfg,
|
||||||
|
int $searchId,
|
||||||
|
int $postId,
|
||||||
|
int $position,
|
||||||
|
int $page,
|
||||||
|
string $destUrl
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
if (empty($cfg['enabled'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
@session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
$vid = self::ensureVisitorId();
|
||||||
|
$sid = session_id() ?: null;
|
||||||
|
$uid = (function_exists('is_user_logged_in') && is_user_logged_in()) ? get_current_user_id() : null;
|
||||||
|
|
||||||
|
$table = $prefix . ($cfg['table_click'] ?? 'rcp_paginas_querys_log');
|
||||||
|
$sql = "INSERT INTO `{$table}`
|
||||||
|
(search_id, visitor_id, session_id, user_id, post_id, position, page, dest_url)
|
||||||
|
VALUES (:sid, :vid, :sess, :uid, :pid, :pos, :page, :dest)";
|
||||||
|
$st = $pdo->prepare($sql);
|
||||||
|
$st->bindValue(':sid', $searchId, PDO::PARAM_INT);
|
||||||
|
$st->bindValue(':vid', $vid);
|
||||||
|
$st->bindValue(':sess', $sid);
|
||||||
|
$st->bindValue(':uid', $uid);
|
||||||
|
$st->bindValue(':pid', $postId, PDO::PARAM_INT);
|
||||||
|
$st->bindValue(':pos', $position, PDO::PARAM_INT);
|
||||||
|
$st->bindValue(':page', $page, PDO::PARAM_INT);
|
||||||
|
$st->bindValue(':dest', substr($destUrl, 0, 255));
|
||||||
|
$st->execute();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('ROI APU Search Analytics Error (logClick): ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
120
includes/class-db-connection.php
Normal file
120
includes/class-db-connection.php
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Database Connection with PDO Persistent
|
||||||
|
*
|
||||||
|
* @package ROI_APU_Search
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Prevent direct access
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton class for PDO database connection with persistent connections
|
||||||
|
*/
|
||||||
|
final class ROI_APU_Search_DB
|
||||||
|
{
|
||||||
|
private static ?self $instance = null;
|
||||||
|
private ?PDO $pdo = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get singleton instance
|
||||||
|
*/
|
||||||
|
public static function get_instance(): self
|
||||||
|
{
|
||||||
|
if (self::$instance === null) {
|
||||||
|
self::$instance = new self();
|
||||||
|
}
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private constructor
|
||||||
|
*/
|
||||||
|
private function __construct()
|
||||||
|
{
|
||||||
|
$this->connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Establish PDO connection with persistent connection enabled
|
||||||
|
*/
|
||||||
|
private function connect(): void
|
||||||
|
{
|
||||||
|
if ($this->pdo !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get WordPress database credentials
|
||||||
|
$host = DB_HOST;
|
||||||
|
$name = DB_NAME;
|
||||||
|
$user = DB_USER;
|
||||||
|
$pass = DB_PASSWORD;
|
||||||
|
$charset = defined('DB_CHARSET') ? DB_CHARSET : 'utf8mb4';
|
||||||
|
|
||||||
|
// Handle port in host
|
||||||
|
$port = 3306;
|
||||||
|
if (strpos($host, ':') !== false) {
|
||||||
|
[$host, $port] = explode(':', $host);
|
||||||
|
$port = (int) $port;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dsn = "mysql:host={$host};port={$port};dbname={$name};charset={$charset}";
|
||||||
|
|
||||||
|
$options = [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => true, // Same as original buscar-apus (enables reusing :q placeholder)
|
||||||
|
PDO::ATTR_PERSISTENT => true, // Enable persistent connections
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->pdo = new PDO($dsn, $user, $pass, $options);
|
||||||
|
|
||||||
|
// Set MySQL specific options (same as original buscar-apus)
|
||||||
|
$this->pdo->exec("SET NAMES {$charset} COLLATE {$charset}_unicode_ci");
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
// Log error but don't expose details
|
||||||
|
error_log('ROI APU Search DB Error: ' . $e->getMessage());
|
||||||
|
throw new RuntimeException('Database connection failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the PDO instance
|
||||||
|
*/
|
||||||
|
public function get_pdo(): PDO
|
||||||
|
{
|
||||||
|
if ($this->pdo === null) {
|
||||||
|
$this->connect();
|
||||||
|
}
|
||||||
|
return $this->pdo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get WordPress table prefix
|
||||||
|
*/
|
||||||
|
public function get_prefix(): string
|
||||||
|
{
|
||||||
|
global $table_prefix;
|
||||||
|
return $table_prefix ?? 'wp_';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prevent cloning
|
||||||
|
*/
|
||||||
|
private function __clone()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prevent unserialization
|
||||||
|
*/
|
||||||
|
public function __wakeup()
|
||||||
|
{
|
||||||
|
throw new RuntimeException('Cannot unserialize singleton');
|
||||||
|
}
|
||||||
|
}
|
||||||
195
includes/class-redis-cache.php
Normal file
195
includes/class-redis-cache.php
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Redis Cache Handler for ROI APU Search
|
||||||
|
*
|
||||||
|
* Provides caching layer for search results using Redis.
|
||||||
|
*
|
||||||
|
* @package ROI_APU_Search
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Prevent direct access
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis cache handler with singleton pattern
|
||||||
|
*/
|
||||||
|
final class ROI_APU_Search_Redis
|
||||||
|
{
|
||||||
|
private static ?self $instance = null;
|
||||||
|
private ?\Redis $redis = null;
|
||||||
|
private bool $available = false;
|
||||||
|
|
||||||
|
private const CACHE_PREFIX = 'apu_search:';
|
||||||
|
private const CACHE_TTL = 900; // 15 minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get singleton instance
|
||||||
|
*/
|
||||||
|
public static function get_instance(): self
|
||||||
|
{
|
||||||
|
if (self::$instance === null) {
|
||||||
|
self::$instance = new self();
|
||||||
|
}
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private constructor - initialize Redis connection
|
||||||
|
*/
|
||||||
|
private function __construct()
|
||||||
|
{
|
||||||
|
$this->connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to Redis server
|
||||||
|
*/
|
||||||
|
private function connect(): void
|
||||||
|
{
|
||||||
|
if (!class_exists('Redis')) {
|
||||||
|
$this->available = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->redis = new \Redis();
|
||||||
|
$connected = @$this->redis->connect('127.0.0.1', 6379, 1.0);
|
||||||
|
|
||||||
|
if ($connected) {
|
||||||
|
// Select database 1 for search cache (avoid conflicts)
|
||||||
|
$this->redis->select(1);
|
||||||
|
$this->available = true;
|
||||||
|
} else {
|
||||||
|
$this->available = false;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('ROI APU Search Redis Error: ' . $e->getMessage());
|
||||||
|
$this->available = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Redis is available
|
||||||
|
*/
|
||||||
|
public function isAvailable(): bool
|
||||||
|
{
|
||||||
|
return $this->available;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate cache key from search parameters
|
||||||
|
*/
|
||||||
|
public function generateKey(string $term, int $limit, int $offset, array $categories): string
|
||||||
|
{
|
||||||
|
$term = mb_strtolower(trim($term), 'UTF-8');
|
||||||
|
sort($categories);
|
||||||
|
$cats = implode(',', $categories);
|
||||||
|
|
||||||
|
return self::CACHE_PREFIX . md5("{$term}|{$limit}|{$offset}|{$cats}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached search results
|
||||||
|
*
|
||||||
|
* @param string $key Cache key
|
||||||
|
* @return array|null Cached results or null if not found
|
||||||
|
*/
|
||||||
|
public function get(string $key): ?array
|
||||||
|
{
|
||||||
|
if (!$this->available) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$data = $this->redis->get($key);
|
||||||
|
if ($data === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($data, true);
|
||||||
|
return is_array($decoded) ? $decoded : null;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('ROI APU Search Redis Get Error: ' . $e->getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set cached search results
|
||||||
|
*
|
||||||
|
* @param string $key Cache key
|
||||||
|
* @param array $data Data to cache
|
||||||
|
* @param int|null $ttl Time to live in seconds (default: 900)
|
||||||
|
* @return bool Success status
|
||||||
|
*/
|
||||||
|
public function set(string $key, array $data, ?int $ttl = null): bool
|
||||||
|
{
|
||||||
|
if (!$this->available) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$ttl = $ttl ?? self::CACHE_TTL;
|
||||||
|
$json = json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
return $this->redis->setex($key, $ttl, $json);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('ROI APU Search Redis Set Error: ' . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush all search cache entries
|
||||||
|
*
|
||||||
|
* @return int Number of keys deleted
|
||||||
|
*/
|
||||||
|
public function flush(): int
|
||||||
|
{
|
||||||
|
if (!$this->available) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$keys = $this->redis->keys(self::CACHE_PREFIX . '*');
|
||||||
|
if (empty($keys)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redis->del($keys);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('ROI APU Search Redis Flush Error: ' . $e->getMessage());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache statistics
|
||||||
|
*
|
||||||
|
* @return array Stats including keys count and memory usage
|
||||||
|
*/
|
||||||
|
public function getStats(): array
|
||||||
|
{
|
||||||
|
if (!$this->available) {
|
||||||
|
return ['available' => false];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$keys = $this->redis->keys(self::CACHE_PREFIX . '*');
|
||||||
|
$info = $this->redis->info('memory');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'available' => true,
|
||||||
|
'keys_count' => count($keys),
|
||||||
|
'memory_used' => $info['used_memory_human'] ?? 'unknown',
|
||||||
|
'ttl' => self::CACHE_TTL,
|
||||||
|
];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return ['available' => true, 'error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
826
includes/class-search-engine.php
Normal file
826
includes/class-search-engine.php
Normal file
@@ -0,0 +1,826 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Hybrid Search Engine with multi-bucket scoring
|
||||||
|
*
|
||||||
|
* @package ROI_APU_Search
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Prevent direct access
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search engine with hybrid multi-bucket algorithm and category filtering
|
||||||
|
*/
|
||||||
|
final class ROI_APU_Search_Engine
|
||||||
|
{
|
||||||
|
private PDO $pdo;
|
||||||
|
private string $prefix;
|
||||||
|
private string $posts_table;
|
||||||
|
private string $term_rel_table;
|
||||||
|
private string $term_tax_table;
|
||||||
|
|
||||||
|
// Scoring weights (same as original)
|
||||||
|
private const RAW_REL_MULT = 40.0;
|
||||||
|
private const W_COVERAGE = 200.0;
|
||||||
|
private const W_STARTSWITH = 240.0;
|
||||||
|
private const W_WORD_EXACT = 140.0;
|
||||||
|
private const W_FUZZY_TOKEN_MAX = 120.0;
|
||||||
|
private const W_RECENCY_MAX = 120.0;
|
||||||
|
private const W_PROX_CHARS = 620.0;
|
||||||
|
private const W_ORDERED_WINDOW = 1600.0;
|
||||||
|
private const W_ORDERED_ANCHOR = 300.0;
|
||||||
|
private const LEN_PEN_START = 180;
|
||||||
|
private const LEN_PEN_PER_CHAR = 0.55;
|
||||||
|
private const REQ_MISS_PER_TOKEN = 420.0;
|
||||||
|
private const REQ_BASE_PENALTY = 140.0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
public function __construct(PDO $pdo)
|
||||||
|
{
|
||||||
|
$this->pdo = $pdo;
|
||||||
|
|
||||||
|
global $table_prefix;
|
||||||
|
$prefix = $table_prefix ?? 'wp_';
|
||||||
|
|
||||||
|
$this->prefix = $prefix;
|
||||||
|
$this->posts_table = $prefix . 'posts';
|
||||||
|
$this->term_rel_table = $prefix . 'term_relationships';
|
||||||
|
$this->term_tax_table = $prefix . 'term_taxonomy';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the search
|
||||||
|
*
|
||||||
|
* @param string $term Search term
|
||||||
|
* @param int $limit Results per page
|
||||||
|
* @param int $offset Pagination offset
|
||||||
|
* @param array $category_ids Optional category IDs to filter by
|
||||||
|
* @return array Search results with total, rows, mode, time
|
||||||
|
*/
|
||||||
|
public function run(string $term, int $limit, int $offset, array $category_ids = []): array
|
||||||
|
{
|
||||||
|
$t0 = microtime(true);
|
||||||
|
|
||||||
|
// Try Redis cache first
|
||||||
|
$redis = ROI_APU_Search_Redis::get_instance();
|
||||||
|
$cacheKey = $redis->generateKey($term, $limit, $offset, $category_ids);
|
||||||
|
$cached = $redis->get($cacheKey);
|
||||||
|
if ($cached !== null) {
|
||||||
|
$cached['time_ms'] = round((microtime(true) - $t0) * 1000, 2);
|
||||||
|
$cached['cached'] = true;
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokens = self::tokens($term);
|
||||||
|
|
||||||
|
// Pool sizes
|
||||||
|
$capFull = max(120, min(300, $limit * 8));
|
||||||
|
$capLike = max(120, min(300, $limit * 8));
|
||||||
|
$capPref = max(60, min(200, $limit * 4));
|
||||||
|
$capEq = min(40, $limit * 2);
|
||||||
|
$capCont = max(80, min(240, $limit * 6));
|
||||||
|
|
||||||
|
// Fetch from all buckets
|
||||||
|
// Note: Skip CONTAINS when only 1 token since LIKE_ALL already does '%token%'
|
||||||
|
$buckets = [
|
||||||
|
['name' => 'LIKE_ALL', 'base' => 900.0, 'rows' => $this->fetchAllTokensLike($tokens, $capLike, $category_ids)],
|
||||||
|
['name' => 'FULLTEXT', 'base' => 700.0, 'rows' => $this->fetchFulltextTitle($term, $capFull, $category_ids)],
|
||||||
|
['name' => 'STARTS', 'base' => 650.0, 'rows' => $this->fetchStartsWith($term, $capPref, $category_ids)],
|
||||||
|
['name' => 'EQUALS', 'base' => 1200.0, 'rows' => $this->fetchEquals($term, $capEq, $category_ids)],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Only add CONTAINS bucket when multiple tokens (otherwise LIKE_ALL is equivalent)
|
||||||
|
if (count($tokens) > 1) {
|
||||||
|
$buckets[] = ['name' => 'CONTAINS', 'base' => 500.0, 'rows' => $this->fetchContains($term, $capCont, $category_ids)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate by normalized title
|
||||||
|
$seen = [];
|
||||||
|
$pool = [];
|
||||||
|
foreach ($buckets as $b) {
|
||||||
|
foreach ($b['rows'] as $r) {
|
||||||
|
$norm = self::normTitle($r['post_title']);
|
||||||
|
if (isset($seen[$norm])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seen[$norm] = true;
|
||||||
|
|
||||||
|
$pool[] = [
|
||||||
|
'ID' => (int) $r['ID'],
|
||||||
|
'post_title' => (string) $r['post_title'],
|
||||||
|
'post_date' => (string) $r['post_date'],
|
||||||
|
'post_name' => (string) ($r['post_name'] ?? ''),
|
||||||
|
'bucket' => $b['name'],
|
||||||
|
'baseW' => (float) $b['base'],
|
||||||
|
'raw_rel' => isset($r['raw_rel']) ? (float) $r['raw_rel'] : 0.0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$poolTotal = count($pool);
|
||||||
|
if ($poolTotal === 0) {
|
||||||
|
$elapsed = round((microtime(true) - $t0) * 1000, 2);
|
||||||
|
return ['total' => 0, 'rows' => [], 'modo' => 'HYBRID', 'time_ms' => $elapsed];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-rank with scoring signals
|
||||||
|
foreach ($pool as &$it) {
|
||||||
|
$title = $it['post_title'];
|
||||||
|
$date = $it['post_date'];
|
||||||
|
$rawRel = $it['raw_rel'];
|
||||||
|
$baseW = $it['baseW'];
|
||||||
|
|
||||||
|
$score = $baseW
|
||||||
|
+ ($rawRel * self::RAW_REL_MULT)
|
||||||
|
+ self::coverageBoost($title, $tokens)
|
||||||
|
+ self::orderedWindowBoost($title, $tokens)
|
||||||
|
+ self::proximityBoost($title, $tokens)
|
||||||
|
+ self::startsWithBoost($title, $term)
|
||||||
|
+ self::wordExactBoost($title, $term)
|
||||||
|
+ (self::levenshteinSimilarity($title, $term) * 160.0)
|
||||||
|
+ self::tokenFuzzyBoost($title, $tokens)
|
||||||
|
+ self::recencyBoost($date)
|
||||||
|
+ self::lengthPenalty($title)
|
||||||
|
+ self::requiredTokensPenalty($title, $tokens);
|
||||||
|
|
||||||
|
$it['score'] = $score;
|
||||||
|
}
|
||||||
|
unset($it);
|
||||||
|
|
||||||
|
// Sort by score
|
||||||
|
usort($pool, function ($a, $b) {
|
||||||
|
if ($a['score'] === $b['score']) {
|
||||||
|
return strcmp($b['post_date'], $a['post_date']);
|
||||||
|
}
|
||||||
|
return ($a['score'] < $b['score']) ? 1 : -1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Paginate
|
||||||
|
$pageRows = array_slice($pool, $offset, $limit);
|
||||||
|
|
||||||
|
$rows = array_map(fn($r) => [
|
||||||
|
'ID' => $r['ID'],
|
||||||
|
'post_title' => $r['post_title'],
|
||||||
|
'post_date' => $r['post_date'],
|
||||||
|
'post_name' => $r['post_name'] ?? '',
|
||||||
|
'permalink' => '', // Se construirá en search-endpoint.php
|
||||||
|
], $pageRows);
|
||||||
|
|
||||||
|
$elapsed = round((microtime(true) - $t0) * 1000, 2);
|
||||||
|
|
||||||
|
$result = [
|
||||||
|
'total' => $poolTotal,
|
||||||
|
'rows' => $rows,
|
||||||
|
'modo' => 'HYBRID',
|
||||||
|
'time_ms' => $elapsed,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Save to Redis cache
|
||||||
|
$redis->set($cacheKey, $result);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build category JOIN clause
|
||||||
|
*/
|
||||||
|
private function buildCategoryJoin(array $category_ids): string
|
||||||
|
{
|
||||||
|
if (empty($category_ids)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return " INNER JOIN {$this->term_rel_table} tr ON p.ID = tr.object_id
|
||||||
|
INNER JOIN {$this->term_tax_table} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
|
||||||
|
AND tt.taxonomy = 'category' ";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build category WHERE clause
|
||||||
|
*/
|
||||||
|
private function buildCategoryWhere(array $category_ids, array &$params): string
|
||||||
|
{
|
||||||
|
if (empty($category_ids)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$placeholders = [];
|
||||||
|
foreach ($category_ids as $i => $cat_id) {
|
||||||
|
$key = ":cat_{$i}";
|
||||||
|
$placeholders[] = $key;
|
||||||
|
$params[$key] = $cat_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ' AND tt.term_id IN (' . implode(',', $placeholders) . ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch exact matches
|
||||||
|
*/
|
||||||
|
private function fetchEquals(string $term, int $limit, array $category_ids): array
|
||||||
|
{
|
||||||
|
$params = [':t' => $term, ':lim' => $limit];
|
||||||
|
$catJoin = $this->buildCategoryJoin($category_ids);
|
||||||
|
$catWhere = $this->buildCategoryWhere($category_ids, $params);
|
||||||
|
|
||||||
|
$sql = "SELECT DISTINCT p.ID, p.post_title, p.post_date, p.post_name
|
||||||
|
FROM {$this->posts_table} p
|
||||||
|
{$catJoin}
|
||||||
|
WHERE p.post_type = 'post' AND p.post_status = 'publish'
|
||||||
|
AND p.post_title COLLATE utf8mb4_general_ci = :t
|
||||||
|
{$catWhere}
|
||||||
|
ORDER BY p.post_date DESC
|
||||||
|
LIMIT :lim";
|
||||||
|
|
||||||
|
$st = $this->pdo->prepare($sql);
|
||||||
|
foreach ($params as $key => $val) {
|
||||||
|
$st->bindValue($key, $val, is_int($val) ? PDO::PARAM_INT : PDO::PARAM_STR);
|
||||||
|
}
|
||||||
|
$st->execute();
|
||||||
|
return $st->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch starts with matches
|
||||||
|
*/
|
||||||
|
private function fetchStartsWith(string $term, int $limit, array $category_ids): array
|
||||||
|
{
|
||||||
|
$prefix = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $term) . '%';
|
||||||
|
$params = [':p' => $prefix, ':lim' => $limit];
|
||||||
|
$catJoin = $this->buildCategoryJoin($category_ids);
|
||||||
|
$catWhere = $this->buildCategoryWhere($category_ids, $params);
|
||||||
|
|
||||||
|
$sql = "SELECT DISTINCT p.ID, p.post_title, p.post_date, p.post_name
|
||||||
|
FROM {$this->posts_table} p
|
||||||
|
{$catJoin}
|
||||||
|
WHERE p.post_type = 'post' AND p.post_status = 'publish'
|
||||||
|
AND p.post_title LIKE :p ESCAPE '\\\\'
|
||||||
|
{$catWhere}
|
||||||
|
ORDER BY p.post_date DESC
|
||||||
|
LIMIT :lim";
|
||||||
|
|
||||||
|
$st = $this->pdo->prepare($sql);
|
||||||
|
foreach ($params as $key => $val) {
|
||||||
|
$st->bindValue($key, $val, is_int($val) ? PDO::PARAM_INT : PDO::PARAM_STR);
|
||||||
|
}
|
||||||
|
$st->execute();
|
||||||
|
return $st->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch FULLTEXT matches on title
|
||||||
|
*/
|
||||||
|
private function fetchFulltextTitle(string $term, int $limit, array $category_ids): array
|
||||||
|
{
|
||||||
|
$q = self::booleanQuery($term);
|
||||||
|
if ($q === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$params = [':q' => $q, ':lim' => $limit];
|
||||||
|
$catJoin = $this->buildCategoryJoin($category_ids);
|
||||||
|
$catWhere = $this->buildCategoryWhere($category_ids, $params);
|
||||||
|
|
||||||
|
$sql = "SELECT DISTINCT p.ID, p.post_title, p.post_date, p.post_name,
|
||||||
|
MATCH(p.post_title) AGAINST (:q IN BOOLEAN MODE) AS raw_rel
|
||||||
|
FROM {$this->posts_table} p
|
||||||
|
{$catJoin}
|
||||||
|
WHERE p.post_type = 'post' AND p.post_status = 'publish'
|
||||||
|
AND MATCH(p.post_title) AGAINST (:q IN BOOLEAN MODE)
|
||||||
|
{$catWhere}
|
||||||
|
ORDER BY raw_rel DESC, p.post_date DESC
|
||||||
|
LIMIT :lim";
|
||||||
|
|
||||||
|
$st = $this->pdo->prepare($sql);
|
||||||
|
foreach ($params as $key => $val) {
|
||||||
|
$st->bindValue($key, $val, is_int($val) ? PDO::PARAM_INT : PDO::PARAM_STR);
|
||||||
|
}
|
||||||
|
$st->execute();
|
||||||
|
return $st->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all tokens with LIKE (AND)
|
||||||
|
*/
|
||||||
|
private function fetchAllTokensLike(array $tokens, int $limit, array $category_ids): array
|
||||||
|
{
|
||||||
|
if (empty($tokens)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$params = [':lim' => $limit];
|
||||||
|
$likeConds = [];
|
||||||
|
foreach ($tokens as $i => $t) {
|
||||||
|
$key = ":lk{$i}";
|
||||||
|
$likeConds[] = "p.post_title LIKE {$key} ESCAPE '\\\\'";
|
||||||
|
$params[$key] = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $t) . '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
$catJoin = $this->buildCategoryJoin($category_ids);
|
||||||
|
$catWhere = $this->buildCategoryWhere($category_ids, $params);
|
||||||
|
|
||||||
|
$where = implode(' AND ', $likeConds);
|
||||||
|
$sql = "SELECT DISTINCT p.ID, p.post_title, p.post_date, p.post_name
|
||||||
|
FROM {$this->posts_table} p
|
||||||
|
{$catJoin}
|
||||||
|
WHERE p.post_type = 'post' AND p.post_status = 'publish' AND {$where}
|
||||||
|
{$catWhere}
|
||||||
|
ORDER BY p.post_date DESC
|
||||||
|
LIMIT :lim";
|
||||||
|
|
||||||
|
$st = $this->pdo->prepare($sql);
|
||||||
|
foreach ($params as $key => $val) {
|
||||||
|
$st->bindValue($key, $val, is_int($val) ? PDO::PARAM_INT : PDO::PARAM_STR);
|
||||||
|
}
|
||||||
|
$st->execute();
|
||||||
|
return $st->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch contains matches
|
||||||
|
*/
|
||||||
|
private function fetchContains(string $term, int $limit, array $category_ids): array
|
||||||
|
{
|
||||||
|
$like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $term) . '%';
|
||||||
|
$params = [':l' => $like, ':lim' => $limit];
|
||||||
|
$catJoin = $this->buildCategoryJoin($category_ids);
|
||||||
|
$catWhere = $this->buildCategoryWhere($category_ids, $params);
|
||||||
|
|
||||||
|
$sql = "SELECT DISTINCT p.ID, p.post_title, p.post_date, p.post_name
|
||||||
|
FROM {$this->posts_table} p
|
||||||
|
{$catJoin}
|
||||||
|
WHERE p.post_type = 'post' AND p.post_status = 'publish'
|
||||||
|
AND p.post_title LIKE :l ESCAPE '\\\\'
|
||||||
|
{$catWhere}
|
||||||
|
ORDER BY p.post_date DESC
|
||||||
|
LIMIT :lim";
|
||||||
|
|
||||||
|
$st = $this->pdo->prepare($sql);
|
||||||
|
foreach ($params as $key => $val) {
|
||||||
|
$st->bindValue($key, $val, is_int($val) ? PDO::PARAM_INT : PDO::PARAM_STR);
|
||||||
|
}
|
||||||
|
$st->execute();
|
||||||
|
return $st->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Text/Token Utilities ====================
|
||||||
|
|
||||||
|
private static function asciiFold(string $s): string
|
||||||
|
{
|
||||||
|
$s = mb_strtolower($s, 'UTF-8');
|
||||||
|
$x = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
|
||||||
|
if ($x !== false) {
|
||||||
|
$s = $x;
|
||||||
|
}
|
||||||
|
$s = preg_replace('/[^a-z0-9 ]+/i', ' ', $s);
|
||||||
|
$s = preg_replace('/\s+/', ' ', trim($s));
|
||||||
|
return $s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normTitle(string $title): string
|
||||||
|
{
|
||||||
|
return substr(self::asciiFold($title), 0, 160);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function tokens(string $term): array
|
||||||
|
{
|
||||||
|
$t = self::asciiFold($term);
|
||||||
|
$raw = array_values(array_filter(explode(' ', $t), fn($x) => $x !== ''));
|
||||||
|
|
||||||
|
$keep1 = ['f', 'x'];
|
||||||
|
$parts = [];
|
||||||
|
foreach ($raw as $x) {
|
||||||
|
if (ctype_digit($x)) {
|
||||||
|
$parts[] = $x;
|
||||||
|
} elseif (strlen($x) >= 2) {
|
||||||
|
$parts[] = $x;
|
||||||
|
} elseif (in_array($x, $keep1, true)) {
|
||||||
|
$parts[] = $x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedupe preserving order
|
||||||
|
$seen = [];
|
||||||
|
$out = [];
|
||||||
|
foreach ($parts as $p) {
|
||||||
|
if (!isset($seen[$p])) {
|
||||||
|
$seen[$p] = true;
|
||||||
|
$out[] = $p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function booleanQuery(string $input): string
|
||||||
|
{
|
||||||
|
$input = preg_replace("/[^\\p{L}\\p{N}\\s\"'\\+\\-\\*]/u", ' ', trim($input));
|
||||||
|
$len = mb_strlen($input, 'UTF-8');
|
||||||
|
$buf = '';
|
||||||
|
$inQ = false;
|
||||||
|
$out = [];
|
||||||
|
|
||||||
|
for ($i = 0; $i < $len; $i++) {
|
||||||
|
$ch = mb_substr($input, $i, 1, 'UTF-8');
|
||||||
|
if ($ch === '"') {
|
||||||
|
if ($inQ) {
|
||||||
|
$buf .= $ch;
|
||||||
|
if ($buf !== '""') {
|
||||||
|
$out[] = $buf;
|
||||||
|
}
|
||||||
|
$buf = '';
|
||||||
|
$inQ = false;
|
||||||
|
} else {
|
||||||
|
if ($buf !== '') {
|
||||||
|
$out[] = $buf;
|
||||||
|
$buf = '';
|
||||||
|
}
|
||||||
|
$buf = '"';
|
||||||
|
$inQ = true;
|
||||||
|
}
|
||||||
|
} elseif (preg_match('/\s/u', $ch)) {
|
||||||
|
if ($inQ) {
|
||||||
|
$buf .= $ch;
|
||||||
|
} else {
|
||||||
|
if ($buf !== '') {
|
||||||
|
$out[] = $buf;
|
||||||
|
$buf = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$buf .= $ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($buf !== '') {
|
||||||
|
$out[] = $inQ ? ($buf . '"') : $buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = [];
|
||||||
|
foreach ($out as $tok) {
|
||||||
|
if ($tok === '' || mb_strlen($tok, 'UTF-8') < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$U = strtoupper($tok);
|
||||||
|
if ($tok[0] === '"' && substr($tok, -1) === '"') {
|
||||||
|
$parts[] = '+' . $tok;
|
||||||
|
} elseif (in_array($U, ['AND', 'OR', 'NOT'], true)) {
|
||||||
|
$parts[] = $U;
|
||||||
|
} else {
|
||||||
|
$parts[] = '+' . $tok . '*';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return implode(' ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Scoring Functions ====================
|
||||||
|
|
||||||
|
private static function coverageBoost(string $title, array $tokens): float
|
||||||
|
{
|
||||||
|
if (empty($tokens)) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
$t = self::asciiFold($title);
|
||||||
|
$hit = 0;
|
||||||
|
foreach ($tokens as $tok) {
|
||||||
|
if ($tok !== '' && strpos($t, $tok) !== false) {
|
||||||
|
$hit++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ($hit / max(1, count($tokens))) * self::W_COVERAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function requiredTokensPenalty(string $title, array $tokens): float
|
||||||
|
{
|
||||||
|
$n = count($tokens);
|
||||||
|
if ($n === 0 || $n > 4) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$t = self::asciiFold($title);
|
||||||
|
$hit = 0;
|
||||||
|
foreach ($tokens as $tok) {
|
||||||
|
if ($tok !== '' && strpos($t, $tok) !== false) {
|
||||||
|
$hit++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$miss = $n - $hit;
|
||||||
|
if ($miss <= 0) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return -(self::REQ_BASE_PENALTY + self::REQ_MISS_PER_TOKEN * $miss);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function startsWithBoost(string $title, string $term): float
|
||||||
|
{
|
||||||
|
$a = self::asciiFold($title);
|
||||||
|
$b = self::asciiFold($term);
|
||||||
|
return str_starts_with($a, $b) ? self::W_STARTSWITH : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function wordExactBoost(string $title, string $term): float
|
||||||
|
{
|
||||||
|
$a = ' ' . self::asciiFold($title) . ' ';
|
||||||
|
$b = self::asciiFold($term);
|
||||||
|
if ($b === '') {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
return preg_match('/\b' . preg_quote($b, '/') . '\b/u', $a) ? self::W_WORD_EXACT : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function recencyBoost(string $date): float
|
||||||
|
{
|
||||||
|
$d = strtotime($date);
|
||||||
|
if (!$d) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
$days = max(1, (time() - $d) / 86400);
|
||||||
|
return self::W_RECENCY_MAX / (1.0 + $days / 180.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function levenshteinSimilarity(string $a, string $b): float
|
||||||
|
{
|
||||||
|
$aa = substr(self::asciiFold($a), 0, 80);
|
||||||
|
$bb = substr(self::asciiFold($b), 0, 80);
|
||||||
|
if ($aa === '' || $bb === '') {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
$dist = levenshtein($aa, $bb);
|
||||||
|
$max = max(strlen($aa), strlen($bb));
|
||||||
|
return $max > 0 ? max(0.0, 1.0 - ($dist / $max)) : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function tokenFuzzyBoost(string $title, array $tokens): float
|
||||||
|
{
|
||||||
|
if (empty($tokens)) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
$tw = array_slice(preg_split('/\s+/', self::asciiFold($title)), 0, 12);
|
||||||
|
if (empty($tw)) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
$best = 0.0;
|
||||||
|
foreach ($tokens as $tok) {
|
||||||
|
$tokA = self::asciiFold($tok);
|
||||||
|
foreach ($tw as $w) {
|
||||||
|
if ($w === '' || $tokA === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$max = max(strlen($tokA), strlen($w));
|
||||||
|
if ($max === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$sim = 1.0 - (levenshtein($tokA, $w) / $max);
|
||||||
|
if ($sim > $best) {
|
||||||
|
$best = $sim;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return max(0.0, $best) * self::W_FUZZY_TOKEN_MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function findPositions(string $foldedTitle, string $token): array
|
||||||
|
{
|
||||||
|
$T = $foldedTitle;
|
||||||
|
$occ = [];
|
||||||
|
$lenT = strlen($T);
|
||||||
|
$lenK = strlen($token);
|
||||||
|
if ($lenK === 0) {
|
||||||
|
return $occ;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasLetter = (bool) preg_match('/[a-z]/', $token);
|
||||||
|
$hasDigit = (bool) preg_match('/[0-9]/', $token);
|
||||||
|
$isMixed = $hasLetter && $hasDigit;
|
||||||
|
|
||||||
|
$pos = 0;
|
||||||
|
while (true) {
|
||||||
|
$p = strpos($T, $token, $pos);
|
||||||
|
if ($p === false) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$left = ($p > 0) ? $T[$p - 1] : ' ';
|
||||||
|
$right = ($p + $lenK < $lenT) ? $T[$p + $lenK] : ' ';
|
||||||
|
|
||||||
|
$leftOk = !ctype_alnum($left);
|
||||||
|
$rightOk = $isMixed ? true : !ctype_alnum($right);
|
||||||
|
|
||||||
|
if ($leftOk && $rightOk) {
|
||||||
|
$occ[] = [$p, $p + $lenK];
|
||||||
|
}
|
||||||
|
$pos = $p + 1;
|
||||||
|
}
|
||||||
|
return $occ;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function proximityBoost(string $title, array $tokens): float
|
||||||
|
{
|
||||||
|
$tokens = array_values(array_unique(array_filter($tokens, fn($t) => $t !== '')));
|
||||||
|
if (count($tokens) < 2) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$T = self::asciiFold($title);
|
||||||
|
$occ = [];
|
||||||
|
foreach ($tokens as $tok) {
|
||||||
|
foreach (self::findPositions($T, $tok) as $p) {
|
||||||
|
$occ[] = ['pos' => $p[0], 'end' => $p[1], 'tok' => $tok];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (empty($occ)) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
usort($occ, fn($a, $b) => $a['pos'] <=> $b['pos']);
|
||||||
|
|
||||||
|
$present = [];
|
||||||
|
foreach ($occ as $o) {
|
||||||
|
$present[$o['tok']] = true;
|
||||||
|
}
|
||||||
|
$needCount = count($present);
|
||||||
|
if ($needCount < 2) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cnt = [];
|
||||||
|
$covered = 0;
|
||||||
|
$bestSpan = PHP_INT_MAX;
|
||||||
|
for ($r = 0, $l = 0; $r < count($occ); $r++) {
|
||||||
|
$t = $occ[$r]['tok'];
|
||||||
|
$cnt[$t] = ($cnt[$t] ?? 0) + 1;
|
||||||
|
if ($cnt[$t] === 1) {
|
||||||
|
$covered++;
|
||||||
|
}
|
||||||
|
while ($covered === $needCount && $l <= $r) {
|
||||||
|
$span = $occ[$r]['end'] - $occ[$l]['pos'];
|
||||||
|
if ($span < $bestSpan) {
|
||||||
|
$bestSpan = $span;
|
||||||
|
}
|
||||||
|
$lt = $occ[$l]['tok'];
|
||||||
|
$cnt[$lt]--;
|
||||||
|
if ($cnt[$lt] === 0) {
|
||||||
|
$covered--;
|
||||||
|
}
|
||||||
|
$l++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($bestSpan === PHP_INT_MAX) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$compact = $needCount / max(1, $bestSpan);
|
||||||
|
return self::W_PROX_CHARS * $compact;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function orderedWindowBoost(string $title, array $tokens): float
|
||||||
|
{
|
||||||
|
$tokens = array_values(array_unique(array_filter($tokens, fn($t) => $t !== '')));
|
||||||
|
if (count($tokens) < 2) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$T = self::asciiFold($title);
|
||||||
|
$posList = [];
|
||||||
|
foreach ($tokens as $t) {
|
||||||
|
$posList[$t] = self::findPositions($T, $t);
|
||||||
|
if (empty($posList[$t])) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$bestSpanChars = PHP_INT_MAX;
|
||||||
|
$bestStart = -1;
|
||||||
|
$t0 = $tokens[0];
|
||||||
|
|
||||||
|
foreach ($posList[$t0] as $p0) {
|
||||||
|
$start = $p0[0];
|
||||||
|
$end = $p0[1];
|
||||||
|
$ok = true;
|
||||||
|
$cursor = $end;
|
||||||
|
for ($i = 1; $i < count($tokens); $i++) {
|
||||||
|
$tok = $tokens[$i];
|
||||||
|
$found = false;
|
||||||
|
foreach ($posList[$tok] as $pp) {
|
||||||
|
if ($pp[0] >= $cursor) {
|
||||||
|
$end = max($end, $pp[1]);
|
||||||
|
$cursor = $pp[1];
|
||||||
|
$found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$found) {
|
||||||
|
$ok = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($ok) {
|
||||||
|
$span = $end - $start;
|
||||||
|
if ($span < $bestSpanChars) {
|
||||||
|
$bestSpanChars = $span;
|
||||||
|
$bestStart = $start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($bestSpanChars === PHP_INT_MAX) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$slice = substr($T, max(0, $bestStart), max(1, $bestSpanChars));
|
||||||
|
$wordsInSpan = max(1, count(array_filter(explode(' ', $slice))));
|
||||||
|
$tightness = count($tokens) / $wordsInSpan;
|
||||||
|
|
||||||
|
$score = self::W_ORDERED_WINDOW * $tightness;
|
||||||
|
if ($bestStart <= 6) {
|
||||||
|
$score += self::W_ORDERED_ANCHOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $score;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function lengthPenalty(string $title): float
|
||||||
|
{
|
||||||
|
$len = mb_strlen($title, 'UTF-8');
|
||||||
|
if ($len <= self::LEN_PEN_START) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
$extra = $len - self::LEN_PEN_START;
|
||||||
|
return -min(300.0, $extra * self::LEN_PEN_PER_CHAR);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== URL Helpers ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene la URL del sitio desde wp_options
|
||||||
|
*/
|
||||||
|
private function getSiteUrlFromDb(): string
|
||||||
|
{
|
||||||
|
static $cached = null;
|
||||||
|
if ($cached !== null) {
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
"SELECT option_value FROM {$this->prefix}options
|
||||||
|
WHERE option_name = 'home' LIMIT 1"
|
||||||
|
);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||||
|
$cached = $result ? rtrim($result['option_value'], '/') : '';
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene la estructura de permalinks desde wp_options
|
||||||
|
*/
|
||||||
|
private function getPermalinkStructure(): string
|
||||||
|
{
|
||||||
|
static $cached = null;
|
||||||
|
if ($cached !== null) {
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
"SELECT option_value FROM {$this->prefix}options
|
||||||
|
WHERE option_name = 'permalink_structure' LIMIT 1"
|
||||||
|
);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||||
|
$cached = $result ? $result['option_value'] : '';
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construye permalink desde post_name
|
||||||
|
* Maneja diferentes estructuras de permalinks
|
||||||
|
*/
|
||||||
|
public function buildPermalink(int $postId, string $postName): string
|
||||||
|
{
|
||||||
|
// Fallback si post_name está vacío
|
||||||
|
if (empty($postName)) {
|
||||||
|
$siteUrl = $this->getSiteUrlFromDb();
|
||||||
|
return $siteUrl . '/?p=' . $postId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$siteUrl = $this->getSiteUrlFromDb();
|
||||||
|
$structure = $this->getPermalinkStructure();
|
||||||
|
|
||||||
|
// Si estructura contiene %post_id%, usar ID
|
||||||
|
if (strpos($structure, '%post_id%') !== false) {
|
||||||
|
return $siteUrl . '/' . $postId . '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si estructura contiene %postname%, usar post_name
|
||||||
|
if (strpos($structure, '%postname%') !== false) {
|
||||||
|
return $siteUrl . '/' . $postName . '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: usar post_name (estructura más común)
|
||||||
|
return $siteUrl . '/' . $postName . '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
125
includes/class-shortcode.php
Normal file
125
includes/class-shortcode.php
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Shortcode Handler
|
||||||
|
*
|
||||||
|
* @package ROI_APU_Search
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Prevent direct access
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the [roi_apu_search] shortcode
|
||||||
|
*/
|
||||||
|
final class ROI_APU_Search_Shortcode
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Render the shortcode
|
||||||
|
*
|
||||||
|
* @param array|string $atts Shortcode attributes
|
||||||
|
* @return string HTML output
|
||||||
|
*/
|
||||||
|
public function render($atts): string
|
||||||
|
{
|
||||||
|
$atts = shortcode_atts([
|
||||||
|
'categories' => '', // Comma-separated category IDs or slugs
|
||||||
|
'per_page' => 10, // Results per page
|
||||||
|
'placeholder' => 'Buscar analisis de precios unitarios...',
|
||||||
|
'min_chars' => 3, // Minimum characters to search
|
||||||
|
'show_info' => 'true', // Show search info (total, time)
|
||||||
|
], $atts, 'roi_apu_search');
|
||||||
|
|
||||||
|
// Generate unique ID for multiple shortcodes on same page
|
||||||
|
$instance_id = 'roi-apu-' . wp_unique_id();
|
||||||
|
|
||||||
|
// Parse categories to validate
|
||||||
|
$categories = $this->sanitize_categories($atts['categories']);
|
||||||
|
$per_page = max(1, min(100, (int) $atts['per_page']));
|
||||||
|
$min_chars = max(1, min(10, (int) $atts['min_chars']));
|
||||||
|
$show_info = filter_var($atts['show_info'], FILTER_VALIDATE_BOOLEAN);
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div id="<?php echo esc_attr($instance_id); ?>"
|
||||||
|
class="roi-apu-search-container"
|
||||||
|
data-categories="<?php echo esc_attr($categories); ?>"
|
||||||
|
data-per-page="<?php echo esc_attr((string) $per_page); ?>"
|
||||||
|
data-min-chars="<?php echo esc_attr((string) $min_chars); ?>"
|
||||||
|
data-show-info="<?php echo esc_attr($show_info ? 'true' : 'false'); ?>">
|
||||||
|
|
||||||
|
<!-- Search Form -->
|
||||||
|
<div class="roi-apu-search-form">
|
||||||
|
<div class="roi-apu-input-wrapper">
|
||||||
|
<input type="text"
|
||||||
|
class="roi-apu-search-input"
|
||||||
|
placeholder="<?php echo esc_attr($atts['placeholder']); ?>"
|
||||||
|
minlength="<?php echo esc_attr((string) $min_chars); ?>"
|
||||||
|
maxlength="250"
|
||||||
|
autocomplete="off">
|
||||||
|
<button type="button" class="roi-apu-search-btn">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="roi-apu-btn-text">Buscar</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="roi-apu-error" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading Indicator -->
|
||||||
|
<div class="roi-apu-loading" style="display: none;">
|
||||||
|
<div class="roi-apu-spinner"></div>
|
||||||
|
<span>Buscando...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Info -->
|
||||||
|
<div class="roi-apu-info" style="display: none;"></div>
|
||||||
|
|
||||||
|
<!-- Results Container -->
|
||||||
|
<div class="roi-apu-results"></div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="roi-apu-pagination" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize and validate categories string
|
||||||
|
*
|
||||||
|
* @param string $categories Comma-separated categories
|
||||||
|
* @return string Sanitized categories string
|
||||||
|
*/
|
||||||
|
private function sanitize_categories(string $categories): string
|
||||||
|
{
|
||||||
|
if (empty($categories)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = array_filter(array_map('trim', explode(',', $categories)));
|
||||||
|
$sanitized = [];
|
||||||
|
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
if (is_numeric($part)) {
|
||||||
|
// Validate category exists
|
||||||
|
$term = get_term((int) $part, 'category');
|
||||||
|
if ($term && !is_wp_error($term)) {
|
||||||
|
$sanitized[] = (int) $part;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Try by slug
|
||||||
|
$term = get_term_by('slug', sanitize_title($part), 'category');
|
||||||
|
if ($term) {
|
||||||
|
$sanitized[] = (int) $term->term_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(',', array_unique($sanitized));
|
||||||
|
}
|
||||||
|
}
|
||||||
248
roi-apu-search.php
Normal file
248
roi-apu-search.php
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: ROI APU Search
|
||||||
|
* Plugin URI: https://analisisdepreciosunitarios.com
|
||||||
|
* Description: Motor de busqueda ultra-rapido para Analisis de Precios Unitarios con PDO persistente y scoring hibrido.
|
||||||
|
* Version: 1.1.0
|
||||||
|
* Author: ROI Theme
|
||||||
|
* Author URI: https://analisisdepreciosunitarios.com
|
||||||
|
* Text Domain: roi-apu-search
|
||||||
|
* Domain Path: /languages
|
||||||
|
* Requires at least: 6.0
|
||||||
|
* Requires PHP: 8.0
|
||||||
|
*
|
||||||
|
* @package ROI_APU_Search
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Prevent direct access
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plugin constants
|
||||||
|
define('ROI_APU_SEARCH_VERSION', '1.1.0');
|
||||||
|
define('ROI_APU_SEARCH_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||||
|
define('ROI_APU_SEARCH_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||||
|
define('ROI_APU_SEARCH_PLUGIN_BASENAME', plugin_basename(__FILE__));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main plugin class
|
||||||
|
*/
|
||||||
|
final class ROI_APU_Search_Plugin
|
||||||
|
{
|
||||||
|
private static ?self $instance = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get singleton instance
|
||||||
|
*/
|
||||||
|
public static function get_instance(): self
|
||||||
|
{
|
||||||
|
if (self::$instance === null) {
|
||||||
|
self::$instance = new self();
|
||||||
|
}
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
private function __construct()
|
||||||
|
{
|
||||||
|
$this->load_dependencies();
|
||||||
|
$this->init_hooks();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load required files
|
||||||
|
*/
|
||||||
|
private function load_dependencies(): void
|
||||||
|
{
|
||||||
|
require_once ROI_APU_SEARCH_PLUGIN_DIR . 'includes/class-db-connection.php';
|
||||||
|
require_once ROI_APU_SEARCH_PLUGIN_DIR . 'includes/class-redis-cache.php';
|
||||||
|
require_once ROI_APU_SEARCH_PLUGIN_DIR . 'includes/class-search-engine.php';
|
||||||
|
require_once ROI_APU_SEARCH_PLUGIN_DIR . 'includes/class-shortcode.php';
|
||||||
|
require_once ROI_APU_SEARCH_PLUGIN_DIR . 'includes/class-analytics.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize WordPress hooks
|
||||||
|
*/
|
||||||
|
private function init_hooks(): void
|
||||||
|
{
|
||||||
|
// Register shortcode
|
||||||
|
add_action('init', [$this, 'register_shortcode']);
|
||||||
|
|
||||||
|
// Enqueue assets
|
||||||
|
add_action('wp_enqueue_scripts', [$this, 'enqueue_assets']);
|
||||||
|
|
||||||
|
// AJAX handlers (for non-SHORTINIT fallback)
|
||||||
|
add_action('wp_ajax_roi_apu_search', [$this, 'handle_ajax_search']);
|
||||||
|
add_action('wp_ajax_nopriv_roi_apu_search', [$this, 'handle_ajax_search']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the shortcode
|
||||||
|
*/
|
||||||
|
public function register_shortcode(): void
|
||||||
|
{
|
||||||
|
$shortcode = new ROI_APU_Search_Shortcode();
|
||||||
|
add_shortcode('roi_apu_search', [$shortcode, 'render']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueue frontend assets
|
||||||
|
*/
|
||||||
|
public function enqueue_assets(): void
|
||||||
|
{
|
||||||
|
// Only load if shortcode is used
|
||||||
|
global $post;
|
||||||
|
if (!$post || !has_shortcode($post->post_content, 'roi_apu_search')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wp_enqueue_style(
|
||||||
|
'roi-apu-search',
|
||||||
|
ROI_APU_SEARCH_PLUGIN_URL . 'assets/css/search-ui.css',
|
||||||
|
[],
|
||||||
|
ROI_APU_SEARCH_VERSION
|
||||||
|
);
|
||||||
|
|
||||||
|
wp_enqueue_script(
|
||||||
|
'roi-apu-search',
|
||||||
|
ROI_APU_SEARCH_PLUGIN_URL . 'assets/js/search-handler.js',
|
||||||
|
[],
|
||||||
|
ROI_APU_SEARCH_VERSION,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// Obtener configuracion de AdSense del tema (si existe)
|
||||||
|
// Verifica que la funcion exista para evitar errores si se usa otro tema
|
||||||
|
$adsConfig = function_exists('roi_get_adsense_search_config')
|
||||||
|
? roi_get_adsense_search_config()
|
||||||
|
: ['enabled' => false];
|
||||||
|
|
||||||
|
// Localize script with AJAX URL, nonce, click tracking URL, and ads config
|
||||||
|
wp_localize_script('roi-apu-search', 'roiApuSearch', [
|
||||||
|
'ajaxUrl' => admin_url('admin-ajax.php'),
|
||||||
|
'apiUrl' => ROI_APU_SEARCH_PLUGIN_URL . 'api/search-endpoint.php',
|
||||||
|
'clickUrl' => ROI_APU_SEARCH_PLUGIN_URL . 'api/click-endpoint.php',
|
||||||
|
'nonce' => wp_create_nonce('roi_apu_search'),
|
||||||
|
'ads' => $adsConfig,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle AJAX search request (fallback if SHORTINIT doesn't work)
|
||||||
|
*/
|
||||||
|
public function handle_ajax_search(): void
|
||||||
|
{
|
||||||
|
// Verify nonce
|
||||||
|
if (!check_ajax_referer('roi_apu_search', 'nonce', false)) {
|
||||||
|
wp_send_json_error(['message' => 'Invalid security token'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store raw term before sanitization
|
||||||
|
$term_raw = isset($_POST['term']) ? (string) wp_unslash($_POST['term']) : '';
|
||||||
|
$term = sanitize_text_field($term_raw);
|
||||||
|
$page = isset($_POST['page']) ? absint($_POST['page']) : 1;
|
||||||
|
$per_page = isset($_POST['per_page']) ? absint($_POST['per_page']) : 10;
|
||||||
|
$categories = isset($_POST['categories']) ? sanitize_text_field(wp_unslash($_POST['categories'])) : '';
|
||||||
|
|
||||||
|
// Validate term
|
||||||
|
$min_len = 3;
|
||||||
|
$max_len = 250;
|
||||||
|
|
||||||
|
if (mb_strlen($term, 'UTF-8') < $min_len) {
|
||||||
|
wp_send_json_error(['message' => "Minimo {$min_len} caracteres"], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mb_strlen($term, 'UTF-8') > $max_len) {
|
||||||
|
$term = mb_substr($term, 0, $max_len, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute search
|
||||||
|
try {
|
||||||
|
$db = ROI_APU_Search_DB::get_instance();
|
||||||
|
$pdo = $db->get_pdo();
|
||||||
|
$prefix = $db->get_prefix();
|
||||||
|
|
||||||
|
$search = new ROI_APU_Search_Engine($pdo);
|
||||||
|
|
||||||
|
// Parse categories
|
||||||
|
$category_ids = [];
|
||||||
|
if (!empty($categories)) {
|
||||||
|
$category_ids = $this->parse_categories($categories);
|
||||||
|
}
|
||||||
|
|
||||||
|
$offset = ($page - 1) * $per_page;
|
||||||
|
$results = $search->run($term, $per_page, $offset, $category_ids);
|
||||||
|
|
||||||
|
// Log search for analytics
|
||||||
|
$analytics_cfg = ROI_APU_Search_Analytics::get_config();
|
||||||
|
$search_id = ROI_APU_Search_Analytics::logSearch(
|
||||||
|
$pdo,
|
||||||
|
$prefix,
|
||||||
|
$analytics_cfg,
|
||||||
|
$term_raw,
|
||||||
|
$term,
|
||||||
|
$results,
|
||||||
|
$page,
|
||||||
|
$per_page
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add search_id to response for click tracking
|
||||||
|
$results['search_id'] = $search_id;
|
||||||
|
|
||||||
|
wp_send_json_success($results);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
wp_send_json_error(['message' => 'Error en la busqueda'], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse categories string to array of IDs
|
||||||
|
*/
|
||||||
|
private function parse_categories(string $categories): array
|
||||||
|
{
|
||||||
|
$parts = array_filter(array_map('trim', explode(',', $categories)));
|
||||||
|
$ids = [];
|
||||||
|
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
if (is_numeric($part)) {
|
||||||
|
$ids[] = (int) $part;
|
||||||
|
} else {
|
||||||
|
// Try to get category by slug
|
||||||
|
$term = get_term_by('slug', $part, 'category');
|
||||||
|
if ($term) {
|
||||||
|
$ids[] = (int) $term->term_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_unique($ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize plugin
|
||||||
|
add_action('plugins_loaded', function () {
|
||||||
|
ROI_APU_Search_Plugin::get_instance();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activation hook
|
||||||
|
register_activation_hook(__FILE__, function () {
|
||||||
|
// Check PHP version
|
||||||
|
if (version_compare(PHP_VERSION, '8.0', '<')) {
|
||||||
|
deactivate_plugins(plugin_basename(__FILE__));
|
||||||
|
wp_die('ROI APU Search requiere PHP 8.0 o superior.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush rewrite rules
|
||||||
|
flush_rewrite_rules();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deactivation hook
|
||||||
|
register_deactivation_hook(__FILE__, function () {
|
||||||
|
flush_rewrite_rules();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user