← All changes
|
includes/integrations/class-google-search-console-client.php
+321
-88
1.0.1
→
2.8.0
View file →
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | <?php |
| 2 | + | |
| 2 | 3 | /** |
| 3 | 4 | * Google Search Console Client Class |
| 4 | 5 | * |
| 5 | 6 | * Handles communication with Google Search Console API for search performance |
| @@ -75,12 +76,12 @@ | ||
| 75 | 76 | * @throws \Exception If API request fails |
| 76 | 77 | */ |
| 77 | 78 | public function list_sites(): array { |
| 78 | 79 | $endpoint = '/sites'; |
| 79 | - $params = ['key' => $this->api_key]; | |
| 80 | - | |
| 80 | + // The API key (when no OAuth token) is sent via the x-goog-api-key | |
| 81 | + // header by the base client — not the query string. | |
| 81 | 82 | $full_url = self::API_BASE_URL . $endpoint; |
| 82 | - return $this->make_request($full_url, $params, 'GET'); | |
| 83 | + return $this->make_request($full_url, [], 'GET'); | |
| 83 | 84 | } |
| 84 | 85 | |
| 85 | 86 | /** |
| 86 | 87 | * Verify site ownership in Search Console |
| @@ -125,28 +126,148 @@ | ||
| 125 | 126 | * @param int $row_limit Maximum number of rows to return |
| 126 | 127 | * @return array Search performance data |
| 127 | 128 | * @throws \Exception If API request fails |
| 128 | 129 | */ |
| 129 | - public function get_search_performance(string $site_url, string $date_range = '30d', array $dimensions = ['query'], int $row_limit = 100): array { | |
| 130 | - // Calculate date range | |
| 131 | - $end_date = gmdate('Y-m-d'); | |
| 132 | - $days = (int) str_replace('d', '', $date_range); | |
| 133 | - $start_date = gmdate('Y-m-d', strtotime("-{$days} days")); | |
| 130 | + public function get_search_performance(string $site_url, string $date_range = '30d', array $dimensions = ['query'], int $row_limit = 1000): array { | |
| 131 | + // GSC data for the current day is never complete; use yesterday as the end date | |
| 132 | + // so the N-day window matches exactly what the GSC dashboard shows. | |
| 133 | + $days = (int) str_replace('d', '', $date_range); | |
| 134 | + $end_date = gmdate('Y-m-d', strtotime('-2 days')); | |
| 135 | + $start_date = gmdate('Y-m-d', strtotime('-' . ($days - 1) . ' days', strtotime($end_date))); | |
| 134 | 136 | |
| 135 | - $endpoint = '/sites/' . urlencode($site_url) . '/searchAnalytics/query'; | |
| 137 | + $endpoint = '/sites/' . rawurlencode($site_url) . '/searchAnalytics/query'; | |
| 136 | 138 | |
| 137 | 139 | $request_body = [ |
| 138 | 140 | 'startDate' => $start_date, |
| 139 | 141 | 'endDate' => $end_date, |
| 140 | 142 | 'dimensions' => $dimensions, |
| 141 | - 'rowLimit' => $row_limit | |
| 143 | + 'rowLimit' => $row_limit, | |
| 144 | + 'dataState' => 'all', | |
| 142 | 145 | ]; |
| 143 | 146 | |
| 144 | - $full_url = self::API_BASE_URL . $endpoint . '?key=' . $this->api_key; | |
| 147 | + $full_url = self::API_BASE_URL . $endpoint; | |
| 145 | 148 | return $this->make_request($full_url, $request_body, 'POST'); |
| 146 | 149 | } |
| 147 | 150 | |
| 148 | 151 | /** |
| 152 | + * Get aggregated search totals (clicks, impressions, ctr, position) | |
| 153 | + * | |
| 154 | + * @param string $site_url Site URL to get data for | |
| 155 | + * @param string $date_range Date range ('7d', '30d', '90d') | |
| 156 | + * @return array Aggregated totals | |
| 157 | + * @throws \Exception If API request fails | |
| 158 | + */ | |
| 159 | + public function get_search_totals(string $site_url, string $date_range = '30d'): array { | |
| 160 | + // GSC data has a 2-day delay; use D-2 as end_date to match the GSC dashboard. | |
| 161 | + $days = (int) str_replace('d', '', $date_range); | |
| 162 | + $end_date = gmdate('Y-m-d', strtotime('-2 days')); | |
| 163 | + $start_date = gmdate('Y-m-d', strtotime('-' . ($days - 1) . ' days', strtotime($end_date))); | |
| 164 | + | |
| 165 | + $endpoint = '/sites/' . rawurlencode($site_url) . '/searchAnalytics/query'; | |
| 166 | + | |
| 167 | + // diverse from get_search_performance: no dimensions, just totals | |
| 168 | + $request_body = [ | |
| 169 | + 'startDate' => $start_date, | |
| 170 | + 'endDate' => $end_date, | |
| 171 | + 'dimensions' => [], // Empty dimensions for aggregation | |
| 172 | + 'rowLimit' => 1, // We only need the totals, but API might require at least 1 | |
| 173 | + 'dataState' => 'all', | |
| 174 | + ]; | |
| 175 | + | |
| 176 | + $full_url = self::API_BASE_URL . $endpoint; | |
| 177 | + $response = $this->make_request($full_url, $request_body, 'POST'); | |
| 178 | + | |
| 179 | + // The API returns rows even if we don't ask for dimensions? | |
| 180 | + // Actually, without dimensions, it returns one row with aggregated values if successful. | |
| 181 | + // Or sometimes it returns just the aggregates if available. | |
| 182 | + // Let's inspect the response format for GSC API v3. | |
| 183 | + // "If no dimensions are requested, the response will contain a single row with the aggregated values." | |
| 184 | + | |
| 185 | + if (!empty($response['rows'])) { | |
| 186 | + $row = $response['rows'][0]; | |
| 187 | + return [ | |
| 188 | + 'clicks' => $row['clicks'] ?? 0, | |
| 189 | + 'impressions' => $row['impressions'] ?? 0, | |
| 190 | + 'ctr' => round(($row['ctr'] ?? 0) * 100, 2), | |
| 191 | + 'position' => round($row['position'] ?? 0, 1) | |
| 192 | + ]; | |
| 193 | + } | |
| 194 | + | |
| 195 | + return [ | |
| 196 | + 'clicks' => 0, | |
| 197 | + 'impressions' => 0, | |
| 198 | + 'ctr' => 0, | |
| 199 | + 'position' => 0 | |
| 200 | + ]; | |
| 201 | + } | |
| 202 | + | |
| 203 | + /** | |
| 204 | + * Get aggregated search totals for explicit start/end dates | |
| 205 | + * | |
| 206 | + * @param string $site_url Site URL to get data for | |
| 207 | + * @param string $start_date Start date (Y-m-d) | |
| 208 | + * @param string $end_date End date (Y-m-d) | |
| 209 | + * @return array Aggregated totals | |
| 210 | + * @throws \Exception If API request fails | |
| 211 | + */ | |
| 212 | + public function get_search_totals_by_dates(string $site_url, string $start_date, string $end_date): array { | |
| 213 | + $endpoint = '/sites/' . rawurlencode($site_url) . '/searchAnalytics/query'; | |
| 214 | + | |
| 215 | + $request_body = [ | |
| 216 | + 'startDate' => $start_date, | |
| 217 | + 'endDate' => $end_date, | |
| 218 | + 'dimensions' => [], | |
| 219 | + 'rowLimit' => 1, | |
| 220 | + 'dataState' => 'all', | |
| 221 | + ]; | |
| 222 | + | |
| 223 | + $full_url = self::API_BASE_URL . $endpoint; | |
| 224 | + $response = $this->make_request($full_url, $request_body, 'POST'); | |
| 225 | + | |
| 226 | + if (!empty($response['rows'])) { | |
| 227 | + $row = $response['rows'][0]; | |
| 228 | + return [ | |
| 229 | + 'clicks' => $row['clicks'] ?? 0, | |
| 230 | + 'impressions' => $row['impressions'] ?? 0, | |
| 231 | + 'ctr' => round(($row['ctr'] ?? 0) * 100, 2), | |
| 232 | + 'position' => round($row['position'] ?? 0, 1), | |
| 233 | + ]; | |
| 234 | + } | |
| 235 | + | |
| 236 | + return ['clicks' => 0, 'impressions' => 0, 'ctr' => 0, 'position' => 0]; | |
| 237 | + } | |
| 238 | + | |
| 239 | + /** | |
| 240 | + * Get query-level search performance for explicit start/end dates | |
| 241 | + * | |
| 242 | + * @param string $site_url Site URL to get data for | |
| 243 | + * @param string $start_date Start date (Y-m-d) | |
| 244 | + * @param string $end_date End date (Y-m-d) | |
| 245 | + * @param int $row_limit Maximum rows to return | |
| 246 | + * @return array Raw rows from GSC API | |
| 247 | + * @throws \Exception If API request fails | |
| 248 | + */ | |
| 249 | + public function get_search_performance_by_dates(string $site_url, string $start_date, string $end_date, int $row_limit = 500, array $dimensions = ['query']): array { | |
| 250 | + $endpoint = '/sites/' . rawurlencode($site_url) . '/searchAnalytics/query'; | |
| 251 | + | |
| 252 | + $request_body = [ | |
| 253 | + 'startDate' => $start_date, | |
| 254 | + 'endDate' => $end_date, | |
| 255 | + 'dimensions' => $dimensions, | |
| 256 | + 'rowLimit' => $row_limit, | |
| 257 | + // 'all' includes both finalised data and fresh (still-processing) | |
| 258 | + // data — matches what the Search Console web UI displays, so the | |
| 259 | + // last 2-4 days aren't missing. | |
| 260 | + 'dataState' => 'all', | |
| 261 | + ]; | |
| 262 | + | |
| 263 | + $full_url = self::API_BASE_URL . $endpoint; | |
| 264 | + $response = $this->make_request($full_url, $request_body, 'POST'); | |
| 265 | + | |
| 266 | + return $response['rows'] ?? []; | |
| 267 | + } | |
| 268 | + | |
| 269 | + /** | |
| 149 | 270 | * Get top search queries |
| 150 | 271 | * |
| 151 | 272 | * @param string $site_url Site URL to get data for |
| 152 | 273 | * @param int $limit Number of queries to retrieve |
| @@ -187,41 +308,8 @@ | ||
| 187 | 308 | } |
| 188 | 309 | } |
| 189 | 310 | |
| 190 | 311 | /** |
| 191 | - * Get page performance data for SEO analytics | |
| 192 | - * | |
| 193 | - * @param string $site_url Site URL to get data for | |
| 194 | - * @param string $date_range Date range for data | |
| 195 | - * @param int $limit Number of pages to retrieve | |
| 196 | - * @return array Page performance data | |
| 197 | - * @throws \Exception If API request fails | |
| 198 | - */ | |
| 199 | - public function get_page_performance(string $site_url, string $date_range = '30d', int $limit = 25): array { | |
| 200 | - $result = $this->get_search_performance($site_url, $date_range, ['page'], $limit); | |
| 201 | - | |
| 202 | - $pages = []; | |
| 203 | - $rows = $result['rows'] ?? []; | |
| 204 | - | |
| 205 | - foreach ($rows as $row) { | |
| 206 | - $pages[] = [ | |
| 207 | - 'page' => $row['keys'][0] ?? '', | |
| 208 | - 'clicks' => $row['clicks'] ?? 0, | |
| 209 | - 'impressions' => $row['impressions'] ?? 0, | |
| 210 | - 'ctr' => round(($row['ctr'] ?? 0) * 100, 2), // Convert to percentage | |
| 211 | - 'position' => round($row['position'] ?? 0, 1) | |
| 212 | - ]; | |
| 213 | - } | |
| 214 | - | |
| 215 | - return [ | |
| 216 | - 'pages' => $pages, | |
| 217 | - 'site_url' => $site_url, | |
| 218 | - 'date_range' => $date_range, | |
| 219 | - 'total_pages' => count($pages) | |
| 220 | - ]; | |
| 221 | - } | |
| 222 | - | |
| 223 | - /** | |
| 224 | 312 | * Get device performance breakdown for mobile SEO insights |
| 225 | 313 | * |
| 226 | 314 | * @param string $site_url Site URL to get data for |
| 227 | 315 | * @param string $date_range Date range for data |
| @@ -282,51 +370,8 @@ | ||
| 282 | 370 | ]; |
| 283 | 371 | } |
| 284 | 372 | |
| 285 | 373 | /** |
| 286 | - * Get site indexing status and coverage data | |
| 287 | - * | |
| 288 | - * @param string $site_url Site URL to check | |
| 289 | - * @return array Indexing status and coverage data | |
| 290 | - * @throws \Exception If API request fails | |
| 291 | - */ | |
| 292 | - public function get_indexing_status(string $site_url): array { | |
| 293 | - try { | |
| 294 | - // Get overall search performance to estimate indexed pages | |
| 295 | - $performance = $this->get_search_performance($site_url, '30d', ['page'], 1000); | |
| 296 | - $indexed_pages = count($performance['rows'] ?? []); | |
| 297 | - | |
| 298 | - // Get basic site info | |
| 299 | - $sites = $this->list_sites(); | |
| 300 | - $site_info = null; | |
| 301 | - | |
| 302 | - foreach ($sites['siteEntry'] ?? [] as $site) { | |
| 303 | - if ($site['siteUrl'] === $site_url) { | |
| 304 | - $site_info = $site; | |
| 305 | - break; | |
| 306 | - } | |
| 307 | - } | |
| 308 | - | |
| 309 | - return [ | |
| 310 | - 'site_url' => $site_url, | |
| 311 | - 'is_verified' => !is_null($site_info), | |
| 312 | - 'indexed_pages_estimate' => $indexed_pages, | |
| 313 | - 'permission_level' => $site_info['permissionLevel'] ?? 'none', | |
| 314 | - 'last_updated' => gmdate('Y-m-d H:i:s') | |
| 315 | - ]; | |
| 316 | - } catch (\Exception $e) { | |
| 317 | - return [ | |
| 318 | - 'site_url' => $site_url, | |
| 319 | - 'is_verified' => false, | |
| 320 | - 'indexed_pages_estimate' => 0, | |
| 321 | - 'permission_level' => 'none', | |
| 322 | - 'error' => $e->getMessage(), | |
| 323 | - 'last_updated' => gmdate('Y-m-d H:i:s') | |
| 324 | - ]; | |
| 325 | - } | |
| 326 | - } | |
| 327 | - | |
| 328 | - /** | |
| 329 | 374 | * Get keyword opportunities for SEO insights |
| 330 | 375 | * Identifies queries with high impressions but low CTR or position |
| 331 | 376 | * |
| 332 | 377 | * @param string $site_url Site URL to analyze |
| @@ -384,9 +429,9 @@ | ||
| 384 | 429 | } |
| 385 | 430 | } |
| 386 | 431 | |
| 387 | 432 | // Sort by opportunity score (highest first) |
| 388 | - usort($opportunities, function($a, $b) { | |
| 433 | + usort($opportunities, function ($a, $b) { | |
| 389 | 434 | return $b['opportunity_score'] <=> $a['opportunity_score']; |
| 390 | 435 | }); |
| 391 | 436 | |
| 392 | 437 | return [ |
| @@ -393,8 +438,196 @@ | ||
| 393 | 438 | 'opportunities' => array_slice($opportunities, 0, 50), // Top 50 opportunities |
| 394 | 439 | 'site_url' => $site_url, |
| 395 | 440 | 'date_range' => $date_range, |
| 396 | 441 | 'total_opportunities' => count($opportunities) |
| 442 | + ]; | |
| 443 | + } | |
| 444 | + | |
| 445 | + /** | |
| 446 | + * Return branded vs non-branded click/impression split that matches the GSC platform. | |
| 447 | + * | |
| 448 | + * Uses two server-side aggregate calls per period (empty dimensions + dimensionFilterGroups) | |
| 449 | + * so the totals are exact — not limited by the 1 000-row query cap: | |
| 450 | + * | |
| 451 | + * • Call A: no filter → real site total clicks | |
| 452 | + * • Call B: query contains brand → branded clicks | |
| 453 | + * • Non-branded = A − B | |
| 454 | + * | |
| 455 | + * Also fetches the equivalent previous period so the frontend can render trend arrows. | |
| 456 | + * | |
| 457 | + * @param string $site_url Registered GSC property URL | |
| 458 | + * @param string $date_range '7d' | '30d' | '90d' | |
| 459 | + * @param string $brand_name Comma-separated brand keywords. Auto-derived from domain when empty. | |
| 460 | + * @return array { | |
| 461 | + * branded, non_branded, previous: { branded, non_branded }, | |
| 462 | + * brand_terms, total_clicks, site_url, date_range | |
| 463 | + * } | |
| 464 | + */ | |
| 465 | + public function get_branded_performance(string $site_url, string $date_range = '30d', string $brand_name = ''): array { | |
| 466 | + $days = max(1, (int) str_replace('d', '', $date_range)); | |
| 467 | + $end = gmdate('Y-m-d', strtotime('-2 days')); | |
| 468 | + $start = gmdate('Y-m-d', strtotime('-' . ($days - 1) . ' days', strtotime($end))); | |
| 469 | + | |
| 470 | + $prev_end = gmdate('Y-m-d', strtotime('-1 day', strtotime($start))); | |
| 471 | + $prev_start = gmdate('Y-m-d', strtotime('-' . ($days - 1) . ' days', strtotime($prev_end))); | |
| 472 | + | |
| 473 | + // Auto-derive brand from domain when not provided. | |
| 474 | + // Handles both URL-prefix (https://example.com) and domain (sc-domain:example.com) formats. | |
| 475 | + if (empty($brand_name)) { | |
| 476 | + $stripped = preg_replace('#^sc-domain:#i', '', $site_url); | |
| 477 | + $host = wp_parse_url($stripped, PHP_URL_HOST) ?? wp_parse_url('https://' . $stripped, PHP_URL_HOST) ?? $stripped; | |
| 478 | + $host = preg_replace('/^www\./i', '', (string) $host); | |
| 479 | + $brand_name = strtolower(explode('.', $host)[0]); | |
| 480 | + } | |
| 481 | + $terms = array_values(array_filter(array_map('trim', explode(',', strtolower($brand_name))))); | |
| 482 | + | |
| 483 | + // For hyphenated brands (e.g. "essential-blocks") also match the space variant | |
| 484 | + // ("essential blocks") since users type both forms in Google searches. | |
| 485 | + $extra = []; | |
| 486 | + foreach ($terms as $t) { | |
| 487 | + if (str_contains($t, '-')) { | |
| 488 | + $spaced = str_replace('-', ' ', $t); | |
| 489 | + if (!in_array($spaced, $terms, true)) { | |
| 490 | + $extra[] = $spaced; | |
| 491 | + } | |
| 492 | + } | |
| 493 | + } | |
| 494 | + $terms = array_values(array_merge($terms, $extra)); | |
| 495 | + | |
| 496 | + $split_cur = $this->gsc_split_by_brand($site_url, $start, $end, $terms); | |
| 497 | + $split_prev = $this->gsc_split_by_brand($site_url, $prev_start, $prev_end, $terms); | |
| 498 | + | |
| 499 | + return [ | |
| 500 | + 'branded' => $split_cur['branded'], | |
| 501 | + 'non_branded' => $split_cur['non_branded'], | |
| 502 | + 'previous' => [ | |
| 503 | + 'branded' => $split_prev['branded'], | |
| 504 | + 'non_branded' => $split_prev['non_branded'], | |
| 505 | + ], | |
| 506 | + 'brand_terms' => $terms, | |
| 507 | + 'total_clicks' => $split_cur['total_clicks'], | |
| 508 | + 'site_url' => $site_url, | |
| 509 | + 'date_range' => $date_range, | |
| 510 | + ]; | |
| 511 | + } | |
| 512 | + | |
| 513 | + /** | |
| 514 | + * Fetch all web query rows for a date window and split into branded / non-branded | |
| 515 | + * using a single API call. Both totals come from the same data set so the | |
| 516 | + * percentages always add up to 100 %. | |
| 517 | + * | |
| 518 | + * @param string $site_url GSC property URL | |
| 519 | + * @param string $start Start date (Y-m-d) | |
| 520 | + * @param string $end End date (Y-m-d) | |
| 521 | + * @param string[] $brand_terms Brand keywords to match (substring, case-insensitive) | |
| 522 | + * @return array { branded: {...}, non_branded: {...}, total_clicks: int } | |
| 523 | + */ | |
| 524 | + private function gsc_split_by_brand(string $site_url, string $start, string $end, array $brand_terms): array { | |
| 525 | + $url = self::API_BASE_URL . '/sites/' . rawurlencode($site_url) . '/searchAnalytics/query'; | |
| 526 | + | |
| 527 | + $page_size = 25000; | |
| 528 | + $start_row = 0; | |
| 529 | + $total_clicks = 0; | |
| 530 | + $branded_clicks = 0; | |
| 531 | + $total_impr = 0; | |
| 532 | + $branded_impr = 0; | |
| 533 | + | |
| 534 | + // Safety cap so a runaway query can never loop unbounded. With a 25k | |
| 535 | + // page size this stops after ~100k rows (4 pages), which is far beyond | |
| 536 | + // the query volume of any real site for a single date window. | |
| 537 | + $max_pages = 4; | |
| 538 | + $pages_fetched = 0; | |
| 539 | + | |
| 540 | + do { | |
| 541 | + $response = $this->make_request($url, [ | |
| 542 | + 'startDate' => $start, | |
| 543 | + 'endDate' => $end, | |
| 544 | + 'type' => 'web', | |
| 545 | + 'dimensions' => ['query'], | |
| 546 | + 'rowLimit' => $page_size, | |
| 547 | + 'startRow' => $start_row, | |
| 548 | + 'dataState' => 'all', | |
| 549 | + ], 'POST'); | |
| 550 | + | |
| 551 | + $rows = $response['rows'] ?? []; | |
| 552 | + foreach ($rows as $row) { | |
| 553 | + $query = strtolower($row['keys'][0] ?? ''); | |
| 554 | + $clicks = (int) ($row['clicks'] ?? 0); | |
| 555 | + $impr = (int) ($row['impressions'] ?? 0); | |
| 556 | + | |
| 557 | + $total_clicks += $clicks; | |
| 558 | + $total_impr += $impr; | |
| 559 | + | |
| 560 | + foreach ($brand_terms as $term) { | |
| 561 | + if (str_contains($query, $term)) { | |
| 562 | + $branded_clicks += $clicks; | |
| 563 | + $branded_impr += $impr; | |
| 564 | + break; | |
| 565 | + } | |
| 566 | + } | |
| 567 | + } | |
| 568 | + | |
| 569 | + $fetched = count($rows); | |
| 570 | + $start_row += $fetched; | |
| 571 | + $pages_fetched++; | |
| 572 | + } while ($fetched === $page_size && $pages_fetched < $max_pages); | |
| 573 | + | |
| 574 | + $non_branded_clicks = max(0, $total_clicks - $branded_clicks); | |
| 575 | + $non_branded_impr = max(0, $total_impr - $branded_impr); | |
| 576 | + | |
| 577 | + return [ | |
| 578 | + 'branded' => [ | |
| 579 | + 'clicks' => $branded_clicks, | |
| 580 | + 'impressions' => $branded_impr, | |
| 581 | + 'percentage' => $total_clicks > 0 ? round($branded_clicks / $total_clicks * 100) : 0, | |
| 582 | + ], | |
| 583 | + 'non_branded' => [ | |
| 584 | + 'clicks' => $non_branded_clicks, | |
| 585 | + 'impressions' => $non_branded_impr, | |
| 586 | + 'percentage' => $total_clicks > 0 ? round($non_branded_clicks / $total_clicks * 100) : 0, | |
| 587 | + ], | |
| 588 | + 'total_clicks' => $total_clicks, | |
| 589 | + ]; | |
| 590 | + } | |
| 591 | + | |
| 592 | + /** | |
| 593 | + * Get top countries by clicks from Search Console. | |
| 594 | + * | |
| 595 | + * Queries with the `country` dimension and returns rows sorted by clicks | |
| 596 | + * descending, each enriched with a percentage share of the total clicks. | |
| 597 | + * | |
| 598 | + * @param string $site_url Site URL to query | |
| 599 | + * @param string $date_range Date range ('7d', '30d', '90d') | |
| 600 | + * @param int $row_limit Maximum countries to return (default 10) | |
| 601 | + * @return array { countries: array, total_clicks: int, site_url: string, date_range: string } | |
| 602 | + */ | |
| 603 | + public function get_country_performance(string $site_url, string $date_range = '30d', int $row_limit = 10): array { | |
| 604 | + $result = $this->get_search_performance($site_url, $date_range, ['country'], $row_limit); | |
| 605 | + $rows = $result['rows'] ?? []; | |
| 606 | + | |
| 607 | + $total_clicks = 0; | |
| 608 | + foreach ($rows as $row) { | |
| 609 | + $total_clicks += (int) ($row['clicks'] ?? 0); | |
| 610 | + } | |
| 611 | + | |
| 612 | + $countries = []; | |
| 613 | + foreach ($rows as $row) { | |
| 614 | + $clicks = (int) ($row['clicks'] ?? 0); | |
| 615 | + $countries[] = [ | |
| 616 | + 'country' => strtolower($row['keys'][0] ?? ''), | |
| 617 | + 'clicks' => $clicks, | |
| 618 | + 'impressions' => (int) ($row['impressions'] ?? 0), | |
| 619 | + 'ctr' => round(($row['ctr'] ?? 0) * 100, 2), | |
| 620 | + 'position' => round($row['position'] ?? 0, 1), | |
| 621 | + 'percentage' => $total_clicks > 0 ? round(($clicks / $total_clicks) * 100) : 0, | |
| 622 | + ]; | |
| 623 | + } | |
| 624 | + | |
| 625 | + return [ | |
| 626 | + 'countries' => $countries, | |
| 627 | + 'total_clicks' => $total_clicks, | |
| 628 | + 'site_url' => $site_url, | |
| 629 | + 'date_range' => $date_range, | |
| 397 | 630 | ]; |
| 398 | 631 | } |
| 399 | 632 | |
| 400 | 633 | /** |