isset( $o['client_id'] ) ? $o['client_id'] : '', 'client_secret' => isset( $o['client_secret'] ) ? AICitare_Crypto::decrypt( $o['client_secret'] ) : '', 'site' => isset( $o['site'] ) && '' !== $o['site'] ? $o['site'] : home_url( '/' ), 'refresh_token' => isset( $o['refresh_token'] ) ? AICitare_Crypto::decrypt( $o['refresh_token'] ) : '', 'access_token' => isset( $o['access_token'] ) ? AICitare_Crypto::decrypt( $o['access_token'] ) : '', 'expires' => isset( $o['expires'] ) ? (int) $o['expires'] : 0, ); } public static function configured() { $c = self::creds(); return '' !== $c['client_id'] && '' !== $c['client_secret']; } public static function connected() { $c = self::creds(); return '' !== $c['refresh_token']; } public static function redirect_uri() { return admin_url( 'admin-post.php?action=aicitare_gsc_callback' ); } /** Persist client id / secret / site property. */ public static function save_creds( $client_id, $client_secret, $site ) { $o = self::data(); $o['client_id'] = sanitize_text_field( $client_id ); if ( '__clear__' === $client_secret ) { $o['client_secret'] = ''; } elseif ( $client_secret && false === strpos( $client_secret, '•' ) ) { $o['client_secret'] = AICitare_Crypto::encrypt( sanitize_text_field( $client_secret ) ); } $o['site'] = esc_url_raw( $site ); self::save( $o ); } public static function disconnect() { $o = self::data(); unset( $o['refresh_token'], $o['access_token'], $o['expires'] ); self::save( $o ); } /** Build the Google OAuth consent URL. */ public static function auth_url() { if ( ! self::configured() ) { return ''; } $c = self::creds(); $args = array( 'client_id' => $c['client_id'], 'redirect_uri' => self::redirect_uri(), 'response_type' => 'code', 'scope' => self::SCOPE, 'access_type' => 'offline', 'prompt' => 'consent', 'state' => wp_create_nonce( 'aicitare_gsc_oauth' ), ); return self::AUTH_URL . '?' . http_build_query( $args ); } /** OAuth redirect handler: exchange the code for tokens. */ public function handle_callback() { if ( ! current_user_can( apply_filters( 'aicitare_capability', 'manage_options' ) ) ) { wp_die( 'Unauthorized' ); } $state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : ''; if ( ! wp_verify_nonce( $state, 'aicitare_gsc_oauth' ) ) { wp_die( 'Invalid state.' ); } $code = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : ''; $dash = admin_url( 'admin.php?page=' . AICITARE_SLUG ); if ( '' === $code ) { wp_safe_redirect( $dash . '#gsc-error' ); exit; } $c = self::creds(); $res = wp_remote_post( self::TOKEN_URL, array( 'timeout' => 20, 'body' => array( 'code' => $code, 'client_id' => $c['client_id'], 'client_secret' => $c['client_secret'], 'redirect_uri' => self::redirect_uri(), 'grant_type' => 'authorization_code', ), ) ); if ( ! is_wp_error( $res ) ) { $body = json_decode( wp_remote_retrieve_body( $res ), true ); if ( isset( $body['access_token'] ) ) { $o = self::data(); $o['access_token'] = AICitare_Crypto::encrypt( $body['access_token'] ); $o['expires'] = time() + ( isset( $body['expires_in'] ) ? (int) $body['expires_in'] : 3600 ) - 60; if ( isset( $body['refresh_token'] ) ) { $o['refresh_token'] = AICitare_Crypto::encrypt( $body['refresh_token'] ); } self::save( $o ); AICitare_Log::add( 'gsc', 'Connected to Google Search Console.' ); wp_safe_redirect( $dash . '#gsc-connected' ); exit; } } wp_safe_redirect( $dash . '#gsc-error' ); exit; } /** Return a valid access token, refreshing if needed. */ private static function token() { $c = self::creds(); if ( '' === $c['refresh_token'] ) { return ''; } if ( $c['access_token'] && time() < $c['expires'] ) { return $c['access_token']; } $res = wp_remote_post( self::TOKEN_URL, array( 'timeout' => 20, 'body' => array( 'client_id' => $c['client_id'], 'client_secret' => $c['client_secret'], 'refresh_token' => $c['refresh_token'], 'grant_type' => 'refresh_token', ), ) ); if ( is_wp_error( $res ) ) { return ''; } $body = json_decode( wp_remote_retrieve_body( $res ), true ); if ( ! isset( $body['access_token'] ) ) { return ''; } $o = self::data(); $o['access_token'] = AICitare_Crypto::encrypt( $body['access_token'] ); $o['expires'] = time() + ( isset( $body['expires_in'] ) ? (int) $body['expires_in'] : 3600 ) - 60; self::save( $o ); return $body['access_token']; } /** Connectivity test: verify the token works and the configured site is accessible. */ public static function test() { if ( ! self::configured() ) { return array( 'ok' => false, 'msg' => 'Enter Client ID + Secret first.' ); } if ( ! self::connected() ) { return array( 'ok' => false, 'msg' => 'Not connected yet — click Connect.' ); } $token = self::token(); if ( '' === $token ) { return array( 'ok' => false, 'msg' => 'Token refresh failed — reconnect.' ); } $res = wp_remote_get( self::API_BASE . '/sites', array( 'timeout' => 20, 'headers' => array( 'Authorization' => 'Bearer ' . $token ), ) ); if ( is_wp_error( $res ) ) { return array( 'ok' => false, 'msg' => $res->get_error_message() ); } $code = (int) wp_remote_retrieve_response_code( $res ); $body = json_decode( wp_remote_retrieve_body( $res ), true ); if ( $code >= 400 ) { $err = isset( $body['error']['message'] ) ? $body['error']['message'] : ( 'HTTP ' . $code ); return array( 'ok' => false, 'msg' => $err ); } $c = self::creds(); $sites = isset( $body['siteEntry'] ) ? $body['siteEntry'] : array(); $list = array(); $found = false; foreach ( $sites as $se ) { $u = isset( $se['siteUrl'] ) ? $se['siteUrl'] : ''; if ( '' === $u ) { continue; } $list[] = $u; if ( $u === $c['site'] || rtrim( $u, '/' ) === rtrim( $c['site'], '/' ) ) { $found = true; } } if ( $found ) { return array( 'ok' => true, 'msg' => 'Connected — property verified: ' . $c['site'] ); } return array( 'ok' => false, 'msg' => '"' . $c['site'] . '" is not in your Search Console properties. Available: ' . ( $list ? implode( ', ', array_slice( $list, 0, 5 ) ) : 'none' ) ); } /** * Query Search Analytics (clicks/impressions per page, last 28 days). * Returns [ 'ok'=>true, 'rows'=>[ {url,clicks,impressions,ctr,position} ] ] or [ 'ok'=>false, 'msg' ]. */ public static function query() { if ( ! self::connected() ) { return array( 'ok' => false, 'msg' => 'Not connected to Search Console.' ); } $token = self::token(); if ( '' === $token ) { return array( 'ok' => false, 'msg' => 'Could not obtain an access token — reconnect.' ); } $c = self::creds(); $url = self::API_BASE . '/sites/' . rawurlencode( $c['site'] ) . '/searchAnalytics/query'; $res = wp_remote_post( $url, array( 'timeout' => 25, 'headers' => array( 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( array( 'startDate' => gmdate( 'Y-m-d', time() - 28 * DAY_IN_SECONDS ), 'endDate' => gmdate( 'Y-m-d' ), 'dimensions' => array( 'page' ), 'rowLimit' => 200, ) ), ) ); if ( is_wp_error( $res ) ) { return array( 'ok' => false, 'msg' => $res->get_error_message() ); } $body = json_decode( wp_remote_retrieve_body( $res ), true ); if ( ! isset( $body['rows'] ) ) { $err = isset( $body['error']['message'] ) ? $body['error']['message'] : ( 'HTTP ' . (int) wp_remote_retrieve_response_code( $res ) ); return array( 'ok' => false, 'msg' => $err ); } $rows = array(); foreach ( $body['rows'] as $r ) { $rows[] = array( 'url' => isset( $r['keys'][0] ) ? $r['keys'][0] : '', 'clicks' => isset( $r['clicks'] ) ? (int) round( $r['clicks'] ) : 0, 'impressions' => isset( $r['impressions'] ) ? (int) round( $r['impressions'] ) : 0, 'ctr' => isset( $r['ctr'] ) ? round( $r['ctr'] * 100, 1 ) : 0, 'position' => isset( $r['position'] ) ? round( $r['position'], 1 ) : 0, ); } // Sort by impressions desc. usort( $rows, function ( $a, $b ) { return $b['impressions'] - $a['impressions']; } ); AICitare_Log::add( 'gsc', 'Fetched ' . count( $rows ) . ' pages from Search Console.' ); return array( 'ok' => true, 'rows' => array_slice( $rows, 0, 100 ), 'site' => $c['site'] ); } }