PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.2
2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.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 All 57 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.14.2, at includes/integrations/class-google-search-console-client.php

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