'Bäckerei / Konditorei', 'gastronomie' => 'Restaurant / Café / Gastronomie', 'handwerk' => 'Handwerk / Bau', 'einzelhandel' => 'Einzelhandel / Geschäft', 'gesundheit' => 'Arzt / Praxis / Therapie', 'schoenheit' => 'Friseur / Kosmetik / Wellness', 'beratung' => 'Beratung / Coaching', 'anwalt' => 'Rechtsanwalt / Notar', 'steuer' => 'Steuerberatung / Buchhaltung', 'immobilien' => 'Immobilien / Makler', 'agentur' => 'Werbe- / Web-Agentur', 'it' => 'IT / Software / Technik', 'bildung' => 'Bildung / Schule / Kurse', 'tourismus' => 'Hotel / Tourismus / Freizeit', 'verein' => 'Verein / Organisation', 'industrie' => 'Produktion / Industrie / B2B', 'sonstige' => 'Sonstige', ); /** Branchen mit übersetzbarer Bezeichnung (Schlüssel wie BRANCHEN). */ public static function branchen_labels() { return array( 'baeckerei' => __( 'Bakery / pastry shop', 'sectivio' ), 'gastronomie' => __( 'Restaurant / café / catering', 'sectivio' ), 'handwerk' => __( 'Trades / construction', 'sectivio' ), 'einzelhandel' => __( 'Retail / shop', 'sectivio' ), 'gesundheit' => __( 'Doctor / practice / therapy', 'sectivio' ), 'schoenheit' => __( 'Hairdresser / beauty / wellness', 'sectivio' ), 'beratung' => __( 'Consulting / coaching', 'sectivio' ), 'anwalt' => __( 'Lawyer / notary', 'sectivio' ), 'steuer' => __( 'Tax advice / accounting', 'sectivio' ), 'immobilien' => __( 'Real estate / agents', 'sectivio' ), 'agentur' => __( 'Advertising / web agency', 'sectivio' ), 'it' => __( 'IT / software / technology', 'sectivio' ), 'bildung' => __( 'Education / school / courses', 'sectivio' ), 'tourismus' => __( 'Hotel / tourism / leisure', 'sectivio' ), 'verein' => __( 'Association / organization', 'sectivio' ), 'industrie' => __( 'Manufacturing / industry / B2B', 'sectivio' ), 'sonstige' => __( 'Other industry', 'sectivio' ), ); } /** Der Anbieter-Dienst teilt die Basis-URL mit der verwalteten Analyse. */ public static function service_available() { return ADSEC_AI::service_available(); } /** Übertragung nur, wenn der Admin zugestimmt hat UND ein Dienst existiert. */ public static function is_enabled() { $s = get_option( 'adsec_settings', array() ); return !empty( $s['share_data'] ) && self::service_available(); } /** * Gewählte Branche als pool-stabiler Schlüssel (Taxonomie ODER eigener * Freitext, zu a-z0-9- normalisiert). Leer = keine Branche gewählt. */ public static function branche() { $s = get_option( 'adsec_settings', array() ); return sanitize_title( (string) ($s['branche'] ?? '') ); } /** Anzeigename der Branche (Taxonomie-Label oder eigener Freitext). */ public static function branche_label() { $s = get_option( 'adsec_settings', array() ); $b = (string) ($s['branche'] ?? ''); if ( '' === $b ) { return ''; } if ( isset( self::BRANCHEN[$b] ) ) { return self::branchen_labels()[$b]; } $label = trim( (string) ($s['branche_label'] ?? '') ); return ( '' !== $label ? $label : $b ); } /** * Navigationsstruktur als Folge von Themen-Kürzeln (z. B. [hero, leistungen, * referenzen, kontakt]) — der „Navigationsstruktur XY"-Teil der zentralen * Auswertung. Klassifiziert die Top-Level-Menüpunkte über ihre Beschriftung; * KEINE Labels/URLs werden geteilt, nur die anonymen Themen-Kürzel. Defensiv: * findet kein Menü -> leeres Array (bricht nie etwas). */ public static function menu_structure() { $topics = array(); foreach ( self::primary_menu_labels() as $label ) { $topics[] = self::classify_menu_label( $label ); } return $topics; } /** Top-Level-Beschriftungen des primären Menüs (defensive Menü-Ermittlung). */ private static function primary_menu_labels() { $menu_id = 0; $locations = ( function_exists( 'get_nav_menu_locations' ) ? get_nav_menu_locations() : array() ); if ( !empty( $locations ) ) { foreach ( array( 'primary', 'menu-1', 'main', 'header', 'top' ) as $loc ) { if ( !empty( $locations[$loc] ) ) { $menu_id = (int) $locations[$loc]; break; } } if ( !$menu_id ) { $menu_id = (int) reset( $locations ); } } if ( !$menu_id ) { $menus = wp_get_nav_menus(); if ( !empty( $menus ) ) { $menu_id = (int) $menus[0]->term_id; } } if ( !$menu_id ) { return array(); } $items = wp_get_nav_menu_items( $menu_id ); if ( empty( $items ) ) { return array(); } $labels = array(); foreach ( $items as $it ) { if ( 0 === (int) $it->menu_item_parent ) { $labels[] = (string) $it->title; } } return array_slice( $labels, 0, 12 ); } /** Menü-Beschriftung -> Themen-Kürzel (leichtgewichtiges Schlüsselwort-Mapping). */ private static function classify_menu_label( $label ) { $t = ( function_exists( 'mb_strtolower' ) ? mb_strtolower( (string) $label ) : strtolower( (string) $label ) ); $map = array( 'leistungen' => array( 'leistung', 'service', 'angebot', 'produkt', 'lösung', 'losung', 'shop', 'store' ), 'ueber-uns' => array( 'über', 'ueber', 'about', 'unternehmen', 'philosoph', 'geschichte', 'werte' ), 'team' => array('team', 'mitarbeiter', 'crew'), 'jobs' => array( 'job', 'karriere', 'stelle', 'career' ), 'news' => array( 'news', 'blog', 'aktuell', 'magazin', 'presse' ), 'referenzen' => array( 'referenz', 'projekt', 'portfolio', 'kunden', 'case', 'arbeiten' ), 'preise' => array( 'preis', 'pricing', 'tarif', 'paket', 'kosten' ), 'faq' => array( 'faq', 'häufig', 'haeufig', 'fragen' ), 'kontakt' => array('kontakt', 'contact', 'anfrage'), 'standort' => array( 'standort', 'anfahrt', 'öffnung', 'oeffnung', 'adresse' ), 'hero' => array('home', 'start'), ); foreach ( $map as $topic => $words ) { foreach ( $words as $w ) { if ( false !== strpos( $t, $w ) ) { return $topic; } } } return 'unbekannt'; } /** * Vom stündlichen Lern-Cron aufgerufen. Holt zuerst (eigene Drossel) die * gebündelten Branchen-Erkenntnisse, überträgt dann höchstens einmal pro Tag * eine anonyme Auswertung — beides nur bei aktiver Zustimmung. */ public static function maybe_push() { if ( !self::is_enabled() ) { return; } if ( time() - (int) get_option( self::LAST_PUSH_OPTION, 0 ) < self::INTERVAL ) { return; } update_option( self::LAST_PUSH_OPTION, time(), false ); $result = self::push( self::collect_analysis() ); self::record_status( array( 'ingest' => $result, ) ); } /** * Erzwingt Pull + Push sofort, ungedrosselt — für den „Jetzt senden & * Verbindung testen"-Knopf. Macht zuerst einen Health-Check und hält den * exakten HTTP-Code/Fehler jedes Schrittes fest (Diagnose). */ public static function force_sync() { $base = ADSEC_AI::service_url(); $out = array( 'service' => $base, 'branche' => self::branche(), ); if ( '' === $base ) { $out['health'] = 'kein Dienst konfiguriert'; self::record_status( $out ); return $out; } $h = wp_remote_get( trailingslashit( $base ) . 'v1/health', array( 'timeout' => 15, ) ); $out['health'] = ( is_wp_error( $h ) ? 'Fehler: ' . $h->get_error_message() : 'HTTP ' . wp_remote_retrieve_response_code( $h ) ); // Beitragen nur mit Zustimmung — auch der manuelle Test sendet sonst nichts. $out['ingest'] = ( self::is_enabled() ? self::push( self::collect_analysis() ) : 'off' ); self::record_status( $out ); return $out; } /** Letzten Sync-Status (Code/Fehler je Schritt + Zeit) merken. */ private static function record_status( array $data ) { $data['time'] = time(); update_option( self::STATUS_OPTION, $data, false ); } /** Letzter Sync-Status fürs Admin (oder leer). */ public static function status() { $s = get_option( self::STATUS_OPTION, array() ); return ( is_array( $s ) ? $s : array() ); } /** * Anonyme Aggregat-Auswertung dieser Website — keine Besucherdaten, keine * Inhalte im Klartext, nur Kennzahlen je Inhaltstyp und je Land. */ public static function collect_analysis() { global $wpdb; $sections = ADSEC_Database::sections_table(); $stats = ADSEC_Database::stats_table(); // Performance je Inhaltstyp (Anzeigen + Engagement), nur Geräte-Bucket, // damit jede Anzeige genau einmal zählt. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table. $topic_perf = $wpdb->get_results( $wpdb->prepare( 'SELECT s.topic, SUM(st.trials) AS trials, SUM(LEAST(st.successes, st.trials)) AS successes FROM %i st JOIN (SELECT uid, MAX(topic) AS topic FROM %i GROUP BY uid) s ON s.uid = st.section_id WHERE st.ctx_bucket LIKE %s GROUP BY s.topic ORDER BY trials DESC LIMIT 20', $stats, $sections, $wpdb->esc_like( 'd:' ) . '%' ), ARRAY_A ); // Performance je Inhaltstyp UND Land (geografische Auswertung): Land-Buckets // 'c:XX'. Erlaubt zentral „in Region X funktioniert Thema Y besonders gut". // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table. $geo_perf = $wpdb->get_results( $wpdb->prepare( 'SELECT SUBSTRING(st.ctx_bucket, 3) AS land, s.topic, SUM(st.trials) AS trials, SUM(LEAST(st.successes, st.trials)) AS successes FROM %i st JOIN (SELECT uid, MAX(topic) AS topic FROM %i GROUP BY uid) s ON s.uid = st.section_id WHERE st.ctx_bucket LIKE %s GROUP BY land, s.topic ORDER BY trials DESC LIMIT 100', $stats, $sections, $wpdb->esc_like( 'c:' ) . '%' ), ARRAY_A ); // Inhalts-Score-Verteilung je Inhaltstyp (ohne die Inhalte selbst). // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table. $topic_scores = $wpdb->get_results( $wpdb->prepare( 'SELECT topic, COUNT(*) AS n, ROUND(AVG(content_score)) AS avg_score FROM %i GROUP BY topic', $sections ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table. $section_count = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $sections ) ); return array( 'schema' => 3, 'branche' => self::branche(), 'topic_perf' => ( $topic_perf ?: array() ), 'geo_perf' => ( $geo_perf ?: array() ), 'menu' => self::menu_structure(), 'topic_scores' => ( $topic_scores ?: array() ), 'section_count' => $section_count, ); } /** * Sendet die anonyme Auswertung an den Anbieter-Dienst. „Feuern und * vergessen" (non-blocking), damit der Cron nicht aufgehalten wird. Ohne * konfigurierten Dienst passiert nichts (Funktion ist „in Vorbereitung"). * Identifikation NUR über die pseudonyme Site-ID, nie über die URL. */ private static function push( array $payload ) { $base = ADSEC_AI::service_url(); if ( '' === $base ) { return 'kein Dienst konfiguriert'; } // Blockierend (zuverlässige Zustellung). Nur 1×/Tag -> die kurze Wartezeit // im Cron ist unkritisch; „fire-and-forget" wurde bei sofortigem Skript-Ende // (z. B. Redirect nach dem Tick) abgebrochen, bevor der Request rausging. $headers = array( 'content-type' => 'application/json', 'x-adsec-site' => ADSEC_AI::site_id(), 'x-adsec-version' => ADSEC_VERSION, 'x-adsec-install' => ADSEC_AI::install_id(), ); $response = wp_remote_post( trailingslashit( $base ) . 'v1/ingest', array( 'timeout' => 20, 'blocking' => true, 'headers' => $headers, 'body' => wp_json_encode( array( 'site' => ADSEC_AI::site_id(), 'branche' => self::branche(), 'analysis' => $payload, ) ), ) ); if ( is_wp_error( $response ) ) { return 'Fehler: ' . $response->get_error_message(); } return 'HTTP ' . wp_remote_retrieve_response_code( $response ); } }