Commit inicial - WordPress Análisis de Precios Unitarios

- WordPress core y plugins
- Tema Twenty Twenty-Four configurado
- Plugin allow-unfiltered-html.php simplificado
- .gitignore configurado para excluir wp-config.php y uploads

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-11-03 21:04:30 -06:00
commit a22573bf0b
24068 changed files with 4993111 additions and 0 deletions

201
buscar-apus/app/Analytics.php Executable file
View File

@@ -0,0 +1,201 @@
<?php
// public_html/buscar-apus/app/Analytics.php
final class Analytics
{
/** Crea/lee cookie 1P para identificar visitantes. */
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 año
'path' => '/',
'secure' => !empty($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Lax',
]);
$_COOKIE[$name] = $vid;
}
return $vid;
}
/** Hash de IP (sin almacenar la IP en claro). */
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 simple de User-Agent (SIN ternarios anidados). */
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];
}
/** Host y path del 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];
}
/** Tokenización simple para analítica (no usa Search::tokens). */
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);
}
/** Inserta la búsqueda y devuelve su 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'] ?? 'search_log');
$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) {
return null; // nunca romper búsqueda por analítica
}
}
/** Registra un clic y no interrumpe la navegación si falla. */
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'] ?? 'search_click');
$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) {}
}
}

18
buscar-apus/app/Db.php Executable file
View File

@@ -0,0 +1,18 @@
<?php
// public_html/buscar-apus/app/Db.php
final class Db {
private static ?PDO $pdo = null;
public static function pdo(array $cfg): PDO {
if (!self::$pdo) {
$dsn = "mysql:host={$cfg['db']['host']};dbname={$cfg['db']['name']};charset=utf8mb4";
self::$pdo = new PDO($dsn, $cfg['db']['user'], $cfg['db']['pass'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_PERSISTENT => true,
]);
self::$pdo->exec("SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci");
}
return self::$pdo;
}
}

15
buscar-apus/app/Logger.php Executable file
View File

@@ -0,0 +1,15 @@
<?php
// public_html/buscar-apus/app/Logger.php
final class Logger {
private static function path(): string { return __DIR__ . '/../buscador_apus_debug.txt'; }
public static function log(bool $enabled, string $event, array $ctx = []): void {
if (!$enabled) return;
$payload = [
'ts' => sprintf('%.3f', microtime(true)),
'event' => $event,
'ctx' => $ctx,
];
@file_put_contents(self::path(), json_encode($payload, JSON_UNESCAPED_UNICODE) . PHP_EOL, FILE_APPEND | LOCK_EX);
}
}

525
buscar-apus/app/Search.php Executable file
View File

