Das YouTube-video auf der startseite ist kein .elementor-widget-video, sondern
Elementors neueres element <div class="e-youtube-base" data-e-type="e-youtube"
data-settings='{"source":"https://www.youtube.com/watch?v=..."}'>. Daher griff
weder server-autodetect (kein iframe) noch mein client-handler -> leere box.
- selektor erweitert um .e-youtube-base/.e-vimeo-base und
[data-e-type^="e-youtube"|"e-vimeo"].
- URL-extraktion liest jetzt auch den key "source" (und "url").
An der echten data-settings der live-seite verifiziert.
+ version 1.4.2 (assets cache-busting).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
400 lines
14 KiB
JavaScript
400 lines
14 KiB
JavaScript
/**
|
||
* Content Blocker – frontend.js
|
||
* Click-to-load: the real iframe is CREATED only after the user actively
|
||
* consents per service. Consent is stored in localStorage per service id.
|
||
*/
|
||
|
||
( function () {
|
||
'use strict';
|
||
|
||
const STORAGE_PREFIX = 'cb_consent_';
|
||
const CFG = window.cbConfig || { services: [], i18n: {} };
|
||
|
||
/* ───────────────────────── consent storage ───────────────────────── */
|
||
|
||
function hasConsent( serviceId ) {
|
||
try {
|
||
return localStorage.getItem( STORAGE_PREFIX + serviceId ) === '1';
|
||
} catch ( e ) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function grantConsent( serviceId ) {
|
||
try {
|
||
localStorage.setItem( STORAGE_PREFIX + serviceId, '1' );
|
||
} catch ( e ) {
|
||
// localStorage unavailable; allow the load for this session only.
|
||
}
|
||
// Release any third-party <script> blocked for this service (handled by
|
||
// the early head guard, which exposes cbActivateScripts).
|
||
if ( typeof window.cbActivateScripts === 'function' ) {
|
||
window.cbActivateScripts( serviceId );
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Public: grant consent for a service from a custom control
|
||
* ([content_blocker_consent]) — used for script-only services that have no
|
||
* visible embed. Reloads so blocked scripts/embeds load cleanly.
|
||
*/
|
||
window.cbConsent = function ( serviceId ) {
|
||
if ( ! serviceId ) {
|
||
return;
|
||
}
|
||
grantConsent( serviceId );
|
||
window.location.reload();
|
||
};
|
||
|
||
/* ───────────────────────── iframe loading ────────────────────────── */
|
||
|
||
/** Replace a .cb-blocker element with the real iframe. src comes from data-src. */
|
||
function loadContent( blockerEl ) {
|
||
const src = blockerEl.dataset.src;
|
||
if ( ! src ) {
|
||
blockerEl.remove();
|
||
return;
|
||
}
|
||
|
||
const iframe = document.createElement( 'iframe' );
|
||
|
||
if ( blockerEl.dataset.width ) iframe.width = blockerEl.dataset.width;
|
||
if ( blockerEl.dataset.height ) iframe.height = blockerEl.dataset.height;
|
||
|
||
iframe.setAttribute( 'loading', 'lazy' );
|
||
iframe.setAttribute( 'allowfullscreen', '' );
|
||
iframe.setAttribute( 'referrerpolicy', 'no-referrer-when-downgrade' );
|
||
iframe.setAttribute( 'data-cb-loaded', '1' ); // mark as ours (observer skips)
|
||
|
||
// Set src last — this is the moment the network request is made.
|
||
iframe.src = src;
|
||
|
||
blockerEl.parentNode.replaceChild( iframe, blockerEl );
|
||
}
|
||
|
||
function loadPreConsented() {
|
||
document.querySelectorAll( '.cb-blocker[data-cb-id]' ).forEach( function ( el ) {
|
||
const id = el.dataset.cbId;
|
||
if ( id && hasConsent( id ) ) {
|
||
loadContent( el );
|
||
}
|
||
} );
|
||
}
|
||
|
||
/* ───────────────────────── consent buttons ───────────────────────── */
|
||
|
||
function attachButtons() {
|
||
document.addEventListener( 'click', function ( e ) {
|
||
const btn = e.target.closest( '.cb-blocker__button' );
|
||
if ( ! btn ) return;
|
||
|
||
const serviceId = btn.dataset.cbId;
|
||
if ( ! serviceId ) return;
|
||
|
||
const wrapper = btn.closest( '.cb-blocker' );
|
||
|
||
// Placeholder without its own embed URL (e.g. an Elementor widget we
|
||
// couldn't read the URL from): persist consent and reload so the
|
||
// original embed renders normally.
|
||
if ( wrapper && wrapper.getAttribute( 'data-cb-reload' ) === '1' ) {
|
||
grantConsent( serviceId );
|
||
window.location.reload();
|
||
return;
|
||
}
|
||
|
||
// "Remember" checkbox (default checked): persist consent only when
|
||
// ticked; otherwise load this embed once without storing consent.
|
||
const remember = wrapper
|
||
? wrapper.querySelector( '.cb-blocker__remember-cb' )
|
||
: null;
|
||
if ( ! remember || remember.checked ) {
|
||
grantConsent( serviceId );
|
||
}
|
||
|
||
if ( wrapper ) {
|
||
loadContent( wrapper );
|
||
}
|
||
} );
|
||
}
|
||
|
||
/* ───────────────────────── revoke (Art. 7 (3)) ───────────────────── */
|
||
|
||
window.cbRevokeAll = function () {
|
||
try {
|
||
const keysToRemove = [];
|
||
for ( let i = 0; i < localStorage.length; i++ ) {
|
||
const key = localStorage.key( i );
|
||
if ( key && key.indexOf( STORAGE_PREFIX ) === 0 ) {
|
||
keysToRemove.push( key );
|
||
}
|
||
}
|
||
keysToRemove.forEach( function ( k ) {
|
||
localStorage.removeItem( k );
|
||
} );
|
||
} catch ( e ) {
|
||
// Silently ignore if localStorage is unavailable.
|
||
}
|
||
window.location.reload();
|
||
};
|
||
|
||
/* ─────────────── client-side detection (JS-injected iframes) ─────────────── */
|
||
|
||
/** Find a configured service whose match pattern is contained in the URL. */
|
||
function findService( url ) {
|
||
if ( ! url ) return null;
|
||
const list = CFG.services || [];
|
||
for ( let i = 0; i < list.length; i++ ) {
|
||
if ( list[ i ].match && url.indexOf( list[ i ].match ) !== -1 ) {
|
||
return list[ i ];
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Convert a YouTube/Vimeo watch URL into its embeddable URL. */
|
||
function toEmbedUrl( url ) {
|
||
let m = url.match( /(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([\w-]+)/ );
|
||
if ( m ) return 'https://www.youtube.com/embed/' + m[ 1 ];
|
||
m = url.match( /(?:player\.)?vimeo\.com\/(?:video\/)?(\d+)/ );
|
||
if ( m ) return 'https://player.vimeo.com/video/' + m[ 1 ];
|
||
return url;
|
||
}
|
||
|
||
/** Build a consent placeholder element (mirrors PHP render_placeholder). */
|
||
function buildPlaceholder( svc, src, dims ) {
|
||
const i18n = CFG.i18n || {};
|
||
dims = dims || {};
|
||
|
||
const wrap = document.createElement( 'div' );
|
||
wrap.className = 'cb-blocker';
|
||
wrap.setAttribute( 'data-cb-id', svc.id );
|
||
wrap.dataset.src = src;
|
||
if ( dims.width ) wrap.dataset.width = dims.width;
|
||
if ( dims.height ) wrap.dataset.height = dims.height;
|
||
if ( dims.height && /^\d+$/.test( String( dims.height ) ) ) {
|
||
wrap.style.minHeight = dims.height + 'px';
|
||
}
|
||
|
||
const inner = document.createElement( 'div' );
|
||
inner.className = 'cb-blocker__inner';
|
||
|
||
const text = document.createElement( 'p' );
|
||
text.className = 'cb-blocker__text';
|
||
if ( svc.placeholder ) {
|
||
text.textContent = svc.placeholder;
|
||
} else {
|
||
const tpl = i18n.defaultText || '%s';
|
||
const parts = tpl.split( '%s' );
|
||
text.appendChild( document.createTextNode( parts[ 0 ] || '' ) );
|
||
const strong = document.createElement( 'strong' );
|
||
strong.textContent = svc.name;
|
||
text.appendChild( strong );
|
||
text.appendChild( document.createTextNode( parts[ 1 ] || '' ) );
|
||
}
|
||
inner.appendChild( text );
|
||
|
||
const rec = document.createElement( 'p' );
|
||
rec.className = 'cb-blocker__recipient';
|
||
const recLabel = document.createElement( 'strong' );
|
||
recLabel.textContent = ( i18n.recipient || 'Empfänger:' ) + ' ';
|
||
rec.appendChild( recLabel );
|
||
rec.appendChild( document.createTextNode( svc.recipient || '' ) );
|
||
if ( svc.third_country ) {
|
||
rec.appendChild( document.createTextNode( ' — ' ) );
|
||
const tc = document.createElement( 'span' );
|
||
tc.className = 'cb-blocker__third-country';
|
||
tc.textContent = i18n.thirdCountry || '';
|
||
rec.appendChild( tc );
|
||
}
|
||
inner.appendChild( rec );
|
||
|
||
const pur = document.createElement( 'p' );
|
||
pur.className = 'cb-blocker__purpose';
|
||
const purLabel = document.createElement( 'strong' );
|
||
purLabel.textContent = ( i18n.purpose || 'Zweck:' ) + ' ';
|
||
pur.appendChild( purLabel );
|
||
pur.appendChild( document.createTextNode( svc.purpose || '' ) );
|
||
inner.appendChild( pur );
|
||
|
||
if ( svc.privacy_url ) {
|
||
const p = document.createElement( 'p' );
|
||
const a = document.createElement( 'a' );
|
||
a.className = 'cb-blocker__privacy-link';
|
||
a.href = svc.privacy_url;
|
||
a.target = '_blank';
|
||
a.rel = 'noopener noreferrer';
|
||
a.textContent = i18n.privacy || 'Datenschutz';
|
||
p.appendChild( a );
|
||
inner.appendChild( p );
|
||
}
|
||
|
||
const btn = document.createElement( 'button' );
|
||
btn.type = 'button';
|
||
btn.className = 'cb-blocker__button';
|
||
btn.setAttribute( 'data-cb-id', svc.id );
|
||
btn.textContent = ( i18n.load || '%s' ).replace( '%s', svc.name );
|
||
inner.appendChild( btn );
|
||
|
||
const label = document.createElement( 'label' );
|
||
label.className = 'cb-blocker__remember';
|
||
const cb = document.createElement( 'input' );
|
||
cb.type = 'checkbox';
|
||
cb.className = 'cb-blocker__remember-cb';
|
||
cb.checked = true;
|
||
label.appendChild( cb );
|
||
label.appendChild( document.createTextNode( ' ' + ( i18n.remember || '' ) ) );
|
||
inner.appendChild( label );
|
||
|
||
wrap.appendChild( inner );
|
||
return wrap;
|
||
}
|
||
|
||
/** Replace a freshly seen iframe with a placeholder (or let it load if consented). */
|
||
function handleIframe( iframe ) {
|
||
if ( iframe.dataset.cbLoaded ) return; // one we created
|
||
if ( iframe.closest( '.cb-blocker' ) ) return; // inside a placeholder
|
||
const rawSrc = iframe.getAttribute( 'src' ) || '';
|
||
const svc = findService( rawSrc );
|
||
if ( ! svc ) return;
|
||
|
||
if ( hasConsent( svc.id ) ) return; // already consented → leave it
|
||
|
||
iframe.setAttribute( 'src', 'about:blank' ); // stop the request ASAP
|
||
const dims = {
|
||
width: iframe.getAttribute( 'width' ) || '',
|
||
height: iframe.getAttribute( 'height' ) || '',
|
||
};
|
||
const placeholder = buildPlaceholder( svc, rawSrc, dims );
|
||
if ( iframe.parentNode ) {
|
||
iframe.parentNode.replaceChild( placeholder, iframe );
|
||
}
|
||
}
|
||
|
||
/** Reconstruct a YouTube watch URL from a thumbnail (i.ytimg.com/vi/ID/…). */
|
||
function ytIdFromThumb( s ) {
|
||
const m = String( s || '' ).match( /(?:i\.ytimg\.com|img\.youtube\.com)\/vi\/([\w-]{6,})\// );
|
||
return m ? m[ 1 ] : '';
|
||
}
|
||
|
||
/**
|
||
* Find the embedded video URL of an Elementor video widget. Elementor often
|
||
* does NOT put the URL in data-settings (only {"video_type":"youtube"}) and
|
||
* builds the player via the JS API — so we look in several places.
|
||
*/
|
||
function videoUrlForWidget( widget ) {
|
||
const ds = widget.getAttribute( 'data-settings' ) || '';
|
||
|
||
// 1) Explicit URL keys in data-settings (older/other Elementor versions).
|
||
try {
|
||
const s = JSON.parse( ds || '{}' );
|
||
const u = s.youtube_url || s.vimeo_url || s.video_url || s.external_url
|
||
|| s.hosted_url || s.source || s.url || ( s.link && s.link.url ) || '';
|
||
if ( u ) return u;
|
||
} catch ( e ) { /* not JSON / no keys */ }
|
||
|
||
// 2) Any youtube/vimeo URL anywhere in the data-settings string (JSON values
|
||
// are quoted, so stop at a quote/space; tolerate escaped slashes \/).
|
||
const m = ds.match( /https?:[^"\s]*?(?:youtube\.com|youtu\.be|vimeo\.com)[^"\s]*/i );
|
||
if ( m ) return m[ 0 ].replace( /\\\//g, '/' ).replace( /\\/g, '' );
|
||
|
||
// 3) A server-rendered iframe already inside the widget.
|
||
const ifr = widget.querySelector( 'iframe[src]' );
|
||
if ( ifr ) return ifr.getAttribute( 'src' );
|
||
|
||
// 4) A YouTube thumbnail (lazyload / image-overlay mode) → rebuild the URL.
|
||
const thumb = widget.querySelector(
|
||
'img[src*="ytimg.com/vi/"], img[src*="img.youtube.com/vi/"], [style*="ytimg.com/vi/"]'
|
||
);
|
||
if ( thumb ) {
|
||
const id = ytIdFromThumb( thumb.getAttribute( 'src' ) || thumb.getAttribute( 'style' ) || '' );
|
||
if ( id ) return 'https://www.youtube.com/watch?v=' + id;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
/** Elementor video widgets build the player via JS — replace them with our
|
||
* consent placeholder before that happens. */
|
||
function handleElementorVideos() {
|
||
const widgets = document.querySelectorAll(
|
||
'.elementor-widget-video, .elementor-widget[data-widget_type^="video"], '
|
||
+ '.e-youtube-base, .e-vimeo-base, [data-e-type^="e-youtube"], [data-e-type^="e-vimeo"]'
|
||
);
|
||
widgets.forEach( function ( widget ) {
|
||
const container = widget.querySelector( '.elementor-widget-container' ) || widget;
|
||
// Already turned into a placeholder (e.g. by the server-side autodetect
|
||
// catching a rendered iframe) → leave it.
|
||
if ( container.querySelector( '.cb-blocker' ) ) return;
|
||
|
||
const raw = videoUrlForWidget( widget );
|
||
const embed = raw ? toEmbedUrl( raw ) : '';
|
||
let svc = raw ? ( findService( raw ) || findService( embed ) ) : null;
|
||
|
||
// Fallback: no URL in the DOM (Elementor only kept {"video_type":"…"}).
|
||
// Identify the service by the video type so we can still show consent.
|
||
if ( ! svc ) {
|
||
const vt = ( widget.getAttribute( 'data-settings' ) || '' )
|
||
.match( /"video_type"\s*:\s*"(youtube|vimeo|dailymotion)"/i );
|
||
if ( vt ) {
|
||
svc = findService( vt[ 1 ] + '.com' );
|
||
}
|
||
}
|
||
if ( ! svc ) return;
|
||
|
||
if ( hasConsent( svc.id ) ) return; // consented → let Elementor build it
|
||
|
||
// Neutralise Elementor so it won't build the player / load the overlay.
|
||
widget.removeAttribute( 'data-settings' );
|
||
const placeholder = buildPlaceholder( svc, embed, {} );
|
||
|
||
// No embed URL → we can't build the iframe ourselves. Make the button
|
||
// grant consent and reload; Elementor then renders the video normally
|
||
// (scripts are released after consent). Drop the "load once" option.
|
||
if ( ! embed ) {
|
||
placeholder.setAttribute( 'data-cb-reload', '1' );
|
||
const remember = placeholder.querySelector( '.cb-blocker__remember' );
|
||
if ( remember ) remember.remove();
|
||
}
|
||
|
||
container.innerHTML = '';
|
||
container.appendChild( placeholder );
|
||
} );
|
||
}
|
||
|
||
function scanExistingIframes() {
|
||
document.querySelectorAll( 'iframe[src]' ).forEach( handleIframe );
|
||
}
|
||
|
||
function startObserver() {
|
||
if ( typeof MutationObserver === 'undefined' ) return;
|
||
const obs = new MutationObserver( function ( mutations ) {
|
||
for ( const m of mutations ) {
|
||
for ( const node of m.addedNodes ) {
|
||
if ( node.nodeType !== 1 ) continue;
|
||
if ( node.tagName === 'IFRAME' ) {
|
||
handleIframe( node );
|
||
} else if ( node.querySelectorAll ) {
|
||
node.querySelectorAll( 'iframe[src]' ).forEach( handleIframe );
|
||
}
|
||
}
|
||
}
|
||
} );
|
||
obs.observe( document.documentElement, { childList: true, subtree: true } );
|
||
}
|
||
|
||
/* ───────────────────────── bootstrap ─────────────────────────────── */
|
||
|
||
function init() {
|
||
loadPreConsented();
|
||
attachButtons();
|
||
handleElementorVideos();
|
||
scanExistingIframes();
|
||
startObserver();
|
||
}
|
||
|
||
if ( document.readyState === 'loading' ) {
|
||
document.addEventListener( 'DOMContentLoaded', init );
|
||
} else {
|
||
init();
|
||
}
|
||
} )();
|