feat(php): implement advanced in-content ads with multi-element targeting

- Add incontent_advanced group with 19 configurable fields in schema
- Support 5 density modes: paragraphs_only, conservative, balanced,
  aggressive, custom
- Enable ad placement after H2, H3, paragraphs, images, lists,
  blockquotes, and tables
- Add probability-based selection (25-100%) per element type
- Implement priority-based and position-based ad selection strategies
- Add detailed mode descriptions in admin UI for better UX
- Rename 'legacy' terminology to 'paragraphs_only' for clarity
- Support deterministic randomization using post_id + date seed

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
FrankZamora
2025-12-10 10:42:53 -06:00
parent c2fff49961
commit 2896e2d006
5 changed files with 1263 additions and 19 deletions

View File

@@ -9,13 +9,30 @@ use ROITheme\Public\AdsensePlacement\Infrastructure\Ui\AdsensePlacementRenderer;
* Inyecta anuncios dentro del contenido del post
* via filtro the_content
*
* Soporta:
* - Modo aleatorio (random) con posiciones variables
* - Configuracion de 1-8 ads maximo
* - Espacio minimo entre anuncios
* 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
@@ -26,17 +43,32 @@ final class ContentAdInjector
*/
public function inject(string $content): string
{
if (!($this->settings['behavior']['post_content_enabled'] ?? false)) {
return $content;
}
// Verificar longitud minima
// 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;
}
// Obtener configuracion
// 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);
@@ -58,7 +90,7 @@ final class ContentAdInjector
}
// Calcular posiciones de insercion
$adPositions = $this->calculateAdPositions(
$adPositions = $this->calculateParagraphsOnlyPositions(
$totalParagraphs,
$afterParagraphs,
$minBetween,
@@ -72,9 +104,354 @@ final class ContentAdInjector
}
// Reconstruir contenido con anuncios insertados
return $this->buildContentWithAds($paragraphs, $adPositions);
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
*
* @return array{position: int, type: string, tag: string, element_index: int}[]
*/
private function scanContent(string $content): array
{
$locations = [];
$elementIndex = 0;
// Regex para encontrar tags de cierre de elementos de bloque
$pattern = '/(<\/(?:p|h2|h3|figure|ul|ol|table|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
$type = $this->getTypeFromTag($tag);
if ($type) {
$locations[] = [
'position' => $position,
'type' => $type,
'tag' => $tag,
'element_index' => $elementIndex++,
];
}
}
}
// Detectar imagenes standalone (no dentro de figure)
$locations = array_merge($locations, $this->scanStandaloneImages($content, $elementIndex));
// 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;
}
/**
* Convierte tag de cierre a tipo de elemento
*/
private function getTypeFromTag(string $tag): ?string
{
return match ($tag) {
'</p>' => 'p',
'</h2>' => 'h2',
'</h3>' => 'h3',
'</figure>' => 'image',
'</ul>', '</ol>' => 'list',
'</table>' => 'table',
'</blockquote>' => 'blockquote',
default => null,
};
}
/**
* Detecta imagenes que no estan dentro de figure
*/
private function scanStandaloneImages(string $content, int $startIndex): 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 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
*/
@@ -103,11 +480,11 @@ final class ContentAdInjector
}
/**
* Calcula las posiciones donde insertar anuncios
* Calcula las posiciones donde insertar anuncios (modo solo parrafos)
*
* @return int[] Indices de parrafos despues de los cuales insertar ads
*/
private function calculateAdPositions(
private function calculateParagraphsOnlyPositions(
int $totalParagraphs,
int $afterFirst,
int $minBetween,
@@ -117,7 +494,6 @@ final class ContentAdInjector
): array {
// Calcular posiciones disponibles respetando el espacio minimo
$availablePositions = [];
$lastPosition = $afterFirst; // Primera posicion fija
// La primera posicion siempre es despues del parrafo indicado
if ($afterFirst < $totalParagraphs) {
@@ -178,9 +554,9 @@ final class ContentAdInjector
}
/**
* Reconstruye el contenido insertando anuncios en las posiciones indicadas
* Reconstruye el contenido insertando anuncios en las posiciones indicadas (modo solo parrafos)
*/
private function buildContentWithAds(array $paragraphs, array $adPositions): string
private function buildParagraphsOnlyContent(array $paragraphs, array $adPositions): string
{
$newContent = '';
$adsInserted = 0;