@@ -0,0 +1,525 @@
<?php
// public_html/buscar-apus/app/Search.php
final class Search {
private PDO $pdo;
private string $table;
private bool $logEnabled;
/* ===== Pesos ajustables ===== */
private const RAW_REL_MULT = 40.0; // señal FULLTEXT (suave)
private const W_COVERAGE = 200.0; // cobertura de tokens
private const W_STARTSWITH = 240.0; // empieza por…
private const W_WORD_EXACT = 140.0; // palabra exacta
private const W_FUZZY_TOKEN_MAX = 120.0; // similitud por token
private const W_RECENCY_MAX = 120.0; // recencia (máx aprox)
private const W_PROX_CHARS = 620.0; // proximidad por tramo en caracteres
private const W_ORDERED_WINDOW = 1600.0; // **principal**: tokens en orden y cerca
private const W_ORDERED_ANCHOR = 300.0; // bonus si arranca ~al inicio
private const LEN_PEN_START = 180; // penaliza longitud desde aquí
private const LEN_PEN_PER_CHAR = 0.55;
// penalización cuando faltan tokens en consultas cortas (≤4 tokens)
private const REQ_MISS_PER_TOKEN = 420.0; // por token faltante
private const REQ_BASE_PENALTY = 140.0; // castigo base si falta al menos uno
public function __construct(PDO $pdo, string $prefix, bool $logEnabled) {
$this->pdo = $pdo;
$this->table = "{$prefix}posts";
$this->logEnabled = $logEnabled;
}
/** --- Sanitización del término --- */
public static function sanitizeTerm(string $raw, int $min, int $max): array {
$term = trim($raw);
if ($term === '') return [false, $term, 'Ingresa un término'];
if (mb_strlen($term, 'UTF-8') < $min) return [false, $term, "Mínimo {$min} caracteres"];
if (mb_strlen($term, 'UTF-8') > $max) return [false, mb_substr($term, 0, $max, 'UTF-8'), "Máximo {$max} caracteres"];
return [true, $term, null];
}
/** --- Utilidades texto/tokens/normalización --- */
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);
}
/**
* tokens(): conserva:
* - tokens de 2+ caracteres
* - números (siempre)
* - tokens de 1 carácter en whitelist, p.ej. "f" (muy relevante para f'c)
*/
private static function tokens(string $term): array {
$t = self::asciiFold($term);
$raw = array_values(array_filter(explode(' ', $t), fn($x)=>$x!==''));
$keep1 = ['f','x']; // amplia si lo necesitas (p.ej. 'h' para secciones, etc.)
$parts = [];
foreach ($raw as $x) {
if (ctype_digit($x)) { // números: 10, 250, 3/4 no entra aquí pero 250 sí
$parts[] = $x;
} elseif (strlen($x) >= 2) {
$parts[] = $x;
} elseif (in_array($x, $keep1, true)) {
$parts[] = $x;
}
}
// dedup orden estable
$seen = [];
$out = [];
foreach ($parts as $p) {
if (!isset($seen[$p])) { $seen[$p]=true; $out[]=$p; }
}
return $out;
}
/** --- FULLTEXT boolean query (solo título) --- */
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);
}
/** --- Señales/boosts --- */
private static function coverageBoost(string $title, array $tokens): float {
if (!$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;
}
/**
* Penaliza faltantes cuando la consulta es corta (≤4 tokens).
* Ej.: "concreto f 250" -> si falta "f" en el título: castigo fuerte.
*/
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 (!$tokens) return 0.0;
$tw = array_slice(preg_split('/\s+/', self::asciiFold($title)), 0, 12);
if (!$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;
}
/* === Posiciones con control de bordes:
- numérico puro: requiere no-alfaNum a ambos lados
- alfabético puro: requiere no-alfaNum a ambos lados
- mixto (letras+nums, ej. "15x15"): sólo exige borde izquierdo no-alfaNum,
y RELAJA el derecho para permitir sufijos ("15x15cm"). */
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; // p.ej. "15x15"
$pureNum = !$hasLetter && $hasDigit; // p.ej. "10"
$pureAlpha = $hasLetter && !$hasDigit; // p.ej. "dala"
$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); // siempre exigimos borde izq.
$rightOk = $isMixed ? true : !ctype_alnum($right); // relajado sólo para mixtos
if ($leftOk && $rightOk) {
$occ[] = [$p, $p+$lenK];
}
$pos = $p + 1;
}
return $occ;
}
/** ===== Proximidad por tramo en caracteres ===== */
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 (!$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; $bestL = 0; $bestR = -1;
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; $bestL = $l; $bestR = $r; }
$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;
}
/** ===== Ventana ORDENADA (muy prioritaria) ===== */
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 (!$posList[$t]) return 0.0; // si falta un token, no hay ventana ordenada
}
$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;
}
/** Penalización por títulos excesivamente largos. */
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);
}
/** --- Buckets de obtención (solo título) --- */
private function fetchEquals(string $term, int $limit): array {
$sql = "SELECT ID, post_title, post_date
FROM {$this->table}
WHERE post_type='post' AND post_status='publish'
AND post_title COLLATE utf8mb4_general_ci = :t
ORDER BY post_date DESC
LIMIT :lim";
$st = $this->pdo->prepare($sql);
$st->bindValue(':t',$term);
$st->bindValue(':lim',$limit,PDO::PARAM_INT);
$st->execute();
$rows = $st->fetchAll();
Logger::log($this->logEnabled,'bucket_equals',['count'=>count($rows)]);
return $rows;
}
private function fetchStartsWith(string $term, int $limit): array {
$sql = "SELECT ID, post_title, post_date
FROM {$this->table}
WHERE post_type='post' AND post_status='publish'
AND post_title LIKE :p ESCAPE '\\\\'
ORDER BY post_date DESC
LIMIT :lim";
$prefix = str_replace(['\\','%','_'], ['\\\\','\%','\_'], $term) . '%';
$st = $this->pdo->prepare($sql);
$st->bindValue(':p',$prefix);
$st->bindValue(':lim',$limit,PDO::PARAM_INT);
$st->execute();
$rows = $st->fetchAll();
Logger::log($this->logEnabled,'bucket_starts',['count'=>count($rows)]);
return $rows;
}
private function fetchFulltextTitle(string $term, int $limit): array {
$q = self::booleanQuery($term);
if ($q === '') return [];
$sql = "SELECT ID, post_title, post_date,
MATCH(post_title) AGAINST (:q IN BOOLEAN MODE) AS raw_rel
FROM {$this->table}
WHERE post_type='post' AND post_status='publish'
AND MATCH(post_title) AGAINST (:q IN BOOLEAN MODE)
ORDER BY raw_rel DESC, post_date DESC
LIMIT :lim";
$st = $this->pdo->prepare($sql);
$st->bindValue(':q',$q);
$st->bindValue(':lim',$limit,PDO::PARAM_INT);
$st->execute();
$rows = $st->fetchAll();
Logger::log($this->logEnabled,'bucket_fulltext',['q'=>$q,'count'=>count($rows)]);
return $rows;
}
/** TODOS los tokens por LIKE: sube el recall de candidatos buenos. */
private function fetchAllTokensLike(array $tokens, int $limit): array {
if (!$tokens) return [];
$likeConds = [];
foreach ($tokens as $i=>$t) {
$likeConds[] = "post_title LIKE :lk{$i} ESCAPE '\\\\'";
}
$where = implode(' AND ', $likeConds);
$sql = "SELECT ID, post_title, post_date
FROM {$this->table}
WHERE post_type='post' AND post_status='publish' AND {$where}
ORDER BY post_date DESC
LIMIT :lim";
$st = $this->pdo->prepare($sql);
foreach ($tokens as $i=>$t) {
$st->bindValue(":lk{$i}", '%' . str_replace(['\\','%','_'], ['\\\\','\%','\_'], $t) . '%');
}
$st->bindValue(':lim',$limit,PDO::PARAM_INT);
$st->execute();
$rows = $st->fetchAll();
Logger::log($this->logEnabled,'bucket_like_all',['count'=>count($rows)]);
return $rows;
}
private function fetchContains(string $term, int $limit): array {
$like = '%' . str_replace(['\\','%','_'], ['\\\\','\%','\_'], $term) . '%';
$sql = "SELECT ID, post_title, post_date
FROM {$this->table}
WHERE post_type='post' AND post_status='publish'
AND post_title LIKE :l ESCAPE '\\\\'
ORDER BY post_date DESC
LIMIT :lim";
$st = $this->pdo->prepare($sql);
$st->bindValue(':l',$like);
$st->bindValue(':lim',$limit,PDO::PARAM_INT);
$st->execute();
$rows = $st->fetchAll();
Logger::log($this->logEnabled,'bucket_contains',['count'=>count($rows)]);
return $rows;
}
/** --- Merge + dedupe + re-rank --- */
public function run(string $term, int $limit, int $offset): array {
$t0 = microtime(true);
$tokens = self::tokens($term);
// Tamaños de pool
$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));
$buckets = [
['name'=>'LIKE_ALL', 'base'=>900.0, 'rows'=>$this->fetchAllTokensLike($tokens, $capLike)],
['name'=>'FULLTEXT', 'base'=>700.0, 'rows'=>$this->fetchFulltextTitle($term, $capFull)],
['name'=>'STARTS', 'base'=>650.0, 'rows'=>$this->fetchStartsWith($term, $capPref)],
['name'=>'CONTAINS', 'base'=>500.0, 'rows'=>$this->fetchContains($term, $capCont)],
['name'=>'EQUALS', 'base'=>1200.0, 'rows'=>$this->fetchEquals($term, $capEq)],
];
// Deduplicar por título normalizado
$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'],
'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);
Logger::log($this->logEnabled,'consulta_ejecutada',[
'modo'=>'HYBRID','total'=>0,'filas'=>0,'tiempo_ms'=>$elapsed,'pool_total'=>0
]);
return ['total'=>0,'rows'=>[],'modo'=>'HYBRID','time_ms'=>$elapsed];
}
// Re-rank
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) // *** manda ***
+ self::proximityBoost($title, $tokens) // cercano (no necesariamente ordenado)
+ 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); // *** nuevo castigo si faltan tokens
$it['score'] = $score;
}
unset($it);
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;
});
// Paginación
$pageRows = array_slice($pool, $offset, $limit);
$rows = array_map(fn($r)=>[
'ID' => $r['ID'],
'post_title' => $r['post_title'],
'post_date' => $r['post_date'],
// para depurar: 'score'=>$r['score'], 'bucket'=>$r['bucket']
], $pageRows);
$elapsed = round((microtime(true)-$t0)*1000,2);
Logger::log($this->logEnabled,'consulta_ejecutada',[
'modo'=>'HYBRID',
'total'=>$poolTotal,
'filas'=>count($rows),
'tiempo_ms'=>$elapsed,
'pool_total'=>$poolTotal,
'buckets'=>[
'like_all'=>count($buckets[0]['rows']),
'fulltext'=>count($buckets[1]['rows']),
'starts'=>count($buckets[2]['rows']),
'contains'=>count($buckets[3]['rows']),
'equals'=>count($buckets[4]['rows']),
],
]);
return ['total'=>$poolTotal,'rows'=>$rows,'modo'=>'HYBRID','time_ms'=>$elapsed];
}
}

