$def Complete report definition (see ReportRegistry). * @param DateRange $range Date range. * @param array $args Per-request args: 'paths' (path_filter), 'page_url' (page_filter), * 'site_hosts' (exclude_site_host; injected by ReportRunner). * @return array * @throws ApiError With slug 'bad_request' when a required arg is missing. */ public static function build( array $def, DateRange $range, array $args ): array { if ( 'gsc' === $def['api'] ) { return self::gsc( $def, $range, $args ); } return self::analytics( $def, $range, $args ); } /** * Data API (runReport) or realtime (runRealtimeReport) body. * * @param array $def Definition. * @param DateRange $range Date range. * @param array $args Args. * @return array * @throws ApiError With slug 'bad_request' when path_filter has no paths. */ private static function analytics( array $def, DateRange $range, array $args ): array { $is_data = 'data' === $def['api']; $dimensions = (array) $def['dimensions']; $compare = $is_data && $def['compare'] && null !== $range->compare_start && null !== $range->compare_end; $body = array(); if ( $is_data ) { $body['dateRanges'] = array( array( 'startDate' => $range->start, 'endDate' => $range->end, 'name' => 'current', ), ); if ( $compare ) { $body['dateRanges'][] = array( 'startDate' => $range->compare_start, 'endDate' => $range->compare_end, 'name' => 'previous', ); } } if ( array() !== $dimensions ) { $body['dimensions'] = array_map( static fn( string $name ): array => array( 'name' => $name ), $dimensions ); } $body['metrics'] = array_map( static fn( string $name ): array => array( 'name' => $name ), (array) $def['metrics'] ); $order_bys = self::order_bys( $def ); if ( null !== $order_bys ) { $body['orderBys'] = $order_bys; } // Google's limit counts the rows of every date range, ordered together, // so the top N of the two periods combined can leave out a row that is // in one period's top N and far down the other's: a page that rose // from below the cutoff would lose its previous row. A compared // request asks for four times the limit, and at least 100 rows; the // Normalizer cuts the current rows back to the limit. if ( null !== $def['limit'] ) { $limit = (int) $def['limit']; $body['limit'] = $compare ? max( $limit * 4, 100 ) : $limit; } // Always ask Google for totals: summing rows is wrong for // non-additive metrics (users, rates). $body['metricAggregations'] = array( 'TOTAL' ); $filter = self::dimension_filter( $def, $args ); if ( null !== $filter ) { $body['dimensionFilter'] = $filter; } // runRealtimeReport has no keepEmptyRows field and rejects unknown fields. if ( $is_data ) { $body['keepEmptyRows'] = false; } return $body; } /** * Search Console searchAnalytics.query body, on the lag-adjusted range. * * @param array $def Definition. * @param DateRange $range Date range. * @param array $args Args. * @return array * @throws ApiError With slug 'bad_request' when page_filter has no page_url. */ private static function gsc( array $def, DateRange $range, array $args ): array { $gsc_range = $range->for_gsc(); $body = array( 'startDate' => $gsc_range->start, 'endDate' => $gsc_range->end, 'dimensions' => array_values( (array) $def['dimensions'] ), ); if ( null !== $def['limit'] ) { $body['rowLimit'] = (int) $def['limit']; } $body['dataState'] = 'final'; if ( $def['page_filter'] ) { $page_url = isset( $args['page_url'] ) && is_string( $args['page_url'] ) ? $args['page_url'] : ''; if ( '' === $page_url ) { throw new ApiError( 'bad_request', 'This report needs a page URL.' ); } $body['dimensionFilterGroups'] = array( array( 'filters' => array( array( 'dimension' => 'page', 'operator' => 'includingRegex', 'expression' => self::page_path_regex( $page_url ), ), ), ), ); } return $body; } /** * An RE2 regex matching a page URL's path on any scheme and host, with or * without the trailing slash: ^https?://[^/]+/path/?$. Matching by path * keeps per-post queries working when Google indexed a different scheme * or host (http vs https, www vs apex, a staging copy's permalink), and * makes the request body (so the cache key) depend on the path only. * * @param string $page_url Page URL (or path). */ private static function page_path_regex( string $page_url ): string { $path = wp_parse_url( $page_url, PHP_URL_PATH ); $path = rtrim( is_string( $path ) ? $path : '', '/' ); return '^https?://[^/]+' . preg_quote( $path, '#' ) . '/?$'; } /** * The orderBys: chronological when 'date' is a dimension, else the * definition's metric order, else none. * * @param array $def Definition. * @return array>|null */ private static function order_bys( array $def ): ?array { if ( in_array( 'date', (array) $def['dimensions'], true ) ) { return array( array( 'dimension' => array( 'dimensionName' => 'date' ) ) ); } $order = $def['order_by']; if ( ! is_array( $order ) || ! isset( $order['metric'] ) || ! is_string( $order['metric'] ) ) { return null; } return array( array( 'metric' => array( 'metricName' => $order['metric'] ), 'desc' => ! empty( $order['desc'] ), ), ); } /** * The dimensionFilter from the definition's filter, its site-host * exclusion (args.site_hosts, e.g. self-referrals) and path_filter; * several expressions are combined with andGroup. * * @param array $def Definition. * @param array $args Args. * @return array|null * @throws ApiError With slug 'bad_request' when path_filter has no paths. */ private static function dimension_filter( array $def, array $args ): ?array { $expressions = array(); if ( is_array( $def['filter'] ) && isset( $def['filter']['field'] ) ) { $expressions[] = self::filter_expression( $def['filter'] ); } $host_regex = self::host_regex( $args['site_hosts'] ?? array() ); if ( is_string( $def['exclude_site_host'] ) && null !== $host_regex ) { $expressions[] = array( 'notExpression' => array( 'filter' => array( 'fieldName' => $def['exclude_site_host'], 'stringFilter' => array( 'matchType' => 'PARTIAL_REGEXP', 'value' => $host_regex, ), ), ), ); } if ( $def['path_filter'] ) { $paths = self::paths( $args['paths'] ?? array() ); if ( array() === $paths ) { throw new ApiError( 'bad_request', 'This report needs at least one page path.' ); } $expressions[] = array( 'filter' => array( 'fieldName' => 'pagePath', 'inListFilter' => array( 'values' => $paths ), ), ); } if ( array() === $expressions ) { return null; } if ( 1 === count( $expressions ) ) { return $expressions[0]; } return array( 'andGroup' => array( 'expressions' => $expressions ) ); } /** * An RE2 regex matching URLs on any of the given hosts, with or without * www., on any scheme and port: ^https?://(www\.)?(a\.com|b\.org)(:[0-9]+)?([/?#]|$). * GA4 string filters are case-insensitive by default. * * @param mixed $hosts Raw args.site_hosts (bare hostnames, no www. prefix). * @return string|null Null when no usable host is given. */ private static function host_regex( $hosts ): ?string { $escaped = array(); foreach ( is_array( $hosts ) ? $hosts : array() as $host ) { if ( is_string( $host ) && '' !== $host ) { $escaped[] = preg_quote( $host, '/' ); } } if ( array() === $escaped ) { return null; } return '^https?://(www\\.)?(' . implode( '|', array_unique( $escaped ) ) . ')(:[0-9]+)?([/?#]|$)'; } /** * One FilterExpression from a definition filter. * * @param array $filter ['field', 'value'] or ['field', 'empty' => true], optional 'not' => true. * @return array */ private static function filter_expression( array $filter ): array { $expression = array( 'fieldName' => (string) $filter['field'] ); if ( ! empty( $filter['empty'] ) ) { // Must encode as {} (an empty JSON object), not []. $expression['emptyFilter'] = new \stdClass(); } else { $expression['stringFilter'] = array( 'matchType' => 'EXACT', 'value' => (string) ( $filter['value'] ?? '' ), ); } $expression = array( 'filter' => $expression ); return empty( $filter['not'] ) ? $expression : array( 'notExpression' => $expression ); } /** * Unique, non-empty string paths, capped at MAX_PATHS. * * @param mixed $paths Raw args.paths. * @return string[] */ private static function paths( $paths ): array { if ( ! is_array( $paths ) ) { return array(); } $clean = array_filter( $paths, static fn( $path ): bool => is_string( $path ) && '' !== $path ); return array_slice( array_values( array_unique( $clean ) ), 0, self::MAX_PATHS ); } }