last_error; } // ── OAuth Flow ──────────────────────────────────────────────────────────── /** * Starts the connect flow. This plugin never holds the Google client * secret, so it can't exchange a code for tokens itself - that step * happens on AWBSEO_OAUTH_BROKER, a server AWBSEO controls. To get * there safely: * * 1. Ask the broker to mint a signed "state" token wrapping this * site's own callback URL + a nonce. Only the broker holds the * signing key, so the plugin still never carries a secret. * 2. Stash the same nonce server-side (transient) so handle_callback() * can confirm whatever comes back really belongs to this connect * attempt. * 3. Send the admin to Google with redirect_uri set to the broker's * own fixed callback - NOT this site's URL. Google only allows * pre-registered redirect URIs for an OAuth app, so the broker's * one fixed URI is what lets a single shared Google app serve * every AWBSEO install. */ public static function handle_connect() { check_admin_referer( 'awbseo_gsc_connect' ); if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Forbidden' ); $site_callback = admin_url( 'admin-post.php?action=awbseo_gsc_callback' ); // A dedicated random bearer token, not a WP nonce - it isn't checked // with wp_verify_nonce() and it deliberately isn't tied to a user // session. The broker's auto-submit page POSTs back to this site // cross-origin, and browsers withhold SameSite=Lax cookies (which is // what WP's auth cookie uses) on cross-site POSTs - so by the time // handle_callback() runs, get_current_user_id() would be 0 even // though it's the same admin's browser. Binding to a single-use, // unguessable token stored under its own value (rather than under // the user ID) sidesteps that entirely: possession of the exact // token is the proof, no session cookie required. $token = bin2hex( random_bytes( 32 ) ); set_transient( 'awbseo_gsc_oauth_' . $token, 1, 10 * MINUTE_IN_SECONDS ); $start = wp_remote_post( AWBSEO_OAUTH_BROKER . '/start', [ 'timeout' => 10, 'sslverify' => true, 'body' => [ 'site_callback' => $site_callback, 'nonce' => $token, ], ] ); if ( is_wp_error( $start ) || 200 !== wp_remote_retrieve_response_code( $start ) ) { // Surface the real cause instead of swallowing it - WP_Error // messages here (cURL error 6/28/60, etc.) are the fastest way // to tell "host is blocking outbound requests" apart from // "Custom Domain isn't live yet" apart from "wrong broker URL." // Safe to leave in: never includes secrets, only connection // diagnostics. TEMP: remove the gsc_detail line once the broker // connection is confirmed working end-to-end. $detail = is_wp_error( $start ) ? $start->get_error_message() : 'HTTP ' . wp_remote_retrieve_response_code( $start ) . ' - ' . wp_remote_retrieve_body( $start ); wp_redirect( add_query_arg( [ 'gsc_error' => 'broker_unreachable', 'gsc_detail' => rawurlencode( $detail ), ], admin_url( 'admin.php?page=awbseo-settings' ) ) ); exit; } $state = json_decode( wp_remote_retrieve_body( $start ), true )['state'] ?? ''; if ( ! $state ) { wp_redirect( admin_url( 'admin.php?page=awbseo-settings&gsc_error=broker_error' ) ); exit; } $oauth_url = add_query_arg( [ 'client_id' => AWBSEO_GSC_CLIENT_ID, 'redirect_uri' => AWBSEO_OAUTH_BROKER . '/callback', 'response_type' => 'code', 'scope' => 'https://www.googleapis.com/auth/webmasters.readonly', 'access_type' => 'offline', 'prompt' => 'consent', 'state' => $state, ], self::OAUTH_ENDPOINT ); wp_redirect( $oauth_url ); exit; } /** * Receives the result of the OAuth flow from the broker, not from * Google directly. The broker did the actual code-for-token exchange * (it holds the secret) and auto-submits a same-browser POST here * with the outcome, so tokens never travel in a URL/query string and * never land in browser history or server access logs. */ public static function handle_callback() { // No user/session check here on purpose - this is a cross-origin // POST from the broker's auto-submit page, not a same-site WP admin // request, so there is no session cookie to check (see the comment // in handle_connect()). The single-use token itself - unguessable, // minted only for a request that already passed the manage_options // check, and consumed on first use - is what proves this callback // corresponds to a connect attempt this site actually started. $token = sanitize_text_field( $_POST['nonce'] ?? '' ); $key = $token ? 'awbseo_gsc_oauth_' . $token : ''; $flow = $key ? get_transient( $key ) : false; // '1' for GSC, 'ga4' for GA4 $valid = (bool) $flow; if ( $key ) delete_transient( $key ); if ( ! $valid ) { wp_redirect( admin_url( 'admin.php?page=awbseo-settings&gsc_error=invalid_state' ) ); exit; } // GA4 connect flows share this callback endpoint (the OAuth broker // only accepts the one registered site_callback URL). The transient // value distinguishes which service the tokens belong to. if ( 'ga4' === $flow ) { AWBSEO_GA4::store_tokens_from_gsc_callback(); exit; } if ( ! empty( $_POST['error'] ) ) { wp_redirect( admin_url( 'admin.php?page=awbseo-settings&gsc_error=' . sanitize_key( $_POST['error'] ) ) ); exit; } $access_token = sanitize_text_field( $_POST['access_token'] ?? '' ); $refresh_token = sanitize_text_field( $_POST['refresh_token'] ?? '' ); $expires_in = (int) ( $_POST['expires_in'] ?? 0 ); if ( ! $access_token ) { wp_redirect( admin_url( 'admin.php?page=awbseo-settings&gsc_error=no_token' ) ); exit; } $tokens = [ 'access_token' => $access_token, 'refresh_token' => $refresh_token, 'expires_in' => $expires_in ?: 3600, 'connected_at' => time(), ]; update_option( self::OPTION_TOKENS, $tokens, false ); self::flush_transients(); // clear any stale cached API results // Auto-detect the site property. $gsc = self::get_instance(); $property = $gsc->detect_property(); if ( $property ) { update_option( self::OPTION_PROPERTY, $property ); } wp_redirect( admin_url( 'admin.php?page=awbseo-settings&gsc_connected=1' ) ); exit; } public static function handle_disconnect() { check_admin_referer( 'awbseo_gsc_disconnect' ); if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Forbidden' ); delete_option( self::OPTION_TOKENS ); delete_option( self::OPTION_PROPERTY ); self::flush_transients(); wp_redirect( admin_url( 'admin.php?page=awbseo-settings&gsc_disconnected=1' ) ); exit; } /** * Deletes all cached GSC API results from the options table. Called on * connect and disconnect so that the first Dashboard load after a * credential change always hits the live API rather than serving a * stale cached result (which could be an empty set from a request that * ran while the previous token was expired or the property was missing). */ public static function flush_transients() { global $wpdb; $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '\_transient\_awbseo\_gsc\_%' OR option_name LIKE '\_transient\_timeout\_awbseo\_gsc\_%'" ); } /** * Lets an admin force a fresh property lookup without disconnecting - * useful if the wrong property was picked up at connect time (most * commonly because the site is verified as a Search Console domain * property) or the site's verified property has since changed. */ public static function handle_redetect() { check_admin_referer( 'awbseo_gsc_redetect' ); if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Forbidden' ); $gsc = self::get_instance(); $property = $gsc->redetect_property(); $redirect = $property ? admin_url( 'admin.php?page=awbseo-settings&gsc_redetected=1' ) : admin_url( 'admin.php?page=awbseo-settings&gsc_error=no_property_found' ); wp_redirect( $redirect ); exit; } // ── Token Management ────────────────────────────────────────────────────── private function get_access_token() { $tokens = get_option( self::OPTION_TOKENS ); if ( empty( $tokens ) || empty( $tokens['access_token'] ) ) { $this->last_error = 'Not connected (no token on file) - connect Google Search Console in Settings.'; return null; } // Refresh if expired (tokens last 1 hour). Note: is_connected() only // checks that an access_token string exists, not whether it's still // valid - so Settings can show "Connected" while a fetch here fails // because the token expired and the refresh attempt below didn't // work. The branches here used to all collapse into one generic "no // valid access token" message; each now records *why* via // last_error, so that distinction survives to the Dashboard banner. if ( ! empty( $tokens['expires_in'] ) && ! empty( $tokens['connected_at'] ) ) { $expires_at = $tokens['connected_at'] + $tokens['expires_in'] - 60; if ( time() > $expires_at ) { return $this->refresh_token( $tokens ); } } $this->last_error = null; return $tokens['access_token']; } /** * Refreshing also needs the client secret, so it goes through the * broker too - this one's a plain server-to-server call (no browser * involved), so it's just a POST, no state/redirect dance needed. */ private function refresh_token( $tokens ) { if ( empty( $tokens['refresh_token'] ) ) { $this->last_error = 'Access token expired and no refresh token is on file - reconnect Google Search Console in Settings.'; return null; } // Don't hammer the broker if a recent refresh attempt failed transiently. // The backoff transient is cleared by flush_transients() on reconnect. if ( get_transient( 'awbseo_gsc_refresh_backoff' ) ) { $this->last_error = 'GSC authorization service temporarily unavailable. Your connection is still active - try again in a few minutes.'; return null; } $response = wp_remote_post( AWBSEO_OAUTH_BROKER . '/refresh', [ 'timeout' => 10, 'sslverify' => true, 'body' => [ 'refresh_token' => $tokens['refresh_token'] ], ] ); if ( is_wp_error( $response ) ) { // Transient network failure - keep the tokens, throttle retries, // and fall back to the stored access token so data keeps loading. set_transient( 'awbseo_gsc_refresh_backoff', 1, 15 * MINUTE_IN_SECONDS ); $this->last_error = null; return $tokens['access_token']; } $code = wp_remote_retrieve_response_code( $response ); $new = json_decode( wp_remote_retrieve_body( $response ), true ); if ( empty( $new['access_token'] ) ) { // Only wipe stored credentials when Google itself has confirmed the // refresh token is revoked - indicated by a specific error code in // the broker's relay response. Anything else (broker down, network // error, unexpected response format) is treated as transient so a // momentary outage never forces a full reconnect. $google_error = is_array( $new['error'] ?? null ) ? ( $new['error']['status'] ?? $new['error']['code'] ?? '' ) : ( $new['error'] ?? '' ); $is_revoked = in_array( $google_error, [ 'invalid_grant', 'TOKEN_REVOKED', 'UNAUTHENTICATED' ], true ); if ( $is_revoked ) { // Confirmed revocation by Google - clear everything so the UI // shows a clean reconnect prompt instead of a stale state. delete_option( self::OPTION_TOKENS ); delete_option( self::OPTION_PROPERTY ); self::flush_transients(); $this->last_error = 'Your Google Search Console authorization was revoked. Please reconnect GSC in Settings.'; } else { // Broker temporarily unavailable, unexpected response, or any // non-revocation 4xx/5xx - keep the tokens and back off. set_transient( 'awbseo_gsc_refresh_backoff', 1, 15 * MINUTE_IN_SECONDS ); $detail = $google_error ?: 'HTTP ' . $code; $this->last_error = 'GSC token refresh temporarily failed (' . $detail . '). Your connection is still active - data will reload automatically.'; } return null; } $tokens['access_token'] = $new['access_token']; $tokens['expires_in'] = $new['expires_in'] ?? 3600; $tokens['connected_at'] = time(); // Google only guarantees a refresh_token on the original consent, // not on every refresh response - keep the existing one unless the // broker actually sent a new one. if ( ! empty( $new['refresh_token'] ) ) { $tokens['refresh_token'] = $new['refresh_token']; } update_option( self::OPTION_TOKENS, $tokens, false ); $this->last_error = null; return $new['access_token']; } // ── API Calls ───────────────────────────────────────────────────────────── private function api_get( $endpoint ) { $token = $this->get_access_token(); if ( ! $token ) { // get_access_token()/refresh_token() already set a specific reason. if ( ! $this->last_error ) { $this->last_error = 'Not connected (no valid access token).'; } return null; } $response = wp_remote_get( self::API_BASE . $endpoint, [ 'headers' => [ 'Authorization' => 'Bearer ' . $token ], 'timeout' => 15, ] ); if ( is_wp_error( $response ) ) { $this->last_error = $response->get_error_message(); return null; } $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! empty( $data['error'] ) ) { $this->last_error = is_array( $data['error'] ) ? ( $data['error']['message'] ?? 'Google API error' ) : (string) $data['error']; return null; } $this->last_error = null; return $data; } private function api_post( $endpoint, $body ) { $token = $this->get_access_token(); if ( ! $token ) { // get_access_token()/refresh_token() already set a specific reason. if ( ! $this->last_error ) { $this->last_error = 'Not connected (no valid access token).'; } return null; } $response = wp_remote_post( self::API_BASE . $endpoint, [ 'headers' => [ 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json', ], 'body' => wp_json_encode( $body ), 'timeout' => 15, ] ); if ( is_wp_error( $response ) ) { $this->last_error = $response->get_error_message(); return null; } $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! empty( $data['error'] ) ) { $this->last_error = is_array( $data['error'] ) ? ( $data['error']['message'] ?? 'Google API error' ) : (string) $data['error']; return null; } $this->last_error = null; return $data; } /** * Normalizes a URL down to "bare host + path" (no scheme, no leading * www., no trailing slash) so property matching tolerates the most * common real-world drift between what's verified in Search Console * and what home_url() reports today - e.g. a site that moved from * http to https, or is verified with/without the www. subdomain, * after the property was originally set up. */ private static function normalize_host_path( $url ) { $parts = wp_parse_url( $url ); $host = isset( $parts['host'] ) ? preg_replace( '/^www\./i', '', $parts['host'] ) : ''; $path = isset( $parts['path'] ) ? rtrim( $parts['path'], '/' ) : ''; return strtolower( $host . $path ); } private function detect_property() { $data = $this->api_get( 'sites' ); if ( empty( $data['siteEntry'] ) ) return ''; $home_norm = self::normalize_host_path( home_url() ); $home_host = wp_parse_url( home_url(), PHP_URL_HOST ); $home_bare = strtolower( preg_replace( '/^www\./i', '', (string) $home_host ) ); // 1. URL-prefix property match (https://example.com/), tolerant // of scheme and www differences. foreach ( $data['siteEntry'] as $site ) { if ( 0 === stripos( $site['siteUrl'], 'sc-domain:' ) ) continue; if ( self::normalize_host_path( $site['siteUrl'] ) === $home_norm ) { return $site['siteUrl']; } } // 2. Domain property match (sc-domain:example.com). These cover // every protocol/subdomain variant for the domain at once, so // most professionally-verified sites use this format instead of // a URL-prefix property. A plain string-equals against // home_url() (the previous behaviour here) can never match // "sc-domain:...", which meant every domain-verified site // silently fell through to the "first verified" fallback below - // pulling stats for a completely unrelated property and making // the Dashboard look connected while showing all zeros. if ( $home_bare ) { foreach ( $data['siteEntry'] as $site ) { if ( 0 !== stripos( $site['siteUrl'], 'sc-domain:' ) ) continue; $site_domain = strtolower( substr( $site['siteUrl'], strlen( 'sc-domain:' ) ) ); if ( preg_replace( '/^www\./i', '', $site_domain ) === $home_bare ) { return $site['siteUrl']; } } } // 3. Last resort: first verified property of any kind. Only // reached if nothing above matched this site at all. foreach ( $data['siteEntry'] as $site ) { if ( ( $site['permissionLevel'] ?? '' ) !== 'siteUnverifiedUser' ) { return $site['siteUrl']; } } return ''; } /** * Re-runs property auto-detection against the live token without * repeating the OAuth flow. Exists so an already-connected site can * pick up a detect_property() fix (like the sc-domain: matching * above) or a change in which property is verified, without having * to disconnect and reconnect. */ public function redetect_property() { $property = $this->detect_property(); if ( $property ) { update_option( self::OPTION_PROPERTY, $property ); } return $property; } /** * Every data-fetching method below needs a property on file before it * can call the API at all. Previously each one did * `if ( ! $property ) return null/[];` straight off get_property() - * which meant "no property detected" skipped api_get()/api_post() * entirely and never touched last_error. The Dashboard's "connected * but the last request failed" banner only fires when last_error is * non-empty, so a missing property produced the worst possible * outcome: zero data, fetch never attempted, no error shown at all - * indistinguishable from a site that's connected and genuinely has no * traffic yet. This centralizes the check so the real reason survives. */ private function require_property() { $property = $this->get_property(); if ( ! $property ) { $this->last_error = 'No verified Search Console property was detected for this Google account - try "Re-detect property" in Settings, or disconnect and reconnect with an account that has access to this site in Search Console.'; return ''; } return $property; } // ── Data Fetching ───────────────────────────────────────────────────────── /** * Get click/impression data for a URL over the last N days. * FREE feature - used by dashboard and content decay (rule-based). */ public function get_url_stats( $url, $days = 90 ) { $property = $this->require_property(); if ( ! $property ) return null; $end = date( 'Y-m-d' ); $start = date( 'Y-m-d', strtotime( "-{$days} days" ) ); return $this->api_post( 'sites/' . urlencode( $property ) . '/searchAnalytics/query', [ 'startDate' => $start, 'endDate' => $end, 'dimensions' => [ 'date' ], 'dimensionFilterGroups' => [ [ 'filters' => [ [ 'dimension' => 'page', 'operator' => 'equals', 'expression' => $url, ] ], ] ], 'rowLimit' => $days, ] ); } /** * Get keyword position for the rank tracker - PRO feature. */ /** * @param string $keyword * @param string $country_iso3 Optional 3-letter ISO country code (GSC's * `country` dimension uses ISO 3166-1 alpha-3, e.g. "kwt" * for Kuwait - a different code length than the 2-letter * codes used elsewhere in this plugin, so callers should * go through AWBSEO_Rank_Tracker::REGION_TARGETING rather * than passing a raw 2-letter code here). Empty string * means no country filter - identical to calling this * method before this parameter existed. */ public function get_keyword_position( $keyword, $country_iso3 = '' ) { $property = $this->require_property(); if ( ! $property ) return null; $filters = [ [ 'dimension' => 'query', 'operator' => 'equals', 'expression' => $keyword, ] ]; if ( $country_iso3 ) { $filters[] = [ 'dimension' => 'country', 'operator' => 'equals', 'expression' => $country_iso3, ]; } $data = $this->api_post( 'sites/' . urlencode( $property ) . '/searchAnalytics/query', [ 'startDate' => date( 'Y-m-d', strtotime( '-3 days' ) ), 'endDate' => date( 'Y-m-d' ), 'dimensions' => [ 'query' ], 'dimensionFilterGroups' => [ [ 'filters' => $filters, ] ], ] ); return $data['rows'][0]['position'] ?? null; } /** * Quick wins - posts ranking positions 5–15 (low-hanging fruit). PRO. */ public function get_quick_wins( $limit = 20 ) { $property = $this->require_property(); if ( ! $property ) return []; $data = $this->api_post( 'sites/' . urlencode( $property ) . '/searchAnalytics/query', [ 'startDate' => date( 'Y-m-d', strtotime( '-28 days' ) ), 'endDate' => date( 'Y-m-d' ), 'dimensions' => [ 'page', 'query' ], 'rowLimit' => 500, ] ); if ( empty( $data['rows'] ) ) return []; $wins = array_filter( $data['rows'], fn( $r ) => $r['position'] >= 5 && $r['position'] <= 15 ); usort( $wins, fn( $a, $b ) => $b['impressions'] <=> $a['impressions'] ); return array_slice( $wins, 0, $limit ); } /** * Resolves a requested range into concrete start/end dates. * * Pass either a day count ($days, looking back from "today minus GSC's * 3-day lag"), or an explicit $start/$end pair (e.g. from a dashboard * date picker) - explicit dates win when both are given. $end is always * clamped so it never asks GSC for data newer than its reporting lag. * * @return array [ $start, $end, $days ] - $days is recalculated from * the resolved dates so callers can use it for rowLimit * and "previous period" math. */ private function resolve_range( $days = 28, $start = null, $end = null ) { $max_end = date( 'Y-m-d', strtotime( '-3 days' ) ); // GSC has 3-day lag if ( $start && $end ) { $end = min( $end, $max_end ); if ( $start > $end ) $start = $end; $days = max( 1, (int) ( ( strtotime( $end ) - strtotime( $start ) ) / DAY_IN_SECONDS ) + 1 ); return [ $start, $end, $days ]; } $days = max( 1, (int) $days ); return [ date( 'Y-m-d', strtotime( "-{$days} days", strtotime( $max_end ) ) ), $max_end, $days ]; } /** * Site-wide clicks + impressions grouped by date - dashboard overview. FREE. */ public function get_site_stats( $days = 28, $start = null, $end = null ) { $property = $this->require_property(); if ( ! $property ) return null; list( $start, $end, $days ) = $this->resolve_range( $days, $start, $end ); $cache_key = 'awbseo_gsc_stats_' . md5( $property . '_' . $start . '_' . $end ); $cached = get_transient( $cache_key ); if ( $cached ) return $cached; $prev_end = date( 'Y-m-d', strtotime( $start . ' -1 day' ) ); $prev_start = date( 'Y-m-d', strtotime( $prev_end . " -{$days} days" ) ); $current = $this->api_post( 'sites/' . urlencode( $property ) . '/searchAnalytics/query', [ 'startDate' => $start, 'endDate' => $end, 'dimensions' => [ 'date' ], 'rowLimit' => $days ] ); $previous = $this->api_post( 'sites/' . urlencode( $property ) . '/searchAnalytics/query', [ 'startDate' => $prev_start, 'endDate' => $prev_end, 'dimensions' => [ 'date' ], 'rowLimit' => $days ] ); $prev_rows = $previous['rows'] ?? []; $prev_count = count( $prev_rows ); $result = [ 'daily' => $current['rows'] ?? [], 'prev_sum' => [ 'clicks' => array_sum( array_column( $prev_rows, 'clicks' ) ), 'impressions' => array_sum( array_column( $prev_rows, 'impressions' ) ), 'ctr' => $prev_count ? round( ( array_sum( array_column( $prev_rows, 'ctr' ) ) / $prev_count ) * 100, 1 ) : 0, 'position' => $prev_count ? round( array_sum( array_column( $prev_rows, 'position' ) ) / $prev_count, 1 ) : 0, ], ]; // Only cache when the API actually responded - null means the call // failed (expired token, network error, etc.). Caching that produces // a non-empty array structure (['daily'=>[],'prev_sum'=>[...]]) which // is truthy, so the cache check above would serve the stale zero // result on every subsequent request until the TTL expires, even // after the underlying problem (e.g. stale token) was fixed. if ( null !== $current ) { set_transient( $cache_key, $result, HOUR_IN_SECONDS ); } return $result; } /** * Top pages by clicks over the last N days. FREE. */ public function get_top_pages( $days = 28, $limit = 10, $start = null, $end = null ) { $property = $this->require_property(); if ( ! $property ) return []; list( $start, $end, $days ) = $this->resolve_range( $days, $start, $end ); $cache_key = 'awbseo_gsc_top_pages_' . md5( $property . '_' . $start . '_' . $end . '_' . $limit ); $cached = get_transient( $cache_key ); if ( $cached ) return $cached; $data = $this->api_post( 'sites/' . urlencode( $property ) . '/searchAnalytics/query', [ 'startDate' => $start, 'endDate' => $end, 'dimensions' => [ 'page' ], 'rowLimit' => $limit ] ); $rows = $data['rows'] ?? []; if ( null !== $data ) { set_transient( $cache_key, $rows, HOUR_IN_SECONDS ); } return $rows; } /** * Top queries by clicks over the last N days. FREE. */ public function get_top_queries( $days = 28, $limit = 10, $start = null, $end = null ) { $property = $this->require_property(); if ( ! $property ) return []; list( $start, $end, $days ) = $this->resolve_range( $days, $start, $end ); $cache_key = 'awbseo_gsc_top_queries_' . md5( $property . '_' . $start . '_' . $end . '_' . $limit ); $cached = get_transient( $cache_key ); if ( $cached ) return $cached; $data = $this->api_post( 'sites/' . urlencode( $property ) . '/searchAnalytics/query', [ 'startDate' => $start, 'endDate' => $end, 'dimensions' => [ 'query' ], 'rowLimit' => $limit ] ); $rows = $data['rows'] ?? []; if ( null !== $data ) { set_transient( $cache_key, $rows, HOUR_IN_SECONDS ); } return $rows; } /** * Query + page rows for the Arabic Spelling Variants tool: every * (query, page) pair with its clicks, impressions and position, so * spellings of the same query can be compared per landing page. * Cached 12 hours. FREE. * * @return array|null Rows (keys[0] = query, keys[1] = page), or null when GSC isn't usable. */ public function get_query_page_rows( $days = 90, $limit = 5000 ) { $property = $this->require_property(); if ( ! $property ) return null; list( $start, $end ) = $this->resolve_range( $days ); $cache_key = 'awbseo_gsc_qp_' . md5( $property . '_' . $start . '_' . $end . '_' . $limit ); $cached = get_transient( $cache_key ); if ( false !== $cached ) return $cached; $data = $this->api_post( 'sites/' . urlencode( $property ) . '/searchAnalytics/query', [ 'startDate' => $start, 'endDate' => $end, 'dimensions' => [ 'query', 'page' ], 'rowLimit' => min( 25000, max( 100, (int) $limit ) ) ] ); if ( null === $data ) return null; $rows = $data['rows'] ?? []; set_transient( $cache_key, $rows, 12 * HOUR_IN_SECONDS ); return $rows; } /** * Same >30%-of-letters-are-Arabic heuristic used elsewhere in the plugin * (on-page analyzer, schema manager) - duplicated here rather than * shared across classes because it's small and stable, and this way * class-gsc.php carries no dependency on either of those classes. */ private static function looks_arabic_text( $text ) { $text = (string) $text; if ( '' === trim( $text ) ) return false; preg_match_all( '/\p{L}/u', $text, $all ); $total = count( $all[0] ); if ( 0 === $total ) return false; preg_match_all( '/[\x{0600}-\x{06FF}\x{0750}-\x{077F}\x{08A0}-\x{08FF}]/u', $text, $ar ); return ( count( $ar[0] ) / $total ) > 0.30; } /** * Search queries split into Arabic vs non-Arabic buckets, each with its * own totals and top-query list, instead of one blended list. A site * that publishes in both Arabic and English (or gets searched in * code-switched Arabic dialect terms written in Latin letters) otherwise * has one language's queries buried under the other's volume in a * single ranked list. FREE. * * Language is detected on the query text itself (not the page it led * to), using the same >30%-Arabic-letters heuristic as the rest of the * plugin, so this works with no hreflang or dialect setup required. * * Returns: * [ * 'arabic' => [ 'totals' => [ 'clicks', 'impressions', 'ctr', 'position', 'query_count' ], 'top_queries' => [...] ], * 'other' => [ same shape ], * ] * or [] if GSC isn't connected/configured. */ public function get_top_queries_by_language( $days = 28, $limit_per_language = 15, $start = null, $end = null ) { $property = $this->require_property(); if ( ! $property ) return []; list( $start, $end, $days ) = $this->resolve_range( $days, $start, $end ); $cache_key = 'awbseo_gsc_queries_lang_' . md5( $property . '_' . $start . '_' . $end ); $cached = get_transient( $cache_key ); if ( $cached ) return $cached; // Pull a much larger sample than a normal top-queries call so each // language bucket still has enough rows to be meaningful once split. $data = $this->api_post( 'sites/' . urlencode( $property ) . '/searchAnalytics/query', [ 'startDate' => $start, 'endDate' => $end, 'dimensions' => [ 'query' ], 'rowLimit' => 500 ] ); $rows = $data['rows'] ?? []; $buckets = [ 'arabic' => [], 'other' => [] ]; foreach ( $rows as $row ) { $query = $row['keys'][0] ?? ''; $bucket = self::looks_arabic_text( $query ) ? 'arabic' : 'other'; $buckets[ $bucket ][] = $row; } $result = []; foreach ( $buckets as $lang => $lang_rows ) { $count = count( $lang_rows ); $result[ $lang ] = [ 'totals' => [ 'clicks' => array_sum( array_column( $lang_rows, 'clicks' ) ), 'impressions' => array_sum( array_column( $lang_rows, 'impressions' ) ), 'ctr' => $count ? round( ( array_sum( array_column( $lang_rows, 'ctr' ) ) / $count ) * 100, 1 ) : 0, 'position' => $count ? round( array_sum( array_column( $lang_rows, 'position' ) ) / $count, 1 ) : 0, 'query_count' => $count, ], 'top_queries' => array_slice( $lang_rows, 0, $limit_per_language ), ]; } if ( null !== $data ) { set_transient( $cache_key, $result, HOUR_IN_SECONDS ); } return $result; } /** * Traffic decay - posts with declining clicks. PRO. */ public function get_traffic_decay( $limit = 30 ) { $property = $this->require_property(); if ( ! $property ) return []; // Compare last 28 days vs previous 28 days. $end = date( 'Y-m-d' ); $mid = date( 'Y-m-d', strtotime( '-28 days' ) ); $start = date( 'Y-m-d', strtotime( '-56 days' ) ); $current = $this->api_post( 'sites/' . urlencode( $property ) . '/searchAnalytics/query', [ 'startDate' => $mid, 'endDate' => $end, 'dimensions' => [ 'page' ], 'rowLimit' => 500 ] ); $previous = $this->api_post( 'sites/' . urlencode( $property ) . '/searchAnalytics/query', [ 'startDate' => $start, 'endDate' => $mid, 'dimensions' => [ 'page' ], 'rowLimit' => 500 ] ); if ( empty( $current['rows'] ) || empty( $previous['rows'] ) ) return []; // Index previous by page. $prev_index = []; foreach ( $previous['rows'] as $row ) { $prev_index[ $row['keys'][0] ] = $row['clicks']; } $decay = []; foreach ( $current['rows'] as $row ) { $url = $row['keys'][0]; $cur = $row['clicks']; $prev = $prev_index[ $url ] ?? 0; if ( $prev === 0 || $cur >= $prev ) continue; $pct_drop = round( ( ( $prev - $cur ) / $prev ) * 100 ); if ( $pct_drop < 10 ) continue; // ignore minor fluctuations $decay[] = [ 'url' => $url, 'current' => $cur, 'previous' => $prev, 'drop_pct' => $pct_drop, 'impressions' => $row['impressions'], ]; } usort( $decay, fn( $a, $b ) => $b['drop_pct'] <=> $a['drop_pct'] ); return array_slice( $decay, 0, $limit ); } }