search targeting. `gl` is the 2-letter country code SerpApi * (and Google's own Ads/Search APIs) use for geotargeting; `gsc_country` * is the 3-letter ISO code Search Console's `country` dimension expects * (a different code length, easy to mix up). 'msa' has no country - it's * the untargeted/formal-Arabic case, tracked without a location filter. * * Deliberately NOT using Google's old country-domain trick * (google.com.sa, google.ae, etc.) - Google has been phasing ccTLD * search domains out in favor of google.com with location handled via * `gl`/account settings, so a domain-based approach would be building on * a mechanism that's actively being retired. */ const REGION_TARGETING = [ 'msa' => [ 'gl' => '', 'gsc_country' => '', 'hl' => 'ar' ], 'saudi' => [ 'gl' => 'sa', 'gsc_country' => 'sau', 'hl' => 'ar' ], 'egyptian' => [ 'gl' => 'eg', 'gsc_country' => 'egy', 'hl' => 'ar' ], 'kuwaiti' => [ 'gl' => 'kw', 'gsc_country' => 'kwt', 'hl' => 'ar' ], 'lebanese' => [ 'gl' => 'lb', 'gsc_country' => 'lbn', 'hl' => 'ar' ], 'uae' => [ 'gl' => 'ae', 'gsc_country' => 'are', 'hl' => 'ar' ], 'iraqi' => [ 'gl' => 'iq', 'gsc_country' => 'irq', 'hl' => 'ar' ], 'jordanian' => [ 'gl' => 'jo', 'gsc_country' => 'jor', 'hl' => 'ar' ], 'moroccan' => [ 'gl' => 'ma', 'gsc_country' => 'mar', 'hl' => 'ar' ], ]; public static function init() { add_action( 'awbseo_daily_rank_check', [ __CLASS__, 'run_checks' ] ); if ( ! wp_next_scheduled( 'awbseo_daily_rank_check' ) ) { wp_schedule_event( time(), 'daily', 'awbseo_daily_rank_check' ); } } public static function create_table() { global $wpdb; $table = $wpdb->prefix . self::TABLE_SUFFIX; $charset = $wpdb->get_charset_collate(); $sql = "CREATE TABLE IF NOT EXISTS {$table} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, keyword VARCHAR(255) NOT NULL, position SMALLINT NOT NULL DEFAULT 0, url VARCHAR(512) NOT NULL, checked_at DATE NOT NULL, PRIMARY KEY (id), KEY keyword (keyword), KEY checked_at (checked_at) ) {$charset};"; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta( $sql ); } /** * The 9 dialect regions a keyword can be pinned to, for the admin UI's * dropdown. Same shape as AWBSEO_Hreflang_Manager::dialect_regions() - * reusing that method directly rather than duplicating its labels here, * since this is a plain code/label lookup, not one of the AI-prompt * hint blocks this codebase deliberately keeps as independent copies. */ public static function regions() { return AWBSEO_Hreflang_Manager::dialect_regions(); } public static function get_keywords() { return get_option( self::OPTION_KEYWORDS, [] ); } public static function add_keyword( $keyword, $target_url = '', $region = '' ) { $keywords = self::get_keywords(); $keyword = sanitize_text_field( strtolower( trim( $keyword ) ) ); if ( ! $keyword || isset( $keywords[ $keyword ] ) ) return false; $region = array_key_exists( $region, self::REGION_TARGETING ) ? $region : ''; $keywords[ $keyword ] = [ 'url' => esc_url_raw( $target_url ), 'region' => $region, 'added' => current_time( 'mysql' ), 'alert_drop' => 5, // alert if drops more than 5 positions ]; update_option( self::OPTION_KEYWORDS, $keywords ); return true; } public static function remove_keyword( $keyword ) { $keywords = self::get_keywords(); unset( $keywords[ sanitize_text_field( $keyword ) ] ); update_option( self::OPTION_KEYWORDS, $keywords ); } public static function get_history( $keyword, $days = 30 ) { global $wpdb; $table = $wpdb->prefix . self::TABLE_SUFFIX; return $wpdb->get_results( $wpdb->prepare( "SELECT position, url, checked_at FROM {$table} WHERE keyword = %s AND checked_at >= DATE_SUB(CURDATE(), INTERVAL %d DAY) ORDER BY checked_at ASC", $keyword, $days ) ); } /** * Fetches the current position for a single keyword and stores it. * Called immediately when a keyword is added so the first data point is * available without waiting for the next daily cron run. * Returns the position (int) or null if unavailable from any source. */ public static function fetch_position( $keyword ) { $keywords = self::get_keywords(); $config = $keywords[ $keyword ] ?? []; $position = self::lookup_position( $keyword, $config['url'] ?? '', $config['region'] ?? '' ); if ( $position === null ) return null; $position = (int) round( $position ); self::store_position( $keyword, $position, $config['url'] ?? '' ); return $position; } /** * Runs daily - checks positions for every tracked keyword. */ public static function run_checks() { $keywords = self::get_keywords(); if ( empty( $keywords ) ) return; foreach ( $keywords as $keyword => $config ) { $position = self::lookup_position( $keyword, $config['url'] ?? '', $config['region'] ?? '' ); if ( $position === null ) continue; $position = (int) round( $position ); self::store_position( $keyword, $position, $config['url'] ?? '' ); self::maybe_send_alert( $keyword, $position, $config ); } } /** * GSC first (real data, free), falling back to a live SerpApi check * whenever GSC is disconnected OR simply has no rows for this query yet * - which is the normal state for a brand-new page or a lower-volume * dialect keyword, not just for sites that skipped connecting GSC. * Returns a float|int position, or null if neither source has one. */ private static function lookup_position( $keyword, $target_url, $region ) { $meta = self::REGION_TARGETING[ $region ] ?? [ 'gl' => '', 'gsc_country' => '', 'hl' => '' ]; $gsc = AWBSEO_GSC::get_instance(); if ( $gsc->is_connected() ) { $position = $gsc->get_keyword_position( $keyword, $meta['gsc_country'] ); if ( $position !== null ) return $position; } return self::serp_check( $keyword, $target_url, $meta ); } private static function store_position( $keyword, $position, $url ) { global $wpdb; $wpdb->replace( $wpdb->prefix . self::TABLE_SUFFIX, [ 'keyword' => $keyword, 'position' => $position, 'url' => $url, 'checked_at' => current_time( 'Y-m-d' ), ], [ '%s', '%d', '%s', '%s' ] ); } private static function maybe_send_alert( $keyword, $current_pos, $config ) { global $wpdb; $table = $wpdb->prefix . self::TABLE_SUFFIX; // Get yesterday's position. $yesterday = $wpdb->get_var( $wpdb->prepare( "SELECT position FROM {$table} WHERE keyword = %s AND checked_at = DATE_SUB(CURDATE(), INTERVAL 1 DAY)", $keyword ) ); if ( $yesterday === null ) return; $drop = (int) $current_pos - (int) $yesterday; if ( $drop < ( $config['alert_drop'] ?? 5 ) ) return; $admin_email = get_option( 'admin_email' ); wp_mail( $admin_email, sprintf( '[AWBSEO] Ranking drop alert: "%s"', $keyword ), sprintf( "Your keyword \"%s\" dropped %d positions (from #%d to #%d).\n\nLog in to CitedSEO → Rank Tracker to view the full history.\n\n%s", $keyword, $drop, $yesterday, $current_pos, admin_url( 'admin.php?page=awb-seo-rank-tracker' ) ) ); } /** * Live SERP position check via SerpApi.com (https://serpapi.com/) - * used as a fallback when GSC has no data for this query. Requires a * "Rank Tracker API Key" set under Settings; without one this always * returns null, exactly like before this integration existed, so a site * with no key configured behaves the same as it always did. * * Matches by host, not exact URL, since a ranking page can carry query * params or a trailing slash a strict string comparison would miss, and * falls back to the site's own domain when the keyword has no specific * target URL set (the "Target URL" field on the Rank Tracker screen is * optional). */ private static function serp_check( $keyword, $target_url, array $region_meta ) { $settings = get_option( 'awbseo_settings', [] ); $api_key = trim( $settings['serp_api_key'] ?? '' ); if ( ! $api_key || ! $keyword ) return null; $target_host = self::normalize_host( $target_url ?: home_url() ); if ( ! $target_host ) return null; $query = [ 'engine' => 'google', 'q' => $keyword, 'num' => 100, 'device' => 'desktop', 'api_key' => $api_key, ]; if ( ! empty( $region_meta['hl'] ) ) $query['hl'] = $region_meta['hl']; if ( ! empty( $region_meta['gl'] ) ) $query['gl'] = $region_meta['gl']; $response = wp_remote_get( 'https://serpapi.com/search.json?' . http_build_query( $query ), [ 'timeout' => 15, ] ); if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { return null; } $body = json_decode( wp_remote_retrieve_body( $response ), true ); $results = $body['organic_results'] ?? []; if ( ! $results ) return null; foreach ( $results as $result ) { $result_host = self::normalize_host( $result['link'] ?? '' ); if ( $result_host && $result_host === $target_host ) { $position = (int) ( $result['position'] ?? 0 ); return $position > 0 ? $position : null; } } return null; } private static function normalize_host( $url ) { $host = wp_parse_url( $url, PHP_URL_HOST ); if ( ! $host ) return ''; return preg_replace( '/^www\./', '', strtolower( $host ) ); } }