23
buscar-apus/clk.php Executable file
View File

@@ -0,0 +1,23 @@
<?php
// public_html/buscar-apus/clk.php
$cfg = require __DIR__ . '/config.php';
require __DIR__ . '/app/Db.php';
require __DIR__ . '/app/Analytics.php';
$searchId = isset($_GET['sid']) ? (int)$_GET['sid'] : 0;
$postId = isset($_GET['pid']) ? (int)$_GET['pid'] : 0;
$pos = isset($_GET['pos']) ? (int)$_GET['pos'] : 0;
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
$dest = '/?p=' . $postId; // válido sin WP cargado
try {
$pdo = Db::pdo($cfg);
Analytics::logClick(
$pdo, $cfg['db']['prefix'], $cfg['analytics'] ?? [],
$searchId, $postId, max(1,$pos), max(1,$page), $dest
);
} catch (\Throwable $e) {}
header('Location: ' . $dest, true, 302);
exit;

26
buscar-apus/config.php Executable file
View File

@@ -0,0 +1,26 @@
<?php
// public_html/buscar-apus/config.php
return [
'analytics' => [
'enabled' => true,
'hash_ip' => true,
'salt' => 'd!&5wIPA0Tnc0SlGrGso',
// >>> aqu<71><75> pones los nombres SIN el prefijo <<<
'table_log' => 'rcp_paginas_querys',
'table_click'=> 'rcp_paginas_querys_log',
],
'db' => [
'host' => 'localhost',
'name' => 'preciosunitarios_wp',
'user' => 'preciosunitarios_wp', // crea un usuario SOLO LECTURA
'pass' => 'Kq#Gk%yEt+PWpVe&HZ',
'prefix' => 'wp_', // cambia si tu prefijo no es wp_
],
'limits' => [
'min_len' => 5,
'max_len' => 250,
'per_page' => 10,
],
'log' => false, // desactiva en producción si quieres
];

