PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.0.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.0.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / integrations / class-google-search-console-client.php

class-google-search-console-client.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.0.0, at includes/integrations/class-google-search-console-client.php

741 lines 27.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Google Search Console Client Class
5 *
6 * Handles communication with Google Search Console API for search performance
7 * data retrieval and site verification. Extends the base Google API client with
8 * Search Console-specific functionality and rate limiting.
9 *
10 * @package ThinkRank\Integrations
11 * @since 1.0.0
12 */
13
14 declare(strict_types=1);
15
16 namespace ThinkRank\Integrations;
17
18 // Prevent direct access
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 /**
24 * Google Search Console Client Class
25 *
26 * Single Responsibility: Handle Google Search Console API communication
27 * Following ThinkRank HTTP client patterns from Claude_Client and OpenAI_Client
28 *
29 * @since 1.0.0
30 */
31 class Google_Search_Console_Client extends Google_API_Base_Client {
32
33 /**
34 * Google Search Console API base URL
35 */
36 private const API_BASE_URL = 'https://www.googleapis.com/webmasters/v3';
37
38 /**
39 * Rate limit transient key prefix
40 * Following ThinkRank option naming patterns
41 */
42 private const RATE_LIMIT_KEY = 'thinkrank_gsc_rate_limit';
43
44 /**
45 * Maximum requests per day (Google standard quota)
46 */
47 private const MAX_REQUESTS_PER_DAY = 2000;
48
49 /**
50 * Test API connection
51 * Following ThinkRank test_connection patterns from AI clients
52 *
53 * @return array Connection test results
54 */
55 public function test_connection(): array {
56 try {
57 $result = $this->list_sites();
58
59 return [
60 'success' => true,
61 'message' => 'Google Search Console API connection successful',
62 'sites_count' => count($result['siteEntry'] ?? [])
63 ];
64 } catch (\Exception $e) {
65 return [
66 'success' => false,
67 'error' => $e->getMessage()
68 ];
69 }
70 }
71
72 /**
73 * List verified sites in Search Console
74 *
75 * @return array List of verified sites
76 * @throws \Exception If API request fails
77 */
78 public function list_sites(): array {
79 $endpoint = '/sites';
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.
82 $full_url = self::API_BASE_URL . $endpoint;
83 return $this->make_request($full_url, [], 'GET');
84 }
85
86 /**
87 * Verify site ownership in Search Console
88 *
89 * @param string $site_url Site URL to verify
90 * @param string $verification_method Verification method used
91 * @return array Verification results
92 */
93 public function verify_site(string $site_url, string $verification_method = 'meta'): array {
94 try {
95 // Check if the site is already verified by listing sites
96 $sites = $this->list_sites();
97 $site_verified = false;
98
99 foreach ($sites['siteEntry'] ?? [] as $site) {
100 if ($site['siteUrl'] === $site_url) {
101 $site_verified = true;
102 break;
103 }
104 }
105
106 return [
107 'success' => $site_verified,
108 'message' => $site_verified ? 'Site is verified in Search Console' : 'Site not found in Search Console',
109 'site_url' => $site_url,
110 'verification_method' => $verification_method
111 ];
112 } catch (\Exception $e) {
113 return [
114 'success' => false,
115 'error' => $e->getMessage()
116 ];
117 }
118 }
119
120 /**
121 * Get search performance data with flexible dimensions
122 *
123 * @param string $site_url Site URL to get data for
124 * @param string $date_range Date range ('7d', '30d', '90d')
125 * @param array $dimensions Dimensions to group by (query, page, country, device, searchAppearance)
126 * @param int $row_limit Maximum number of rows to return
127 * @return array Search performance data
128 * @throws \Exception If API request fails
129 */
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)));
136
137 $endpoint = '/sites/' . rawurlencode($site_url) . '/searchAnalytics/query';
138
139 $request_body = [
140 'startDate' => $start_date,
141 'endDate' => $end_date,
142 'dimensions' => $dimensions,
143 'rowLimit' => $row_limit,
144 'dataState' => 'all',
145 ];
146
147 $full_url = self::API_BASE_URL . $endpoint;
148 return $this->make_request($full_url, $request_body, 'POST');
149 }
150
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 /**
270 * Get top search queries
271 *
272 * @param string $site_url Site URL to get data for
273 * @param int $limit Number of queries to retrieve
274 * @return array Top search queries
275 * @throws \Exception If API request fails
276 */
277 public function get_top_queries(string $site_url, int $limit = 10): array {
278 try {
279 $performance_data = $this->get_search_performance($site_url, '30d');
280 $queries = [];
281
282 foreach ($performance_data['rows'] ?? [] as $row) {
283 if (count($queries) >= $limit) {
284 break;
285 }
286
287 $queries[] = [
288 'query' => $row['keys'][0] ?? '',
289 'clicks' => $row['clicks'] ?? 0,
290 'impressions' => $row['impressions'] ?? 0,
291 'ctr' => $row['ctr'] ?? 0,
292 'position' => $row['position'] ?? 0
293 ];
294 }
295
296 return [
297 'queries' => $queries,
298 'site_url' => $site_url,
299 'limit' => $limit
300 ];
301 } catch (\Exception $e) {
302 return [
303 'queries' => [],
304 'site_url' => $site_url,
305 'limit' => $limit,
306 'error' => $e->getMessage()
307 ];
308 }
309 }
310
311 /**
312 * Get page performance data for SEO analytics
313 *
314 * @param string $site_url Site URL to get data for
315 * @param string $date_range Date range for data
316 * @param int $limit Number of pages to retrieve
317 * @return array Page performance data
318 * @throws \Exception If API request fails
319 */
320 public function get_page_performance(string $site_url, string $date_range = '30d', int $limit = 25): array {
321 $result = $this->get_search_performance($site_url, $date_range, ['page'], $limit);
322
323 $pages = [];
324 $rows = $result['rows'] ?? [];
325
326 foreach ($rows as $row) {
327 $pages[] = [
328 'page' => $row['keys'][0] ?? '',
329 'clicks' => $row['clicks'] ?? 0,
330 'impressions' => $row['impressions'] ?? 0,
331 'ctr' => round(($row['ctr'] ?? 0) * 100, 2), // Convert to percentage
332 'position' => round($row['position'] ?? 0, 1)
333 ];
334 }
335
336 return [
337 'pages' => $pages,
338 'site_url' => $site_url,
339 'date_range' => $date_range,
340 'total_pages' => count($pages)
341 ];
342 }
343
344 /**
345 * Get device performance breakdown for mobile SEO insights
346 *
347 * @param string $site_url Site URL to get data for
348 * @param string $date_range Date range for data
349 * @return array Device performance data
350 * @throws \Exception If API request fails
351 */
352 public function get_device_performance(string $site_url, string $date_range = '30d'): array {
353 $result = $this->get_search_performance($site_url, $date_range, ['device'], 10);
354
355 $devices = [];
356 $rows = $result['rows'] ?? [];
357
358 foreach ($rows as $row) {
359 $device = $row['keys'][0] ?? '';
360 $devices[$device] = [
361 'clicks' => $row['clicks'] ?? 0,
362 'impressions' => $row['impressions'] ?? 0,
363 'ctr' => round(($row['ctr'] ?? 0) * 100, 2),
364 'position' => round($row['position'] ?? 0, 1)
365 ];
366 }
367
368 return [
369 'devices' => $devices,
370 'site_url' => $site_url,
371 'date_range' => $date_range
372 ];
373 }
374
375 /**
376 * Get search appearance data for rich results tracking
377 *
378 * @param string $site_url Site URL to get data for
379 * @param string $date_range Date range for data
380 * @return array Search appearance data
381 * @throws \Exception If API request fails
382 */
383 public function get_search_appearance(string $site_url, string $date_range = '30d'): array {
384 $result = $this->get_search_performance($site_url, $date_range, ['searchAppearance'], 20);
385
386 $appearances = [];
387 $rows = $result['rows'] ?? [];
388
389 foreach ($rows as $row) {
390 $appearance = $row['keys'][0] ?? '';
391 $appearances[$appearance] = [
392 'clicks' => $row['clicks'] ?? 0,
393 'impressions' => $row['impressions'] ?? 0,
394 'ctr' => round(($row['ctr'] ?? 0) * 100, 2),
395 'position' => round($row['position'] ?? 0, 1)
396 ];
397 }
398
399 return [
400 'appearances' => $appearances,
401 'site_url' => $site_url,
402 'date_range' => $date_range
403 ];
404 }
405
406 /**
407 * Get site indexing status and coverage data
408 *
409 * @param string $site_url Site URL to check
410 * @return array Indexing status and coverage data
411 * @throws \Exception If API request fails
412 */
413 public function get_indexing_status(string $site_url): array {
414 try {
415 // Get overall search performance to estimate indexed pages
416 $performance = $this->get_search_performance($site_url, '30d', ['page'], 1000);
417 $indexed_pages = count($performance['rows'] ?? []);
418
419 // Get basic site info
420 $sites = $this->list_sites();
421 $site_info = null;
422
423 foreach ($sites['siteEntry'] ?? [] as $site) {
424 if ($site['siteUrl'] === $site_url) {
425 $site_info = $site;
426 break;
427 }
428 }
429
430 return [
431 'site_url' => $site_url,
432 'is_verified' => !is_null($site_info),
433 'indexed_pages_estimate' => $indexed_pages,
434 'permission_level' => $site_info['permissionLevel'] ?? 'none',
435 'last_updated' => gmdate('Y-m-d H:i:s')
436 ];
437 } catch (\Exception $e) {
438 return [
439 'site_url' => $site_url,
440 'is_verified' => false,
441 'indexed_pages_estimate' => 0,
442 'permission_level' => 'none',
443 'error' => $e->getMessage(),
444 'last_updated' => gmdate('Y-m-d H:i:s')
445 ];
446 }
447 }
448
449 /**
450 * Get keyword opportunities for SEO insights
451 * Identifies queries with high impressions but low CTR or position
452 *
453 * @param string $site_url Site URL to analyze
454 * @param string $date_range Date range for analysis
455 * @param int $min_impressions Minimum impressions threshold
456 * @return array Keyword opportunities
457 * @throws \Exception If API request fails
458 */
459 public function get_keyword_opportunities(string $site_url, string $date_range = '30d', int $min_impressions = 100): array {
460 $result = $this->get_search_performance($site_url, $date_range, ['query'], 500);
461
462 $opportunities = [];
463 $rows = $result['rows'] ?? [];
464
465 foreach ($rows as $row) {
466 $impressions = $row['impressions'] ?? 0;
467 $ctr = $row['ctr'] ?? 0;
468 $position = $row['position'] ?? 0;
469 $clicks = $row['clicks'] ?? 0;
470
471 // Identify opportunities: high impressions, low CTR, or position 4-10
472 if ($impressions >= $min_impressions) {
473 $opportunity_score = 0;
474 $opportunity_reasons = [];
475
476 // Low CTR opportunity
477 if ($ctr < 0.05 && $position <= 10) { // Less than 5% CTR in top 10
478 $opportunity_score += 30;
479 $opportunity_reasons[] = 'Low CTR for top 10 position';
480 }
481
482 // Position 4-10 opportunity (could reach top 3)
483 if ($position >= 4 && $position <= 10) {
484 $opportunity_score += 40;
485 $opportunity_reasons[] = 'Ranking 4-10, potential for top 3';
486 }
487
488 // High impressions, low clicks
489 if ($impressions > 500 && $clicks < 25) {
490 $opportunity_score += 20;
491 $opportunity_reasons[] = 'High impressions but low clicks';
492 }
493
494 if ($opportunity_score > 0) {
495 $opportunities[] = [
496 'query' => $row['keys'][0] ?? '',
497 'clicks' => $clicks,
498 'impressions' => $impressions,
499 'ctr' => round($ctr * 100, 2),
500 'position' => round($position, 1),
501 'opportunity_score' => $opportunity_score,
502 'reasons' => $opportunity_reasons
503 ];
504 }
505 }
506 }
507
508 // Sort by opportunity score (highest first)
509 usort($opportunities, function ($a, $b) {
510 return $b['opportunity_score'] <=> $a['opportunity_score'];
511 });
512
513 return [
514 'opportunities' => array_slice($opportunities, 0, 50), // Top 50 opportunities
515 'site_url' => $site_url,
516 'date_range' => $date_range,
517 'total_opportunities' => count($opportunities)
518 ];
519 }
520
521 /**
522 * Return branded vs non-branded click/impression split that matches the GSC platform.
523 *
524 * Uses two server-side aggregate calls per period (empty dimensions + dimensionFilterGroups)
525 * so the totals are exact — not limited by the 1 000-row query cap:
526 *
527 * • Call A: no filter → real site total clicks
528 * • Call B: query contains brand → branded clicks
529 * • Non-branded = A − B
530 *
531 * Also fetches the equivalent previous period so the frontend can render trend arrows.
532 *
533 * @param string $site_url Registered GSC property URL
534 * @param string $date_range '7d' | '30d' | '90d'
535 * @param string $brand_name Comma-separated brand keywords. Auto-derived from domain when empty.
536 * @return array {
537 * branded, non_branded, previous: { branded, non_branded },
538 * brand_terms, total_clicks, site_url, date_range
539 * }
540 */
541 public function get_branded_performance(string $site_url, string $date_range = '30d', string $brand_name = ''): array {
542 $days = max(1, (int) str_replace('d', '', $date_range));
543 $end = gmdate('Y-m-d', strtotime('-2 days'));
544 $start = gmdate('Y-m-d', strtotime('-' . ($days - 1) . ' days', strtotime($end)));
545
546 $prev_end = gmdate('Y-m-d', strtotime('-1 day', strtotime($start)));
547 $prev_start = gmdate('Y-m-d', strtotime('-' . ($days - 1) . ' days', strtotime($prev_end)));
548
549 // Auto-derive brand from domain when not provided.
550 // Handles both URL-prefix (https://example.com) and domain (sc-domain:example.com) formats.
551 if (empty($brand_name)) {
552 $stripped = preg_replace('#^sc-domain:#i', '', $site_url);
553 $host = wp_parse_url($stripped, PHP_URL_HOST) ?? wp_parse_url('https://' . $stripped, PHP_URL_HOST) ?? $stripped;
554 $host = preg_replace('/^www\./i', '', (string) $host);
555 $brand_name = strtolower(explode('.', $host)[0]);
556 }
557 $terms = array_values(array_filter(array_map('trim', explode(',', strtolower($brand_name)))));
558
559 // For hyphenated brands (e.g. "essential-blocks") also match the space variant
560 // ("essential blocks") since users type both forms in Google searches.
561 $extra = [];
562 foreach ($terms as $t) {
563 if (str_contains($t, '-')) {
564 $spaced = str_replace('-', ' ', $t);
565 if (!in_array($spaced, $terms, true)) {
566 $extra[] = $spaced;
567 }
568 }
569 }
570 $terms = array_values(array_merge($terms, $extra));
571
572 $split_cur = $this->gsc_split_by_brand($site_url, $start, $end, $terms);
573 $split_prev = $this->gsc_split_by_brand($site_url, $prev_start, $prev_end, $terms);
574
575 return [
576 'branded' => $split_cur['branded'],
577 'non_branded' => $split_cur['non_branded'],
578 'previous' => [
579 'branded' => $split_prev['branded'],
580 'non_branded' => $split_prev['non_branded'],
581 ],
582 'brand_terms' => $terms,
583 'total_clicks' => $split_cur['total_clicks'],
584 'site_url' => $site_url,
585 'date_range' => $date_range,
586 ];
587 }
588
589 /**
590 * Fetch all web query rows for a date window and split into branded / non-branded
591 * using a single API call. Both totals come from the same data set so the
592 * percentages always add up to 100 %.
593 *
594 * @param string $site_url GSC property URL
595 * @param string $start Start date (Y-m-d)
596 * @param string $end End date (Y-m-d)
597 * @param string[] $brand_terms Brand keywords to match (substring, case-insensitive)
598 * @return array { branded: {...}, non_branded: {...}, total_clicks: int }
599 */
600 private function gsc_split_by_brand(string $site_url, string $start, string $end, array $brand_terms): array {
601 $url = self::API_BASE_URL . '/sites/' . rawurlencode($site_url) . '/searchAnalytics/query';
602
603 $page_size = 25000;
604 $start_row = 0;
605 $total_clicks = 0;
606 $branded_clicks = 0;
607 $total_impr = 0;
608 $branded_impr = 0;
609
610 // Safety cap so a runaway query can never loop unbounded. With a 25k
611 // page size this stops after ~100k rows (4 pages), which is far beyond
612 // the query volume of any real site for a single date window.
613 $max_pages = 4;
614 $pages_fetched = 0;
615
616 do {
617 $response = $this->make_request($url, [
618 'startDate' => $start,
619 'endDate' => $end,
620 'type' => 'web',
621 'dimensions' => ['query'],
622 'rowLimit' => $page_size,
623 'startRow' => $start_row,
624 'dataState' => 'all',
625 ], 'POST');
626
627 $rows = $response['rows'] ?? [];
628 foreach ($rows as $row) {
629 $query = strtolower($row['keys'][0] ?? '');
630 $clicks = (int) ($row['clicks'] ?? 0);
631 $impr = (int) ($row['impressions'] ?? 0);
632
633 $total_clicks += $clicks;
634 $total_impr += $impr;
635
636 foreach ($brand_terms as $term) {
637 if (str_contains($query, $term)) {
638 $branded_clicks += $clicks;
639 $branded_impr += $impr;
640 break;
641 }
642 }
643 }
644
645 $fetched = count($rows);
646 $start_row += $fetched;
647 $pages_fetched++;
648 } while ($fetched === $page_size && $pages_fetched < $max_pages);
649
650 $non_branded_clicks = max(0, $total_clicks - $branded_clicks);
651 $non_branded_impr = max(0, $total_impr - $branded_impr);
652
653 return [
654 'branded' => [
655 'clicks' => $branded_clicks,
656 'impressions' => $branded_impr,
657 'percentage' => $total_clicks > 0 ? round($branded_clicks / $total_clicks * 100) : 0,
658 ],
659 'non_branded' => [
660 'clicks' => $non_branded_clicks,
661 'impressions' => $non_branded_impr,
662 'percentage' => $total_clicks > 0 ? round($non_branded_clicks / $total_clicks * 100) : 0,
663 ],
664 'total_clicks' => $total_clicks,
665 ];
666 }
667
668 /**
669 * Get top countries by clicks from Search Console.
670 *
671 * Queries with the `country` dimension and returns rows sorted by clicks
672 * descending, each enriched with a percentage share of the total clicks.
673 *
674 * @param string $site_url Site URL to query
675 * @param string $date_range Date range ('7d', '30d', '90d')
676 * @param int $row_limit Maximum countries to return (default 10)
677 * @return array { countries: array, total_clicks: int, site_url: string, date_range: string }
678 */
679 public function get_country_performance(string $site_url, string $date_range = '30d', int $row_limit = 10): array {
680 $result = $this->get_search_performance($site_url, $date_range, ['country'], $row_limit);
681 $rows = $result['rows'] ?? [];
682
683 $total_clicks = 0;
684 foreach ($rows as $row) {
685 $total_clicks += (int) ($row['clicks'] ?? 0);
686 }
687
688 $countries = [];
689 foreach ($rows as $row) {
690 $clicks = (int) ($row['clicks'] ?? 0);
691 $countries[] = [
692 'country' => strtolower($row['keys'][0] ?? ''),
693 'clicks' => $clicks,
694 'impressions' => (int) ($row['impressions'] ?? 0),
695 'ctr' => round(($row['ctr'] ?? 0) * 100, 2),
696 'position' => round($row['position'] ?? 0, 1),
697 'percentage' => $total_clicks > 0 ? round(($clicks / $total_clicks) * 100) : 0,
698 ];
699 }
700
701 return [
702 'countries' => $countries,
703 'total_clicks' => $total_clicks,
704 'site_url' => $site_url,
705 'date_range' => $date_range,
706 ];
707 }
708
709 /**
710 * Get rate limit configuration
711 * Following ThinkRank rate limiting patterns
712 *
713 * @return array Rate limit configuration
714 */
715 protected function get_rate_limits(): array {
716 return [
717 'max_requests_per_day' => self::MAX_REQUESTS_PER_DAY,
718 'reset_time' => get_transient(self::RATE_LIMIT_KEY . '_reset') ?: strtotime('tomorrow')
719 ];
720 }
721
722 /**
723 * Get rate limit transient key
724 * Following ThinkRank option naming patterns
725 *
726 * @return string Rate limit key
727 */
728 protected function get_rate_limit_key(): string {
729 return self::RATE_LIMIT_KEY;
730 }
731
732 /**
733 * Get rate limit error message
734 *
735 * @return string Error message
736 */
737 protected function get_rate_limit_error_message(): string {
738 return 'Google Search Console API rate limit exceeded. Try again tomorrow.';
739 }
740 }
741