get_opts(); $client_id = $o['gsc_client_id'] ?? ''; $client_secret = $o['gsc_client_secret'] ?? ''; $redirect_uri = admin_url( 'tools.php?page=aije-lite&tab=gsc&gsc_callback=1' ); $resp = wp_remote_post( 'https://oauth2.googleapis.com/token', array( 'body' => array( 'code' => sanitize_text_field( $_GET['code'] ), 'client_id' => $client_id, 'client_secret' => $client_secret, 'redirect_uri' => $redirect_uri, 'grant_type' => 'authorization_code' ), ) ); if ( ! is_wp_error( $resp ) ) { $token = json_decode( wp_remote_retrieve_body( $resp ), true ); if ( ! empty( $token['access_token'] ) ) { update_option( 'aije_gsc_access_token', $token['access_token'] ); update_option( 'aije_gsc_refresh_token', $token['refresh_token'] ?? '' ); update_option( 'aije_gsc_token_expiry', time() + ( $token['expires_in'] ?? 3600 ) ); wp_safe_redirect( admin_url( 'tools.php?page=aije-lite&tab=gsc&gsc_connected=1' ) ); exit; } } wp_safe_redirect( admin_url( 'tools.php?page=aije-lite&tab=gsc&gsc_error=1' ) ); exit; } if ( isset( $_GET['gsc_disconnect'] ) ) { delete_option( 'aije_gsc_access_token' ); delete_option( 'aije_gsc_refresh_token' ); delete_option( 'aije_gsc_token_expiry' ); wp_safe_redirect( admin_url( 'tools.php?page=aije-lite&tab=gsc' ) ); exit; } } private function refresh_gsc_token() { $o = $this->get_opts(); $client_id = $o['gsc_client_id'] ?? ''; $client_secret = $o['gsc_client_secret'] ?? ''; $refresh_token = get_option( 'aije_gsc_refresh_token', '' ); if ( ! $refresh_token || ! $client_id || ! $client_secret ) { return false; } $resp = wp_remote_post( 'https://oauth2.googleapis.com/token', array( 'body' => array( 'client_id' => $client_id, 'client_secret' => $client_secret, 'refresh_token' => $refresh_token, 'grant_type' => 'refresh_token' ), 'timeout' => 10 ) ); if ( is_wp_error( $resp ) ) { return false; } $token = json_decode( wp_remote_retrieve_body( $resp ), true ); if ( empty( $token['access_token'] ) ) { return false; } update_option( 'aije_gsc_access_token', $token['access_token'] ); update_option( 'aije_gsc_token_expiry', time() + ( $token['expires_in'] ?? 3600 ) ); return $token['access_token']; } private function get_valid_gsc_token() { $access_token = get_option( 'aije_gsc_access_token', '' ); $expiry = (int) get_option( 'aije_gsc_token_expiry', 0 ); if ( $expiry && time() > $expiry - 300 ) { $new_token = $this->refresh_gsc_token(); if ( $new_token ) { return $new_token; } delete_option( 'aije_gsc_access_token' ); return ''; } return $access_token; } /** * Ruft GSC Search Analytics ab und gibt Rohdaten zurück. * Wird von tab-gsc.php (Seitenansicht) und render_gsc_data() genutzt. */ private function gsc_query( $access_token, $property, $body_array, $timeout = 15 ) { $resp = wp_remote_post( 'https://www.googleapis.com/webmasters/v3/sites/' . urlencode( $property ) . '/searchAnalytics/query', array( 'headers' => array( 'Authorization' => 'Bearer ' . $access_token, 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( $body_array ), 'timeout' => $timeout ) ); if ( is_wp_error( $resp ) ) { return array(); } return json_decode( wp_remote_retrieve_body( $resp ), true )['rows'] ?? array(); } private function gsc_get_sites( $access_token ) { $resp = wp_remote_get( 'https://www.googleapis.com/webmasters/v3/sites', array( 'headers' => array( 'Authorization' => 'Bearer ' . $access_token ), 'timeout' => 10 ) ); if ( is_wp_error( $resp ) ) { return array(); } if ( wp_remote_retrieve_response_code( $resp ) === 401 ) { $new = $this->refresh_gsc_token(); if ( ! $new ) { delete_option( 'aije_gsc_access_token' ); return array(); } $resp = wp_remote_get( 'https://www.googleapis.com/webmasters/v3/sites', array( 'headers' => array( 'Authorization' => 'Bearer ' . $new ), 'timeout' => 10 ) ); if ( is_wp_error( $resp ) ) { return array(); } } return json_decode( wp_remote_retrieve_body( $resp ), true )['siteEntry'] ?? array(); } /** * Holt query+page Rohdaten für die Opportunity-Berechnungen, gecacht via Transient * (Standard-Cache-Dauer 6h, damit GSC-API nicht bei jedem Tab-Aufruf neu abgefragt wird). */ private function gsc_get_query_page_rows( $access_token, $property, $start, $end ) { $cache_key = 'aije_gsc_qp_' . md5( $property . $start . $end ); $cached = get_transient( $cache_key ); if ( $cached !== false ) { return $cached; } $rows = $this->gsc_query( $access_token, $property, array( 'startDate' => $start, 'endDate' => $end, 'dimensions' => array( 'query', 'page' ), 'rowLimit' => 5000, 'orderBy' => array( array( 'fieldName' => 'impressions', 'sortOrder' => 'DESCENDING' ) ), ), 25 ); set_transient( $cache_key, $rows, 6 * HOUR_IN_SECONDS ); return $rows; } /** * Branchenstandard-Erwartungs-CTR-Kurve nach Position (gerundete Richtwerte). * Quelle: aggregierte Industry-Benchmarks (Backlinko/Advanced Web Ranking-Größenordnung). */ private function gsc_expected_ctr( $position ) { $curve = array( 1 => 28.0, 2 => 15.0, 3 => 11.0, 4 => 8.0, 5 => 6.0, 6 => 5.0, 7 => 4.0, 8 => 3.0, 9 => 2.5, 10 => 2.0, ); $p = max( 1, min( 10, round( $position ) ) ); if ( $position > 10 ) { return 1.0; } return $curve[ $p ] ?? 2.0; } /** * Zentrale Gate-Funktion für das Opportunity Radar. * Aktuell: immer true (Free). Später: hier Tier-Check einbauen (z. B. aije_pro_enabled()). */ private function gsc_opportunity_radar_enabled() { return true; } /** * Berechnet die drei Opportunity-Kategorien aus query+page Rohdaten. * Gibt array mit 'striking_distance', 'ctr_leaks', 'cannibalization' zurück. */ private function gsc_compute_opportunities( $rows ) { $striking = array(); $ctr_leaks = array(); $by_query = array(); foreach ( $rows as $r ) { $query = $r['keys'][0] ?? ''; $page = $r['keys'][1] ?? ''; if ( $query === '' || $page === '' ) { continue; } $clicks = (int) ( $r['clicks'] ?? 0 ); $impr = (int) ( $r['impressions'] ?? 0 ); $ctr = round( ( $r['ctr'] ?? 0 ) * 100, 2 ); $pos = round( $r['position'] ?? 0, 1 ); // 1) Striking Distance: Position 5–15, Mindest-Impressions, um Rauschen zu filtern if ( $pos >= 5 && $pos <= 15 && $impr >= 20 ) { $striking[] = array( 'query' => $query, 'page' => $page, 'position' => $pos, 'impressions' => $impr, 'clicks' => $clicks, 'ctr' => $ctr, ); } // 2) CTR-Lecks: tatsächliche CTR deutlich unter Erwartungswert für die Position if ( $impr >= 50 && $pos <= 20 ) { $expected = $this->gsc_expected_ctr( $pos ); $gap = $expected - $ctr; // positiver Wert = Leck if ( $gap >= 1.5 ) { $ctr_leaks[] = array( 'query' => $query, 'page' => $page, 'position' => $pos, 'impressions' => $impr, 'ctr' => $ctr, 'expected_ctr' => $expected, 'gap' => round( $gap, 1 ), 'lost_clicks_est' => (int) round( $impr * ( $gap / 100 ) ), ); } } // 3) Kannibalisierung: gruppiere nach Query, sammle alle Pages if ( ! isset( $by_query[ $query ] ) ) { $by_query[ $query ] = array(); } $by_query[ $query ][] = array( 'page' => $page, 'impressions' => $impr, 'clicks' => $clicks, 'position' => $pos ); } $cannibalization = array(); foreach ( $by_query as $query => $pages ) { if ( count( $pages ) < 2 ) { continue; } // Nur relevant, wenn mehr als eine Seite nennenswerte Impressionen hat $relevant = array_filter( $pages, function( $p ) { return $p['impressions'] >= 10; } ); if ( count( $relevant ) < 2 ) { continue; } usort( $relevant, function( $a, $b ) { return $b['impressions'] - $a['impressions']; } ); $total_impr = array_sum( array_column( $relevant, 'impressions' ) ); $cannibalization[] = array( 'query' => $query, 'pages' => $relevant, 'page_count' => count( $relevant ), 'total_impressions' => $total_impr, ); } usort( $cannibalization, function( $a, $b ) { return $b['total_impressions'] - $a['total_impressions']; } ); usort( $striking, function( $a, $b ) { return $b['impressions'] - $a['impressions']; } ); usort( $ctr_leaks, function( $a, $b ) { return $b['lost_clicks_est'] - $a['lost_clicks_est']; } ); return array( 'striking_distance' => $striking, 'ctr_leaks' => $ctr_leaks, 'cannibalization' => $cannibalization, 'counts' => array( 'striking_distance' => count( $striking ), 'ctr_leaks' => count( $ctr_leaks ), 'cannibalization' => count( $cannibalization ), ), ); } /** * Generisches Suchfeld + sortierbare Tabelle (gleiches Bedienkonzept wie der Seiten-Tab). * $table_id muss pro Tabelle eindeutig sein, $columns = array( array(key,label,align,sortable) ). * $row_renderer ist eine Closure, die für jede Datenzeile
| ' . esc_html( $label ) . ( $sortable ? ' ' : '' ) . ' | '; } echo '
|---|
' . esc_html__( 'Noch keine ausreichenden Suchdaten im gewählten Zeitraum.', 'ai-json-extractor-auto-embed-lite' ) . '
'; return; } $opp = $this->gsc_compute_opportunities( $rows ); $data = $opp['striking_distance']; echo '🚀 ' . sprintf( esc_html__( 'Keywords auf Position 5–15 mit Quick-Win-Potenzial Richtung Top 5 · %d gefunden', 'ai-json-extractor-auto-embed-lite' ), count( $data ) ) . '
'; if ( empty( $data ) ) { echo '' . esc_html__( 'Keine Quick-Win-Keywords im aktuellen Zeitraum.', 'ai-json-extractor-auto-embed-lite' ) . '
'; return; } $columns = array( array( 'query', __( 'Suchbegriff', 'ai-json-extractor-auto-embed-lite' ), 'left', true ), array( 'page', __( 'Seite', 'ai-json-extractor-auto-embed-lite' ), 'left', true ), array( 'position', __( 'Position', 'ai-json-extractor-auto-embed-lite' ), 'right', true ), array( 'impressions', __( 'Impressionen', 'ai-json-extractor-auto-embed-lite' ), 'right', true ), array( 'clicks', __( 'Klicks', 'ai-json-extractor-auto-embed-lite' ), 'right', true ), array( 'ctr', __( 'CTR', 'ai-json-extractor-auto-embed-lite' ), 'right', true ), ); $path = function( $url ) { return str_replace( home_url(), '', $url ) ?: '/'; }; $renderer = function( $r ) use ( $path ) { $p = $path( $r['page'] ); $search = strtolower( $r['query'] . ' ' . $p ); $html = '' . esc_html__( 'Noch keine ausreichenden Suchdaten im gewählten Zeitraum.', 'ai-json-extractor-auto-embed-lite' ) . '
'; return; } $opp = $this->gsc_compute_opportunities( $rows ); $data = $opp['ctr_leaks']; echo '📉 ' . sprintf( esc_html__( 'Suchbegriffe, deren CTR deutlich unter dem Erwartungswert für ihre Position liegt · %d gefunden', 'ai-json-extractor-auto-embed-lite' ), count( $data ) ) . '
'; if ( empty( $data ) ) { echo '' . esc_html__( 'Keine auffälligen CTR-Lecks gefunden.', 'ai-json-extractor-auto-embed-lite' ) . '
'; return; } $columns = array( array( 'query', __( 'Suchbegriff', 'ai-json-extractor-auto-embed-lite' ), 'left', true ), array( 'page', __( 'Seite', 'ai-json-extractor-auto-embed-lite' ), 'left', true ), array( 'position', __( 'Position', 'ai-json-extractor-auto-embed-lite' ), 'right', true ), array( 'impressions', __( 'Impressionen', 'ai-json-extractor-auto-embed-lite' ), 'right', true ), array( 'ctr', __( 'CTR (ist)', 'ai-json-extractor-auto-embed-lite' ), 'right', true ), array( 'expected_ctr', __( 'CTR (erwartet)', 'ai-json-extractor-auto-embed-lite' ), 'right', true ), array( 'lost_clicks_est', 'Verpasste Klicks (≈)', 'right', true ), ); $path = function( $url ) { return str_replace( home_url(), '', $url ) ?: '/'; }; $renderer = function( $r ) use ( $path ) { $p = $path( $r['page'] ); $search = strtolower( $r['query'] . ' ' . $p ); $html = '' . esc_html__( 'Noch keine ausreichenden Suchdaten im gewählten Zeitraum.', 'ai-json-extractor-auto-embed-lite' ) . '
'; return; } $opp = $this->gsc_compute_opportunities( $rows ); $data = $opp['cannibalization']; echo '⚔️ ' . sprintf( esc_html__( 'Suchbegriffe, für die mehrere eigene Seiten gleichzeitig Impressionen erhalten · %d betroffene Suchbegriffe · Klick auf eine Zeile zeigt die einzelnen URLs', 'ai-json-extractor-auto-embed-lite' ), count( $data ) ) . '
'; if ( empty( $data ) ) { echo '' . esc_html__( 'Keine Kannibalisierung erkannt.', 'ai-json-extractor-auto-embed-lite' ) . '
'; return; } $path = function( $url ) { return str_replace( home_url(), '', $url ) ?: '/'; }; $table_id = 'aije-gsc-cannib'; echo ''; echo '| ' . esc_html( $col[1] ) . ( $col[0] !== '' ? ' ' : '' ) . ' | '; } echo '||||
|---|---|---|---|---|
| ▸' . esc_html( $can['query'] ) . ' | '; echo '' . esc_html( $can['page_count'] ) . ' | '; echo '' . esc_html( number_format_i18n( $can['total_impressions'] ) ) . ' | '; echo '' . esc_html( number_format_i18n( $total_clicks ) ) . ' | '; echo '' . esc_html__( 'Details', 'ai-json-extractor-auto-embed-lite' ) . ' | '; echo '
' . wp_kses( sprintf( __( 'Google zeigt seit dem 3. Juni 2026 in der Search Console, wie oft deine Seiten in %1$s und %2$s erscheinen. Diese Daten umfassen aktuell nur %3$s — keine Klicks oder Suchbegriffe.', 'ai-json-extractor-auto-embed-lite' ), 'AI Overviews', 'AI Mode', '' . esc_html__( 'Impressionen', 'ai-json-extractor-auto-embed-lite' ) . '' ), array( 'strong' => array() ) ) . '
'; echo '' . wp_kses( sprintf( __( 'Google hat diese Daten bisher %s für die Search Console API freigegeben. WP Rankify kann sie deshalb noch nicht automatisch abrufen und in dieser Tabelle anzeigen — das ist eine aktuelle Einschränkung von Google selbst, nicht von diesem Plugin. Sobald Google die API erweitert, wird dieser Tab automatisch mit echten Daten befüllt.', 'ai-json-extractor-auto-embed-lite' ), '' . esc_html__( 'nicht', 'ai-json-extractor-auto-embed-lite' ) . '' ), array( 'strong' => array() ) ) . '
'; echo '🔗 In der echten Search Console prüfen'; echo '' . esc_html__( 'Dort: Performance-Bericht öffnen → Filter „Suchtyp" bzw. „Search Appearance" → AI Overviews / AI Mode auswählen.', 'ai-json-extractor-auto-embed-lite' ) . '
'; echo ''; } /** * Rendert die GSC-Datentabelle (aufgerufen aus tab-gsc.php wenn verbunden). */ private function render_gsc_data( $access_token ) { $access_token = $this->get_valid_gsc_token(); if ( ! $access_token ) { echo '⚠ Google Token abgelaufen. Bitte Verbindung trennen und neu verbinden.
' . esc_html__( 'Keine Search Console Properties gefunden.', 'ai-json-extractor-auto-embed-lite' ) . '
'; return; } $selected = get_option( 'aije_gsc_property', $entries[0]['siteUrl'] ?? '' ); if ( isset( $_POST['gsc_property'] ) ) { $selected = sanitize_text_field( $_POST['gsc_property'] ); update_option( 'aije_gsc_property', $selected ); } // Property-Auswahl echo '' . esc_html__( 'Keine Daten gefunden.', 'ai-json-extractor-auto-embed-lite' ) . '
'; return; } // KPI-Zeile $total_clicks = array_sum( array_column( $rows, 'clicks' ) ); $total_impr = array_sum( array_column( $rows, 'impressions' ) ); $cmp_clicks = array_sum( array_column( $cmp_rows, 'clicks' ) ); $cmp_impr = array_sum( array_column( $cmp_rows, 'impressions' ) ); $ti_avg = $total_impr > 0 ? $total_impr : 1; $w_ctr = 0; $w_pos = 0; foreach ( $rows as $r ) { $iw = ( $r['impressions'] ?? 0 ) / $ti_avg; $w_ctr += ( $r['ctr'] ?? 0 ) * $iw; $w_pos += ( $r['position'] ?? 0 ) * $iw; } $avg_ctr = round( $w_ctr * 100, 1 ); $avg_pos = round( $w_pos, 1 ); $dc = $total_clicks - $cmp_clicks; $di = $total_impr - $cmp_impr; $fmt = function( $v, $inv = false ) { if ( $v == 0 ) return '±0'; $pos = $inv ? $v < 0 : $v > 0; $c = $pos ? '#22c55e' : '#ef4444'; return '' . ( $v > 0 ? '+' : '' ) . round( $v, 1 ) . ''; }; echo '| ' . esc_html( $col[1] ) . ( ! $no_sort ? ' ' : '' ) . ' | '; } echo '||||||
|---|---|---|---|---|---|---|
| ' . ( $task_html ? ' ' . $task_html . ' ' : '✓ OK' ) . ' | ';
echo '' . number_format_i18n( $clicks ) . ' | '; echo '' . $dc_html . ' | '; echo '' . number_format_i18n( $impr ) . ' | '; echo '' . $ctr_raw . '% | '; echo '' . $pos . ' | '; echo '' . $dp_html . ' | '; echo '