get( 'GetUserSites', $api_key, array() ); if ( isset( $out['error'] ) ) { return $out; } $sites = array(); foreach ( (array) $out['d'] as $row ) { $sites[] = array( 'url' => (string) ( isset( $row['Url'] ) ? $row['Url'] : '' ), 'verified' => ! empty( $row['IsVerified'] ), ); } return array( 'sites' => $sites ); } /** * Add a site to the account (unverified until VerifySite succeeds). * * @param string $api_key API key. * @param string $site_url Site URL. * @return array { ok?: true, error?: string } */ public function add_site( $api_key, $site_url ) { $out = $this->post( 'AddSite', $api_key, array( 'siteUrl' => (string) $site_url ) ); return isset( $out['error'] ) ? $out : array( 'ok' => true ); } /** * Ask Bing to verify the site now — it fetches the page and looks for the * msvalidate tag this plugin prints. * * @param string $api_key API key. * @param string $site_url Site URL. * @return array { verified?: bool, error?: string } */ public function verify_site( $api_key, $site_url ) { $out = $this->post( 'VerifySite', $api_key, array( 'siteUrl' => (string) $site_url ) ); if ( isset( $out['error'] ) ) { return $out; } return array( 'verified' => ! empty( $out['d'] ) ); } /** * Daily crawl statistics, oldest first: one row per day with the crawl * counts, index size and robots blocks — the panel's raw material. * * @param string $api_key API key. * @param string $site_url Site URL. * @return array { rows?: array, error?: string } */ public function crawl_stats( $api_key, $site_url ) { $out = $this->get( 'GetCrawlStats', $api_key, array( 'siteUrl' => (string) $site_url ) ); if ( isset( $out['error'] ) ) { return $out; } $rows = array(); foreach ( (array) $out['d'] as $row ) { $date = self::wcf_date( isset( $row['Date'] ) ? (string) $row['Date'] : '' ); if ( '' === $date ) { continue; } $rows[] = array( 'date_at' => $date, 'crawled' => (int) ( isset( $row['CrawledPages'] ) ? $row['CrawledPages'] : 0 ), 'in_index' => (int) ( isset( $row['InIndex'] ) ? $row['InIndex'] : 0 ), 'in_links' => (int) ( isset( $row['InLinks'] ) ? $row['InLinks'] : 0 ), 'blocked_robots' => (int) ( isset( $row['BlockedByRobotsTxt'] ) ? $row['BlockedByRobotsTxt'] : 0 ), 'code_2xx' => (int) ( isset( $row['Code2xx'] ) ? $row['Code2xx'] : 0 ), 'code_301' => (int) ( isset( $row['Code301'] ) ? $row['Code301'] : 0 ), 'code_302' => (int) ( isset( $row['Code302'] ) ? $row['Code302'] : 0 ), 'code_4xx' => (int) ( isset( $row['Code4xx'] ) ? $row['Code4xx'] : 0 ), 'code_5xx' => (int) ( isset( $row['Code5xx'] ) ? $row['Code5xx'] : 0 ), ); } usort( $rows, static function ( $a, $b ) { return strcmp( $a['date_at'], $b['date_at'] ); } ); return array( 'rows' => $rows ); } /** * Site-wide clicks and impressions PER DAY — GetRankAndTrafficStats, the * human-traffic twin of crawl_stats(). One row per calendar day, oldest * first. Verified against the live API: ~16 months of consecutive days, * no gaps, no rank field despite the method's name. * * @param string $api_key API key. * @param string $site_url Site URL. * @return array { rows?: array, error?: string } */ public function traffic_stats( $api_key, $site_url ) { $out = $this->get( 'GetRankAndTrafficStats', $api_key, array( 'siteUrl' => (string) $site_url ) ); if ( isset( $out['error'] ) ) { return $out; } $rows = array(); foreach ( (array) $out['d'] as $row ) { if ( ! is_array( $row ) ) { continue; } $date = self::wcf_date( isset( $row['Date'] ) ? (string) $row['Date'] : '' ); if ( '' === $date ) { continue; } $rows[] = array( 'date_at' => $date, 'clicks' => (int) ( isset( $row['Clicks'] ) ? $row['Clicks'] : 0 ), 'impressions' => (int) ( isset( $row['Impressions'] ) ? $row['Impressions'] : 0 ), ); } usort( $rows, static function ( $a, $b ) { return strcmp( $a['date_at'], $b['date_at'] ); } ); return array( 'rows' => $rows ); } /** * One page as Bing's crawler knows it — GetUrlInfo, asked LIVE. * * ⚠️ Probed against the live API: a page Bing has never seen answers 200 * with .NET's zero date (year 1) in both date fields — {@see wcf_date()} * folds those to '' — and a URL outside the verified site answers * "ERROR!!! NotAuthorized". There is NO per-URL indexed-yes/no anywhere * in this API: discovered and last-crawled are all Bing will say about * one page, and the card's copy must not promise more. * (GetUrlTrafficInfo exists too, but returned zero impressions for pages * the query report counts thousands for — unreliable, deliberately unused.) * * @param string $api_key API key. * @param string $site_url Site URL. * @param string $url The page URL to ask about. * @return array { info?: array{known:bool,discoveredAt:string,lastCrawledAt:string}, error?: string } */ public function url_info( $api_key, $site_url, $url ) { $out = $this->get( 'GetUrlInfo', $api_key, array( 'siteUrl' => (string) $site_url, 'url' => (string) $url, ) ); if ( isset( $out['error'] ) ) { return $out; } $d = is_array( $out['d'] ) ? $out['d'] : array(); $discovered = self::wcf_date( (string) ( isset( $d['DiscoveryDate'] ) ? $d['DiscoveryDate'] : '' ) ); $crawled = self::wcf_date( (string) ( isset( $d['LastCrawledDate'] ) ? $d['LastCrawledDate'] : '' ) ); return array( 'info' => array( 'known' => '' !== $discovered || '' !== $crawled, 'discoveredAt' => $discovered, 'lastCrawledAt' => $crawled, ), ); } /** * The sitemaps Bing holds for the site — GetFeeds: each feed's URL, when * Bing last read it, and how many URLs it carried. This is Bing's own * record, not ours: the health line it feeds answers "does Bing know my * sitemap, and is it reading it?" with Bing's dates, the same question the * Google card answers from sitemaps.list. * * @param string $api_key API key. * @param string $site_url Site URL. * @return array { rows?: array, error?: string } */ public function feeds( $api_key, $site_url ) { $out = $this->get( 'GetFeeds', $api_key, array( 'siteUrl' => (string) $site_url ) ); if ( isset( $out['error'] ) ) { return $out; } $rows = array(); foreach ( (array) $out['d'] as $row ) { $url = isset( $row['Url'] ) ? (string) $row['Url'] : ( isset( $row['FeedUrl'] ) ? (string) $row['FeedUrl'] : '' ); if ( '' === $url ) { continue; } $rows[] = array( 'url' => $url, // Y-m-d, '' when Bing hasn't read it yet — the card renders the // absence honestly rather than inventing a date. 'lastReadAt' => self::wcf_date( isset( $row['LastCrawled'] ) ? (string) $row['LastCrawled'] : '' ), 'urls' => (int) ( isset( $row['UrlCount'] ) ? $row['UrlCount'] : ( isset( $row['SubmittedCount'] ) ? $row['SubmittedCount'] : 0 ) ), ); } // Most-recently-read first: a long-lived site's registry collects // fossils (a 2018 sitemap, a feed from 2013), and the card leads with // row one — it must name the feed Bing is actually reading today. usort( $rows, static function ( $a, $b ) { return strcmp( $b['lastReadAt'], $a['lastReadAt'] ); } ); return array( 'rows' => $rows ); } /** * Site-wide query traffic: one row per query over Bing's trailing window. * No page attribution at this endpoint — these rows feed the site's own * CTR median; the page-attributed rows come from page_query_stats(). * * @param string $api_key API key. * @param string $site_url Site URL. * @return array { rows?: array, error?: string } */ public function query_stats( $api_key, $site_url ) { $out = $this->get( 'GetQueryStats', $api_key, array( 'siteUrl' => (string) $site_url ) ); return isset( $out['error'] ) ? $out : array( 'rows' => self::qstats_rows( $out['d'], 'Query' ) ); } /** * Per-page traffic: one row per PAGE (Bing reuses its QueryStats DTO here — * the page URL travels in the Query field). Used to pick the top pages worth * a per-page query breakdown. * * @param string $api_key API key. * @param string $site_url Site URL. * @return array { rows?: array, error?: string } */ public function page_stats( $api_key, $site_url ) { $out = $this->get( 'GetPageStats', $api_key, array( 'siteUrl' => (string) $site_url ) ); return isset( $out['error'] ) ? $out : array( 'rows' => self::qstats_rows( $out['d'], 'Query' ) ); } /** * The queries one specific page ranks for. * * @param string $api_key API key. * @param string $site_url Site URL. * @param string $page The page URL. * @return array { rows?: array, error?: string } */ public function page_query_stats( $api_key, $site_url, $page ) { $out = $this->get( 'GetPageQueryStats', $api_key, array( 'siteUrl' => (string) $site_url, 'page' => (string) $page, ) ); return isset( $out['error'] ) ? $out : array( 'rows' => self::qstats_rows( $out['d'], 'Query' ) ); } /** * The pages one specific SEARCH shows — the same relationship as * {@see page_query_stats()}, asked from the other end. * * Worth having both. "Which searches show this page" fills in one page's row * and costs a request per page, so a large site is worked through slowly. * "Which pages show for this search" answers the split-search question — two * of your own pages competing for one search — in a single request, over a * list of searches that is short and already known. It also reaches pages the * page-by-page rotation has not got to yet. * * Bing reuses its QueryStats shape here as it does for page_stats(): the row * key is the PAGE URL, not the query. * * @param string $api_key API key. * @param string $site_url Site URL. * @param string $query The search. * @return array { rows?: array, error?: string } */ public function query_page_stats( $api_key, $site_url, $query ) { $out = $this->get( 'GetQueryPageStats', $api_key, array( 'siteUrl' => (string) $site_url, 'query' => (string) $query, ) ); return isset( $out['error'] ) ? $out : array( 'rows' => self::qstats_rows( $out['d'], 'Query' ) ); } /** * Normalize Bing's QueryStats DTO rows: { key, date_at, clicks, * impressions, position }. Position prefers the impression-weighted * average and is filterable (`agentimus_bing_position_scale`) because the * DTO's position scale gets confirmed against the live API at connect * time — the parser stays tolerant instead of assuming. * * ⚠️ These rows are WEEKLY BUCKETS, not aggregates: the DTO's Date field * (verified live: always a Friday, exactly 7 days apart, ~16 months deep) * stamps which week each row counts. Discarding it — as this parser once * did — turns "top queries" into a 16-month sum posing as a window. * * @param mixed $d The WCF "d" payload. * @param string $key_field Which field carries the row's key (query text, or the page URL). * @return array */ private static function qstats_rows( $d, $key_field ) { /** * Filter the divisor applied to Bing's Avg*Position values. * * @param float $scale 1 by default; set 10 if the live API reports positions ×10. */ $scale = max( 1.0, (float) apply_filters( 'agentimus_bing_position_scale', 1.0 ) ); $rows = array(); foreach ( (array) $d as $row ) { if ( ! is_array( $row ) ) { continue; } $key = (string) ( $row[ $key_field ] ?? $row['Page'] ?? $row['Url'] ?? '' ); if ( '' === $key ) { continue; } $position = (float) ( $row['AvgImpressionPosition'] ?? $row['AvgClickPosition'] ?? 0 ); $rows[] = array( 'key' => $key, // '' when the DTO drifts — callers treat a dateless row as // in-window rather than silently dropping data. 'date_at' => self::wcf_date( (string) ( $row['Date'] ?? '' ) ), 'clicks' => (int) ( $row['Clicks'] ?? 0 ), 'impressions' => (int) ( $row['Impressions'] ?? 0 ), 'position' => round( $position / $scale, 2 ), ); } return $rows; } /** * A WCF "/Date(1690761600000)/" (optionally with a zone suffix) as * Y-m-d, or '' when unparseable. * * @param string $raw The WCF date string. * @return string */ public static function wcf_date( $raw ) { if ( ! preg_match( '/\/Date\((\-?\d+)/', (string) $raw, $m ) ) { return ''; } $seconds = (int) floor( ( (float) $m[1] ) / 1000 ); return $seconds > 0 ? gmdate( 'Y-m-d', $seconds ) : ''; } /** * One GET read. * * @param string $method API method name. * @param string $api_key API key. * @param array $params Query params (besides apikey). * @return array { d?: mixed, error?: string } */ private function get( $method, $api_key, array $params ) { $url = self::API . $method . '?' . http_build_query( array_merge( array( 'apikey' => (string) $api_key ), $params ) ); return $this->handle( wp_remote_get( $url, array( 'timeout' => 15 ) ) ); } /** * One POST write. * * @param string $method API method name. * @param string $api_key API key. * @param array $body JSON body. * @return array { d?: mixed, error?: string } */ private function post( $method, $api_key, array $body ) { $url = self::API . $method . '?' . http_build_query( array( 'apikey' => (string) $api_key ) ); return $this->handle( wp_remote_post( $url, array( 'timeout' => 15, 'headers' => array( 'Content-Type' => 'application/json; charset=utf-8' ), 'body' => wp_json_encode( $body ), ) ) ); } /** * Shared response handling: transport errors, HTTP status words, and the * WCF "d" envelope. * * @param array|\WP_Error $response The wp_remote_* result. * @return array { d?: mixed, error?: string } */ private function handle( $response ) { if ( is_wp_error( $response ) ) { return array( 'error' => $response->get_error_message() ); } $code = (int) wp_remote_retrieve_response_code( $response ); $json = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( $code < 200 || $code >= 300 ) { // Bing wraps errors as {"ErrorCode":n,"Message":"…"}; surface the words. $message = is_array( $json ) && isset( $json['Message'] ) ? (string) $json['Message'] : ''; return array( 'error' => '' !== $message ? $message : sprintf( /* translators: %d: HTTP status code. */ __( 'Bing answered with status %d.', 'agentimus' ), $code ) ); } if ( ! is_array( $json ) || ! array_key_exists( 'd', $json ) ) { return array( 'error' => __( 'Bing returned an unexpected response shape.', 'agentimus' ) ); } return array( 'd' => $json['d'] ); } }