Files
roi-theme/Public/AdsensePlacement/Infrastructure/Services/ContentAdInjector.php

683 lines
23 KiB
PHP

<?php
declare(strict_types=1);
namespace ROITheme\Public\AdsensePlacement\Infrastructure\Services;
use ROITheme\Public\AdsensePlacement\Infrastructure\Ui\AdsensePlacementRenderer;
/**
* Inyecta anuncios dentro del contenido del post
* via filtro the_content
*
* Soporta dos modos:
* - Solo parrafos: Logica clasica solo con parrafos (usa config de behavior)
* - Avanzado: Multiples tipos de elementos (H2, H3, p, img, lists, blockquotes, tables)
*
* El modo se determina por incontent_mode:
* - "paragraphs_only": usa config de behavior (insercion solo en parrafos)
* - Otros: usa config de incontent_advanced
*/
final class ContentAdInjector
{
/**
* Prioridades de elementos para seleccion
* Mayor = mas importante
*/
private const ELEMENT_PRIORITIES = [
'h2' => 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
{
// DEBUG TEMPORAL
$debug = '<!-- ROI_AD_DEBUG: inject() called, content length=' . strlen($content) . ' -->';
// 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 $debug . '<!-- SKIP: too short -->' . $content;
}
// Determinar modo de operacion
$mode = $this->settings['incontent_advanced']['incontent_mode'] ?? 'paragraphs_only';
$debug .= '<!-- MODE=' . $mode . ' -->';
if ($mode === 'paragraphs_only') {
return $debug . $this->injectParagraphsOnly($content);
}
return $debug . $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'] ?? [];
$debug = '<!-- ADV: config keys=' . implode(',', array_keys($config)) . ' -->';
// 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';
$debug .= '<!-- ADV: maxAds=' . $maxAds . ' minSpacing=' . $minSpacing . ' -->';
// PASO 1: Escanear contenido para encontrar todas las ubicaciones
$locations = $this->scanContent($content);
$debug .= '<!-- ADV: scanContent found ' . count($locations) . ' locations -->';
if (empty($locations)) {
return $debug . '<!-- ADV: NO locations found -->' . $content;
}
// PASO 2: Filtrar por configuracion (enabled)
$locations = $this->filterByEnabled($locations, $config);
$debug .= '<!-- ADV: filterByEnabled left ' . count($locations) . ' -->';
if (empty($locations)) {
return $debug . '<!-- ADV: EMPTY after filterByEnabled -->' . $content;
}
// PASO 3: Aplicar probabilidad deterministica
$postId = get_the_ID() ?: 0;
$locations = $this->applyProbability($locations, $config, $postId);
$debug .= '<!-- ADV: applyProbability left ' . count($locations) . ' -->';
if (empty($locations)) {
return $debug . '<!-- ADV: EMPTY after applyProbability -->' . $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);
}
$debug .= '<!-- ADV: filterBySpacing left ' . count($locations) . ' -->';
if (empty($locations)) {
return $debug . '<!-- ADV: EMPTY after filterBySpacing -->' . $content;
}
// Ordenar por posicion para insercion correcta
usort($locations, fn($a, $b) => $a['position'] <=> $b['position']);
$debug .= '<!-- ADV: INSERTING ' . count($locations) . ' ads -->';
// PASO 6: Insertar anuncios
return $debug . $this->insertAds($content, $locations, $format);
}
/**
* PASO 1: Escanea el contenido para encontrar ubicaciones elegibles
*
* IMPORTANTE: No inserta anuncios:
* - Dentro de tablas (<table>...</table>)
* - 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 </table> - 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 = [];
$contentLength = strlen($content);
// Tablas: buscar cada <table> y su </table> correspondiente
$this->findMatchingTags($content, 'table', $zones);
// Iframes (YouTube, Vimeo, etc)
$this->findMatchingTags($content, 'iframe', $zones);
// Figure con clase wp-block-embed (embeds de WordPress)
if (preg_match_all('/<figure[^>]*class="[^"]*wp-block-embed[^"]*"[^>]*>/i', $content, $matches, PREG_OFFSET_CAPTURE)) {
foreach ($matches[0] as $match) {
$startPos = $match[1];
$closePos = strpos($content, '</figure>', $startPos);
if ($closePos !== false) {
$zones[] = [
'start' => $startPos,
'end' => $closePos + strlen('</figure>'),
];
}
}
}
return $zones;
}
/**
* Encuentra tags de apertura y cierre correspondientes
*/
private function findMatchingTags(string $content, string $tagName, array &$zones): void
{
$openTag = '<' . $tagName;
$closeTag = '</' . $tagName . '>';
$offset = 0;
while (($startPos = stripos($content, $openTag, $offset)) !== false) {
// Buscar el cierre correspondiente
$closePos = stripos($content, $closeTag, $startPos);
if ($closePos !== false) {
$zones[] = [
'start' => $startPos,
'end' => $closePos + strlen($closeTag),
];
$offset = $closePos + strlen($closeTag);
} else {
break;
}
}
}
/**
* 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: </table> excluido - no insertamos ads despues de tablas
*/
private function getTypeFromTag(string $tag): ?string
{
return match ($tag) {
'</p>' => 'p',
'</h2>' => 'h2',
'</h3>' => 'h3',
'</figure>' => 'image',
'</ul>', '</ol>' => 'list',
'</blockquote>' => '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('/<img[^>]*>/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 <figure> abierto antes de esta imagen
$contentBefore = substr($content, 0, $imgPosition);
$lastFigureOpen = strrpos($contentBefore, '<figure');
$lastFigureClose = strrpos($contentBefore, '</figure>');
// Si hay figure abierto sin cerrar, esta imagen esta dentro de figure
if ($lastFigureOpen !== false && ($lastFigureClose === false || $lastFigureClose < $lastFigureOpen)) {
continue; // Ignorar, se contara con </figure>
}
// Imagen standalone - calcular posicion despues del tag
$endPosition = $imgPosition + strlen($imgTag);
// Si la imagen esta seguida de </p> 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); // </ul> -> <ul>
// Buscar hacia atras el tag de apertura
$contentBefore = substr($content, 0, $endPos);
$lastOpen = strrpos($contentBefore, '<' . substr($openTag, 1)); // <ul o <ol
if ($lastOpen === false) {
return false;
}
$listContent = substr($content, $lastOpen, $endPos - $lastOpen);
// Contar items (usando substr_count como indica el spec)
$itemCount = substr_count(strtolower($listContent), '<li');
return $itemCount >= 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 </p>, 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) === '</p>') {
$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;
}
}