310
buscar-apus/index.php Executable file
View File

@@ -0,0 +1,310 @@
<?php
// public_html/buscar-apus/index.php
$cfg = require __DIR__ . '/config.php';
require __DIR__ . '/app/Db.php';
require __DIR__ . '/app/Logger.php';
require __DIR__ . '/app/Search.php';
require __DIR__ . '/menu.php';
require __DIR__ . '/app/Analytics.php';
if (session_status() !== PHP_SESSION_ACTIVE) @session_start();
Logger::log($cfg['log'], 'peticion', [
'uri' => $_SERVER['REQUEST_URI'] ?? '',
'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '',
'get' => $_GET
]);
/**
* =========================
* Config de anuncios
* =========================
* - $ADS_BETWEEN_MAX: máximo de anuncios entre resultados.
* - $ALLOWED_AD_UNITS: tipos permitidos para los anuncios intermedios.
* - $TOP_FIXED_AD_SIZE: tamaño fijo del anuncio bajo el buscador ('300x250' | '728x90' | '320x100' | '970x250').
* - $TOP_FIXED_AD_SLOT: SLOT de AdSense para ese tamaño fijo (reemplázalo por el tuyo).
*/
$ADS_BETWEEN_MAX = 1; // 1..n
$ALLOWED_AD_UNITS = ['auto', 'autorelaxed', 'in-article'];
$TOP_FIXED_AD_SIZE = '1100x315';
$TOP_FIXED_AD_SLOT = '2873062302'; // <-- pon aquí tu data-ad-slot (numérico)
// Parámetros
$raw = isset($_GET['s']) ? trim((string)$_GET['s']) : '';
$page = (isset($_GET['pagina']) && ctype_digit($_GET['pagina']) && (int)$_GET['pagina'] > 0) ? (int)$_GET['pagina'] : 1;
$pp = max(1, min(100, (int)$cfg['limits']['per_page']));
$off = ($page - 1) * $pp;
[$valid, $term, $err] = Search::sanitizeTerm($raw, $cfg['limits']['min_len'], $cfg['limits']['max_len']);
$results = ['total'=>0,'rows'=>[],'modo'=>'NONE','time_ms'=>0];
$pdo = null; $searchId = null;
if ($valid) {
$pdo = Db::pdo($cfg);
$search = new Search($pdo, $cfg['db']['prefix'], $cfg['log']);
$results = $search->run($term, $pp, $off);
$analyticsCfg = $cfg['analytics'] ?? ['enabled' => false];
// al terminar la búsqueda:
$searchId = Analytics::logSearch(
$pdo, $cfg['db']['prefix'], $analyticsCfg,
$raw, $term, $results, $page, $pp
);
} else if ($raw !== '') {
Logger::log($cfg['log'], 'consulta_invalida', ['error' => $err]);
}
/** ===== Helpers ===== **/
// Resalta el texto (sin snippet). Útil para títulos.
function apu_highlight(string $text, string $term): string {
$safe = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
foreach (array_filter(explode(' ', $term)) as $t) {
if (mb_strlen($t, 'UTF-8') >= 2) {
$safe = preg_replace('/' . preg_quote($t, '/') . '/iu', '<mark>$0</mark>', $safe);
}
}
return $safe;
}
/**
* Anuncio fijo (display) para el bloque bajo el buscador.
* Usa un slot de tamaño específico para mantener altura constante.
*
* @param string $size '300x250' | '728x90' | '320x100' | '970x250'
* @param string $slot Tu data-ad-slot para ese tamaño
*/
function apu_top_fixed_ad_html(string $size, string $slot): string {
$client = 'ca-pub-8476420265998726';
// whitelist de tamaños (incluye 970x250)
$allowed = ['300x250','728x90','320x100','970x250','1100x315'];
if (!in_array($size, $allowed, true)) $size = '300x250';
[$w,$h] = array_map('intval', explode('x', $size));
$w = max(1,$w); $h = max(1,$h);
$slot = preg_match('/^\d+$/', $slot) ? $slot : '0000000000'; // placeholder seguro (no muestra anuncio)
return '<div class="apu-top-ad d-flex justify-content-center">'.
'<ins class="adsbygoogle" style="display:inline-block;width:'.$w.'px;height:'.$h.'px" '.
'data-ad-client="'.$client.'" data-ad-slot="'.$slot.'"></ins>'.
'<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>'.
'</div>';
}
/**
* Solo anuncios AUTO (responsive). Ignora $allowed y fuerza 'auto'.
* Puedes fijar una altura para evitar CLS: 250320 suele ir bien.
*/
function apu_random_ad_html(array $allowed = ['auto']): string {
// Alturas sugeridas: elige una fija o deja una lista si quieres variar
$heights = [100, 200, 250, 280, 300, 315, 320, 350, 400]; // puedes cambiarlo por [300] para altura constante
$h = $heights[array_rand($heights)];
return '<ins class="adsbygoogle" style="display:block; min-height:'.$h.'px"
data-ad-client="ca-pub-8476420265998726"
data-ad-slot="8471732096"
data-ad-format="auto"
data-full-width-responsive="true"></ins>
<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>';
}
/**
* HTML de un anuncio AdSense aleatorio, restringido a tipos permitidos (intermedios).
*
* @param array $allowed tipos permitidos: 'auto','autorelaxed','in-article'
* @return string
*/
/*
function apu_random_ad_html(array $allowed = ['auto']): string {
$allowed = array_values(array_intersect(
array_map('strval', $allowed),
['auto','autorelaxed','in-article']
));
if (empty($allowed)) $allowed = ['auto','autorelaxed','in-article'];
$unit = $allowed[array_rand($allowed)];
if ($unit === 'auto') { // AUTO
$heights = [100, 150, 200, 250, 280, 300, 350, 400, 500, 600];
$h = $heights[array_rand($heights)];
return '<ins class="adsbygoogle" style="display:block; min-height:'.$h.'px"
data-ad-client="ca-pub-8476420265998726"
data-ad-slot="8471732096"
data-ad-format="auto"
data-full-width-responsive="true"></ins>
<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>';
}
if ($unit === 'autorelaxed') { // AUTORELAXED (feed)
$heights = [280, 300, 320, 360];
$h = $heights[array_rand($heights)];
return '<ins class="adsbygoogle" style="display:block; min-height:'.$h.'px"
data-ad-format="autorelaxed"
data-ad-client="ca-pub-8476420265998726"
data-ad-slot="9205569855"></ins>
<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>';
}
// IN-ARTICLE (fluid)
$heights = [180, 200, 220, 240];
$h = $heights[array_rand($heights)];
return '<ins class="adsbygoogle" style="display:block; text-align:center; min-height:'.$h.'px"
data-ad-layout="in-article"
data-ad-format="fluid"
data-ad-client="ca-pub-8476420265998726"
data-ad-slot="7285187368"></ins>
<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>';
}
*/
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>Buscador APU</title>
<!-- AdSense loader (una sola vez) -->
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-8476420265998726" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<style>
:root { --apu-max-width: 1150px; }
.apu-wrap { max-width: var(--apu-max-width); width: 100%; margin: 0 auto; padding-inline: 12px; }
.container { max-width: var(--apu-max-width) !important; }
body{background:#fafafa}
mark{background:#fff3cd;padding:0;border-radius:2px}
.apu-top-ad { margin-top: .5rem; margin-bottom: .75rem; } /* espacio del anuncio fijo */
</style>
</head>
<body>
<main class="apu-wrap">
<section class="container my-4">
<div class="p-3 border rounded-3 bg-light">
<form method="get" action="" class="row g-2 align-items-center">
<div class="col-12 col-md-9">
<input
type="text"
name="s"
class="form-control<?php echo (!$valid && $raw!=='') ? ' is-invalid' : ''; ?>"
placeholder="Buscar análisis… (cantidad mínima de caracteres para buscar. <?php echo (int)$cfg['limits']['min_len']; ?>)"
value="<?php echo htmlspecialchars($term ?: $raw, ENT_QUOTES, 'UTF-8'); ?>"
maxlength="<?php echo (int)$cfg['limits']['max_len']; ?>">
<?php if(!$valid && $raw!==''): ?>
<div class="invalid-feedback"><?php echo htmlspecialchars($err, ENT_QUOTES, 'UTF-8'); ?></div>
<?php endif; ?>
</div>
<div class="col-12 col-md-3 d-grid"><button class="btn btn-primary" type="submit">Buscar</button></div>
</form>
</div>
<?php
// ===== Anuncio FIJO debajo del buscador (tamaño constante) =====
echo apu_top_fixed_ad_html($TOP_FIXED_AD_SIZE, $TOP_FIXED_AD_SLOT);
?>
</section>
<?php if ($valid): ?>
<section class="container my-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="h5 mb-0">
Resultados para "<strong><?php echo htmlspecialchars($term, ENT_QUOTES, 'UTF-8'); ?></strong>":
<?php echo number_format($results['total']); ?>
</h2>
<small class="text-muted"><?php echo htmlspecialchars($results['modo']); ?> (<?php echo $results['time_ms']; ?>ms)</small>
</div>
<?php if (!empty($results['rows'])): ?>
<div class="list-group mb-4">
<?php
// ===== Anuncios entre resultados =====
$rows = $results['rows'];
$n = count($rows);
// Máximo permitido por configuración (no puede superar los huecos disponibles)
$adsBetween = min((int)$ADS_BETWEEN_MAX, max(0, $n - 1));
// Elegimos $adsBetween huecos aleatorios únicos (entre item i y i+1)
$adPos = [];
if ($adsBetween > 0) {
$pool = range(1, $n - 1); // huecos posibles
shuffle($pool);
$adPos = array_flip(array_slice($pool, 0, $adsBetween)); // set de posiciones
}
$i = 0;
foreach ($rows as $r):
$i++;
$permalink = '/?p='.(int)$r['ID'];
$clk = ($searchId)
? '/buscar-apus/clk.php?sid='.$searchId.'&pid='.(int)$r['ID'].'&pos='.$i.'&page='.$page
: $permalink; // fallback seguro
$title = (string)$r['post_title'];
$dateStr = htmlspecialchars(substr($r['post_date'],0,10), ENT_QUOTES, 'UTF-8');
?>
<a class="list-group-item list-group-item-action py-3"
href="<?php echo htmlspecialchars($clk, ENT_QUOTES, 'UTF-8'); ?>"
target="_blank" rel="noopener noreferrer">
<div class="d-flex w-100 justify-content-between mb-1">
<h3 class="h6 mb-0"><?php echo apu_highlight($title, $term); ?></h3>
</div>
</a>
<?php
// ¿Insertar anuncio después de este ítem?
if (isset($adPos[$i])) {
echo '<div class="list-group-item py-3">'.apu_random_ad_html($ALLOWED_AD_UNITS).'</div>';
}
?>
<?php endforeach; ?>
</div>
<?php
$total_pages = (int)ceil($results['total'] / $pp);
if ($total_pages > 1):
$base = $_GET; unset($base['pagina']);
?>
<nav aria-label="Paginación">
<ul class="pagination justify-content-center flex-wrap gap-1">
<?php if ($page > 1): ?>
<li class="page-item"><a class="page-link" href="?<?php echo http_build_query($base + ['pagina'=>1]); ?>">&laquo; Primera</a></li>
<li class="page-item"><a class="page-link" href="?<?php echo http_build_query($base + ['pagina'=>$page-1]); ?>">&lsaquo; Anterior</a></li>
<?php endif; ?>
<?php
$start = max(1, $page - 5);
$end = min($total_pages, $start + 9);
if ($end - $start < 9) $start = max(1, $end - 9);
for ($i = $start; $i <= $end; $i++):
?>
<li class="page-item<?php echo ($i === $page) ? ' active' : ''; ?>">
<a class="page-link" href="?<?php echo http_build_query($base + ['pagina'=>$i]); ?>"><?php echo $i; ?></a>
</li>
<?php endfor; ?>
<?php if ($page < $total_pages): ?>
<li class="page-item"><a class="page-link" href="?<?php echo http_build_query($base + ['pagina'=>$page+1]); ?>">Siguiente &rsaquo;</a></li>
<li class="page-item"><a class="page-link" href="?<?php echo http_build_query($base + ['pagina'=>$total_pages]); ?>">Última &raquo;</a></li>
<?php endif; ?>
</ul>
</nav>
<?php endif; ?>
<?php else: ?>
<div class="alert alert-secondary"><strong>Sin resultados.</strong> Prueba con términos más generales.</div>
<?php endif; ?>
</section>
<?php endif; ?>
</main>
</body>
</html>
<?php Logger::log($cfg['log'], 'fin_peticion', []); ?>

232
buscar-apus/menu.php Executable file
View File

@@ -0,0 +1,232 @@
<?php /* menu.php — clon visual del header Thrive (desktop + móvil simple) */ ?>
<style>
/* ---- Layout base ---- */
#thrive-header-clone{position:relative; z-index:14; background:#fff;}
.apu-container {
max-width: 1150px;
margin: 0 auto;
padding: 0px;
margin-top: 0px;
padding-top: 30px;
}
.bar-container{width:1000px; margin:0 auto; padding:9px 20px; margin-top: 0px;}
/* ---- Menú ---- */
.apu-menu{padding:10px 0;}
.apu-menu ul{list-style:none; margin:0; padding:0;}
/* Centrar elementos del menú */
#thrive-header-clone .apu-root{ justify-content:center; }
#thrive-header-clone .apu-root>li.push-right{ margin-left:0; }
.apu-root{display:flex; align-items:center; flex-wrap:wrap;}
.apu-root>li{position:relative; padding:2px 10px; margin:0 2px;}
.apu-root>li.push-right{margin-left:auto;} /* “Accesar” a la derecha */
.apu-root>li>a{
display:flex; align-items:center; gap:8px;
font-size:16px; font-weight:600; line-height:2;
color:#373737; text-decoration:none; padding:6px 10px;
}
/*.apu-root>li>a:hover{color:#39a3d1;}*/
/* caret SVG del mismo tamaño/margen que Thrive */
.apu-caret{width:1em; height:1em; flex:0 0 auto; fill:currentColor;}
/* ---- Submenús (idénticos a Thrive) ---- */
.apu-sub{
position:absolute; top:100%; left:0; display:none; visibility:visible;
background:#fff; border:1px solid #b4b4b4; border-radius:3px;
box-shadow:0 0 9px 1px rgba(0,0,0,.15);
z-index:2000; padding:0; white-space:nowrap;
max-height:70vh; overflow:auto;
}
.apu-sub.right{left:auto; right:0;} /* para “Accesar” */
.has-sub:hover>.apu-sub{display:block;} /* abrir por hover en desktop */
.apu-sub li{padding:0;}
.apu-sub a{
display:block; padding:2px 15px; line-height:2;
color:#666; text-decoration:none;
}
.apu-sub a:hover{color:#363636; background:transparent;}
/* ---- CTA naranja bajo el menú (look Thrive) ---- */
.apu-cta-wrap{background:#fff; padding:0px;}
.apu-cta {
display: block;
text-align: center;
font-weight: 700;
letter-spacing: 2px;
color: #fff;
text-decoration: none;
border-radius: 5px;
padding: 6px;
background-image: linear-gradient(#f5a000,#f5a000);
box-shadow: 0 8px 12px rgba(0,0,0,.25);
font-family: arial;
font-size: 18px;
min-height: 40px;
}
/* Icono blanco en la CTA */
.apu-cta { color:#fff !important; }
.apu-cta .tcb-icon{
width:1em;
height:1em;
vertical-align:middle;
margin-right:.4em;
fill:currentColor;
stroke:currentColor;
}
/* Menú ajustado al contenido (desktop) */
@media (min-width:768px){
#thrive-header-clone .apu-container{
max-width:none;
width:max-content;
}
}
/* ---- Móvil: hamburguesa simple ---- */
.apu-burger{display:none; cursor:pointer; font-size:33px; line-height:1;}
@media (max-width:1023px){
.apu-container{padding-left:40px; padding-right:30px;}
}
@media (max-width:767px){
.apu-container{padding-left:20px; padding-right:10px;}
.apu-burger{display:block;}
.apu-root{display:none; width:100%;}
.apu-root.open{display:block;}
.apu-root>li{display:block; padding:0; margin:0;}
.apu-root>li>a{padding:12px 15px;}
.has-sub>.apu-sub{position:static; box-shadow:none; border:0; max-height:none;}
.has-sub:hover>.apu-sub{display:block;}
.apu-sub a{padding-left:30px;}
}
.apu-cta svg.tcb-icon {
width: 1em;
height: 1em;
vertical-align: middle;
margin-right: .4em;
margin-top: -2px;
}
</style>
<style>
/* ====== PATCH: compactar alto/ancho del menú ====== */
:root{
--apu-menu-fz: 15px; /* antes 16px */
--apu-menu-xpad: 6px; /* padding horizontal del <a> (antes 10px) */
--apu-menu-ypad: 4px; /* padding vertical del <a> (antes 6px) */
--apu-menu-gap: 6px; /* espacio entre texto y caret (antes 8px) */
}
/* Menos “aire” arriba/abajo del bloque menú */
#thrive-header-clone .apu-menu{ padding: 1px 0; } /* antes 10px 0 */
/* Top-level items más compactos */
#thrive-header-clone .apu-root>li{
padding: 0 1.5px;
margin: 0 0px;
}
#thrive-header-clone .apu-root>li>a {
padding: var(--apu-menu-ypad) var(--apu-menu-xpad);
line-height: 1.2;
font-size: 16px;
gap: var(--apu-menu-gap);
white-space: nowrap;
font-family: arial;
}
/* Caret un pelín más pequeño */
#thrive-header-clone .apu-caret{ width: 1em; height: 1em; }
/* Ajustes finos cuando falta ancho en “desktop” estrecho */
@media (max-width:1280px){
#thrive-header-clone .apu-root>li>a{ font-size: 14.5px; }
}
@media (max-width:1120px){
#thrive-header-clone .apu-root>li>a{ font-size: 14px; }
}
</style>
<header id="thrive-header-clone">
<div class="apu-container">
<div class="apu-menu">
<div class="apu-burger" onclick="this.nextElementSibling.classList.toggle('open')">☰</div>
<ul class="apu-root">
<li><a href="https://analisisdepreciosunitarios.com/">Home</a></li>
<li><a href="/blog-temas-del-curso-analisis-de-precios-unitarios">Blog</a></li>
<li class="has-sub">
<a href="/buscar-apus">
Buscador General de Precios Unitarios
<!-- caret SVG igual al de Thrive -->
<svg class="apu-caret" viewBox="0 0 320 512" aria-hidden="true">
<path d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z"/>
</svg>
</a>
<ul class="apu-sub">
<li><a href="/buscar-apus-tabulador-de-precios-unitarios">Análisis de Precios Unitarios</a></li>
<li><a href="/buscar-apus-ciudad-de-mexico-cdmx">Ciudad de México (CDMX)</a></li>
<li><a href="/buscar-apus-petroleos-mexicanos-pemex">Petróleos Mexicanos (PEMEX)</a></li>
<li><a href="/buscar-apus-precios-unitarios-sct">Secretaría de Comunicaciones y Transportes (SCT)</a></li>
<li><a href="/buscar-apus-comision-federal-de-electricidad-cfe">Comisión Federal de Electricidad (CFE)</a></li>
<li><a href="/buscar-apus-instituto-mexicano-del-seguro-social-imss">Instituto Mexicano del Seguro Social (IMSS)</a></li>
<li><a href="/buscar-apus-comision-nacional-del-agua-conagua">Comisión Nacional del Agua (CONAGUA)</a></li>
<li><a href="/buscar-apus-secretaria-de-educacion-publica-sep">Secretaría de Educación Pública (SEP)</a></li>
<li><a href="/buscar-apus-trabajos-de-mantenimiento-industrial">Trabajos de mantenimiento industrial</a></li>
<li><a href="/buscar-apus-acabados-instalaciones-electricas-hidraulicas-sanitarias-gas">Acabados, instalaciones eléctricas, hidráulicas, sanitarias y gas</a></li>
<li><a href="/buscar-apus-aire-acondicionado">Aire acondicionado</a></li>
<li><a href="/buscar-apus-casa-habitacion">Casa habitación</a></li>
<li><a href="/buscar-apus-cimentaciones">Cimentaciones</a></li>
<li><a href="/buscar-apus-construccion">Construcción</a></li>
<li><a href="/buscar-apus-construccion-de-obras-de-edificacion">Construcción de obras de edificación</a></li>
<li><a href="/buscar-apus-construcciones-residenciales">Construcciones residenciales</a></li>
<li><a href="/buscar-apus-obra-civil">Obra civil</a></li>
<li><a href="/buscar-apus-obra-de-albanileria">Obra de albañilería</a></li>
</ul>
</li>
<li><a href="/buscar-apus-auxiliares-o-basicos">Auxiliares</a></li>
<li><a href="/buscar-apus-costos-horarios-maquinaria-y-equipo-de-construccion">Costos Horarios</a></li>
<li><a href="/buscar-apus-tablas-base-de-rendimientos-de-mano-de-obra">Rendimientos</a></li>
<li class="has-sub">
<a href="#">
Calculadoras
<svg class="apu-caret" viewBox="0 0 320 512" aria-hidden="true"><path d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z"/></svg>
</a>
<ul class="apu-sub">
<li><a href="/calculadora-de-dosificacion-de-concreto">Dosificación de concreto</a></li>
</ul>
</li>
<li class="has-sub push-right">
<a href="#">
Accesar
<svg class="apu-caret" viewBox="0 0 320 512" aria-hidden="true"><path d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z"/></svg>
</a>
<ul class="apu-sub right">
<li><a href="/wp-login.php">Iniciar sesión</a></li>
<li><a href="/wp-admin/profile.php">Editar mis datos</a></li>
<li><a href="/wp-login.php?action=lostpassword">Recuperar contraseña</a></li>
<li><a href="https://www.facebook.com/groups/analisis.de.precios.unitarios.y.opus.pro" target="_blank" rel="noopener">Grupo privado de Facebook</a></li>
</ul>
</li>
</ul>
</div>
</div>
<!-- Franja CTA -->
<div class="apu-cta-wrap">
<div class="bar-container">
<a class="apu-cta" href="/suscripcion-vip">
<svg class="tcb-icon" viewBox="0 0 448 512" data-id="icon-unlock-keyhole-solid" data-name="">
<path d="M224 64c-44.2 0-80 35.8-80 80l0 48 240 0c35.3 0 64 28.7 64 64l0 192c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 256c0-35.3 28.7-64 64-64l16 0 0-48C80 64.5 144.5 0 224 0c57.5 0 107 33.7 130.1 82.3c7.6 16 .8 35.1-15.2 42.6s-35.1 .8-42.6-15.2C283.4 82.6 255.9 64 224 64zm32 320c17.7 0 32-14.3 32-32s-14.3-32-32-32l-64 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l64 0z"></path>
</svg>
Haz clic aquí conviértete en VIP y consulta más de 200,000 análisis. Sin restricciones.
</a>
</div>
</div>
</header>

64
buscar-apus/querys-stats.php Executable file
View File

@@ -0,0 +1,64 @@
Top búsquedas (últimos 30 días, no bots; aquí no guardamos bot flag, así que todo cuenta):
SELECT q_term, COUNT(*) AS n
FROM wp_search_log
WHERE ts >= NOW() - INTERVAL 30 DAY
GROUP BY q_term ORDER BY n DESC LIMIT 100;
Búsquedas sin resultados (qué falta en tu índice):
SELECT q_term, COUNT(*) AS n
FROM wp_search_log
WHERE zero_results = 1
GROUP BY q_term ORDER BY n DESC LIMIT 100;
CTR por consulta (clics / búsquedas):
SELECT s.q_term,
COUNT(DISTINCT s.id) AS searches,
COUNT(c.id) AS clicks,
ROUND(COUNT(c.id)/COUNT(DISTINCT s.id), 3) AS ctr
FROM wp_search_log s
LEFT JOIN wp_search_click c ON c.search_id = s.id
GROUP BY s.q_term
HAVING searches >= 5
ORDER BY ctr DESC, searches DESC
LIMIT 100;
Posición media clicada por consulta (calidad percibida):
SELECT s.q_term,
AVG(c.position) AS avg_pos,
COUNT(*) AS clicks
FROM wp_search_click c
JOIN wp_search_log s ON s.id = c.search_id
GROUP BY s.q_term
HAVING clicks >= 5
ORDER BY avg_pos ASC; -- más bajo = mejor
Embudo por dispositivo:
SELECT s.device,
COUNT(DISTINCT s.id) AS searches,
COUNT(c.id) AS clicks,
ROUND(COUNT(c.id)/COUNT(DISTINCT s.id), 3) AS ctr
FROM wp_search_log s
LEFT JOIN wp_search_click c ON c.search_id = s.id
GROUP BY s.device;
Refinamientos (siguientes consultas en la misma sesión, 10 min):
SELECT a.q_term AS q1, b.q_term AS q2, COUNT(*) AS n
FROM wp_search_log a
JOIN wp_search_log b
ON a.visitor_id = b.visitor_id
AND b.ts BETWEEN a.ts AND a.ts + INTERVAL 10 MINUTE
AND b.id > a.id
GROUP BY q1, q2
ORDER BY n DESC
LIMIT 100;