PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.10.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 1.10.0, at includes/integrations/class-google-search-console-client.php

757 lines 27.7 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 $params = [];
81 // Only add API key if no OAuth access token is present (avoid credential conflict)
82 if (!empty($this->api_key) && empty($this->access_token)) {
83 $params['key'] = $this->api_key;
84 }
85
86 $full_url = self::API_BASE_URL . $endpoint;
87 return $this->make_request($full_url, $params, 'GET');
88 }
89
90 /**
91 * Verify site ownership in Search Console
92 *
93 * @param string $site_url Site URL to verify
94 * @param string $verification_method Verification method used
95 * @return array Verification results
96 */
97 public function verify_site(string $site_url, string $verification_method = 'meta'): array {
98 try {
99 // Check if the site is already verified by listing sites
100 $sites = $this->list_sites();
101 $site_verified = false;
102
103 foreach ($sites['siteEntry'] ?? [] as $site) {
104 if ($site['siteUrl'] === $site_url) {
105 $site_verified = true;
106 break;
107 }
108 }
109
110 return [
111 'success' => $site_verified,
112 'message' => $site_verified ? 'Site is verified in Search Console' : 'Site not found in Search Console',
113 'site_url' => $site_url,
114 'verification_method' => $verification_method
115 ];
116 } catch (\Exception $e) {
117 return [
118 'success' => false,
119 'error' => $e->getMessage()
120 ];
121 }
122 }
123
124 /**
125 * Get search performance data with flexible dimensions
126 *
127 * @param string $site_url Site URL to get data for
128 * @param string $date_range Date range ('7d', '30d', '90d')
129 * @param array $dimensions Dimensions to group by (query, page, country, device, searchAppearance)
130 * @param int $row_limit Maximum number of rows to return
131 * @return array Search performance data
132 * @throws \Exception If API request fails
133 */
134 public function get_search_performance(string $site_url, string $date_range = '30d', array $dimensions = ['query'], int $row_limit = 1000): array {
135 // GSC data for the current day is never complete; use yesterday as the end date
136 // so the N-day window matches exactly what the GSC dashboard shows.
137 $days = (int) str_replace('d', '', $date_range);
138 $end_date = gmdate('Y-m-d', strtotime('-2 days'));
139 $start_date = gmdate('Y-m-d', strtotime('-' . ($days - 1) . ' days', strtotime($end_date)));
140
141 $endpoint = '/sites/' . urlencode($site_url) . '/searchAnalytics/query';
142
143 $request_body = [
144 'startDate' => $start_date,
145 'endDate' => $end_date,
146 'dimensions' => $dimensions,
147 'rowLimit' => $row_limit,
148 'dataState' => 'all',
149 ];
150
151 $full_url = self::API_BASE_URL . $endpoint;
152 if (!empty($this->api_key) && empty($this->access_token)) {
153 $full_url .= '?key=' . $this->api_key;
154 }
155
156 return $this->make_request($full_url, $request_body, 'POST');
157 }
158
159 /**
160 * Get aggregated search totals (clicks, impressions, ctr, position)
161 *
162 * @param string $site_url Site URL to get data for
163 * @param string $date_range Date range ('7d', '30d', '90d')
164 * @return array Aggregated totals
165 * @throws \Exception If API request fails
166 */
167 public function get_search_totals(string $site_url, string $date_range = '30d'): array {
168 // GSC data has a 2-day delay; use D-2 as end_date to match the GSC dashboard.
169 $days = (int) str_replace('d', '', $date_range);
170 $end_date = gmdate('Y-m-d', strtotime('-2 days'));
171 $start_date = gmdate('Y-m-d', strtotime('-' . ($days - 1) . ' days', strtotime($end_date)));
172
173 $endpoint = '/sites/' . urlencode($site_url) . '/searchAnalytics/query';
174
175 // diverse from get_search_performance: no dimensions, just totals
176 $request_body = [
177 'startDate' => $start_date,
178 'endDate' => $end_date,
179 'dimensions' => [], // Empty dimensions for aggregation
180 'rowLimit' => 1, // We only need the totals, but API might require at least 1
181 'dataState' => 'all',
182 ];
183
184 $full_url = self::API_BASE_URL . $endpoint;
185 if (!empty($this->api_key) && empty($this->access_token)) {
186 $full_url .= '?key=' . $this->api_key;
187 }
188
189 $response = $this->make_request($full_url, $request_body, 'POST');
190
191 // The API returns rows even if we don't ask for dimensions?
192 // Actually, without dimensions, it returns one row with aggregated values if successful.
193 // Or sometimes it returns just the aggregates if available.
194 // Let's inspect the response format for GSC API v3.
195 // "If no dimensions are requested, the response will contain a single row with the aggregated values."
196
197 if (!empty($response['rows'])) {
198 $row = $response['rows'][0];
199 return [
200 'clicks' => $row['clicks'] ?? 0,
201 'impressions' => $row['impressions'] ?? 0,
202 'ctr' => round(($row['ctr'] ?? 0) * 100, 2),
203 'position' => round($row['position'] ?? 0, 1)
204 ];
205 }
206
207 return [
208 'clicks' => 0,
209 'impressions' => 0,
210 'ctr' => 0,
211 'position' => 0
212 ];
213 }
214
215 /**
216 * Get aggregated search totals for explicit start/end dates
217 *
218 * @param string $site_url Site URL to get data for
219 * @param string $start_date Start date (Y-m-d)
220 * @param string $end_date End date (Y-m-d)
221 * @return array Aggregated totals
222 * @throws \Exception If API request fails
223 */
224 public function get_search_totals_by_dates(string $site_url, string $start_date, string $end_date): array {
225 $endpoint = '/sites/' . urlencode($site_url) . '/searchAnalytics/query';
226
227 $request_body = [
228 'startDate' => $start_date,
229 'endDate' => $end_date,
230 'dimensions' => [],
231 'rowLimit' => 1,
232 'dataState' => 'all',
233 ];
234
235 $full_url = self::API_BASE_URL . $endpoint;
236 if (!empty($this->api_key) && empty($this->access_token)) {
237 $full_url .= '?key=' . $this->api_key;
238 }
239
240 $response = $this->make_request($full_url, $request_body, 'POST');
241
242 if (!empty($response['rows'])) {
243 $row = $response['rows'][0];
244 return [
245 'clicks' => $row['clicks'] ?? 0,
246 'impressions' => $row['impressions'] ?? 0,
247 'ctr' => round(($row['ctr'] ?? 0) * 100, 2),
248 'position' => round($row['position'] ?? 0, 1),
249 ];
250 }
251
252 return ['clicks' => 0, 'impressions' => 0, 'ctr' => 0, 'position' => 0];
253 }
254
255 /**
256 * Get query-level search performance for explicit start/end dates
257 *
258 * @param string $site_url Site URL to get data for
259 * @param string $start_date Start date (Y-m-d)
260 * @param string $end_date End date (Y-m-d)
261 * @param int $row_limit Maximum rows to return
262 * @return array Raw rows from GSC API
263 * @throws \Exception If API request fails
264 */
265 public function get_search_performance_by_dates(string $site_url, string $start_date, string $end_date, int $row_limit = 500, array $dimensions = ['query']): array {
266 $endpoint = '/sites/' . urlencode($site_url) . '/searchAnalytics/query';
267
268 $request_body = [
269 'startDate' => $start_date,
270 'endDate' => $end_date,
271 'dimensions' => $dimensions,
272 'rowLimit' => $row_limit,
273 // 'all' includes both finalised data and fresh (still-processing)
274 // data — matches what the Search Console web UI displays, so the
275 // last 2-4 days aren't missing.
276 'dataState' => 'all',
277 ];
278
279 $full_url = self::API_BASE_URL . $endpoint;
280 if (!empty($this->api_key) && empty($this->access_token)) {
281 $full_url .= '?key=' . $this->api_key;
282 }
283
284 $response = $this->make_request($full_url, $request_body, 'POST');
285
286 return $response['rows'] ?? [];
287 }
288
289 /**
290 * Get top search queries
291 *
292 * @param string $site_url Site URL to get data for
293 * @param int $limit Number of queries to retrieve
294 * @return array Top search queries
295 * @throws \Exception If API request fails
296 */
297 public function get_top_queries(string $site_url, int $limit = 10): array {
298 try {
299 $performance_data = $this->get_search_performance($site_url, '30d');
300 $queries = [];
301
302 foreach ($performance_data['rows'] ?? [] as $row) {
303 if (count($queries) >= $limit) {
304 break;
305 }
306
307 $queries[] = [
308 'query' => $row['keys'][0] ?? '',
309 'clicks' => $row['clicks'] ?? 0,
310 'impressions' => $row['impressions'] ?? 0,
311 'ctr' => $row['ctr'] ?? 0,
312 'position' => $row['position'] ?? 0
313 ];
314 }
315
316 return [
317 'queries' => $queries,
318 'site_url' => $site_url,
319 'limit' => $limit
320 ];
321 } catch (\Exception $e) {
322 return [
323 'queries' => [],
324 'site_url' => $site_url,
325 'limit' => $limit,
326 'error' => $e->getMessage()
327 ];
328 }
329 }
330
331 /**
332 * Get page performance data for SEO analytics
333 *
334 * @param string $site_url Site URL to get data for
335 * @param string $date_range Date range for data
336 * @param int $limit Number of pages to retrieve
337 * @return array Page performance data
338 * @throws \Exception If API request fails
339 */
340 public function get_page_performance(string $site_url, string $date_range = '30d', int $limit = 25): array {
341 $result = $this->get_search_performance($site_url, $date_range, ['page'], $limit);
342
343 $pages = [];
344 $rows = $result['rows'] ?? [];
345
346 foreach ($rows as $row) {
347 $pages[] = [
348 'page' => $row['keys'][0] ?? '',
349 'clicks' => $row['clicks'] ?? 0,
350 'impressions' => $row['impressions'] ?? 0,
351 'ctr' => round(($row['ctr'] ?? 0) * 100, 2), // Convert to percentage
352 'position' => round($row['position'] ?? 0, 1)
353 ];
354 }
355
356 return [
357 'pages' => $pages,
358 'site_url' => $site_url,
359 'date_range' => $date_range,
360 'total_pages' => count($pages)
361 ];
362 }
363
364 /**
365 * Get device performance breakdown for mobile SEO insights
366 *
367 * @param string $site_url Site URL to get data for
368 * @param string $date_range Date range for data
369 * @return array Device performance data
370 * @throws \Exception If API request fails
371 */
372 public function get_device_performance(string $site_url, string $date_range = '30d'): array {
373 $result = $this->get_search_performance($site_url, $date_range, ['device'], 10);
374
375 $devices = [];
376 $rows = $result['rows'] ?? [];
377
378 foreach ($rows as $row) {
379 $device = $row['keys'][0] ?? '';
380 $devices[$device] = [
381 'clicks' => $row['clicks'] ?? 0,
382 'impressions' => $row['impressions'] ?? 0,
383 'ctr' => round(($row['ctr'] ?? 0) * 100, 2),
384 'position' => round($row['position'] ?? 0, 1)
385 ];
386 }
387
388 return [
389 'devices' => $devices,
390 'site_url' => $site_url,
391 'date_range' => $date_range
392 ];
393 }
394
395 /**
396 * Get search appearance data for rich results tracking
397 *
398 * @param string $site_url Site URL to get data for
399 * @param string $date_range Date range for data
400 * @return array Search appearance data
401 * @throws \Exception If API request fails
402 */
403 public function get_search_appearance(string $site_url, string $date_range = '30d'): array {
404 $result = $this->get_search_performance($site_url, $date_range, ['searchAppearance'], 20);
405
406 $appearances = [];
407 $rows = $result['rows'] ?? [];
408
409 foreach ($rows as $row) {
410 $appearance = $row['keys'][0] ?? '';
411 $appearances[$appearance] = [
412 'clicks' => $row['clicks'] ?? 0,
413 'impressions' => $row['impressions'] ?? 0,
414 'ctr' => round(($row['ctr'] ?? 0) * 100, 2),
415 'position' => round($row['position'] ?? 0, 1)
416 ];
417 }
418
419 return [
420 'appearances' => $appearances,
421 'site_url' => $site_url,
422 'date_range' => $date_range
423 ];
424 }
425
426 /**
427 * Get site indexing status and coverage data
428 *
429 * @param string $site_url Site URL to check
430 * @return array Indexing status and coverage data
431 * @throws \Exception If API request fails
432 */
433 public function get_indexing_status(string $site_url): array {
434 try {
435 // Get overall search performance to estimate indexed pages
436 $performance = $this->get_search_performance($site_url, '30d', ['page'], 1000);
437 $indexed_pages = count($performance['rows'] ?? []);
438
439 // Get basic site info
440 $sites = $this->list_sites();
441 $site_info = null;
442
443 foreach ($sites['siteEntry'] ?? [] as $site) {
444 if ($site['siteUrl'] === $site_url) {
445 $site_info = $site;
446 break;
447 }
448 }
449
450 return [
451 'site_url' => $site_url,
452 'is_verified' => !is_null($site_info),
453 'indexed_pages_estimate' => $indexed_pages,
454 'permission_level' => $site_info['permissionLevel'] ?? 'none',
455 'last_updated' => gmdate('Y-m-d H:i:s')
456 ];
457 } catch (\Exception $e) {
458 return [
459 'site_url' => $site_url,
460 'is_verified' => false,
461 'indexed_pages_estimate' => 0,
462 'permission_level' => 'none',
463 'error' => $e->getMessage(),
464 'last_updated' => gmdate('Y-m-d H:i:s')
465 ];
466 }
467 }
468
469 /**
470 * Get keyword opportunities for SEO insights
471 * Identifies queries with high impressions but low CTR or position
472 *
473 * @param string $site_url Site URL to analyze
474 * @param string $date_range Date range for analysis
475 * @param int $min_impressions Minimum impressions threshold
476 * @return array Keyword opportunities
477 * @throws \Exception If API request fails
478 */
479 public function get_keyword_opportunities(string $site_url, string $date_range = '30d', int $min_impressions = 100): array {
480 $result = $this->get_search_performance($site_url, $date_range, ['query'], 500);
481
482 $opportunities = [];
483 $rows = $result['rows'] ?? [];
484
485 foreach ($rows as $row) {
486 $impressions = $row['impressions'] ?? 0;
487 $ctr = $row['ctr'] ?? 0;
488 $position = $row['position'] ?? 0;
489 $clicks = $row['clicks'] ?? 0;
490
491 // Identify opportunities: high impressions, low CTR, or position 4-10
492 if ($impressions >= $min_impressions) {
493 $opportunity_score = 0;
494 $opportunity_reasons = [];
495
496 // Low CTR opportunity
497 if ($ctr < 0.05 && $position <= 10) { // Less than 5% CTR in top 10
498 $opportunity_score += 30;
499 $opportunity_reasons[] = 'Low CTR for top 10 position';
500 }
501
502 // Position 4-10 opportunity (could reach top 3)
503 if ($position >= 4 && $position <= 10) {
504 $opportunity_score += 40;
505 $opportunity_reasons[] = 'Ranking 4-10, potential for top 3';
506 }
507
508 // High impressions, low clicks
509 if ($impressions > 500 && $clicks < 25) {
510 $opportunity_score += 20;
511 $opportunity_reasons[] = 'High impressions but low clicks';
512 }
513
514 if ($opportunity_score > 0) {
515 $opportunities[] = [
516 'query' => $row['keys'][0] ?? '',
517 'clicks' => $clicks,
518 'impressions' => $impressions,
519 'ctr' => round($ctr * 100, 2),
520 'position' => round($position, 1),
521 'opportunity_score' => $opportunity_score,
522 'reasons' => $opportunity_reasons
523 ];
524 }
525 }
526 }
527
528 // Sort by opportunity score (highest first)
529 usort($opportunities, function ($a, $b) {
530 return $b['opportunity_score'] <=> $a['opportunity_score'];
531 });
532
533 return [
534 'opportunities' => array_slice($opportunities, 0, 50), // Top 50 opportunities
535 'site_url' => $site_url,
536 'date_range' => $date_range,
537 'total_opportunities' => count($opportunities)
538 ];
539 }
540
541 /**
542 * Return branded vs non-branded click/impression split that matches the GSC platform.
543 *
544 * Uses two server-side aggregate calls per period (empty dimensions + dimensionFilterGroups)
545 * so the totals are exact — not limited by the 1 000-row query cap:
546 *
547 * • Call A: no filter → real site total clicks
548 * • Call B: query contains brand → branded clicks
549 * • Non-branded = A − B
550 *
551 * Also fetches the equivalent previous period so the frontend can render trend arrows.
552 *
553 * @param string $site_url Registered GSC property URL
554 * @param string $date_range '7d' | '30d' | '90d'
555 * @param string $brand_name Comma-separated brand keywords. Auto-derived from domain when empty.
556 * @return array {
557 * branded, non_branded, previous: { branded, non_branded },
558 * brand_terms, total_clicks, site_url, date_range
559 * }
560 */
561 public function get_branded_performance(string $site_url, string $date_range = '30d', string $brand_name = ''): array {
562 $days = max(1, (int) str_replace('d', '', $date_range));
563 $end = gmdate('Y-m-d', strtotime('-2 days'));
564 $start = gmdate('Y-m-d', strtotime('-' . ($days - 1) . ' days', strtotime($end)));
565
566 $prev_end = gmdate('Y-m-d', strtotime('-1 day', strtotime($start)));
567 $prev_start = gmdate('Y-m-d', strtotime('-' . ($days - 1) . ' days', strtotime($prev_end)));
568
569 // Auto-derive brand from domain when not provided.
570 // Handles both URL-prefix (https://example.com) and domain (sc-domain:example.com) formats.
571 if (empty($brand_name)) {
572 $stripped = preg_replace('#^sc-domain:#i', '', $site_url);
573 $host = parse_url($stripped, PHP_URL_HOST) ?? parse_url('https://' . $stripped, PHP_URL_HOST) ?? $stripped;
574 $host = preg_replace('/^www\./i', '', (string) $host);
575 $brand_name = strtolower(explode('.', $host)[0]);
576 }
577 $terms = array_values(array_filter(array_map('trim', explode(',', strtolower($brand_name)))));
578
579 // For hyphenated brands (e.g. "essential-blocks") also match the space variant
580 // ("essential blocks") since users type both forms in Google searches.
581 $extra = [];
582 foreach ($terms as $t) {
583 if (str_contains($t, '-')) {
584 $spaced = str_replace('-', ' ', $t);
585 if (!in_array($spaced, $terms, true)) {
586 $extra[] = $spaced;
587 }
588 }
589 }
590 $terms = array_values(array_merge($terms, $extra));
591
592 $split_cur = $this->gsc_split_by_brand($site_url, $start, $end, $terms);
593 $split_prev = $this->gsc_split_by_brand($site_url, $prev_start, $prev_end, $terms);
594
595 return [
596 'branded' => $split_cur['branded'],
597 'non_branded' => $split_cur['non_branded'],
598 'previous' => [
599 'branded' => $split_prev['branded'],
600 'non_branded' => $split_prev['non_branded'],
601 ],
602 'brand_terms' => $terms,
603 'total_clicks' => $split_cur['total_clicks'],
604 'site_url' => $site_url,
605 'date_range' => $date_range,
606 ];
607 }
608
609 /**
610 * Fetch all web query rows for a date window and split into branded / non-branded
611 * using a single API call. Both totals come from the same data set so the
612 * percentages always add up to 100 %.
613 *
614 * @param string $site_url GSC property URL
615 * @param string $start Start date (Y-m-d)
616 * @param string $end End date (Y-m-d)
617 * @param string[] $brand_terms Brand keywords to match (substring, case-insensitive)
618 * @return array { branded: {...}, non_branded: {...}, total_clicks: int }
619 */
620 private function gsc_split_by_brand(string $site_url, string $start, string $end, array $brand_terms): array {
621 $url = self::API_BASE_URL . '/sites/' . urlencode($site_url) . '/searchAnalytics/query';
622 if (!empty($this->api_key) && empty($this->access_token)) {
623 $url .= '?key=' . $this->api_key;
624 }
625
626 $page_size = 25000;
627 $start_row = 0;
628 $total_clicks = 0;
629 $branded_clicks = 0;
630 $total_impr = 0;
631 $branded_impr = 0;
632
633 do {
634 $response = $this->make_request($url, [
635 'startDate' => $start,
636 'endDate' => $end,
637 'type' => 'web',
638 'dimensions' => ['query'],
639 'rowLimit' => $page_size,
640 'startRow' => $start_row,
641 'dataState' => 'all',
642 ], 'POST');
643
644 $rows = $response['rows'] ?? [];
645 foreach ($rows as $row) {
646 $query = strtolower($row['keys'][0] ?? '');
647 $clicks = (int) ($row['clicks'] ?? 0);
648 $impr = (int) ($row['impressions'] ?? 0);
649
650 $total_clicks += $clicks;
651 $total_impr += $impr;
652
653 foreach ($brand_terms as $term) {
654 if (str_contains($query, $term)) {
655 $branded_clicks += $clicks;
656 $branded_impr += $impr;
657 break;
658 }
659 }
660 }
661
662 $fetched = count($rows);
663 $start_row += $fetched;
664 } while ($fetched === $page_size);
665
666 $non_branded_clicks = max(0, $total_clicks - $branded_clicks);
667 $non_branded_impr = max(0, $total_impr - $branded_impr);
668
669 return [
670 'branded' => [
671 'clicks' => $branded_clicks,
672 'impressions' => $branded_impr,
673 'percentage' => $total_clicks > 0 ? round($branded_clicks / $total_clicks * 100) : 0,
674 ],
675 'non_branded' => [
676 'clicks' => $non_branded_clicks,
677 'impressions' => $non_branded_impr,
678 'percentage' => $total_clicks > 0 ? round($non_branded_clicks / $total_clicks * 100) : 0,
679 ],
680 'total_clicks' => $total_clicks,
681 ];
682 }
683
684 /**
685 * Get top countries by clicks from Search Console.
686 *
687 * Queries with the `country` dimension and returns rows sorted by clicks
688 * descending, each enriched with a percentage share of the total clicks.
689 *
690 * @param string $site_url Site URL to query
691 * @param string $date_range Date range ('7d', '30d', '90d')
692 * @param int $row_limit Maximum countries to return (default 10)
693 * @return array { countries: array, total_clicks: int, site_url: string, date_range: string }
694 */
695 public function get_country_performance(string $site_url, string $date_range = '30d', int $row_limit = 10): array {
696 $result = $this->get_search_performance($site_url, $date_range, ['country'], $row_limit);
697 $rows = $result['rows'] ?? [];
698
699 $total_clicks = 0;
700 foreach ($rows as $row) {
701 $total_clicks += (int) ($row['clicks'] ?? 0);
702 }
703
704 $countries = [];
705 foreach ($rows as $row) {
706 $clicks = (int) ($row['clicks'] ?? 0);
707 $countries[] = [
708 'country' => strtolower($row['keys'][0] ?? ''),
709 'clicks' => $clicks,
710 'impressions' => (int) ($row['impressions'] ?? 0),
711 'ctr' => round(($row['ctr'] ?? 0) * 100, 2),
712 'position' => round($row['position'] ?? 0, 1),
713 'percentage' => $total_clicks > 0 ? round(($clicks / $total_clicks) * 100) : 0,
714 ];
715 }
716
717 return [
718 'countries' => $countries,
719 'total_clicks' => $total_clicks,
720 'site_url' => $site_url,
721 'date_range' => $date_range,
722 ];
723 }
724
725 /**
726 * Get rate limit configuration
727 * Following ThinkRank rate limiting patterns
728 *
729 * @return array Rate limit configuration
730 */
731 protected function get_rate_limits(): array {
732 return [
733 'max_requests_per_day' => self::MAX_REQUESTS_PER_DAY,
734 'reset_time' => get_transient(self::RATE_LIMIT_KEY . '_reset') ?: strtotime('tomorrow')
735 ];
736 }
737
738 /**
739 * Get rate limit transient key
740 * Following ThinkRank option naming patterns
741 *
742 * @return string Rate limit key
743 */
744 protected function get_rate_limit_key(): string {
745 return self::RATE_LIMIT_KEY;
746 }
747
748 /**
749 * Get rate limit error message
750 *
751 * @return string Error message
752 */
753 protected function get_rate_limit_error_message(): string {
754 return 'Google Search Console API rate limit exceeded. Try again tomorrow.';
755 }
756 }
757