10, 'p' => 8, 'h3' => 7, 'image' => 6, 'list' => 5, 'blockquote' => 4, 'table' => 3, ]; public function __construct( private array $settings, private AdsensePlacementRenderer $renderer ) {} /** * Filtra the_content para insertar anuncios */ public function inject(string $content): string { // PASO 0: Validar longitud minima (aplica a todos los modos) $minLength = (int)($this->settings['forms']['min_content_length'] ?? 500); if (strlen(strip_tags($content)) < $minLength) { return $content; } // Determinar modo de operacion $mode = $this->settings['incontent_advanced']['incontent_mode'] ?? 'paragraphs_only'; if ($mode === 'paragraphs_only') { return $this->injectParagraphsOnly($content); } return $this->injectAdvanced($content); } /** * Modo solo parrafos: logica clasica que inserta anuncios unicamente despues de parrafos */ private function injectParagraphsOnly(string $content): string { if (!($this->settings['behavior']['post_content_enabled'] ?? false)) { return $content; } // Obtener configuracion de behavior (modo solo parrafos) $minAds = (int)($this->settings['behavior']['post_content_min_ads'] ?? 1); $maxAds = (int)($this->settings['behavior']['post_content_max_ads'] ?? 3); $afterParagraphs = (int)($this->settings['behavior']['post_content_after_paragraphs'] ?? 3); $minBetween = (int)($this->settings['behavior']['post_content_min_paragraphs_between'] ?? 4); $randomMode = ($this->settings['behavior']['post_content_random_mode'] ?? true) === true; // Validar min <= max if ($minAds > $maxAds) { $minAds = $maxAds; } // Dividir contenido en parrafos $paragraphs = $this->splitIntoParagraphs($content); $totalParagraphs = count($paragraphs); // Necesitamos al menos afterParagraphs + 1 parrafos if ($totalParagraphs < $afterParagraphs + 1) { return $content; } // Calcular posiciones de insercion $adPositions = $this->calculateParagraphsOnlyPositions( $totalParagraphs, $afterParagraphs, $minBetween, $minAds, $maxAds, $randomMode ); if (empty($adPositions)) { return $content; } // Reconstruir contenido con anuncios insertados return $this->buildParagraphsOnlyContent($paragraphs, $adPositions); } /** * Modo avanzado: multiples tipos de elementos */ private function injectAdvanced(string $content): string { $config = $this->settings['incontent_advanced'] ?? []; // Obtener configuracion $maxAds = (int)($config['incontent_max_total_ads'] ?? 8); $minSpacing = (int)($config['incontent_min_spacing'] ?? 3); $priorityMode = $config['incontent_priority_mode'] ?? 'position'; $format = $config['incontent_format'] ?? 'in-article'; // PASO 1: Escanear contenido para encontrar todas las ubicaciones $locations = $this->scanContent($content); if (empty($locations)) { return $content; } // PASO 2: Filtrar por configuracion (enabled) $locations = $this->filterByEnabled($locations, $config); if (empty($locations)) { return $content; } // PASO 3: Aplicar probabilidad deterministica $postId = get_the_ID() ?: 0; $locations = $this->applyProbability($locations, $config, $postId); if (empty($locations)) { return $content; } // PASO 4-5: Filtrar por espaciado y limite (segun priority_mode) if ($priorityMode === 'priority') { $locations = $this->filterByPriorityFirst($locations, $minSpacing, $maxAds); } else { $locations = $this->filterByPositionFirst($locations, $minSpacing, $maxAds); } if (empty($locations)) { return $content; } // Ordenar por posicion para insercion correcta usort($locations, fn($a, $b) => $a['position'] <=> $b['position']); // PASO 6: Insertar anuncios return $this->insertAds($content, $locations, $format); } /** * PASO 1: Escanea el contenido para encontrar ubicaciones elegibles * * IMPORTANTE: No inserta anuncios: * - Dentro de tablas (...
) * - Dentro de embeds/iframes (YouTube, etc.) * - Despues de tablas (tables excluidas completamente) * * @return array{position: int, type: string, tag: string, element_index: int}[] */ private function scanContent(string $content): array { $locations = []; $elementIndex = 0; // Primero, mapear zonas prohibidas (tablas, iframes, embeds) $forbiddenZones = $this->mapForbiddenZones($content); // Regex para encontrar tags de cierre de elementos de bloque // NOTA: Excluimos - no queremos insertar despues de tablas $pattern = '/(<\/(?:p|h2|h3|figure|ul|ol|blockquote)>)/i'; // Encontrar todas las coincidencias con sus posiciones if (preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE)) { foreach ($matches[0] as $match) { $tag = strtolower($match[0]); $position = $match[1] + strlen($match[0]); // Posicion despues del tag // Verificar que no este dentro de una zona prohibida if ($this->isInForbiddenZone($position, $forbiddenZones)) { continue; } $type = $this->getTypeFromTag($tag); if ($type) { $locations[] = [ 'position' => $position, 'type' => $type, 'tag' => $tag, 'element_index' => $elementIndex++, ]; } } } // Detectar imagenes standalone (no dentro de figure ni zonas prohibidas) $locations = array_merge($locations, $this->scanStandaloneImages($content, $elementIndex, $forbiddenZones)); // Validar listas (minimo 3 items) $locations = $this->validateLists($content, $locations); // Ordenar por posicion usort($locations, fn($a, $b) => $a['position'] <=> $b['position']); // Reasignar indices de elemento foreach ($locations as $i => &$loc) { $loc['element_index'] = $i; } return $locations; } /** * Mapea zonas donde NO se deben insertar anuncios * Incluye: tablas, iframes, embeds de video * * @return array{start: int, end: int}[] */ private function mapForbiddenZones(string $content): array { $zones = []; // Tablas: ...
if (preg_match_all('/]*>.*?<\/table>/is', $content, $matches, PREG_OFFSET_CAPTURE)) { foreach ($matches[0] as $match) { $zones[] = [ 'start' => $match[1], 'end' => $match[1] + strlen($match[0]), ]; } } // Iframes (YouTube, Vimeo, etc): if (preg_match_all('/]*>.*?<\/iframe>/is', $content, $matches, PREG_OFFSET_CAPTURE)) { foreach ($matches[0] as $match) { $zones[] = [ 'start' => $match[1], 'end' => $match[1] + strlen($match[0]), ]; } } // Divs con clase de embed/video (wp-block-embed, youtube, video-container, etc) if (preg_match_all('/]*class="[^"]*(?:embed|video|youtube|vimeo|player)[^"]*"[^>]*>.*?<\/div>/is', $content, $matches, PREG_OFFSET_CAPTURE)) { foreach ($matches[0] as $match) { $zones[] = [ 'start' => $match[1], 'end' => $match[1] + strlen($match[0]), ]; } } // Figure con iframe (embeds de WordPress) if (preg_match_all('/]*class="[^"]*wp-block-embed[^"]*"[^>]*>.*?<\/figure>/is', $content, $matches, PREG_OFFSET_CAPTURE)) { foreach ($matches[0] as $match) { $zones[] = [ 'start' => $match[1], 'end' => $match[1] + strlen($match[0]), ]; } } return $zones; } /** * Verifica si una posicion esta dentro de una zona prohibida */ private function isInForbiddenZone(int $position, array $forbiddenZones): bool { foreach ($forbiddenZones as $zone) { if ($position >= $zone['start'] && $position <= $zone['end']) { return true; } } return false; } /** * Convierte tag de cierre a tipo de elemento * NOTA: excluido - no insertamos ads despues de tablas */ private function getTypeFromTag(string $tag): ?string { return match ($tag) { '

' => 'p', '' => 'h2', '' => 'h3', '' => 'image', '', '' => 'list', '' => 'blockquote', default => null, }; } /** * Detecta imagenes que no estan dentro de figure ni zonas prohibidas */ private function scanStandaloneImages(string $content, int $startIndex, array $forbiddenZones = []): array { $locations = []; // Encontrar todas las imagenes con sus posiciones if (!preg_match_all('/]*>/i', $content, $matches, PREG_OFFSET_CAPTURE)) { return $locations; } foreach ($matches[0] as $match) { $imgTag = $match[0]; $imgPosition = $match[1]; // Verificar que no este en zona prohibida if ($this->isInForbiddenZone($imgPosition, $forbiddenZones)) { continue; } // Verificar si hay un
abierto antes de esta imagen $contentBefore = substr($content, 0, $imgPosition); $lastFigureOpen = strrpos($contentBefore, ''); // Si hay figure abierto sin cerrar, esta imagen esta dentro de figure if ($lastFigureOpen !== false && ($lastFigureClose === false || $lastFigureClose < $lastFigureOpen)) { continue; // Ignorar, se contara con
} // Imagen standalone - calcular posicion despues del tag $endPosition = $imgPosition + strlen($imgTag); // Si la imagen esta seguida de

