PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.1
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 1.27.0 All 56 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.1, at includes/integrations/class-google-search-console-client.php

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