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 '
|---|
Noch keine ausreichenden Suchdaten im gewählten Zeitraum.
'; return; } $opp = $this->gsc_compute_opportunities( $rows ); $data = $opp['striking_distance']; echo '🚀 Keywords auf Position 5–15 mit Quick-Win-Potenzial Richtung Top 5 · ' . count( $data ) . ' gefunden
'; if ( empty( $data ) ) { echo 'Keine Quick-Win-Keywords im aktuellen Zeitraum.
'; return; } $columns = array( array( 'query', 'Suchbegriff', 'left', true ), array( 'page', 'Seite', 'left', true ), array( 'position', 'Position', 'right', true ), array( 'impressions', 'Impressionen', 'right', true ), array( 'clicks', 'Klicks', 'right', true ), array( 'ctr', 'CTR', '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 = 'Noch keine ausreichenden Suchdaten im gewählten Zeitraum.
'; return; } $opp = $this->gsc_compute_opportunities( $rows ); $data = $opp['ctr_leaks']; echo '📉 Suchbegriffe, deren CTR deutlich unter dem Erwartungswert für ihre Position liegt · ' . count( $data ) . ' gefunden
'; if ( empty( $data ) ) { echo 'Keine auffälligen CTR-Lecks gefunden.
'; return; } $columns = array( array( 'query', 'Suchbegriff', 'left', true ), array( 'page', 'Seite', 'left', true ), array( 'position', 'Position', 'right', true ), array( 'impressions', 'Impressionen', 'right', true ), array( 'ctr', 'CTR (ist)', 'right', true ), array( 'expected_ctr', 'CTR (erwartet)', '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 = 'Noch keine ausreichenden Suchdaten im gewählten Zeitraum.
'; return; } $opp = $this->gsc_compute_opportunities( $rows ); $data = $opp['cannibalization']; echo '⚔️ Suchbegriffe, für die mehrere eigene Seiten gleichzeitig Impressionen erhalten · ' . count( $data ) . ' betroffene Suchbegriffe · Klick auf eine Zeile zeigt die einzelnen URLs
'; if ( empty( $data ) ) { echo 'Keine Kannibalisierung erkannt.
'; 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 'Details | '; echo '
Google zeigt seit dem 3. Juni 2026 in der Search Console, wie oft deine Seiten in AI Overviews und AI Mode erscheinen. Diese Daten umfassen aktuell nur Impressionen — keine Klicks oder Suchbegriffe.
'; echo 'Google hat diese Daten bisher nicht 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.
'; echo '🔗 In der echten Search Console prüfen'; echo 'Dort: Performance-Bericht öffnen → Filter „Suchtyp" bzw. „Search Appearance" → AI Overviews / AI Mode auswählen.
'; 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.
Keine Search Console Properties gefunden.
'; 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 'Keine Daten gefunden.
'; 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 '