5, 'body' => array( 'token' => $refresh ), ) ); } delete_option( self::TOKENS_OPT ); $s = self::settings(); if ( isset( $s['property'] ) ) { unset( $s['property'] ); update_option( self::SETTINGS_OPT, $s, false ); } self::forget_connection_state(); delete_transient( self::SITES_CACHE ); } private static function forget_connection_state() { delete_option( self::LAST_SYNC_OPT ); delete_option( self::LAST_ERROR_OPT ); delete_transient( self::QUERIES_CACHE ); } public static function last_error() { $e = get_option( self::LAST_ERROR_OPT, array() ); if ( ! is_array( $e ) || empty( $e['message'] ) ) { return array(); } return array( 'code' => (string) ( $e['code'] ?? '' ), 'message' => (string) $e['message'], 'at' => (int) ( $e['at'] ?? 0 ), ); } public static function record_error( $code, $message ) { update_option( self::LAST_ERROR_OPT, array( 'code' => (string) $code, 'message' => (string) $message, 'at' => time(), ), false ); } public static function clear_error() { delete_option( self::LAST_ERROR_OPT ); } public static function error_reason( $error ) { if ( ! is_wp_error( $error ) ) { return ''; } $data = $error->get_error_data(); if ( is_array( $data ) && ! empty( $data['reason'] ) ) { return (string) $data['reason']; } return (string) $error->get_error_code(); } public static function needs_reconnect( $error ) { $reason = is_wp_error( $error ) ? self::error_reason( $error ) : (string) $error; return in_array( $reason, self::RECONNECT_REASONS, true ); } public static function auth_url() { $uid = get_current_user_id(); if ( ! self::is_configured() || ! $uid ) { return ''; } $s = self::settings(); $state = wp_generate_password( 24, false ); update_user_meta( $uid, self::STATE_META, array( 'state' => $state, 'expires' => time() + self::STATE_TTL, ) ); $args = array( 'client_id' => $s['client_id'], 'redirect_uri' => self::redirect_uri(), 'response_type' => 'code', 'scope' => self::SCOPE, 'access_type' => 'offline', 'include_granted_scopes' => 'true', 'prompt' => 'consent', 'state' => $state, ); return self::AUTH_URL . '?' . http_build_query( $args ); } public static function handle_callback( $code, $state ) { $checked = self::consume_state( get_current_user_id(), (string) $state ); if ( is_wp_error( $checked ) ) { return $checked; } if ( '' === (string) $code ) { return new WP_Error( 'aieo_gsc_nocode', __( 'Google did not return an authorization code.', 'ai-eshop-optimizer' ) ); } $s = self::settings(); $resp = wp_remote_post( self::TOKEN_URL, array( 'timeout' => self::TIMEOUT, 'body' => array( 'code' => (string) $code, 'client_id' => $s['client_id'], 'client_secret' => $s['client_secret'], 'redirect_uri' => self::redirect_uri(), 'grant_type' => 'authorization_code', ), ) ); $data = self::decode( $resp ); if ( is_wp_error( $data ) ) { return $data; } if ( empty( $data['access_token'] ) ) { return new WP_Error( 'aieo_gsc_token', __( 'Google did not return an access token. Re-check the client credentials + redirect URI.', 'ai-eshop-optimizer' ) ); } $tokens = array( 'access_token' => (string) $data['access_token'], 'refresh_token' => ! empty( $data['refresh_token'] ) ? (string) $data['refresh_token'] : self::stored_refresh(), 'scope' => (string) ( $data['scope'] ?? self::SCOPE ), 'expires_at' => time() + (int) ( $data['expires_in'] ?? 3600 ) - 60, ); if ( '' === $tokens['refresh_token'] ) { return new WP_Error( 'aieo_gsc_norefresh', __( 'Google did not return a refresh token. Remove the app from your Google account’s third-party access and reconnect (we request offline access).', 'ai-eshop-optimizer' ) ); } update_option( self::TOKENS_OPT, $tokens, false ); self::clear_error(); return true; } private static function consume_state( $user_id, $state ) { $pending = $user_id ? get_user_meta( $user_id, self::STATE_META, true ) : array(); if ( is_array( $pending ) && ! empty( $pending['state'] ) && (int) ( $pending['expires'] ?? 0 ) >= time() ) { if ( ! hash_equals( (string) $pending['state'], $state ) ) { return new WP_Error( 'aieo_gsc_state', __( 'The Google sign-in could not be verified (state mismatch). Please try again.', 'ai-eshop-optimizer' ) ); } delete_user_meta( $user_id, self::STATE_META ); return true; } if ( $user_id && ! empty( $pending ) ) { delete_user_meta( $user_id, self::STATE_META ); } $legacy = (string) get_transient( self::STATE_TRANSIENT ); if ( '' !== $legacy && hash_equals( $legacy, $state ) ) { delete_transient( self::STATE_TRANSIENT ); return true; } return new WP_Error( 'aieo_gsc_state_expired', __( 'This Google sign-in has expired, or was started by a different WordPress user. Click “Connect Google Search Console” again and finish within 15 minutes.', 'ai-eshop-optimizer' ) ); } private static function stored_refresh() { $t = get_option( self::TOKENS_OPT, array() ); return is_array( $t ) && ! empty( $t['refresh_token'] ) ? (string) $t['refresh_token'] : ''; } public static function access_token() { $t = get_option( self::TOKENS_OPT, array() ); if ( ! is_array( $t ) || empty( $t['refresh_token'] ) ) { return new WP_Error( 'aieo_gsc_disconnected', __( 'Google Search Console is not connected.', 'ai-eshop-optimizer' ) ); } if ( ! empty( $t['access_token'] ) && ! empty( $t['expires_at'] ) && time() < (int) $t['expires_at'] ) { return (string) $t['access_token']; } $s = self::settings(); $resp = wp_remote_post( self::TOKEN_URL, array( 'timeout' => self::TIMEOUT, 'body' => array( 'client_id' => $s['client_id'] ?? '', 'client_secret' => $s['client_secret'] ?? '', 'refresh_token' => (string) $t['refresh_token'], 'grant_type' => 'refresh_token', ), ) ); $data = self::decode( $resp ); if ( is_wp_error( $data ) ) { return $data; } if ( empty( $data['access_token'] ) ) { return new WP_Error( 'aieo_gsc_refresh', __( 'Could not refresh the Google access token. Reconnect Search Console.', 'ai-eshop-optimizer' ) ); } $t['access_token'] = (string) $data['access_token']; $t['expires_at'] = time() + (int) ( $data['expires_in'] ?? 3600 ) - 60; update_option( self::TOKENS_OPT, $t, false ); return $t['access_token']; } public static function list_sites() { $token = self::access_token(); if ( is_wp_error( $token ) ) { return self::keep_sites( $token ); } $resp = wp_remote_get( self::API_BASE . '/sites', array( 'timeout' => self::TIMEOUT, 'headers' => array( 'Authorization' => 'Bearer ' . $token ), ) ); $data = self::decode( $resp ); if ( is_wp_error( $data ) ) { return self::keep_sites( $data ); } $out = array(); foreach ( (array) ( $data['siteEntry'] ?? array() ) as $e ) { $out[] = array( 'siteUrl' => (string) ( $e['siteUrl'] ?? '' ), 'permissionLevel' => (string) ( $e['permissionLevel'] ?? '' ), ); } return self::keep_sites( $out ); } public static function saved_property_label( $property, $unloaded ) { return $unloaded ? (string) $property : sprintf( __( '%s (saved)', 'ai-eshop-optimizer' ), (string) $property ); } public static function cached_sites() { $c = get_transient( self::SITES_CACHE ); if ( ! is_array( $c ) ) { return null; } if ( isset( $c['error'] ) ) { return new WP_Error( (string) ( $c['code'] ?? 'aieo_gsc_sites' ), (string) $c['error'], array( 'reason' => (string) ( $c['reason'] ?? '' ) ) ); } return isset( $c['sites'] ) && is_array( $c['sites'] ) ? $c['sites'] : null; } private static function keep_sites( $answer ) { $keep = is_wp_error( $answer ) ? array( 'error' => $answer->get_error_message(), 'code' => $answer->get_error_code(), 'reason' => self::error_reason( $answer ) ) : array( 'sites' => $answer ); set_transient( self::SITES_CACHE, $keep, self::SITES_TTL ); return $answer; } public static function query( array $args = array() ) { $token = self::access_token(); if ( is_wp_error( $token ) ) { return $token; } $property = self::property(); if ( '' === $property ) { return new WP_Error( 'aieo_gsc_noprop', __( 'No Search Console property is selected.', 'ai-eshop-optimizer' ) ); } $end = isset( $args['end_date'] ) ? (string) $args['end_date'] : gmdate( 'Y-m-d', time() - 2 * DAY_IN_SECONDS ); $start = isset( $args['start_date'] ) ? (string) $args['start_date'] : gmdate( 'Y-m-d', time() - 90 * DAY_IN_SECONDS ); $body = array( 'startDate' => $start, 'endDate' => $end, 'dimensions' => isset( $args['dimensions'] ) && is_array( $args['dimensions'] ) ? array_values( $args['dimensions'] ) : array( 'query' ), 'rowLimit' => min( 25000, max( 1, (int) ( $args['row_limit'] ?? 1000 ) ) ), 'startRow' => max( 0, (int) ( $args['start_row'] ?? 0 ) ), 'type' => isset( $args['type'] ) ? (string) $args['type'] : 'web', 'dataState' => isset( $args['data_state'] ) ? (string) $args['data_state'] : 'final', ); if ( ! empty( $args['filters'] ) && is_array( $args['filters'] ) ) { $body['dimensionFilterGroups'] = array( array( 'filters' => array_values( $args['filters'] ) ) ); } $resp = wp_remote_post( self::API_BASE . '/sites/' . rawurlencode( $property ) . '/searchAnalytics/query', array( 'timeout' => self::TIMEOUT, 'headers' => array( 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json', ), 'body' => wp_json_encode( $body ), ) ); $data = self::decode( $resp ); if ( is_wp_error( $data ) ) { return $data; } $rows = array(); foreach ( (array) ( $data['rows'] ?? array() ) as $r ) { $rows[] = array( 'keys' => array_map( 'strval', (array) ( $r['keys'] ?? array() ) ), 'clicks' => (int) round( (float) ( $r['clicks'] ?? 0 ) ), 'impressions' => (int) round( (float) ( $r['impressions'] ?? 0 ) ), 'ctr' => round( (float) ( $r['ctr'] ?? 0 ), 4 ), 'position' => round( (float) ( $r['position'] ?? 0 ), 1 ), ); } return array( 'rows' => $rows, 'range' => array( 'start' => $start, 'end' => $end ) ); } public static function top_queries( $days = 90, $limit = 100, $dimensions = array( 'query' ) ) { return self::query( array( 'start_date' => gmdate( 'Y-m-d', time() - max( 1, (int) $days ) * DAY_IN_SECONDS ), 'end_date' => gmdate( 'Y-m-d', time() - 2 * DAY_IN_SECONDS ), 'dimensions' => $dimensions, 'row_limit' => (int) $limit, ) ); } public static function inspect_url( $url ) { $token = self::access_token(); if ( is_wp_error( $token ) ) { return $token; } $property = self::property(); if ( '' === $property ) { return new WP_Error( 'aieo_gsc_noprop', __( 'No Search Console property is selected.', 'ai-eshop-optimizer' ) ); } $resp = wp_remote_post( self::INSPECT_URL, array( 'timeout' => self::TIMEOUT, 'headers' => array( 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json', ), 'body' => wp_json_encode( array( 'inspectionUrl' => (string) $url, 'siteUrl' => $property, ) ), ) ); $data = self::decode( $resp ); if ( is_wp_error( $data ) ) { return $data; } return isset( $data['inspectionResult'] ) && is_array( $data['inspectionResult'] ) ? $data['inspectionResult'] : $data; } private static function decode( $resp ) { if ( is_wp_error( $resp ) ) { return $resp; } $code = (int) wp_remote_retrieve_response_code( $resp ); $data = json_decode( (string) wp_remote_retrieve_body( $resp ), true ); if ( $code < 200 || $code >= 300 ) { $msg = is_array( $data ) && isset( $data['error']['message'] ) ? (string) $data['error']['message'] : ( is_array( $data ) && isset( $data['error_description'] ) ? (string) $data['error_description'] : sprintf( 'HTTP %d', $code ) ); $reason = ''; if ( is_array( $data ) && isset( $data['error'] ) ) { $reason = is_array( $data['error'] ) ? (string) ( $data['error']['status'] ?? '' ) : (string) $data['error']; } return new WP_Error( 'aieo_gsc_http', sprintf( __( 'Google API error: %s', 'ai-eshop-optimizer' ), $msg ), array( 'google_status' => $code, 'reason' => $reason ) ); } return is_array( $data ) ? $data : array(); } private static function sanitize_property( $p ) { $p = trim( (string) $p ); if ( 0 === strpos( $p, 'sc-domain:' ) ) { return 'sc-domain:' . preg_replace( '/[^a-z0-9.-]/i', '', substr( $p, 10 ) ); } return esc_url_raw( $p ); } }