o similar, usar esa posicion $contentAfter = substr($content, $endPosition, 20); if (preg_match('/^\s*<\/p>/i', $contentAfter, $closeMatch)) { // La imagen esta dentro de un parrafo, no es standalone continue; } $locations[] = [ 'position' => $endPosition, 'type' => 'image', 'tag' => $imgTag, 'element_index' => $startIndex++, ]; } return $locations; } /** * Valida que las listas tengan minimo 3 items */ private function validateLists(string $content, array $locations): array { return array_filter($locations, function ($loc) use ($content) { if ($loc['type'] !== 'list') { return true; } // Encontrar el contenido de la lista $endPos = $loc['position']; $tag = $loc['tag']; $openTag = str_replace('/', '', $tag); // ->
    // Buscar hacia atras el tag de apertura $contentBefore = substr($content, 0, $endPos); $lastOpen = strrpos($contentBefore, '<' . substr($openTag, 1)); //
      = 3; }); } /** * PASO 2: Filtra ubicaciones por campos enabled */ private function filterByEnabled(array $locations, array $config): array { $enabledTypes = []; $typeMapping = [ 'h2' => 'incontent_after_h2_enabled', 'h3' => 'incontent_after_h3_enabled', 'p' => 'incontent_after_paragraphs_enabled', 'image' => 'incontent_after_images_enabled', 'list' => 'incontent_after_lists_enabled', 'blockquote' => 'incontent_after_blockquotes_enabled', 'table' => 'incontent_after_tables_enabled', ]; foreach ($typeMapping as $type => $field) { if ($config[$field] ?? false) { $enabledTypes[] = $type; } } return array_filter($locations, fn($loc) => in_array($loc['type'], $enabledTypes, true)); } /** * PASO 3: Aplica probabilidad deterministica usando seed del dia */ private function applyProbability(array $locations, array $config, int $postId): array { // Calcular seed deterministico $seed = crc32($postId . date('Y-m-d')); mt_srand($seed); $probMapping = [ 'h2' => 'incontent_after_h2_probability', 'h3' => 'incontent_after_h3_probability', 'p' => 'incontent_after_paragraphs_probability', 'image' => 'incontent_after_images_probability', 'list' => 'incontent_after_lists_probability', 'blockquote' => 'incontent_after_blockquotes_probability', 'table' => 'incontent_after_tables_probability', ]; return array_filter($locations, function ($loc) use ($config, $probMapping) { $field = $probMapping[$loc['type']] ?? null; if (!$field) { return true; } $probability = (int)($config[$field] ?? 100); $roll = mt_rand(1, 100); return $roll <= $probability; }); } /** * PASO 4-5 (modo position): Filtrar por espaciado primero, luego por prioridad */ private function filterByPositionFirst(array $locations, int $minSpacing, int $maxAds): array { // PASO 4: Ordenar por posicion y filtrar por espaciado usort($locations, fn($a, $b) => $a['element_index'] <=> $b['element_index']); $filtered = []; $lastIndex = -999; foreach ($locations as $loc) { if ($loc['element_index'] - $lastIndex >= $minSpacing) { $filtered[] = $loc; $lastIndex = $loc['element_index']; } } // PASO 5: Si excede max, seleccionar por prioridad if (count($filtered) > $maxAds) { usort($filtered, fn($a, $b) => (self::ELEMENT_PRIORITIES[$b['type']] ?? 0) <=> (self::ELEMENT_PRIORITIES[$a['type']] ?? 0) ); $filtered = array_slice($filtered, 0, $maxAds); } return $filtered; } /** * PASO 4-5 (modo priority): Ordenar por prioridad primero, luego aplicar espaciado */ private function filterByPriorityFirst(array $locations, int $minSpacing, int $maxAds): array { // PASO 4: Ordenar por prioridad usort($locations, fn($a, $b) => (self::ELEMENT_PRIORITIES[$b['type']] ?? 0) <=> (self::ELEMENT_PRIORITIES[$a['type']] ?? 0) ); // PASO 5: Filtrar por espaciado en orden de prioridad $selected = []; $usedIndices = []; foreach ($locations as $loc) { if (count($selected) >= $maxAds) { break; } // Verificar espaciado con ubicaciones ya seleccionadas $violatesSpacing = false; foreach ($usedIndices as $usedIndex) { if (abs($loc['element_index'] - $usedIndex) < $minSpacing) { $violatesSpacing = true; break; } } if (!$violatesSpacing) { $selected[] = $loc; $usedIndices[] = $loc['element_index']; } } return $selected; } /** * PASO 6: Inserta los anuncios en las posiciones calculadas */ private function insertAds(string $content, array $locations, string $format): string { // Insertar de atras hacia adelante para no afectar posiciones $locations = array_reverse($locations); $adCount = count($locations); $currentAd = $adCount; foreach ($locations as $loc) { $adHtml = $this->renderer->renderSlot($this->settings, 'post-content-adv-' . $currentAd); $content = substr_replace($content, $adHtml, $loc['position'], 0); $currentAd--; } return $content; } // ========================================================================= // METODOS LEGACY (sin cambios para backward compatibility) // ========================================================================= /** * Divide el contenido en parrafos preservando el HTML */ private function splitIntoParagraphs(string $content): array { // Dividir por

      , pero mantener el tag $parts = preg_split('/(<\/p>)/i', $content, -1, PREG_SPLIT_DELIM_CAPTURE); $paragraphs = []; $current = ''; foreach ($parts as $part) { $current .= $part; if (strtolower($part) === '

      ') { $paragraphs[] = $current; $current = ''; } } // Si hay contenido restante (sin cerrar), agregarlo if (!empty(trim($current))) { $paragraphs[] = $current; } return $paragraphs; } /** * Calcula las posiciones donde insertar anuncios (modo solo parrafos) * * @return int[] Indices de parrafos despues de los cuales insertar ads */ private function calculateParagraphsOnlyPositions( int $totalParagraphs, int $afterFirst, int $minBetween, int $minAds, int $maxAds, bool $randomMode ): array { // Calcular posiciones disponibles respetando el espacio minimo $availablePositions = []; // La primera posicion siempre es despues del parrafo indicado if ($afterFirst < $totalParagraphs) { $availablePositions[] = $afterFirst; } // Calcular posiciones adicionales respetando minBetween $nextPosition = $afterFirst + $minBetween; while ($nextPosition < $totalParagraphs - 1) { // -1 para no insertar al final $availablePositions[] = $nextPosition; $nextPosition += $minBetween; } // Determinar cuantos ads insertar $maxPossible = count($availablePositions); if ($maxPossible === 0) { return []; } // Limitar por maxAds y lo que el contenido permite $actualMax = min($maxAds, $maxPossible); $actualMin = min($minAds, $actualMax); // Determinar cantidad final de ads if ($randomMode) { // En modo random, elegir cantidad aleatoria entre min y max $numAds = rand($actualMin, $actualMax); } else { // En modo fijo, usar el maximo posible $numAds = $actualMax; } if ($numAds === 0) { return []; } // Seleccionar posiciones if ($randomMode && $numAds < $maxPossible) { // Modo random: elegir posiciones aleatorias // Siempre incluir la primera posicion $selectedPositions = [$availablePositions[0]]; if ($numAds > 1) { // Elegir aleatoriamente del resto $remainingPositions = array_slice($availablePositions, 1); shuffle($remainingPositions); $additionalPositions = array_slice($remainingPositions, 0, $numAds - 1); $selectedPositions = array_merge($selectedPositions, $additionalPositions); } // Ordenar para insertar en orden correcto sort($selectedPositions); return $selectedPositions; } else { // Modo fijo o todas las posiciones necesarias return array_slice($availablePositions, 0, $numAds); } } /** * Reconstruye el contenido insertando anuncios en las posiciones indicadas (modo solo parrafos) */ private function buildParagraphsOnlyContent(array $paragraphs, array $adPositions): string { $newContent = ''; $adsInserted = 0; foreach ($paragraphs as $index => $paragraph) { $newContent .= $paragraph; // Verificar si debemos insertar un ad despues de este parrafo // El indice es 0-based, las posiciones son 1-based (parrafo #3 = index 2) $paragraphNumber = $index + 1; if (in_array($paragraphNumber, $adPositions, true)) { $adsInserted++; $adHtml = $this->renderer->renderSlot($this->settings, 'post-content-' . $adsInserted); $newContent .= $adHtml; } } return $newContent; } }