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