reports[ $report->id() ] = $report; } } /** * Summary for a site and period. * * @param Profile $site Site. * @param array $period Period with s, e, ps and pe. * @param bool $full Also build the detail the PDF and insights need. * @return array Sections keyed by name; each holds data and a status, or an 'error'. */ public function build( Profile $site, array $period, $full = false ) { $out = array(); if ( $site->wants( Profile::SECTION_SEARCH ) ) { $out['search'] = $this->section( array( $this, 'search' ), $site, $period, $full ); } if ( $site->wants( Profile::SECTION_CONVERSIONS ) ) { $out['conversions'] = $this->section( array( $this, 'conversions' ), $site, $period, $full ); } if ( $site->wants( Profile::SECTION_TRAFFIC ) && $site->traffic_metrics ) { $out['traffic'] = $this->section( array( $this, 'traffic' ), $site, $period, $full ); } if ( $site->wants( Profile::SECTION_AI ) ) { $out['ai'] = $this->section( array( $this, 'ai' ), $site, $period, $full ); } if ( $site->wants( Profile::SECTION_BUSINESS ) ) { $out['business'] = $this->section( array( $this, 'business' ), $site, $period, $full ); } if ( $full && isset( $out['search']['pages'], $out['conversions']['page_totals'] ) ) { $out['search'] = self::join_page_leads( $out['search'], $out['conversions']['page_totals'] ); } return $out; } /** * Runs one section, turning errors into a message and a reason. * * @param callable $builder Section builder. * @param Profile $site Site. * @param array $period Period. * @param bool $full Full detail. * @return array */ private function section( callable $builder, Profile $site, array $period, $full ) { try { return $builder( $site, $period, $full ); } catch ( ApiException $e ) { return array( 'error' => $e->getMessage(), 'reason' => $e->reason(), 'status' => 'error', ); } catch ( \Throwable $e ) { // A broken or removed report (see the turtlereport_reports filter) costs one section, not the whole email. return array( 'error' => __( 'An unexpected error stopped this section from loading.', 'turtlereport' ), 'reason' => ApiException::GOOGLE, 'status' => 'error', ); } } /** * Runs a registered report. * * @param string $id Report ID. * @param Profile $site Site. * @param array $params Parameters. * @return mixed * @throws ApiException When the report isn't registered or Google fails. */ private function run( $id, Profile $site, array $params ) { if ( ! isset( $this->reports[ $id ] ) ) { throw new ApiException( __( 'This part of the report isn\'t available.', 'turtlereport' ), ApiException::UNSUPPORTED ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Caught by the plugin; escaped where the message is shown. } return $this->reports[ $id ]->run( $site, array_merge( array( 'segment' => '' ), $params ) ); } /** * Converts a decoded JSON object (stdClass or array) to an array. * * @param mixed $value Value. * @return array */ private static function arr( $value ) { if ( $value instanceof \stdClass ) { $value = get_object_vars( $value ); } return is_array( $value ) ? $value : array(); } /** * Search Console totals and the biggest page movers; with $full, daily and * monthly series and page detail. * * @param Profile $site Site. * @param array $period Period. * @param bool $full Full detail. * @return array */ private function search( Profile $site, array $period, $full = false ) { $q = function ( $dimension, $s, $e ) use ( $site ) { return (array) $this->run( 'search-console', $site, array( 'dimension' => $dimension, 'start' => $s, 'end' => $e, ) ); }; $cur_rows = $q( 'date', $period['s'], $period['e'] ); $cur = self::gsc_totals( $cur_rows ); $prev = self::gsc_totals( $q( 'date', $period['ps'], $period['pe'] ) ); $pages = self::join_rows( $q( 'page', $period['s'], $period['e'] ), $q( 'page', $period['ps'], $period['pe'] ) ); $winners = array(); $losers = array(); foreach ( self::movers( $pages, 1 ) as $row ) { $winners[] = array( 'page' => $row['key'], 'clicks' => $row['clicks'], 'delta' => $row['dClicks'], ); } foreach ( self::movers( $pages, -1 ) as $row ) { $losers[] = array( 'page' => $row['key'], 'clicks' => $row['clicks'], 'delta' => $row['dClicks'], ); } $out = array( 'cur' => $cur, 'prev' => $prev, 'winners' => array_slice( $winners, 0, self::EMAIL_ROWS ), 'losers' => array_slice( $losers, 0, self::EMAIL_ROWS ), 'last_date' => self::last_date( $cur_rows ), ); $trend = array(); if ( $full ) { $t = Periods::trend( $period['e'] ); $trend = $q( 'date', $t['s'], $t['e'] ); $daily = array(); foreach ( $cur_rows as $row ) { $daily[ (string) $row['keys'][0] ] = array( 'clicks' => (float) $row['clicks'], 'impressions' => (float) $row['impressions'], ); } $monthly = array(); foreach ( $trend as $row ) { $month = substr( (string) $row['keys'][0], 0, 7 ); $monthly[ $month ] = ( isset( $monthly[ $month ] ) ? $monthly[ $month ] : 0.0 ) + (float) $row['clicks']; } $days = self::days( $period['s'], $period['e'] ); $out['daily'] = $daily; $out['monthly'] = $monthly; $out['trend'] = Periods::trend( $period['e'] ); $out['pages'] = array( 'up' => self::movers( $pages, 1, self::PAGE_ROWS ), 'down' => self::movers( $pages, -1, self::PAGE_ROWS ), ); $out['page_rows'] = count( $pages ); $out['gaining'] = count( array_filter( $pages, static function ( $r ) { return $r['dClicks'] > 0; } ) ); $out['losing'] = count( array_filter( $pages, static function ( $r ) { return $r['dClicks'] < 0; } ) ); $out['low_ctr'] = self::low_ctr( $pages, $cur['ctr'], $days ); $out['top_pages'] = self::top_by_clicks( $pages, 25 ); } $out['status'] = self::search_status( $cur_rows, $prev, $trend ); return $out; } /** * Status for a Search Console section: zero clicks is only "no data" when * the property has nothing at all, not even last period. * * @param array $cur_rows Current date rows. * @param array $prev Previous totals. * @param array $trend Six-month rows (may be empty without full detail). * @return string */ private static function search_status( array $cur_rows, array $prev, array $trend ) { if ( $cur_rows ) { return 'ok'; } return ( $prev['impressions'] > 0 || $trend ) ? 'zero' : 'no_data'; } /** * Conversion totals per group, plus a total that counts each event once. * * Groups may share an event (for example "generate_lead" in "Forms" and * "All leads"). Each group's own number includes it, but the overall * total doesn't count it twice. 'overlaps' names the shared events so the * report can say so. * * @param Profile $site Site. * @param array $period Period. * @param bool $full Full detail. * @return array */ private function conversions( Profile $site, array $period, $full = false ) { $events = $site->all_conversion_events(); $out = array( 'label' => $site->conversion_label(), 'events' => $events, 'overlaps' => $site->overlapping_events(), ); if ( ! $events ) { return array_merge( $out, array( 'cur' => 0, 'prev' => 0, 'groups' => array(), 'missing_events' => array(), 'status' => 'no_events', ) ); } $q = function ( $kind, $s, $e ) use ( $site ) { return self::arr( $this->run( 'conversions', $site, array( 'kind' => $kind, 'start' => $s, 'end' => $e, ) ) ); }; $cur = $q( 'totals', $period['s'], $period['e'] ); $prev = $q( 'totals', $period['ps'], $period['pe'] ); $seen = $q( 'seen', Periods::add_days( $period['e'], -( self::SEEN_DAYS - 1 ) ), $period['e'] ); $split = self::split( $site, $cur ); $psplit = self::split( $site, $prev ); $groups = array(); foreach ( $site->conversion_groups as $i => $group ) { $groups[] = array( 'label' => $group['label'], 'cur' => $split['groups'][ $i ], 'prev' => $psplit['groups'][ $i ], ); } $missing = array_values( array_filter( $events, static function ( $event ) use ( $seen, $cur, $prev ) { return empty( $seen[ $event ] ) && empty( $cur[ $event ] ) && empty( $prev[ $event ] ); } ) ); if ( $split['total'] > 0 ) { $status = 'ok'; } elseif ( count( $missing ) === count( $events ) ) { $status = 'no_data'; } else { $status = 'zero'; } $out = array_merge( $out, array( 'cur' => $split['total'], 'prev' => $psplit['total'], 'groups' => $groups, 'by_event' => array( 'cur' => array_map( 'intval', $cur ), 'prev' => array_map( 'intval', $prev ), ), 'missing_events' => $missing, 'status' => $status, ) ); if ( $full ) { $t = Periods::trend( $period['e'] ); $pages = $q( 'pages', $period['s'], $period['e'] ); $ppages = $q( 'pages', $period['ps'], $period['pe'] ); $totals = array(); foreach ( $pages as $path => $map ) { $totals[ $path ] = array( self::split( $site, self::arr( $map ) )['total'], 0 ); } foreach ( $ppages as $path => $map ) { $totals[ $path ] = array( isset( $totals[ $path ] ) ? $totals[ $path ][0] : 0, self::split( $site, self::arr( $map ) )['total'] ); } $top = array(); foreach ( $totals as $path => $pair ) { if ( $pair[0] > 0 && '(not set)' !== $path ) { $top[] = array( 'page' => (string) $path, 'total' => $pair[0], 'prev' => $pair[1], ); } } usort( $top, static function ( $a, $b ) { return $b['total'] <=> $a['total']; } ); $out['daily'] = array_map( 'intval', $q( 'daily', $period['s'], $period['e'] ) ); $out['monthly'] = array_map( 'intval', $q( 'monthly', $t['s'], $t['e'] ) ); $out['top_pages'] = array_slice( $top, 0, self::EMAIL_ROWS ); $out['page_totals'] = $totals; } return $out; } /** * Splits event counts into the site's groups, and totals each event once. * * Mirrors splitConversions() in the frontend reporting metric model. * * @param Profile $site Site. * @param array $counts Event name => count. * @return array{groups:int[],total:int} */ public static function split( Profile $site, array $counts ) { $groups = array(); foreach ( $site->conversion_groups as $i => $group ) { $n = 0; foreach ( array_unique( $group['events'] ) as $event ) { $n += isset( $counts[ $event ] ) ? (int) $counts[ $event ] : 0; } $groups[ $i ] = $n; } $total = 0; foreach ( $site->all_conversion_events() as $event ) { $total += isset( $counts[ $event ] ) ? (int) $counts[ $event ] : 0; } return array( 'groups' => $groups, 'total' => $total, ); } /** * Chosen GA4 traffic metrics. * * @param Profile $site Site. * @param array $period Period. * @return array */ private function traffic( Profile $site, array $period ) { $data = self::arr( $this->run( 'traffic', $site, array( 'start' => $period['s'], 'end' => $period['e'], 'prev_start' => $period['ps'], 'prev_end' => $period['pe'], ) ) ); $cur = self::arr( isset( $data['cur'] ) ? $data['cur'] : array() ); $prev = self::arr( isset( $data['prev'] ) ? $data['prev'] : array() ); $defs = TrafficMetrics::all(); $metrics = array(); $any = false; foreach ( $site->traffic_metrics as $key ) { if ( isset( $defs[ $key ] ) ) { $c = isset( $cur[ $key ] ) ? (float) $cur[ $key ] : 0.0; $p = isset( $prev[ $key ] ) ? (float) $prev[ $key ] : 0.0; $any = $any || $c > 0 || $p > 0; $metrics[] = array_merge( $defs[ $key ], array( 'key' => $key, 'cur' => $c, 'prev' => $p, ) ); } } return array( 'scope' => $site->traffic_scope, 'metrics' => $metrics, 'status' => $any ? 'ok' : 'no_data', ); } /** * AI assistant sessions and conversions. * * @param Profile $site Site. * @param array $period Period. * @param bool $full Full detail. * @return array */ private function ai( Profile $site, array $period, $full = false ) { $data = self::arr( $this->run( 'ai-traffic', $site, array( 'start' => $period['s'], 'end' => $period['e'], 'prev_start' => $period['ps'], 'prev_end' => $period['pe'], ) ) ); $events = $site->all_conversion_events(); $sum = static function ( $map ) use ( $events ) { $map = self::arr( $map ); $n = 0; foreach ( $events as $event ) { $n += isset( $map[ $event ] ) ? (int) $map[ $event ] : 0; } return $n; }; $cur = self::arr( isset( $data['cur'] ) ? $data['cur'] : array() ); $prev = self::arr( isset( $data['prev'] ) ? $data['prev'] : array() ); $out = array( 'cur' => isset( $cur['sessions'] ) ? (int) $cur['sessions'] : 0, 'prev' => isset( $prev['sessions'] ) ? (int) $prev['sessions'] : 0, 'conv' => $sum( isset( $data['ev'] ) ? $data['ev'] : array() ), 'conv_prev' => $sum( isset( $data['pev'] ) ? $data['pev'] : array() ), ); $out['status'] = $out['cur'] > 0 ? 'ok' : 'zero'; if ( $full ) { $leads = self::arr( isset( $data['page_leads'] ) ? $data['page_leads'] : array() ); $pages = array(); foreach ( array_slice( isset( $data['pages'] ) ? (array) $data['pages'] : array(), 0, 3 ) as $row ) { $row = self::arr( $row ); $path = strtolower( rtrim( (string) $row['p'], '/' ) ); $path = '' === $path ? '/' : $path; $pages[] = array( 'page' => (string) $row['p'], 'sessions' => (int) $row['sessions'], 'leads' => $sum( isset( $leads[ $path ] ) ? $leads[ $path ] : array() ), ); } $out['engaged'] = isset( $cur['engaged'] ) ? (int) $cur['engaged'] : 0; $out['engaged_prev'] = isset( $prev['engaged'] ) ? (int) $prev['engaged'] : 0; $out['all'] = isset( $data['all'] ) ? (int) $data['all'] : 0; $out['mode'] = isset( $data['mode'] ) ? (string) $data['mode'] : 'channel'; $out['pages'] = $pages; $out['sources'] = array_slice( isset( $data['sources'] ) ? array_map( array( __CLASS__, 'arr' ), (array) $data['sources'] ) : array(), 0, 5 ); } return $out; } /** * Business Profile totals; with $full, daily and monthly series. * * @param Profile $site Site. * @param array $period Period. * @param bool $full Full detail. * @return array */ private function business( Profile $site, array $period, $full = false ) { $start = min( $period['s'], $period['ps'] ); if ( $full ) { $start = min( $start, Periods::trend( $period['e'] )['s'] ); } $daily = self::arr( $this->run( 'business-profile', $site, array( 'start' => $start, 'end' => $period['e'], ) ) ); $sum = static function ( $s, $e ) use ( $daily ) { $out = array( 'calls' => 0, 'web' => 0, 'dirs' => 0, 'views' => 0, ); foreach ( $daily as $date => $row ) { $row = self::arr( $row ); if ( $date >= $s && $date <= $e ) { foreach ( $out as $k => $v ) { $out[ $k ] = $v + ( isset( $row[ $k ] ) ? (int) $row[ $k ] : 0 ); } } } return $out; }; $in_period = array_filter( array_keys( $daily ), static function ( $date ) use ( $period ) { return $date >= $period['s'] && $date <= $period['e']; } ); $out = array( 'cur' => $sum( $period['s'], $period['e'] ), 'prev' => $sum( $period['ps'], $period['pe'] ), 'last_date' => $in_period ? (string) max( $in_period ) : '', ); $out['status'] = $in_period ? ( array_sum( $out['cur'] ) > 0 ? 'ok' : 'zero' ) : ( $daily ? 'zero' : 'no_data' ); if ( $full ) { $days = array(); $monthly = array(); $t = Periods::trend( $period['e'] ); foreach ( $daily as $date => $row ) { $row = self::arr( $row ); if ( $date >= $period['s'] && $date <= $period['e'] ) { $days[ $date ] = array( 'calls' => isset( $row['calls'] ) ? (int) $row['calls'] : 0, 'web' => isset( $row['web'] ) ? (int) $row['web'] : 0, ); } if ( $date >= $t['s'] && $date <= $t['e'] ) { $month = substr( (string) $date, 0, 7 ); $monthly[ $month ] = ( isset( $monthly[ $month ] ) ? $monthly[ $month ] : 0 ) + ( isset( $row['calls'] ) ? (int) $row['calls'] : 0 ); } } $out['daily'] = $days; $out['monthly'] = $monthly; } return $out; } /** * Totals from Search Console date rows. * * @param array $rows Rows. * @return array{clicks:float,impressions:float,ctr:float,position:float} */ public static function gsc_totals( array $rows ) { $clicks = 0.0; $impr = 0.0; $pos = 0.0; foreach ( $rows as $row ) { $clicks += (float) $row['clicks']; $impr += (float) $row['impressions']; $pos += (float) $row['position'] * (float) $row['impressions']; } return array( 'clicks' => $clicks, 'impressions' => $impr, 'ctr' => $impr ? $clicks / $impr : 0.0, 'position' => $impr ? $pos / $impr : 0.0, ); } /** * Joins this period's and last period's rows by key (joinRows() in the frontend reporting selectors). * * @param array $cur Current rows. * @param array $prev Previous rows. * @return array[] Rows with key, clicks, impressions, ctr, position, pClicks, pImpr, pPos, inCur, inPrev, dClicks, dPos. */ public static function join_rows( array $cur, array $prev ) { $m = array(); foreach ( $cur as $x ) { $k = (string) $x['keys'][0]; $m[ $k ] = array( 'key' => $k, 'clicks' => (float) $x['clicks'], 'impressions' => (float) ( isset( $x['impressions'] ) ? $x['impressions'] : 0 ), 'ctr' => (float) ( isset( $x['ctr'] ) ? $x['ctr'] : 0 ), 'position' => (float) ( isset( $x['position'] ) ? $x['position'] : 0 ), 'pClicks' => 0.0, 'pImpr' => 0.0, 'pPos' => null, 'inCur' => true, 'inPrev' => false, ); } foreach ( $prev as $x ) { $k = (string) $x['keys'][0]; if ( ! isset( $m[ $k ] ) ) { $m[ $k ] = array( 'key' => $k, 'clicks' => 0.0, 'impressions' => 0.0, 'ctr' => 0.0, 'position' => null, 'inCur' => false, ); } $m[ $k ]['pClicks'] = (float) $x['clicks']; $m[ $k ]['pImpr'] = (float) ( isset( $x['impressions'] ) ? $x['impressions'] : 0 ); $m[ $k ]['pPos'] = (float) ( isset( $x['position'] ) ? $x['position'] : 0 ); $m[ $k ]['inPrev'] = true; } foreach ( $m as $k => $r ) { $m[ $k ]['dClicks'] = $r['clicks'] - $r['pClicks']; $m[ $k ]['dPos'] = $r['inCur'] && $r['inPrev'] ? $r['position'] - $r['pPos'] : null; } return array_values( $m ); } /** * Rows that gained ($dir 1) or lost ($dir -1) the most clicks. * * @param array[] $rows Joined rows. * @param int $dir 1 or -1. * @param int $limit Most rows. * @return array[] */ private static function movers( array $rows, $dir, $limit = 10 ) { $rows = array_values( array_filter( $rows, static function ( $r ) use ( $dir ) { return $dir > 0 ? $r['dClicks'] > 0 : $r['dClicks'] < 0; } ) ); usort( $rows, static function ( $a, $b ) use ( $dir ) { return $dir > 0 ? $b['dClicks'] <=> $a['dClicks'] : $a['dClicks'] <=> $b['dClicks']; } ); return array_slice( $rows, 0, $limit ); } /** * Rows with the most clicks this period. * * @param array[] $rows Joined rows. * @param int $limit Most rows. * @return array[] */ private static function top_by_clicks( array $rows, $limit ) { $rows = array_values( array_filter( $rows, static function ( $r ) { return $r['inCur'] && $r['clicks'] > 0; } ) ); usort( $rows, static function ( $a, $b ) { return $b['clicks'] <=> $a['clicks']; } ); return array_slice( $rows, 0, $limit ); } /** * Pages on page 1 of Google with a click-through rate well below the site's own. * * @param array[] $pages Joined page rows. * @param float $site_ctr The site's CTR this period. * @param int $days Days in the period. * @return array[] */ private static function low_ctr( array $pages, $site_ctr, $days ) { $min = max( 100, self::min_impressions( $days ) * 10 ); $rows = array_values( array_filter( $pages, static function ( $r ) use ( $min, $site_ctr ) { return $r['inCur'] && $r['impressions'] >= $min && $r['position'] > 0 && $r['position'] <= 10 && $site_ctr > 0 && $r['ctr'] < $site_ctr * 0.5; } ) ); usort( $rows, static function ( $a, $b ) { return $b['impressions'] <=> $a['impressions']; } ); return array_map( static function ( $r ) { return array_intersect_key( $r, array_flip( array( 'key', 'clicks', 'impressions', 'ctr', 'position' ) ) ); }, array_slice( $rows, 0, 3 ) ); } /** * Impressions a keyword needs to count as a mover (minImpr() in the frontend reporting selectors). * * @param int $days Days in the period. * @return int */ public static function min_impressions( $days ) { return (int) max( 10, round( 20 * (int) $days / 28 ) ); } /** * Adds conversion counts to the page tables, matched by landing path. * * @param array $search Search section. * @param array $totals Path => [current, previous] conversions. * @return array */ private static function join_page_leads( array $search, array $totals ) { $lookup = static function ( $url ) use ( $totals ) { $path = wp_parse_url( (string) $url, PHP_URL_PATH ); $path = strtolower( is_string( $path ) && '' !== $path ? $path : (string) $url ); if ( strlen( $path ) > 1 && '/' === substr( $path, -1 ) ) { $path = substr( $path, 0, -1 ); } return isset( $totals[ $path ] ) ? $totals[ $path ] : array( 0, 0 ); }; foreach ( array( 'up', 'down' ) as $dir ) { foreach ( $search['pages'][ $dir ] as $i => $row ) { $pair = $lookup( $row['key'] ); $search['pages'][ $dir ][ $i ]['leads'] = $pair[0]; $search['pages'][ $dir ][ $i ]['p_leads'] = $pair[1]; } } foreach ( $search['top_pages'] as $i => $row ) { $search['top_pages'][ $i ]['leads'] = $lookup( $row['key'] )[0]; } $search['has_leads'] = true; return $search; } /** * Latest date in Search Console date rows. * * @param array $rows Rows. * @return string Y-m-d, or '' when there are none. */ private static function last_date( array $rows ) { $last = ''; foreach ( $rows as $row ) { $date = (string) $row['keys'][0]; $last = $date > $last ? $date : $last; } return $last; } /** * Days in a range, inclusive. * * @param string $s Start. * @param string $e End. * @return int */ public static function days( $s, $e ) { $a = new \DateTimeImmutable( $s . ' 00:00:00', new \DateTimeZone( 'UTC' ) ); $b = new \DateTimeImmutable( $e . ' 00:00:00', new \DateTimeZone( 'UTC' ) ); return (int) $a->diff( $b )->days + 1; } }