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:
FrankZamora
2025-12-03 10:59:56 -06:00
commit 81d65c0f9a
10 changed files with 3220 additions and 0 deletions

View 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());
}
}
}

View 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');
}
}

View 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()];
}
}
}

View 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 . '/';
}
}

View 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));
}
}