is_wpcom_simple = ( new Host() )->is_wpcom_simple(); } /** * Get site's stats. * * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/ * @param array $args Optional query parameters. * @return array| WP_Error */ public function get_stats( $args = array() ) { $this->resource = ''; return $this->fetch_stats( $args ); } /** * Get site's summarized views, visitors, likes and comments. * * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/summary/ * @param array $args Optional query parameters. * @return array|WP_Error */ public function get_stats_summary( $args = array() ) { $this->resource = 'summary'; return $this->fetch_stats( $args ); } /** * Get site's top posts and pages by views. * * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/top-posts/ * @param array $args Optional query parameters. * @param bool $override_cache Optional override cache. * @return array|WP_Error */ public function get_top_posts( $args = array(), $override_cache = false ) { $this->resource = 'top-posts'; // Needed for the Top Posts block, so users can preview changes instantly. if ( $override_cache ) { return $this->fetch_remote_stats( $this->build_endpoint(), $args ); } return $this->fetch_stats( $args ); } /** * Get site's archive pages by views. * * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/archives/ * @param array $args Optional query parameters. * @return array|WP_Error */ public function get_archives( $args = array() ) { $this->resource = 'archives'; return $this->fetch_stats( $args ); } /** * Get the details of a single video. * * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/video/%24post_id/ * @param int $post_id The video's ID. * @param array $args Optional query parameters. * @return array|WP_Error */ public function get_video_details( $post_id, $args = array() ) { $this->resource = sprintf( 'video/%d', $post_id ); return $this->fetch_stats( $args ); } /** * Get site's referrers. * * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/referrers/ * @param array $args Optional query parameters. * @return array|WP_Error */ public function get_referrers( $args = array() ) { $this->resource = 'referrers'; return $this->fetch_stats( $args ); } /** * Get site's outbound clicks. * * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/clicks/ * @param array $args Optional query parameters. * @return array|WP_Error */ public function get_clicks( $args = array() ) { $this->resource = 'clicks'; return $this->fetch_stats( $args ); } /** * Get site's views by tags and categories. * * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/tags/ * @param array $args Optional query parameters. * @return array|WP_Error */ public function get_tags( $args = array() ) { $this->resource = 'tags'; return $this->fetch_stats( $args ); } /** * Get site's top authors. * * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/top-authors/ * @param array $args Optional query parameters. * @return array|WP_Error */ public function get_top_authors( $args = array() ) { $this->resource = 'top-authors'; return $this->fetch_stats( $args ); } /** * Get site's top comment authors and most-commented posts. * * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/comments/ * @param array $args Optional query parameters. * @return array|WP_Error */ public function get_top_comments( $args = array() ) { $this->resource = 'comments'; return $this->fetch_stats( $args ); } /** * Get site's video plays. * * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/video-plays/ * @param array $args Optional query parameters. * @return array|WP_Error */ public function get_video_plays( $args = array() ) { $this->resource = 'video-plays'; return $this->fetch_stats( $args ); } /** * Get site's file downloads. * * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/file-downloads/ * @param array $args Optional query parameters. * @return array|WP_Error */ public function get_file_downloads( $args = array() ) { $this->resource = 'file-downloads'; return $this->fetch_stats( $args ); } /** * Get a post's views. * * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/post/%24post_id/ * @param int $post_id The post's ID. * @param array $args Optional query parameters. * @param bool $cache_in_meta Optional should cache in post meta. * @return array|WP_Error */ public function get_post_views( $post_id, $args = array(), $cache_in_meta = false ) { $this->resource = sprintf( 'post/%d', $post_id ); if ( $cache_in_meta ) { return $this->fetch_post_stats( $args, $post_id ); } return $this->fetch_stats( $args ); } /** * Get site's views by country. * * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/country-views/ * @param array $args Optional query parameters. * @return array|WP_Error */ public function get_views_by_country( $args = array() ) { $this->resource = 'country-views'; return $this->fetch_stats( $args ); } /** * Get site's views by location. * * @param string $geo_mode The type of location to fetch views for (country, region, city). * @param array $args Optional query parameters. * @return array|WP_Error */ public function get_views_by_location( $geo_mode, $args = array() ) { $this->resource = sprintf( 'location-views/%s', $geo_mode ); return $this->fetch_stats( $args ); } /** * Get site's followers. * * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/followers/ * @param array $args Optional query parameters. * @return array|WP_Error */ public function get_followers( $args = array() ) { $this->resource = 'followers'; return $this->fetch_stats( $args ); } /** * Get site's comment followers. * * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/comment-followers/ * @param array $args Optional query parameters. * @return array|WP_Error */ public function get_comment_followers( $args = array() ) { $this->resource = 'comment-followers'; return $this->fetch_stats( $args ); } /** * Get site's publicize follower counts. * * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/publicize/ * @param array $args Optional query parameters. * @return array|WP_Error */ public function get_publicize_followers( $args = array() ) { $this->resource = 'publicize'; return $this->fetch_stats( $args ); } /** * Get search terms used to find the site. * * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/search-terms/ * @param array $args Optional query parameters. * @return array|WP_Error */ public function get_search_terms( $args = array() ) { $this->resource = 'search-terms'; return $this->fetch_stats( $args ); } /** * Get the total number of views for each post. * * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/views/posts/ * @param array $args Optional query parameters. * @return array|WP_Error */ public function get_total_post_views( $args = array() ) { if ( $this->is_wpcom_simple ) { $post_ids = isset( $args['post_ids'] ) ? array_map( 'absint', explode( ',', $args['post_ids'] ) ) : array(); $escaped_post_ids = implode( ',', $post_ids ); $number_of_days = isset( $args['num'] ) ? absint( $args['num'] ) : 1; // It's the same function used in WPCOM simple. // @phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date $end_date = $args['end'] ?? date( 'Y-m-d' ); $stats = $this->fetch_stats_on_wpcom_simple( $end_date, $number_of_days, $escaped_post_ids ); $post_views = $stats['-'] ?? array(); $posts = array_map( function ( $post_id ) use ( $post_views ) { return array( 'ID' => $post_id, 'views' => $post_views[ $post_id ] ?? 0, ); }, $post_ids ); return array( 'posts' => $posts ); } $this->resource = 'views/posts'; return $this->fetch_stats( $args ); } /** * Get the number of visits for the site. * * @param array $args Optional query parameters. * @return array|WP_Error */ public function get_visits( $args = array() ) { $this->resource = 'visits'; return $this->fetch_stats( $args ); } /** * Get streaks for the site. * * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/streak/ * * @param array $args Optional query parameters. * @return array|WP_Error */ public function get_streak( $args = array() ) { $this->resource = 'streak'; return $this->fetch_stats( $args ); } /** * Get the highlights for the site. * * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/highlights/ * * @param array $args Optional query parameters. * @return array|WP_Error */ public function get_highlights( $args = array() ) { $this->resource = 'highlights'; return $this->fetch_stats( $args ); } /** * Get the number of visits for the site. * * @param array $args Optional query parameters. * @return array|WP_Error */ public function get_insights( $args = array() ) { $this->resource = 'insights'; return $this->fetch_stats( $args ); } /** * Build WPCOM REST API endpoint. * * @return string */ protected function build_endpoint() { $resource = ltrim( $this->resource, '/' ); return sprintf( '/sites/%d/stats/%s', Jetpack_Options::get_option( 'id' ), $resource ); } /** * Fetches stats data from WPCOM or local Cache. Caches locally for 5 minutes. * * @param array $args Optional query parameters. * * @return array|WP_Error */ protected function fetch_stats( $args = array() ) { $endpoint = $this->build_endpoint(); $api_version = self::STATS_REST_API_VERSION; $cache_key = md5( implode( '|', array( $endpoint, $api_version, wp_json_encode( $args, JSON_UNESCAPED_SLASHES ) ) ) ); $transient_name = self::STATS_CACHE_TRANSIENT_PREFIX . $cache_key; $stats_cache = $this->should_bypass_cache() ? false : get_transient( $transient_name ); if ( $stats_cache ) { $time = key( $stats_cache ); $data = $stats_cache[ $time ]; // WP_Error or string (JSON encoded object). if ( is_wp_error( $data ) ) { return $data; } return array_merge( array( 'cached_at' => $time ), (array) json_decode( $data, true ) ); } $wpcom_stats = $this->fetch_remote_stats( $endpoint, $args ); /* * Connection failures stop being true as soon as the site reconnects. Do not cache * them: otherwise the next request keeps returning the old failure after recovery. * * `no_possible_tokens` is what the connection package reports for a missing blog token * since it started naming the reason; `missing_token` is what older versions still return. */ if ( is_wp_error( $wpcom_stats ) && in_array( $wpcom_stats->get_error_code(), array( 'missing_token', 'no_possible_tokens', 'site_not_connected' ), true ) ) { return $wpcom_stats; } // To reduce size in storage: store with time as key, store JSON encoded data. $cached_value = is_wp_error( $wpcom_stats ) ? $wpcom_stats : wp_json_encode( $wpcom_stats, JSON_UNESCAPED_SLASHES ); /** * Filters the expiration time for the stats cache. * * @module stats * * @since 0.10.0 * * @param int $expiration The expiration time in minutes. */ $expiration = apply_filters( 'jetpack_fetch_stats_cache_expiration', self::STATS_CACHE_EXPIRATION_IN_MINUTES * MINUTE_IN_SECONDS ); set_transient( $transient_name, array( time() => $cached_value ), $expiration ); return $wpcom_stats; } /** * Fetches stats data from WPCOM or local Cache. Caches locally for 5 minutes. * * Unlike the above function, this caches data in the post meta table. As such, * it prevents wp_options from blowing up when retrieving views for large numbers * of posts at the same time. * * This function returns valid arrays and WP_Error objects from cache if within the expiration period. * If the cached entry is malformed or invalid, a refresh is triggered regardless of cache time. * This self-healing behavior reduces API calls when remote fetch fails, but ensures data validity. * * @param array $args Query parameters. * @param int $post_id Post ID to acquire stats for. * * @return array|WP_Error */ protected function fetch_post_stats( $args, $post_id ) { $endpoint = $this->build_endpoint(); $meta_name = '_' . self::STATS_CACHE_TRANSIENT_PREFIX; $stats_cache = get_post_meta( $post_id, $meta_name, false ); if ( $stats_cache ) { $data = reset( $stats_cache ); // Check if we have a valid cache structure with a time key. if ( is_array( $data ) && ! empty( $data ) ) { $time = key( $data ); // If we have a numeric time, check if cache is still valid. if ( is_numeric( $time ) ) { /** This filter is already documented in projects/packages/stats/src/class-wpcom-stats.php */ $expiration = apply_filters( 'jetpack_fetch_stats_cache_expiration', self::STATS_CACHE_EXPIRATION_IN_MINUTES * MINUTE_IN_SECONDS ); // If within cache period, return cached data after type validation. if ( ( time() - $time ) < $expiration ) { $cached_value = $data[ $time ]; // If it's an array or WP_Error, handle appropriately. if ( is_wp_error( $cached_value ) ) { return $cached_value; } if ( is_array( $cached_value ) ) { return array_merge( array( 'cached_at' => $time ), $cached_value ); } // For any other unexpected type, treat as malformed cache. // Fall through to refresh. } } } } // Cache doesn't exist, is expired, or is malformed - refresh it. return $this->refresh_post_stats_cache( $endpoint, $args, $post_id, $meta_name ); } /** * Force fetch stats from WPCOM, and always update cache. * * This function will cache the result regardless of whether the fetch succeeds * or fails. This ensures that failed requests are also cached, reducing the * frequency of API calls when the remote service is experiencing issues. * * @param string $endpoint The stats endpoint. * @param array $args The query arguments. * @param int $post_id The post ID. * @param string $meta_name The meta name. * * @return array|WP_Error */ protected function refresh_post_stats_cache( $endpoint, $args, $post_id, $meta_name ) { $wpcom_stats = $this->fetch_remote_stats( $endpoint, $args ); // Always cache the result, even if it's an error or empty. update_post_meta( $post_id, $meta_name, array( time() => $wpcom_stats ) ); return $wpcom_stats; } /** * Whether the caller has asked for an answer newer than the cached one. * * A site that has just connected, or just bought a plan, carries answers from before it did * -- including failures, which are cached like any other answer. Both markers are the ones * `Stats_Admin\WPCOM_Client` already honours, so a page load clears every layer or none. * * The dashboard asks for its data over REST, and those requests carry none of the page's * query, so the marker has to be read from the page that sent them as well. * * Limited to users who can view stats: `fetch_stats()` also serves public widgets and * blocks, and those must not skip the cache because a visitor supplied a query arg or a * Referer that happens to contain one of the markers. * * @return bool */ protected function should_bypass_cache() { if ( ! current_user_can( 'view_stats' ) ) { return false; } foreach ( array( 'force_refresh', 'statsPurchaseSuccess' ) as $marker ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( isset( $_GET[ $marker ] ) ) { return true; } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized if ( isset( $_SERVER['HTTP_REFERER'] ) && false !== strpos( (string) $_SERVER['HTTP_REFERER'], $marker ) ) { return true; } } return false; } /** * Fetches stats data from WPCOM. * * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/ * @param string $endpoint The stats endpoint. * @param array $args The query arguments. * @return array|WP_Error */ protected function fetch_remote_stats( $endpoint, $args ) { if ( is_array( $args ) && ! empty( $args ) ) { $endpoint .= '?' . http_build_query( $args ); } $response = Client::wpcom_json_api_request_as_blog( $endpoint, self::STATS_REST_API_VERSION, array( 'timeout' => 20 ) ); $response_code = wp_remote_retrieve_response_code( $response ); $response_body = wp_remote_retrieve_body( $response ); $data = json_decode( $response_body, true ); $error_code = is_wp_error( $response ) ? $response->get_error_code() : ( $data['error'] ?? $data['code'] ?? null ); // Traffic endpoints must expose the same connection errors as the Stats admin API client. if ( in_array( $error_code, array( 'missing_token', 'no_possible_tokens', 'malformed_token', 'invalid_token', 'unknown_token', 'signature_mismatch' ), true ) ) { return new WP_Error( 'site_not_connected', __( 'This site is not connected to WordPress.com.', 'jetpack-stats-pkg' ), array( 'status' => 400 ) ); } if ( is_wp_error( $response ) || 200 !== $response_code || empty( $response_body ) ) { return is_wp_error( $response ) ? $response : new WP_Error( 'stats_error', 'Failed to fetch Stats from WPCOM' ); } return $data; } /** * Fetch the stats when executed in WPCOM Simple. * * @param string $end_date The end date. * @param int $number_of_days The number of days. * @param string $escaped_post_ids The escaped post ids. * * @return array */ protected function fetch_stats_on_wpcom_simple( $end_date, $number_of_days, $escaped_post_ids ) { return stats_get_daily_history( false, get_current_blog_id(), 'postviews', 'post_id', $end_date, $number_of_days, " AND post_id IN ($escaped_post_ids)", 0, true ); } /** * Convert stats array to object after sanity checking the array is valid. * * @since 0.11.0 * * @param array $stats_array The stats array. * @return WP_Error|object|null */ public function convert_stats_array_to_object( $stats_array ) { if ( is_wp_error( $stats_array ) ) { return $stats_array; } $encoded_array = wp_json_encode( $stats_array, JSON_UNESCAPED_SLASHES ); if ( ! $encoded_array ) { return new WP_Error( 'stats_encoding_error', 'Failed to encode stats array' ); } return json_decode( $encoded_array ); } }