1// Client-side promo loader â fetches from /api/promo/serve/:zone and injects HTML 2// Empty containers are hidden via CSS (.sponsor-slot:empty { display:none }) 3(function() { 4 'use strict'; 5 6 // Sanitización básica de HTML para prevenir XSS (scripts, event handlers, javascript: hrefs) 7 function sanitizeHtml(html) { 8 var div = document.createElement('div'); 9 div.innerHTML = html; 10 // Eliminar etiquetas script 11 var scripts = div.querySelectorAll('script'); 12 for (var i = 0; i < scripts.length; i++) scripts[i].remove(); 13 // Eliminar event handlers de todos los elementos 14 var all = div.querySelectorAll('*'); 15 for (var i = 0; i < all.length; i++) { 16 var attrs = all[i].attributes; 17 for (var j = attrs.length - 1; j >= 0; j--) { 18 if (attrs[j].name.toLowerCase().startsWith('on')) { 19 all[i].removeAttribute(attrs[j].name); 20 } 21 } 22 // Eliminar hrefs con javascript: 23 if (all[i].hasAttribute('href') && all[i].getAttribute('href').trim().toLowerCase().startsWith('javascript:')) { 24 all[i].removeAttribute('href'); 25 } 26 } 27 return div.innerHTML; 28 } 29 30 var containers = document.querySelectorAll('[data-promo]'); 31 if (!containers.length) return; 32 33 var rotatingSlots = []; // Slots que rotan (no AdSense) 34 containers.forEach(function(el) { loadSlot(el); }); 35 36 // En notas con video NO rotamos los anuncios: evita interrumpir la 37 // reproducción y los saltos de maquetación mientras el lector ve el video. 38 var hasVideo = !!document.querySelector('video, iframe[src*="youtube"], iframe[src*="vimeo"], iframe[src*="dailymotion"], iframe[src*="tiktok"]'); 39 40 // Rotación automática cada 120 segundos para slots con campañas propias. 41 if (!hasVideo) { 42 setInterval(function() { 43 rotatingSlots.forEach(function(el) { 44 if (document.hidden) return; // No rotar si la pestaña no está visible 45 loadSlot(el, true); 46 }); 47 }, 120000); 48 } 49 50 function loadSlot(el, isRotation) { 51 var zone = el.getAttribute('data-promo'); 52 fetch('/api/promo/serve/' + zone) 53 .then(function(res) { return res.json(); }) 54 .then(function(data) { 55 if (!data || data.type === 'empty') { return; } 56 57 var html = ''; 58 // Limitar altura del creative para que no cubra más del 50% del viewport mobile (~340px en celulares estándar). 59 // Evita que un creative de 1200px de alto colapse la lectura. 60 var imgStyle = 'max-height:340px;width:auto;max-width:100%;margin:0 auto;display:block;object-fit:contain'; 61 if (data.type === 'image' && data.imageUrl) { 62 var clickWrap = data.clickUrl 63 ? '<a href="/api/promo/click/' + data.id + '" target="_blank" rel="noopener sponsored">' 64 : ''; 65 var clickEnd = data.clickUrl ? '</a>' : ''; 66 html = clickWrap + '<img src="' + data.imageUrl + '" alt="' + (data.altText || '') + '" class="rounded-lg" style="' + imgStyle + '" loading="lazy">' + clickEnd; 67 } else if (data.type === 'video' && data.videoUrl) { 68 var vClickWrap = data.clickUrl 69 ? '<a href="/api/promo/click/' + data.id + '" target="_blank" rel="noopener sponsored">' 70 : ''; 71 var vClickEnd = data.clickUrl ? '</a>' : ''; 72 html = vClickWrap + '<video src="' + data.videoUrl + '" autoplay muted loop playsinline class="rounded-lg" style="' + imgStyle + '">' + (data.altText || '') + '</video>' + vClickEnd; 73 } else if (data.type === 'html' && data.htmlContent) { 74 html = sanitizeHtml(data.htmlContent); 75 } else if (data.type === 'adsense' && data.adsenseClient) { 76 if (!isRotation) renderAdsense(el, data); // No re-inyectar AdSense en rotación 77 return; 78 } 79 80 if (!html) { return; } 81 // Reset reserved CLS space once content loads 82 el.style.minHeight = '0'; 83 // Transición suave en rotación 84 if (isRotation) { 85 el.style.opacity = '0'; 86 setTimeout(function() { 87 el.innerHTML = html; 88 el.style.opacity = '1'; 89 }, 200); 90 } else { 91 el.innerHTML = html; 92 } 93 // Registrar slot para rotación (solo campañas propias, no AdSense) 94 if (!isRotation && rotatingSlots.indexOf(el) === -1) { 95 rotatingSlots.push(el); 96 } 97 // Trackear impresión solo cuando el ad sea visible en el viewport 98 scheduleViewTrack(el, data.id); 99 }) 100 .catch(function() {}); 101 } 102 103 // Deduplicación: no recontar la misma campaña en la misma sesión de página 104 var trackedCampaigns = {}; 105 106 // IntersectionObserver para contar impresiones solo cuando el ad es visible 107 var viewObserver = ('IntersectionObserver' in window) 108 ? new IntersectionObserver(function(entries) {
109 entries.forEach(function(entry) { 110 if (!entry.isIntersecting) return; 111 var el = entry.target; 112 var cid = el._pendingTrackId; 113 if (!cid) return; 114 // Solo contar 1 vez por campaña por pageview 115 if (trackedCampaigns[cid]) { viewObserver.unobserve(el); return; } 116 trackedCampaigns[cid] = true; 117 trackView(cid); 118 viewObserver.unobserve(el); 119 }); 120 }, { threshold: 0.5 }) // Al menos 50% visible 121 : null; 122 123 function scheduleViewTrack(el, campaignId) { 124 if (!campaignId) return; 125 // Si ya se contó esta campaña en este pageview, no hacer nada 126 if (trackedCampaigns[campaignId]) return; 127 if (viewObserver) { 128 el._pendingTrackId = campaignId; 129 viewObserver.observe(el); 130 } else { 131 // Fallback sin IntersectionObserver: contar directo pero con dedup 132 if (!trackedCampaigns[campaignId]) { 133 trackedCampaigns[campaignId] = true; 134 trackView(campaignId); 135 } 136 } 137 } 138 139 function renderAdsense(el, data) { 140 el.style.minHeight = '0'; // Reset reserved CLS space 141 var ins = document.createElement('ins'); 142 ins.className = 'adsbygoogle'; 143 ins.style.display = 'block'; 144 ins.setAttribute('data-ad-client', data.adsenseClient); 145 if (data.adsenseSlot) ins.setAttribute('data-ad-slot', data.adsenseSlot); 146 ins.setAttribute('data-ad-format', 'auto'); 147 ins.setAttribute('data-full-width-responsive', 'true'); 148 el.appendChild(ins); 149 try { (window.adsbygoogle = window.adsbygoogle || []).push({}); } catch(e) {} 150 151 // Si AdSense no llena el slot en 3s, colapsar el contenedor para evitar espacio blanco 152 setTimeout(function() { 153 var filled = ins.getAttribute('data-ad-status'); 154 var h = ins.offsetHeight; 155 if (h < 10 || filled === 'unfilled') { 156 el.innerHTML = ''; // Vuelve a :empty â CSS lo oculta 157 } 158 }, 3000); 159 } 160 161 function trackView(id) { 162 if (!id) return; 163 try { 164 navigator.sendBeacon('/api/promo/view/' + id); 165 } catch (e) { 166 fetch('/api/promo/view/' + id, { method: 'POST' }).catch(function() {}); 167 } 168 } 169})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.