'Hero/Intro', 'leistungen' => 'Leistungen', 'ueber-uns' => 'Über uns', 'team' => 'Team', 'jobs' => 'Jobs', 'news' => 'News/Blog', 'referenzen' => 'Referenzen/Projekte', 'preise' => 'Preise', 'faq' => 'FAQ', 'kontakt' => 'Kontakt', 'standort' => 'Standort', 'cta' => 'Abschluss-CTA', 'unbekannt' => '– unbekannt –', ); /** * Struktur-Signale: enthaltene Widgets/Elemente verraten das Thema * zuverlässiger als Text (Klassifikation v2, 12.06.2026). * Muster wird gegen die Widget-Token (elementor-widget-XYZ, wp-block-XYZ) * der Section geprüft. */ const WIDGET_SIGNALS = array( 'kontakt' => array( 'form', 'wpforms', 'contact', 'gravityforms' ), 'standort' => array('google_maps', 'google-maps', 'map'), 'referenzen' => array( 'testimonial', 'reviews', 'portfolio', 'logo' ), 'preise' => array('price-table', 'price-list', 'pricing'), 'faq' => array('accordion', 'toggle', 'faq'), 'team' => array('team-member', 'team', 'person'), 'news' => array('posts', 'loop-grid', 'latest-posts'), ); /** Link-Ziel-Signale: URL-Pfade in der Section verraten das Thema. */ const HREF_SIGNALS = array( 'jobs' => array( 'job', 'karriere', 'career', 'stelle' ), 'kontakt' => array('kontakt', 'contact', 'anfrage'), 'leistungen' => array('leistung', 'service'), 'referenzen' => array( 'referenz', 'projekt', 'portfolio', 'kunden' ), 'preise' => array('preis', 'pricing', 'tarif'), 'team' => array('team'), 'ueber-uns' => array('ueber', 'about', 'unternehmen'), 'news' => array('blog', 'news', 'aktuelles'), 'standort' => array('standort', 'anfahrt'), 'faq' => array('faq'), ); /** DE/EN-Lexikon: Begriff (lowercase) => Thema. Überschrift zählt doppelt. */ const LEXICON = array( 'leistungen' => array( 'leistung', 'service', 'angebot', 'kompetenz', 'lösung', 'solution', 'was wir tun', 'was wir bieten' ), 'ueber-uns' => array( 'über uns', 'ueber uns', 'about', 'unternehmen', 'wir sind', 'geschichte', 'philosophie', 'werte', 'mission' ), 'team' => array( 'team', 'mitarbeiter', 'ansprechpartner', 'köpfe', 'people' ), 'jobs' => array( 'job', 'karriere', 'career', 'offene stelle', 'stellenangebot', 'bewerbung', 'bewirb', 'bewerben', 'mitarbeiten' ), 'news' => array( 'news', 'blog', 'aktuell', 'neuigkeit', 'presse', 'magazin' ), 'referenzen' => array( 'referenz', 'projekt', 'portfolio', 'case', 'kunden', 'testimonial', 'bewertung', 'erfahrungen' ), 'preise' => array( 'preis', 'pricing', 'paket', 'tarif', 'kosten' ), 'faq' => array('faq', 'häufige fragen', 'fragen und antworten'), 'kontakt' => array( 'kontakt', 'contact', 'anfrage', 'termin vereinbaren', 'schreiben sie uns' ), 'standort' => array( 'standort', 'anfahrt', 'öffnungszeiten', 'adresse', 'so finden sie uns', 'location' ), ); /** * Themen, die als Einstieg oder Abschluss gelten und deshalb automatisch fest * an ihrem Platz bleiben (0.44.4: Kontakt und Jobs dazu — auf w-mm.at sprangen * „Bewirb dich" und „Sprechen Sie mit uns" an den Seitenanfang). */ const ANCHOR_TOPICS = array( 'hero', 'cta', 'kontakt', 'jobs' ); /** Klassen-Token, die eine Section markieren — Spiegel der tracker.js-SELECTORS. */ const SECTION_CLASSES = array( 'elementor-section', 'e-con', // Elementor 'et_pb_section', // Divi 'vc_row', // WPBakery 'ct-section', 'brxe-section', // Oxygen, Bricks 'fusion-fullwidth', // Avada / Fusion Builder 'fl-row', // Beaver Builder 'brz-section', // Brizy 'panel-grid', // SiteOrigin Page Builder 'wp-block-cover', // Gutenberg 'bde-section', // Breakdance (0.51.0) 'kb-row-layout-wrap', // Kadence Blocks (0.51.0) 'uagb-is-root-container', // Spectra (0.51.0) 'gb-container', ); const SCAN_BATCH = 40; const REPORT_OPTION = 'adsec_scan_report'; // post id => [ builder, sections ] of the last scan /** * Page builders by their markup, most specific first. Supported = Sectivio * recognizes their sections (checked in tests/compat). Names are brand names. */ const BUILDERS = array( 'elementor' => array('Elementor', true, '/elementor-element|elementor-section/'), 'divi' => array('Divi', true, '/et_pb_section/'), 'wpbakery' => array('WPBakery', true, '/wpb-content-wrapper|vc_row/'), 'avada' => array('Avada', true, '/fusion-fullwidth/'), 'beaver' => array('Beaver Builder', true, '/fl-builder-content/'), 'bricks' => array('Bricks', true, '/brxe-/'), 'breakdance' => array('Breakdance', true, '/bde-section|bde-f-container/'), 'oxygen' => array('Oxygen', true, '/oxy-container|ct-section/'), 'brizy' => array('Brizy', true, '/brz-section/'), 'siteorigin' => array('SiteOrigin Page Builder', true, '/panel-grid/'), 'kadence' => array('Kadence Blocks', true, '/kb-row-layout/'), 'spectra' => array('Spectra', true, '/uagb-is-root-container/'), 'generateblocks' => array('GenerateBlocks', true, '/gb-element-|gb-container/'), 'thrive' => array('Thrive Architect', false, '/thrv_wrapper|tve_flt/'), 'zion' => array('Zion Builder', false, '/znpb-|zb-section/'), 'cornerstone' => array('Cornerstone', false, '/cs-content|x-section/'), 'visualcomposer' => array('Visual Composer', false, '/vce-row/'), 'gutenberg' => array('WordPress blocks', true, '/wp-block-/'), ); /** Which page builder made this page? Slug from BUILDERS, or '' (theme only). */ public static function detect_builder( $html ) { $body = (string) $html; $i = stripos( $body, ' } foreach ( self::BUILDERS as $slug => $b ) { if ( preg_match( $b[2], $body ) ) { return $slug; } } return ''; } /** * Summary of the last scan for the content overview: builders found (with the * number of pages), pages without sections, and unsupported builders. */ public static function scan_summary() { $report = (array) get_option( self::REPORT_OPTION, array() ); $out = array( 'builders' => array(), 'empty' => array(), 'unsupported' => array(), 'sections' => 0, ); foreach ( $report as $post_id => $r ) { list( $b, $n ) = array_pad( (array) $r, 2, 0 ); $out['sections'] += (int) $n; if ( '' !== $b && isset( self::BUILDERS[$b] ) ) { $name = ( 'gutenberg' === $b ? __( 'WordPress blocks', 'sectivio' ) : self::BUILDERS[$b][0] ); $out['builders'][$name] = ($out['builders'][$name] ?? 0) + 1; if ( !self::BUILDERS[$b][1] ) { $out['unsupported'][$name] = ($out['unsupported'][$name] ?? 0) + 1; } } if ( 0 === (int) $n && 'page' === get_post_type( (int) $post_id ) ) { $out['empty'][] = (int) $post_id; } } arsort( $out['builders'] ); return $out; } /** Header/footer areas of builders and themes – mirror of HEADER_RE / FOOTER_RE in tracker.js. */ const HEADER_MARKERS = '/ (elementor-location-header|fusion-tb-header|bde-header-builder|brz-section__header|fl-theme-builder-header|site-header) /'; const FOOTER_MARKERS = '/ (elementor-location-footer|fusion-tb-footer|bde-footer|brz-section__footer|fl-theme-builder-footer|site-footer) /'; /** * Content-Quality-Gate (v0.7.0): Mindest-Score, damit eine Section * seitenübergreifend eingeblendet (reusable) werden darf. Verhindert, dass * inhaltsarme Deko-/Spacer-/Icon-Sections in den Stufe-2-Pool gelangen — * der Bandit würde solche erst nach verschwendetem Traffic aussortieren. */ const MIN_REUSABLE_SCORE = 25; /** * Rechtstext-Begriffe (v0.8.0): Seiten wie Impressum/Datenschutz/AGB werden * automatisch von der Optimierung ausgenommen — weder umsortiert noch als * Einblende-Quelle genutzt (rechtliches Risiko). Geprüft als ganze Tokens * gegen Slug + Titel; der User kann pro Seite übersteuern. Bindestriche in * Slug und Begriff werden zu Leerzeichen normalisiert (Wortgrenzen-Match). */ const LEGAL_TERMS = array( 'impressum', 'imprint', 'datenschutz', 'datenschutzerklaerung', 'datenschutzerklärung', 'privacy', 'privacy policy', 'agb', 'allgemeine geschaeftsbedingungen', 'allgemeine geschäftsbedingungen', 'nutzungsbedingungen', 'terms', 'terms of service', 'terms and conditions', 'terms of use', 'widerruf', 'widerrufsbelehrung', 'widerrufsrecht', 'revocation', 'cancellation', 'refund', 'refund policy', 'rechtliches', 'legal', 'legal notice', 'disclaimer', 'haftungsausschluss', 'cookie', 'cookies', 'cookie policy', 'cookie richtlinie', 'dsgvo', 'gdpr' ); /* ---------- Scan ---------- */ /** * Scannt einen Batch veröffentlichter Inhalte. Mehrfach aufrufen, bis * 'remaining' 0 ist (Admin-Button). Gibt Fortschritt zurück. */ public static function scan_site( $batch = self::SCAN_BATCH ) { $offset = (int) get_option( 'adsec_scan_offset', 0 ); $query = new WP_Query(array( 'post_type' => array('page', 'post'), 'post_status' => 'publish', 'posts_per_page' => max( 1, (int) $batch ), 'offset' => $offset, 'orderby' => 'ID', 'order' => 'ASC', 'fields' => 'ids', )); $scanned = 0; $found = 0; foreach ( $query->posts as $post_id ) { $found += self::scan_post( $post_id ); $scanned++; } $total = (int) $query->found_posts; $remaining = max( 0, $total - $offset - $scanned ); update_option( 'adsec_scan_offset', ( $remaining > 0 ? $offset + $scanned : 0 ), false ); return array( 'scanned' => $scanned, 'sections' => $found, 'remaining' => $remaining, 'done' => $offset + $scanned, 'total' => $total, ); } /** * Holt das gerenderte HTML einer Seite per Loopback-Request und * registriert die erkannten Sections. So funktioniert die Erkennung * editor-unabhängig (Elementor/Divi/... rendern erst zur Laufzeit). */ public static function scan_post( $post_id ) { $url = get_permalink( $post_id ); $response = wp_remote_get( $url, array( 'timeout' => 15, 'sslverify' => false, ) ); if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { return 0; } $html = wp_remote_retrieve_body( $response ); $sections = self::detect_sections( $html ); // 0.51.0: remember the page builder and the number of sections per page, so // the content overview can say what was found (and what is not supported). $report = (array) get_option( self::REPORT_OPTION, array() ); $report[$post_id] = array(self::detect_builder( $html ), count( $sections )); update_option( self::REPORT_OPTION, $report, false ); $seen = array(); foreach ( $sections as $s ) { self::register_section( $post_id, $s ); $seen[] = $s['uid']; } if ( !empty( $seen ) ) { self::prune_stale_sections( $post_id, $seen ); } return count( $sections ); } /** * Entfernt Sections einer Seite, die im aktuellen Render NICHT mehr * vorkommen (behebt den Phantom-Drift: heading-basierte UIDs erzeugten * bei jeder Render-Variation neue Datensätze). Kuratierte Sections * bleiben geschützt: manuell/KI-Thema, gepinnt oder ausgeschlossen. */ private static function prune_stale_sections( $post_id, array $seen_uids ) { global $wpdb; $table = ADSEC_Database::sections_table(); $params = array_merge( array($table, $post_id), array_values( $seen_uids ) ); // Entfernt werden: (a) unbearbeitete Abschnitte, (b) nur AUTOMATISCH fixierte // (Einstieg/Abschluss — Thema hero/cta, nicht von Hand geändert) und (c) alles, // was seit 30 Tagen nicht mehr auf der Seite vorkam. Von Hand Gepflegtes bleibt // so lange erhalten, falls ein Abschnitt nur vorübergehend fehlt. $params[] = gmdate( 'Y-m-d H:i:s', time() - 30 * DAY_IN_SECONDS ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table. $wpdb->query( $wpdb->prepare( "DELETE FROM %i\n\t\t\t WHERE page_id = %d\n\t\t\t AND uid NOT IN (" . implode( ',', array_fill( 0, count( $seen_uids ), '%s' ) ) . ")\n\t\t\t AND (\n\t\t\t ( topic_source = 'auto' AND excluded = 0 AND ( pinned = 0 OR topic IN ('hero','cta','kontakt','jobs') ) )\n\t\t\t OR last_seen < %s\n\t\t\t )", $params ) ); } /** * Spiegel von detectSections() in tracker.js: Kandidaten sammeln, * verschachtelte entfernen, größte Geschwister-Gruppe wählen. */ public static function detect_sections( $html ) { if ( !class_exists( 'DOMDocument' ) || '' === trim( $html ) ) { return array(); } libxml_use_internal_errors( true ); $dom = new DOMDocument(); $dom->loadHTML( '' . $html ); libxml_clear_errors(); $xpath = new DOMXPath($dom); $queries = array(); foreach ( self::SECTION_CLASSES as $cls ) { $queries[] = "//*[contains(concat(' ', normalize-space(@class), ' '), ' {$cls} ')]"; } $queries[] = "//main/*[contains(concat(' ', normalize-space(@class), ' '), ' wp-block-group ')]"; $queries[] = "//*[contains(concat(' ', normalize-space(@class), ' '), ' entry-content ')]/*[contains(concat(' ', normalize-space(@class), ' '), ' wp-block-group ')]"; $queries[] = "//*[contains(concat(' ', normalize-space(@class), ' '), ' wp-block-post-content ')]/*[contains(concat(' ', normalize-space(@class), ' '), ' wp-block-group ')]"; $queries[] = "//*[starts-with(@class, 'gb-element-') or contains(@class, ' gb-element-')]"; // GenerateBlocks 2 $queries[] = '//main/section'; $queries[] = '//article/section'; // 0.48.1: sections hidden on every device (e.g. Elementor "hide on desktop, // tablet and mobile") are not part of the page for visitors – never read in. $devices = self::hide_devices(); $candidates = array(); foreach ( $queries as $q ) { foreach ( $xpath->query( $q ) as $el ) { if ( self::is_hidden_everywhere( $el, $devices, true ) || '' !== self::region_of( $el ) ) { continue; // hidden for everyone, or part of the site header/footer } if ( !in_array( $el, $candidates, true ) ) { $candidates[] = $el; } } } // Dokument-Reihenfolge herstellen (die Kandidaten stammen aus mehreren Abfragen; // Zeilennummern taugen bei minifiziertem HTML nicht). $order = array(); foreach ( $candidates as $i => $el ) { $order[$i] = (int) $xpath->evaluate( 'count(preceding::*)', $el ); } asort( $order ); $candidates = array_values( array_map( function ( $i ) use($candidates) { return $candidates[$i]; }, array_keys( $order ) ) ); $best = self::best_group( self::top_level( $candidates, null ) ); // 0.51.0: look inside wrappers. Block themes (Twenty Twenty-Five …) and some // builders wrap the whole page in one container that matches the section // classes itself; the real sections sit inside it. With one or two top-level // candidates, descend into the one that holds (nearly) all of the content. for ($level = 0; $level < 4 && count( $best ) <= 2; $level++) { $total = 0; foreach ( $best as $el ) { $total += self::text_len( $el ); } $inner = array(); foreach ( $best as $el ) { if ( 1 === count( $best ) || $total > 0 && self::text_len( $el ) / $total >= 0.7 ) { $inner = self::best_group( self::top_level( $candidates, $el ) ); break; } } if ( count( $inner ) < 2 || count( $inner ) <= count( $best ) ) { break; } $best = $inner; } if ( count( $best ) < 2 ) { return array(); } $result = array(); $total = count( $best ); foreach ( array_values( $best ) as $index => $el ) { $classes = $el->getAttribute( 'class' ); $uid_head = self::first_heading( $xpath, $el ); // same as the browser sees in the markup (section id) $snapshot = ''; // Hidden parts inside the section (a hidden button, form or heading) must // not decide what the section is about or which enquiries exist. self::strip_hidden( $xpath, $el, $devices ); $heading = self::first_heading( $xpath, $el ); $text = preg_replace( '/\\s+/u', ' ', trim( $el->textContent ) ); $item = array( 'uid' => self::section_uid( $el->tagName, $classes, $uid_head, $index, self::stable_key( $el ) ), 'heading' => $heading, 'classes' => $classes, 'index' => $index, 'total' => $total, 'excerpt' => mb_substr( $text, 0, 180 ), 'text' => mb_substr( $text, 0, 1500 ), 'features' => self::extract_features( $xpath, $el ), 'region' => self::region_of( $el ), ); $result[] = $item; } // 0.49.0: a short lead-in ("We offer the following services:") belongs to the // section after it – the two only make sense together. $last = count( $result ) - 1; foreach ( $result as $i => $item ) { $result[$i]['lead_in'] = ( $i < $last && self::is_lead_in( $item ) ? 1 : 0 ); } return $result; } /** * Candidates that are not inside another candidate – within $within (a * candidate itself, excluded) or on the whole page. Mirror of topLevel() in tracker.js. */ private static function top_level( array $candidates, $within ) { $out = array(); foreach ( $candidates as $el ) { if ( $el === $within ) { continue; } $inside = null === $within; $nested = false; for ($p = $el->parentNode; $p instanceof DOMNode; $p = $p->parentNode) { if ( null !== $within && $p === $within ) { $inside = true; break; } if ( in_array( $p, $candidates, true ) ) { $nested = true; break; } } if ( $inside && !$nested ) { $out[] = $el; } } return $out; } /** * Largest group of siblings (same parent). Key = XPath of the parent: * spl_object_hash() is unreliable for DOM nodes. libxml's HTML4 parser sometimes * hangs sections directly on ; such strays join the previous content group, * the way the browser's HTML5 parser sees them. Mirror of bestGroup() in tracker.js. */ private static function best_group( array $els ) { $groups = array(); $last_key = null; $last_parent = ''; foreach ( $els as $el ) { $parent = $el->parentNode; $is_stray = $parent instanceof DOMElement && in_array( strtolower( $parent->tagName ), array('body', 'html'), true ); // A content group directly in
is parsed correctly; a section outside //
really is outside (Bricks puts a closing call to action there). $merge = $is_stray && null !== $last_key && 'main' !== $last_parent; $key = ( $merge ? $last_key : $parent->getNodePath() ); $groups[$key][] = $el; if ( '' === self::region_of( $el ) && !$merge ) { $last_key = $key; $last_parent = ( $parent instanceof DOMElement ? strtolower( $parent->tagName ) : '' ); } } $best = array(); foreach ( $groups as $group ) { if ( count( $group ) > count( $best ) ) { $best = $group; } } return $best; } /** Visible text length (whitespace collapsed) – mirror of textLen() in tracker.js. */ private static function text_len( DOMElement $el ) { return mb_strlen( trim( preg_replace( '/\\s+/u', ' ', $el->textContent ) ) ); } /** * Does this section only lead into the next one? A heading with little text and * nothing of its own (no picture, form, button, video), or a short text that * points ahead ("the following", "our services:", ending with a colon). * When in doubt it links: a pair that is never split costs nothing. */ public static function is_lead_in( array $s ) { $f = (array) ($s['features'] ?? array()); if ( !empty( $f['form'] ) || !empty( $f['media'] ) ) { return false; } $heading = trim( (string) ($s['heading'] ?? '') ); $text = trim( (string) ($s['text'] ?? '') ); if ( '' !== $heading && 0 === mb_stripos( $text, $heading ) ) { $text = trim( mb_substr( $text, mb_strlen( $heading ) ) ); } // Content of its own: a picture, a link or button (also from add-ons), a // slider, a map … Layout pieces (columns, groups, inner containers) are not. $own = !empty( $f['raster'] ) || !empty( $f['buttons'] ) || !empty( $f['hrefs'] ) || !empty( $f['link_text_len'] ); foreach ( (array) ($f['widgets'] ?? array()) as $w ) { if ( preg_match( '/image|gallery|video|embed|button|form|map|carousel|slider|icon|counter|testimonial|accordion|tabs?\\b|table|audio|file|social|html|shortcode|countdown|rating|progress|price|lottie|media|spotify|youtube/', $w ) ) { $own = true; } } $all = $heading . ' ' . $text; $cue = (bool) preg_match( '/(nachfolgend|im folgenden|folgende[nrs]?\\b|hier (finden|sehen) sie|das bieten wir|was wir (bieten|anbieten|machen)|unsere leistungen|unser angebot|unsere projekte|unsere referenzen|the following|below you|our services|what we (offer|do))/iu', $all ) || ':' === mb_substr( rtrim( $heading ), -1 ) || ':' === mb_substr( rtrim( $text ), -1 ); if ( '' !== $heading && !$own && mb_strlen( $text ) < 160 ) { return true; } return $cue && !$own && mb_strlen( $text ) < 400; } /** Lead-in found by the rules or by the AI (Pro). */ public static function is_auto_lead( $row ) { return 1 === (int) $row->lead_in || 1 === (int) ($row->lead_ai ?? 0); } /** * Devices a section must be hidden on to count as hidden for everyone * (0.48.1). Elementor can have extra breakpoints (laptop, widescreen …); a * section that is still shown on one of them is visible. Passed to the * browser too (ADSEC_CFG.hideDevices), so server and browser agree. */ public static function hide_devices() { $devices = array('desktop', 'tablet', 'mobile'); if ( class_exists( '\\Elementor\\Plugin' ) && isset( \Elementor\Plugin::$instance->breakpoints ) ) { try { $active = (array) \Elementor\Plugin::$instance->breakpoints->get_active_breakpoints(); if ( $active ) { $devices = array_merge( array('desktop'), array_keys( $active ) ); } } catch ( \Throwable $e ) { $devices = array('desktop', 'tablet', 'mobile'); } } return array_values( array_unique( array_map( 'strval', $devices ) ) ); } /** * Is this element hidden for every visitor by its own markup? Mirror of * hiddenEverywhere() in tracker.js. Covers the "hide on device" switches of * Elementor, WPBakery and Avada, the hidden attribute and inline display:none. * Other ways of hiding (theme CSS) are checked in the browser. */ public static function hidden_by_markup( $classes, $style, $hidden_attr, array $devices ) { if ( $hidden_attr ) { return true; } if ( false !== strpos( strtolower( preg_replace( '/\\s+/', '', (string) $style ) ), 'display:none' ) ) { return true; } $c = ' ' . preg_replace( '/\\s+/', ' ', (string) $classes ) . ' '; $has = function ( array $tokens ) use($c) { foreach ( $tokens as $t ) { if ( false === strpos( $c, ' ' . $t . ' ' ) ) { return false; } } return true; }; $elementor = array(); foreach ( $devices as $d ) { $elementor[] = 'elementor-hidden-' . $d; } return $has( $elementor ) || $has( array( 'vc_hidden-lg', 'vc_hidden-md', 'vc_hidden-sm', 'vc_hidden-xs' ) ) || $has( array('fusion-no-small-visibility', 'fusion-no-medium-visibility', 'fusion-no-large-visibility') ) || $has( array('kb-v-lg-hidden', 'kb-v-md-hidden', 'kb-v-sm-hidden') ) || $has( array('uag-hide-desktop', 'uag-hide-tab', 'uag-hide-mob') ); // Spectra } /** Hidden for everyone – the element itself or (with $ancestors) one of its parents. */ private static function is_hidden_everywhere( DOMElement $el, array $devices, $ancestors = false ) { for ($n = $el; $n instanceof DOMElement; $n = ( $ancestors ? $n->parentNode : null )) { if ( self::hidden_by_markup( $n->getAttribute( 'class' ), $n->getAttribute( 'style' ), $n->hasAttribute( 'hidden' ), $devices ) ) { return true; } } return false; } /** Removes the parts of a section that no visitor can see (analysis copy only). */ private static function strip_hidden( DOMXPath $xpath, DOMElement $el, array $devices ) { $q = './/*[@hidden or contains(@style, "none") or contains(@class, "elementor-hidden-") or contains(@class, "vc_hidden-") or contains(@class, "fusion-no-") or contains(@class, "kb-v-") or contains(@class, "uag-hide-")]'; $nodes = array(); foreach ( $xpath->query( $q, $el ) as $n ) { $nodes[] = $n; } foreach ( $nodes as $n ) { if ( $n->parentNode && self::is_hidden_everywhere( $n, $devices ) ) { $n->parentNode->removeChild( $n ); } } } /** * Inhalts-Merkmale einer Section: Formulare, Bilder, Button-Texte, * Widget-Token, Link-Pfade. Grundlage für Klassifikation v2 und die * Inhalts-Badges im Inventar. */ private static function extract_features( DOMXPath $xpath, DOMElement $el ) { $features = array( 'form' => 0, 'imgs' => 0, 'tel' => 0, 'mailto' => 0, 'buttons' => array(), 'widgets' => array(), 'hrefs' => array(), ); $features['form'] = $xpath->query( './/form', $el )->length; $features['media'] = $xpath->query( './/iframe | .//video', $el )->length; $features['imgs'] = $xpath->query( './/img', $el )->length; // Bildtypen für das Content-Quality-Gate (v0.7.0): echte Fotos (Raster) // zählen stark, SVG-Icons/Logos nur schwach. WICHTIG: Lazy-Load (WP Rocket // u. a.) legt die echte URL in data-src/data-lazy-src, nicht in src. $raster = 0; $svg = 0; foreach ( $xpath->query( './/img', $el ) as $img ) { $src = $img->getAttribute( 'src' ); if ( '' === $src || false !== strpos( $src, 'data:image' ) ) { $src = $img->getAttribute( 'data-src' ); } if ( '' === $src ) { $src = $img->getAttribute( 'data-lazy-src' ); } $src = strtolower( $src ); if ( preg_match( '/\\.svg(\\?|$)/', $src ) ) { $svg++; } elseif ( preg_match( '/\\.(jpe?g|png|webp|gif|avif)(\\?|$)/', $src ) ) { $raster++; } elseif ( '' !== $src ) { $raster++; // unbekannte Endung -> konservativ als echtes Bild werten } } $features['raster'] = $raster; $features['svg'] = $svg; // Navigations-/Menü-Marker (v0.7.1): Footer-Menüs, Mega-Menüs und // Haupt-Navigation sehen „textreich" aus (viele Menü-Labels), sind aber // nie einblendbarer Inhalt. Sitzen meist auf Nachfahren, nicht auf der // Section selbst — daher hier per Nachfahren-Query erkennen. $nav_query = './/nav' . ' | .//*[contains(concat(" ", normalize-space(@class), " "), " menu-item ")]' . ' | .//*[contains(@class, "jet-mega-menu")]' . ' | .//*[contains(@class, "elementor-nav-menu")]' . ' | .//*[contains(@class, "wp-block-navigation")]'; $features['nav'] = ( $xpath->query( $nav_query, $el )->length > 0 ? 1 : 0 ); // Link-Textlänge für die Link-Dichte: Footer/Menü = fast nur Link-Text, // echte Inhalts-Sections haben viel Text AUSSERHALB von Links. $link_text = ''; foreach ( $xpath->query( './/a', $el ) as $a ) { $link_text .= ' ' . $a->textContent; } $features['link_text_len'] = mb_strlen( preg_replace( '/\\s+/u', ' ', trim( $link_text ) ) ); $button_query = './/a[contains(concat(" ", normalize-space(@class), " "), " elementor-button ")]' . ' | .//*[contains(concat(" ", normalize-space(@class), " "), " wp-block-button__link ")]' . ' | .//button'; foreach ( $xpath->query( $button_query, $el ) as $btn ) { $label = preg_replace( '/\\s+/u', ' ', trim( $btn->textContent ) ); if ( '' !== $label && count( $features['buttons'] ) < 4 ) { $features['buttons'][] = mb_substr( $label, 0, 30 ); } } foreach ( $xpath->query( './/a[@href]', $el ) as $a ) { $href = $a->getAttribute( 'href' ); // Conversion-Erkennung: Telefon-/E-Mail-Links als mögliche Erfolge markieren // (unabhängig vom hrefs-Limit, daher 'continue' statt 'break'). if ( preg_match( '/^\\s*tel:/i', $href ) ) { $features['tel'] = 1; } elseif ( preg_match( '/^\\s*mailto:/i', $href ) ) { $features['mailto'] = 1; } if ( count( $features['hrefs'] ) >= 10 ) { continue; } $path = wp_parse_url( $href, PHP_URL_PATH ); if ( $path && '/' !== $path ) { $features['hrefs'][] = $path; } } $widgets = array(); foreach ( $xpath->query( './/*[contains(@class, "elementor-widget-") or contains(@class, "wp-block-")]', $el ) as $w ) { if ( preg_match_all( '/(?:elementor-widget|wp-block)-([a-z0-9_-]{2,30})/', $w->getAttribute( 'class' ), $m ) ) { foreach ( $m[1] as $token ) { $widgets[$token] = 1; } } if ( count( $widgets ) > 20 ) { break; } } $features['widgets'] = array_keys( $widgets ); return $features; } /** * Region einer Section anhand ihrer Vorfahren (v0.7.1): Elementor markiert * Header/Footer am Eltern-Container (elementor-location-header/-footer), nicht * an der Section selbst; zusätzlich
/