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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user