0 && $current > 0 && $current === $owner_id; } /** True when Bing is connected but no owner has been recorded yet. */ public static function needs_owner_claim(): bool { return self::get_instance()->is_connected() && 0 === (int) get_option( self::OPTION_OWNER, 0 ); } /** * Lets the current admin explicitly claim an unclaimed Bing connection. * Requires a deliberate click (nonce + manage_options). */ public static function handle_claim_owner() { check_admin_referer( 'awbseo_bing_claim_owner' ); if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Forbidden' ); if ( 0 === (int) get_option( self::OPTION_OWNER, 0 ) ) { update_option( self::OPTION_OWNER, get_current_user_id(), false ); } wp_redirect( admin_url( 'admin.php?page=awbseo-settings#bing' ) ); exit; } // ── Connect / Disconnect ────────────────────────────────────────────────── public static function handle_connect() { check_admin_referer( 'awbseo_bing_connect' ); if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Forbidden' ); $api_key = sanitize_text_field( $_POST['awb_bing_api_key'] ?? '' ); if ( ! $api_key ) { wp_redirect( admin_url( 'admin.php?page=awbseo-settings&bing_error=no_key' ) ); exit; } // Verify the key works before storing it. $sites = self::api_call( 'GetUserSites', $api_key ); if ( null === $sites || isset( $sites['ErrorCode'] ) ) { wp_redirect( admin_url( 'admin.php?page=awbseo-settings&bing_error=invalid_key' ) ); exit; } update_option( self::OPTION_API_KEY, $api_key, false ); update_option( self::OPTION_OWNER, get_current_user_id(), false ); $sites_list = $sites['d'] ?? $sites ?? []; // Store full list so the property dropdown can show all sites. $urls = []; foreach ( $sites_list as $s ) { $url = $s['Url'] ?? $s['url'] ?? ''; if ( $url ) { $urls[] = $url; } } update_option( self::OPTION_ALL_SITES, $urls, false ); $property = self::match_property( $sites_list ); if ( $property ) { update_option( self::OPTION_PROPERTY, $property ); } else { delete_option( self::OPTION_PROPERTY ); } self::flush_transients(); wp_redirect( admin_url( 'admin.php?page=awbseo-settings&bing_connected=1' ) ); exit; } public static function handle_change_property() { check_admin_referer( 'awbseo_bing_change_property' ); if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Forbidden' ); $property = sanitize_text_field( $_POST['awb_bing_property'] ?? '' ); if ( $property ) { update_option( self::OPTION_PROPERTY, $property ); } else { delete_option( self::OPTION_PROPERTY ); } self::flush_transients(); wp_redirect( admin_url( 'admin.php?page=awbseo-settings&bing_connected=1' ) ); exit; } public static function handle_disconnect() { check_admin_referer( 'awbseo_bing_disconnect' ); if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Forbidden' ); delete_option( self::OPTION_API_KEY ); delete_option( self::OPTION_PROPERTY ); delete_option( self::OPTION_ALL_SITES ); delete_option( self::OPTION_OWNER ); self::flush_transients(); wp_redirect( admin_url( 'admin.php?page=awbseo-settings&bing_disconnected=1' ) ); exit; } /** * Picks the site entry that matches this WordPress install's home URL, * falling back to the first verified site Bing returns for this key. */ private static function match_property( $sites ) { if ( ! is_array( $sites ) || empty( $sites ) ) { return ''; } $home = rtrim( home_url(), '/' ); $home_bare = self::bare_host( home_url() ); foreach ( $sites as $site ) { $url = $site['Url'] ?? $site['url'] ?? ''; if ( $url && rtrim( $url, '/' ) === $home ) { return $url; } } // Loosen to a bare-host match (scheme/www/trailing-path differences) // before giving up entirely. if ( $home_bare ) { foreach ( $sites as $site ) { $url = $site['Url'] ?? $site['url'] ?? ''; if ( $url && self::bare_host( $url ) === $home_bare ) { return $url; } } } // IMPORTANT: never fall back to "the first site" here. The pasted // API key is often an agency's own Bing Webmaster account with // access to many unrelated clients' verified sites -- silently // assigning properties[0] previously meant a site with no exact // domain match could end up auto-connected to a completely // different client's Bing property with no indication anything // was wrong. return ''; } /** Lowercase hostname with a leading "www." stripped, for loose domain comparisons. */ private static function bare_host( $url ) { $host = (string) wp_parse_url( $url, PHP_URL_HOST ); return strtolower( preg_replace( '/^www\\./i', '', $host ) ); } /** * Same list as get_all_sites(), filtered down to entries whose host * matches this WordPress install's own domain. The pasted API key is * frequently an agency's Bing Webmaster account with access to every * client site it manages -- never let one client's property picker * browse every other client's site through this dropdown. */ public function get_all_sites_for_this_domain() { $home_bare = self::bare_host( home_url() ); if ( ! $home_bare ) { return []; } $matches = []; foreach ( $this->get_all_sites() as $site_url ) { if ( self::bare_host( $site_url ) === $home_bare ) { $matches[] = $site_url; } } return $matches; } // ── API Calls ───────────────────────────────────────────────────────────── private static function api_call( $endpoint, $api_key, $args = [] ) { if ( ! $api_key ) { return null; } $args['apikey'] = $api_key; $url = add_query_arg( $args, self::API_BASE . $endpoint ); $response = wp_remote_get( $url, [ 'timeout' => 15 ] ); if ( is_wp_error( $response ) ) { return null; } return json_decode( wp_remote_retrieve_body( $response ), true ); } private function api_get( $endpoint, $args = [] ) { return self::api_call( $endpoint, $this->get_api_key(), $args ); } /** * Bing returns dates as legacy ASP.NET AJAX strings, e.g. * "/Date(1316156400000-0700)/". The numeric portion is already a * UTC millisecond epoch, so we can parse it directly into Y-m-d. */ private static function parse_bing_date( $value ) { if ( ! $value || ! preg_match( '/-?\d+/', $value, $m ) ) { return ''; } return gmdate( 'Y-m-d', intdiv( (int) $m[0], 1000 ) ); } /** * Reshapes a raw Bing row ({Query|PageURL, Clicks, Impressions, * AvgClickPosition, Date, ...}) into the same shape AWBSEO_GSC rows * use ({keys, clicks, impressions, ctr, position}) so dashboard * rendering code can treat both sources identically. Bing has no * native CTR field, so it's derived from clicks / impressions. */ private static function normalize_rows( $rows, $key_field ) { if ( ! is_array( $rows ) ) { return []; } $out = []; foreach ( $rows as $row ) { $clicks = (int) ( $row['Clicks'] ?? 0 ); $impressions = (int) ( $row['Impressions'] ?? 0 ); $out[] = [ 'keys' => [ $row[ $key_field ] ?? '' ], 'clicks' => $clicks, 'impressions' => $impressions, 'position' => (float) ( $row['AvgClickPosition'] ?? $row['AvgImpressionPosition'] ?? 0 ), 'ctr' => $impressions > 0 ? $clicks / $impressions : 0, 'date' => self::parse_bing_date( $row['Date'] ?? '' ), ]; } return $out; } /** * Resolves a requested range into concrete start/end dates - mirrors * AWBSEO_GSC::resolve_range() so the dashboard's date picker works the * same way against both sources. Bing has no fixed reporting lag, so * $end just clamps to today. * * @return array [ $start, $end, $days ] */ private function resolve_range( $days = 28, $start = null, $end = null ) { $today = gmdate( 'Y-m-d' ); if ( $start && $end ) { $end = min( $end, $today ); 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 [ gmdate( 'Y-m-d', strtotime( "-{$days} days" ) ), $today, $days ]; } /** * Bing's GetQueryStats/GetPageStats return one row per key per date * rather than a single date-ranged total, so we fetch everything Bing * has, drop rows outside the requested window, then group by key and * sum. Note: Bing's own data refreshes weekly, not daily like GSC. */ private function get_aggregated( $endpoint, $key_field, $days, $limit, $cache_prefix, $start = null, $end = null ) { $property = $this->get_property(); if ( ! $property ) { return []; } list( $start, $end, $days ) = $this->resolve_range( $days, $start, $end ); $cache_key = $cache_prefix . md5( $property . '_' . $start . '_' . $end . '_' . $limit ); $cached = get_transient( $cache_key ); if ( false !== $cached ) { return $cached; } $data = $this->api_get( $endpoint, [ 'siteUrl' => $property ] ); $rows = self::normalize_rows( $data['d'] ?? [], $key_field ); $rows = array_filter( $rows, function ( $row ) use ( $start, $end ) { return $row['date'] >= $start && $row['date'] <= $end; } ); $grouped = []; foreach ( $rows as $row ) { $key = $row['keys'][0]; if ( ! isset( $grouped[ $key ] ) ) { $grouped[ $key ] = [ 'keys' => [ $key ], 'clicks' => 0, 'impressions' => 0, 'position_sum' => 0, 'position_n' => 0, ]; } $grouped[ $key ]['clicks'] += $row['clicks']; $grouped[ $key ]['impressions'] += $row['impressions']; $grouped[ $key ]['position_sum'] += $row['position']; $grouped[ $key ]['position_n']++; } $result = []; foreach ( $grouped as $g ) { $result[] = [ 'keys' => $g['keys'], 'clicks' => $g['clicks'], 'impressions' => $g['impressions'], 'position' => $g['position_n'] ? $g['position_sum'] / $g['position_n'] : 0, 'ctr' => $g['impressions'] > 0 ? $g['clicks'] / $g['impressions'] : 0, ]; } usort( $result, function ( $a, $b ) { return $b['clicks'] <=> $a['clicks']; } ); $result = array_slice( $result, 0, $limit ); set_transient( $cache_key, $result, HOUR_IN_SECONDS ); return $result; } /** * Top queries by clicks over the last N days - mirrors * AWBSEO_GSC::get_top_queries(). FREE. */ public function get_top_queries( $days = 28, $limit = 10, $start = null, $end = null ) { return $this->get_aggregated( 'GetQueryStats', 'Query', $days, $limit, 'awbseo_bing_top_queries_', $start, $end ); } /** * Top pages by clicks over the last N days - mirrors * AWBSEO_GSC::get_top_pages(). FREE. */ public function get_top_pages( $days = 28, $limit = 10, $start = null, $end = null ) { return $this->get_aggregated( 'GetPageStats', 'PageURL', $days, $limit, 'awbseo_bing_top_pages_', $start, $end ); } /** * Daily clicks/impressions series for the last N days, plus the prior * N-day window's totals for period-over-period deltas - mirrors * AWBSEO_GSC::get_site_stats() so the dashboard can reuse the same * rendering code for both sources. Built from page-level stats since * that's the closest thing Bing offers to a site-wide breakdown. * Note: Bing updates this data weekly, so day-to-day movement within * the series will look "steppy" compared to GSC's daily granularity. */ public function get_site_stats( $days = 28, $start = null, $end = null ) { $property = $this->get_property(); if ( ! $property ) { return null; } list( $start, $end, $days ) = $this->resolve_range( $days, $start, $end ); $cache_key = 'awbseo_bing_site_stats_' . md5( $property . '_' . $start . '_' . $end ); $cached = get_transient( $cache_key ); if ( false !== $cached ) { return $cached; } $data = $this->api_get( 'GetPageStats', [ 'siteUrl' => $property ] ); $rows = self::normalize_rows( $data['d'] ?? [], 'PageURL' ); $prev_end = gmdate( 'Y-m-d', strtotime( $start . ' -1 day' ) ); $prev_start = gmdate( 'Y-m-d', strtotime( $prev_end . " -{$days} days" ) ); $by_date = []; $prev_clicks = 0; $prev_impr = 0; $prev_ctr_sum = 0; $prev_pos_sum = 0; $prev_days = 0; foreach ( $rows as $row ) { if ( ! $row['date'] ) { continue; } if ( $row['date'] >= $start && $row['date'] <= $end ) { if ( ! isset( $by_date[ $row['date'] ] ) ) { $by_date[ $row['date'] ] = [ 'clicks' => 0, 'impressions' => 0, 'position_sum' => 0, 'position_n' => 0 ]; } $by_date[ $row['date'] ]['clicks'] += $row['clicks']; $by_date[ $row['date'] ]['impressions'] += $row['impressions']; $by_date[ $row['date'] ]['position_sum'] += $row['position']; $by_date[ $row['date'] ]['position_n']++; } elseif ( $row['date'] >= $prev_start && $row['date'] <= $prev_end ) { $prev_clicks += $row['clicks']; $prev_impr += $row['impressions']; $prev_ctr_sum += $row['impressions'] > 0 ? $row['clicks'] / $row['impressions'] : 0; $prev_pos_sum += $row['position']; $prev_days++; } } ksort( $by_date ); $daily = []; foreach ( $by_date as $date => $vals ) { $daily[] = [ 'keys' => [ $date ], 'clicks' => $vals['clicks'], 'impressions' => $vals['impressions'], 'position' => $vals['position_n'] ? $vals['position_sum'] / $vals['position_n'] : 0, 'ctr' => $vals['impressions'] > 0 ? $vals['clicks'] / $vals['impressions'] : 0, ]; } $result = [ 'daily' => $daily, 'prev_sum' => [ 'clicks' => $prev_clicks, 'impressions' => $prev_impr, 'ctr' => $prev_days ? round( ( $prev_ctr_sum / $prev_days ) * 100, 1 ) : 0, 'position' => $prev_days ? round( $prev_pos_sum / $prev_days, 1 ) : 0, ], ]; set_transient( $cache_key, $result, HOUR_IN_SECONDS ); return $result; } /** * Deletes all cached Bing API results from the options table. * Called on connect and disconnect so stale cached data is cleared. */ public static function flush_transients() { global $wpdb; $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '\_transient\_awbseo\_bing\_%' OR option_name LIKE '\_transient\_timeout\_awbseo\_bing\_%'" ); } }